remove: woodpecker CI — replaced by Gitea Actions
Some checks failed
CI / lint-test (push) Failing after 55s
CI / skinny-install (aco) (push) Successful in 1m26s
CI / skinny-install (api) (push) Failing after 39s
CI / skinny-install (bcda) (push) Successful in 47s
CI / skinny-install (bls) (push) Successful in 29s
CI / skinny-install (ccw) (push) Successful in 40s
CI / skinny-install (cli) (push) Successful in 43s
CI / skinny-install (cms) (push) Successful in 40s
CI / skinny-install (conf) (push) Successful in 37s
CI / skinny-install (opps) (push) Successful in 39s
CI / skinny-install (bib) (push) Successful in 5m2s
CI / skinny-install (perf) (push) Successful in 41s
CI / skinny-install (rex) (push) Successful in 40s
CI / skinny-install (pfs) (push) Successful in 53s
Infra CI / notebooks (push) Successful in 13s
Infra CI / zotero (push) Successful in 19s
Infra CI / docs (push) Successful in 1m21s
Infra CI / api (push) Successful in 12s
Infra CI / mc (push) Successful in 12s
Deploy / build-scan-report (push) Failing after 5m22s

- Remove woodpecker-server + woodpecker-agent from compose.yml
- Remove woodpecker volumes and webhook allowlist entry
- Remove traefik ci.fhirworx.io route
- Remove woodpecker client library, backend script, tests
- Remove woodpecker CSS theme
- Remove woodpecker provisioning from deploy pipeline

Gitea Actions (act-runner) handles all CI/CD now.
This commit is contained in:
kert
2026-04-18 13:45:56 -04:00
parent 2efe1b55af
commit 0ed1d2c31f
9 changed files with 1 additions and 1419 deletions

View File

