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) ──────────────────────────────────
log "Tier 3: CI"
up woodpecker-server act-runner
wait_running woodpecker-server
up woodpecker-agent
up act-runner
# ── 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:
- .env file (all secrets)
- PostgreSQL roles and databases
- Woodpecker CI repo secrets
- RustFS IAM (via mc)
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)
Usage:
@@ -35,12 +34,10 @@ SECRETS = {
"POSTGRES_PASSWORD": 24,
"GITEA_DB_PASSWORD": 24,
"GITEA_ADMIN_PASSWORD": 24,
"WOODPECKER_DB_PASSWORD": 24,
"NESSIE_DB_PASSWORD": 24,
"POLARIS_DB_PASSWORD": 24,
"RUSTFS_ACCESS_KEY": 32,
"RUSTFS_SECRET_KEY": 32,
"WOODPECKER_AGENT_SECRET": 32,
"POLARIS_ROOT_SECRET": 24,
"GF_ADMIN_PASSWORD": 24,
"ROOT_KEY": 32,
@@ -50,29 +47,16 @@ SECRETS = {
HEX_SECRETS = {
"RUSTFS_ACCESS_KEY",
"RUSTFS_SECRET_KEY",
"WOODPECKER_AGENT_SECRET",
"ROOT_KEY",
}
# PostgreSQL roles: role_name → (env_var for password, database_name)
PG_ROLES = {
"git": ("GITEA_DB_PASSWORD", "gitea"),
"woodpecker": ("WOODPECKER_DB_PASSWORD", "woodpecker"),
"nessie": ("NESSIE_DB_PASSWORD", "nessie"),
"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
ALIASES = {
"GITEA_S3_ACCESS_KEY": "RUSTFS_ACCESS_KEY",
@@ -224,46 +208,6 @@ def bootstrap_gitea(env: dict[str, str]) -> list[str]:
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]:
"""Verify all secrets are consistent across systems."""
errors = []
@@ -342,7 +286,7 @@ def verify(env: dict[str, str]) -> list[str]:
def bootstrap() -> int:
"""Full bootstrap: generate → postgres → gitea → woodpecker → verify."""
"""Full bootstrap: generate → postgres → gitea → verify."""
env = generate_secrets()
_write_env(env, ENV_PATH)
log.info("Wrote %d keys to %s", len(env), ENV_PATH)
@@ -351,7 +295,6 @@ def bootstrap() -> int:
all_errors.extend(bootstrap_postgres(env))
all_errors.extend(bootstrap_gitea(env))
_write_env(env, ENV_PATH) # re-write with GITEA_TOKEN
all_errors.extend(bootstrap_woodpecker(env))
# Verify
log.info("Verifying...")

View File

@@ -1,7 +1,7 @@
"""Generate all derived config files 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.
Usage::
@@ -81,9 +81,8 @@ def gen_coredns() -> dict[str, str]:
# ── Backend dispatch ─────────────────────────────────────────────
STALE_DIRS: dict[str, list[str]] = {
"woodpecker": [".github/workflows", ".gitea/workflows"],
"github": [".woodpecker", ".gitea/workflows"],
"gitea": [".woodpecker", ".github/workflows"],
"github": [".gitea/workflows"],
"gitea": [".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.
ci_cfg.setdefault("coverage_threshold", ci_data.get("coverage_threshold", 99))
if backend == "woodpecker":
from backends.woodpecker import emit
elif backend == "github":
if backend == "github":
from backends.github import emit
elif backend == "gitea":
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]:
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.update(gen_coredns())
@@ -218,7 +215,7 @@ def main() -> int:
parser.add_argument(
"--backend",
default=None,
help="Override CI backend (gitea|github|woodpecker)",
help="Override CI backend (gitea|github)",
)
parser.add_argument(
"--dab-sql",
@@ -251,7 +248,7 @@ def main() -> int:
write_all(files)
# 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)
for s in stale:
(ROOT / s).unlink()

View File

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

View File

@@ -1,14 +1,13 @@
"""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
2. Rotate PostgreSQL passwords (ALTER ROLE)
3. Rotate Gitea admin password + create fresh token
4. Write .env with all new credentials
5. Restart services (docker compose up -d)
6. Sync Woodpecker secrets
7. Verify all backends are healthy
8. Rollback on failure
6. Verify all backends are healthy
7. Rollback on failure
Usage:
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 2: Write .env + docker compose up -d
Phase 3: Sync Woodpecker secrets + health check
Phase 3: Health check
"""
result = ProvisionResult()
cwd = compose_dir or env_path.parent

View File

@@ -49,13 +49,6 @@ CREDENTIALS: tuple[Credential, ...] = (
Format.PASSWORD,
Provisioner.POSTGRES,
),
Credential(
"WOODPECKER_DB_PASSWORD",
"postgres/woodpecker",
Tier.SERVICE,
Format.PASSWORD,
Provisioner.POSTGRES,
),
Credential(
"NESSIE_DB_PASSWORD",
"postgres/nessie",
@@ -133,28 +126,6 @@ CREDENTIALS: tuple[Credential, ...] = (
Provisioner.ENV_ONLY,
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 ───────────────────────────────────────────────
Credential(
"POLARIS_ROOT_SECRET",
@@ -186,14 +157,12 @@ MANAGED_VARS: frozenset[str] = frozenset(c.env_var for c in CREDENTIALS)
POSTGRES_ROLES: dict[str, str] = {
"git": "GITEA_DB_PASSWORD",
"woodpecker": "WOODPECKER_DB_PASSWORD",
"nessie": "NESSIE_DB_PASSWORD",
"polaris": "POLARIS_DB_PASSWORD",
}
POSTGRES_DATABASES: dict[str, str] = {
"git": "gitea",
"woodpecker": "woodpecker",
"nessie": "nessie",
"polaris": "polaris",
}

View File

@@ -20,7 +20,6 @@ from api.auth.manifest import (
Tier,
)
from api.clients.gitea import GiteaClient
from api.clients.woodpecker import WoodpeckerClient
log = logging.getLogger(__name__)
@@ -35,7 +34,6 @@ class ProvisionResult:
postgres: bool = False
gitea: bool = False
woodpecker: bool = False
env_written: bool = False
errors: list[tuple[str, Exception]] = field(default_factory=list)
@@ -252,82 +250,6 @@ def provision_gitea(
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 reserves the GITEA_ prefix for Actions secrets, so the token
@@ -408,14 +330,6 @@ def provision(
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)
try:
_retry(lambda: provision_gitea_actions(values), "gitea-actions")
log.info("Gitea Actions secrets synced")
@@ -457,13 +371,6 @@ def bootstrap(
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)
try:
provision_gitea_actions(values)
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
Reads CI environment variables to locate the failed pipeline, pulls
step logs via the Woodpecker API, parses any Python tracebacks, runs
``git blame`` on the offending lines, and files a Gitea issue with
labels for the pipeline name, step category, and image (if applicable).
Reads CI environment variables to locate the failed workflow run,
parses any Python tracebacks, runs ``git blame`` on the offending
lines, and files a Gitea issue with labels for the pipeline name,
step category, and image (if applicable).
"""
from __future__ import annotations
@@ -91,13 +91,13 @@ def main() -> int:
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")
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:
log.error("CI_REPO_ID and CI_PIPELINE_NUMBER are required")
if not commit_sha:
log.error("CI_COMMIT_SHA is required")
return 1
gitea_token = os.environ.get("GITEA_TOKEN", "")
@@ -114,28 +114,6 @@ def main() -> int:
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.diag.blame import blame_report
from api.diag.issue import _truncate, build_issue_body
@@ -143,79 +121,35 @@ def main() -> int:
gitea = GiteaClient(gitea_token)
for step in failed_steps:
step_name = step.get("name", "unknown")
step_id = step.get("id", 0)
if not step_name:
step_name = "unknown"
log.info("Processing failed step: %s (id=%d)", step_name, step_id)
label_names = _resolve_label_names(pipeline_name, step_name)
labels = gitea.resolve_labels(owner, repo, label_names)
label_names = _resolve_label_names(pipeline_name, step_name)
labels = gitea.resolve_labels(owner, repo, label_names)
sha_short = (commit_sha or "unknown")[:8]
# Fetch logs — Woodpecker returns a list of log line dicts
try:
log_entries = wp.get_logs(rid, pnum, step_id)
except Exception:
log.exception("Failed to fetch logs for step %s", step_name)
continue
# Try to parse Python tracebacks
reports = parse_traceback_text(log_text)
# 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
if reports:
for report in reports:
blames = blame_report(report)
body = build_issue_body(
report,
blames,
{f"step:{step_name}": log_text[-3000:]},
commit_sha or "unknown",
)
else:
log_text = str(log_entries)
# Try to parse Python tracebacks
reports = parse_traceback_text(log_text)
sha_short = (commit_sha or "unknown")[:8]
if reports:
# File one issue per traceback found
for report in reports:
blames = blame_report(report)
body = build_issue_body(
report,
blames,
{f"step:{step_name}": log_text[-3000:]},
commit_sha or "unknown",
)
body = (
f"**Pipeline:** `{pipeline_name}` #{pnum} "
f"step `{step_name}`\n"
f"**Commit:** `{sha_short}`\n"
f"**Status:** `failure`\n\n" + body
)
title = (
f"{pipeline_name}/{step_name}: {report.exc_type}: "
f"{_truncate(report.exc_value, 50)}"
)
result = gitea.create_issue(
owner,
repo,
{
"title": title,
"body": body,
"labels": labels,
},
)
log.info("Filed issue #%s: %s", result.get("number"), title)
else:
# Non-Python failure — file with raw log
title = f"{pipeline_name}/{step_name} failed (#{pnum} @ {sha_short})"
body = (
f"**Pipeline:** `{pipeline_name}` #{pnum} "
f"**Pipeline:** `{pipeline_name}` "
f"step `{step_name}`\n"
f"**Commit:** `{sha_short}`\n"
f"**Status:** `failure`\n\n"
f"## Step Log\n\n"
f"<details>\n"
f"<summary>{step_name} output (last 3000 chars)</summary>\n\n"
f"```\n{log_text[-3000:]}\n```\n\n"
f"</details>\n"
f"**Status:** `failure`\n\n" + body
)
title = (
f"{pipeline_name}/{step_name}: {report.exc_type}: "
f"{_truncate(report.exc_value, 50)}"
)
result = gitea.create_issue(
owner,
@@ -227,8 +161,32 @@ def main() -> int:
},
)
log.info("Filed issue #%s: %s", result.get("number"), title)
elif log_text:
title = f"{pipeline_name}/{step_name} failed (@ {sha_short})"
body = (
f"**Pipeline:** `{pipeline_name}` "
f"step `{step_name}`\n"
f"**Commit:** `{sha_short}`\n"
f"**Status:** `failure`\n\n"
f"## Step Log\n\n"
f"<details>\n"
f"<summary>{step_name} output (last 3000 chars)</summary>\n\n"
f"```\n{log_text[-3000:]}\n```\n\n"
f"</details>\n"
)
result = gitea.create_issue(
owner,
repo,
{
"title": title,
"body": body,
"labels": labels,
},
)
log.info("Filed issue #%s: %s", result.get("number"), title)
else:
log.info("No log text provided — nothing to report")
wp.close()
gitea.close()
return 0

View File

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

View File

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

View File

@@ -29,7 +29,6 @@ subdomains = [
[services]
git = "http://git:3000"
woodpecker = "http://woodpecker-server:8000"
rustfs = "http://rustfs:9000"
nessie = "http://nessie:19120"
polaris = "http://polaris:8181"
@@ -192,15 +191,6 @@ workers = 1
backend = "gitea"
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]
runner = "ubuntu-latest"
registry = "ghcr.io"

View File

@@ -25,7 +25,6 @@ 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
@@ -66,8 +65,8 @@ class TestBootstrapE2E:
def test_credential_count(self):
skip_count = sum(1 for c in CREDENTIALS if c.provisioner is Provisioner.SKIP)
assert len(CREDENTIALS) == 20
assert len(CREDENTIALS) - skip_count == 18
assert len(CREDENTIALS) == 16
assert len(CREDENTIALS) - skip_count == 16
def test_derive_all_count(self):
values = derive_all(ROOT, COMMIT)

View File

@@ -46,95 +46,34 @@ class TestDiagMainPipelineName:
class TestDiagMainMissingToken:
"""Cover lines 105-106 (no GITEA_TOKEN)."""
"""Cover no GITEA_TOKEN path."""
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_REPO", "homelab/stack")
monkeypatch.delenv("GITEA_TOKEN", raising=False)
from api.diag.__main__ import main
assert main() == 1 # lines 105-106
assert main() == 1
class TestDiagMainFallbackRepo:
"""Cover lines 112-113 (CI_REPO has no slash)."""
"""Cover CI_REPO with no slash."""
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_REPO", "no-slash-here")
monkeypatch.setenv("GITEA_TOKEN", "tok")
mock_wp = MagicMock()
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")
monkeypatch.setenv("CI_STEP_NAME", "test")
monkeypatch.setenv("CI_STEP_LOG", "")
mock_gitea = MagicMock()
with (
patch("api.clients.woodpecker.WoodpeckerClient", return_value=mock_wp),
patch("api.clients.gitea.GiteaClient", return_value=mock_gitea),
):
with patch("api.clients.gitea.GiteaClient", return_value=mock_gitea):
from api.diag.__main__ import main
assert main() == 0 # lines 158-160 (continue)
# 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
assert main() == 0
class TestDiagMainDunderMain:
@@ -191,7 +130,7 @@ class TestVerifyRustfsException:
class TestDeployComposeUpFailure:
"""Cover lines 209-211 (CalledProcessError in compose up)."""
"""Cover CalledProcessError in compose up."""
def test_compose_up_fails(self, tmp_path):
from api.auth.deploy import deploy
@@ -204,7 +143,6 @@ class TestDeployComposeUpFailure:
with (
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.verify_all", return_value=[]),
patch("api.auth.deploy.time.sleep"),
@@ -215,31 +153,6 @@ class TestDeployComposeUpFailure:
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:
"""Cover lines 246-247 (rollback compose up fails)."""
@@ -260,7 +173,6 @@ class TestDeployRollbackComposeFailure:
with (
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.verify_all",

View File

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

View File

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

View File

@@ -16,10 +16,8 @@ from api.auth.manifest import (
Tier,
)
from api.auth.provision import (
WOODPECKER_SECRET_MAP,
derive_all,
provision,
provision_woodpecker,
write_env,
)
@@ -52,7 +50,7 @@ class TestManifestInvariants:
assert cred.fmt == parent.fmt
def test_credential_count(self):
assert len(CREDENTIALS) == 20
assert len(CREDENTIALS) == 16
class TestDeriveAll:
@@ -210,41 +208,6 @@ class TestProvisionGitea:
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:
def test_partial_failure_still_writes_env(self, tmp_path):
env = tmp_path / ".env"
@@ -257,27 +220,21 @@ class TestProvisionEndToEnd:
"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 len(result.errors) == 2
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)
@@ -285,7 +242,6 @@ class TestProvisionEndToEnd:
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
@@ -304,7 +260,6 @@ class TestProvisionEndToEnd:
with (
patch("api.auth.provision.provision_postgres"),
patch("api.auth.provision.provision_gitea", return_value="tok"),
patch("api.auth.provision.provision_woodpecker"),
patch(
"api.auth.provision.provision_gitea_actions",
side_effect=Exception("actions down"),
@@ -318,33 +273,6 @@ class TestProvisionEndToEnd:
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:
def test_raises_without_gitea_token(self) -> None:
"""Raises ValueError when GITEA_TOKEN is missing."""
@@ -425,10 +353,6 @@ class TestBootstrap:
"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.provision_gitea_actions",
side_effect=Exception("actions down"),
@@ -440,7 +364,6 @@ class TestBootstrap:
error_backends = [b for b, _ in result.errors]
assert "postgres" in error_backends
assert "gitea" in error_backends
assert "woodpecker" in error_backends
assert "gitea-actions" in error_backends
def test_success_path(self, tmp_path) -> None:
@@ -451,7 +374,6 @@ class TestBootstrap:
patch("api.auth.provision.bootstrap_postgres"),
patch("api.auth.provision.provision_postgres"),
patch("api.auth.provision.provision_gitea", return_value="tok"),
patch("api.auth.provision.provision_woodpecker"),
patch("api.auth.provision.provision_gitea_actions"),
):
result = bootstrap(ROOT, COMMIT, env)

View File

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