Skip to content

Commit 4f4d7eb

Browse files
committed
Close #149: Updated choice_set to correct format
1 parent 9ad9fb0 commit 4f4d7eb

5 files changed

Lines changed: 307 additions & 12 deletions

File tree

src/netbox_initializers/initializers/custom_fields.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,22 +123,27 @@ def load_data(self):
123123
continue
124124
custom_field.validation_maximum = cf_details["validation_maximum"]
125125

126-
# choices should only be applied when type is select, multiselect
127-
if choices := cf_details.get("choices"):
126+
# choice_set should only be applied when type is select, multiselect
127+
if choice_set_choices := cf_details.get("choice_set"):
128128
if cf_details.get("type") not in (
129129
"select",
130130
"multiselect",
131131
):
132132
print(
133-
f"⚠️ Unable to create Custom Field '{cf_name}': choices is supported only "
133+
f"⚠️ Unable to create Custom Field '{cf_name}': choice_set is supported only "
134134
+ "for select and multiselect types"
135135
)
136136
custom_field.delete()
137137
continue
138138
choice_set, _ = CustomFieldChoiceSet.objects.get_or_create(
139139
name=f"{cf_name}_choices"
140140
)
141-
choice_set.extra_choices = choices
141+
# NetBox stores choices as [value, label] pairs. Allow the YAML to
142+
# provide either plain values or explicit [value, label] pairs.
143+
choice_set.extra_choices = [
144+
choice if isinstance(choice, (list, tuple)) else [choice, choice]
145+
for choice in choice_set_choices
146+
]
142147
choice_set.save()
143148
custom_field.choice_set = choice_set
144149

src/netbox_initializers/initializers/yaml/custom_fields.yml

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,18 @@
5858
# required: false
5959
# filter_logic: exact
6060
# weight: 30
61-
# default: First Item
61+
# default: first
6262
# is_cloneable: true
6363
# on_objects:
6464
# - dcim.models.Device
65-
# choices:
66-
# - First Item
67-
# - Second Item
68-
# - Third Item
69-
# - Fifth Item
70-
# - Fourth Item
65+
# # choice_set entries are [value, label] pairs. A plain value may also be
66+
# # given, in which case it is used as both the value and the label.
67+
# choice_set:
68+
# - [first, First Item]
69+
# - [second, Second Item]
70+
# - [third, Third Item]
71+
# - [fourth, Fourth Item]
72+
# - [fifth, Fifth Item]
7173
# boolean_field:
7274
# type: boolean
7375
# label: Yes Or No?

src/netbox_initializers/initializers/yaml/users.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
# test_superuser:
2+
# first_name: Test
3+
# last_name: Admin
4+
# token:
5+
# key: ifiap3Roh1oh # Random identifier (12 characters) for the token, but not secret. #gitleaks:allow
6+
# value: aexophoo0Aizoo2aithi2aofeice7EeMe9shizei6Eghieghoh # must be looooong! #gitleaks:allow
7+
# is_active: true
8+
# is_superuser: true
9+
# email: admin@example.com
110
# technical_user:
211
# token:
312
# key: Xaeboophoo8e # Random identifier (12 characters) for the token, but not secret. #gitleaks:allow

