Implement reproducible S1 handoff contracts
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
This commit is contained in:
parent
c8cb1c8edf
commit
b93af8cc78
44 changed files with 2035 additions and 342 deletions
240
scripts/baseline_contract.py
Normal file
240
scripts/baseline_contract.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate and resolve the executable S1 host-baseline contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
SCHEMA_VERSION = "2.0"
|
||||
PROFILE_MODES = {"ufw", "external"}
|
||||
REQUIRED_PACKAGES = {"curl", "git", "vim", "htop", "ufw", "fail2ban"}
|
||||
REQUIRED_SERVICES = {"fail2ban", "ssh.socket"}
|
||||
REQUIRED_SSH = {
|
||||
"PasswordAuthentication": "no",
|
||||
"PermitRootLogin": "no",
|
||||
"PubkeyAuthentication": "yes",
|
||||
"ChallengeResponseAuthentication": "no",
|
||||
}
|
||||
CONSUMER_MARKERS = {
|
||||
"ansible/roles/base/tasks/main.yml": [
|
||||
"baseline_required_packages",
|
||||
"baseline_ssh_directives",
|
||||
"baseline_user",
|
||||
"baseline_security",
|
||||
"baseline_firewall",
|
||||
],
|
||||
"goss/baseline.yaml.j2": [
|
||||
"baseline_required_packages",
|
||||
"baseline_required_services",
|
||||
"baseline_ssh_directives",
|
||||
"baseline_user",
|
||||
"baseline_security",
|
||||
"baseline_firewall",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class BaselineError(ValueError):
|
||||
"""The baseline model or its repository consumers are inconsistent."""
|
||||
|
||||
|
||||
def _strings(
|
||||
value: Any, label: str, errors: list[str], *, allow_empty: bool = False
|
||||
) -> list[str]:
|
||||
if not isinstance(value, list) or (not value and not allow_empty) or not all(
|
||||
isinstance(item, str) and item.strip() for item in value
|
||||
):
|
||||
errors.append(f"{label} must be a non-empty string list")
|
||||
return []
|
||||
if len(set(value)) != len(value):
|
||||
errors.append(f"{label} contains duplicates")
|
||||
return value
|
||||
|
||||
|
||||
def validate_spec(payload: Any) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise BaselineError("baseline must be a YAML object")
|
||||
errors: list[str] = []
|
||||
if str(payload.get("version")) != SCHEMA_VERSION:
|
||||
errors.append(f"version must be {SCHEMA_VERSION!r}")
|
||||
defaults = payload.get("defaults")
|
||||
profiles = payload.get("profiles")
|
||||
if not isinstance(defaults, dict):
|
||||
errors.append("defaults must be an object")
|
||||
defaults = {}
|
||||
if not isinstance(profiles, dict) or not profiles:
|
||||
errors.append("profiles must be a non-empty object")
|
||||
profiles = {}
|
||||
|
||||
packages = _strings(defaults.get("packages"), "defaults.packages", errors)
|
||||
services = _strings(defaults.get("services"), "defaults.services", errors)
|
||||
missing_packages = sorted(REQUIRED_PACKAGES - set(packages))
|
||||
missing_services = sorted(REQUIRED_SERVICES - set(services))
|
||||
if missing_packages:
|
||||
errors.append(f"defaults.packages omits governed packages {', '.join(missing_packages)}")
|
||||
if missing_services:
|
||||
errors.append(f"defaults.services omits governed services {', '.join(missing_services)}")
|
||||
ssh = defaults.get("ssh_directives")
|
||||
if not isinstance(ssh, dict) or not ssh or not all(
|
||||
isinstance(key, str) and isinstance(value, str) for key, value in (ssh or {}).items()
|
||||
):
|
||||
errors.append("defaults.ssh_directives must be a non-empty string map")
|
||||
elif {key: ssh.get(key) for key in REQUIRED_SSH} != REQUIRED_SSH:
|
||||
errors.append("defaults.ssh_directives weakens a governed SSH directive")
|
||||
user = defaults.get("user")
|
||||
if not isinstance(user, dict):
|
||||
errors.append("defaults.user must be an object")
|
||||
else:
|
||||
for key in ("name", "shell", "sudo"):
|
||||
if not isinstance(user.get(key), str) or not user[key]:
|
||||
errors.append(f"defaults.user.{key} must be a non-empty string")
|
||||
if user.get("sudo") != "NOPASSWD":
|
||||
errors.append("defaults.user.sudo must be NOPASSWD")
|
||||
security = defaults.get("security")
|
||||
if not isinstance(security, dict):
|
||||
errors.append("defaults.security must be an object")
|
||||
else:
|
||||
if not isinstance(security.get("histcontrol"), str):
|
||||
errors.append("defaults.security.histcontrol must be a string")
|
||||
_strings(
|
||||
security.get("fail2ban_jails"),
|
||||
"defaults.security.fail2ban_jails",
|
||||
errors,
|
||||
)
|
||||
|
||||
for name, profile in profiles.items():
|
||||
label = f"profiles.{name}"
|
||||
if not isinstance(profile, dict):
|
||||
errors.append(f"{label} must be an object")
|
||||
continue
|
||||
_strings(
|
||||
profile.get("services", []),
|
||||
f"{label}.services",
|
||||
errors,
|
||||
allow_empty=True,
|
||||
)
|
||||
firewall = profile.get("firewall")
|
||||
if not isinstance(firewall, dict):
|
||||
errors.append(f"{label}.firewall must be an object")
|
||||
continue
|
||||
mode = firewall.get("mode")
|
||||
if mode not in PROFILE_MODES:
|
||||
errors.append(f"{label}.firewall.mode must be one of {sorted(PROFILE_MODES)}")
|
||||
expected_managed = mode == "ufw"
|
||||
if firewall.get("managed") is not expected_managed:
|
||||
errors.append(f"{label}.firewall.managed must be {expected_managed}")
|
||||
verification = firewall.get("verification")
|
||||
if not isinstance(verification, dict):
|
||||
errors.append(f"{label}.firewall.verification must be an object")
|
||||
else:
|
||||
if not isinstance(verification.get("command"), str):
|
||||
errors.append(f"{label}.firewall.verification.command must be a string")
|
||||
_strings(
|
||||
verification.get("stdout"),
|
||||
f"{label}.firewall.verification.stdout",
|
||||
errors,
|
||||
)
|
||||
if mode == "external":
|
||||
replacement = firewall.get("replacement_control")
|
||||
if not isinstance(replacement, dict):
|
||||
errors.append(f"{label}.firewall.replacement_control must be an object")
|
||||
else:
|
||||
for key in ("description", "owner", "removal_condition"):
|
||||
if not isinstance(replacement.get(key), str) or not replacement[key]:
|
||||
errors.append(
|
||||
f"{label}.firewall.replacement_control.{key} must be a string"
|
||||
)
|
||||
|
||||
for required_profile, required_mode in {
|
||||
"ufw-managed": "ufw",
|
||||
"external-firewall": "external",
|
||||
}.items():
|
||||
if profiles.get(required_profile, {}).get("firewall", {}).get("mode") != required_mode:
|
||||
errors.append(
|
||||
f"profiles.{required_profile} must declare firewall mode {required_mode}"
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise BaselineError("baseline contract failed:\n- " + "\n- ".join(errors))
|
||||
return payload
|
||||
|
||||
|
||||
def load_spec(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise BaselineError(f"cannot read {path}: {exc}") from exc
|
||||
return validate_spec(payload)
|
||||
|
||||
|
||||
def profile_hostvars(payload: dict[str, Any], profile_name: str) -> dict[str, Any]:
|
||||
validate_spec(payload)
|
||||
try:
|
||||
profile = payload["profiles"][profile_name]
|
||||
except KeyError as exc:
|
||||
raise BaselineError(f"unknown baseline profile {profile_name!r}") from exc
|
||||
defaults = payload["defaults"]
|
||||
services = list(dict.fromkeys(defaults["services"] + profile["services"]))
|
||||
return {
|
||||
"baseline_profile": profile_name,
|
||||
"baseline_required_packages": defaults["packages"],
|
||||
"baseline_required_services": services,
|
||||
"baseline_ssh_directives": defaults["ssh_directives"],
|
||||
"baseline_user": defaults["user"],
|
||||
"baseline_security": defaults["security"],
|
||||
"baseline_firewall": profile["firewall"],
|
||||
"ufw_manage": profile["firewall"]["managed"],
|
||||
}
|
||||
|
||||
|
||||
def validate_repo_consumers(root: Path) -> None:
|
||||
errors: list[str] = []
|
||||
for relative, markers in CONSUMER_MARKERS.items():
|
||||
path = root / relative
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
errors.append(f"cannot read {relative}: {exc}")
|
||||
continue
|
||||
missing = [marker for marker in markers if marker not in text]
|
||||
if missing:
|
||||
errors.append(f"{relative} does not consume {', '.join(missing)}")
|
||||
if errors:
|
||||
raise BaselineError("baseline consumer parity failed:\n- " + "\n- ".join(errors))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"spec", nargs="?", type=Path, default=Path("spec/server-baseline.yaml")
|
||||
)
|
||||
parser.add_argument("--profile", help="resolve one profile as Ansible hostvars")
|
||||
parser.add_argument("--check-repo", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
payload = load_spec(args.spec)
|
||||
if args.check_repo:
|
||||
validate_repo_consumers(Path(__file__).resolve().parents[1])
|
||||
result: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"version": payload["version"],
|
||||
"profiles": sorted(payload["profiles"]),
|
||||
}
|
||||
if args.profile:
|
||||
result["hostvars"] = profile_hostvars(payload, args.profile)
|
||||
except BaselineError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
87
scripts/check_secret_paths.py
Normal file
87
scripts/check_secret_paths.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail when a declared secret-bearing Git path is not SOPS/age encrypted."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def is_protected_path(path: str) -> bool:
|
||||
normalized = path.strip("/")
|
||||
name = Path(normalized).name.lower()
|
||||
return normalized.startswith("secrets/") or (
|
||||
normalized.startswith("inventory/")
|
||||
and name.startswith("secrets")
|
||||
and name.endswith((".yaml", ".yml", ".json"))
|
||||
)
|
||||
|
||||
|
||||
def is_encrypted_content(path: str, content: str) -> bool:
|
||||
if path.endswith((".age", ".gpg")):
|
||||
return bool(content)
|
||||
return any(
|
||||
line.strip() == "sops:" or line.lstrip().startswith('"sops"')
|
||||
for line in content.splitlines()
|
||||
)
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
return subprocess.check_output(["git", *args], cwd=ROOT, text=True)
|
||||
|
||||
|
||||
def staged_files() -> list[str]:
|
||||
return [
|
||||
path
|
||||
for path in _git("diff", "--cached", "--name-only", "--diff-filter=ACMR").splitlines()
|
||||
if is_protected_path(path)
|
||||
]
|
||||
|
||||
|
||||
def tracked_files() -> list[str]:
|
||||
return [
|
||||
path
|
||||
for path in _git("ls-files").splitlines()
|
||||
if is_protected_path(path) and (ROOT / path).is_file()
|
||||
]
|
||||
|
||||
|
||||
def validate_paths(paths: list[str], *, staged: bool) -> list[str]:
|
||||
failures = []
|
||||
for relative in sorted(set(paths)):
|
||||
try:
|
||||
content = (
|
||||
_git("show", f":{relative}")
|
||||
if staged
|
||||
else (ROOT / relative).read_text(encoding="utf-8")
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
failures.append(f"{relative}: cannot read protected content")
|
||||
continue
|
||||
if not is_encrypted_content(relative, content):
|
||||
failures.append(f"{relative}: plaintext or missing SOPS metadata")
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--staged", action="store_true")
|
||||
mode.add_argument("--tracked", action="store_true")
|
||||
args = parser.parse_args()
|
||||
paths = staged_files() if args.staged else tracked_files()
|
||||
failures = validate_paths(paths, staged=args.staged)
|
||||
if failures:
|
||||
print("Unencrypted secret-bearing paths:\n- " + "\n- ".join(failures), file=sys.stderr)
|
||||
return 1
|
||||
print(f"secret path check passed ({len(paths)} protected file(s))")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# hcloud_new_server.sh — Add a host to inventory and provision it on Hetzner
|
||||
# Usage:
|
||||
# scripts/hcloud_new_server.sh <NAME> [--type cpx11] [--region nbg1] [--role web] [--image ubuntu-24.04] [--user admin]
|
||||
# scripts/hcloud_new_server.sh <NAME> [options] [--apply]
|
||||
#
|
||||
# Prereqs:
|
||||
# - age + SOPS installed, with access to decrypt your Hetzner token
|
||||
|
|
@ -23,6 +23,7 @@ REGION="nbg1"
|
|||
ROLE="generic"
|
||||
IMAGE="ubuntu-24.04"
|
||||
USER="admin"
|
||||
APPLY=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
|
|
@ -31,6 +32,7 @@ while [[ $# -gt 0 ]]; do
|
|||
--role) ROLE="$2"; shift 2;;
|
||||
--image) IMAGE="$2"; shift 2;;
|
||||
--user) USER="$2"; shift 2;;
|
||||
--apply) APPLY=true; shift;;
|
||||
*) fail "Unknown arg: $1 (usage: scripts/hcloud_new_server.sh <NAME> [--type cpx11] [--region nbg1] [--role web] [--image ubuntu-24.04] [--user admin])";;
|
||||
esac
|
||||
done
|
||||
|
|
@ -58,18 +60,27 @@ python3 scripts/new_host.py \
|
|||
--region "$REGION" \
|
||||
--role "$ROLE" \
|
||||
--image "$IMAGE" \
|
||||
--user "$USER"
|
||||
--user "$USER" \
|
||||
--reuse-existing
|
||||
|
||||
ok "Inventory updated: $NAME → inventory/servers.yaml"
|
||||
|
||||
# --- Decrypt Hetzner token and apply Terraform ---
|
||||
HCLOUD_TOKEN="$(sops -d --extract '["hetzner"]["token"]' secrets/hetzner-token.sops.yaml 2>/dev/null)"
|
||||
[[ -n "$HCLOUD_TOKEN" ]] || fail "Could not decrypt ops.hcloud_token from secrets/hetzner-token.sops.yaml. Ensure SOPS_AGE_KEY or keys.txt is set and token exists."
|
||||
HCLOUD_TOKEN="$(sops -d --extract '["hetzner"]["token"]' secrets/hetzner-token.yaml 2>/dev/null)"
|
||||
[[ -n "$HCLOUD_TOKEN" ]] || fail "Could not decrypt hetzner.token from secrets/hetzner-token.yaml. Ensure SOPS_AGE_KEY or keys.txt is set."
|
||||
|
||||
pushd terraform/hetzner >/dev/null
|
||||
|
||||
terraform init -upgrade
|
||||
export HCLOUD_TOKEN
|
||||
export TF_VAR_hcloud_token="$HCLOUD_TOKEN"
|
||||
terraform plan
|
||||
|
||||
if [[ "$APPLY" != true ]]; then
|
||||
info "Plan complete; no provider mutation performed. Re-run with --apply only after review and approval."
|
||||
popd >/dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
terraform apply -auto-approve
|
||||
|
||||
# Try to show IP of the created host
|
||||
|
|
|
|||
174
scripts/inventory_contract.py
Normal file
174
scripts/inventory_contract.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate the S1 host inventory without contacting a provider or host."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
PROVIDERS = {"hosteurope", "hetzner"}
|
||||
LIFECYCLE_MODES = {"adopted", "provider-managed"}
|
||||
BASELINE_PROFILES = {"ufw-managed", "external-firewall"}
|
||||
HETZNER_REQUIRED = {"server_type", "region", "image", "role"}
|
||||
HETZNER_OPTIONAL = {"labels"}
|
||||
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$")
|
||||
USER_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
|
||||
|
||||
|
||||
class InventoryError(ValueError):
|
||||
"""The inventory does not satisfy the S1 contract."""
|
||||
|
||||
|
||||
def _nonempty_string(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(value.strip())
|
||||
|
||||
|
||||
def validate_inventory(payload: Any) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise InventoryError("inventory must be a YAML object")
|
||||
if str(payload.get("schema_version")) != SCHEMA_VERSION:
|
||||
raise InventoryError(f"schema_version must be {SCHEMA_VERSION!r}")
|
||||
servers = payload.get("servers")
|
||||
if not isinstance(servers, list) or not servers:
|
||||
raise InventoryError("servers must be a non-empty list")
|
||||
|
||||
errors: list[str] = []
|
||||
names: set[str] = set()
|
||||
for index, server in enumerate(servers):
|
||||
label = f"servers[{index}]"
|
||||
if not isinstance(server, dict):
|
||||
errors.append(f"{label} must be an object")
|
||||
continue
|
||||
name = server.get("name")
|
||||
if not _nonempty_string(name) or not NAME_RE.fullmatch(name):
|
||||
errors.append(f"{label}.name must be a stable host identifier")
|
||||
name = label
|
||||
elif name in names:
|
||||
errors.append(f"{label}.name duplicates {name!r}")
|
||||
else:
|
||||
names.add(name)
|
||||
label = str(name)
|
||||
|
||||
provider = server.get("provider")
|
||||
lifecycle = server.get("lifecycle_mode")
|
||||
profile = server.get("baseline_profile")
|
||||
ssh_user = server.get("ssh_user")
|
||||
if provider not in PROVIDERS:
|
||||
errors.append(f"{label}: provider must be one of {sorted(PROVIDERS)}")
|
||||
if lifecycle not in LIFECYCLE_MODES:
|
||||
errors.append(
|
||||
f"{label}: lifecycle_mode must be one of {sorted(LIFECYCLE_MODES)}"
|
||||
)
|
||||
if profile not in BASELINE_PROFILES:
|
||||
errors.append(
|
||||
f"{label}: baseline_profile must be one of {sorted(BASELINE_PROFILES)}"
|
||||
)
|
||||
if not _nonempty_string(ssh_user) or not USER_RE.fullmatch(ssh_user):
|
||||
errors.append(f"{label}: ssh_user must be a valid Unix user name")
|
||||
|
||||
if lifecycle == "adopted":
|
||||
address = server.get("ip")
|
||||
try:
|
||||
ipaddress.ip_address(address)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"{label}: adopted hosts require a literal ip address")
|
||||
if "provisioning" in server:
|
||||
errors.append(
|
||||
f"{label}: adopted hosts must not carry provider provisioning fields"
|
||||
)
|
||||
elif lifecycle == "provider-managed":
|
||||
if provider != "hetzner":
|
||||
errors.append(
|
||||
f"{label}: provider-managed is currently implemented only for hetzner"
|
||||
)
|
||||
if "ip" in server:
|
||||
errors.append(
|
||||
f"{label}: provider-managed addresses come from provider output; remove ip"
|
||||
)
|
||||
provisioning = server.get("provisioning")
|
||||
if not isinstance(provisioning, dict):
|
||||
errors.append(f"{label}: provider-managed hosts require provisioning")
|
||||
else:
|
||||
missing = sorted(
|
||||
key
|
||||
for key in HETZNER_REQUIRED
|
||||
if not _nonempty_string(provisioning.get(key))
|
||||
)
|
||||
unknown = sorted(
|
||||
set(provisioning) - HETZNER_REQUIRED - HETZNER_OPTIONAL
|
||||
)
|
||||
if missing:
|
||||
errors.append(
|
||||
f"{label}: provisioning missing {', '.join(missing)}"
|
||||
)
|
||||
if unknown:
|
||||
errors.append(
|
||||
f"{label}: provisioning has unknown fields {', '.join(unknown)}"
|
||||
)
|
||||
labels = provisioning.get("labels", [])
|
||||
if not isinstance(labels, list) or not all(
|
||||
_nonempty_string(item) for item in labels
|
||||
):
|
||||
errors.append(
|
||||
f"{label}: provisioning.labels must be a list of strings"
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise InventoryError("inventory contract failed:\n- " + "\n- ".join(errors))
|
||||
return payload
|
||||
|
||||
|
||||
def load_inventory(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise InventoryError(f"cannot read {path}: {exc}") from exc
|
||||
return validate_inventory(payload)
|
||||
|
||||
|
||||
def managed_hetzner_servers(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
validate_inventory(payload)
|
||||
return [
|
||||
server
|
||||
for server in payload["servers"]
|
||||
if server["provider"] == "hetzner"
|
||||
and server["lifecycle_mode"] == "provider-managed"
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"inventory", nargs="?", type=Path, default=Path("inventory/servers.yaml")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--managed-hetzner", action="store_true", help="print selected names"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
payload = load_inventory(args.inventory)
|
||||
except InventoryError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
selected = managed_hetzner_servers(payload)
|
||||
result = {
|
||||
"ok": True,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"server_count": len(payload["servers"]),
|
||||
"managed_hetzner": [server["name"] for server in selected],
|
||||
}
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -5,7 +5,8 @@ if [[ -z "$NAME" ]]; then
|
|||
echo "Usage: scripts/new-server.sh <name>"
|
||||
exit 1
|
||||
fi
|
||||
yq -i '.servers += [{ "name": "'$NAME'", "labels": [], "role": "generic", "region": "nbg1", "type": "cpx21", "image": "ubuntu-24.04", "ssh_user": "admin"}]' inventory/servers.yaml
|
||||
python3 scripts/new_host.py --name "$NAME" --type cpx21 --region nbg1 \
|
||||
--role generic --image ubuntu-24.04 --user admin
|
||||
git add inventory/servers.yaml
|
||||
git commit -m "Add server ${NAME}"
|
||||
echo "Added ${NAME}. Run: make apply"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ def main():
|
|||
p.add_argument("--role", default="test")
|
||||
p.add_argument("--image", default="ubuntu-24.04")
|
||||
p.add_argument("--user", default="admin")
|
||||
p.add_argument(
|
||||
"--reuse-existing",
|
||||
action="store_true",
|
||||
help="succeed only when an existing record exactly matches the request",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
inv_path = os.path.join("inventory", "servers.yaml")
|
||||
|
|
@ -23,20 +28,33 @@ def main():
|
|||
data = yaml.safe_load(f) or {}
|
||||
servers = data.setdefault("servers", [])
|
||||
|
||||
# Prevent duplicates
|
||||
if any(s.get("name") == args.name for s in servers):
|
||||
print(f"ERROR: host '{args.name}' already exists in {inv_path}", file=sys.stderr)
|
||||
candidate = {
|
||||
"name": args.name,
|
||||
"provider": "hetzner",
|
||||
"lifecycle_mode": "provider-managed",
|
||||
"ssh_user": args.user,
|
||||
"baseline_profile": "ufw-managed",
|
||||
"provisioning": {
|
||||
"server_type": args.type,
|
||||
"region": args.region,
|
||||
"role": args.role,
|
||||
"image": args.image,
|
||||
"labels": [],
|
||||
},
|
||||
}
|
||||
existing = next((s for s in servers if s.get("name") == args.name), None)
|
||||
if existing is not None:
|
||||
if args.reuse_existing and existing == candidate:
|
||||
print(f"Reusing matching host '{args.name}' from {inv_path}")
|
||||
return
|
||||
print(
|
||||
f"ERROR: host '{args.name}' already exists with a different declaration",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
servers.append({
|
||||
"name": args.name,
|
||||
"labels": [],
|
||||
"role": args.role,
|
||||
"region": args.region,
|
||||
"type": args.type,
|
||||
"image": args.image,
|
||||
"ssh_user": args.user,
|
||||
})
|
||||
data.setdefault("schema_version", "1.0")
|
||||
servers.append(candidate)
|
||||
|
||||
with open(inv_path, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False)
|
||||
|
|
|
|||
163
scripts/s1_handoff.py
Normal file
163
scripts/s1_handoff.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run the fail-closed S1 verification gate and emit a metadata-only receipt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from baseline_contract import load_spec, profile_hostvars, validate_repo_consumers
|
||||
from inventory_contract import load_inventory
|
||||
from s1_receipt import validate_receipt
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
|
||||
|
||||
|
||||
def _timestamp(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _selected_hosts(inventory: dict[str, Any], requested: list[str]) -> list[dict[str, Any]]:
|
||||
hosts = inventory["servers"]
|
||||
if not requested:
|
||||
return hosts
|
||||
known = {host["name"]: host for host in hosts}
|
||||
missing = sorted(set(requested) - set(known))
|
||||
if missing:
|
||||
raise ValueError(f"unknown hosts: {', '.join(missing)}")
|
||||
return [known[name] for name in requested]
|
||||
|
||||
|
||||
def build_receipt(
|
||||
*,
|
||||
revision: str,
|
||||
inventory_digest: str,
|
||||
hosts: list[dict[str, Any]],
|
||||
results: dict[str, int] | None,
|
||||
observed_at: datetime,
|
||||
freshness_hours: int,
|
||||
evidence: list[dict[str, str]],
|
||||
) -> dict[str, Any]:
|
||||
ran = results is not None
|
||||
passed = ran and all(results[host["name"]] == 0 for host in hosts)
|
||||
receipt: dict[str, Any] = {
|
||||
"schema_version": "1.0",
|
||||
"receipt_id": str(uuid.uuid4()),
|
||||
"event_type": "verification",
|
||||
"synthetic": False,
|
||||
"created_at": _timestamp(datetime.now(timezone.utc)),
|
||||
"source_revision": revision,
|
||||
"inventory_sha256": inventory_digest,
|
||||
"status": "pass" if passed else ("fail" if ran else "not-run"),
|
||||
"hosts": [host["name"] for host in hosts],
|
||||
"profiles": {host["name"]: host["baseline_profile"] for host in hosts},
|
||||
"observed_at": _timestamp(observed_at),
|
||||
"fresh_until": _timestamp(observed_at + timedelta(hours=freshness_hours)),
|
||||
"evidence": evidence,
|
||||
}
|
||||
if ran:
|
||||
receipt["host_exit_status"] = results
|
||||
validate_receipt(receipt)
|
||||
return receipt
|
||||
|
||||
|
||||
def _new_reports(started: float, host_names: list[str]) -> list[dict[str, str]]:
|
||||
evidence = []
|
||||
for path in sorted((ROOT / "reports").glob("goss-*.tap")):
|
||||
if path.stat().st_mtime < started:
|
||||
continue
|
||||
if not any(f"goss-{name}-" in path.name for name in host_names):
|
||||
continue
|
||||
evidence.append(
|
||||
{
|
||||
"kind": "goss-tap",
|
||||
"path": str(path.relative_to(ROOT)),
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
)
|
||||
return evidence
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", action="append", default=[])
|
||||
parser.add_argument("--freshness-hours", type=int, default=24)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
if not 1 <= args.freshness_hours <= 168:
|
||||
parser.error("--freshness-hours must be between 1 and 168")
|
||||
|
||||
inventory_path = ROOT / "inventory" / "servers.yaml"
|
||||
spec_path = ROOT / "spec" / "server-baseline.yaml"
|
||||
inventory = load_inventory(inventory_path)
|
||||
baseline = load_spec(spec_path)
|
||||
validate_repo_consumers(ROOT)
|
||||
hosts = _selected_hosts(inventory, args.host)
|
||||
for host in hosts:
|
||||
profile_hostvars(baseline, host["baseline_profile"])
|
||||
|
||||
revision = _git("rev-parse", "HEAD")
|
||||
dirty = _git("status", "--porcelain")
|
||||
if dirty and not args.dry_run:
|
||||
print("handoff gate requires a clean checkout so the receipt pins all inputs", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
observed = datetime.now(timezone.utc)
|
||||
results: dict[str, int] | None = None
|
||||
evidence: list[dict[str, str]] = []
|
||||
if not args.dry_run:
|
||||
if shutil.which("ansible-playbook") is None:
|
||||
print("handoff gate requires ansible-playbook", file=sys.stderr)
|
||||
return 2
|
||||
started = datetime.now(timezone.utc).timestamp()
|
||||
results = {}
|
||||
for host in hosts:
|
||||
completed = subprocess.run(
|
||||
["ansible-playbook", "playbooks/verify.yaml", "--limit", host["name"]],
|
||||
cwd=ROOT / "ansible",
|
||||
check=False,
|
||||
)
|
||||
results[host["name"]] = completed.returncode
|
||||
evidence = _new_reports(started, [host["name"] for host in hosts])
|
||||
|
||||
receipt = build_receipt(
|
||||
revision=revision,
|
||||
inventory_digest=_sha256(inventory_path),
|
||||
hosts=hosts,
|
||||
results=results,
|
||||
observed_at=observed,
|
||||
freshness_hours=args.freshness_hours,
|
||||
evidence=evidence,
|
||||
)
|
||||
output = args.output
|
||||
if output is None:
|
||||
stamp = observed.strftime("%Y%m%dT%H%M%SZ")
|
||||
output = ROOT / "reports" / f"s1-handoff-{stamp}.json"
|
||||
elif not output.is_absolute():
|
||||
output = ROOT / output
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"receipt": str(output), "status": receipt["status"]}, sort_keys=True))
|
||||
return 0 if receipt["status"] in {"pass", "not-run"} else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
190
scripts/s1_receipt.py
Normal file
190
scripts/s1_receipt.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate Railiance S1 receipts and reject secret-shaped content."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
EVENT_TYPES = {
|
||||
"plan",
|
||||
"apply",
|
||||
"convergence",
|
||||
"verification",
|
||||
"rotation",
|
||||
"provisioning-chain",
|
||||
}
|
||||
STATUSES = {"pass", "fail", "not-run"}
|
||||
DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
REVISION_RE = re.compile(r"^[0-9a-f]{7,40}$")
|
||||
FORBIDDEN_KEYS = {
|
||||
"api_key",
|
||||
"credential",
|
||||
"credentials",
|
||||
"password",
|
||||
"private_key",
|
||||
"secret",
|
||||
"secret_value",
|
||||
"token",
|
||||
"tokens",
|
||||
}
|
||||
FORBIDDEN_VALUE_PATTERNS = (
|
||||
re.compile(r"-----BEGIN (?:OPENSSH|RSA|EC|AGE) PRIVATE KEY-----"),
|
||||
re.compile(r"\bhc_[A-Za-z0-9_-]{16,}\b"),
|
||||
)
|
||||
|
||||
|
||||
class ReceiptError(ValueError):
|
||||
"""A receipt is incomplete, ambiguous, or unsafe to retain."""
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _parse_time(value: Any, label: str) -> datetime:
|
||||
if not isinstance(value, str):
|
||||
raise ReceiptError(f"{label} must be an RFC3339 string")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ReceiptError(f"{label} must be RFC3339") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise ReceiptError(f"{label} must include a timezone")
|
||||
return parsed
|
||||
|
||||
|
||||
def _scan_safe(value: Any, path: str = "$") -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
normalized = str(key).lower().replace("-", "_")
|
||||
if normalized in FORBIDDEN_KEYS or normalized.endswith(
|
||||
("_api_key", "_credential", "_password", "_private_key", "_secret", "_token")
|
||||
):
|
||||
raise ReceiptError(f"forbidden secret-shaped key at {path}.{key}")
|
||||
_scan_safe(child, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
_scan_safe(child, f"{path}[{index}]")
|
||||
elif isinstance(value, str):
|
||||
for pattern in FORBIDDEN_VALUE_PATTERNS:
|
||||
if pattern.search(value):
|
||||
raise ReceiptError(f"forbidden credential-shaped value at {path}")
|
||||
|
||||
|
||||
def _require_digest(payload: dict[str, Any], key: str) -> None:
|
||||
if not DIGEST_RE.fullmatch(str(payload.get(key, ""))):
|
||||
raise ReceiptError(f"{key} must be a lowercase sha256 digest")
|
||||
|
||||
|
||||
def _validate_hosts(payload: dict[str, Any]) -> None:
|
||||
hosts = payload.get("hosts")
|
||||
profiles = payload.get("profiles")
|
||||
if not isinstance(hosts, list) or not hosts or not all(
|
||||
isinstance(host, str) and host for host in hosts
|
||||
):
|
||||
raise ReceiptError("passing host receipts require hosts")
|
||||
if len(set(hosts)) != len(hosts):
|
||||
raise ReceiptError("hosts must be unique")
|
||||
if not isinstance(profiles, dict) or set(profiles) != set(hosts):
|
||||
raise ReceiptError("profiles must map every and only listed host")
|
||||
|
||||
|
||||
def validate_receipt(payload: Any) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise ReceiptError("receipt must be a JSON object")
|
||||
_scan_safe(payload)
|
||||
if payload.get("schema_version") != SCHEMA_VERSION:
|
||||
raise ReceiptError(f"schema_version must be {SCHEMA_VERSION}")
|
||||
try:
|
||||
uuid.UUID(str(payload.get("receipt_id")))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise ReceiptError("receipt_id must be a UUID") from exc
|
||||
event_type = payload.get("event_type")
|
||||
status = payload.get("status")
|
||||
if event_type not in EVENT_TYPES:
|
||||
raise ReceiptError(f"event_type must be one of {sorted(EVENT_TYPES)}")
|
||||
if status not in STATUSES:
|
||||
raise ReceiptError(f"status must be one of {sorted(STATUSES)}")
|
||||
if not isinstance(payload.get("synthetic"), bool):
|
||||
raise ReceiptError("synthetic must be boolean")
|
||||
_parse_time(payload.get("created_at"), "created_at")
|
||||
if not REVISION_RE.fullmatch(str(payload.get("source_revision", ""))):
|
||||
raise ReceiptError("source_revision must be a 7-40 character git revision")
|
||||
_require_digest(payload, "inventory_sha256")
|
||||
|
||||
if status == "pass" and event_type == "plan":
|
||||
_require_digest(payload, "plan_summary_sha256")
|
||||
elif status == "pass" and event_type == "apply":
|
||||
resource_ids = payload.get("provider_resource_ids")
|
||||
if not isinstance(resource_ids, list) or not resource_ids:
|
||||
raise ReceiptError("passing apply receipts require provider_resource_ids")
|
||||
elif status == "pass" and event_type in {"convergence", "verification"}:
|
||||
_validate_hosts(payload)
|
||||
if event_type == "verification":
|
||||
observed = _parse_time(payload.get("observed_at"), "observed_at")
|
||||
fresh = _parse_time(payload.get("fresh_until"), "fresh_until")
|
||||
if fresh <= observed:
|
||||
raise ReceiptError("fresh_until must be after observed_at")
|
||||
evidence = payload.get("evidence")
|
||||
if not isinstance(evidence, list) or len(evidence) < len(payload["hosts"]):
|
||||
raise ReceiptError("passing verification receipts require host evidence")
|
||||
for item in evidence:
|
||||
if not isinstance(item, dict) or not DIGEST_RE.fullmatch(
|
||||
str(item.get("sha256", ""))
|
||||
):
|
||||
raise ReceiptError("verification evidence requires sha256 digests")
|
||||
elif status == "pass" and event_type == "rotation":
|
||||
if payload.get("decryption_verified") is not True:
|
||||
raise ReceiptError("passing rotation receipts require decryption_verified=true")
|
||||
for key in ("files", "before_recipients", "after_recipients"):
|
||||
if not isinstance(payload.get(key), list) or not payload[key]:
|
||||
raise ReceiptError(f"passing rotation receipts require {key}")
|
||||
elif status == "pass" and event_type == "provisioning-chain":
|
||||
if payload.get("synthetic") is not True:
|
||||
raise ReceiptError("combined provisioning-chain receipts are synthetic only")
|
||||
phases = payload.get("phases")
|
||||
expected = ["plan", "apply", "cloud-init", "convergence", "verification"]
|
||||
if not isinstance(phases, list) or [p.get("phase") for p in phases] != expected:
|
||||
raise ReceiptError(f"provisioning-chain phases must be {expected}")
|
||||
if any(phase.get("status") != "pass" for phase in phases):
|
||||
raise ReceiptError("passing provisioning-chain receipts require every phase to pass")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def load_receipt(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ReceiptError(f"cannot read {path}: {exc}") from exc
|
||||
return validate_receipt(payload)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("receipts", nargs="+", type=Path)
|
||||
args = parser.parse_args()
|
||||
failures = []
|
||||
for path in args.receipts:
|
||||
try:
|
||||
load_receipt(path)
|
||||
except ReceiptError as exc:
|
||||
failures.append(f"{path}: {exc}")
|
||||
if failures:
|
||||
print("receipt validation failed:\n- " + "\n- ".join(failures), file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps({"ok": True, "validated": len(args.receipts)}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
edit) sops inventory/group_vars/secrets.sops.yaml ;;
|
||||
rotate) sops --rotate --in-place inventory/group_vars/secrets.sops.yaml ;;
|
||||
edit) sops secrets/hetzner-token.yaml ;;
|
||||
rotate) python3 scripts/sops_rotation.py --check ;;
|
||||
*)
|
||||
echo "Usage: scripts/sops.sh [edit|rotate]"
|
||||
;;
|
||||
|
|
|
|||
233
scripts/sops_rotation.py
Normal file
233
scripts/sops_rotation.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Check or execute a bounded, metadata-only SOPS recipient rotation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from check_secret_paths import is_protected_path
|
||||
from s1_receipt import validate_receipt
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class RotationError(ValueError):
|
||||
"""Rotation inputs, metadata, or approval are unsafe or incomplete."""
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
|
||||
|
||||
|
||||
def protected_files(root: Path = ROOT) -> list[Path]:
|
||||
candidates = list((root / "secrets").rglob("*")) if (root / "secrets").exists() else []
|
||||
inventory = list((root / "inventory").rglob("secrets*"))
|
||||
return sorted(
|
||||
path
|
||||
for path in candidates + inventory
|
||||
if path.is_file() and is_protected_path(str(path.relative_to(root)))
|
||||
)
|
||||
|
||||
|
||||
def load_policy(path: Path) -> list[dict[str, Any]]:
|
||||
try:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
rules = payload["creation_rules"]
|
||||
except (OSError, yaml.YAMLError, KeyError, TypeError) as exc:
|
||||
raise RotationError(f"cannot read SOPS policy {path}: {exc}") from exc
|
||||
if not isinstance(rules, list) or not rules:
|
||||
raise RotationError("SOPS policy has no creation_rules")
|
||||
return rules
|
||||
|
||||
|
||||
def expected_recipients(rules: list[dict[str, Any]], relative: str) -> list[str]:
|
||||
for rule in rules:
|
||||
pattern = rule.get("path_regex")
|
||||
if not isinstance(pattern, str) or re.fullmatch(pattern, relative) is None:
|
||||
continue
|
||||
recipients = []
|
||||
for group in rule.get("key_groups", []):
|
||||
recipients.extend(group.get("age", []))
|
||||
recipients = sorted(set(recipients))
|
||||
if not recipients:
|
||||
raise RotationError(f"{relative}: matching policy has no age recipients")
|
||||
return recipients
|
||||
raise RotationError(f"{relative}: no .sops.yaml creation rule matches")
|
||||
|
||||
|
||||
def actual_recipients(path: Path) -> list[str]:
|
||||
try:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
age_entries = payload["sops"]["age"]
|
||||
recipients = sorted({entry["recipient"] for entry in age_entries})
|
||||
except (OSError, yaml.YAMLError, KeyError, TypeError) as exc:
|
||||
raise RotationError(f"{path}: missing readable SOPS age metadata") from exc
|
||||
if not recipients:
|
||||
raise RotationError(f"{path}: SOPS metadata has no age recipients")
|
||||
return recipients
|
||||
|
||||
|
||||
def rotation_plan(root: Path = ROOT) -> list[dict[str, Any]]:
|
||||
rules = load_policy(root / ".sops.yaml")
|
||||
plan = []
|
||||
for path in protected_files(root):
|
||||
relative = str(path.relative_to(root))
|
||||
before = actual_recipients(path)
|
||||
after = expected_recipients(rules, relative)
|
||||
plan.append(
|
||||
{
|
||||
"path": relative,
|
||||
"sha256": _sha256(path),
|
||||
"before_recipients": before,
|
||||
"after_recipients": after,
|
||||
"changed": before != after,
|
||||
}
|
||||
)
|
||||
if not plan:
|
||||
raise RotationError("no protected SOPS files found")
|
||||
return plan
|
||||
|
||||
|
||||
def _load_approval(path: Path, plan: list[dict[str, Any]]) -> None:
|
||||
try:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise RotationError(f"cannot read approval file: {exc}") from exc
|
||||
if not isinstance(payload, dict) or payload.get("approved") is not True:
|
||||
raise RotationError("approval file must contain approved: true")
|
||||
if not payload.get("approved_by") or not payload.get("approved_at"):
|
||||
raise RotationError("approval file requires approved_by and approved_at")
|
||||
expected = [
|
||||
{
|
||||
"path": item["path"],
|
||||
"before_recipients": item["before_recipients"],
|
||||
"after_recipients": item["after_recipients"],
|
||||
}
|
||||
for item in plan
|
||||
if item["changed"]
|
||||
]
|
||||
if payload.get("changes") != expected:
|
||||
raise RotationError("approval changes do not exactly match the current rotation plan")
|
||||
|
||||
|
||||
def _verify_decryption(paths: list[Path]) -> bool:
|
||||
if shutil.which("sops") is None:
|
||||
raise RotationError("sops is required for non-printing decryption verification")
|
||||
for path in paths:
|
||||
completed = subprocess.run(
|
||||
["sops", "--decrypt", str(path)],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RotationError(f"decryption verification failed for {path.relative_to(ROOT)}")
|
||||
return True
|
||||
|
||||
|
||||
def _apply(plan: list[dict[str, Any]]) -> None:
|
||||
if shutil.which("sops") is None:
|
||||
raise RotationError("sops is required for rotation")
|
||||
for item in plan:
|
||||
if not item["changed"]:
|
||||
continue
|
||||
completed = subprocess.run(
|
||||
["sops", "updatekeys", "--yes", item["path"]],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RotationError(f"sops updatekeys failed for {item['path']}")
|
||||
|
||||
|
||||
def build_receipt(plan: list[dict[str, Any]], verified: bool, applied: bool) -> dict[str, Any]:
|
||||
all_before = sorted({r for item in plan for r in item["before_recipients"]})
|
||||
all_after = sorted({r for item in plan for r in item["after_recipients"]})
|
||||
receipt = {
|
||||
"schema_version": "1.0",
|
||||
"receipt_id": str(uuid.uuid4()),
|
||||
"event_type": "rotation",
|
||||
"synthetic": False,
|
||||
"created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"source_revision": _git("rev-parse", "HEAD"),
|
||||
"inventory_sha256": _sha256(ROOT / "inventory" / "servers.yaml"),
|
||||
"status": "pass" if verified else "not-run",
|
||||
"applied": applied,
|
||||
"decryption_verified": verified,
|
||||
"files": [item["path"] for item in plan],
|
||||
"before_recipients": all_before,
|
||||
"after_recipients": all_after,
|
||||
"file_metadata": plan,
|
||||
}
|
||||
validate_receipt(receipt)
|
||||
return receipt
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="fail on recipient drift")
|
||||
parser.add_argument("--verify-decryption", action="store_true")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--approval-file", type=Path)
|
||||
parser.add_argument("--receipt", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
plan = rotation_plan()
|
||||
if args.check and any(item["changed"] for item in plan):
|
||||
raise RotationError("recipient drift detected")
|
||||
if args.apply:
|
||||
if args.approval_file is None:
|
||||
raise RotationError("--apply requires --approval-file")
|
||||
if not any(item["changed"] for item in plan):
|
||||
raise RotationError("--apply requires at least one recipient change")
|
||||
_load_approval(args.approval_file, plan)
|
||||
_apply(plan)
|
||||
plan = rotation_plan()
|
||||
if any(item["changed"] for item in plan):
|
||||
raise RotationError("recipient drift remains after rotation")
|
||||
verified = _verify_decryption(protected_files()) if args.verify_decryption or args.apply else False
|
||||
receipt = build_receipt(plan, verified, args.apply)
|
||||
if args.receipt:
|
||||
destination = args.receipt if args.receipt.is_absolute() else ROOT / args.receipt
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"files": len(plan),
|
||||
"changes": sum(1 for item in plan if item["changed"]),
|
||||
"decryption_verified": verified,
|
||||
"applied": args.apply,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
except RotationError as exc:
|
||||
print(f"rotation failed closed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue