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
import logging
import os
import sys
from pathlib import Path
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
args = sys.argv[1:]
if len(args) < 2 or args[0] not in ("bootstrap", "provision", "derive"):
print(__doc__.strip(), file=sys.stderr)
@@ -50,27 +53,29 @@ def main() -> int:
return 0
if command == "provision":
from api.auth.provision import provision
from api.auth.provision import derive_all, provision
provision(root_key, commit_sha, env_path)
print(f"Provisioned {len(derive_all_count(root_key, commit_sha))} credentials")
result = provision(root_key, commit_sha, env_path)
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
if command == "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")
if result.errors:
for backend, err in result.errors:
print(f" FAILED: {backend}{err}", file=sys.stderr)
return 0
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__":
sys.exit(main())

View File

@@ -3,8 +3,10 @@
from __future__ import annotations
import base64
import logging
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from api.auth.derive import derive_hex, derive_password
@@ -17,8 +19,29 @@ from api.auth.manifest import (
Provisioner,
Tier,
)
from api.clients.gitea import GiteaClient
from api.clients.woodpecker import WoodpeckerClient
log = logging.getLogger(__name__)
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:
@@ -57,8 +80,36 @@ def write_env(values: dict[str, str], path: Path) -> None:
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:
"""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():
pw = values[env_var]
subprocess.run(
@@ -69,8 +120,10 @@ def provision_postgres(values: dict[str, str], *, container: str = "postgres") -
"psql",
"-U",
"postgres",
"-v",
f"pw={pw}",
"-c",
f"ALTER ROLE {role} PASSWORD '{pw}'",
f"ALTER ROLE {role} PASSWORD :'pw'",
],
check=True,
capture_output=True,
@@ -88,8 +141,10 @@ def bootstrap_postgres(values: dict[str, str], *, container: str = "postgres") -
"psql",
"-U",
"postgres",
"-v",
f"pw={superuser_pw}",
"-c",
f"ALTER ROLE postgres PASSWORD '{superuser_pw}'",
"ALTER ROLE postgres PASSWORD :'pw'",
],
check=True,
capture_output=True,
@@ -100,9 +155,12 @@ def bootstrap_postgres(values: dict[str, str], *, container: str = "postgres") -
sql = (
f"DO $$ BEGIN "
f"IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='{role}') "
f"THEN CREATE ROLE {role} LOGIN PASSWORD '{pw}'; END IF; END $$; "
f"SELECT 'CREATE DATABASE {db} OWNER {role}' "
f"WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname='{db}');"
f"THEN CREATE ROLE {role} LOGIN; END IF; END $$;"
)
subprocess.run(
["docker", "exec", container, "psql", "-U", "postgres", "-c", sql],
check=True,
capture_output=True,
)
subprocess.run(
[
@@ -112,14 +170,14 @@ def bootstrap_postgres(values: dict[str, str], *, container: str = "postgres") -
"psql",
"-U",
"postgres",
"-v",
f"pw={pw}",
"-c",
sql,
f"ALTER ROLE {role} PASSWORD :'pw'",
],
check=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(
[
"docker",
@@ -135,39 +193,31 @@ def bootstrap_postgres(values: dict[str, str], *, container: str = "postgres") -
)
# ── Gitea provisioning ──────────────────────────────────────────
def provision_gitea(
values: dict[str, str],
*,
container: str = "gitea",
admin_user: str = "kert",
base_url: str = "http://gitea:3000/api/v1",
) -> 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"]
# Change admin password
subprocess.run(
[
"docker",
"exec",
container,
"gitea",
"admin",
"user",
"change-password",
"-u",
admin_user,
"-p",
pw,
],
check=True,
capture_output=True,
)
current_token = values.get("GITEA_TOKEN", "")
if current_token:
client = GiteaClient(current_token, base_url=base_url)
client.change_admin_password(admin_user, pw)
client.close()
# Delete stale deploy tokens then create a fresh one, via API
auth_header = _basic_header(admin_user, pw)
client = _make_gitea_client(container, auth_header)
encoded = base64.b64encode(f"{admin_user}:{pw}".encode()).decode()
auth_header = f"Basic {encoded}"
client = GiteaClient("unused", base_url=base_url)
# List and delete stale deploy-* tokens
tokens = client.get(
f"/users/{admin_user}/tokens",
headers={"Authorization": auth_header},
@@ -179,28 +229,56 @@ def provision_gitea(
headers={"Authorization": auth_header},
)
# Create fresh token
token_name = f"deploy-{int(time.time())}"
resp = client.post(
f"/users/{admin_user}/tokens",
json={"name": token_name, "scopes": ["all"]},
headers={"Authorization": auth_header},
).json()
client.close()
return resp["sha1"]
def _basic_header(user: str, password: str) -> str:
encoded = base64.b64encode(f"{user}:{password}".encode()).decode()
return f"Basic {encoded}"
# ── Woodpecker secret sync ──────────────────────────────────────
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):
from api.clients.gitea import GiteaClient
def provision_woodpecker(
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(
"unused",
base_url=f"http://{container}:3000/api/v1",
)
wp = WoodpeckerClient(token, base_url=base_url)
for secret_name, env_var in WOODPECKER_SECRET_MAP.items():
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(
@@ -209,17 +287,45 @@ def provision(
env_path: Path,
*,
skip_backends: bool = False,
) -> dict[str, str]:
"""Main entry: derive, provision backends, write .env."""
) -> ProvisionResult:
"""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)
if not skip_backends:
provision_postgres(values)
token = provision_gitea(values)
values["GITEA_TOKEN"] = token
try:
_retry(lambda: provision_postgres(values), "postgres")
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)
return values
result.env_written = True
log.info("Wrote %d credentials to %s", len(values), env_path)
return result
def bootstrap(
@@ -228,15 +334,35 @@ def bootstrap(
env_path: Path,
*,
skip_backends: bool = False,
) -> dict[str, str]:
) -> ProvisionResult:
"""First-time setup: create DBs, roles, then provision."""
result = ProvisionResult()
values = derive_all(root_key, commit_sha)
if not skip_backends:
bootstrap_postgres(values)
provision_postgres(values)
token = provision_gitea(values)
values["GITEA_TOKEN"] = token
try:
bootstrap_postgres(values)
provision_postgres(values)
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)
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.provision.provision") as mock_prov,
):
mock_prov.return_value = None
# provision has a bug referencing derive_all_count — test that path
# We expect either success or the NameError
from api.auth.provision import ProvisionResult
mock_prov.return_value = ProvisionResult(
postgres=True, gitea=True, env_written=True
)
result = main()
assert result == 0 or mock_prov.called
assert result == 0
@pytest.mark.usefixtures("_valid_env")
def test_bootstrap(self, tmp_path):
@@ -93,5 +95,7 @@ class TestMain:
patch("api.auth.__main__.Path", return_value=env_file),
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