@@ -1,402 +0,0 @@
/* HTI-5 — Federal Register editorial design system for Woodpecker CI */
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Source+Serif+4:ital,wght@0,300;0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
/* HTI-5 Color Palette */
--background: #F7F5F0;
--foreground: #1A1A18;
--card: #FAFAF7;
--primary: #1C2B3A;
--primary-foreground: #F7F5F0;
--secondary: #EDEBE6;
--muted-foreground: #6B6B68;
--border: #D4D0C8;
--sidebar: #1C2B3A;
--sidebar-foreground: #B8C5D0;
--sidebar-accent: #253748;
--sidebar-accent-foreground: #EEE9E0;
--destructive: #C0392B;
--chart-4: #2E8B6E;
--chart-5: #C8702A;
--font-display: "Playfair Display", Georgia, serif;
--font-body: "Source Serif 4", Georgia, serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
/* Woodpecker color overrides — mapped from hti5 tokens */
--color-wp-primary-100: #B8C5D0;
--color-wp-primary-200: #7A96AE;
--color-wp-primary-300: #1C2B3A;
--color-wp-primary-400: #162030;
--color-wp-primary-500: #0E1520;
--color-wp-success-100: #56B397;
--color-wp-success-200: #2E8B6E;
--color-wp-error-100: #D9534F;
--color-wp-error-200: #C0392B;
--color-wp-warning-100: #DDA060;
--color-wp-warning-200: #C8702A;
--color-wp-gray-100: #1A1A18;
--color-wp-gray-200: #6B6B68;
--color-wp-gray-300: #D4D0C8;
--color-wp-gray-400: #EDEBE6;
--color-wp-gray-500: #F7F5F0;
--color-wp-gray-600: #FAFAF7;
}
/* Global styles */
body,
html {
font-family: var(--font-body) !important;
background: var(--background) !important;
color: var(--foreground) !important;
}
/* Headings */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display) !important;
font-weight: 600 !important;
line-height: 1.3 !important;
}
h1 { font-size: 22px !important; color: var(--foreground) !important; }
h2 { font-size: 17px !important; color: var(--primary) !important; }
h3 { font-size: 14px !important; color: var(--foreground) !important; }
/* Header/Navbar */
header,
nav,
.navbar,
[class*="header"],
[class*="Header"] {
background: var(--sidebar) !important;
border-bottom: 3px solid var(--foreground) !important;
}
/* Sidebar */
aside,
[class*="sidebar"],
[class*="Sidebar"] {
background: var(--sidebar) !important;
border-right: 1px solid var(--sidebar-border, var(--sidebar-accent)) !important;
}
/* Main content area */
main,
[class*="content"],
[class*="Content"] {
background: transparent !important;
}
/* Cards and panels */
[class*="card"],
[class*="Card"],
[class*="panel"],
[class*="Panel"],
[class*="box"],
[class*="Box"] {
background: var(--card) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius, 0.2rem) !important;
}
/* Buttons — serif body for button labels */
button,
[class*="btn"],
[class*="Btn"],
[class*="button"],
[class*="Button"] {
font-family: var(--font-body) !important;
font-weight: 500 !important;
border-radius: var(--radius, 0.2rem) !important;
transition: all 0.15s ease !important;
}
/* Primary buttons */
[class*="primary"],
[class*="Primary"],
button[type="submit"] {
background: var(--primary) !important;
color: var(--primary-foreground) !important;
border: 1px solid var(--primary) !important;
}
[class*="primary"]:hover,
[class*="Primary"]:hover,
button[type="submit"]:hover {
background: var(--sidebar-accent) !important;
color: var(--sidebar-accent-foreground) !important;
}
/* Success/green buttons */
[class*="success"],
[class*="Success"],
[class*="green"],
[class*="Green"] {
background: var(--chart-4) !important;
color: #fff !important;
border: 1px solid var(--chart-4) !important;
}
[class*="success"]:hover,
[class*="Success"]:hover {
opacity: 0.85 !important;
}
/* Danger/red buttons */
[class*="danger"],
[class*="Danger"],
[class*="error"],
[class*="Error"],
[class*="red"],
[class*="Red"] {
background: var(--destructive) !important;
color: #fff !important;
}
/* Forms */
input,
textarea,
select {
font-family: var(--font-body) !important;
background: var(--card) !important;
color: var(--foreground) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius, 0.2rem) !important;
padding: 8px 12px !important;
}
/* Pipeline/repo names — Playfair for titles */
[class*="pipeline-name"],
[class*="PipelineName"],
[class*="repo-name"],
[class*="RepoName"] {
font-family: var(--font-display) !important;
font-weight: 700 !important;
}
/* Commit hashes, build numbers, durations — mono */
[class*="commit-hash"],
[class*="CommitHash"],
[class*="build-number"],
[class*="BuildNumber"],
[class*="duration"],
[class*="Duration"],
[class*="timestamp"],
[class*="Timestamp"] {
font-family: var(--font-mono) !important;
font-size: 12px !important;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--primary) !important;
outline: none !important;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent) !important;
}
/* Tables */
table {
background: var(--card) !important;
border: 1px solid var(--border) !important;
border-collapse: collapse !important;
}
th {
background: var(--primary) !important;
color: var(--primary-foreground) !important;
font-family: var(--font-mono) !important;
font-size: 10px !important;
text-transform: uppercase !important;
padding: 10px 12px !important;
border: 1px solid var(--sidebar-accent) !important;
}
td {
background: var(--card) !important;
color: var(--foreground) !important;
padding: 8px 12px !important;
border: 1px solid var(--border) !important;
}
tr:nth-child(even) td {
background: var(--secondary) !important;
}
tr:hover td {
background: var(--secondary) !important;
}
/* Pipeline/build status colors */
[class*="running"],
[class*="Running"],
[class*="pending"],
[class*="Pending"] {
color: var(--chart-5) !important;
}
[class*="success"],
[class*="Success"],
[class*="passed"],
[class*="Passed"] {
color: var(--chart-4) !important;
}
[class*="failure"],
[class*="Failure"],
[class*="failed"],
[class*="Failed"],
[class*="error"],
[class*="Error"] {
color: var(--destructive) !important;
}
/* Build logs */
pre,
code,
[class*="log"],
[class*="Log"],
[class*="console"],
[class*="Console"] {
font-family: var(--font-mono) !important;
background: var(--secondary) !important;
color: var(--foreground) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius, 0.2rem) !important;
}
/* Inline code */
code {
background: var(--secondary) !important;
color: var(--primary) !important;
padding: 2px 6px !important;
border: none !important;
}
/* Links */
a {
color: var(--primary) !important;
text-decoration: none !important;
}
a:hover {
color: var(--foreground) !important;
text-decoration: underline !important;
}
/* Dropdowns */
[class*="dropdown"],
[class*="Dropdown"],
[class*="menu"],
[class*="Menu"],
[class*="popover"],
[class*="Popover"] {
background: var(--card) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius, 0.2rem) !important;
}
[class*="dropdown"] li:hover,
[class*="menu"] li:hover,
[role="menuitem"]:hover,
[role="option"]:hover {
background: var(--secondary) !important;
}
/* Badges and tags */
[class*="badge"],
[class*="Badge"],
[class*="tag"],
[class*="Tag"],
[class*="label"],
[class*="Label"] {
font-family: var(--font-mono) !important;
font-size: 11px !important;
border-radius: var(--radius, 0.2rem) !important;
}
/* Tooltips */
[role="tooltip"],
[class*="tooltip"],
[class*="Tooltip"] {
font-family: var(--font-body) !important;
font-size: 13px !important;
background: var(--card) !important;
color: var(--foreground) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius, 0.2rem) !important;
}
/* Progress bars */
progress,
[class*="progress"],
[class*="Progress"] {
background: var(--secondary) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius, 0.2rem) !important;
}
progress::-webkit-progress-bar {
background: var(--secondary) !important;
}
progress::-webkit-progress-value {
background: var(--primary) !important;
}
/* Scrollbars */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* Selection */
::selection {
background: var(--primary) !important;
color: var(--primary-foreground) !important;
}
/* Icons - ensure visibility */
svg,
[class*="icon"],
[class*="Icon"] {
fill: currentColor;
}
/* Avatar images */
img[class*="avatar"],
[class*="avatar"] img {
border: 1px solid var(--border) !important;
border-radius: var(--radius, 0.2rem) !important;
}
/* Repo/pipeline list items */
[class*="item"],
[class*="Item"],
[class*="list-item"],
[class*="ListItem"] {
border-bottom: 1px solid var(--border) !important;
}
[class*="item"]:hover,
[class*="Item"]:hover {
background: var(--secondary) !important;
}

