Files
stack/dev/scripts/bootstrap_secrets.py
kert bd8cda1931
Some checks failed
CI / skinny-install (aco) (push) Successful in 1m14s
CI / skinny-install (bib) (push) Successful in 5m47s
CI / skinny-install (api) (push) Successful in 37s
CI / skinny-install (bcda) (push) Successful in 37s
CI / skinny-install (bls) (push) Successful in 40s
CI / skinny-install (ccw) (push) Successful in 41s
CI / skinny-install (cli) (push) Successful in 42s
CI / skinny-install (cms) (push) Successful in 39s
CI / skinny-install (conf) (push) Successful in 41s
CI / skinny-install (opps) (push) Successful in 39s
CI / skinny-install (perf) (push) Successful in 40s
CI / skinny-install (pfs) (push) Successful in 50s
CI / skinny-install (rex) (push) Successful in 33s
Deploy / build-scan-report (push) Failing after 12m6s
Infra CI / notebooks (push) Successful in 22s
Infra CI / zotero (push) Successful in 12s
Infra CI / docs (push) Successful in 11s
Infra CI / api (push) Successful in 12s
Infra CI / mc (push) Successful in 22s
CI / lint-test (push) Failing after 41m1s
remove: scrub all woodpecker references from codebase
Clean 17 files across src/, tests/, dev/, stack.toml, deploy.sh:
- api/auth/provision.py: remove WoodpeckerClient, provision_woodpecker,
  _get_woodpecker_token, woodpecker field from ProvisionResult
- api/auth/manifest.py: remove woodpecker from CREDENTIALS + Provisioner
- api/diag: remove woodpecker log fetching
- sem/hooks.py: remove woodpecker sync step
- stack.toml: remove [services.woodpecker] config
- deploy.sh: remove woodpecker deploy steps
- dev/scripts: remove woodpecker from config gen, secrets, readme
- tests: remove all woodpecker assertions and test cases

Zero woodpecker references remain in the codebase.
2026-04-18 18:51:21 -04:00

335 lines
9.0 KiB
Python