View File

@@ -1,11 +1,8 @@
"""End-to-end bootstrap integration test.
Tests the full bootstrap() flow with all backends mocked:
ROOT_KEY → derive → provision all backends → .env
"""
"""End-to-end bootstrap integration test."""
from __future__ import annotations
import contextlib
from unittest.mock import MagicMock, patch
from api.auth.manifest import CREDENTIALS, Provisioner
@@ -19,122 +16,64 @@ def _mock_gitea_client():
mock = MagicMock()
mock.get.return_value.json.return_value = []
mock.post.return_value.json.return_value = {"sha1": "tok-e2e-123"}
mock.change_admin_password.return_value = {}
return mock
def _mock_rustfs():
s3 = MagicMock()
s3.bucket_exists.return_value = False
admin = MagicMock()
admin.add_user.return_value = {}
admin.add_policy.return_value = {}
return s3, admin
def _mock_woodpecker():
mock = MagicMock()
mock.list_global_secrets.return_value = []
mock.create_global_secret.return_value = {}
return mock
@contextlib.contextmanager
def _bootstrap_patches():
with (
patch("api.auth.provision.subprocess.run"),
patch("api.auth.provision.GiteaClient", return_value=_mock_gitea_client()),
patch("api.auth.provision.WoodpeckerClient", return_value=MagicMock()),
):
yield
class TestBootstrapE2E:
def test_full_bootstrap_writes_all_credentials(self, tmp_path):
env_path = tmp_path / ".env"
s3, admin = _mock_rustfs()
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)),
):
with _bootstrap_patches():
result = bootstrap(ROOT, COMMIT, env_path)
# All non-SKIP credentials should be in result
assert result.env_written
expected_vars = {
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()
for var in expected_vars:
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):
"""Running bootstrap twice produces the same .env."""
env_path = tmp_path / ".env"
def run_bootstrap():
s3, admin = _mock_rustfs()
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),
),
):
return bootstrap(ROOT, COMMIT, env_path)
def run():
with _bootstrap_patches():
bootstrap(ROOT, COMMIT, env_path)
return env_path.read_text()
first = run_bootstrap()
content_first = env_path.read_text()
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
first = run()
second = run()
assert first == second
def test_skip_backends_skips_all(self, tmp_path):
"""skip_backends=True should only derive + write .env."""
env_path = tmp_path / ".env"
result = bootstrap(ROOT, COMMIT, env_path, skip_backends=True)
assert env_path.exists()
assert len(result) == len(
[c for c in CREDENTIALS if c.provisioner is not Provisioner.SKIP]
)
assert result.env_written
assert not result.postgres
assert not result.gitea
def test_credential_count(self):
"""Verify credential count matches manifest."""
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 derived_count == 18
assert len(CREDENTIALS) - skip_count == 18
def test_derive_all_count(self):
"""Derive returns all non-SKIP credentials."""
values = derive_all(ROOT, COMMIT)
skip_count = sum(1 for c in CREDENTIALS if c.provisioner is Provisioner.SKIP)
assert len(values) == len(CREDENTIALS) - skip_count
def test_gitea_token_is_derived(self):
"""GITEA_TOKEN should be in derived values."""
values = derive_all(ROOT, COMMIT)
assert "GITEA_TOKEN" in values

