remove: scrub all woodpecker references from codebase
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.
This commit is contained in:
kert
2026-04-18 18:51:21 -04:00
parent 0ed1d2c31f
commit bd8cda1931
17 changed files with 105 additions and 543 deletions

View File

@@ -125,9 +125,7 @@ up mc docs api notebooks zotero
# ── Tier 3: CI (depends on Gitea) ────────────────────────────────── # ── Tier 3: CI (depends on Gitea) ──────────────────────────────────
log "Tier 3: CI" log "Tier 3: CI"
up woodpecker-server act-runner up act-runner
wait_running woodpecker-server
up woodpecker-agent
# ── Tier 4: Observability ────────────────────────────────────────── # ── Tier 4: Observability ──────────────────────────────────────────
log "Tier 4: Observability" log "Tier 4: Observability"

View File

@@ -3,11 +3,10 @@
Run once after cloning the repo to set up all credentials across: Run once after cloning the repo to set up all credentials across:
- .env file (all secrets) - .env file (all secrets)
- PostgreSQL roles and databases - PostgreSQL roles and databases
- Woodpecker CI repo secrets
- RustFS IAM (via mc) - RustFS IAM (via mc)
Prerequisites: Prerequisites:
- docker compose services running (postgres, gitea, rustfs, woodpecker-server) - docker compose services running (postgres, gitea, rustfs)
- No prior .env (or willing to overwrite managed keys) - No prior .env (or willing to overwrite managed keys)
Usage: Usage:
@@ -35,12 +34,10 @@ SECRETS = {
"POSTGRES_PASSWORD": 24, "POSTGRES_PASSWORD": 24,
"GITEA_DB_PASSWORD": 24, "GITEA_DB_PASSWORD": 24,
"GITEA_ADMIN_PASSWORD": 24, "GITEA_ADMIN_PASSWORD": 24,
"WOODPECKER_DB_PASSWORD": 24,
"NESSIE_DB_PASSWORD": 24, "NESSIE_DB_PASSWORD": 24,
"POLARIS_DB_PASSWORD": 24, "POLARIS_DB_PASSWORD": 24,
"RUSTFS_ACCESS_KEY": 32, "RUSTFS_ACCESS_KEY": 32,
"RUSTFS_SECRET_KEY": 32, "RUSTFS_SECRET_KEY": 32,
"WOODPECKER_AGENT_SECRET": 32,
"POLARIS_ROOT_SECRET": 24, "POLARIS_ROOT_SECRET": 24,
"GF_ADMIN_PASSWORD": 24, "GF_ADMIN_PASSWORD": 24,
"ROOT_KEY": 32, "ROOT_KEY": 32,
@@ -50,29 +47,16 @@ SECRETS = {
HEX_SECRETS = { HEX_SECRETS = {
"RUSTFS_ACCESS_KEY", "RUSTFS_ACCESS_KEY",
"RUSTFS_SECRET_KEY", "RUSTFS_SECRET_KEY",
"WOODPECKER_AGENT_SECRET",
"ROOT_KEY", "ROOT_KEY",
} }
# PostgreSQL roles: role_name → (env_var for password, database_name) # PostgreSQL roles: role_name → (env_var for password, database_name)
PG_ROLES = { PG_ROLES = {
"git": ("GITEA_DB_PASSWORD", "gitea"), "git": ("GITEA_DB_PASSWORD", "gitea"),
"woodpecker": ("WOODPECKER_DB_PASSWORD", "woodpecker"),
"nessie": ("NESSIE_DB_PASSWORD", "nessie"), "nessie": ("NESSIE_DB_PASSWORD", "nessie"),
"polaris": ("POLARIS_DB_PASSWORD", "polaris"), "polaris": ("POLARIS_DB_PASSWORD", "polaris"),
} }
# Woodpecker secrets: wp_secret_name → env_var
WP_SECRETS = {
"registry_user": ("kert", None), # static value, not from .env
"registry_pass": (None, "GITEA_ADMIN_PASSWORD"),
"gitea_token": (None, "GITEA_TOKEN"),
"gitea_url": (None, "_GITEA_SERVICE_URL"), # resolved from cfg below
"s3_access_key": (None, "RUSTFS_ACCESS_KEY"),
"s3_secret_key": (None, "RUSTFS_SECRET_KEY"),
"root_key": (None, "ROOT_KEY"),
}
# Alias mappings: these .env vars copy from another # Alias mappings: these .env vars copy from another
ALIASES = { ALIASES = {
"GITEA_S3_ACCESS_KEY": "RUSTFS_ACCESS_KEY", "GITEA_S3_ACCESS_KEY": "RUSTFS_ACCESS_KEY",
@@ -224,46 +208,6 @@ def bootstrap_gitea(env: dict[str, str]) -> list[str]:
return errors return errors
def bootstrap_woodpecker(env: dict[str, str]) -> list[str]:
"""Set Woodpecker repo secrets via database."""
from conf import cfg
env.setdefault("_GITEA_SERVICE_URL", cfg.services.gitea)
errors = []
wp_pw = env.get("WOODPECKER_DB_PASSWORD", "")
for name, (static_val, env_var) in WP_SECRETS.items():
val = static_val if static_val else env.get(env_var, "")
if not val:
errors.append(f"No value for WP secret {name}")
continue
events = '["push","pull_request","cron","manual","deployment","tag","release"]'
r = subprocess.run(
[
"docker",
"exec",
"-e",
f"PGPASSWORD={wp_pw}",
"postgres",
"psql",
"-U",
"woodpecker",
"-d",
"woodpecker",
"-c",
f"INSERT INTO secrets (repo_id, name, value, events) "
f"VALUES (1, '{name}', '{val}', '{events}') "
f"ON CONFLICT (repo_id, name) DO UPDATE SET value='{val}', events='{events}';",
],
capture_output=True,
text=True,
)
if r.returncode != 0:
errors.append(f"WP secret {name}: {r.stderr.strip()}")
else:
log.info("WP secret %s set", name)
return errors
def verify(env: dict[str, str]) -> list[str]: def verify(env: dict[str, str]) -> list[str]:
"""Verify all secrets are consistent across systems.""" """Verify all secrets are consistent across systems."""
errors = [] errors = []
@@ -342,7 +286,7 @@ def verify(env: dict[str, str]) -> list[str]:
def bootstrap() -> int: def bootstrap() -> int:
"""Full bootstrap: generate → postgres → gitea → woodpecker → verify.""" """Full bootstrap: generate → postgres → gitea → verify."""
env = generate_secrets() env = generate_secrets()
_write_env(env, ENV_PATH) _write_env(env, ENV_PATH)
log.info("Wrote %d keys to %s", len(env), ENV_PATH) log.info("Wrote %d keys to %s", len(env), ENV_PATH)
@@ -351,7 +295,6 @@ def bootstrap() -> int:
all_errors.extend(bootstrap_postgres(env)) all_errors.extend(bootstrap_postgres(env))
all_errors.extend(bootstrap_gitea(env)) all_errors.extend(bootstrap_gitea(env))
_write_env(env, ENV_PATH) # re-write with GITEA_TOKEN _write_env(env, ENV_PATH) # re-write with GITEA_TOKEN
all_errors.extend(bootstrap_woodpecker(env))
# Verify # Verify
log.info("Verifying...") log.info("Verifying...")

View File

@@ -1,7 +1,7 @@
"""Generate all derived config files from stack.toml. """Generate all derived config files from stack.toml.
Reads [platform], [services], [images], and [ci] from stack.toml. Reads [platform], [services], [images], and [ci] from stack.toml.
Dispatches to the active CI backend emitter (woodpecker or github) Dispatches to the active CI backend emitter (gitea or github)
for pipeline YAML, plus generates coredns config. for pipeline YAML, plus generates coredns config.
Usage:: Usage::
@@ -81,9 +81,8 @@ def gen_coredns() -> dict[str, str]:
# ── Backend dispatch ───────────────────────────────────────────── # ── Backend dispatch ─────────────────────────────────────────────
STALE_DIRS: dict[str, list[str]] = { STALE_DIRS: dict[str, list[str]] = {
"woodpecker": [".github/workflows", ".gitea/workflows"], "github": [".gitea/workflows"],
"github": [".woodpecker", ".gitea/workflows"], "gitea": [".github/workflows"],
"gitea": [".woodpecker", ".github/workflows"],
} }
@@ -98,9 +97,7 @@ def _emit_backend(backend: str) -> dict[str, str]:
# Propagate top-level coverage_threshold so each backend can read it. # Propagate top-level coverage_threshold so each backend can read it.
ci_cfg.setdefault("coverage_threshold", ci_data.get("coverage_threshold", 99)) ci_cfg.setdefault("coverage_threshold", ci_data.get("coverage_threshold", 99))
if backend == "woodpecker": if backend == "github":
from backends.woodpecker import emit
elif backend == "github":
from backends.github import emit from backends.github import emit
elif backend == "gitea": elif backend == "gitea":
from backends.gitea import emit from backends.gitea import emit
@@ -147,7 +144,7 @@ def _emit_dab(*, sql: bool = False) -> dict[str, str]:
def generate(backend: str | None = None, *, dab_sql: bool = False) -> dict[str, str]: def generate(backend: str | None = None, *, dab_sql: bool = False) -> dict[str, str]:
if backend is None: if backend is None:
backend = cfg._data.get("ci", {}).get("backend", "woodpecker") backend = cfg._data.get("ci", {}).get("backend", "gitea")
files: dict[str, str] = {} files: dict[str, str] = {}
files.update(gen_coredns()) files.update(gen_coredns())
@@ -218,7 +215,7 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--backend", "--backend",
default=None, default=None,
help="Override CI backend (gitea|github|woodpecker)", help="Override CI backend (gitea|github)",
) )
parser.add_argument( parser.add_argument(
"--dab-sql", "--dab-sql",
@@ -251,7 +248,7 @@ def main() -> int:
write_all(files) write_all(files)
# Clean stale files from inactive backend # Clean stale files from inactive backend
backend = args.backend or cfg._data.get("ci", {}).get("backend", "woodpecker") backend = args.backend or cfg._data.get("ci", {}).get("backend", "gitea")
stale = _clean_stale(backend, files) stale = _clean_stale(backend, files)
for s in stale: for s in stale:
(ROOT / s).unlink() (ROOT / s).unlink()

View File

@@ -156,7 +156,6 @@ def _collect_workflows() -> dict:
workflow_dirs = [ workflow_dirs = [
ROOT / ".gitea" / "workflows", ROOT / ".gitea" / "workflows",
ROOT / ".github" / "workflows", ROOT / ".github" / "workflows",
ROOT / ".woodpecker",
] ]
workflows = [] workflows = []
for d in workflow_dirs: for d in workflow_dirs:

View File

@@ -1,14 +1,13 @@
"""Two-phase deploy: rotate backends FIRST, then restart services. """Two-phase deploy: rotate backends FIRST, then restart services.
Called from the Woodpecker deploy step to ensure credential consistency: Called from the Gitea Actions deploy step to ensure credential consistency:
1. Derive new credentials from ROOT_KEY + commit SHA 1. Derive new credentials from ROOT_KEY + commit SHA
2. Rotate PostgreSQL passwords (ALTER ROLE) 2. Rotate PostgreSQL passwords (ALTER ROLE)
3. Rotate Gitea admin password + create fresh token 3. Rotate Gitea admin password + create fresh token
4. Write .env with all new credentials 4. Write .env with all new credentials
5. Restart services (docker compose up -d) 5. Restart services (docker compose up -d)
6. Sync Woodpecker secrets 6. Verify all backends are healthy
7. Verify all backends are healthy 7. Rollback on failure
8. Rollback on failure
Usage: Usage:
uv run python -m api.auth deploy <commit_sha> uv run python -m api.auth deploy <commit_sha>
@@ -129,7 +128,7 @@ def deploy(
Phase 1: Rotate backend credentials (ALTER ROLE, Gitea API) Phase 1: Rotate backend credentials (ALTER ROLE, Gitea API)
Phase 2: Write .env + docker compose up -d Phase 2: Write .env + docker compose up -d
Phase 3: Sync Woodpecker secrets + health check Phase 3: Health check
""" """
result = ProvisionResult() result = ProvisionResult()
cwd = compose_dir or env_path.parent cwd = compose_dir or env_path.parent

View File

@@ -49,13 +49,6 @@ CREDENTIALS: tuple[Credential, ...] = (
Format.PASSWORD, Format.PASSWORD,
Provisioner.POSTGRES, Provisioner.POSTGRES,
), ),
Credential(
"WOODPECKER_DB_PASSWORD",
"postgres/woodpecker",
Tier.SERVICE,
Format.PASSWORD,
Provisioner.POSTGRES,
),
Credential( Credential(
"NESSIE_DB_PASSWORD", "NESSIE_DB_PASSWORD",
"postgres/nessie", "postgres/nessie",
@@ -133,28 +126,6 @@ CREDENTIALS: tuple[Credential, ...] = (
Provisioner.ENV_ONLY, Provisioner.ENV_ONLY,
alias_of="RUSTFS_SECRET_KEY", alias_of="RUSTFS_SECRET_KEY",
), ),
# ── Woodpecker ───────────────────────────────────────────
Credential(
"WOODPECKER_AGENT_SECRET",
"woodpecker/agent",
Tier.SERVICE,
Format.HEX,
Provisioner.ENV_ONLY,
),
Credential(
"WOODPECKER_GITEA_CLIENT",
"woodpecker/oauth-client",
Tier.BOOTSTRAP,
Format.HEX,
Provisioner.SKIP,
),
Credential(
"WOODPECKER_GITEA_SECRET",
"woodpecker/oauth-secret",
Tier.BOOTSTRAP,
Format.HEX,
Provisioner.SKIP,
),
# ── Others ─────────────────────────────────────────────── # ── Others ───────────────────────────────────────────────
Credential( Credential(
"POLARIS_ROOT_SECRET", "POLARIS_ROOT_SECRET",
@@ -186,14 +157,12 @@ MANAGED_VARS: frozenset[str] = frozenset(c.env_var for c in CREDENTIALS)
POSTGRES_ROLES: dict[str, str] = { POSTGRES_ROLES: dict[str, str] = {
"git": "GITEA_DB_PASSWORD", "git": "GITEA_DB_PASSWORD",
"woodpecker": "WOODPECKER_DB_PASSWORD",
"nessie": "NESSIE_DB_PASSWORD", "nessie": "NESSIE_DB_PASSWORD",
"polaris": "POLARIS_DB_PASSWORD", "polaris": "POLARIS_DB_PASSWORD",
} }
POSTGRES_DATABASES: dict[str, str] = { POSTGRES_DATABASES: dict[str, str] = {
"git": "gitea", "git": "gitea",
"woodpecker": "woodpecker",
"nessie": "nessie", "nessie": "nessie",
"polaris": "polaris", "polaris": "polaris",
} }

View File

@@ -20,7 +20,6 @@ from api.auth.manifest import (
Tier, Tier,
) )
from api.clients.gitea import GiteaClient from api.clients.gitea import GiteaClient
from api.clients.woodpecker import WoodpeckerClient
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -35,7 +34,6 @@ class ProvisionResult:
postgres: bool = False postgres: bool = False
gitea: bool = False gitea: bool = False
woodpecker: bool = False
env_written: bool = False env_written: bool = False
errors: list[tuple[str, Exception]] = field(default_factory=list) errors: list[tuple[str, Exception]] = field(default_factory=list)
@@ -252,82 +250,6 @@ def provision_gitea(
return resp["sha1"] return resp["sha1"]
# ── 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 _get_woodpecker_token(
values: dict[str, str], *, container: str = "postgres"
) -> str:
"""Read Woodpecker user API token from its database.
Woodpecker authenticates via Gitea OAuth. Its REST API uses the
user's `hash` field from the woodpecker DB, not a Gitea API token.
"""
wp_db_pw = values.get("WOODPECKER_DB_PASSWORD", "")
result = subprocess.run(
[
"docker",
"exec",
"-e",
f"PGPASSWORD={wp_db_pw}",
container,
"psql",
"-U",
"woodpecker",
"-d",
"woodpecker",
"-t",
"-A",
"-c",
"SELECT hash FROM users WHERE login='kert' LIMIT 1",
],
capture_output=True,
text=True,
)
token = result.stdout.strip()
if not token:
raise ValueError("Could not read Woodpecker token from DB")
return token
def provision_woodpecker(
values: dict[str, str],
*,
repo_id: int = 1,
base_url: str = "",
) -> None:
"""Sync rotated credentials to Woodpecker repo secrets."""
if not base_url:
from conf import cfg
base_url = f"{cfg.services.woodpecker}/api"
token = _get_woodpecker_token(values)
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()
# ── Gitea Actions secret sync ────────────────────────────────── # ── Gitea Actions secret sync ──────────────────────────────────
# Gitea reserves the GITEA_ prefix for Actions secrets, so the token # Gitea reserves the GITEA_ prefix for Actions secrets, so the token
@@ -408,14 +330,6 @@ def provision(
result.errors.append(("gitea", e)) result.errors.append(("gitea", e))
log.error("Gitea provisioning failed: %s", 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)
try: try:
_retry(lambda: provision_gitea_actions(values), "gitea-actions") _retry(lambda: provision_gitea_actions(values), "gitea-actions")
log.info("Gitea Actions secrets synced") log.info("Gitea Actions secrets synced")
@@ -457,13 +371,6 @@ def bootstrap(
result.errors.append(("gitea", e)) result.errors.append(("gitea", e))
log.error("Gitea bootstrap failed: %s", 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)
try: try:
provision_gitea_actions(values) provision_gitea_actions(values)
log.info("Gitea Actions secrets synced") log.info("Gitea Actions secrets synced")

View File

@@ -1,13 +1,13 @@
"""CI crash reporter — scrape failed Woodpecker step logs and file issues. """CI crash reporter — scrape failed CI step logs and file issues.
Usage (in a Woodpecker ``failure`` step):: Usage (in a Gitea Actions ``failure`` step)::
uv run python -m api.diag uv run python -m api.diag
Reads CI environment variables to locate the failed pipeline, pulls Reads CI environment variables to locate the failed workflow run,
step logs via the Woodpecker API, parses any Python tracebacks, runs parses any Python tracebacks, runs ``git blame`` on the offending
``git blame`` on the offending lines, and files a Gitea issue with lines, and files a Gitea issue with labels for the pipeline name,
labels for the pipeline name, step category, and image (if applicable). step category, and image (if applicable).
""" """
from __future__ import annotations from __future__ import annotations
@@ -91,13 +91,13 @@ def main() -> int:
format="%(levelname)s: %(message)s", format="%(levelname)s: %(message)s",
) )
repo_id = _get_env("CI_REPO_ID")
pipeline_number = _get_env("CI_PIPELINE_NUMBER")
commit_sha = _get_env("CI_COMMIT_SHA") commit_sha = _get_env("CI_COMMIT_SHA")
repo_name = _get_env("CI_REPO") # e.g. "homelab/stack" repo_name = _get_env("CI_REPO") # e.g. "homelab/stack"
step_name = _get_env("CI_STEP_NAME")
log_text = _get_env("CI_STEP_LOG")
if not repo_id or not pipeline_number: if not commit_sha:
log.error("CI_REPO_ID and CI_PIPELINE_NUMBER are required") log.error("CI_COMMIT_SHA is required")
return 1 return 1
gitea_token = os.environ.get("GITEA_TOKEN", "") gitea_token = os.environ.get("GITEA_TOKEN", "")
@@ -114,28 +114,6 @@ def main() -> int:
pipeline_name = _pipeline_name_from_env() pipeline_name = _pipeline_name_from_env()
# Fetch pipeline steps and find failures
from api.clients.woodpecker import WoodpeckerClient
wp = WoodpeckerClient(
gitea_token,
base_url=os.environ.get("CI_WOODPECKER_URL", f"{cfg.services.woodpecker}/api"),
)
rid = int(repo_id)
pnum = int(pipeline_number)
pipeline = wp.get_pipeline(rid, pnum)
steps = pipeline.get("steps", [])
failed_steps = [s for s in steps if s.get("state") == "failure"]
if not failed_steps:
log.info("No failed steps in pipeline %d — nothing to report", pnum)
wp.close()
return 0
# Collect logs from all failed steps
from api.clients.gitea import GiteaClient from api.clients.gitea import GiteaClient
from api.diag.blame import blame_report from api.diag.blame import blame_report
from api.diag.issue import _truncate, build_issue_body from api.diag.issue import _truncate, build_issue_body
@@ -143,38 +121,18 @@ def main() -> int:
gitea = GiteaClient(gitea_token) gitea = GiteaClient(gitea_token)
for step in failed_steps: if not step_name:
step_name = step.get("name", "unknown") step_name = "unknown"
step_id = step.get("id", 0)
log.info("Processing failed step: %s (id=%d)", step_name, step_id)
label_names = _resolve_label_names(pipeline_name, step_name) label_names = _resolve_label_names(pipeline_name, step_name)
labels = gitea.resolve_labels(owner, repo, label_names) labels = gitea.resolve_labels(owner, repo, label_names)
# Fetch logs — Woodpecker returns a list of log line dicts sha_short = (commit_sha or "unknown")[:8]
try:
log_entries = wp.get_logs(rid, pnum, step_id)
except Exception:
log.exception("Failed to fetch logs for step %s", step_name)
continue
# Concatenate log lines
if isinstance(log_entries, list):
log_text = "\n".join(
entry.get("data", "") if isinstance(entry, dict) else str(entry)
for entry in log_entries
)
else:
log_text = str(log_entries)
# Try to parse Python tracebacks # Try to parse Python tracebacks
reports = parse_traceback_text(log_text) reports = parse_traceback_text(log_text)
sha_short = (commit_sha or "unknown")[:8]
if reports: if reports:
# File one issue per traceback found
for report in reports: for report in reports:
blames = blame_report(report) blames = blame_report(report)
body = build_issue_body( body = build_issue_body(
@@ -184,7 +142,7 @@ def main() -> int:
commit_sha or "unknown", commit_sha or "unknown",
) )
body = ( body = (
f"**Pipeline:** `{pipeline_name}` #{pnum} " f"**Pipeline:** `{pipeline_name}` "
f"step `{step_name}`\n" f"step `{step_name}`\n"
f"**Commit:** `{sha_short}`\n" f"**Commit:** `{sha_short}`\n"
f"**Status:** `failure`\n\n" + body f"**Status:** `failure`\n\n" + body
@@ -203,11 +161,10 @@ def main() -> int:
}, },
) )
log.info("Filed issue #%s: %s", result.get("number"), title) log.info("Filed issue #%s: %s", result.get("number"), title)
else: elif log_text:
# Non-Python failure — file with raw log title = f"{pipeline_name}/{step_name} failed (@ {sha_short})"
title = f"{pipeline_name}/{step_name} failed (#{pnum} @ {sha_short})"
body = ( body = (
f"**Pipeline:** `{pipeline_name}` #{pnum} " f"**Pipeline:** `{pipeline_name}` "
f"step `{step_name}`\n" f"step `{step_name}`\n"
f"**Commit:** `{sha_short}`\n" f"**Commit:** `{sha_short}`\n"
f"**Status:** `failure`\n\n" f"**Status:** `failure`\n\n"
@@ -227,8 +184,9 @@ def main() -> int:
}, },
) )
log.info("Filed issue #%s: %s", result.get("number"), title) log.info("Filed issue #%s: %s", result.get("number"), title)
else:
log.info("No log text provided — nothing to report")
wp.close()
gitea.close() gitea.close()
return 0 return 0

View File

@@ -23,7 +23,6 @@ RELATED_CONTAINERS: dict[str, tuple[str, ...]] = {
"rustfs": ("rustfs",), "rustfs": ("rustfs",),
"s3": ("rustfs",), "s3": ("rustfs",),
"gitea": ("gitea",), "gitea": ("gitea",),
"woodpecker": ("woodpecker-server",),
"grafana": ("grafana",), "grafana": ("grafana",),
"nessie": ("nessie",), "nessie": ("nessie",),
"trino": ("trino",), "trino": ("trino",),

View File

@@ -272,7 +272,6 @@ def main() -> int:
[ [
"git", "git",
"add", "add",
".woodpecker/*.yml",
".github/workflows/*.yml", ".github/workflows/*.yml",
".gitea/workflows/*.yml", ".gitea/workflows/*.yml",
"coredns/hosts", "coredns/hosts",

View File

@@ -29,7 +29,6 @@ subdomains = [
[services] [services]
git = "http://git:3000" git = "http://git:3000"
woodpecker = "http://woodpecker-server:8000"
rustfs = "http://rustfs:9000" rustfs = "http://rustfs:9000"
nessie = "http://nessie:19120" nessie = "http://nessie:19120"
polaris = "http://polaris:8181" polaris = "http://polaris:8181"
@@ -192,15 +191,6 @@ workers = 1
backend = "gitea" backend = "gitea"
coverage_threshold = 99 coverage_threshold = 99
[ci.woodpecker]
plugin_buildx = "woodpeckerci/plugin-docker-buildx:5-insecure"
plugin_buildx_dryrun = "woodpeckerci/plugin-docker-buildx"
plugin_s3 = "woodpeckerci/plugin-s3"
plugin_comment = "woodpeckerci/plugin-gitea-comment"
plugin_release = "woodpeckerci/plugin-gitea-release"
docker_socket = "/run/user/1000/docker.sock"
uv_image = "ghcr.io/astral-sh/uv:python3.13-bookworm-slim"
[ci.github] [ci.github]
runner = "ubuntu-latest" runner = "ubuntu-latest"
registry = "ghcr.io" registry = "ghcr.io"

View File

@@ -25,7 +25,6 @@ def _bootstrap_patches():
with ( with (
patch("api.auth.provision.subprocess.run"), patch("api.auth.provision.subprocess.run"),
patch("api.auth.provision.GiteaClient", return_value=_mock_gitea_client()), patch("api.auth.provision.GiteaClient", return_value=_mock_gitea_client()),
patch("api.auth.provision.WoodpeckerClient", return_value=MagicMock()),
): ):
yield yield
@@ -66,8 +65,8 @@ class TestBootstrapE2E:
def test_credential_count(self): def test_credential_count(self):
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(CREDENTIALS) == 20 assert len(CREDENTIALS) == 16
assert len(CREDENTIALS) - skip_count == 18 assert len(CREDENTIALS) - skip_count == 16
def test_derive_all_count(self): def test_derive_all_count(self):
values = derive_all(ROOT, COMMIT) values = derive_all(ROOT, COMMIT)

View File

@@ -46,95 +46,34 @@ class TestDiagMainPipelineName:
class TestDiagMainMissingToken: class TestDiagMainMissingToken:
"""Cover lines 105-106 (no GITEA_TOKEN).""" """Cover no GITEA_TOKEN path."""
def test_missing_gitea_token(self, monkeypatch): def test_missing_gitea_token(self, monkeypatch):
monkeypatch.setenv("CI_REPO_ID", "1")
monkeypatch.setenv("CI_PIPELINE_NUMBER", "42")
monkeypatch.setenv("CI_COMMIT_SHA", "abc123") monkeypatch.setenv("CI_COMMIT_SHA", "abc123")
monkeypatch.setenv("CI_REPO", "homelab/stack") monkeypatch.setenv("CI_REPO", "homelab/stack")
monkeypatch.delenv("GITEA_TOKEN", raising=False) monkeypatch.delenv("GITEA_TOKEN", raising=False)
from api.diag.__main__ import main from api.diag.__main__ import main
assert main() == 1 # lines 105-106 assert main() == 1
class TestDiagMainFallbackRepo: class TestDiagMainFallbackRepo:
"""Cover lines 112-113 (CI_REPO has no slash).""" """Cover CI_REPO with no slash."""
def test_repo_no_slash(self, monkeypatch): def test_repo_no_slash(self, monkeypatch):
monkeypatch.setenv("CI_REPO_ID", "1")
monkeypatch.setenv("CI_PIPELINE_NUMBER", "42")
monkeypatch.setenv("CI_COMMIT_SHA", "abc123") monkeypatch.setenv("CI_COMMIT_SHA", "abc123")
monkeypatch.setenv("CI_REPO", "no-slash-here") monkeypatch.setenv("CI_REPO", "no-slash-here")
monkeypatch.setenv("GITEA_TOKEN", "tok") monkeypatch.setenv("GITEA_TOKEN", "tok")
monkeypatch.setenv("CI_STEP_NAME", "test")
mock_wp = MagicMock() monkeypatch.setenv("CI_STEP_LOG", "")
mock_wp.get_pipeline.return_value = {"steps": []}
with patch("api.clients.woodpecker.WoodpeckerClient", return_value=mock_wp):
from api.diag.__main__ import main
assert main() == 0 # lines 112-113
class TestDiagMainLogFetchException:
"""Cover lines 158-160 (log fetch exception)."""
def test_log_fetch_fails(self, monkeypatch):
monkeypatch.setenv("CI_REPO_ID", "1")
monkeypatch.setenv("CI_PIPELINE_NUMBER", "42")
monkeypatch.setenv("CI_COMMIT_SHA", "abc123")
monkeypatch.setenv("CI_REPO", "homelab/stack")
monkeypatch.setenv("GITEA_TOKEN", "tok")
mock_wp = MagicMock()
mock_wp.get_pipeline.return_value = {
"steps": [{"name": "test", "state": "failure", "id": 1}],
}
mock_wp.get_logs.side_effect = Exception("timeout")
mock_gitea = MagicMock() mock_gitea = MagicMock()
with ( with patch("api.clients.gitea.GiteaClient", return_value=mock_gitea):
patch("api.clients.woodpecker.WoodpeckerClient", return_value=mock_wp),
patch("api.clients.gitea.GiteaClient", return_value=mock_gitea),
):
from api.diag.__main__ import main from api.diag.__main__ import main
assert main() == 0 # lines 158-160 (continue) assert main() == 0
# No issue filed because log fetch failed
mock_gitea.create_issue.assert_not_called()
class TestDiagMainLogEntriesString:
"""Cover line 169 (log_entries not a list)."""
def test_log_entries_string(self, monkeypatch):
monkeypatch.setenv("CI_REPO_ID", "1")
monkeypatch.setenv("CI_PIPELINE_NUMBER", "42")
monkeypatch.setenv("CI_COMMIT_SHA", "abc123")
monkeypatch.setenv("CI_REPO", "homelab/stack")
monkeypatch.setenv("GITEA_TOKEN", "tok")
mock_wp = MagicMock()
mock_wp.get_pipeline.return_value = {
"steps": [{"name": "build-api", "state": "failure", "id": 1}],
}
mock_wp.get_logs.return_value = "raw string logs" # not a list
mock_gitea = MagicMock()
mock_gitea.create_issue.return_value = {"number": 99}
with (
patch("api.clients.woodpecker.WoodpeckerClient", return_value=mock_wp),
patch("api.clients.gitea.GiteaClient", return_value=mock_gitea),
):
from api.diag.__main__ import main
assert main() == 0 # line 169
class TestDiagMainDunderMain: class TestDiagMainDunderMain:
@@ -191,7 +130,7 @@ class TestVerifyRustfsException:
class TestDeployComposeUpFailure: class TestDeployComposeUpFailure:
"""Cover lines 209-211 (CalledProcessError in compose up).""" """Cover CalledProcessError in compose up."""
def test_compose_up_fails(self, tmp_path): def test_compose_up_fails(self, tmp_path):
from api.auth.deploy import deploy from api.auth.deploy import deploy
@@ -204,7 +143,6 @@ class TestDeployComposeUpFailure:
with ( with (
patch("api.auth.deploy.provision_gitea", return_value="tok"), patch("api.auth.deploy.provision_gitea", return_value="tok"),
patch("api.auth.deploy.provision_woodpecker"),
patch("api.auth.deploy.subprocess.run", side_effect=compose_err), patch("api.auth.deploy.subprocess.run", side_effect=compose_err),
patch("api.auth.deploy.verify_all", return_value=[]), patch("api.auth.deploy.verify_all", return_value=[]),
patch("api.auth.deploy.time.sleep"), patch("api.auth.deploy.time.sleep"),
@@ -215,31 +153,6 @@ class TestDeployComposeUpFailure:
assert any(b == "compose-up" for b, _ in result.errors) assert any(b == "compose-up" for b, _ in result.errors)
class TestDeployWoodpeckerFailure:
"""Cover lines 222-224 (woodpecker sync fails)."""
def test_woodpecker_sync_fails(self, tmp_path):
from api.auth.deploy import deploy
ROOT = bytes.fromhex("deadbeef" * 8)
env = tmp_path / ".env"
env.write_text("KEY=val\n")
with (
patch("api.auth.deploy.provision_gitea", return_value="tok"),
patch(
"api.auth.deploy.provision_woodpecker",
side_effect=Exception("wp down"),
),
patch("api.auth.deploy.subprocess.run"),
patch("api.auth.deploy.verify_all", return_value=[]),
patch("api.auth.deploy.time.sleep"),
):
result = deploy(ROOT, "abc123", env, compose_dir=tmp_path)
assert any(b == "woodpecker" for b, _ in result.errors)
class TestDeployRollbackComposeFailure: class TestDeployRollbackComposeFailure:
"""Cover lines 246-247 (rollback compose up fails).""" """Cover lines 246-247 (rollback compose up fails)."""
@@ -260,7 +173,6 @@ class TestDeployRollbackComposeFailure:
with ( with (
patch("api.auth.deploy.provision_gitea", return_value="tok"), patch("api.auth.deploy.provision_gitea", return_value="tok"),
patch("api.auth.deploy.provision_woodpecker"),
patch("api.auth.deploy.subprocess.run", side_effect=side_effect_run), patch("api.auth.deploy.subprocess.run", side_effect=side_effect_run),
patch( patch(
"api.auth.deploy.verify_all", "api.auth.deploy.verify_all",

View File

@@ -110,7 +110,6 @@ class TestDeploy:
with ( with (
# provision_postgres no longer called # provision_postgres no longer called
patch("api.auth.deploy.provision_gitea", return_value="new-tok"), patch("api.auth.deploy.provision_gitea", return_value="new-tok"),
patch("api.auth.deploy.provision_woodpecker"),
patch("api.auth.deploy.subprocess.run"), patch("api.auth.deploy.subprocess.run"),
patch("api.auth.deploy.verify_all", return_value=[]), patch("api.auth.deploy.verify_all", return_value=[]),
patch("api.auth.deploy.time.sleep"), patch("api.auth.deploy.time.sleep"),
@@ -120,7 +119,6 @@ class TestDeploy:
assert result.ok assert result.ok
assert result.postgres assert result.postgres
assert result.gitea assert result.gitea
assert result.woodpecker
assert result.env_written assert result.env_written
content = env.read_text() content = env.read_text()
assert "GITEA_TOKEN=new-tok" in content assert "GITEA_TOKEN=new-tok" in content
@@ -133,7 +131,6 @@ class TestDeploy:
with ( with (
# provision_postgres no longer called # provision_postgres no longer called
patch("api.auth.deploy.provision_gitea", return_value="tok"), patch("api.auth.deploy.provision_gitea", return_value="tok"),
patch("api.auth.deploy.provision_woodpecker"),
patch("api.auth.deploy.subprocess.run"), patch("api.auth.deploy.subprocess.run"),
patch( patch(
"api.auth.deploy.verify_all", "api.auth.deploy.verify_all",
@@ -157,7 +154,6 @@ class TestDeploy:
"api.auth.deploy.provision_gitea", "api.auth.deploy.provision_gitea",
side_effect=Exception("gitea down"), side_effect=Exception("gitea down"),
), ),
patch("api.auth.deploy.provision_woodpecker"),
patch("api.auth.deploy.subprocess.run"), patch("api.auth.deploy.subprocess.run"),
patch("api.auth.deploy.verify_all", return_value=[]), patch("api.auth.deploy.verify_all", return_value=[]),
patch("api.auth.deploy.time.sleep"), patch("api.auth.deploy.time.sleep"),

View File

@@ -583,8 +583,8 @@ class TestParseTracebackText:
assert reports[0].exc_type == "RuntimeError" assert reports[0].exc_type == "RuntimeError"
assert reports[0].exc_value == "" assert reports[0].exc_value == ""
def test_handles_woodpecker_log_prefix(self): def test_handles_ci_log_prefix(self):
# Woodpecker log lines may have timestamps or prefixes # CI log lines may have timestamps or prefixes
text = ( text = (
"some setup output\n" "some setup output\n"
"Traceback (most recent call last):\n" "Traceback (most recent call last):\n"
@@ -681,56 +681,44 @@ class TestCliGuard:
class TestCiMain: class TestCiMain:
def test_missing_env_vars(self, monkeypatch): def test_missing_commit_sha(self, monkeypatch):
monkeypatch.delenv("CI_REPO_ID", raising=False) monkeypatch.delenv("CI_COMMIT_SHA", raising=False)
monkeypatch.delenv("CI_PIPELINE_NUMBER", raising=False)
from api.diag.__main__ import main from api.diag.__main__ import main
assert main() == 1 assert main() == 1
def test_no_failed_steps(self, monkeypatch): def test_no_log_text(self, monkeypatch):
monkeypatch.setenv("CI_REPO_ID", "1")
monkeypatch.setenv("CI_PIPELINE_NUMBER", "42")
monkeypatch.setenv("CI_COMMIT_SHA", "abc123") monkeypatch.setenv("CI_COMMIT_SHA", "abc123")
monkeypatch.setenv("CI_REPO", "homelab/stack") monkeypatch.setenv("CI_REPO", "homelab/stack")
monkeypatch.setenv("GITEA_TOKEN", "tok") monkeypatch.setenv("GITEA_TOKEN", "tok")
monkeypatch.setenv("CI_STEP_NAME", "build")
monkeypatch.setenv("CI_STEP_LOG", "")
mock_wp = MagicMock() mock_gitea = MagicMock()
mock_wp.get_pipeline.return_value = {
"steps": [{"name": "build", "state": "success"}],
}
with patch("api.clients.woodpecker.WoodpeckerClient", return_value=mock_wp): with patch("api.clients.gitea.GiteaClient", return_value=mock_gitea):
from api.diag.__main__ import main from api.diag.__main__ import main
assert main() == 0 assert main() == 0
def test_files_issue_for_python_failure(self, monkeypatch): def test_files_issue_for_python_failure(self, monkeypatch):
monkeypatch.setenv("CI_REPO_ID", "1")
monkeypatch.setenv("CI_PIPELINE_NUMBER", "42")
monkeypatch.setenv("CI_COMMIT_SHA", "abc123") monkeypatch.setenv("CI_COMMIT_SHA", "abc123")
monkeypatch.setenv("CI_REPO", "homelab/stack") monkeypatch.setenv("CI_REPO", "homelab/stack")
monkeypatch.setenv("GITEA_TOKEN", "tok") monkeypatch.setenv("GITEA_TOKEN", "tok")
monkeypatch.setenv("CI_STEP_NAME", "provision")
mock_wp = MagicMock() monkeypatch.setenv(
mock_wp.get_pipeline.return_value = { "CI_STEP_LOG",
"steps": [ "Traceback (most recent call last):\n"
{"name": "provision", "state": "failure", "id": 5}, ' File "src/api/auth/provision.py", line 10, in provision\n'
], " do_stuff()\n"
} "RuntimeError: oops\n",
mock_wp.get_logs.return_value = [ )
{"data": "Traceback (most recent call last):"},
{"data": ' File "src/api/auth/provision.py", line 10, in provision'},
{"data": " do_stuff()"},
{"data": "RuntimeError: oops"},
]
mock_gitea = MagicMock() mock_gitea = MagicMock()
mock_gitea.create_issue.return_value = {"number": 55} mock_gitea.create_issue.return_value = {"number": 55}
with ( with (
patch("api.clients.woodpecker.WoodpeckerClient", return_value=mock_wp),
patch("api.clients.gitea.GiteaClient", return_value=mock_gitea), patch("api.clients.gitea.GiteaClient", return_value=mock_gitea),
patch("api.diag.blame.blame_report", return_value={}), patch("api.diag.blame.blame_report", return_value={}),
): ):
@@ -744,30 +732,19 @@ class TestCiMain:
assert "provision" in issue["title"] assert "provision" in issue["title"]
def test_files_issue_for_non_python_failure(self, monkeypatch): def test_files_issue_for_non_python_failure(self, monkeypatch):
monkeypatch.setenv("CI_REPO_ID", "1")
monkeypatch.setenv("CI_PIPELINE_NUMBER", "42")
monkeypatch.setenv("CI_COMMIT_SHA", "abc123") monkeypatch.setenv("CI_COMMIT_SHA", "abc123")
monkeypatch.setenv("CI_REPO", "homelab/stack") monkeypatch.setenv("CI_REPO", "homelab/stack")
monkeypatch.setenv("GITEA_TOKEN", "tok") monkeypatch.setenv("GITEA_TOKEN", "tok")
monkeypatch.setenv("CI_STEP_NAME", "build-docs")
mock_wp = MagicMock() monkeypatch.setenv(
mock_wp.get_pipeline.return_value = { "CI_STEP_LOG",
"steps": [ "COPY failed: file not found\nERROR: build failed\n",
{"name": "build-docs", "state": "failure", "id": 3}, )
],
}
mock_wp.get_logs.return_value = [
{"data": "COPY failed: file not found"},
{"data": "ERROR: build failed"},
]
mock_gitea = MagicMock() mock_gitea = MagicMock()
mock_gitea.create_issue.return_value = {"number": 56} mock_gitea.create_issue.return_value = {"number": 56}
with ( with patch("api.clients.gitea.GiteaClient", return_value=mock_gitea):
patch("api.clients.woodpecker.WoodpeckerClient", return_value=mock_wp),
patch("api.clients.gitea.GiteaClient", return_value=mock_gitea),
):
from api.diag.__main__ import main from api.diag.__main__ import main
assert main() == 0 assert main() == 0

View File

@@ -16,10 +16,8 @@ from api.auth.manifest import (
Tier, Tier,
) )
from api.auth.provision import ( from api.auth.provision import (
WOODPECKER_SECRET_MAP,
derive_all, derive_all,
provision, provision,
provision_woodpecker,
write_env, write_env,
) )
@@ -52,7 +50,7 @@ class TestManifestInvariants:
assert cred.fmt == parent.fmt assert cred.fmt == parent.fmt
def test_credential_count(self): def test_credential_count(self):
assert len(CREDENTIALS) == 20 assert len(CREDENTIALS) == 16
class TestDeriveAll: class TestDeriveAll:
@@ -210,41 +208,6 @@ class TestProvisionGitea:
mock_client.delete.assert_called_once() mock_client.delete.assert_called_once()
class TestProvisionWoodpecker:
def test_updates_secrets(self):
values = derive_all(ROOT, COMMIT)
mock_wp = MagicMock()
with (
patch("api.auth.provision._get_woodpecker_token", return_value="wp-hash"),
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)
mock_wp = MagicMock()
mock_wp.update_secret.side_effect = Exception("not found")
with (
patch("api.auth.provision._get_woodpecker_token", return_value="wp-hash"),
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_db_token(self):
with patch(
"api.auth.provision._get_woodpecker_token",
side_effect=ValueError("Could not read"),
):
with pytest.raises(ValueError, match="Could not read"):
provision_woodpecker({}, repo_id=1)
class TestProvisionEndToEnd: class TestProvisionEndToEnd:
def test_partial_failure_still_writes_env(self, tmp_path): def test_partial_failure_still_writes_env(self, tmp_path):
env = tmp_path / ".env" env = tmp_path / ".env"
@@ -257,27 +220,21 @@ class TestProvisionEndToEnd:
"api.auth.provision.provision_gitea", "api.auth.provision.provision_gitea",
side_effect=Exception("gitea down"), side_effect=Exception("gitea down"),
), ),
patch(
"api.auth.provision.provision_woodpecker",
side_effect=Exception("wp down"),
),
patch("api.auth.provision.time.sleep"), patch("api.auth.provision.time.sleep"),
): ):
result = provision(ROOT, COMMIT, env) result = provision(ROOT, COMMIT, env)
assert result.env_written assert result.env_written
assert env.exists() assert env.exists()
assert len(result.errors) == 3 assert len(result.errors) == 2
assert not result.postgres assert not result.postgres
assert not result.gitea assert not result.gitea
assert not result.woodpecker
def test_full_success(self, tmp_path): def test_full_success(self, tmp_path):
env = tmp_path / ".env" env = tmp_path / ".env"
with ( with (
patch("api.auth.provision.provision_postgres"), patch("api.auth.provision.provision_postgres"),
patch("api.auth.provision.provision_gitea", return_value="tok-new"), patch("api.auth.provision.provision_gitea", return_value="tok-new"),
patch("api.auth.provision.provision_woodpecker"),
patch("api.auth.provision.time.sleep"), patch("api.auth.provision.time.sleep"),
): ):
result = provision(ROOT, COMMIT, env) result = provision(ROOT, COMMIT, env)
@@ -285,7 +242,6 @@ class TestProvisionEndToEnd:
assert result.ok assert result.ok
assert result.postgres assert result.postgres
assert result.gitea assert result.gitea
assert result.woodpecker
assert result.env_written assert result.env_written
content = env.read_text() content = env.read_text()
assert "GITEA_TOKEN=tok-new" in content assert "GITEA_TOKEN=tok-new" in content
@@ -304,7 +260,6 @@ class TestProvisionEndToEnd:
with ( with (
patch("api.auth.provision.provision_postgres"), patch("api.auth.provision.provision_postgres"),
patch("api.auth.provision.provision_gitea", return_value="tok"), patch("api.auth.provision.provision_gitea", return_value="tok"),
patch("api.auth.provision.provision_woodpecker"),
patch( patch(
"api.auth.provision.provision_gitea_actions", "api.auth.provision.provision_gitea_actions",
side_effect=Exception("actions down"), side_effect=Exception("actions down"),
@@ -318,33 +273,6 @@ class TestProvisionEndToEnd:
assert "gitea-actions" in error_backends assert "gitea-actions" in error_backends
class TestGetWoodpeckerToken:
def test_raises_when_output_empty(self) -> None:
"""Raises ValueError if DB query returns empty string."""
from api.auth.provision import _get_woodpecker_token
with patch(
"api.auth.provision.subprocess.run",
) as mock_run:
mock_run.return_value.stdout = ""
with pytest.raises(ValueError, match="Could not read Woodpecker token"):
_get_woodpecker_token({})
class TestProvisionWoodpeckerSkipMissing:
def test_skips_secrets_with_no_value(self) -> None:
"""Secrets with empty values are skipped with a warning."""
mock_wp = MagicMock()
with (
patch("api.auth.provision._get_woodpecker_token", return_value="tok"),
patch("api.auth.provision.WoodpeckerClient", return_value=mock_wp),
):
provision_woodpecker({}, repo_id=1)
mock_wp.update_secret.assert_not_called()
mock_wp.create_secret.assert_not_called()
class TestProvisionGiteaActions: class TestProvisionGiteaActions:
def test_raises_without_gitea_token(self) -> None: def test_raises_without_gitea_token(self) -> None:
"""Raises ValueError when GITEA_TOKEN is missing.""" """Raises ValueError when GITEA_TOKEN is missing."""
@@ -425,10 +353,6 @@ class TestBootstrap:
"api.auth.provision.provision_gitea", "api.auth.provision.provision_gitea",
side_effect=Exception("gitea down"), side_effect=Exception("gitea down"),
), ),
patch(
"api.auth.provision.provision_woodpecker",
side_effect=Exception("wp down"),
),
patch( patch(
"api.auth.provision.provision_gitea_actions", "api.auth.provision.provision_gitea_actions",
side_effect=Exception("actions down"), side_effect=Exception("actions down"),
@@ -440,7 +364,6 @@ class TestBootstrap:
error_backends = [b for b, _ in result.errors] error_backends = [b for b, _ in result.errors]
assert "postgres" in error_backends assert "postgres" in error_backends
assert "gitea" in error_backends assert "gitea" in error_backends
assert "woodpecker" in error_backends
assert "gitea-actions" in error_backends assert "gitea-actions" in error_backends
def test_success_path(self, tmp_path) -> None: def test_success_path(self, tmp_path) -> None:
@@ -451,7 +374,6 @@ class TestBootstrap:
patch("api.auth.provision.bootstrap_postgres"), patch("api.auth.provision.bootstrap_postgres"),
patch("api.auth.provision.provision_postgres"), patch("api.auth.provision.provision_postgres"),
patch("api.auth.provision.provision_gitea", return_value="tok"), patch("api.auth.provision.provision_gitea", return_value="tok"),
patch("api.auth.provision.provision_woodpecker"),
patch("api.auth.provision.provision_gitea_actions"), patch("api.auth.provision.provision_gitea_actions"),
): ):
result = bootstrap(ROOT, COMMIT, env) result = bootstrap(ROOT, COMMIT, env)

View File

@@ -127,12 +127,10 @@ class TestSecurityOpts:
"""Most services should have no-new-privileges.""" """Most services should have no-new-privileges."""
# Exceptions: privileged, ephemeral, or upstream images without secopt # Exceptions: privileged, ephemeral, or upstream images without secopt
exempt = { exempt = {
"woodpecker-agent", # needs privileged for Docker builds
"wire", # ephemeral bootstrap, profile=tools "wire", # ephemeral bootstrap, profile=tools
"zotero", # GPU + display server needs "zotero", # GPU + display server needs
"coredns", # upstream, no secopt in image "coredns", # upstream, no secopt in image
"gitea", # rootless image handles its own security "gitea", # rootless image handles its own security
"woodpecker-server", # upstream CI server
"notebooks", # GPU + dev environment "notebooks", # GPU + dev environment
"webdav", # rclone upstream "webdav", # rclone upstream
"docs", # static site "docs", # static site