View File

@@ -181,7 +181,7 @@ services:
- GITEA__server__DOMAIN=git.${DOMAIN:-fhirworx.io}
- GITEA__server__ROOT_URL=https://git.${DOMAIN:-fhirworx.io}/
- GITEA__server__SSH_DOMAIN=git.${DOMAIN:-fhirworx.io}
- GITEA__webhook__ALLOWED_HOST_LIST=woodpecker-server,ci.${DOMAIN:-fhirworx.io},${HOST_IP:-192.168.1.192},172.19.0.0/16
- GITEA__webhook__ALLOWED_HOST_LIST=ci.${DOMAIN:-fhirworx.io},${HOST_IP:-192.168.1.192},172.19.0.0/16
- GITEA__ui__THEMES=fhirworx,fhirworx-dark
- GITEA__ui__DEFAULT_THEME=fhirworx
volumes:
@@ -198,59 +198,6 @@ services:
- no-new-privileges:true
restart: unless-stopped
woodpecker-server:
image: woodpeckerci/woodpecker-server:v3.13.0
container_name: woodpecker-server
networks:
- gateway
- storage
- ci
dns:
- 192.168.5.53
environment:
- WOODPECKER_HOST=https://ci.${DOMAIN:-fhirworx.io}
- WOODPECKER_GRPC_ADDR=:9000
- WOODPECKER_LOG_LEVEL=debug
- WOODPECKER_ADMIN=${WOODPECKER_ADMIN:-kert}
- WOODPECKER_OPEN=true
- WOODPECKER_GITEA=true
- WOODPECKER_GITEA_URL=https://git.${DOMAIN:-fhirworx.io}
- WOODPECKER_GITEA_CLIENT=${WOODPECKER_GITEA_CLIENT}
- WOODPECKER_GITEA_SECRET=${WOODPECKER_GITEA_SECRET}
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
- WOODPECKER_DATABASE_DRIVER=postgres
- WOODPECKER_DATABASE_DATASOURCE=postgres://woodpecker:${WOODPECKER_DB_PASSWORD}@postgres:5432/woodpecker?sslmode=disable
- WOODPECKER_PLUGINS_PRIVILEGED=woodpeckerci/plugin-docker-buildx,woodpeckerci/plugin-trivy
- WOODPECKER_CUSTOM_CSS_FILE=/etc/woodpecker/custom.css
volumes:
- woodpecker_data:/var/lib/woodpecker
- ./assets/css/woodpecker.css:/etc/woodpecker/custom.css:ro
depends_on:
- git
- postgres
restart: unless-stopped
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3.13.0
container_name: woodpecker-agent
networks:
- ci
privileged: true
environment:
- WOODPECKER_SERVER=woodpecker-server:9000
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
- WOODPECKER_MAX_WORKFLOWS=4
- WOODPECKER_BACKEND=docker
- WOODPECKER_BACKEND_DOCKER_NETWORK=ci
- WOODPECKER_BACKEND_DOCKER_VOLUMES=/home/kert/stack/infra/traefik/certs/docker-certs.d:/etc/docker/certs.d:ro
- DOCKER_API_VERSION=1.44
volumes:
- ${DOCKER_SOCK:-/run/user/1000/docker.sock}:/var/run/docker.sock
- woodpecker_agent_config:/etc/woodpecker
depends_on:
- woodpecker-server
restart: unless-stopped
act-runner:
image: gitea/act_runner:latest
container_name: act-runner
@@ -804,8 +751,6 @@ volumes:
rustfs_logs:
gitea_data:
gitea_config:
woodpecker_data:
woodpecker_agent_config:
act_runner_data:
loki_data:
prometheus_data:

View File

