diff --git a/.gitignore b/.gitignore index 8b712a5..29d31af 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,9 @@ Dockerfile.cross *.swo *~ private/ +lab/dev/**/*.annotations.json lab/dev/clab-* +lab/dev/netbox/secrets design/ notes/ docs/public diff --git a/Makefile b/Makefile index d67e65c..e988a43 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ include test.mk +include netbox.mk # Image URL to use all building/pushing image targets IMG ?= controller:latest diff --git a/lab/dev/3-nodes.clab.yaml b/lab/dev/3-nodes.clab.yaml index 89b9191..d656839 100644 --- a/lab/dev/3-nodes.clab.yaml +++ b/lab/dev/3-nodes.clab.yaml @@ -19,7 +19,11 @@ topology: leaf1: leaf2: + ceos1: + kind: arista_ceos + image: ceos:4.35.2F links: - endpoints: ["spine1:e1-1", "leaf1:e1-49"] - endpoints: ["spine1:e1-2", "leaf2:e1-49"] + - endpoints: ["spine1:e1-3", "ceos1:eth1"] diff --git a/lab/dev/netbox/initializers/device-roles.yaml b/lab/dev/netbox/initializers/device-roles.yaml new file mode 100644 index 0000000..9167dab --- /dev/null +++ b/lab/dev/netbox/initializers/device-roles.yaml @@ -0,0 +1,3 @@ +- name: Router + slug: router + color: ff0000 diff --git a/lab/dev/netbox/initializers/device-types.yaml b/lab/dev/netbox/initializers/device-types.yaml new file mode 100644 index 0000000..a6279ed --- /dev/null +++ b/lab/dev/netbox/initializers/device-types.yaml @@ -0,0 +1,16 @@ +- model: ixr-d2l + slug: arista-ixr-d2l + manufacturer: + name: Arista +- model: ixr-d2l + slug: nokia-ixr-d2l + manufacturer: + name: Nokia +- model: ixr-d2l-leaf + slug: nokia-ixr-d2l-leaf + manufacturer: + name: Nokia +- model: ixr-d3l + slug: nokia-ixr-d3l + manufacturer: + name: Nokia diff --git a/lab/dev/netbox/initializers/devices.yaml b/lab/dev/netbox/initializers/devices.yaml new file mode 100644 index 0000000..17ed036 --- /dev/null +++ b/lab/dev/netbox/initializers/devices.yaml @@ -0,0 +1,36 @@ +- name: ceos1 + role: + slug: router + manufacturer: + name: Arista + device_type: + slug: arista-ixr-d2l + site: + name: Lab +- name: leaf1 + role: + slug: router + manufacturer: + name: Nokia + device_type: + slug: nokia-ixr-d2l + site: + name: Lab +- name: leaf2 + role: + slug: router + manufacturer: + name: Nokia + device_type: + slug: nokia-ixr-d2l-leaf + site: + name: Lab +- name: spine1 + role: + slug: router + manufacturer: + name: Nokia + device_type: + slug: nokia-ixr-d3l + site: + name: Lab diff --git a/lab/dev/netbox/initializers/interfaces.yaml b/lab/dev/netbox/initializers/interfaces.yaml new file mode 100644 index 0000000..05e8d24 --- /dev/null +++ b/lab/dev/netbox/initializers/interfaces.yaml @@ -0,0 +1,40 @@ +- device: + name: spine1 + name: e1-1 + type: 1000base-t +- device: + name: leaf1 + name: e1-49 + type: 1000base-t +- device: + name: spine1 + name: e1-2 + type: 1000base-t +- device: + name: leaf2 + name: e1-49 + type: 1000base-t +- device: + name: spine1 + name: e1-3 + type: 1000base-t +- device: + name: ceos1 + name: eth1 + type: 1000base-t +- device: + name: spine1 + name: mgmt0 + type: 1000base-t +- device: + name: leaf1 + name: mgmt0 + type: 1000base-t +- device: + name: leaf2 + name: mgmt0 + type: 1000base-t +- device: + name: ceos1 + name: mgmt0 + type: 1000base-t diff --git a/lab/dev/netbox/initializers/ip-addresses.yaml b/lab/dev/netbox/initializers/ip-addresses.yaml new file mode 100644 index 0000000..c474fa1 --- /dev/null +++ b/lab/dev/netbox/initializers/ip-addresses.yaml @@ -0,0 +1,50 @@ +- address: 10.0.1.1/32 + assigned_object: + device: + name: leaf1 + name: system0 + status: active +- address: 10.0.1.2/32 + assigned_object: + device: + name: leaf2 + name: system0 + status: active +- address: 10.0.2.1/32 + assigned_object: + device: + name: spine1 + name: system0 + status: active +- address: 172.18.0.4/32 + assigned_object: + device: + name: spine1 + name: mgmt0 + status: active + primary: true + dns_name: clab-3-nodes-spine1 +- address: 172.18.0.5/32 + assigned_object: + device: + name: leaf1 + name: mgmt0 + status: active + primary: true + dns_name: clab-3-nodes-leaf1 +- address: 172.18.0.3/32 + assigned_object: + device: + name: leaf2 + name: mgmt0 + status: active + primary: true + dns_name: clab-3-nodes-leaf2 +- address: 172.18.0.6/32 + assigned_object: + device: + name: ceos1 + name: mgmt0 + status: active + primary: true + dns_name: clab-3-nodes-ceos1 diff --git a/lab/dev/netbox/initializers/manufacturers.yaml b/lab/dev/netbox/initializers/manufacturers.yaml new file mode 100644 index 0000000..68627af --- /dev/null +++ b/lab/dev/netbox/initializers/manufacturers.yaml @@ -0,0 +1,4 @@ +- name: Nokia + slug: nokia +- name: Arista + slug: arista diff --git a/lab/dev/netbox/initializers/sites.yaml b/lab/dev/netbox/initializers/sites.yaml new file mode 100644 index 0000000..bc8ed18 --- /dev/null +++ b/lab/dev/netbox/initializers/sites.yaml @@ -0,0 +1,2 @@ +- name: Lab + slug: lab diff --git a/lab/dev/netbox/netbox-values.yaml b/lab/dev/netbox/netbox-values.yaml new file mode 100644 index 0000000..eb89f14 --- /dev/null +++ b/lab/dev/netbox/netbox-values.yaml @@ -0,0 +1,30 @@ +postgresql: + enabled: true + auth: + username: netbox + password: netbox + database: netbox + +redis: + enabled: true + +superuser: + enabled: true + existingSecret: netbox-superuser + +service: + type: LoadBalancer + +persistence: + enabled: true + size: 1Gi + +extraVolumes: + - name: peppers + configMap: + name: netbox-peppers + +extraVolumeMounts: + - name: peppers + mountPath: /run/config/extra/peppers + readOnly: true \ No newline at end of file diff --git a/lab/dev/netbox/publish.py b/lab/dev/netbox/publish.py new file mode 100755 index 0000000..0922ab4 --- /dev/null +++ b/lab/dev/netbox/publish.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import sys + +try: + import requests + import yaml +except ImportError as e: + print("Missing dependency:", e, file=sys.stderr) + print("Install with: pip install requests pyyaml", file=sys.stderr) + sys.exit(1) + +ENDPOINTS = { + "sites": "dcim/sites", + "manufacturers": "dcim/manufacturers", + "device-roles": "dcim/device-roles", + "device-types": "dcim/device-types", + "devices": "dcim/devices", + "interfaces": "dcim/interfaces", + # "cables": "dcim/cables", + "ip-addresses": "ipam/ip-addresses", +} + + +def netbox_get(base_url, headers, path, params=None): + url = f"{base_url.rstrip('/')}/api/{path}/" + r = requests.get(url, headers=headers, params=params, timeout=60) + r.raise_for_status() + return r.json() + + +def netbox_find_id(base_url, headers, path, field, value): + if value is None: + return None + params = {field: value} + try: + r = netbox_get(base_url, headers, path, params) + except Exception: + return None + results = r.get("results", []) + if results: + return results[0].get("id") + return None + + +def resolve_relation(base_url, headers, path, value): + if value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, dict): + if "id" in value: + return value["id"] + if "pk" in value: + return value["pk"] + if "name" in value: + return netbox_find_id(base_url, headers, path, "name", value["name"]) + if "slug" in value: + return netbox_find_id(base_url, headers, path, "slug", value["slug"]) + return None + + if isinstance(value, str): + if value.isdigit(): + return int(value) + found = netbox_find_id(base_url, headers, path, "name", value) + if found: + return found + return netbox_find_id(base_url, headers, path, "slug", value) + return None + + +def get_interface_id(base_url, headers, device_ref, iface_name): + if device_ref is None or iface_name is None: + return None + device_id = None + if isinstance(device_ref, int): + device_id = device_ref + else: + device_id = resolve_relation(base_url, headers, "dcim/devices", device_ref) + if not device_id: + return None + params = {"device_id": device_id, "name": iface_name} + r = netbox_get(base_url, headers, "dcim/interfaces", params) + results = r.get("results", []) + if results: + return results[0].get("id") + return None + + +def update_device_primary_ip(base_url, headers, interface_id, ip_id): + r = netbox_get(base_url, headers, f"dcim/interfaces/{interface_id}") + device = r.get("device") + if not device: + return + device_id = device.get("id") + if not device_id: + return + url = f"{base_url.rstrip('/')}/api/dcim/devices/{device_id}/" + data = {"primary_ip4": ip_id} + r2 = requests.patch(url, headers=headers, json=data, timeout=60) + if r2.status_code not in (200, 202): + print(f"Warning: failed to update primary_ip4 for device {device_id}: {r2.status_code} {r2.text}") + + +def resolve_ip_address(base_url, headers, address): + r = netbox_get(base_url, headers, "ipam/ip-addresses", {"address": address}) + results = r.get("results", []) + if results: + return results[0].get("id") + return None + + +def ensure_ip_assigned_to_interface(base_url, headers, ip_id, interface_id): + url = f"{base_url.rstrip('/')}/api/ipam/ip-addresses/{ip_id}/" + r = requests.get(url, headers=headers, timeout=60) + if r.status_code not in (200, 201): + print(f"Warning: failed to get IP address {ip_id}: {r.status_code} {r.text}") + return False + + assigned = r.json().get("assigned_object") + if assigned and assigned.get("object_type") == "dcim.interface" and assigned.get("id") == interface_id: + return True + + r2 = requests.patch(url, headers=headers, json={"assigned_object_type": "dcim.interface", "assigned_object_id": interface_id}, timeout=60) + if r2.status_code not in (200, 202): + print(f"Warning: failed to assign IP {ip_id} to interface {interface_id}: {r2.status_code} {r2.text}") + return False + return True + + +def post_items(base_url, token, dirname, force=False): + if not token: + raise ValueError("NETBOX_TOKEN must be provided") + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + primary_ip_entries = [] + + cache = { + 'sites': {}, + 'manufacturers': {}, + 'device-roles': {}, + 'device-types': {}, + 'devices': {}, + } + + def resolve_cached_id(path_key, value): + path_mapping = { + 'dcim/sites': 'sites', + 'dcim/manufacturers': 'manufacturers', + 'dcim/device-roles': 'device-roles', + 'dcim/device-types': 'device-types', + 'dcim/devices': 'devices', + 'sites': 'sites', + 'manufacturers': 'manufacturers', + 'device-roles': 'device-roles', + 'device-types': 'device-types', + 'devices': 'devices', + } + cache_key = path_mapping.get(path_key, None) + + if isinstance(value, dict): + if 'id' in value: + return value['id'] + if 'name' in value and cache_key and value['name'] in cache.get(cache_key, {}): + return cache[cache_key][value['name']] + if 'slug' in value and cache_key and value['slug'] in cache.get(cache_key, {}): + return cache[cache_key][value['slug']] + if 'name' in value: + return resolve_relation(base_url, headers, path_key, value['name']) + if 'slug' in value: + return resolve_relation(base_url, headers, path_key, value['slug']) + return None + + if isinstance(value, str): + if cache_key and value in cache.get(cache_key, {}): + return cache[cache_key][value] + return resolve_relation(base_url, headers, path_key, value) + + if isinstance(value, int): + return value + + return None + + for name, path in ENDPOINTS.items(): + file_path = os.path.join(dirname, f"{name}.yaml") + if not os.path.isfile(file_path): + print(f"Skipping {name}: file not found: {file_path}") + continue + + with open(file_path, "r", encoding="utf-8") as f: + payload = yaml.safe_load(f) + + if not payload: + print(f"Skipping {name}: empty payload in {file_path}") + continue + + if isinstance(payload, list): + items = payload + elif isinstance(payload, dict): + if name in payload and isinstance(payload[name], list): + items = payload[name] + elif len(payload) == 1 and isinstance(next(iter(payload.values())), list): + items = next(iter(payload.values())) + else: + items = [payload] + else: + print(f"Skipping {name}: invalid payload type {type(payload)} in {file_path}") + continue + + if not items: + print(f"Skipping {name}: no entries") + continue + + print(f"Posting {len(items)} {name} entries to {path}...") + for i, obj in enumerate(items, 1): + data = obj.copy() if isinstance(obj, dict) else obj + + if path == "dcim/device-types": + manufacturer = data.get("manufacturer") + if manufacturer: + mid = resolve_relation(base_url, headers, "dcim/manufacturers", manufacturer) + if mid: + data["manufacturer"] = mid + + if path == "dcim/devices": + data.pop("manufacturer", None) + + for field, endpoint in ( + ("role", "dcim/device-roles"), + ("device_type", "dcim/device-types"), + ("site", "dcim/sites"), + ): + if field in data: + resolved = resolve_relation(base_url, headers, endpoint, data[field]) + if resolved: + data[field] = resolved + + if path == "dcim/interfaces": + device = data.get("device") + if device is not None: + did = resolve_cached_id("dcim/devices", device) + if did: + data["device"] = did + if force: + existing_id = get_interface_id(base_url, headers, did, data.get("name")) + if existing_id: + del_url = f"{base_url.rstrip('/')}/api/dcim/interfaces/{existing_id}/" + requests.delete(del_url, headers=headers, timeout=60) + print(f" {name}[{i}] deleted existing interface") + else: + print(f" {name}[{i}] warning: cannot resolve device {device}") + + primary_ip4 = False + if path == "ipam/ip-addresses": + assigned_object = data.pop("assigned_object", None) + if assigned_object and isinstance(assigned_object, dict): + assigned_device = assigned_object.get("device") + assigned_name = assigned_object.get("name") + device_id = resolve_cached_id("dcim/devices", assigned_device) + if assigned_device and assigned_name: + interface_id = get_interface_id(base_url, headers, device_id if device_id else assigned_device, assigned_name) + if interface_id: + data["assigned_object_type"] = "dcim.interface" + data["assigned_object_id"] = interface_id + # preserve mapping for later primary update + if "primary" in obj and bool(obj.get("primary")): + primary_ip_entries.append({ + "address": data.get("address"), + "device_id": device_id, + "interface": assigned_name, + }) + if "primary" in data: + primary_ip4 = bool(data.pop("primary")) + + url = f"{base_url.rstrip('/')}/api/{path}/" + r = requests.post(url, headers=headers, json=data, timeout=60) + + if r.status_code in (200, 201): + resp = r.json() + print(f" {name}[{i}] created") + if name == 'sites': + cache['sites'][resp.get('name')] = resp.get('id') + cache['sites'][resp.get('slug')] = resp.get('id') + elif name == 'manufacturers': + cache['manufacturers'][resp.get('name')] = resp.get('id') + cache['manufacturers'][resp.get('slug')] = resp.get('id') + elif name == 'device-roles': + cache['device-roles'][resp.get('name')] = resp.get('id') + cache['device-roles'][resp.get('slug')] = resp.get('id') + elif name == 'device-types': + cache['device-types'][resp.get('slug')] = resp.get('id') + elif name == 'devices': + cache['devices'][resp.get('name')] = resp.get('id') + + elif r.status_code in (409, 400): + reason = "already exists" if r.status_code == 409 else f"invalid ({r.text})" + print(f" {name}[{i}] skipped ({reason})") + if name in ('sites', 'manufacturers', 'device-roles', 'device-types', 'devices'): + key = None + if isinstance(obj, dict): + key = obj.get('name') or obj.get('slug') + if not key and isinstance(obj, str): + key = obj + if key: + existing_id = resolve_relation(base_url, headers, path, key) + if existing_id: + cache_key = name + cache[cache_key][key] = existing_id + + else: + print(f" {name}[{i}] failed: {r.status_code} {r.text}") + r.raise_for_status() + + # apply primary IP updates based on YAML intent (and tolerate existing/duplicated records) + for info in primary_ip_entries: + ip_id = resolve_ip_address(base_url, headers, info["address"]) + interface_ref = info.get("device_id") if info.get("device_id") is not None else info.get("device") + interface_id = get_interface_id(base_url, headers, interface_ref, info["interface"]) + if not ip_id or not interface_id: + print(f"Skipping primary assignment for {info}: missing IP/interface") + continue + if ensure_ip_assigned_to_interface(base_url, headers, ip_id, interface_id): + update_device_primary_ip(base_url, headers, interface_id, ip_id) + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="Publish NetBox initializers via REST API") + p.add_argument("netbox_url", help="Base URL for NetBox (http://host:80)") + p.add_argument("token", help="NetBox API token") + p.add_argument("init_dir", help="directory with initializers *.yaml") + p.add_argument("--force", action="store_true", help="Force recreate interfaces and IPs even if they exist") + args = p.parse_args() + + post_items(args.netbox_url, args.token, args.init_dir, args.force) diff --git a/lab/dev/resources/pipelines/cpipe1.yaml b/lab/dev/resources/pipelines/cpipe1.yaml new file mode 100644 index 0000000..8bfcb54 --- /dev/null +++ b/lab/dev/resources/pipelines/cpipe1.yaml @@ -0,0 +1,17 @@ +apiVersion: operator.gnmic.dev/v1alpha1 +kind: Pipeline +metadata: + name: cpipe1 +spec: + clusterRef: c1 + enabled: true + targetSelectors: + - matchLabels: + vendor: arista_ceos + subscriptionSelectors: + - matchLabels: + type: interfaces + outputs: + outputSelectors: + - matchLabels: + type: metrics diff --git a/lab/dev/resources/subscriptions/csub1.yaml b/lab/dev/resources/subscriptions/csub1.yaml new file mode 100644 index 0000000..b318d9a --- /dev/null +++ b/lab/dev/resources/subscriptions/csub1.yaml @@ -0,0 +1,12 @@ +apiVersion: operator.gnmic.dev/v1alpha1 +kind: Subscription +metadata: + name: csub1 + labels: + type: interfaces +spec: + paths: + - /interfaces + - /network-instances + mode: STREAM/SAMPLE + sampleInterval: 10s diff --git a/lab/dev/resources/targets/ceos1.yaml b/lab/dev/resources/targets/ceos1.yaml new file mode 100644 index 0000000..350b621 --- /dev/null +++ b/lab/dev/resources/targets/ceos1.yaml @@ -0,0 +1,9 @@ +apiVersion: operator.gnmic.dev/v1alpha1 +kind: Target +metadata: + name: ceos1 + labels: + vendor: arista_ceos +spec: + address: clab-3-nodes-ceos1:6030 + profile: eos diff --git a/lab/dev/resources/targets/profile/ccreds.yaml b/lab/dev/resources/targets/profile/ccreds.yaml new file mode 100644 index 0000000..862d677 --- /dev/null +++ b/lab/dev/resources/targets/profile/ccreds.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: eos-credentials +type: Opaque +data: + username: YWRtaW4= + password: YWRtaW4= \ No newline at end of file diff --git a/lab/dev/resources/targets/profile/cprofile.yaml b/lab/dev/resources/targets/profile/cprofile.yaml new file mode 100644 index 0000000..0b415fb --- /dev/null +++ b/lab/dev/resources/targets/profile/cprofile.yaml @@ -0,0 +1,15 @@ +apiVersion: operator.gnmic.dev/v1alpha1 +kind: TargetProfile +metadata: + name: eos +spec: + credentialsRef: eos-credentials + tls: null + encoding: JSON_IETF + timeout: 10s + tcpKeepAlive: 10s + retryTimer: 5s + grpcKeepAlive: + time: 10s + timeout: 10s + permitWithoutStream: true \ No newline at end of file diff --git a/netbox.mk b/netbox.mk new file mode 100644 index 0000000..555c2f1 --- /dev/null +++ b/netbox.mk @@ -0,0 +1,82 @@ + +# Add NetBox instance to Kubernetes +# Only for development and testing purposes +# is generally vibe coded and will be removed after development +##@ NetBox +NETBOX_CLUSTER_NAME ?= +NETBOX_CHART ?= netbox/netbox +NETBOX_RELEASE ?= netbox +NETBOX_NAMESPACE ?= netbox +NETBOX_PORT ?= 8081 +NETBOX_URL ?= http://localhost:$(NETBOX_PORT) +NETBOX_TOKEN ?= $(shell kubectl get secret netbox-superuser -n $(NETBOX_NAMESPACE) --context kind-$(NETBOX_CLUSTER_NAME) -o jsonpath='{.data.api_token}' | base64 -d || true) +NETBOX_VALUES ?= lab/dev/netbox/netbox-values.yaml +NETBOX_PASSWORD ?= +NETBOX_INIT ?= lab/dev/netbox/initializers + +.PHONY: netbox-setup +netbox-setup: netbox-deploy-cluster netbox-install + +.PHONY: netbox-deploy-cluster +netbox-deploy-cluster: ## Deploy the netbox cluster +ifndef NETBOX_CLUSTER_NAME + $(error NETBOX_CLUSTER_NAME is required. Usage: make netbox-deploy-cluster NETBOX_CLUSTER_NAME=cluster-name) +endif + kind get clusters | grep -q "$(NETBOX_CLUSTER_NAME)" || kind create cluster --name $(NETBOX_CLUSTER_NAME) + kubectl config use-context kind-$(CLUSTER_NAME) + +.PHONY: netbox-undeploy +netbox-undeploy: ## Undeploy the netbox cluster +ifndef NETBOX_CLUSTER_NAME + $(error NETBOX_CLUSTER_NAME is required. This will delete the cluster!!! Usage: make netbox-undeploy NETBOX_CLUSTER_NAME=cluster-name) +endif + kind delete cluster --name $(NETBOX_CLUSTER_NAME) + +.PHONY: netbox-install +netbox-install: ## Generate NetBox secrets, patch templates, create namespace, and deploy NetBox via Helm +ifndef NETBOX_PASSWORD + $(error NETBOX_PASSWORD is required. Usage: make netbox-install NETBOX_PASSWORD=yourpassword) +endif +ifndef NETBOX_CLUSTER_NAME + $(error NETBOX_CLUSTER_NAME is required. Usage: make netbox-install NETBOX_CLUSTER_NAME=cluster-name) +endif + mkdir -p lab/dev/netbox/secrets + @echo "Generating NetBox secrets..." + @PEPPER=$$(openssl rand -hex 32); \ + API_TOKEN=$$(openssl rand -hex 32); \ + echo -e '---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: netbox-peppers\n namespace: $(NETBOX_NAMESPACE)\ndata:\n peppers.yaml: |-\n API_TOKEN_PEPPERS:\n 1: '\''$$PEPPER'\''\n' > lab/dev/netbox/secrets/netbox_peppers.yaml; \ + echo -e '---\napiVersion: v1\nkind: Secret\nmetadata:\n name: netbox-superuser\n namespace: $(NETBOX_NAMESPACE)\ntype: Opaque\nstringData:\n username: "admin"\n email: "admin@example.com"\n password: "$(NETBOX_PASSWORD)"\n api_token: "$$API_TOKEN"\n' > lab/dev/netbox/secrets/netbox_secret.yaml; \ + sed -i "s|\$$PEPPER|$${PEPPER}|g" lab/dev/netbox/secrets/netbox_peppers.yaml; \ + sed -i "s|\$$API_TOKEN|$${API_TOKEN}|g" lab/dev/netbox/secrets/netbox_secret.yaml + kubectl create namespace $(NETBOX_NAMESPACE) --context kind-$(NETBOX_CLUSTER_NAME) || true + kubectl apply -f lab/dev/netbox/secrets/ -n $(NETBOX_NAMESPACE) --context kind-$(NETBOX_CLUSTER_NAME) + helm repo add netbox https://netbox-community.github.io/netbox-helm 2>/dev/null --kube-context kind-$(NETBOX_CLUSTER_NAME) || true + helm repo update --kube-context kind-$(NETBOX_CLUSTER_NAME) + helm upgrade --install $(NETBOX_RELEASE) $(NETBOX_CHART) \ + -n $(NETBOX_NAMESPACE) --kube-context kind-$(NETBOX_CLUSTER_NAME) -f $(NETBOX_VALUES) \ + --wait --timeout 10m +# kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=netbox -n $(NETBOX_NAMESPACE) --context kind-$(NETBOX_CLUSTER_NAME) --timeout=600s + @echo "Make sure NetBox is reachable before 'make netbox-sync-data' by running: kubectl port-forward svc/netbox 8081:80 -n $(NETBOX_NAMESPACE) --context kind-$(NETBOX_CLUSTER_NAME) --address='0.0.0.0' &" + +.PHONY: netbox-delete +netbox-delete: ## Uninstall NetBox helm deployment and delete the namespace +ifndef NETBOX_CLUSTER_NAME + $(error NETBOX_CLUSTER_NAME is required. Usage: make netbox-delete NETBOX_CLUSTER_NAME=cluster-name) +endif + helm uninstall netbox -n $(NETBOX_NAMESPACE) --kube-context kind-$(NETBOX_CLUSTER_NAME) || true + kubectl delete namespace $(NETBOX_NAMESPACE) --context kind-$(NETBOX_CLUSTER_NAME) || true + +.PHONY: netbox-sync-data +netbox-sync-data: ## Publish initializers data into NetBox via REST API +ifndef NETBOX_CLUSTER_NAME + $(error NETBOX_CLUSTER_NAME is required. Usage: make netbox-sync-data NETBOX_CLUSTER_NAME=cluster-name) +endif + @echo "NetBox URL: $(NETBOX_URL)" + @POD=$$(kubectl -n $(NETBOX_NAMESPACE) --context kind-$(NETBOX_CLUSTER_NAME) get pod -l app.kubernetes.io/name=netbox -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true); \ + if [ -z "$$POD" ]; then echo "Error: no NetBox pod found in namespace $(NETBOX_NAMESPACE). Run make netbox-install first."; exit 1; fi; \ + TOKEN_KEY=$$(kubectl exec -n $(NETBOX_NAMESPACE) --context kind-$(NETBOX_CLUSTER_NAME) $$POD -- python manage.py shell -c "from users.models import Token; print(next((t.key for t in Token.objects.filter(user__username='admin')), ''))" 2>/dev/null | tr -d '\r' | grep -E '^[A-Za-z0-9]+$$' | head -n1); \ + if [ -z "$$TOKEN_KEY" ]; then echo "Error: no admin v2 API token found in NetBox. Create one in NetBox admin and retry."; exit 1; fi; \ + echo "NetBox Token Key: $$TOKEN_KEY"; \ + echo "NetBox Token: $(NETBOX_TOKEN)"; \ + python3 lab/dev/netbox/publish.py $(NETBOX_URL) "nbt_$$TOKEN_KEY.$(NETBOX_TOKEN)" $(NETBOX_INIT) --force + @echo "NetBox sync complete!"