View File

@@ -2,7 +2,9 @@
from __future__ import annotations
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from api.auth.manifest import (
CREDENTIALS,
@@ -13,7 +15,13 @@ from api.auth.manifest import (
Provisioner,
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")
COMMIT = "abc1234"
@@ -88,11 +96,9 @@ class TestDeriveAll:
def test_different_commit_different_service_values(self):
a = derive_all(ROOT, "commit-a")
b = derive_all(ROOT, "commit-b")
# Bootstrap values should be the same
for cred in CREDENTIALS:
if cred.tier is Tier.BOOTSTRAP and cred.provisioner is not Provisioner.SKIP:
assert a[cred.env_var] == b[cred.env_var]
# At least one service value should differ
service_vars = [
c.env_var
for c in CREDENTIALS
@@ -137,7 +143,7 @@ class TestWriteEnv:
p = tmp_path / ".env"
write_env({"Z": "3", "A": "1", "M": "2"}, p)
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)
def test_skips_comments_and_blanks(self, tmp_path):
@@ -147,12 +153,11 @@ class TestWriteEnv:
content = p.read_text()
assert "KEY=val" in content
assert "NEW=v" in content
# Comments are not preserved (by design)
assert "# comment" not in content
class TestProvisionPostgres:
def test_calls_docker_exec(self):
def test_uses_psql_variable_for_password(self):
values = derive_all(ROOT, COMMIT)
with patch("api.auth.provision.subprocess.run") as mock_run:
from api.auth.provision import provision_postgres
@@ -160,32 +165,136 @@ class TestProvisionPostgres:
provision_postgres(values, container="test-pg")
assert mock_run.call_count == len(POSTGRES_ROLES)
for call in mock_run.call_args_list:
cmd = call[0][0]
for c in mock_run.call_args_list:
cmd = c[0][0]
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:
def test_calls_change_password(self):
def test_uses_api_not_docker_exec(self):
values = derive_all(ROOT, COMMIT)
mock_client = type("C", (), {})()
mock_client.get = lambda *a, **kw: type("R", (), {"json": lambda self: []})()
mock_client.post = lambda *a, **kw: type(
"R", (), {"json": lambda self: {"sha1": "tok123"}}
)()
with (
patch("api.auth.provision.subprocess.run") as mock_run,
patch(
"api.auth.provision._make_gitea_client",
return_value=mock_client,
),
):
values["GITEA_TOKEN"] = "current-token"
mock_client = MagicMock()
mock_client.get.return_value.json.return_value = []
mock_client.post.return_value.json.return_value = {"sha1": "new-tok"}
with patch("api.auth.provision.GiteaClient", return_value=mock_client):
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 mock_run.call_count == 1
cmd = mock_run.call_args[0][0]
assert "change-password" in cmd
assert token == "new-tok"
mock_client.change_admin_password.assert_called_once()
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