@@ -1,745 +0,0 @@
"""Woodpecker CI backend emitter.
Generates .woodpecker/*.yml pipeline files from image definitions
and platform config.
"""
from __future__ import annotations
def emit(
images: list[dict],
scans: list[dict],
platform: dict,
ci_cfg: dict,
) -> dict[str, str]:
"""Return {relative_path: content} for all Woodpecker pipelines."""
ctx = _Ctx(images, scans, platform, ci_cfg)
return {
".woodpecker/ci.yml": ctx.gen_ci(),
".woodpecker/deploy.yml": ctx.gen_deploy(),
".woodpecker/harden.yml": ctx.gen_harden(),
".woodpecker/infra-ci.yml": ctx.gen_infra_ci(),
".woodpecker/rebuild-all.yml": ctx.gen_rebuild_all(),
".woodpecker/release.yml": ctx.gen_release(),
}
class _Ctx:
"""Holds platform/image context for YAML generation."""
def __init__(
self,
images: list[dict],
scans: list[dict],
platform: dict,
ci_cfg: dict,
) -> None:
self.images = images
self.scans = scans
self.registry = platform["registry"]
self.org = platform["org"]
self.image_prefix = platform["image_prefix"]
self.plugin_buildx = ci_cfg.get(
"plugin_buildx", "woodpeckerci/plugin-docker-buildx:5-insecure"
)
self.plugin_buildx_dryrun = ci_cfg.get(
"plugin_buildx_dryrun", "woodpeckerci/plugin-docker-buildx"
)
self.plugin_s3 = ci_cfg.get("plugin_s3", "woodpeckerci/plugin-s3")
self.plugin_comment = ci_cfg.get(
"plugin_comment", "woodpeckerci/plugin-gitea-comment"
)
self.plugin_release = ci_cfg.get(
"plugin_release", "woodpeckerci/plugin-gitea-release"
)
self.docker_socket = ci_cfg.get("docker_socket", "/run/user/1000/docker.sock")
self.uv_image = ci_cfg.get(
"uv_image", "ghcr.io/astral-sh/uv:python3.13-bookworm-slim"
)
self.coverage_threshold = ci_cfg.get("coverage_threshold", 99)
# ── shared fragments ─────────────────────────────────────────
@property
def _variables_block(self) -> str:
return f"""\
variables:
- &buildx_image {self.plugin_buildx}
- &registry {self.registry}
- &org {self.org}
- &image_prefix {self.image_prefix}
- &buildx_base
buildkit_config: |
[registry."{self.registry}"]
http = true
registry: *registry
username:
from_secret: registry_user
password:
from_secret: registry_pass
- &trivy_scan
image: aquasec/trivy:latest
environment:
REGISTRY: *registry
ORG: *org
- &diag_env
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
GITEA_TOKEN:
from_secret: gitea_token
"""
@property
def _failure_reporter(self) -> str:
return f"""\
# ── Failure reporter ─────────────────────────────────────────
- name: report-failure
image: {self.uv_image}
failure: ignore
environment:
<<: *diag_env
commands:
- uv sync --no-dev
- uv run python -m api.diag
when:
- status: [failure]
"""
def _build_step(
self,
img: dict,
tags: list[str],
*,
no_cache: bool = False,
name_prefix: str = "build",
depends: str = "",
) -> str:
n = img["name"]
lines = [
f" - name: {name_prefix}-{n}",
" image: *buildx_image",
" settings:",
" <<: *buildx_base",
f" repo: {self.registry}/{self.org}/{n}",
f" dockerfile: {img['dockerfile']}",
f" context: {img['context']}",
" tags:",
]
for t in tags:
lines.append(f' - "{t}"')
if no_cache:
lines.append(" no_cache: true")
if depends:
lines.append(" depends_on:")
lines.append(f" - {depends}")
return "\n".join(lines)
def _prep_step(self, img: dict, *, env_block: str = "") -> str:
n = img["name"]
cmds = img.get("prep_commands", [])
if not cmds:
return ""
lines = [
f" - name: prep-{n}-context",
" image: alpine:3",
]
if env_block:
lines.append(env_block)
if n == "docs":
lines.append(" volumes:")
lines.append(" - ${CI_WORKSPACE}/data:/host-data:ro")
lines.append(" commands:")
for cmd in cmds:
lines.append(f" - {cmd}")
return "\n".join(lines)
def _scan_step(self, img: dict, tag_expr: str) -> str:
n = img["name"]
sev = img.get("trivy_severity", "HIGH,CRITICAL")
ec = img.get("trivy_exit_code", 0)
return f"""\
- name: scan-{n}
<<: *trivy_scan
commands:
- trivy image --image-src remote --insecure --severity {sev}
--exit-code {ec} --format json -o {n}-scan.json
"$REGISTRY/$ORG/{n}:{tag_expr}"
depends_on: [build-push-{n}]"""
def _s3_upload_step(self, img: dict) -> str:
n = img["name"]
return f"""\
- name: upload-{n}-scan
image: {self.plugin_s3}
failure: ignore
settings:
endpoint: http://rustfs:9000
bucket: gitea
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: "{n}-scan.json"
target: /ci/${{CI_REPO}}/${{CI_COMMIT_SHA:0:8}}/
path_style: true
depends_on:
- scan-{n}"""
def _scan_report_loop(self, imgs: list[dict]) -> str:
files = " ".join(f"{i['name']}-scan.json" for i in imgs)
return f"""\
- |
for f in {files}; do
[ -f "$f" ] && uv run python -m api.diag.vuln "$f" || true
done"""
def _harden_close_loop(self, imgs: list[dict]) -> str:
files = " ".join(f"{i['name']}-scan.json" for i in imgs)
return f"""\
- |
for f in {files}; do
if [ -f "$f" ]; then
uv run python -m api.diag.vuln --close "$f" || \\
uv run python -m api.diag.vuln "$f" || true
fi
done"""
def _path_block(self, pf: list[str]) -> str:
if not pf:
return ""
if len(pf) == 1:
return f' - path: "{pf[0]}"'
return " - path:\n" + "\n".join(f' - "{p}"' for p in pf)
# ── pipeline generators ──────────────────────────────────────
def gen_ci(self) -> str:
return f"""\
# ── Quality gate (generated by gen_config.py — woodpecker backend) ──
# DO NOT EDIT — edit stack.toml and run gen_config.py
when:
- event: [push, pull_request, manual]
steps:
- name: lint
image: {self.uv_image}
environment:
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
commands:
- uv sync --dev
- uv run ruff check src/ tests/ --output-format=concise
- uv run ruff format --check src/ tests/
- name: test
image: {self.uv_image}
environment:
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
commands:
- uv run pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under={self.coverage_threshold} -q 2>&1 | tee pytest.out
- uv run python dev/scripts/coverage_badge.py < pytest.out > assets/icons/coverage.svg
depends_on:
- lint
- name: coverage-badge
image: {self.uv_image}
failure: ignore
environment:
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
GITEA_TOKEN:
from_secret: gitea_token
GITEA_URL:
from_secret: gitea_url
commands:
- uv run python dev/scripts/coverage_badge.py --post pytest.out
depends_on:
- test
- name: commit-badge
image: alpine/git
failure: ignore
environment:
REGISTRY_USER:
from_secret: registry_user
REGISTRY_PASS:
from_secret: registry_pass
commands:
- git config user.name woodpecker-ci
- git config user.email ci@${{CI_REPO_OWNER}}.io
- git add assets/icons/coverage.svg
- git diff --cached --quiet && echo "no change" && exit 0
- git commit -m "update coverage badge [skip ci]"
- git push http://$REGISTRY_USER:$REGISTRY_PASS@git:3000/${{CI_REPO}}.git HEAD:main
when:
- branch: main
event: push
depends_on:
- test
- name: upload-coverage-badge
image: {self.plugin_s3}
failure: ignore
settings:
endpoint: http://rustfs:9000
bucket: gitea
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: "assets/icons/coverage.svg"
target: /badges/${{CI_REPO}}/
path_style: true
overwrite: true
depends_on:
- test
- name: pr-comment
image: {self.plugin_comment}
settings:
gitea_url:
from_secret: gitea_url
api_key:
from_secret: gitea_token
message: >
**CI** for `${{CI_COMMIT_SHA:0:8}}` —
[pipeline #${{CI_PIPELINE_NUMBER}}](${{CI_PIPELINE_URL}})
when:
- event: pull_request
depends_on:
- test
- coverage-badge
- name: validate-compose
image: docker:cli
volumes:
- {self.docker_socket}:/var/run/docker.sock
commands:
- docker compose config --quiet
- name: validate-gen-config
image: {self.uv_image}
environment:
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
commands:
- uv run python dev/scripts/gen_config.py --check
- name: skinny-install
image: {self.uv_image}
environment:
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
commands:
- |
for extra in conf aco api bcda bib bls ccw cli cms opps perf pfs rex; do
echo "=== stack[$extra] ==="
uv sync --no-dev --extra "$extra"
uv run python -c "import $extra"
if [ -d "tests/$extra" ]; then
uv run pytest "tests/$extra/" -x -q || true
fi
done
{self._failure_reporter}
"""
def gen_deploy(self) -> str:
tag_expr = "${CI_COMMIT_SHA:0:8}"
parts = [
"# ── Deploy pipeline (generated by gen_config.py — woodpecker backend) ──",
"# DO NOT EDIT — edit stack.toml [images] and run gen_config.py",
"",
"when:",
" - event: push",
" branch: main",
"",
self._variables_block,
"steps:",
" # ── Python package ──────────────────────────────────────────",
" - name: build-package",
f" image: {self.uv_image}",
" commands:",
" - BASE=$(grep '^version' pyproject.toml | head -1 | sed 's/.*\"\\(.*\\)\"/\\1/')",
' - uv version "$BASE.dev${CI_PIPELINE_NUMBER}" --no-sync',
" - uv build --out-dir dist/",
" - ls -lh dist/",
"",
" - name: publish-package",
f" image: {self.uv_image}",
" environment:",
" REGISTRY_USER:",
" from_secret: registry_user",
" REGISTRY_PASS:",
" from_secret: registry_pass",
" commands:",
' - uv publish --publish-url http://git:3000/api/packages/${CI_REPO_OWNER}/pypi --username "$REGISTRY_USER" --password "$REGISTRY_PASS" dist/*',
" depends_on:",
" - build-package",
"",
" # ── Container images ──────────────────────────────────────",
]
for img in self.images:
prep = self._prep_step(img)
if prep:
parts.append(prep)
parts.append("")
parts.append(
self._build_step(
img,
[tag_expr, "latest"],
name_prefix="build-push",
depends=f"prep-{img['name']}-context",
)
)
else:
parts.append(
self._build_step(
img, [tag_expr, "latest"], name_prefix="build-push"
)
)
parts.append("")
parts.append(" # ── Scan images ──────────────────────────────────────────")
for img in self.scans:
parts.append(self._scan_step(img, tag_expr))
parts.append("")
parts.append(" # ── Upload scan results ─────────────────────────────────")
for img in self.scans:
parts.append(self._s3_upload_step(img))
parts.append("")
parts.append(" # ── Report vulnerabilities ──────────────────────────────")
parts.append(" - name: report-vulns")
parts.append(f" image: {self.uv_image}")
parts.append(" failure: ignore")
parts.append(" environment:")
parts.append(" <<: *diag_env")
parts.append(" commands:")
parts.append(" - uv sync --no-dev")
parts.append(self._scan_report_loop(self.scans))
parts.append(" depends_on:")
for i in self.scans:
parts.append(f" - scan-{i['name']}")
parts.append("")
parts.append(" # ── Deploy (disabled — manual only) ────────────────────")
parts.append(" - name: deploy")
parts.append(" image: alpine:3")
parts.append(" commands:")
parts.append(
' - echo "Deploy disabled — run manually after green pipeline"'
)
parts.append(" depends_on:")
parts.append(" - publish-package")
for i in self.scans:
parts.append(f" - scan-{i['name']}")
for i in self.images:
if not i.get("scan", True):
parts.append(f" - build-push-{i['name']}")
parts.append("")
parts.append(self._failure_reporter)
return "\n".join(parts)
def gen_harden(self) -> str:
parts = [
"# ── Hardening pipeline (generated by gen_config.py — woodpecker backend) ──",
"# DO NOT EDIT — edit stack.toml [images] and run gen_config.py",
"",
"when:",
" - event: [manual, cron]",
"",
self._variables_block,
"steps:",
" # ── Build hardened images ──────────────────────────────────",
]
for img in self.images:
parts.append(self._build_step(img, ["hardened", "latest"], no_cache=True))
parts.append("")
parts.append(" # ── Scan hardened images ───────────────────────────────────")
for img in self.scans:
parts.append(self._scan_step(img, "hardened"))
parts.append("")
parts.append(" # ── Close resolved vuln issues OR file new ones ────────────")
parts.append(" - name: close-or-report-vulns")
parts.append(f" image: {self.uv_image}")
parts.append(" environment:")
parts.append(" <<: *diag_env")
parts.append(" commands:")
parts.append(" - uv sync --no-dev")
parts.append(self._harden_close_loop(self.scans))
parts.append(" depends_on:")
for i in self.scans:
parts.append(f" - scan-{i['name']}")
parts.append("")
parts.append(self._failure_reporter)
return "\n".join(parts)
def gen_rebuild_all(self) -> str:
tag_expr = "${CI_COMMIT_SHA:0:8}"
svc_list = " ".join(i["name"] for i in self.images)
parts = [
"# ── Rebuild-all pipeline (generated by gen_config.py — woodpecker backend) ──",
"# DO NOT EDIT — edit stack.toml [images] and run gen_config.py",
"",
"when:",
" - event: manual",
"",
self._variables_block,
"steps:",
]
for img in self.images:
prep = self._prep_step(
img,
env_block=" environment:\n STACK_ROOT:\n from_secret: stack_root",
)
if prep:
parts.append(prep)
parts.append("")
parts.append(
self._build_step(
img,
[tag_expr, "latest"],
name_prefix="build-push",
depends=f"prep-{img['name']}-context",
)
)
else:
parts.append(
self._build_step(
img, [tag_expr, "latest"], name_prefix="build-push"
)
)
parts.append("")
parts.append(" # ── Scan images ──────────────────────────────────────────")
for img in self.scans:
parts.append(self._scan_step(img, tag_expr))
parts.append("")
parts.append(" # ── Report vulnerabilities ──────────────────────────────")
parts.append(" - name: report-vulns")
parts.append(f" image: {self.uv_image}")
parts.append(" failure: ignore")
parts.append(" environment:")
parts.append(" <<: *diag_env")
parts.append(" commands:")
parts.append(" - uv sync --no-dev")
parts.append(self._scan_report_loop(self.scans))
parts.append(" depends_on:")
for i in self.scans:
parts.append(f" - scan-{i['name']}")
parts.append("")
parts.append(" - name: deploy")
parts.append(" image: docker:cli")
parts.append(" environment:")
parts.append(" REGISTRY: *registry")
parts.append(" ORG: *org")
parts.append(" IMAGE_PREFIX: *image_prefix")
parts.append(" STACK_ROOT:")
parts.append(" from_secret: stack_root")
parts.append(" volumes:")
parts.append(f" - {self.docker_socket}:/var/run/docker.sock")
parts.append(
" - ${STACK_ROOT:-/home/kert/stack}:${STACK_ROOT:-/home/kert/stack}"
)
parts.append(" commands:")
parts.append(" - STACK_ROOT=${STACK_ROOT:-/home/kert/stack}")
parts.append(" - cd $STACK_ROOT")
parts.append(" - TAG=${CI_COMMIT_SHA:0:8}")
parts.append(f" - for SVC in {svc_list}; do")
parts.append(" docker pull $REGISTRY/$ORG/$SVC:$TAG &&")
parts.append(
" docker tag $REGISTRY/$ORG/$SVC:$TAG $IMAGE_PREFIX/$SVC:$TAG;"
)
parts.append(" done")
parts.append(
' - sed -i "s/^COMMIT_SHA=.*/COMMIT_SHA=$TAG/" .env 2>/dev/null || echo "COMMIT_SHA=$TAG" >> .env'
)
parts.append(" - docker compose up -d --remove-orphans")
parts.append(" depends_on:")
for i in self.scans:
parts.append(f" - scan-{i['name']}")
for i in self.images:
if not i.get("scan", True):
parts.append(f" - build-push-{i['name']}")
parts.append("")
parts.append(self._failure_reporter)
return "\n".join(parts)
def gen_infra_ci(self) -> str:
parts = [
"# ── Infrastructure quality gate (generated by gen_config.py — woodpecker backend) ──",
"# DO NOT EDIT — edit stack.toml [images] and run gen_config.py",
"",
"when:",
" - event: [push, pull_request, manual]",
"",
"steps:",
" - name: infra-gate",
" image: alpine:3",
" commands:",
' - echo "infra-ci gate passed"',
"",
]
for img in self.images:
if not img.get("hadolint", True):
continue
n = img["name"]
pb = self._path_block(img.get("path_filter", []))
parts.append(f" - name: hadolint-{n}")
parts.append(" image: hadolint/hadolint:latest-debian")
parts.append(" commands:")
parts.append(f" - hadolint {img['dockerfile']}")
if pb:
parts.append(" when:")
parts.append(pb)
parts.append("")
parts.append(f" - name: build-{n}")
parts.append(f" image: {self.plugin_buildx_dryrun}")
parts.append(" settings:")
parts.append(f" dockerfile: {img['dockerfile']}")
parts.append(f" context: {img['context']}")
parts.append(' tags: ["ci-test"]')
parts.append(" daemon_off: true")
parts.append(" dry_run: true")
parts.append(" volumes:")
parts.append(f" - {self.docker_socket}:/var/run/docker.sock")
parts.append(" depends_on:")
parts.append(f" - hadolint-{n}")
if pb:
parts.append(" when:")
parts.append(pb)
parts.append("")
parts.append(" # ── Config validation ────────────────────────────────────")
parts.append("""\
- name: validate-nginx
image: nginx:alpine
commands:
- cp infra/nginx/nginx.conf /etc/nginx/nginx.conf
- nginx -t
when:
- path: "infra/nginx/**"
- name: validate-prometheus
image: prom/prometheus:latest
commands:
- promtool check config infra/prometheus/prometheus.yml
when:
- path: "infra/prometheus/**"
- name: validate-traefik-static
image: cytopia/yamllint:latest
commands:
- yamllint -d relaxed infra/traefik/traefik.yml
when:
- path: "infra/traefik/traefik.yml"
- name: validate-traefik-template
image: traefik:v3.3
environment:
DOMAIN: ci-test.example.com
commands:
- |
timeout 5 traefik \\
--providers.file.directory=infra/traefik/dynamic \\
--api.dashboard=false \\
--log.level=DEBUG 2>&1 | head -80 || true
- echo "Traefik template syntax OK"
when:
- path: "infra/traefik/dynamic/**"
- name: validate-loki
image: cytopia/yamllint:latest
commands:
- yamllint -d relaxed infra/loki/
when:
- path: "infra/loki/**"
- name: validate-trino
image: alpine:3
commands:
- |
OK=true
for f in infra/trino/etc/config.properties infra/trino/etc/node.properties infra/trino/etc/jvm.config; do
if [ ! -f "$f" ]; then echo "MISSING: $f"; OK=false; fi
done
ls infra/trino/etc/catalog/*.properties >/dev/null 2>&1 || { echo "MISSING: no catalog properties"; OK=false; }
for f in $(find infra/trino/etc -name '*.properties'); do
while IFS= read -r line; do
case "$line" in ''|'#'*|'!'*) continue ;; esac
echo "$line" | grep -q '=' || { echo "BAD LINE in $f: $line"; OK=false; }
done < "$f"
done
$OK && echo "Trino config validation passed"
$OK
when:
- path: "infra/trino/etc/**"
""")
parts.append(self._failure_reporter)
return "\n".join(parts)
def gen_release(self) -> str:
return f"""\
# ── Release pipeline (generated by gen_config.py — woodpecker backend) ──
# DO NOT EDIT — edit stack.toml and run gen_config.py
when:
- event: tag
variables:
- &diag_env
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
GITEA_TOKEN:
from_secret: gitea_token
steps:
- name: build-package
image: {self.uv_image}
commands:
- uv build --out-dir dist/
- ls -lh dist/
- name: release
image: {self.plugin_release}
settings:
api_key:
from_secret: gitea_token
base_url:
from_secret: gitea_url
files:
- dist/*.whl
- dist/*.tar.gz
title: "${{CI_COMMIT_TAG}}"
depends_on:
- build-package
{self._failure_reporter}
"""

