Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/schema-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
# yamllint disable rule:truthy rule:line-length
name: Schema Drift Check

# Warn-only: surfaces when the published Infrahub JSON schema has drifted from
# the formatter's committed baseline (infrahub_sdk/ctl/schema_properties.json).
# This never fails the run — it emits ::warning:: annotations and a job summary
# so a maintainer can account for the change in schema_format.py.

on:
release:
types:
- published
workflow_dispatch:

jobs:
schema-drift:
runs-on: "ubuntu-22.04"
timeout-minutes: 5
steps:
- name: "Check out repository code"
uses: "actions/checkout@v6"
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install UV
uses: astral-sh/setup-uv@v7
- name: Install dependencies
run: uv sync --all-groups --all-extras
- name: "Check for Infrahub schema drift (warn only)"
run: uv run invoke schema-drift-check
1 change: 1 addition & 0 deletions changelog/+schema-format-command.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `infrahubctl schema format` command, an opinionated offline formatter that normalises the key ordering of schema files. Optional flags can also strip redundant default values (`--strip-defaults`), sort attributes/relationships by `order_weight` (`--sort-by-order-weight`), and backfill a missing `order_weight` (`--backfill-order-weight`).
43 changes: 43 additions & 0 deletions docs/docs/infrahubctl/infrahubctl-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ $ infrahubctl schema [OPTIONS] COMMAND [ARGS]...
* `export`: Export the schema from Infrahub as YAML...
* `list`: List all available schema kinds.
* `show`: Show details for a specific schema kind.
* `format`: Format Infrahub schema files with a...

## `infrahubctl schema load`

Expand Down Expand Up @@ -133,3 +134,45 @@ $ infrahubctl schema show [OPTIONS] KIND
* `-b, --branch TEXT`: Target branch
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
* `--help`: Show this message and exit.

## `infrahubctl schema format`

Format Infrahub schema files with a canonical key ordering.

Reorders the keys within each node, generic, attribute, relationship and
dropdown choice into a consistent, opinionated order so schema files read
the same way and produce small diffs.

Only your own nodes are formatted; nodes in Infrahub-reserved namespaces are
left untouched. Comments, quoting, and inline (flow) sequences are preserved.

By default the change is purely key ordering. The opt-in flags additionally
change content: --strip-defaults drops redundant default values,
--sort-by-order-weight reorders attributes/relationships, and
--backfill-order-weight fills in a missing order_weight.

Examples:
infrahubctl schema format schemas/
infrahubctl schema format schemas/dcim.yml --diff
infrahubctl schema format schemas/ --check
infrahubctl schema format schemas/ --strip-defaults --sort-by-order-weight

**Usage**:

```console
$ infrahubctl schema format [OPTIONS] SCHEMAS...
```

**Arguments**:

* `SCHEMAS...`: [required]

**Options**:

* `--check`: Do not write files; exit 1 if any file would be reformatted.
* `--diff`: Print a diff of the changes instead of writing files.
* `--strip-defaults`: Remove attribute/relationship/node keys whose value equals the schema default.
* `--sort-by-order-weight`: Sort attributes and relationships by order_weight (items without one keep their order and go last).
* `--backfill-order-weight`: Give attributes/relationships that lack an order_weight the value 1000.
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
* `--help`: Show this message and exit.
158 changes: 158 additions & 0 deletions infrahub_sdk/ctl/schema.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import difflib
import time
from datetime import datetime, timezone
from pathlib import Path
Expand All @@ -19,6 +20,13 @@
from ..schema import NodeSchemaAPI, SchemaWarning
from ..yaml import SchemaFile
from .parameters import CONFIG_PARAM
from .schema_format import (
DEFAULT_BACKFILL_ORDER_WEIGHT,
FormatError,
FormatOptions,
format_schema_text,
is_schema_document,
)
from .utils import load_yamlfile_from_disk_and_exit

if TYPE_CHECKING:
Expand Down Expand Up @@ -414,3 +422,153 @@ async def schema_show(
"Yes" if rel.optional else "No",
)
console.print(rel_table)


