Skip to content
Merged
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.
168 changes: 168 additions & 0 deletions infrahub_sdk/ctl/schema.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import asyncio
import difflib
import time
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal

Expand All @@ -19,6 +21,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 All @@ -30,6 +39,15 @@
console = Console()


class FormatOutcome(Enum):
"""Result of formatting a single schema file."""

ERROR = "error"
SKIPPED = "skipped"
UNCHANGED = "unchanged"
CHANGED = "changed"


@app.callback()
def callback() -> None:
"""Manage the schema in a remote Infrahub instance."""
Expand Down Expand Up @@ -414,3 +432,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
) -> FormatOutcome:
"""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:
The :class:`FormatOutcome` for this file.

"""
if len(entries) > 1:
console.print(f"[yellow] Skipped {location}: multi-document files are not supported by format")
return FormatOutcome.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 FormatOutcome.ERROR

if not is_schema_document(schema_file.content):
return FormatOutcome.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 FormatOutcome.ERROR

if formatted == original:
return FormatOutcome.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 FormatOutcome.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 is FormatOutcome.ERROR:
has_error = True
elif status is FormatOutcome.UNCHANGED:
unchanged += 1
elif status is FormatOutcome.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