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
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.
335 lines
9.0 KiB
Python
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())
|