def _print_schema_diff(location: Path, original: str, formatted: str) -> None:
diff = difflib.unified_diff(
original.splitlines(keepends=True),
formatted.splitlines(keepends=True),
fromfile=f"{location} (current)",
tofile=f"{location} (formatted)",
)
for line in diff:
# markup=False keeps bracketed diff content (e.g. `[manufacturer, name]`)
# literal, so colour is applied via style= rather than inline markup.
if line.startswith("+") and not line.startswith("+++"):
console.print(line, end="", markup=False, highlight=False, style="green")
elif line.startswith("-") and not line.startswith("---"):
console.print(line, end="", markup=False, highlight=False, style="red")
else:
console.print(line, end="", markup=False, highlight=False)


def _format_one_schema_file(
location: Path, entries: list[SchemaFile], check: bool, diff: bool, options: FormatOptions
) -> str:
"""Format a single schema file and report what happened.

Args:
location: Path of the file on disk.
entries: SchemaFile entries parsed for this location (more than one means
a genuine multi-document file, which is not supported).
check: Report changes without writing.
diff: Print a diff instead of writing.
options: Opt-in transforms to apply.

Returns:
One of ``"error"``, ``"skipped"``, ``"unchanged"`` or ``"changed"``.

"""
if len(entries) > 1:
console.print(f"[yellow] Skipped {location}: multi-document files are not supported by format")
return "skipped"

schema_file = entries[0]
if not schema_file.valid or schema_file.content is None:
console.print(f"[red] {location}: {schema_file.error_message or 'invalid file'}")
return "error"

if not is_schema_document(schema_file.content):
return "skipped"

original = location.read_text(encoding="utf-8")
try:
formatted = format_schema_text(original, options)
except FormatError as exc:
console.print(f"[red] {location}: {exc}")
return "error"

if formatted == original:
return "unchanged"

if diff:
_print_schema_diff(location=location, original=original, formatted=formatted)
elif check:
console.print(f"[yellow] Would reformat {location}")
else:
location.write_text(formatted, encoding="utf-8")
console.print(f"[green] Reformatted {location}")
return "changed"


@app.command(name="format")
@catch_exception(console=console)
def schema_format(
schemas: list[Path],
check: bool = typer.Option(False, "--check", help="Do not write files; exit 1 if any file would be reformatted."),
diff: bool = typer.Option(False, "--diff", help="Print a diff of the changes instead of writing files."),
strip_defaults: bool = typer.Option(
False, "--strip-defaults", help="Remove attribute/relationship/node keys whose value equals the schema default."
),
sort_by_order_weight: bool = typer.Option(
False,
"--sort-by-order-weight",
help="Sort attributes and relationships by order_weight (items without one keep their order and go last).",
),
backfill_order_weight: bool = typer.Option(
False,
"--backfill-order-weight",
help=f"Give attributes/relationships that lack an order_weight the value {DEFAULT_BACKFILL_ORDER_WEIGHT}.",
),
_: str = CONFIG_PARAM,
) -> None:
"""Format Infrahub schema files with a canonical key ordering.

Reorders the keys within each node, generic, attribute, relationship and
dropdown choice into a consistent, opinionated order so schema files read
the same way and produce small diffs.

Only your own nodes are formatted; nodes in Infrahub-reserved namespaces are
left untouched. Comments, quoting, and inline (flow) sequences are preserved.

By default the change is purely key ordering. The opt-in flags additionally
change content: --strip-defaults drops redundant default values,
--sort-by-order-weight reorders attributes/relationships, and
--backfill-order-weight fills in a missing order_weight.

\b
Examples:
infrahubctl schema format schemas/
infrahubctl schema format schemas/dcim.yml --diff
infrahubctl schema format schemas/ --check
infrahubctl schema format schemas/ --strip-defaults --sort-by-order-weight
"""
options = FormatOptions(
strip_defaults=strip_defaults,
sort_by_order_weight=sort_by_order_weight,
backfill_order_weight=backfill_order_weight,
)
schema_files = SchemaFile.load_from_disk(paths=schemas)

# A genuine multi-document file yields several SchemaFile entries for the
# same location. The per-file ``multiple_documents`` flag is unreliable
# (it is set from a naive `---` substring count that also matches `---`
# inside comments), so group by location and count real documents instead.
entries_by_location: dict[Path, list[SchemaFile]] = {}
for schema_file in schema_files:
entries_by_location.setdefault(schema_file.location, []).append(schema_file)