"""Bootstrap all secrets for the homelab stack.
Run once after cloning the repo to set up all credentials across:
- .env file (all secrets)
- PostgreSQL roles and databases
- RustFS IAM (via mc)
Prerequisites:
- docker compose services running (postgres, gitea, rustfs)
- No prior .env (or willing to overwrite managed keys)
Usage:
uv run python dev/scripts/bootstrap_secrets.py
uv run python dev/scripts/bootstrap_secrets.py --verify # check consistency only
"""
from __future__ import annotations
import argparse
import base64
import logging
import secrets
import subprocess
import sys
from pathlib import Path
log = logging.getLogger(__name__)
REPO_ROOT = Path(__file__).resolve().parents[2]
ENV_PATH = REPO_ROOT / ".env"
# Secrets to generate (name → length in bytes)
SECRETS = {
"POSTGRES_PASSWORD": 24,
"GITEA_DB_PASSWORD": 24,
"GITEA_ADMIN_PASSWORD": 24,
"NESSIE_DB_PASSWORD": 24,
"POLARIS_DB_PASSWORD": 24,
"RUSTFS_ACCESS_KEY": 32,
"RUSTFS_SECRET_KEY": 32,
"POLARIS_ROOT_SECRET": 24,
"GF_ADMIN_PASSWORD": 24,
"ROOT_KEY": 32,
}
# Secrets that use hex encoding (rest use base64url)
HEX_SECRETS = {
"RUSTFS_ACCESS_KEY",
"RUSTFS_SECRET_KEY",
"ROOT_KEY",
}
# PostgreSQL roles: role_name → (env_var for password, database_name)
PG_ROLES = {
"git": ("GITEA_DB_PASSWORD", "gitea"),
"nessie": ("NESSIE_DB_PASSWORD", "nessie"),
"polaris": ("POLARIS_DB_PASSWORD", "polaris"),
}
# Alias mappings: these .env vars copy from another
ALIASES = {
"GITEA_S3_ACCESS_KEY": "RUSTFS_ACCESS_KEY",
"GITEA_S3_SECRET_KEY": "RUSTFS_SECRET_KEY",
"NESSIE_S3_ACCESS_KEY": "RUSTFS_ACCESS_KEY",
"NESSIE_S3_SECRET_KEY": "RUSTFS_SECRET_KEY",
"POLARIS_S3_ACCESS_KEY": "RUSTFS_ACCESS_KEY",
"POLARIS_S3_SECRET_KEY": "RUSTFS_SECRET_KEY",
}
def _generate(name: str, length: int) -> str:
raw = secrets.token_bytes(length)
if name in HEX_SECRETS:
return raw.hex()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _read_env(path: Path) -> dict[str, str]:
env: dict[str, str] = {}
if path.exists():
for line in path.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, _, v = line.partition("=")
env[k.strip()] = v.strip()
return env
def _write_env(env: dict[str, str], path: Path) -> None:
lines = [f"{k}={v}" for k, v in sorted(env.items())]
path.write_text("\n".join(lines) + "\n")
def _psql(sql: str, *, pw: str, container: str = "postgres") -> bool:
r = subprocess.run(
[
"docker",
"exec",
"-e",
f"PGPASSWORD={pw}",
container,
"psql",
"-U",
"postgres",
"-c",
sql,
],
capture_output=True,
text=True,
)
return r.returncode == 0
def generate_secrets() -> dict[str, str]:
"""Generate all secrets, preserving existing .env values."""
env = _read_env(ENV_PATH)
for name, length in SECRETS.items():
if name not in env or not env[name]:
env[name] = _generate(name, length)
log.info("Generated %s", name)
else:
log.info("Kept existing %s", name)
for alias, source in ALIASES.items():
env[alias] = env[source]
return env
def bootstrap_postgres(env: dict[str, str]) -> list[str]:
"""Create PostgreSQL roles and databases."""
errors = []
pw = env["POSTGRES_PASSWORD"]
# Set superuser password
if not _psql(f"ALTER ROLE postgres PASSWORD '{pw}'", pw=pw):
# Try without password (first run, trust auth)
_psql(f"ALTER ROLE postgres PASSWORD '{pw}'", pw="")
for role, (env_var, db) in PG_ROLES.items():
role_pw = env[env_var]
_psql(
f"DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='{role}') "
f"THEN CREATE ROLE {role} LOGIN; END IF; END $$;",
pw=pw,
)
if not _psql(f"ALTER ROLE {role} PASSWORD '{role_pw}'", pw=pw):
errors.append(f"Failed to set password for {role}")
_psql(f"CREATE DATABASE {db} OWNER {role}", pw=pw) # idempotent fail ok
log.info("PostgreSQL role %s → db %s", role, db)
return errors
def bootstrap_gitea(env: dict[str, str]) -> list[str]:
"""Set Gitea admin password and create API token."""
errors = []
pw = env["GITEA_ADMIN_PASSWORD"]
r = subprocess.run(
[
"docker",
"exec",
"gitea",
"gitea",
"admin",
"user",
"change-password",
"-u",
"kert",
"-p",
pw,
"--must-change-password=false",
],
capture_output=True,
text=True,
)
if r.returncode != 0:
errors.append(f"Gitea password change failed: {r.stderr.strip()}")
return errors
# Create token via API
import json
r = subprocess.run(
[
"docker",
"exec",
"gitea",
"curl",
"-s",
"-u",
f"kert:{pw}",
"-H",
"Content-Type: application/json",
"-X",
"POST",
"http://localhost:3000/api/v1/users/kert/tokens",
"-d",
json.dumps(
{"name": f"bootstrap-{secrets.token_hex(4)}", "scopes": ["all"]}
),
],
capture_output=True,
text=True,
)
try:
token = json.loads(r.stdout)["sha1"]
env["GITEA_TOKEN"] = token
log.info("Gitea token created: %s...", token[:12])
except (json.JSONDecodeError, KeyError):
errors.append(f"Gitea token creation failed: {r.stdout}")
return errors
def verify(env: dict[str, str]) -> list[str]:
"""Verify all secrets are consistent across systems."""
errors = []
# PostgreSQL roles
for role, (env_var, db) in PG_ROLES.items():
role_pw = env.get(env_var, "")
r = subprocess.run(
[
"docker",
"exec",
"-e",
f"PGPASSWORD={role_pw}",
"postgres",
"psql",
"-U",
role,
"-d",
db,
"-c",
"SELECT 1",
],
capture_output=True,
text=True,
)
if r.returncode != 0:
errors.append(f"postgres/{role}: auth failed")
else:
log.info("postgres/%s: OK", role)
# Gitea token
token = env.get("GITEA_TOKEN", "")
if token:
r = subprocess.run(
[
"docker",
"exec",
"gitea",
"curl",
"-sf",
"-H",
f"Authorization: token {token}",
"http://localhost:3000/api/v1/user",
],
capture_output=True,
text=True,
)
if r.returncode != 0:
errors.append("gitea: token invalid")
else:
log.info("gitea: OK")
# Gitea registry auth
admin_pw = env.get("GITEA_ADMIN_PASSWORD", "")
if admin_pw:
r = subprocess.run(
[
"docker",
"exec",
"gitea",
"curl",
"-sf",
"-u",
f"kert:{admin_pw}",
"http://localhost:3000/api/v1/user",
],
capture_output=True,
text=True,
)
if r.returncode != 0:
errors.append("gitea registry: admin password invalid")
else:
log.info("gitea registry: OK")
return errors
def bootstrap() -> int:
"""Full bootstrap: generate → postgres → gitea → verify."""
env = generate_secrets()
_write_env(env, ENV_PATH)
log.info("Wrote %d keys to %s", len(env), ENV_PATH)
all_errors = []
all_errors.extend(bootstrap_postgres(env))
all_errors.extend(bootstrap_gitea(env))
_write_env(env, ENV_PATH) # re-write with GITEA_TOKEN
# Verify
log.info("Verifying...")
verify_errors = verify(env)
all_errors.extend(verify_errors)
if all_errors:
for e in all_errors:
log.error("FAIL: %s", e)
return 1
log.info("Bootstrap complete — all systems consistent")
return 0
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--verify", action="store_true", help="verify only, don't generate"
)
args = parser.parse_args()
if args.verify:
env = _read_env(ENV_PATH)
errors = verify(env)
if errors:
for e in errors:
log.error("FAIL: %s", e)
return 1
log.info("All checks passed")
return 0
return bootstrap()
if __name__ == "__main__":
sys.exit(main())