diff --git a/schema/components.json b/schema/components.json index 8fc95aef58..fadfc9b356 100644 --- a/schema/components.json +++ b/schema/components.json @@ -198,9 +198,11 @@ }, "required": [ "name", - "type", - "rear_port" + "type" ], + "dependentRequired": { + "rear_port_position": ["rear_port"] + }, "additionalProperties": false }, "rear-port": { diff --git a/schema/devicetype.json b/schema/devicetype.json index 9f8ef3ea1a..9de3eedda6 100644 --- a/schema/devicetype.json +++ b/schema/devicetype.json @@ -91,6 +91,20 @@ "$ref": "urn:devicetype-library:components#/definitions/rear-port" } }, + "port-mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "front_port": { "type": "string", "minLength": 1, "maxLength": 64 }, + "front_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 }, + "rear_port": { "type": "string", "minLength": 1, "maxLength": 64 }, + "rear_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 } + }, + "required": ["front_port", "rear_port"], + "additionalProperties": false + } + }, "module-bays": { "type": "array", "items": { diff --git a/schema/moduletype.json b/schema/moduletype.json index cc0112ac94..339677671b 100644 --- a/schema/moduletype.json +++ b/schema/moduletype.json @@ -66,6 +66,20 @@ "$ref": "urn:devicetype-library:components#/definitions/rear-port" } }, + "port-mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "front_port": { "type": "string", "minLength": 1, "maxLength": 64 }, + "front_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 }, + "rear_port": { "type": "string", "minLength": 1, "maxLength": 64 }, + "rear_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 } + }, + "required": ["front_port", "rear_port"], + "additionalProperties": false + } + }, "module-bays": { "type": "array", "items": { diff --git a/tests/definitions_test.py b/tests/definitions_test.py index 3d9a07b26b..48734751bb 100644 --- a/tests/definitions_test.py +++ b/tests/definitions_test.py @@ -281,6 +281,89 @@ def test_definitions(file_path, schema, change_type): ) rear_port_positions[key] = fp.get("name") + # Validate the optional `port-mappings` stanza (NetBox v4.5+ format). + # The library accepts both formats: the legacy inline `rear_port` / + # `rear_port_position` on each front-port (checked in the loop above), + # and the new top-level `port-mappings:` list. A given front-port must + # use only one of the two formats. + # + # Note: we deliberately do NOT require every front-port to be referenced + # by some mapping (inline or stanza). NetBox v4.5+ permits unmapped + # front-ports — `FrontPortTemplate.clean()` enforces only the upper + # bound `positions >= mappings.count()` — so DTL matches that + # permissiveness rather than being stricter than the target system. + if any(x in file_path for x in ("device-types", "module-types")): + port_mappings = definition.get("port-mappings", []) or [] + front_port_names = { + fp.get("name") for fp in front_ports if isinstance(fp, dict) + } + front_ports_with_inline_rear = { + fp.get("name") for fp in front_ports + if isinstance(fp, dict) and fp.get("rear_port") + } + stanza_front_port_positions = set() + + # Structural validity of each entry (object shape, required keys, + # non-empty strings) is enforced by the JSON schema validated + # earlier in this test — don't re-check those invariants here. + # This block only enforces cross-document rules the schema can't + # express. + for pm in port_mappings: + fp_ref = pm["front_port"] + rp_ref = pm["rear_port"] + + if fp_ref not in front_port_names: + pytest.fail( + f"{file_path}: port-mappings entry references " + f"front_port '{fp_ref}', but no such front-port exists. " + f"Defined front-ports: {sorted(front_port_names)}", + pytrace=False, + ) + + if rp_ref not in rear_port_names: + pytest.fail( + f"{file_path}: port-mappings entry references " + f"rear_port '{rp_ref}', but no such rear-port exists. " + f"Defined rear-ports: {sorted(rear_port_names)}", + pytrace=False, + ) + + # Reject mixing inline `rear_port` and a stanza entry for the + # same front-port — pick one format per port. + if fp_ref in front_ports_with_inline_rear: + pytest.fail( + f"{file_path}: front-port '{fp_ref}' has inline " + f"'rear_port' AND appears in 'port-mappings' stanza. " + f"Use only one format per front-port.", + pytrace=False, + ) + + # (rear_port, rear_port_position) uniqueness — checked across + # both formats by reusing `rear_port_positions` from above. + rear_port_pos = pm.get("rear_port_position", 1) + key = (rp_ref, rear_port_pos) + if key in rear_port_positions: + pytest.fail( + f"{file_path}: port-mappings entry for front_port " + f"'{fp_ref}' has duplicate (rear_port, " + f"rear_port_position) = ('{rp_ref}', {rear_port_pos}). " + f"Already used by '{rear_port_positions[key]}'.", + pytrace=False, + ) + rear_port_positions[key] = f"port-mappings entry for '{fp_ref}'" + + # (front_port, front_port_position) uniqueness within the stanza. + front_port_pos = pm.get("front_port_position", 1) + fkey = (fp_ref, front_port_pos) + if fkey in stanza_front_port_positions: + pytest.fail( + f"{file_path}: port-mappings entry has duplicate " + f"(front_port, front_port_position) = " + f"('{fp_ref}', {front_port_pos}).", + pytrace=False, + ) + stanza_front_port_positions.add(fkey) + # Verify the slug is valid, only if the definition type is a Device if this_device.isDevice: assert this_device.verify_slug(KNOWN_SLUGS), pytest.fail(this_device.failureMessage, False)