#!/usr/bin/env python3 """provision-stack.py — manifest-driven env-file rendering + Docker secret provisioning for one stack. Usage (from the CI workspace root, inside the provision-secrets step): python3 deploy/provision-stack.py Reads secrets/secrets-map.yaml (data only — no code, no values) and, for the named stack: 1. env_template -> renders the COMPLETE env file. Placeholders of the form dollar-brace VARNAME are resolved from this process's environment (the Woodpecker from_secret-backed vars). The whole file is rendered every run; nothing is line-edited in place, so keys can never silently go missing (root cause of the 2026-09-03 ai.env incident). 2. env_dest -> ships the rendered file to the Swarm manager over ssh stdin (write-to-temp + atomic mv, mode 600). The rendered file never touches the CI workspace disk under the repo (no chance of being committed) and never appears on a command line. 3. docker_secrets -> for each swarm-secret-name -> ENV_VAR mapping, creates or rotates the Docker secret. Values are passed via ssh stdin only. Rotation uses the same sha256-checksum-label convention as deploy/create-secrets.sh (unchanged secrets are skipped silently). Safety properties: - FAILS HARD (non-zero) if any referenced env var is missing or empty, and lists the missing NAMES. A partial/broken render can never ship. - FAILS HARD if any unresolved placeholder remains after rendering. - NEVER prints a secret value — names and counts only. - Requires SWARM_MANAGER_IP in the environment and a usable ssh identity (both already set up by the provision-secrets step). Stacks not present in the manifest exit 0 with a notice, so this script is safe to call unconditionally; legacy case-entries in deploy.yml keep handling unmigrated stacks. """ import hashlib import os import re import subprocess import sys REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) MANIFEST_PATH = os.path.join(REPO_ROOT, "secrets", "secrets-map.yaml") REMOTE_BASE = "/volume1/docker/compose-files" PLACEHOLDER_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") SSH_OPTS = ["-o", "StrictHostKeyChecking=no"] def die(msg: str) -> None: print(f"ERROR: {msg}", file=sys.stderr) sys.exit(1) def load_manifest() -> dict: try: import yaml # py3-yaml, installed by the provision-secrets step except ImportError: die("PyYAML not available — provision-secrets step must apk add py3-yaml") if not os.path.isfile(MANIFEST_PATH): die(f"manifest not found: {MANIFEST_PATH}") with open(MANIFEST_PATH, "r", encoding="utf-8") as fh: data = yaml.safe_load(fh) or {} stacks = data.get("stacks") if not isinstance(stacks, dict): die("manifest has no 'stacks:' mapping") return stacks def ssh_target() -> str: ip = os.environ.get("SWARM_MANAGER_IP", "").strip() if not ip: die("SWARM_MANAGER_IP is empty — check Woodpecker repo secrets") return f"root@{ip}" def ssh_run(target: str, remote_cmd: str, stdin_data: bytes | None = None, check: bool = True) -> subprocess.CompletedProcess: proc = subprocess.run( ["ssh", *SSH_OPTS, target, remote_cmd], input=stdin_data, capture_output=True, ) if check and proc.returncode != 0: # stderr may be verbose but must never contain our secret values — # we only ever send values via stdin, never embed them in remote_cmd. die(f"remote command failed (rc={proc.returncode}): {remote_cmd}\n" f"{proc.stderr.decode(errors='replace').strip()}") return proc def render_template(template_path: str) -> str: if not os.path.isfile(template_path): die(f"env_template not found: {template_path}") with open(template_path, "r", encoding="utf-8") as fh: raw = fh.read() referenced = sorted(set(PLACEHOLDER_RE.findall(raw))) missing = [v for v in referenced if not os.environ.get(v, "").strip()] if missing: die("template references vars that are MISSING or EMPTY in the CI " "environment (add them via from_secret in " ".woodpecker/deploy.yml provision-secrets, and as Woodpecker " "secrets):\n " + "\n ".join(missing)) rendered = PLACEHOLDER_RE.sub(lambda m: os.environ[m.group(1)], raw) # Belt-and-braces: nothing placeholder-shaped may survive the render. leftover = sorted(set(PLACEHOLDER_RE.findall(rendered))) if leftover: die("unresolved placeholders remain after rendering: " + ", ".join(leftover)) print(f" [render] {template_path}: {len(referenced)} secret placeholder(s) " f"resolved: {', '.join(referenced)}") return rendered def ship_env_file(target: str, rendered: str, dest_rel: str) -> None: dest = f"{REMOTE_BASE}/{dest_rel}" tmp = f"{dest}.provision-tmp" # Value travels over ssh stdin; never on a command line; atomic mv. ssh_run(target, f"umask 077 && cat > {tmp} && chmod 600 {tmp} && mv {tmp} {dest}", stdin_data=rendered.encode()) keys = [ln.split("=", 1)[0] for ln in rendered.splitlines() if "=" in ln and not ln.lstrip().startswith("#") and ln.strip()] print(f" [env] shipped {dest} ({len(keys)} keys): {', '.join(keys)}") def provision_docker_secret(target: str, name: str, env_var: str) -> None: value = os.environ.get(env_var, "") if not value.strip(): die(f"docker secret '{name}': env var {env_var} is missing/empty") new_hash = hashlib.sha256(value.encode()).hexdigest() probe = ssh_run( target, f"docker secret inspect {name} " "--format '{{index .Spec.Labels \"checksum\"}}' 2>/dev/null || true", check=True) old_hash = probe.stdout.decode().strip() if old_hash == new_hash: print(f" [skip] docker secret {name} (unchanged)") return if old_hash: rm = ssh_run(target, f"docker secret rm {name}", check=False) if rm.returncode != 0: die(f"docker secret {name}: value changed but removal failed — " "it is probably referenced by a running service. Provision " "under a versioned name (see vaultwarden_database_url_v2 " "precedent) or scale the service down first.") action = "update" else: action = "create" ssh_run(target, f"docker secret create --label checksum={new_hash} " f"--label managed-by=woodpecker {name} -", stdin_data=value.encode()) print(f" [{action}] docker secret {name} (value via stdin)") def main() -> None: if len(sys.argv) != 2: die("usage: provision-stack.py ") stack = sys.argv[1] stacks = load_manifest() cfg = stacks.get(stack) if cfg is None: print(f" [info] stack '{stack}' not in secrets-map.yaml — " "legacy provisioning (deploy.yml case-entry) applies. Nothing to do.") return target = ssh_target() print(f"==> provision-stack: {stack}") template_rel = cfg.get("env_template") dest_rel = cfg.get("env_dest") if template_rel and not dest_rel: die("env_template set but env_dest missing in manifest") if template_rel: rendered = render_template(os.path.join(REPO_ROOT, template_rel)) ship_env_file(target, rendered, dest_rel) for name, env_var in (cfg.get("docker_secrets") or {}).items(): provision_docker_secret(target, name, env_var) print(f"==> provision-stack: {stack} done") if __name__ == "__main__": main()