harden provision: API-only Gitea, safe SQL, retry, Woodpecker sync
Some checks failed
ci/woodpecker/push/infra-ci Pipeline was successful
coverage 99% coverage
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline failed

- Replace docker exec gitea with GiteaClient.change_admin_password()
  API call — no Docker socket needed for Gitea (#110)
- Use psql -v variable binding for passwords instead of f-string
  interpolation — passwords never appear in SQL text (#113)
- Wrap each backend in try/except with 3x retry + exponential
  backoff — partial failure still writes .env (#111)
- Add provision_woodpecker() to sync rotated creds (gitea_token,
  registry_pass, s3 keys) to Woodpecker repo secrets via API (#112)
- Return ProvisionResult dataclass with per-backend status

fix #109 fix #110 fix #111 fix #112 fix #113
This commit is contained in:
kert
2026-03-22 13:31:04 -04:00
parent 881d136e32
commit 6bba64ef16
5 changed files with 366 additions and 183 deletions

View File

@@ -8,12 +8,15 @@ Usage:
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
def main() -> int: def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
args = sys.argv[1:] args = sys.argv[1:]
if len(args) < 2 or args[0] not in ("bootstrap", "provision", "derive"): if len(args) < 2 or args[0] not in ("bootstrap", "provision", "derive"):
print(__doc__.strip(), file=sys.stderr) print(__doc__.strip(), file=sys.stderr)
@@ -50,27 +53,29 @@ def main() -> int:
return 0 return 0
if command == "provision": if command == "provision":
from api.auth.provision import provision from api.auth.provision import derive_all, provision
provision(root_key, commit_sha, env_path) result = provision(root_key, commit_sha, env_path)
print(f"Provisioned {len(derive_all_count(root_key, commit_sha))} credentials") n = len(derive_all(root_key, commit_sha))
print(f"Provisioned {n} credentials")
if result.errors:
for backend, err in result.errors:
print(f" FAILED: {backend}{err}", file=sys.stderr)
return 1 if not result.postgres and not result.gitea else 0
return 0 return 0
if command == "bootstrap": if command == "bootstrap":
from api.auth.provision import bootstrap from api.auth.provision import bootstrap
bootstrap(root_key, commit_sha, env_path) result = bootstrap(root_key, commit_sha, env_path)
print("Bootstrap complete") print("Bootstrap complete")
if result.errors:
for backend, err in result.errors:
print(f" FAILED: {backend}{err}", file=sys.stderr)
return 0 return 0
return 1 return 1
def derive_all_count(root_key: bytes, commit_sha: str) -> dict[str, str]:
from api.auth.provision import derive_all
return derive_all(root_key, commit_sha)
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@@ -3,8 +3,10 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import logging
import subprocess import subprocess
import time import time
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from api.auth.derive import derive_hex, derive_password from api.auth.derive import derive_hex, derive_password
@@ -17,8 +19,29 @@ from api.auth.manifest import (
Provisioner, Provisioner,
Tier, Tier,
) )
from api.clients.gitea import GiteaClient
from api.clients.woodpecker import WoodpeckerClient
log = logging.getLogger(__name__)
BOOTSTRAP_SALT = b"bootstrap" BOOTSTRAP_SALT = b"bootstrap"
MAX_RETRIES = 3
RETRY_DELAY = 2.0
@dataclass
class ProvisionResult:
"""Summary of what succeeded and failed during provisioning."""
postgres: bool = False
gitea: bool = False
woodpecker: bool = False
env_written: bool = False
errors: list[tuple[str, Exception]] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def _derive_one(cred: Credential, root_key: bytes, salt: bytes) -> str: def _derive_one(cred: Credential, root_key: bytes, salt: bytes) -> str:
@@ -57,8 +80,36 @@ def write_env(values: dict[str, str], path: Path) -> None:
path.write_text("\n".join(lines) + "\n") path.write_text("\n".join(lines) + "\n")
def _retry(fn, label: str, retries: int = MAX_RETRIES):
"""Retry a function with exponential backoff."""
last_err = None
for attempt in range(retries):
try:
return fn()
except Exception as e:
last_err = e
if attempt < retries - 1:
delay = RETRY_DELAY * (2**attempt)
log.warning(
"%s attempt %d/%d failed: %s — retrying in %.1fs",
label,
attempt + 1,
retries,
e,
delay,
)
time.sleep(delay)
raise last_err # type: ignore[misc]
# ── PostgreSQL provisioning ─────────────────────────────────────
def provision_postgres(values: dict[str, str], *, container: str = "postgres") -> None: def provision_postgres(values: dict[str, str], *, container: str = "postgres") -> None:
"""Rotate passwords for all managed PostgreSQL roles.""" """Rotate passwords for all managed PostgreSQL roles.
Uses psql -v variable binding so passwords never appear in SQL text.
"""
for role, env_var in POSTGRES_ROLES.items(): for role, env_var in POSTGRES_ROLES.items():
pw = values[env_var] pw = values[env_var]
subprocess.run( subprocess.run(
@@ -69,8 +120,10 @@ def provision_postgres(values: dict[str, str], *, container: str = "postgres") -
"psql", "psql",
"-U", "-U",
"postgres", "postgres",
"-v",
f"pw={pw}",
"-c", "-c",
f"ALTER ROLE {role} PASSWORD '{pw}'", f"ALTER ROLE {role} PASSWORD :'pw'",
], ],
check=True, check=True,
capture_output=True, capture_output=True,
@@ -88,8 +141,10 @@ def bootstrap_postgres(values: dict[str, str], *, container: str = "postgres") -
"psql", "psql",
"-U", "-U",
"postgres", "postgres",
"-v",
f"pw={superuser_pw}",
"-c", "-c",
f"ALTER ROLE postgres PASSWORD '{superuser_pw}'", "ALTER ROLE postgres PASSWORD :'pw'",
], ],
check=True, check=True,
capture_output=True, capture_output=True,
@@ -100,9 +155,12 @@ def bootstrap_postgres(values: dict[str, str], *, container: str = "postgres") -
sql = ( sql = (
f"DO $$ BEGIN " f"DO $$ BEGIN "
f"IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='{role}') " f"IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='{role}') "
f"THEN CREATE ROLE {role} LOGIN PASSWORD '{pw}'; END IF; END $$; " f"THEN CREATE ROLE {role} LOGIN; END IF; END $$;"
f"SELECT 'CREATE DATABASE {db} OWNER {role}' " )
f"WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname='{db}');" subprocess.run(
["docker", "exec", container, "psql", "-U", "postgres", "-c", sql],
check=True,
capture_output=True,
) )
subprocess.run( subprocess.run(
[ [
@@ -112,14 +170,14 @@ def bootstrap_postgres(values: dict[str, str], *, container: str = "postgres") -
"psql", "psql",
"-U", "-U",
"postgres", "postgres",
"-v",
f"pw={pw}",
"-c", "-c",
sql, f"ALTER ROLE {role} PASSWORD :'pw'",
], ],
check=True, check=True,
capture_output=True, capture_output=True,
) )
# Create database if it doesn't exist (psql doesn't support
# IF NOT EXISTS for CREATE DATABASE inside DO blocks).
subprocess.run( subprocess.run(
[ [
"docker", "docker",
@@ -135,39 +193,31 @@ def bootstrap_postgres(values: dict[str, str], *, container: str = "postgres") -
) )
# ── Gitea provisioning ──────────────────────────────────────────
def provision_gitea( def provision_gitea(
values: dict[str, str], values: dict[str, str],
*, *,
container: str = "gitea",
admin_user: str = "kert", admin_user: str = "kert",
base_url: str = "http://gitea:3000/api/v1",
) -> str: ) -> str:
"""Rotate Gitea admin password and create a fresh API token.""" """Rotate Gitea admin password and create a fresh API token.
Uses the Gitea HTTP API exclusively — no docker exec needed.
"""
pw = values["GITEA_ADMIN_PASSWORD"] pw = values["GITEA_ADMIN_PASSWORD"]
# Change admin password current_token = values.get("GITEA_TOKEN", "")
subprocess.run( if current_token:
[ client = GiteaClient(current_token, base_url=base_url)
"docker", client.change_admin_password(admin_user, pw)
"exec", client.close()
container,
"gitea",
"admin",
"user",
"change-password",
"-u",
admin_user,
"-p",
pw,
],
check=True,
capture_output=True,
)
# Delete stale deploy tokens then create a fresh one, via API encoded = base64.b64encode(f"{admin_user}:{pw}".encode()).decode()
auth_header = _basic_header(admin_user, pw) auth_header = f"Basic {encoded}"
client = _make_gitea_client(container, auth_header) client = GiteaClient("unused", base_url=base_url)
# List and delete stale deploy-* tokens
tokens = client.get( tokens = client.get(
f"/users/{admin_user}/tokens", f"/users/{admin_user}/tokens",
headers={"Authorization": auth_header}, headers={"Authorization": auth_header},
@@ -179,28 +229,56 @@ def provision_gitea(
headers={"Authorization": auth_header}, headers={"Authorization": auth_header},
) )
# Create fresh token
token_name = f"deploy-{int(time.time())}" token_name = f"deploy-{int(time.time())}"
resp = client.post( resp = client.post(
f"/users/{admin_user}/tokens", f"/users/{admin_user}/tokens",
json={"name": token_name, "scopes": ["all"]}, json={"name": token_name, "scopes": ["all"]},
headers={"Authorization": auth_header}, headers={"Authorization": auth_header},
).json() ).json()
client.close()
return resp["sha1"] return resp["sha1"]
def _basic_header(user: str, password: str) -> str: # ── Woodpecker secret sync ──────────────────────────────────────
encoded = base64.b64encode(f"{user}:{password}".encode()).decode()
return f"Basic {encoded}" WOODPECKER_SECRET_MAP = {
"gitea_token": "GITEA_TOKEN",
"registry_pass": "GITEA_ADMIN_PASSWORD",
"s3_access_key": "RUSTFS_ACCESS_KEY",
"s3_secret_key": "RUSTFS_SECRET_KEY",
}
def _make_gitea_client(container: str, auth_header: str): def provision_woodpecker(
from api.clients.gitea import GiteaClient values: dict[str, str],
*,
repo_id: int = 1,
base_url: str = "http://woodpecker-server:8000/api",
) -> None:
"""Sync rotated credentials to Woodpecker repo secrets."""
token = values.get("GITEA_TOKEN", "")
if not token:
raise ValueError("GITEA_TOKEN not available — cannot sync Woodpecker secrets")
return GiteaClient( wp = WoodpeckerClient(token, base_url=base_url)
"unused", for secret_name, env_var in WOODPECKER_SECRET_MAP.items():
base_url=f"http://{container}:3000/api/v1", val = values.get(env_var, "")
) if not val:
log.warning("Skipping Woodpecker secret %s — no value", secret_name)
continue
try:
wp.update_secret(repo_id, secret_name, {"value": val})
log.info("Updated Woodpecker secret: %s", secret_name)
except Exception:
wp.create_secret(
repo_id,
{"name": secret_name, "value": val, "events": ["push"]},
)
log.info("Created Woodpecker secret: %s", secret_name)
wp.close()
# ── Main entry points ───────────────────────────────────────────
def provision( def provision(
@@ -209,17 +287,45 @@ def provision(
env_path: Path, env_path: Path,
*, *,
skip_backends: bool = False, skip_backends: bool = False,
) -> dict[str, str]: ) -> ProvisionResult:
"""Main entry: derive, provision backends, write .env.""" """Main entry: derive, provision backends, write .env.
Each backend is tried independently — one failure doesn't
prevent the others from running. .env is always written.
"""
result = ProvisionResult()
values = derive_all(root_key, commit_sha) values = derive_all(root_key, commit_sha)
if not skip_backends: if not skip_backends:
provision_postgres(values) try:
token = provision_gitea(values) _retry(lambda: provision_postgres(values), "postgres")
values["GITEA_TOKEN"] = token result.postgres = True
log.info("PostgreSQL credentials rotated")
except Exception as e:
result.errors.append(("postgres", e))
log.error("PostgreSQL provisioning failed: %s", e)
try:
token = _retry(lambda: provision_gitea(values), "gitea")
values["GITEA_TOKEN"] = token
result.gitea = True
log.info("Gitea credentials rotated")
except Exception as e:
result.errors.append(("gitea", e))
log.error("Gitea provisioning failed: %s", e)
try:
_retry(lambda: provision_woodpecker(values), "woodpecker")
result.woodpecker = True
log.info("Woodpecker secrets synced")
except Exception as e:
result.errors.append(("woodpecker", e))
log.error("Woodpecker secret sync failed: %s", e)
write_env(values, env_path) write_env(values, env_path)
return values result.env_written = True
log.info("Wrote %d credentials to %s", len(values), env_path)
return result
def bootstrap( def bootstrap(
@@ -228,15 +334,35 @@ def bootstrap(
env_path: Path, env_path: Path,
*, *,
skip_backends: bool = False, skip_backends: bool = False,
) -> dict[str, str]: ) -> ProvisionResult:
"""First-time setup: create DBs, roles, then provision.""" """First-time setup: create DBs, roles, then provision."""
result = ProvisionResult()
values = derive_all(root_key, commit_sha) values = derive_all(root_key, commit_sha)
if not skip_backends: if not skip_backends:
bootstrap_postgres(values) try:
provision_postgres(values) bootstrap_postgres(values)
token = provision_gitea(values) provision_postgres(values)
values["GITEA_TOKEN"] = token result.postgres = True
except Exception as e:
result.errors.append(("postgres", e))
log.error("PostgreSQL bootstrap failed: %s", e)
try:
token = provision_gitea(values)
values["GITEA_TOKEN"] = token
result.gitea = True
except Exception as e:
result.errors.append(("gitea", e))
log.error("Gitea bootstrap failed: %s", e)
try:
provision_woodpecker(values)
result.woodpecker = True
except Exception as e:
result.errors.append(("woodpecker", e))
log.error("Woodpecker bootstrap failed: %s", e)
write_env(values, env_path) write_env(values, env_path)
return values result.env_written = True
return result

View File

@@ -77,11 +77,13 @@ class TestMain:
patch("api.auth.__main__.Path", return_value=env_file), patch("api.auth.__main__.Path", return_value=env_file),
patch("api.auth.provision.provision") as mock_prov, patch("api.auth.provision.provision") as mock_prov,
): ):
mock_prov.return_value = None from api.auth.provision import ProvisionResult
# provision has a bug referencing derive_all_count — test that path
# We expect either success or the NameError mock_prov.return_value = ProvisionResult(
postgres=True, gitea=True, env_written=True
)
result = main() result = main()
assert result == 0 or mock_prov.called assert result == 0
@pytest.mark.usefixtures("_valid_env") @pytest.mark.usefixtures("_valid_env")
def test_bootstrap(self, tmp_path): def test_bootstrap(self, tmp_path):
@@ -93,5 +95,7 @@ class TestMain:
patch("api.auth.__main__.Path", return_value=env_file), patch("api.auth.__main__.Path", return_value=env_file),
patch("api.auth.provision.bootstrap") as mock_boot, patch("api.auth.provision.bootstrap") as mock_boot,
): ):
mock_boot.return_value = None from api.auth.provision import ProvisionResult
mock_boot.return_value = ProvisionResult(env_written=True)
assert main() == 0 assert main() == 0

View File

@@ -1,11 +1,8 @@
"""End-to-end bootstrap integration test. """End-to-end bootstrap integration test."""
Tests the full bootstrap() flow with all backends mocked:
ROOT_KEY → derive → provision all backends → .env
"""
from __future__ import annotations from __future__ import annotations
import contextlib
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from api.auth.manifest import CREDENTIALS, Provisioner from api.auth.manifest import CREDENTIALS, Provisioner
@@ -19,122 +16,64 @@ def _mock_gitea_client():
mock = MagicMock() mock = MagicMock()
mock.get.return_value.json.return_value = [] mock.get.return_value.json.return_value = []
mock.post.return_value.json.return_value = {"sha1": "tok-e2e-123"} mock.post.return_value.json.return_value = {"sha1": "tok-e2e-123"}
mock.change_admin_password.return_value = {}
return mock return mock
def _mock_rustfs(): @contextlib.contextmanager
s3 = MagicMock() def _bootstrap_patches():
s3.bucket_exists.return_value = False with (
admin = MagicMock() patch("api.auth.provision.subprocess.run"),
admin.add_user.return_value = {} patch("api.auth.provision.GiteaClient", return_value=_mock_gitea_client()),
admin.add_policy.return_value = {} patch("api.auth.provision.WoodpeckerClient", return_value=MagicMock()),
return s3, admin ):
yield
def _mock_woodpecker():
mock = MagicMock()
mock.list_global_secrets.return_value = []
mock.create_global_secret.return_value = {}
return mock
class TestBootstrapE2E: class TestBootstrapE2E:
def test_full_bootstrap_writes_all_credentials(self, tmp_path): def test_full_bootstrap_writes_all_credentials(self, tmp_path):
env_path = tmp_path / ".env" env_path = tmp_path / ".env"
s3, admin = _mock_rustfs() with _bootstrap_patches():
wp = _mock_woodpecker()
with (
patch("api.auth.provision.subprocess.run"),
patch(
"api.auth.provision._make_gitea_client",
return_value=_mock_gitea_client(),
),
patch("api.clients.rustfs.RustFSClient", return_value=s3),
patch("api.clients.rustfs.RustFSAdmin", return_value=admin),
patch("api.clients.woodpecker.WoodpeckerClient", return_value=wp),
patch("httpx.put", return_value=MagicMock(status_code=200)),
):
result = bootstrap(ROOT, COMMIT, env_path) result = bootstrap(ROOT, COMMIT, env_path)
# All non-SKIP credentials should be in result assert result.env_written
expected_vars = { expected_vars = {
c.env_var for c in CREDENTIALS if c.provisioner is not Provisioner.SKIP c.env_var for c in CREDENTIALS if c.provisioner is not Provisioner.SKIP
} }
assert expected_vars == set(result.keys())
# .env file should contain all credentials
env_content = env_path.read_text() env_content = env_path.read_text()
for var in expected_vars: for var in expected_vars:
assert f"{var}=" in env_content, f"Missing {var} in .env" assert f"{var}=" in env_content, f"Missing {var} in .env"
# GITEA_TOKEN should be the mocked token
assert result["GITEA_TOKEN"] == "tok-e2e-123"
# Total count: 21 credentials (minus 2 SKIP = 19 derived + GITEA_TOKEN overwritten)
assert len(result) == len(expected_vars)
def test_bootstrap_idempotent(self, tmp_path): def test_bootstrap_idempotent(self, tmp_path):
"""Running bootstrap twice produces the same .env."""
env_path = tmp_path / ".env" env_path = tmp_path / ".env"
def run_bootstrap(): def run():
s3, admin = _mock_rustfs() with _bootstrap_patches():
wp = _mock_woodpecker() bootstrap(ROOT, COMMIT, env_path)
with ( return env_path.read_text()
patch("api.auth.provision.subprocess.run"),
patch(
"api.auth.provision._make_gitea_client",
return_value=_mock_gitea_client(),
),
patch("api.clients.rustfs.RustFSClient", return_value=s3),
patch("api.clients.rustfs.RustFSAdmin", return_value=admin),
patch("api.clients.woodpecker.WoodpeckerClient", return_value=wp),
patch(
"httpx.put",
return_value=MagicMock(status_code=200),
),
):
return bootstrap(ROOT, COMMIT, env_path)
first = run_bootstrap() first = run()
content_first = env_path.read_text() second = run()
assert first == second
second = run_bootstrap()
content_second = env_path.read_text()
# Derived values should be identical (deterministic)
for var in first:
if var != "GITEA_TOKEN":
assert first[var] == second[var], f"{var} differs between runs"
# .env content should be identical (same sorted keys, same values)
assert content_first == content_second
def test_skip_backends_skips_all(self, tmp_path): def test_skip_backends_skips_all(self, tmp_path):
"""skip_backends=True should only derive + write .env."""
env_path = tmp_path / ".env" env_path = tmp_path / ".env"
result = bootstrap(ROOT, COMMIT, env_path, skip_backends=True) result = bootstrap(ROOT, COMMIT, env_path, skip_backends=True)
assert env_path.exists() assert env_path.exists()
assert len(result) == len( assert result.env_written
[c for c in CREDENTIALS if c.provisioner is not Provisioner.SKIP] assert not result.postgres
) assert not result.gitea
def test_credential_count(self): def test_credential_count(self):
"""Verify credential count matches manifest."""
skip_count = sum(1 for c in CREDENTIALS if c.provisioner is Provisioner.SKIP) skip_count = sum(1 for c in CREDENTIALS if c.provisioner is Provisioner.SKIP)
derived_count = len(CREDENTIALS) - skip_count
assert len(CREDENTIALS) == 20 assert len(CREDENTIALS) == 20
assert derived_count == 18 assert len(CREDENTIALS) - skip_count == 18
def test_derive_all_count(self): def test_derive_all_count(self):
"""Derive returns all non-SKIP credentials."""
values = derive_all(ROOT, COMMIT) values = derive_all(ROOT, COMMIT)
skip_count = sum(1 for c in CREDENTIALS if c.provisioner is Provisioner.SKIP) skip_count = sum(1 for c in CREDENTIALS if c.provisioner is Provisioner.SKIP)
assert len(values) == len(CREDENTIALS) - skip_count assert len(values) == len(CREDENTIALS) - skip_count
def test_gitea_token_is_derived(self): def test_gitea_token_is_derived(self):
"""GITEA_TOKEN should be in derived values."""
values = derive_all(ROOT, COMMIT) values = derive_all(ROOT, COMMIT)
assert "GITEA_TOKEN" in values assert "GITEA_TOKEN" in values

View File

@@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import patch from unittest.mock import MagicMock, patch
import pytest
from api.auth.manifest import ( from api.auth.manifest import (
CREDENTIALS, CREDENTIALS,
@@ -13,7 +15,13 @@ from api.auth.manifest import (
Provisioner, Provisioner,
Tier, Tier,
) )
from api.auth.provision import derive_all, write_env from api.auth.provision import (
WOODPECKER_SECRET_MAP,
derive_all,
provision,
provision_woodpecker,
write_env,
)
ROOT = bytes.fromhex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ROOT = bytes.fromhex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
COMMIT = "abc1234" COMMIT = "abc1234"
@@ -88,11 +96,9 @@ class TestDeriveAll:
def test_different_commit_different_service_values(self): def test_different_commit_different_service_values(self):
a = derive_all(ROOT, "commit-a") a = derive_all(ROOT, "commit-a")
b = derive_all(ROOT, "commit-b") b = derive_all(ROOT, "commit-b")
# Bootstrap values should be the same
for cred in CREDENTIALS: for cred in CREDENTIALS:
if cred.tier is Tier.BOOTSTRAP and cred.provisioner is not Provisioner.SKIP: if cred.tier is Tier.BOOTSTRAP and cred.provisioner is not Provisioner.SKIP:
assert a[cred.env_var] == b[cred.env_var] assert a[cred.env_var] == b[cred.env_var]
# At least one service value should differ
service_vars = [ service_vars = [
c.env_var c.env_var
for c in CREDENTIALS for c in CREDENTIALS
@@ -137,7 +143,7 @@ class TestWriteEnv:
p = tmp_path / ".env" p = tmp_path / ".env"
write_env({"Z": "3", "A": "1", "M": "2"}, p) write_env({"Z": "3", "A": "1", "M": "2"}, p)
lines = p.read_text().splitlines() lines = p.read_text().splitlines()
keys = [l.split("=")[0] for l in lines] keys = [ln.split("=")[0] for ln in lines]
assert keys == sorted(keys) assert keys == sorted(keys)
def test_skips_comments_and_blanks(self, tmp_path): def test_skips_comments_and_blanks(self, tmp_path):
@@ -147,12 +153,11 @@ class TestWriteEnv:
content = p.read_text() content = p.read_text()
assert "KEY=val" in content assert "KEY=val" in content
assert "NEW=v" in content assert "NEW=v" in content
# Comments are not preserved (by design)
assert "# comment" not in content assert "# comment" not in content
class TestProvisionPostgres: class TestProvisionPostgres:
def test_calls_docker_exec(self): def test_uses_psql_variable_for_password(self):
values = derive_all(ROOT, COMMIT) values = derive_all(ROOT, COMMIT)
with patch("api.auth.provision.subprocess.run") as mock_run: with patch("api.auth.provision.subprocess.run") as mock_run:
from api.auth.provision import provision_postgres from api.auth.provision import provision_postgres
@@ -160,32 +165,136 @@ class TestProvisionPostgres:
provision_postgres(values, container="test-pg") provision_postgres(values, container="test-pg")
assert mock_run.call_count == len(POSTGRES_ROLES) assert mock_run.call_count == len(POSTGRES_ROLES)
for call in mock_run.call_args_list: for c in mock_run.call_args_list:
cmd = call[0][0] cmd = c[0][0]
assert cmd[:3] == ["docker", "exec", "test-pg"] assert cmd[:3] == ["docker", "exec", "test-pg"]
assert "ALTER ROLE" in cmd[-1] assert "-v" in cmd
assert ":'pw'" in cmd[-1]
def test_no_password_in_sql(self):
values = derive_all(ROOT, COMMIT)
with patch("api.auth.provision.subprocess.run") as mock_run:
from api.auth.provision import provision_postgres
provision_postgres(values, container="pg")
for c in mock_run.call_args_list:
sql = c[0][0][-1]
for env_var in POSTGRES_ROLES.values():
assert values[env_var] not in sql
class TestProvisionGitea: class TestProvisionGitea:
def test_calls_change_password(self): def test_uses_api_not_docker_exec(self):
values = derive_all(ROOT, COMMIT) values = derive_all(ROOT, COMMIT)
mock_client = type("C", (), {})() values["GITEA_TOKEN"] = "current-token"
mock_client.get = lambda *a, **kw: type("R", (), {"json": lambda self: []})() mock_client = MagicMock()
mock_client.post = lambda *a, **kw: type( mock_client.get.return_value.json.return_value = []
"R", (), {"json": lambda self: {"sha1": "tok123"}} mock_client.post.return_value.json.return_value = {"sha1": "new-tok"}
)()
with ( with patch("api.auth.provision.GiteaClient", return_value=mock_client):
patch("api.auth.provision.subprocess.run") as mock_run,
patch(
"api.auth.provision._make_gitea_client",
return_value=mock_client,
),
):
from api.auth.provision import provision_gitea from api.auth.provision import provision_gitea
token = provision_gitea(values, container="test-gitea") token = provision_gitea(values, base_url="http://test:3000/api/v1")
assert token == "tok123" assert token == "new-tok"
assert mock_run.call_count == 1 mock_client.change_admin_password.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "change-password" in cmd def test_creates_fresh_token(self):
values = derive_all(ROOT, COMMIT)
values["GITEA_TOKEN"] = "old-token"
mock_client = MagicMock()
mock_client.get.return_value.json.return_value = [
{"id": 1, "name": "deploy-old"},
{"id": 2, "name": "other-token"},
]
mock_client.post.return_value.json.return_value = {"sha1": "fresh"}
with patch("api.auth.provision.GiteaClient", return_value=mock_client):
from api.auth.provision import provision_gitea
token = provision_gitea(values)
assert token == "fresh"
mock_client.delete.assert_called_once()
class TestProvisionWoodpecker:
def test_updates_secrets(self):
values = derive_all(ROOT, COMMIT)
values["GITEA_TOKEN"] = "tok"
mock_wp = MagicMock()
with patch("api.auth.provision.WoodpeckerClient", return_value=mock_wp):
provision_woodpecker(values, repo_id=1)
assert mock_wp.update_secret.call_count == len(WOODPECKER_SECRET_MAP)
def test_creates_on_update_failure(self):
values = derive_all(ROOT, COMMIT)
values["GITEA_TOKEN"] = "tok"
mock_wp = MagicMock()
mock_wp.update_secret.side_effect = Exception("not found")
with patch("api.auth.provision.WoodpeckerClient", return_value=mock_wp):
provision_woodpecker(values, repo_id=1)
assert mock_wp.create_secret.call_count == len(WOODPECKER_SECRET_MAP)
def test_raises_without_token(self):
with pytest.raises(ValueError, match="GITEA_TOKEN"):
provision_woodpecker({}, repo_id=1)
class TestProvisionEndToEnd:
def test_partial_failure_still_writes_env(self, tmp_path):
env = tmp_path / ".env"
with (
patch(
"api.auth.provision.provision_postgres",
side_effect=Exception("pg down"),
),
patch(
"api.auth.provision.provision_gitea",
side_effect=Exception("gitea down"),
),
patch(
"api.auth.provision.provision_woodpecker",
side_effect=Exception("wp down"),
),
patch("api.auth.provision.time.sleep"),
):
result = provision(ROOT, COMMIT, env)
assert result.env_written
assert env.exists()
assert len(result.errors) == 3
assert not result.postgres
assert not result.gitea
assert not result.woodpecker
def test_full_success(self, tmp_path):
env = tmp_path / ".env"
with (
patch("api.auth.provision.provision_postgres"),
patch("api.auth.provision.provision_gitea", return_value="tok-new"),
patch("api.auth.provision.provision_woodpecker"),
patch("api.auth.provision.time.sleep"),
):
result = provision(ROOT, COMMIT, env)
assert result.ok
assert result.postgres
assert result.gitea
assert result.woodpecker
assert result.env_written
content = env.read_text()
assert "GITEA_TOKEN=tok-new" in content
def test_skip_backends(self, tmp_path):
env = tmp_path / ".env"
result = provision(ROOT, COMMIT, env, skip_backends=True)
assert result.ok
assert result.env_written
assert not result.postgres
assert not result.gitea