diff --git a/.github/workflows/test-netbox-main.yaml b/.github/workflows/test-netbox.yaml similarity index 78% rename from .github/workflows/test-netbox-main.yaml rename to .github/workflows/test-netbox.yaml index 5f483cc9..a53d21a1 100644 --- a/.github/workflows/test-netbox-main.yaml +++ b/.github/workflows/test-netbox.yaml @@ -1,10 +1,11 @@ # SPDX-License-Identifier: MIT -name: Weekly test against NetBox main +name: Test against live NetBox on: schedule: - cron: '0 6 * * 1' # Every Monday at 06:00 UTC workflow_dispatch: + pull_request: permissions: contents: read @@ -17,6 +18,14 @@ jobs: integration-test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Weekly runs track the moving target (main) only; released tags are + # immutable, so the full supported matrix (latest patch of each line) + # runs on pull requests and on demand instead. + netbox_ref: ${{ github.event_name == 'schedule' && fromJSON('["main"]') || fromJSON('["main", "v4.5.10", "v4.4.10", "v4.3.7"]') }} + services: postgres: image: postgres:16 @@ -52,15 +61,18 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: - enable-cache: true + # No Actions cache in this job: it executes third-party code (the + # NetBox checkout), which must not be able to poison the default + # branch cache from the scheduled/dispatched privileged context. + enable-cache: false - - name: Checkout NetBox main + - name: Checkout NetBox ${{ matrix.netbox_ref }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false repository: netbox-community/netbox path: netbox - ref: main + ref: ${{ matrix.netbox_ref }} - name: Install NetBox dependencies run: pip install -r netbox/requirements.txt @@ -92,7 +104,7 @@ jobs: # (June 2026) enforces object-level 'view' permission when serving # /media/devicetype-images/ and /media/image-attachments/ via MediaView, and that # view's TokenConditionalLoginRequiredMixin only authenticates the API token when - # LOGIN_REQUIRED is True. With it True, the integration test's Bearer-token session + # LOGIN_REQUIRED is True. With it True, the integration test's token-authenticated session # resolves to the admin superuser, restrict() grants access, and the image is # served (a genuinely missing file still 404s through django.views.static.serve). # NOTE: this also gates the REST API, so the readiness curl below must send the token. @@ -115,10 +127,17 @@ jobs: user = get_user_model().objects.get(username='admin') t = Token(user=user) t.save() - # v2 token credential: nbt_. (plaintext only available right after save) - print(f'nbt_{t.key}.{t.token}') + # v2 credential nbt_<key>.<plaintext> (NetBox >= 4.5, plaintext only available + # right after save) or the classic plaintext key on 4.3/4.4. + token = getattr(t, 'token', None) + print(f'nbt_{t.key}.{token}' if token else t.key) " 2>/dev/null | tail -1) echo "NETBOX_TOKEN=$TOKEN" >> "$GITHUB_ENV" + # The importer picks the scheme the same way: Bearer for nbt_, else Token. + case "$TOKEN" in + nbt_*) echo "NETBOX_AUTH_SCHEME=Bearer" >> "$GITHUB_ENV" ;; + *) echo "NETBOX_AUTH_SCHEME=Token" >> "$GITHUB_ENV" ;; + esac echo "Created API token" - name: Start NetBox dev server @@ -132,14 +151,14 @@ jobs: # anonymous curl would 403 and never report ready. # Wait up to 60 s for NetBox to respond for i in $(seq 1 30); do - if curl -sf -H "Authorization: Bearer $NETBOX_TOKEN" http://localhost:8000/api/ > /dev/null 2>&1; then + if curl -sf -H "Authorization: $NETBOX_AUTH_SCHEME $NETBOX_TOKEN" http://localhost:8000/api/ > /dev/null 2>&1; then echo "NetBox is ready (attempt $i)" break fi echo "Waiting for NetBox... ($i/30)" sleep 2 done - curl -sf -H "Authorization: Bearer $NETBOX_TOKEN" http://localhost:8000/api/ > /dev/null || { echo "NetBox did not start"; exit 1; } + curl -sf -H "Authorization: $NETBOX_AUTH_SCHEME $NETBOX_TOKEN" http://localhost:8000/api/ > /dev/null || { echo "NetBox did not start"; exit 1; } - name: Install importer dependencies working-directory: importer @@ -175,6 +194,6 @@ jobs: - name: Print NetBox version on failure if: failure() run: | - curl -s -H "Authorization: Bearer $NETBOX_TOKEN" http://localhost:8000/api/status/ | python3 -m json.tool || true + curl -s -H "Authorization: $NETBOX_AUTH_SCHEME $NETBOX_TOKEN" http://localhost:8000/api/status/ | python3 -m json.tool || true echo "--- NetBox server log ---" cat /tmp/netbox.log 2>/dev/null | tail -50 || true diff --git a/README.md b/README.md index cec7a8fd..447a98e9 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # NetBox Device Type Import [![Tests](https://github.com/marcinpsk/Device-Type-Library-Import/actions/workflows/tests.yml/badge.svg)](https://github.com/marcinpsk/Device-Type-Library-Import/actions/workflows/tests.yml) -[![NetBox main](https://github.com/marcinpsk/Device-Type-Library-Import/actions/workflows/test-netbox-main.yaml/badge.svg)](https://github.com/marcinpsk/Device-Type-Library-Import/actions/workflows/test-netbox-main.yaml) -[![NetBox](https://img.shields.io/badge/NetBox-3.2%2B_through_4.5%2B-blue)](https://netbox.dev) +[![Live NetBox](https://github.com/marcinpsk/Device-Type-Library-Import/actions/workflows/test-netbox.yaml/badge.svg)](https://github.com/marcinpsk/Device-Type-Library-Import/actions/workflows/test-netbox.yaml) +[![NetBox](https://img.shields.io/badge/NetBox-4.3%2B-blue)](https://netbox.dev) [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org) [![Container image](https://img.shields.io/badge/ghcr.io-device--type--library--import-2496ED?logo=docker&logoColor=white)](https://github.com/marcinpsk/Device-Type-Library-Import/pkgs/container/device-type-library-import) This library is intended to be your friend and help you import all the device-types defined within the [NetBox Device Type Library Repository](https://github.com/netbox-community/devicetype-library). -> **Tested working with NetBox 3.2+ through 4.5+** (weekly CI run against NetBox `main`) +> **Requires NetBox 4.3 or later.** Every pull request and manual CI run exercises the full import pipeline against the latest 4.3, 4.4, and 4.5 patch releases plus NetBox `main`; a weekly scheduled run tracks NetBox `main`. ## Description diff --git a/tests/integration/test_import.py b/tests/integration/test_import.py index a1a4bb17..acb110f7 100644 --- a/tests/integration/test_import.py +++ b/tests/integration/test_import.py @@ -289,6 +289,20 @@ def _test_images(fd: dict, fd_id: int) -> None: # ────────────────────────────────────────────────────────────────────────────── +def front_port_mappings(fp: dict) -> list: + """Normalize a front-port template's rear-port linkage across NetBox versions. + + NetBox >= 4.5 serves an M2M ``rear_ports`` list of PortMapping entries; + older releases serve a scalar ``rear_port`` object plus ``rear_port_position``. + """ + if "rear_ports" in fp: + return fp.get("rear_ports") or [] + rear_port = fp.get("rear_port") + if not rear_port: + return [] + return [{"rear_port": rear_port["id"], "rear_port_position": fp.get("rear_port_position")}] + + def test_front_port_multiposition() -> None: print("\n=== Scenario E: Front-port multi-position linkage ===") fd = get_one("/dcim/device-types/", slug="testvendor-full-device") @@ -299,17 +313,17 @@ def test_front_port_multiposition() -> None: for name, expected_pos in [("FP1", 1), ("FP2", 2)]: fp = fps[name] - mapping = fp.get("rear_ports", []) + mapping = front_port_mappings(fp) if not mapping: - fail(f"{name}: rear_ports is empty — M2M linkage not created") + fail(f"{name}: rear-port linkage is empty — mapping not created") pos = mapping[0].get("rear_port_position") if pos != expected_pos: fail(f"{name}: rear_port_position = {pos!r}, expected {expected_pos}") ok(f"{name}: rear_port_position = {pos}") # Both front ports should point to the same rear port - rp1_id = fps["FP1"]["rear_ports"][0]["rear_port"] - rp2_id = fps["FP2"]["rear_ports"][0]["rear_port"] + rp1_id = front_port_mappings(fps["FP1"])[0]["rear_port"] + rp2_id = front_port_mappings(fps["FP2"])[0]["rear_port"] if rp1_id != rp2_id: fail(f"FP1 and FP2 point to different rear ports ({rp1_id} vs {rp2_id}), expected same RP1") ok("FP1 and FP2 both map to the same rear port (RP1)") @@ -317,10 +331,10 @@ def test_front_port_multiposition() -> None: # Also check patch-panel front ports pp = get_one("/dcim/device-types/", slug="testvendor-patch-panel-4") pp_fps = api("/dcim/front-port-templates/", device_type_id=pp["id"])["results"] - broken = [fp["name"] for fp in pp_fps if not fp.get("rear_ports")] + broken = [fp["name"] for fp in pp_fps if not front_port_mappings(fp)] if broken: - fail(f"patch-panel-4: {len(broken)} front ports have empty rear_ports: {broken}") - ok(f"patch-panel-4: all {len(pp_fps)} front ports have rear_ports mapping") + fail(f"patch-panel-4: {len(broken)} front ports have empty rear-port linkage: {broken}") + ok(f"patch-panel-4: all {len(pp_fps)} front ports have a rear-port mapping") # ────────────────────────────────────────────────────────────────────────────── @@ -354,10 +368,10 @@ def test_module_types() -> None: # Front port linkage for module type mt_fps = api("/dcim/front-port-templates/", module_type_id=mt_id)["results"] - broken = [fp["name"] for fp in mt_fps if not fp.get("rear_ports")] + broken = [fp["name"] for fp in mt_fps if not front_port_mappings(fp)] if broken: - fail(f"full-module: {len(broken)} front ports have empty rear_ports: {broken}") - ok("full-module: front port rear_ports mapping set correctly") + fail(f"full-module: {len(broken)} front ports have empty rear-port linkage: {broken}") + ok("full-module: front port rear-port mapping set correctly") # ──────────────────────────────────────────────────────────────────────────────