Files

270 lines
8.8 KiB
Python

"""Rotate the generated credentials of an inventory.
Usage example::
infinito administration inventory credentials reset \\
--inventory-dir /etc/inventories/local-full-server \\
--schema --users --except administrator
"""
from __future__ import annotations
import argparse
import json
import shutil
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from ruamel.yaml.comments import CommentedMap
from cli.administration.inventory.provision.project import (
build_env_with_project_root,
detect_project_root,
)
from cli.administration.inventory.provision.reset import reset_credentials
from cli.administration.inventory.provision.ruamel_io import (
dump_document,
load_document,
)
if TYPE_CHECKING:
from collections.abc import Iterable
def _parse_app_variants(raw: str | None) -> dict[str, int]:
if not raw:
return {}
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise SystemExit(f"--app-variants must be valid JSON: {exc}") from exc
if not isinstance(parsed, dict):
raise SystemExit("--app-variants must be a JSON object")
variants: dict[str, int] = {}
for application_id, index in parsed.items():
try:
variants[str(application_id)] = int(index)
except (TypeError, ValueError) as exc:
raise SystemExit(
f"--app-variants[{application_id!r}] must be an integer, got {index!r}"
) from exc
return variants
def _backup(host_vars_file: Path) -> Path:
"""Copy ``host_vars_file`` next to itself, stamped with the current UTC time.
Args:
host_vars_file: the file about to be rewritten.
Returns:
The path of the copy.
A rotation destroys the only record of what the previous pass deployed
with, which is exactly what a post-mortem needs. The `.backup` suffix
keeps the copy out of both the mirror glob and Ansible's host_vars
loading, which only reads YAML extensions.
"""
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
copy = host_vars_file.with_name(f"{host_vars_file.name}.{stamp}.backup")
shutil.copyfile(host_vars_file, copy)
return copy
def _subtree(document: Any, path: str) -> Any:
node = document
for key in path.split("."):
if not isinstance(node, dict) or key not in node:
return None
node = node[key]
return node
def _restore(document: Any, path: str, value: Any) -> None:
keys = path.split(".")
node = document
for key in keys[:-1]:
branch = node.get(key)
if not isinstance(branch, dict):
branch = CommentedMap()
node[key] = branch
node = branch
node[keys[-1]] = value
def _mirror_host_vars(
host_vars_dir: Path, source: Path, keep: Iterable[str] = ()
) -> list[str]:
"""Copy ``source`` over every other host_vars file in its directory.
Args:
host_vars_dir: the inventory's ``host_vars`` directory.
source: the file the other hosts are aligned to.
keep: dotted paths restored from the destination after the copy.
Returns:
The host names that were overwritten.
A multi-host test inventory holds one file per node, all copies of the
manager's. Rotating only the manager's would hand every other node the
previous secrets.
"""
keep = list(keep)
mirrored: list[str] = []
for candidate in sorted(host_vars_dir.glob("*.yml")):
if candidate == source:
continue
preserved = (
{path: _subtree(load_document(candidate), path) for path in keep}
if keep and candidate.exists()
else {}
)
shutil.copyfile(source, candidate)
preserved = {path: node for path, node in preserved.items() if node is not None}
if preserved:
document = load_document(candidate)
for path, node in preserved.items():
_restore(document, path, node)
dump_document(candidate, document)
mirrored.append(candidate.stem)
return mirrored
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=(
"Drop the generated credentials of an inventory and regenerate them, "
"so the next deploy has to propagate a changed secret."
)
)
parser.add_argument(
"--inventory-dir", required=True, help="Inventory directory to rotate."
)
parser.add_argument(
"--host", default="localhost", help="Host whose host_vars file is rotated."
)
parser.add_argument(
"--vault-password-file",
default=None,
help="Vault password file. Default: <inventory-dir>/.password",
)
parser.add_argument(
"--schema",
action="store_true",
help="Rotate applications.<app>.credentials.* (everything meta/secrets.yml generates).",
)
parser.add_argument(
"--users", action="store_true", help="Rotate users.<name>.password."
)
parser.add_argument(
"--except",
dest="exclude",
nargs="*",
default=[],
help="Application ids and user keys to leave untouched (e.g. administrator).",
)
parser.add_argument(
"--include",
nargs="*",
default=None,
help=(
"Application ids whose credentials rotate. Absent rotates every "
"application block in the file, which on a matrix inventory is "
"every mirror artefact too, not just the ids the round "
"provisioned. User passwords ignore this and always rotate in "
"full."
),
)
parser.add_argument(
"--app-variants",
default=None,
help="JSON object {app_id: variant_index}, as passed to provision.",
)
parser.add_argument(
"--mirror",
action="store_true",
help="Copy the rotated host_vars file over every other host in the inventory.",
)
parser.add_argument(
"--mirror-keep",
action="append",
default=[],
metavar="DOTTED.PATH",
help=(
"Subtree the mirror restores from the destination afterwards. "
"Repeatable; ignored without --mirror."
),
)
parser.add_argument(
"--backup",
action="store_true",
help="Keep the pre-rotation host_vars as <file>.YYYYMMDDTHHMMSSZ.backup.",
)
args = parser.parse_args(argv)
if not args.schema and not args.users:
raise SystemExit("Nothing to rotate: pass --schema, --users, or both.")
inventory_dir = Path(args.inventory_dir).resolve()
host_vars_file = inventory_dir / "host_vars" / f"{args.host}.yml"
if not host_vars_file.exists():
raise SystemExit(f"No host_vars file to rotate: {host_vars_file}")
vault_password_file = (
Path(args.vault_password_file).resolve()
if args.vault_password_file
else inventory_dir / ".password"
)
if not vault_password_file.exists():
raise SystemExit(f"Vault password file not found: {vault_password_file}")
project_root = detect_project_root(Path(__file__).resolve())
env = build_env_with_project_root(project_root)
document = load_document(host_vars_file)
applications = document.get("applications") or {}
application_ids = sorted(str(application_id) for application_id in applications)
if not application_ids:
raise SystemExit(f"No applications block to rotate in {host_vars_file}")
credential_ids = None
if args.include is not None:
wanted = set(args.include)
credential_ids = [app_id for app_id in application_ids if app_id in wanted]
if not credential_ids:
raise SystemExit(f"--include matches no block in {host_vars_file}")
if args.backup:
print(f"[INFO] Pre-rotation copy: {_backup(host_vars_file)}")
exclude = set(args.exclude)
print(
f"[INFO] Rotating credentials in {host_vars_file} "
f"({len(credential_ids or application_ids)} applications, "
f"{len(application_ids)} for users, "
f"excluding {sorted(exclude) or 'nothing'})"
)
rotated = reset_credentials(
application_ids=application_ids,
credential_ids=credential_ids,
roles_dir=(project_root / "roles").resolve(),
host_vars_file=host_vars_file,
vault_password_file=vault_password_file,
project_root=project_root,
env=env,
schema=args.schema,
users=args.users,
exclude=exclude,
app_variants=_parse_app_variants(args.app_variants),
)
print(f"[INFO] Rotated {rotated} value(s)")
if args.mirror:
mirrored = _mirror_host_vars(
host_vars_file.parent, host_vars_file, keep=args.mirror_keep
)
print(f"[INFO] Mirrored to {', '.join(mirrored) or 'no other host'}")
return 0