diff --git a/.forgejo/workflows/build-deb.yml b/.forgejo/workflows/build-deb.yml index 28276df..047d2bc 100644 --- a/.forgejo/workflows/build-deb.yml +++ b/.forgejo/workflows/build-deb.yml @@ -21,6 +21,7 @@ jobs: python3-poetry-core \ python3-yaml \ python3-paramiko \ + python3-jsonschema \ rsync \ ca-certificates diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 68e1c02..38fe90a 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: run: | apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - ansible ansible-lint python3-venv pipx systemctl python3-apt jq + ansible ansible-lint python3-venv pipx systemctl python3-apt jq python3-jsonschema - name: Install Poetry run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index b09a3a6..60aad11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.4.0 + + * Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest. + # 0.3.0 * Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why. @@ -17,7 +21,7 @@ # 0.2.1 - * Don't accidentally add extra_paths role to usr_local_custom list, resulting in extra_paths appearing twice in manifested playbook + * Don't accidentally add `extra_paths` role to `usr_local_custom` list, resulting in `extra_paths` appearing twice in manifested playbook * Ensure directories in the tree of anything included with --include are defined in the state and manifest so we make dirs before we try to create files # 0.2.0 @@ -38,8 +42,8 @@ # 0.1.5 * Consolidate logrotate and cron files into their main service/package roles if they exist. - * Standardise on MAX_FILES_CAP in one place - * Manage apt stuff in its own role, not in etc_custom + * Standardise on `MAX_FILES_CAP` in one place + * Manage apt stuff in its own role, not in `etc_custom` # 0.1.4 diff --git a/Dockerfile.debbuild b/Dockerfile.debbuild index a466ee2..c6ebedb 100644 --- a/Dockerfile.debbuild +++ b/Dockerfile.debbuild @@ -26,6 +26,7 @@ RUN set -eux; \ python3-poetry-core \ python3-yaml \ python3-paramiko \ + python3-jsonschema \ rsync \ ca-certificates \ ; \ diff --git a/Dockerfile.rpmbuild b/Dockerfile.rpmbuild index f76a673..05bfd48 100644 --- a/Dockerfile.rpmbuild +++ b/Dockerfile.rpmbuild @@ -22,6 +22,7 @@ RUN set -eux; \ python3-rpm-macros \ python3-yaml \ python3-paramiko \ + python3-jsonschema \ openssl-devel \ python3-poetry-core ; \ dnf -y clean all diff --git a/README.md b/README.md index d848615..7938859 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,62 @@ Output can be provided in plaintext or json. --- +### `enroll validate` + +Validates a harvest by checking: + + * state.json exists and is valid JSON + * state.json validates against a JSON Schema (by default the vendored one) + * Every `managed_file` entry has a corresponding artifact at: `artifacts//` + +It also warns if there are **unreferenced files** sitting in `artifacts/`. + +#### Schema location + overrides + +The master schema lives at: `enroll/schema/state.schema.json`. + +You can override with a local file or URL: + +``` +enroll validate /path/to/harvest --schema ./state.schema.json +enroll validate /path/to/harvest --schema https://enroll.sh/schema/state.schema.json +``` + +Or skip schema checks (still does artifact consistency checks): + +``` +enroll validate /path/to/harvest --no-schema +``` + +#### CLI usage examples + +Validate a local harvest: + +``` +enroll validate ./harvest +``` + +Validate a harvest tarball or a sops bundle: + +``` +enroll validate ./harvest.tar.gz +enroll validate ./harvest.sops --sops +``` + +JSON output + write to file: + +``` +enroll validate ./harvest --format json --out validate.json +``` + +Return exit code 1 for any warnings, not just errors (useful for CI): + +``` +enroll validate ./harvest --fail-on-warnings +``` + +--- + ## Sensitive data By default, `enroll` does **not** assume how you handle secrets in Ansible. It will attempt to avoid harvesting likely sensitive data (private keys, passwords, tokens, etc.). This can mean it skips some config files you may ultimately want to manage. diff --git a/debian/control b/debian/control index 7f323fd..d5a21fe 100644 --- a/debian/control +++ b/debian/control @@ -10,12 +10,13 @@ Build-Depends: python3-all, python3-yaml, python3-poetry-core, - python3-paramiko + python3-paramiko, + python3-jsonschema Standards-Version: 4.6.2 Homepage: https://git.mig5.net/mig5/enroll Package: enroll Architecture: all -Depends: ${misc:Depends}, ${python3:Depends}, python3-yaml, python3-paramiko +Depends: ${misc:Depends}, ${python3:Depends}, python3-yaml, python3-paramiko, python3-jsonschema Description: Harvest a host into Ansible roles A tool that inspects a system and emits Ansible roles/playbooks to reproduce it. diff --git a/enroll/cli.py b/enroll/cli.py index 829a4ac..9f9e63f 100644 --- a/enroll/cli.py +++ b/enroll/cli.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse import configparser +import json import os import sys import tarfile @@ -16,6 +17,7 @@ from .harvest import harvest from .manifest import manifest from .remote import remote_harvest, RemoteSudoPasswordRequired from .sopsutil import SopsError, encrypt_file_binary +from .validate import validate_harvest from .version import get_enroll_version @@ -632,6 +634,49 @@ def main() -> None: help="How many example paths/refs to show per reason.", ) + v = sub.add_parser( + "validate", help="Validate a harvest bundle (state.json + artifacts)" + ) + _add_config_args(v) + v.add_argument( + "harvest", + help=( + "Harvest input (directory, a path to state.json, a tarball, or a SOPS-encrypted bundle)." + ), + ) + v.add_argument( + "--sops", + action="store_true", + help="Treat the input as a SOPS-encrypted bundle (auto-detected if the filename ends with .sops).", + ) + v.add_argument( + "--schema", + help=( + "Optional JSON schema source (file path or https:// URL). " + "If omitted, uses the schema vendored in the enroll codebase." + ), + ) + v.add_argument( + "--no-schema", + action="store_true", + help="Skip JSON schema validation and only perform bundle consistency checks.", + ) + v.add_argument( + "--fail-on-warnings", + action="store_true", + help="Exit non-zero if validation produces warnings.", + ) + v.add_argument( + "--format", + choices=["text", "json"], + default="text", + help="Output format.", + ) + v.add_argument( + "--out", + help="Write the report to this file instead of stdout.", + ) + argv = sys.argv[1:] cfg_path = _discover_config_path(argv) argv = _inject_config_argv( @@ -644,6 +689,7 @@ def main() -> None: "single-shot": s, "diff": d, "explain": e, + "validate": v, }, ) args = ap.parse_args(argv) @@ -739,6 +785,33 @@ def main() -> None: ) sys.stdout.write(out) + elif args.cmd == "validate": + res = validate_harvest( + args.harvest, + sops_mode=bool(getattr(args, "sops", False)), + schema=getattr(args, "schema", None), + no_schema=bool(getattr(args, "no_schema", False)), + ) + + fmt = str(getattr(args, "format", "text")) + if fmt == "json": + txt = json.dumps(res.to_dict(), indent=2, sort_keys=True) + "\n" + else: + txt = res.to_text() + + out_path = getattr(args, "out", None) + if out_path: + p = Path(out_path).expanduser() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(txt, encoding="utf-8") + else: + sys.stdout.write(txt) + + if res.errors: + raise SystemExit(1) + if res.warnings and bool(getattr(args, "fail_on_warnings", False)): + raise SystemExit(1) + elif args.cmd == "manifest": out_enc = manifest( args.harvest, diff --git a/enroll/schema/__init__.py b/enroll/schema/__init__.py new file mode 100644 index 0000000..9d19c43 --- /dev/null +++ b/enroll/schema/__init__.py @@ -0,0 +1,4 @@ +"""Vendored JSON schemas. + +These are used by `enroll validate` so validation can run offline. +""" diff --git a/enroll/schema/state.schema.json b/enroll/schema/state.schema.json new file mode 100644 index 0000000..083f90f --- /dev/null +++ b/enroll/schema/state.schema.json @@ -0,0 +1,712 @@ +{ + "$defs": { + "AptConfigSnapshot": { + "allOf": [ + { + "$ref": "#/$defs/RoleCommon" + }, + { + "properties": { + "role_name": { + "const": "apt_config" + } + }, + "type": "object" + } + ], + "unevaluatedProperties": false + }, + "DnfConfigSnapshot": { + "allOf": [ + { + "$ref": "#/$defs/RoleCommon" + }, + { + "properties": { + "role_name": { + "const": "dnf_config" + } + }, + "type": "object" + } + ], + "unevaluatedProperties": false + }, + "EtcCustomSnapshot": { + "allOf": [ + { + "$ref": "#/$defs/RoleCommon" + }, + { + "properties": { + "role_name": { + "const": "etc_custom" + } + }, + "type": "object" + } + ], + "unevaluatedProperties": false + }, + "ExcludedFile": { + "additionalProperties": false, + "properties": { + "path": { + "minLength": 1, + "pattern": "^/.*", + "type": "string" + }, + "reason": { + "enum": [ + "user_excluded", + "unreadable", + "backup_file", + "log_file", + "denied_path", + "too_large", + "not_regular_file", + "not_symlink", + "binary_like", + "sensitive_content" + ], + "type": "string" + } + }, + "required": [ + "path", + "reason" + ], + "type": "object" + }, + "ExtraPathsSnapshot": { + "allOf": [ + { + "$ref": "#/$defs/RoleCommon" + }, + { + "properties": { + "exclude_patterns": { + "items": { + "type": "string" + }, + "type": "array" + }, + "include_patterns": { + "items": { + "type": "string" + }, + "type": "array" + }, + "role_name": { + "const": "extra_paths" + } + }, + "required": [ + "include_patterns", + "exclude_patterns" + ], + "type": "object" + } + ], + "unevaluatedProperties": false + }, + "InstalledPackageInstance": { + "additionalProperties": false, + "properties": { + "arch": { + "minLength": 1, + "type": "string" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "version", + "arch" + ], + "type": "object" + }, + "ManagedDir": { + "additionalProperties": false, + "properties": { + "group": { + "minLength": 1, + "type": "string" + }, + "mode": { + "pattern": "^[0-7]{4}$", + "type": "string" + }, + "owner": { + "minLength": 1, + "type": "string" + }, + "path": { + "minLength": 1, + "pattern": "^/.*", + "type": "string" + }, + "reason": { + "enum": [ + "parent_of_managed_file", + "user_include_dir" + ], + "type": "string" + } + }, + "required": [ + "path", + "owner", + "group", + "mode", + "reason" + ], + "type": "object" + }, + "ManagedFile": { + "additionalProperties": false, + "properties": { + "group": { + "minLength": 1, + "type": "string" + }, + "mode": { + "pattern": "^[0-7]{4}$", + "type": "string" + }, + "owner": { + "minLength": 1, + "type": "string" + }, + "path": { + "minLength": 1, + "pattern": "^/.*", + "type": "string" + }, + "reason": { + "enum": [ + "apt_config", + "apt_keyring", + "apt_signed_by_keyring", + "apt_source", + "authorized_keys", + "cron_snippet", + "custom_specific_path", + "custom_unowned", + "dnf_config", + "logrotate_snippet", + "modified_conffile", + "modified_packaged_file", + "related_timer", + "rpm_gpg_key", + "ssh_public_key", + "system_cron", + "system_firewall", + "system_logrotate", + "system_modprobe", + "system_mounts", + "system_network", + "system_rc", + "system_security", + "system_sysctl", + "systemd_dropin", + "systemd_envfile", + "user_include", + "user_profile", + "user_shell_aliases", + "user_shell_logout", + "user_shell_rc", + "usr_local_bin_script", + "usr_local_etc_custom", + "yum_conf", + "yum_config", + "yum_repo" + ], + "type": "string" + }, + "src_rel": { + "minLength": 1, + "pattern": "^[^/].*", + "type": "string" + } + }, + "required": [ + "path", + "src_rel", + "owner", + "group", + "mode", + "reason" + ], + "type": "object" + }, + "ManagedLink": { + "additionalProperties": false, + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "pattern": "^/.*" + }, + "target": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "enum": [ + "enabled_symlink" + ] + } + }, + "required": [ + "path", + "target", + "reason" + ] + }, + "ObservedVia": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "user_installed" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "systemd_unit" + }, + "ref": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "ref" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "package_role" + }, + "ref": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "ref" + ], + "type": "object" + } + ] + }, + "PackageInventoryEntry": { + "additionalProperties": false, + "properties": { + "arches": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "installations": { + "items": { + "$ref": "#/$defs/InstalledPackageInstance" + }, + "type": "array" + }, + "observed_via": { + "items": { + "$ref": "#/$defs/ObservedVia" + }, + "type": "array" + }, + "roles": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "version": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "version", + "arches", + "installations", + "observed_via", + "roles" + ], + "type": "object" + }, + "PackageSnapshot": { + "allOf": [ + { + "$ref": "#/$defs/RoleCommon" + }, + { + "properties": { + "package": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "package" + ], + "type": "object" + } + ], + "unevaluatedProperties": false + }, + "RoleCommon": { + "properties": { + "excluded": { + "items": { + "$ref": "#/$defs/ExcludedFile" + }, + "type": "array" + }, + "managed_dirs": { + "items": { + "$ref": "#/$defs/ManagedDir" + }, + "type": "array" + }, + "managed_files": { + "items": { + "$ref": "#/$defs/ManagedFile" + }, + "type": "array" + }, + "managed_links": { + "items": { + "$ref": "#/$defs/ManagedLink" + }, + "type": "array" + }, + "notes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "role_name": { + "minLength": 1, + "pattern": "^[A-Za-z0-9_]+$", + "type": "string" + } + }, + "required": [ + "role_name", + "managed_dirs", + "managed_files", + "excluded", + "notes" + ], + "type": "object" + }, + "ServiceSnapshot": { + "allOf": [ + { + "$ref": "#/$defs/RoleCommon" + }, + { + "properties": { + "active_state": { + "type": [ + "string", + "null" + ] + }, + "condition_result": { + "type": [ + "string", + "null" + ] + }, + "packages": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "role_name": { + "minLength": 1, + "pattern": "^[a-z_][a-z0-9_]*$", + "type": "string" + }, + "sub_state": { + "type": [ + "string", + "null" + ] + }, + "unit": { + "minLength": 1, + "type": "string" + }, + "unit_file_state": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "unit", + "packages", + "active_state", + "sub_state", + "unit_file_state", + "condition_result" + ], + "type": "object" + } + ], + "unevaluatedProperties": false + }, + "UserEntry": { + "additionalProperties": false, + "properties": { + "gecos": { + "type": "string" + }, + "gid": { + "minimum": 0, + "type": "integer" + }, + "home": { + "type": "string" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "primary_group": { + "minLength": 1, + "type": "string" + }, + "shell": { + "type": "string" + }, + "supplementary_groups": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "uid": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name", + "uid", + "gid", + "gecos", + "home", + "shell", + "primary_group", + "supplementary_groups" + ], + "type": "object" + }, + "UsersSnapshot": { + "allOf": [ + { + "$ref": "#/$defs/RoleCommon" + }, + { + "properties": { + "role_name": { + "const": "users" + }, + "users": { + "items": { + "$ref": "#/$defs/UserEntry" + }, + "type": "array" + } + }, + "required": [ + "users" + ], + "type": "object" + } + ], + "unevaluatedProperties": false + }, + "UsrLocalCustomSnapshot": { + "allOf": [ + { + "$ref": "#/$defs/RoleCommon" + }, + { + "properties": { + "role_name": { + "const": "usr_local_custom" + } + }, + "type": "object" + } + ], + "unevaluatedProperties": false + } + }, + "$id": "https://enroll.sh/schema/state.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "enroll": { + "additionalProperties": false, + "properties": { + "harvest_time": { + "minimum": 0, + "type": "integer" + }, + "version": { + "type": "string" + } + }, + "required": [ + "version", + "harvest_time" + ], + "type": "object" + }, + "host": { + "additionalProperties": false, + "properties": { + "hostname": { + "minLength": 1, + "type": "string" + }, + "os": { + "enum": [ + "debian", + "redhat", + "unknown" + ], + "type": "string" + }, + "os_release": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "pkg_backend": { + "enum": [ + "dpkg", + "rpm" + ], + "type": "string" + } + }, + "required": [ + "hostname", + "os", + "pkg_backend", + "os_release" + ], + "type": "object" + }, + "inventory": { + "additionalProperties": false, + "properties": { + "packages": { + "additionalProperties": { + "$ref": "#/$defs/PackageInventoryEntry" + }, + "type": "object" + } + }, + "required": [ + "packages" + ], + "type": "object" + }, + "roles": { + "additionalProperties": false, + "properties": { + "apt_config": { + "$ref": "#/$defs/AptConfigSnapshot" + }, + "dnf_config": { + "$ref": "#/$defs/DnfConfigSnapshot" + }, + "etc_custom": { + "$ref": "#/$defs/EtcCustomSnapshot" + }, + "extra_paths": { + "$ref": "#/$defs/ExtraPathsSnapshot" + }, + "packages": { + "items": { + "$ref": "#/$defs/PackageSnapshot" + }, + "type": "array" + }, + "services": { + "items": { + "$ref": "#/$defs/ServiceSnapshot" + }, + "type": "array" + }, + "users": { + "$ref": "#/$defs/UsersSnapshot" + }, + "usr_local_custom": { + "$ref": "#/$defs/UsrLocalCustomSnapshot" + } + }, + "required": [ + "users", + "services", + "packages", + "apt_config", + "dnf_config", + "etc_custom", + "usr_local_custom", + "extra_paths" + ], + "type": "object" + } + }, + "required": [ + "enroll", + "host", + "inventory", + "roles" + ], + "title": "Enroll harvest state.json schema (latest)", + "type": "object" +} diff --git a/enroll/validate.py b/enroll/validate.py new file mode 100644 index 0000000..5a8fa88 --- /dev/null +++ b/enroll/validate.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import json +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +import jsonschema + +from .diff import BundleRef, _bundle_from_input + + +@dataclass +class ValidationResult: + errors: List[str] + warnings: List[str] + + @property + def ok(self) -> bool: + return not self.errors + + def to_dict(self) -> Dict[str, Any]: + return { + "ok": self.ok, + "errors": list(self.errors), + "warnings": list(self.warnings), + } + + def to_text(self) -> str: + lines: List[str] = [] + if not self.errors and not self.warnings: + lines.append("OK: harvest bundle validated") + elif not self.errors and self.warnings: + lines.append(f"WARN: {len(self.warnings)} warning(s)") + else: + lines.append(f"ERROR: {len(self.errors)} validation error(s)") + + if self.errors: + lines.append("") + lines.append("Errors:") + for e in self.errors: + lines.append(f"- {e}") + if self.warnings: + lines.append("") + lines.append("Warnings:") + for w in self.warnings: + lines.append(f"- {w}") + return "\n".join(lines) + "\n" + + +def _default_schema_path() -> Path: + # Keep the schema vendored with the codebase so enroll can validate offline. + return Path(__file__).resolve().parent / "schema" / "state.schema.json" + + +def _load_schema(schema: Optional[str]) -> Dict[str, Any]: + """Load a JSON schema. + + If schema is None, load the vendored schema. + If schema begins with http(s)://, fetch it. + Otherwise, treat it as a local file path. + """ + + if not schema: + p = _default_schema_path() + with open(p, "r", encoding="utf-8") as f: + return json.load(f) + + if schema.startswith("http://") or schema.startswith("https://"): + with urllib.request.urlopen(schema, timeout=10) as resp: # nosec + data = resp.read() + return json.loads(data.decode("utf-8")) + + p = Path(schema).expanduser() + with open(p, "r", encoding="utf-8") as f: + return json.load(f) + + +def _json_pointer(err: jsonschema.ValidationError) -> str: + # Build a JSON pointer-ish path that is easy to read. + if err.absolute_path: + parts = [str(p) for p in err.absolute_path] + return "/" + "/".join(parts) + return "/" + + +def _iter_managed_files(state: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """Return (role_name, managed_file_dict) tuples across all roles.""" + + roles = state.get("roles") or {} + out: List[Tuple[str, Dict[str, Any]]] = [] + + # Singleton roles + for rn in [ + "users", + "apt_config", + "dnf_config", + "etc_custom", + "usr_local_custom", + "extra_paths", + ]: + snap = roles.get(rn) or {} + for mf in snap.get("managed_files") or []: + if isinstance(mf, dict): + out.append((rn, mf)) + + # Array roles + for s in roles.get("services") or []: + if not isinstance(s, dict): + continue + role_name = str(s.get("role_name") or "unknown") + for mf in s.get("managed_files") or []: + if isinstance(mf, dict): + out.append((role_name, mf)) + + for p in roles.get("packages") or []: + if not isinstance(p, dict): + continue + role_name = str(p.get("role_name") or "unknown") + for mf in p.get("managed_files") or []: + if isinstance(mf, dict): + out.append((role_name, mf)) + + return out + + +def validate_harvest( + harvest_input: str, + *, + sops_mode: bool = False, + schema: Optional[str] = None, + no_schema: bool = False, +) -> ValidationResult: + """Validate an enroll harvest bundle. + + Checks: + - state.json parses + - state.json validates against the schema (unless no_schema) + - every managed_file src_rel exists in artifacts// + """ + + errors: List[str] = [] + warnings: List[str] = [] + + bundle: BundleRef = _bundle_from_input(harvest_input, sops_mode=sops_mode) + try: + state_path = bundle.state_path + if not state_path.exists(): + return ValidationResult( + errors=[f"missing state.json at {state_path}"], warnings=[] + ) + + try: + state = json.loads(state_path.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 + return ValidationResult( + errors=[f"failed to parse state.json: {e!r}"], warnings=[] + ) + + if not no_schema: + try: + sch = _load_schema(schema) + validator = jsonschema.Draft202012Validator(sch) + for err in sorted(validator.iter_errors(state), key=str): + ptr = _json_pointer(err) + msg = err.message + errors.append(f"schema {ptr}: {msg}") + except Exception as e: # noqa: BLE001 + errors.append(f"failed to load/validate schema: {e!r}") + + # Artifact existence checks + artifacts_dir = bundle.dir / "artifacts" + referenced: Set[Tuple[str, str]] = set() + for role_name, mf in _iter_managed_files(state): + src_rel = str(mf.get("src_rel") or "") + if not src_rel: + errors.append( + f"managed_file missing src_rel for role {role_name} (path={mf.get('path')!r})" + ) + continue + if src_rel.startswith("/") or ".." in src_rel.split("/"): + errors.append( + f"managed_file has suspicious src_rel for role {role_name}: {src_rel!r}" + ) + continue + + referenced.add((role_name, src_rel)) + p = artifacts_dir / role_name / src_rel + if not p.exists(): + errors.append( + f"missing artifact for role {role_name}: artifacts/{role_name}/{src_rel}" + ) + continue + if not p.is_file(): + errors.append( + f"artifact is not a file for role {role_name}: artifacts/{role_name}/{src_rel}" + ) + + # Warn if there are extra files in artifacts not referenced. + if artifacts_dir.exists() and artifacts_dir.is_dir(): + for fp in artifacts_dir.rglob("*"): + if not fp.is_file(): + continue + try: + rel = fp.relative_to(artifacts_dir) + except ValueError: + continue + parts = rel.parts + if len(parts) < 2: + continue + role_name = parts[0] + src_rel = "/".join(parts[1:]) + if (role_name, src_rel) not in referenced: + warnings.append( + f"unreferenced artifact present: artifacts/{role_name}/{src_rel}" + ) + + return ValidationResult(errors=errors, warnings=warnings) + finally: + # Ensure any temp extraction dirs are cleaned up. + if bundle.tempdir is not None: + bundle.tempdir.cleanup() diff --git a/poetry.lock b/poetry.lock index 0a90711..5bac734 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,16 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "attrs" +version = "25.4.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +files = [ + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, +] + [[package]] name = "bcrypt" version = "5.0.0" @@ -567,6 +578,41 @@ files = [ {file = "invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707"}, ] +[[package]] +name = "jsonschema" +version = "4.25.1" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"}, + {file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +files = [ + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + [[package]] name = "packaging" version = "25.0" @@ -820,6 +866,22 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "referencing" +version = "0.37.0" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + [[package]] name = "requests" version = "2.32.5" @@ -841,6 +903,130 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rpds-py" +version = "0.30.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.10" +files = [ + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, +] + [[package]] name = "tomli" version = "2.3.0" @@ -923,4 +1109,4 @@ zstd = ["backports-zstd (>=1.0.0)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "20623104a1a5f4c6d4aaa759f25b2591d5de345d1464e727eb4140a6ef9a5b6e" +content-hash = "77b45cc66c342b8b69af982d3d8566d7f83af7ca20ad7b3488bbf93db553e0be" diff --git a/pyproject.toml b/pyproject.toml index 7a6143a..9835ace 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,21 @@ [tool.poetry] name = "enroll" -version = "0.3.0" +version = "0.3.1" description = "Enroll a server's running state retrospectively into Ansible" authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" readme = "README.md" packages = [{ include = "enroll" }] repository = "https://git.mig5.net/mig5/enroll" +include = [ + { path = "enroll/schema/state.schema.json", format = ["sdist", "wheel"] } +] [tool.poetry.dependencies] python = "^3.10" pyyaml = "^6" paramiko = ">=3.5" +jsonschema = "^4.25.1" [tool.poetry.scripts] enroll = "enroll.cli:main" diff --git a/rpm/enroll.spec b/rpm/enroll.spec index eb55262..c35525a 100644 --- a/rpm/enroll.spec +++ b/rpm/enroll.spec @@ -17,6 +17,7 @@ BuildRequires: python3-poetry-core Requires: python3-yaml Requires: python3-paramiko +Requires: python3-jsonschema # Make sure private repo dependency is pulled in by package name as well. Recommends: jinjaturtle diff --git a/tests/test_ignore.py b/tests/test_ignore.py index 1202b8c..1eaae01 100644 --- a/tests/test_ignore.py +++ b/tests/test_ignore.py @@ -8,21 +8,3 @@ def test_ignore_policy_denies_common_backup_files(): assert pol.deny_reason("/etc/group-") == "backup_file" assert pol.deny_reason("/etc/something~") == "backup_file" assert pol.deny_reason("/foobar") == "unreadable" - - -def test_ignore_policy_deny_reason_link(tmp_path): - pol = IgnorePolicy() - - target = tmp_path / "target.txt" - target.write_text("hello", encoding="utf-8") - link = tmp_path / "link.txt" - link.symlink_to(target) - - # File is not a symlink. - assert pol.deny_reason_link(str(target)) == "not_symlink" - - # Symlink is accepted if readable. - assert pol.deny_reason_link(str(link)) is None - - # Missing path should be unreadable. - assert pol.deny_reason_link(str(tmp_path / "missing")) == "unreadable" diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 0000000..4f7977e --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +import enroll.cli as cli +from enroll.validate import validate_harvest + + +def _base_state() -> dict: + return { + "enroll": {"version": "0.0.test", "harvest_time": 0}, + "host": { + "hostname": "testhost", + "os": "unknown", + "pkg_backend": "dpkg", + "os_release": {}, + }, + "inventory": {"packages": {}}, + "roles": { + "users": { + "role_name": "users", + "users": [], + "managed_dirs": [], + "managed_files": [], + "excluded": [], + "notes": [], + }, + "services": [], + "packages": [], + "apt_config": { + "role_name": "apt_config", + "managed_dirs": [], + "managed_files": [], + "excluded": [], + "notes": [], + }, + "dnf_config": { + "role_name": "dnf_config", + "managed_dirs": [], + "managed_files": [], + "excluded": [], + "notes": [], + }, + "etc_custom": { + "role_name": "etc_custom", + "managed_dirs": [], + "managed_files": [], + "excluded": [], + "notes": [], + }, + "usr_local_custom": { + "role_name": "usr_local_custom", + "managed_dirs": [], + "managed_files": [], + "excluded": [], + "notes": [], + }, + "extra_paths": { + "role_name": "extra_paths", + "include_patterns": [], + "exclude_patterns": [], + "managed_dirs": [], + "managed_files": [], + "managed_links": [], + "excluded": [], + "notes": [], + }, + }, + } + + +def _write_bundle(tmp_path: Path, state: dict) -> Path: + bundle = tmp_path / "bundle" + bundle.mkdir(parents=True) + (bundle / "artifacts").mkdir() + (bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8") + return bundle + + +def test_validate_ok_bundle(tmp_path: Path): + state = _base_state() + state["roles"]["etc_custom"]["managed_files"].append( + { + "path": "/etc/hosts", + "src_rel": "etc/hosts", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "custom_specific_path", + } + ) + + bundle = _write_bundle(tmp_path, state) + art = bundle / "artifacts" / "etc_custom" / "etc" / "hosts" + art.parent.mkdir(parents=True, exist_ok=True) + art.write_text("127.0.0.1 localhost\n", encoding="utf-8") + + res = validate_harvest(str(bundle)) + assert res.ok + assert res.errors == [] + + +def test_validate_missing_artifact_is_error(tmp_path: Path): + state = _base_state() + state["roles"]["etc_custom"]["managed_files"].append( + { + "path": "/etc/hosts", + "src_rel": "etc/hosts", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "custom_specific_path", + } + ) + bundle = _write_bundle(tmp_path, state) + res = validate_harvest(str(bundle)) + assert not res.ok + assert any("missing artifact" in e for e in res.errors) + + +def test_validate_schema_error_is_reported(tmp_path: Path): + state = _base_state() + state["host"]["os"] = "not_a_real_os" + bundle = _write_bundle(tmp_path, state) + res = validate_harvest(str(bundle)) + assert not res.ok + assert any(e.startswith("schema /host/os") for e in res.errors) + + +def test_cli_validate_exits_2_on_validation_error(monkeypatch, tmp_path: Path): + state = _base_state() + state["roles"]["etc_custom"]["managed_files"].append( + { + "path": "/etc/hosts", + "src_rel": "etc/hosts", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "custom_specific_path", + } + ) + bundle = _write_bundle(tmp_path, state) + + monkeypatch.setattr(sys, "argv", ["enroll", "validate", str(bundle)]) + with pytest.raises(SystemExit) as e: + cli.main() + assert e.value.code == 2