test/test.sh

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,30 @@ test_cleanup() {
4242

4343
test_initializers() {
4444
echo "🏭 Testing Initializers"
45-
$doco run --rm netbox /opt/netbox/docker-entrypoint.sh ./manage.py load_initializer_data --path /etc/netbox/initializer-data || exit 1
45+
$doco run --rm netbox ./manage.py load_initializer_data --path /etc/netbox/initializer-data || exit 1
46+
}
47+
48+
test_api_verification() {
49+
echo "🔍 Verifying data via NetBox API"
50+
51+
# Start the NetBox web server so the REST API is available
52+
$doco up -d netbox || exit 1
53+
54+
# Wait for the API to become available
55+
echo "⏳ Waiting for the NetBox API to be ready"
56+
for _ in $(seq 1 30); do
57+
if $doco exec -T netbox python3 -c \
58+
"import urllib.request; urllib.request.urlopen('http://localhost:8080/api/')" \
59+
>/dev/null 2>&1; then
60+
echo "✅ NetBox API is ready"
61+
break
62+
fi
63+
sleep 5
64+
done
65+
66+
# Copy the verification script into the running container and run it
67+
$doco cp ./verify_api.py netbox:/tmp/verify_api.py || exit 1
68+
$doco exec -T netbox python3 /tmp/verify_api.py || exit 1
4669
}
4770

4871
echo "🐳🐳🐳 Start testing"
@@ -58,4 +81,8 @@ gh_echo "::group::Initializer tests"
5881
test_initializers
5982
gh_echo "::endgroup::"
6083

84+
gh_echo "::group::API verification"
85+
test_api_verification
86+
gh_echo "::endgroup::"
87+
6188
echo "🐳🐳🐳 Done testing"

test/verify_api.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"""Verify NetBox initializer data via the REST API.
2+
3+
The script iterates over every initializer YAML file, and for each object it
4+
defines, queries the NetBox REST API to confirm the object was created. The
5+
expected values are read directly from the YAML files, so there are no
6+
hard-coded test values to keep in sync.
7+
"""
8+
9+
import json
10+
import sys
11+
import urllib.error
12+
import urllib.parse
13+
import urllib.request
14+
from pathlib import Path
15+
from typing import Any
16+
17+
import yaml
18+
19+
BASE_URL = "http://localhost:8080/api"
20+
YAML_DIR = Path("/etc/netbox/initializer-data")
21+
22+
# NetBox v2 API tokens are transmitted as "nbt_<key>.<plaintext>"
23+
TOKEN_PREFIX = "nbt_"
24+
25+
# A single entry in the CHECKS table below.
26+
type Check = dict[str, Any]
27+
type JSON = dict[str, Any]
28+
29+
30+
def load_yaml(name: str) -> Any:
31+
"""Load and parse an initializer YAML file, returning None if absent."""
32+
path = YAML_DIR / name
33+
if not path.exists():
34+
return None
35+
with open(path) as fh:
36+
return yaml.safe_load(fh)
37+
38+
39+
def build_token() -> str | None:
40+
"""Assemble the API token for the first superuser defined in users.yml."""
41+
users = load_yaml("users.yml")
42+
if not users:
43+
return None
44+
for details in users.values():
45+
if details.get("is_superuser"):
46+
token_data = details.get("token", {})
47+
key = token_data.get("key")
48+
value = token_data.get("value")
49+
if key and value:
50+
return f"{TOKEN_PREFIX}{key}.{value}"
51+
return None
52+
53+
54+
TOKEN = build_token()
55+
if not TOKEN:
56+
print("❌ Could not find a superuser token in users.yml")
57+
sys.exit(1)
58+
59+
HEADERS = {
60+
"Authorization": f"Token {TOKEN}",
61+
"Accept": "application/json",
62+
}
63+
64+
65+
def api_get(endpoint: str, params: dict[str, Any] | None = None) -> JSON | None:
66+
"""Make a GET request to the NetBox API.
67+
68+
``endpoint`` may be a path (e.g. ``/dcim/sites/``) or an absolute URL as
69+
returned by the API in nested object references.
70+
"""
71+
url = endpoint if endpoint.startswith("http") else f"{BASE_URL}{endpoint}"
72+
if params:
73+
url = f"{url}?{urllib.parse.urlencode(params)}"
74+
75+
try:
76+
req = urllib.request.Request(url, headers=HEADERS)
77+
with urllib.request.urlopen(req) as response:
78+
return json.loads(response.read().decode())
79+
except urllib.error.HTTPError as e:
80+
print(f"❌ HTTP Error {e.code} on GET {url}: {e.reason}")
81+
return None
82+
except Exception as e: # noqa: BLE001
83+
print(f"❌ Error on GET {url}: {e}")
84+
return None
85+
86+
87+
def choice_value(choice: Any) -> Any:
88+
"""Return the value of a choice, which may be a [value, label] pair."""
89+
return choice[0] if isinstance(choice, (list, tuple)) else choice
90+
91+
92+
# Mapping of each initializer YAML file to how its objects can be located via
93+
# the REST API.
94+
# endpoint: REST API list endpoint
95+
# structure: "list" (list of objects) or "dict" (objects keyed by identifier)
96+
# filters: for "list", the object fields used to build the lookup query
97+
# key_filter: for "dict", the query field the mapping key maps to
98+
CHECKS: list[Check] = [
99+
{"file": "aggregates.yml", "endpoint": "/ipam/aggregates/", "structure": "list", "filters": ["prefix"]},
100+
{"file": "asns.yml", "endpoint": "/ipam/asns/", "structure": "list", "filters": ["asn"]},
101+
{"file": "circuit_types.yml", "endpoint": "/circuits/circuit-types/", "structure": "list", "filters": ["slug"]},
102+
{"file": "circuits.yml", "endpoint": "/circuits/circuits/", "structure": "list", "filters": ["cid"]},
103+
{"file": "cluster_groups.yml", "endpoint": "/virtualization/cluster-groups/", "structure": "list", "filters": ["slug"]},
104+
{"file": "cluster_types.yml", "endpoint": "/virtualization/cluster-types/", "structure": "list", "filters": ["slug"]},
105+
{"file": "clusters.yml", "endpoint": "/virtualization/clusters/", "structure": "list", "filters": ["name"]},
106+
{"file": "config_contexts.yml", "endpoint": "/extras/config-contexts/", "structure": "list", "filters": ["name"]},
107+
{"file": "config_templates.yml", "endpoint": "/extras/config-templates/", "structure": "list", "filters": ["name"]},
108+
{"file": "contact_groups.yml", "endpoint": "/tenancy/contact-groups/", "structure": "list", "filters": ["slug"]},
109+
{"file": "contact_roles.yml", "endpoint": "/tenancy/contact-roles/", "structure": "list", "filters": ["slug"]},
110+
{"file": "contacts.yml", "endpoint": "/tenancy/contacts/", "structure": "list", "filters": ["name"]},
111+
{"file": "custom_fields.yml", "endpoint": "/extras/custom-fields/", "structure": "dict", "key_filter": "name"},
112+
{"file": "custom_links.yml", "endpoint": "/extras/custom-links/", "structure": "list", "filters": ["name"]},
113+
{"file": "device_roles.yml", "endpoint": "/dcim/device-roles/", "structure": "list", "filters": ["slug"]},
114+
{"file": "device_types.yml", "endpoint": "/dcim/device-types/", "structure": "list", "filters": ["slug"]},
115+
{"file": "devices.yml", "endpoint": "/dcim/devices/", "structure": "list", "filters": ["name"]},
116+
{"file": "groups.yml", "endpoint": "/users/groups/", "structure": "dict", "key_filter": "name"},
117+
{"file": "interfaces.yml", "endpoint": "/dcim/interfaces/", "structure": "list", "filters": ["device", "name"]},
118+
{"file": "ip_addresses.yml", "endpoint": "/ipam/ip-addresses/", "structure": "list", "filters": ["address"]},
119+
{"file": "locations.yml", "endpoint": "/dcim/locations/", "structure": "list", "filters": ["slug"]},
120+
{"file": "macs.yml", "endpoint": "/dcim/mac-addresses/", "structure": "list", "filters": ["mac_address"]},
121+
{"file": "manufacturers.yml", "endpoint": "/dcim/manufacturers/", "structure": "list", "filters": ["slug"]},
122+
{"file": "object_permissions.yml", "endpoint": "/users/permissions/", "structure": "dict", "key_filter": "name"},
123+
{"file": "platforms.yml", "endpoint": "/dcim/platforms/", "structure": "list", "filters": ["slug"]},
124+
{"file": "power_feeds.yml", "endpoint": "/dcim/power-feeds/", "structure": "list", "filters": ["name"]},
125+
{"file": "power_panels.yml", "endpoint": "/dcim/power-panels/", "structure": "list", "filters": ["name"]},
126+
{"file": "prefix_vlan_roles.yml", "endpoint": "/ipam/roles/", "structure": "list", "filters": ["slug"]},
127+
{"file": "prefixes.yml", "endpoint": "/ipam/prefixes/", "structure": "list", "filters": ["prefix"]},
128+
{"file": "providers.yml", "endpoint": "/circuits/providers/", "structure": "list", "filters": ["slug"]},
129+
{"file": "rack_roles.yml", "endpoint": "/dcim/rack-roles/", "structure": "list", "filters": ["slug"]},
130+
{"file": "rack_types.yml", "endpoint": "/dcim/rack-types/", "structure": "list", "filters": ["slug"]},
131+
{"file": "racks.yml", "endpoint": "/dcim/racks/", "structure": "list", "filters": ["name"]},
132+
{"file": "regions.yml", "endpoint": "/dcim/regions/", "structure": "list", "filters": ["slug"]},
133+
{"file": "rirs.yml", "endpoint": "/ipam/rirs/", "structure": "list", "filters": ["slug"]},
134+
{"file": "route_targets.yml", "endpoint": "/ipam/route-targets/", "structure": "list", "filters": ["name"]},
135+
{"file": "service_templates.yml", "endpoint": "/ipam/service-templates/", "structure": "list", "filters": ["name"]},
136+
{"file": "services.yml", "endpoint": "/ipam/services/", "structure": "list", "filters": ["name"]},
137+
{"file": "site_groups.yml", "endpoint": "/dcim/site-groups/", "structure": "list", "filters": ["slug"]},
138+
{"file": "sites.yml", "endpoint": "/dcim/sites/", "structure": "list", "filters": ["slug"]},
139+
{"file": "tags.yml", "endpoint": "/extras/tags/", "structure": "list", "filters": ["slug"]},
140+
{"file": "tenant_groups.yml", "endpoint": "/tenancy/tenant-groups/", "structure": "list", "filters": ["slug"]},
141+
{"file": "tenants.yml", "endpoint": "/tenancy/tenants/", "structure": "list", "filters": ["slug"]},
142+
{"file": "users.yml", "endpoint": "/users/users/", "structure": "dict", "key_filter": "username"},
143+
{"file": "virtual_machines.yml", "endpoint": "/virtualization/virtual-machines/", "structure": "list", "filters": ["name"]},
144+
{"file": "virtualization_interfaces.yml", "endpoint": "/virtualization/interfaces/", "structure": "list", "filters": ["virtual_machine", "name"]},
145+
{"file": "vlan_groups.yml", "endpoint": "/ipam/vlan-groups/", "structure": "list", "filters": ["slug"]},
146+
{"file": "vlans.yml", "endpoint": "/ipam/vlans/", "structure": "list", "filters": ["vid"]},
147+
{"file": "vrfs.yml", "endpoint": "/ipam/vrfs/", "structure": "list", "filters": ["name"]},
148+
{"file": "webhooks.yml", "endpoint": "/extras/webhooks/", "structure": "list", "filters": ["name"]},
149+
# Cables have no simple natural key; verify the expected number were created.
150+
{"file": "cables.yml", "endpoint": "/dcim/cables/", "structure": "count"},
151+
]
152+
153+
154+
class Verifier:
155+
def __init__(self) -> None:
156+
self.passed = 0
157+
self.failed = 0
158+
159+
def ok(self, message: str) -> None:
160+
self.passed += 1
161+
print(f"✅ {message}")
162+
163+
def fail(self, message: str) -> None:
164+
self.failed += 1
165+
print(f"❌ {message}")
166+
167+
def object_exists(self, endpoint: str, filters: dict[str, Any]) -> bool:
168+
data = api_get(endpoint, filters)
169+
return bool(data and data.get("results"))
170+
171+
def verify_custom_field_choices(self, cf: JSON, details: JSON, label: str) -> None:
172+
"""For select/multiselect fields, confirm the choice set matches the YAML."""
173+
expected = details.get("choice_set")
174+
if not expected or not cf.get("choice_set"):
175+
return
176+
choice_data = api_get(cf["choice_set"]["url"])
177+
if not choice_data:
178+
self.fail(f"{label}: choice set could not be retrieved")
179+
return
180+
actual = {choice_value(c) for c in choice_data.get("extra_choices", [])}
181+
wanted = {choice_value(c) for c in expected}
182+
if actual != wanted:
183+
self.fail(f"{label}: choices {sorted(actual)} != expected {sorted(wanted)}")
184+
else:
185+
self.ok(f"{label}: choice set verified")
186+
187+
def run_check(self, check: Check) -> None:
188+
data = load_yaml(check["file"])
189+
if not data:
190+
return
191+
obj = check["file"].removesuffix(".yml")
192+
193+
match check["structure"]:
194+
case "count":
195+
self._check_count(check, obj, data)
196+
case "dict":
197+
self._check_dict(check, obj, data)
198+
case "list":
199+
self._check_list(check, obj, data)
200+
201+
def _check_count(self, check: Check, obj: str, data: list) -> None:
202+
resp = api_get(check["endpoint"], {"limit": 1})
203+
count = resp.get("count") if resp else None
204+
if count is not None and count >= len(data):
205+
self.ok(f"{obj}: {count} object(s) present (>= {len(data)} defined)")
206+
else:
207+
self.fail(f"{obj}: expected >= {len(data)} objects, API reports {count}")
208+
209+
def _check_dict(self, check: Check, obj: str, data: dict) -> None:
210+
endpoint = check["endpoint"]
211+
key_filter = check["key_filter"]
212+
for key, details in data.items():
213+
label = f"{obj} '{key}'"
214+
result = api_get(endpoint, {key_filter: key})
215+
if result and result.get("results"):
216+
self.ok(f"{label} verified")
217+
if obj == "custom_fields":
218+
self.verify_custom_field_choices(result["results"][0], details, label)
219+
else:
220+
self.fail(f"{label} not found")
221+
222+
def _check_list(self, check: Check, obj: str, data: list) -> None:
223+
for item in data:
224+
filters = {f: item[f] for f in check["filters"] if item.get(f) is not None}
225+
if not filters:
226+
continue
227+
label_value = filters.get(check["filters"][-1], next(iter(filters.values())))
228+
label = f"{obj} '{label_value}'"
229+
if self.object_exists(check["endpoint"], filters):
230+
self.ok(f"{label} verified")
231+
else:
232+
self.fail(f"{label} not found")
233+
234+
def run(self) -> int:
235+
print("🔍 Verifying NetBox initializer data via API")
236+
print(f" Base URL: {BASE_URL}")
237+
print(f" Loading config from: {YAML_DIR}\n")
238+
239+
for check in CHECKS:
240+
self.run_check(check)
241+
242+
total = self.passed + self.failed
243+
print(f"\n📊 Verification Results: {self.passed}/{total} checks passed")
244+
if self.failed == 0:
245+
print("✨ All verifications passed!")
246+
return 0
247+
print(f"⚠️ {self.failed} verification(s) failed")
248+
return 1
249+
250+
251+
if __name__ == "__main__":
252+
sys.exit(Verifier().run())

0 commit comments

Comments
 (0)