reformatted = 0
unchanged = 0
would_change = 0
has_error = False

for location, entries in entries_by_location.items():
status = _format_one_schema_file(location=location, entries=entries, check=check, diff=diff, options=options)
if status == "error":
has_error = True
elif status == "unchanged":
unchanged += 1
elif status == "changed":
if check or diff:
would_change += 1
else:
reformatted += 1

if check or diff:
console.print(f"\n[bold]{would_change} file(s) would be reformatted, {unchanged} unchanged.")
else:
console.print(f"\n[bold]{reformatted} file(s) reformatted, {unchanged} unchanged.")

if has_error or (check and would_change):
raise typer.Exit(1)
105 changes: 105 additions & 0 deletions infrahub_sdk/ctl/schema_drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Detect drift between the Infrahub JSON schema and the formatter's baseline.

The canonical key ordering in :mod:`infrahub_sdk.ctl.schema_format` is written
against a known set of schema properties. When Infrahub adds, removes, or
renames a property in the published JSON schema
(https://schema.infrahub.app/infrahub/schema/latest.json), that ordering may
need updating so the new key lands in a sensible slot rather than being
preserved as an unrecognised key.

This module compares the live schema against a committed baseline
(``schema_properties.json``) and reports the difference. It backs the
``schema-drift-check`` invoke task (a warn-only CI step) and the
``schema-drift-update`` task that refreshes the baseline. It never raises on
drift — reporting is the caller's job.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import httpx

from .schema_format import SCHEMA_URL

# The JSON-schema ``$defs`` whose property sets the formatter orders. Each maps
# to a canonical key list in ``schema_format`` (nodes/generics, attributes,
# relationships, dropdown choices, and node extensions).
TRACKED_DEFINITIONS = [
"NodeSchema",
"GenericSchema",
"AttributeSchema",
"RelationshipSchema",
"DropdownChoice",
"NodeExtensionSchema",
]

BASELINE_PATH = Path(__file__).parent / "schema_properties.json"


def extract_properties(schema: dict[str, Any]) -> dict[str, list[str]]:
"""Extract the sorted property names of each tracked definition.

Args:
schema: The parsed JSON schema document.

Returns:
A mapping of definition name to its sorted list of property names.

"""
definitions = schema.get("$defs") or schema.get("definitions") or {}
return {name: sorted(definitions.get(name, {}).get("properties", {})) for name in TRACKED_DEFINITIONS}


def fetch_live_properties(url: str = SCHEMA_URL, timeout: float = 30.0) -> dict[str, list[str]]:
"""Fetch the live JSON schema and return its tracked property sets.

Args:
url: The schema URL to fetch.
timeout: Request timeout in seconds.

Returns:
A mapping of definition name to its sorted list of property names.

Raises:
httpx.HTTPError: If the schema cannot be fetched.

"""
response = httpx.get(url, timeout=timeout, follow_redirects=True)
response.raise_for_status()
return extract_properties(response.json())


def load_baseline(path: Path = BASELINE_PATH) -> dict[str, list[str]]:
"""Load the committed baseline property sets."""
return json.loads(path.read_text(encoding="utf-8"))


def write_baseline(properties: dict[str, list[str]], path: Path = BASELINE_PATH) -> None:
"""Write ``properties`` to the baseline file as sorted, indented JSON."""
path.write_text(json.dumps(properties, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def compute_drift(live: dict[str, list[str]], baseline: dict[str, list[str]]) -> dict[str, dict[str, list[str]]]:
"""Compare live and baseline property sets.

Args:
live: Property sets from the live schema.
baseline: Property sets from the committed baseline.

Returns:
A mapping of definition name to ``{"added": [...], "removed": [...]}``,
containing only the definitions that changed.

"""
drift: dict[str, dict[str, list[str]]] = {}
for name in TRACKED_DEFINITIONS:
live_set = set(live.get(name, []))
baseline_set = set(baseline.get(name, []))
added = sorted(live_set - baseline_set)
removed = sorted(baseline_set - live_set)
if added or removed:
drift[name] = {"added": added, "removed": removed}
return drift
Loading
Loading