View File

@@ -3,7 +3,6 @@
"dashboard" (dict "port" "80" "theme" true "extra_hosts" (list $domain) "mw" "secure-headers")
"docs" (dict "port" "80" "theme" true "mw" "git-sso,secure-headers")
"git" (dict "port" "3000" "theme" false "mw" "secure-headers")
"woodpecker-server" (dict "port" "8000" "theme" true "subdomain" "ci" "mw" "git-sso,secure-headers")
"notebooks" (dict "port" "2718" "theme" true "mw" "git-sso,secure-headers")
"zotero" (dict "port" "8080" "theme" true "mw" "git-sso,secure-headers")
"webdav" (dict "port" "8080" "theme" false "mw" "secure-headers")

View File

@@ -29,7 +29,6 @@ from api.auth.provision import (
_retry,
derive_all,
provision_gitea,
provision_woodpecker,
write_env,
)
@@ -214,15 +213,6 @@ def deploy(
# Wait for services to be ready
time.sleep(10)
# Woodpecker secrets
try:
_retry(lambda: provision_woodpecker(values), "woodpecker")
result.woodpecker = True
log.info("Phase 3: Woodpecker secrets synced")
except Exception as e:
result.errors.append(("woodpecker", e))
log.error("Phase 3: Woodpecker sync failed: %s", e)
# Health check
health_errors = verify_all(values)
if health_errors:

View File

@@ -5,5 +5,4 @@ from .base import Client as Client
from .gitea import GiteaClient as GiteaClient
from .rustfs import RustFSAdmin as RustFSAdmin
from .rustfs import RustFSClient as RustFSClient
from .woodpecker import WoodpeckerClient as WoodpeckerClient
from .zotero import ZoteroClient as ZoteroClient

View File

@@ -1,3 +0,0 @@
"""Woodpecker CI API client."""
from .client import WoodpeckerClient as WoodpeckerClient

View File

@@ -1,83 +0,0 @@
"""Woodpecker CI API client."""
from __future__ import annotations
from api.clients.base import Client
class WoodpeckerClient(Client):
"""Client for the Woodpecker CI REST API.
Parameters
----------
token : str
Personal API token.
base_url : str
Woodpecker API base URL.
"""
def __init__(
self,
token: str,
*,
base_url: str = "",
**kw,
) -> None:
if not base_url:
from conf import cfg
base_url = f"{cfg.services.woodpecker}/api"
super().__init__(base_url, **kw)
self._token = token
def _default_headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self._token}"}
# ── Repos ────────────────────────────────────────────────
def list_repos(self) -> list[dict]:
return self.get("/repos").json()
def get_repo(self, repo_id: int) -> dict:
return self.get(f"/repos/{repo_id}").json()
# ── Pipelines ────────────────────────────────────────────
def list_pipelines(self, repo_id: int) -> list[dict]:
return self.get(f"/repos/{repo_id}/pipelines").json()
def get_pipeline(self, repo_id: int, number: int) -> dict:
return self.get(f"/repos/{repo_id}/pipelines/{number}").json()
def get_logs(self, repo_id: int, number: int, step: int) -> list[dict]:
return self.get(f"/repos/{repo_id}/logs/{number}/{step}").json()
# ── Secrets ──────────────────────────────────────────────
def list_secrets(self, repo_id: int) -> list[dict]:
return self.get(f"/repos/{repo_id}/secrets").json()
def create_secret(self, repo_id: int, body: dict) -> dict:
return self.post(f"/repos/{repo_id}/secrets", json=body).json()
def update_secret(self, repo_id: int, name: str, body: dict) -> dict:
return self.patch(f"/repos/{repo_id}/secrets/{name}", json=body).json()
def delete_secret(self, repo_id: int, name: str) -> None:
self.delete(f"/repos/{repo_id}/secrets/{name}")
# ── Global secrets ─────────────────────────────────────
def list_global_secrets(self) -> list[dict]:
return self.get("/secrets").json()
def create_global_secret(self, body: dict) -> dict:
return self.post("/secrets", json=body).json()
def update_global_secret(self, name: str, body: dict) -> dict:
return self.patch(f"/secrets/{name}", json=body).json()
# ── Server ───────────────────────────────────────────────
def version(self) -> dict:
return self.get("/version").json()

View File

@@ -1,118 +0,0 @@
"""Tests for WoodpeckerClient."""
from __future__ import annotations
from api.clients.woodpecker import WoodpeckerClient
class TestWoodpeckerAuth:
def test_bearer_header(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("wp-tok", _transport=cap.transport())
c.list_repos()
assert cap.requests[0].headers["authorization"] == "Bearer wp-tok"
class TestWoodpeckerRoutes:
def test_list_repos(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport([]))
c.list_repos()
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/api/repos"
def test_get_pipeline(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport({}))
c.get_pipeline(42, 7)
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/api/repos/42/pipelines/7"
def test_create_secret(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport({}))
c.create_secret(1, {"name": "s", "value": "v"})
req = cap.requests[0]
assert req.method == "POST"
assert req.url.path == "/api/repos/1/secrets"
def test_version(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport({"version": "2.0"}))
result = c.version()
assert result == {"version": "2.0"}
assert cap.requests[0].url.path == "/api/version"
def test_get_repo(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport({}))
c.get_repo(42)
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/api/repos/42"
def test_list_pipelines(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport([]))
c.list_pipelines(10)
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/api/repos/10/pipelines"
def test_get_logs(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport([]))
c.get_logs(10, 5, 1)
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/api/repos/10/logs/5/1"
def test_list_secrets(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport([]))
c.list_secrets(10)
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/api/repos/10/secrets"
def test_update_secret(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport({}))
c.update_secret(10, "my_secret", {"value": "new"})
req = cap.requests[0]
assert req.method == "PATCH"
assert req.url.path == "/api/repos/10/secrets/my_secret"
def test_delete_secret(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport(None, status=204))
c.delete_secret(10, "old_secret")
req = cap.requests[0]
assert req.method == "DELETE"
assert req.url.path == "/api/repos/10/secrets/old_secret"
def test_list_global_secrets(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport([]))
c.list_global_secrets()
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/api/secrets"
def test_create_global_secret(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport({}))
c.create_global_secret({"name": "s", "value": "v"})
req = cap.requests[0]
assert req.method == "POST"
assert req.url.path == "/api/secrets"
def test_update_global_secret(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport({}))
c.update_global_secret("root_key", {"value": "new"})
req = cap.requests[0]
assert req.method == "PATCH"
assert req.url.path == "/api/secrets/root_key"