feat: package supply chain — inventory, mirrors, drift, vuln scanning (refs #159–#168)
Some checks failed
CI / skinny-install (aco) (push) Successful in 46s
CI / skinny-install (api) (push) Successful in 28s
CI / skinny-install (bcda) (push) Successful in 26s
CI / skinny-install (bib) (push) Successful in 24s
CI / skinny-install (bls) (push) Successful in 28s
CI / skinny-install (ccw) (push) Successful in 31s
CI / skinny-install (cli) (push) Successful in 26s
CI / skinny-install (cms) (push) Successful in 26s
CI / skinny-install (conf) (push) Successful in 26s
CI / skinny-install (pfs) (push) Successful in 26s
CI / skinny-install (rex) (push) Successful in 24s
CI / lint-test (push) Successful in 5m50s
Infra CI / notebooks (push) Successful in 1m14s
Infra CI / zotero (push) Failing after 5s
Infra CI / docs (push) Successful in 6s
Infra CI / api (push) Successful in 13s
Infra CI / mc (push) Successful in 7s
Deploy / build-scan-report (push) Successful in 5m6s

Cherry-picked from feat/pkg-supply-chain, adapted for infra/ tree layout:

- dev/scripts/pkg_inventory.py — scans Dockerfiles, pyproject.toml, CI
  workflows, and shell scripts to build a unified package manifest
- dev/scripts/pkg_mirror_sync.py — syncs PyPI/APK/npm packages to
  Gitea package registry (replaces devpi/apt-cacher-ng)
- dev/scripts/pkg_drift.py — compares mirror contents against manifest,
  flags missing or stale packages
- dev/scripts/pkg_issues.py — auto-creates Gitea issues for drift and
  CVE findings
- dev/scripts/test_network_isolation.sh — verifies containers can't
  reach the internet except through mirrors
- dev/pipelines/pkg-supply-chain.yml — CI-agnostic pipeline spec
- .gitea/workflows/pkg-supply-chain.yml — daily + push-triggered CI job
- PYPI_INDEX_URL build arg added to api and notebooks Dockerfiles
This commit is contained in:
kert
2026-03-24 17:02:30 -04:00
parent 4820adf28f
commit 29b302cdf0
9 changed files with 1599 additions and 1 deletions

View File

@@ -0,0 +1,58 @@
# DO NOT EDIT — generated by gen_config.py from stack.toml
# Re-generate: uv run python dev/scripts/gen_config.py
name: Package Supply Chain
on:
push:
paths:
- "**/Dockerfile*"
- "pyproject.toml"
- "uv.lock"
- "docs/package.json"
- "docs/pnpm-lock.yaml"
workflow_dispatch:
schedule:
- cron: "0 6 * * *"
jobs:
pkg-supply-chain:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Set up uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
env:
UV_INSTALL_DIR: /usr/local/bin
- name: Install dependencies
run: uv sync --no-dev
- name: Package inventory
run: uv run python dev/scripts/pkg_inventory.py
- name: Check manifest freshness
run: uv run python dev/scripts/pkg_inventory.py --check
- name: Sync to Gitea package registry
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: uv run python dev/scripts/pkg_mirror_sync.py
- name: Drift detection
run: uv run python dev/scripts/pkg_drift.py
- name: Install trivy
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
- name: Vulnerability scan
run: |
trivy fs --scanners vuln --format json -o data/pkg-vulns.json uv.lock || true
trivy fs --scanners vuln --format json -o data/pkg-vulns-pyproject.json pyproject.toml || true
- name: Auto-create issues for drift and vulns
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: uv run python dev/scripts/pkg_issues.py

View File

@@ -0,0 +1,61 @@
# CI-agnostic pipeline definition for package supply chain management.
# This is the abstract spec — use gen_config.py to emit concrete workflow
# files for Gitea Actions, Woodpecker, and GitHub Actions.
#
# Pipeline: inventory → mirror-sync → drift-check → vuln-scan → issue-create
name: Package Supply Chain
triggers:
push:
paths:
- "**/Dockerfile*"
- "pyproject.toml"
- "uv.lock"
- "docs/package.json"
- "docs/pnpm-lock.yaml"
- ".gitea/workflows/*.yml"
schedule:
cron: "0 6 * * *" # daily at 06:00 UTC
env:
GITEA_URL: "http://gitea:3000"
DEVPI_URL: "http://devpi:3141/root/pypi/+simple/"
APT_MIRROR_URL: "http://apt-cache:3142"
steps:
- name: inventory
description: Scan repo and regenerate package manifest
run: uv run python dev/scripts/pkg_inventory.py
outputs:
- data/pkg-manifest.json
- name: mirror-sync
description: Update local mirrors to match manifest
needs: [inventory]
run: uv run python dev/scripts/pkg_mirror_sync.py
services:
- apt-cache
- devpi
- name: drift-check
description: Compare mirror contents against manifest
needs: [mirror-sync]
run: uv run python dev/scripts/pkg_drift.py
fail_on: drift
- name: vuln-scan
description: Scan mirrored packages for known CVEs
needs: [mirror-sync]
run: |
trivy fs --scanners vuln --format json --output data/pkg-vulns.json data/pkg-manifest.json
uv run python dev/scripts/pkg_vuln_report.py
tools:
- trivy
- name: auto-issues
description: Create Gitea issues for missing packages and CVEs
needs: [drift-check, vuln-scan]
run: uv run python dev/scripts/pkg_issues.py
env:
GITEA_TOKEN: "${GITEA_TOKEN}"

142
dev/scripts/pkg_drift.py Normal file
View File

@@ -0,0 +1,142 @@
"""Detect drift between the package manifest and what is actually used.
Compares data/pkg-manifest.json against the repo source to detect:
- packages in manifest but missing from source (phantom)
- packages in source but missing from manifest (untracked)
Usage:
uv run python dev/scripts/pkg_drift.py # report + exit code
uv run python dev/scripts/pkg_drift.py --json # JSON output
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MANIFEST_PATH = ROOT / "data" / "pkg-manifest.json"
def load_manifest() -> dict:
if not MANIFEST_PATH.exists():
print(f"ERROR: {MANIFEST_PATH} not found. Run pkg_inventory.py first.")
raise SystemExit(2)
return json.loads(MANIFEST_PATH.read_text())
def scan_fresh() -> dict:
"""Run a fresh inventory scan and return the result."""
from pkg_inventory import scan
return scan()
def diff_lists(manifest_items: list, fresh_items: list, key: str = "name") -> dict:
"""Compare two lists of dicts by a key field."""
if manifest_items and isinstance(manifest_items[0], dict):
m_set = {item[key] for item in manifest_items}
f_set = {item[key] for item in fresh_items}
else:
m_set = set(manifest_items)
f_set = set(fresh_items)
return {
"phantom": sorted(m_set - f_set),
"untracked": sorted(f_set - m_set),
}
def check_drift() -> dict:
manifest = load_manifest()
fresh = scan_fresh()
drift: dict = {"apt": {}, "apk": {}, "pypi": {}, "npm": {}, "has_drift": False}
# apt
all_apt_manifest = set()
for pkgs in manifest.get("apt", {}).values():
all_apt_manifest.update(pkgs)
all_apt_fresh = set()
for pkgs in fresh.get("apt", {}).values():
all_apt_fresh.update(pkgs)
apt_diff = {
"phantom": sorted(all_apt_manifest - all_apt_fresh),
"untracked": sorted(all_apt_fresh - all_apt_manifest),
}
if apt_diff["phantom"] or apt_diff["untracked"]:
drift["apt"] = apt_diff
drift["has_drift"] = True
# apk
all_apk_manifest = set()
for pkgs in manifest.get("apk", {}).values():
all_apk_manifest.update(pkgs)
all_apk_fresh = set()
for pkgs in fresh.get("apk", {}).values():
all_apk_fresh.update(pkgs)
apk_diff = {
"phantom": sorted(all_apk_manifest - all_apk_fresh),
"untracked": sorted(all_apk_fresh - all_apk_manifest),
}
if apk_diff["phantom"] or apk_diff["untracked"]:
drift["apk"] = apk_diff
drift["has_drift"] = True
# pypi (compare all sections)
for section in ("project_prod", "project_dev", "notebook", "dockerfile_adhoc"):
d = diff_lists(
manifest.get("pypi", {}).get(section, []),
fresh.get("pypi", {}).get(section, []),
)
if d["phantom"] or d["untracked"]:
drift["pypi"][section] = d
drift["has_drift"] = True
# npm
d = diff_lists(
manifest.get("npm", []),
fresh.get("npm", []),
)
if d["phantom"] or d["untracked"]:
drift["npm"] = d
drift["has_drift"] = True
return drift
def main() -> None:
drift = check_drift()
if "--json" in sys.argv:
print(json.dumps(drift, indent=2))
else:
if not drift["has_drift"]:
print("No drift detected — manifest matches repo source.")
else:
print("DRIFT DETECTED:")
for pkg_type in ("apt", "apk", "pypi", "npm"):
section = drift[pkg_type]
if not section:
continue
if pkg_type == "pypi":
for sub, d in section.items():
if d.get("phantom"):
print(
f" pypi/{sub} phantom (in manifest, not in source): {d['phantom']}"
)
if d.get("untracked"):
print(
f" pypi/{sub} untracked (in source, not in manifest): {d['untracked']}"
)
else:
if section.get("phantom"):
print(f" {pkg_type} phantom: {section['phantom']}")
if section.get("untracked"):
print(f" {pkg_type} untracked: {section['untracked']}")
raise SystemExit(1 if drift["has_drift"] else 0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,339 @@
"""Scan the repo for all apt, apk, and PyPI packages in use.
Parses Dockerfiles, pyproject.toml, CI workflows, and shell scripts to
produce a canonical JSON manifest at data/pkg-manifest.json.
Usage:
uv run python dev/scripts/pkg_inventory.py
uv run python dev/scripts/pkg_inventory.py --check # exit 1 if manifest is stale
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MANIFEST_PATH = ROOT / "data" / "pkg-manifest.json"
# Directories to skip when scanning Dockerfiles
SKIP_DIRS = {"node_modules", ".git", "__pycache__", "plugins"}
# ---------------------------------------------------------------------------
# Dockerfile parsers
# ---------------------------------------------------------------------------
def _join_continuation_lines(text: str) -> str:
"""Merge backslash-continued lines into single lines."""
return re.sub(r"\\\s*\n\s*", " ", text)
def _parse_apt_packages(text: str) -> list[str]:
"""Extract packages from apt-get install commands."""
text = _join_continuation_lines(text)
pkgs: set[str] = set()
for m in re.finditer(r"apt-get\s+install\s+(?:-\S+\s+)*(.+?)(?:&&|;|\n|$)", text):
tokens = m.group(1).split()
for tok in tokens:
tok = tok.strip()
if tok and not tok.startswith("-") and not tok.startswith("#"):
# strip version pin like =1.2.3
name = re.split(r"[=<>]", tok)[0]
if name and not name.startswith("/"):
pkgs.add(name)
return sorted(pkgs)
def _parse_apk_packages(text: str) -> list[str]:
"""Extract packages from apk add commands."""
text = _join_continuation_lines(text)
pkgs: set[str] = set()
for m in re.finditer(r"apk\s+add\s+(?:-\S+\s+)*(.+?)(?:&&|;|\n|$)", text):
tokens = m.group(1).split()
for tok in tokens:
tok = tok.strip()
if tok and not tok.startswith("-") and not tok.startswith("#"):
name = re.split(r"[=<>~]", tok)[0]
if name:
pkgs.add(name)
return sorted(pkgs)
def _parse_uv_add_packages(text: str) -> list[dict]:
"""Extract packages from 'uv add' commands in Dockerfiles."""
text = _join_continuation_lines(text)
pkgs: list[dict] = []
for m in re.finditer(r"uv\s+add\s+(.+?)(?:&&|;|\n|$)", text):
tokens = m.group(1).split()
for tok in tokens:
tok = tok.strip().strip('"').strip("'")
if tok.startswith("-"):
continue
if tok.startswith("http"):
continue
if tok:
pkgs.append(_parse_pypi_spec(tok))
return pkgs
def _parse_pip_install_packages(text: str) -> list[dict]:
"""Extract packages from 'pip install' commands in Dockerfiles."""
text = _join_continuation_lines(text)
pkgs: list[dict] = []
for m in re.finditer(r"pip\s+install\s+(.+?)(?:&&|;|\n|$)", text):
tokens = m.group(1).split()
for tok in tokens:
tok = tok.strip().strip('"').strip("'")
if tok.startswith("-"):
continue
if tok and tok != ".":
pkgs.append(_parse_pypi_spec(tok))
return pkgs
def _parse_uv_run_with_packages(text: str) -> list[dict]:
"""Extract packages from 'uv run --with pkg' commands."""
text = _join_continuation_lines(text)
pkgs: list[dict] = []
for m in re.finditer(r"uv\s+run\s+--with\s+(\S+)", text):
tok = m.group(1).strip('"').strip("'")
if tok:
pkgs.append(_parse_pypi_spec(tok))
return pkgs
def _parse_pypi_spec(spec: str) -> dict:
"""Parse a PEP 508 spec like 'narwhals>=2.17.0' or 'marimo[recommended]'."""
# strip extras
extras = ""
if "[" in spec:
base, rest = spec.split("[", 1)
extras = rest.split("]")[0]
spec = base + rest.split("]")[-1] if "]" in rest else base
m = re.match(r"([a-zA-Z0-9_-]+)(.*)", spec)
if not m:
return {"name": spec, "version": "", "extras": ""}
return {
"name": m.group(1).lower().replace("_", "-"),
"version": m.group(2).strip(),
"extras": extras,
}
def _parse_base_images(text: str) -> list[str]:
"""Extract FROM base images from Dockerfiles."""
images: list[str] = []
for m in re.finditer(r"^FROM\s+(\S+)", text, re.MULTILINE):
img = m.group(1)
if img not in images:
images.append(img)
return images
# ---------------------------------------------------------------------------
# CI workflow parser
# ---------------------------------------------------------------------------
def _parse_ci_curl_tools(text: str) -> list[dict]:
"""Extract tools installed via curl in CI workflows."""
tools: list[dict] = []
patterns = [
(r"astral\.sh/uv/install\.sh", "uv", "latest"),
(r"go-containerregistry.*crane", "crane", "latest"),
(r"aquasecurity/trivy", "trivy", "latest"),
]
for pattern, name, version in patterns:
if re.search(pattern, text):
tools.append({"name": name, "version": version})
return tools
# ---------------------------------------------------------------------------
# pyproject.toml parser (simple, no toml dependency)
# ---------------------------------------------------------------------------
def _parse_pyproject_deps(text: str) -> tuple[list[dict], list[dict]]:
"""Parse dependencies from pyproject.toml without a TOML library."""
prod: list[dict] = []
dev: list[dict] = []
# prod deps
m = re.search(r"^dependencies\s*=\s*\[(.*?)\]", text, re.MULTILINE | re.DOTALL)
if m:
for line in m.group(1).splitlines():
line = line.strip().strip(",").strip('"').strip("'")
if line and not line.startswith("#"):
prod.append(_parse_pypi_spec(line))
# dev deps
m = re.search(r"dev\s*=\s*\[(.*?)\]", text, re.MULTILINE | re.DOTALL)
if m:
for line in m.group(1).splitlines():
line = line.strip().strip(",").strip('"').strip("'")
if line and not line.startswith("#"):
dev.append(_parse_pypi_spec(line))
# build-system requires
m = re.search(
r"\[build-system\].*?requires\s*=\s*\[(.*?)\]",
text,
re.MULTILINE | re.DOTALL,
)
if m:
for line in m.group(1).splitlines():
line = line.strip().strip(",").strip('"').strip("'")
if line and not line.startswith("#"):
prod.append(_parse_pypi_spec(line))
return prod, dev
# ---------------------------------------------------------------------------
# pnpm / Node parser
# ---------------------------------------------------------------------------
def _parse_package_json_deps(text: str) -> list[dict]:
"""Parse npm dependencies from package.json."""
data = json.loads(text)
pkgs: list[dict] = []
for section in ("dependencies", "devDependencies"):
for name, version in data.get(section, {}).items():
pkgs.append({"name": name, "version": version})
return pkgs
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def scan() -> dict:
"""Scan repo and return the package manifest."""
manifest: dict = {
"apt": {},
"apk": {},
"pypi": {
"project_prod": [],
"project_dev": [],
"notebook": [],
"dockerfile_adhoc": [],
},
"npm": [],
"ci_tools": [],
"base_images": {},
}
# --- Dockerfiles ---
dockerfiles = list(ROOT.glob("**/Dockerfile")) + list(ROOT.glob("**/Dockerfile.*"))
dockerfiles = [
df for df in dockerfiles if not any(skip in df.parts for skip in SKIP_DIRS)
]
for df in sorted(dockerfiles):
rel = str(df.relative_to(ROOT))
text = df.read_text()
apt = _parse_apt_packages(text)
if apt:
manifest["apt"][rel] = apt
apk = _parse_apk_packages(text)
if apk:
manifest["apk"][rel] = apk
images = _parse_base_images(text)
if images:
manifest["base_images"][rel] = images
# uv add in Dockerfiles (notebook pattern)
uv_pkgs = _parse_uv_add_packages(text)
if uv_pkgs:
manifest["pypi"]["notebook"].extend(uv_pkgs)
# pip install in Dockerfiles
pip_pkgs = _parse_pip_install_packages(text)
if pip_pkgs:
manifest["pypi"]["dockerfile_adhoc"].extend(pip_pkgs)
# uv run --with
with_pkgs = _parse_uv_run_with_packages(text)
if with_pkgs:
manifest["pypi"]["dockerfile_adhoc"].extend(with_pkgs)
# --- pyproject.toml ---
pyproject = ROOT / "pyproject.toml"
if pyproject.exists():
prod, dev = _parse_pyproject_deps(pyproject.read_text())
manifest["pypi"]["project_prod"] = prod
manifest["pypi"]["project_dev"] = dev
# --- package.json (docs) ---
pkg_json = ROOT / "docs" / "package.json"
if pkg_json.exists():
manifest["npm"] = _parse_package_json_deps(pkg_json.read_text())
# --- CI workflows ---
ci_tools_seen: set[str] = set()
for wf in sorted((ROOT / ".gitea" / "workflows").glob("*.yml")):
text = wf.read_text()
for tool in _parse_ci_curl_tools(text):
if tool["name"] not in ci_tools_seen:
ci_tools_seen.add(tool["name"])
manifest["ci_tools"].append(tool)
# --- Deduplicate & sort ---
for section in ("notebook", "dockerfile_adhoc"):
seen: set[str] = set()
deduped: list[dict] = []
for pkg in manifest["pypi"][section]:
if pkg["name"] not in seen:
seen.add(pkg["name"])
deduped.append(pkg)
manifest["pypi"][section] = sorted(deduped, key=lambda p: p["name"])
return manifest
def main() -> None:
manifest = scan()
output = json.dumps(manifest, indent=2, sort_keys=False) + "\n"
if "--check" in sys.argv:
if MANIFEST_PATH.exists():
existing = MANIFEST_PATH.read_text()
if existing == output:
print("pkg-manifest.json is up to date.")
raise SystemExit(0)
else:
print("pkg-manifest.json is STALE — re-run without --check.")
raise SystemExit(1)
else:
print("pkg-manifest.json does not exist — run without --check first.")
raise SystemExit(1)
MANIFEST_PATH.write_text(output)
# Summary
apt_count = sum(len(v) for v in manifest["apt"].values())
apk_count = sum(len(v) for v in manifest["apk"].values())
pypi_count = sum(
len(manifest["pypi"][k])
for k in ("project_prod", "project_dev", "notebook", "dockerfile_adhoc")
)
npm_count = len(manifest["npm"])
ci_count = len(manifest["ci_tools"])
img_count = sum(len(v) for v in manifest["base_images"].values())
print(f"Wrote {MANIFEST_PATH.relative_to(ROOT)}")
print(
f" apt: {apt_count} apk: {apk_count} pypi: {pypi_count}"
f" npm: {npm_count} ci_tools: {ci_count} base_images: {img_count}"
)
if __name__ == "__main__":
main()

307
dev/scripts/pkg_issues.py Normal file
View File

@@ -0,0 +1,307 @@
"""Auto-create Gitea issues for missing packages and vulnerabilities.
Reads drift report and vulnerability scan results, creates/closes
Gitea issues via the API.
Usage:
uv run python dev/scripts/pkg_issues.py # all checks
uv run python dev/scripts/pkg_issues.py --drift-only # missing/surplus only
uv run python dev/scripts/pkg_issues.py --vuln-only # CVEs only
uv run python dev/scripts/pkg_issues.py --dry-run # show what would happen
"""
from __future__ import annotations
import json
import os
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MANIFEST_PATH = ROOT / "data" / "pkg-manifest.json"
VULN_PATH = ROOT / "data" / "pkg-vulns.json"
GITEA_URL = os.environ.get("GITEA_URL", "http://localhost:3000")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
REPO = os.environ.get("GITEA_REPO", "homelab/stack")
MILESTONE_TITLE = "P21: Package Supply Chain"
# Labels to attach (by name — resolved to IDs at runtime)
LABEL_CI = "ci"
LABEL_QUALITY = "quality"
def _api(method: str, path: str, data: dict | None = None) -> dict | list | None:
"""Call Gitea API. Uses docker exec if GITEA_TOKEN not in env."""
import subprocess
token = GITEA_TOKEN
if not token:
# Try to load from .env
env_file = ROOT / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
if line.startswith("GITEA_TOKEN="):
token = line.split("=", 1)[1].strip().strip('"').strip("'")
break
if not token:
print("ERROR: GITEA_TOKEN not set and not found in .env")
raise SystemExit(2)
url = f"http://localhost:3000/api/v1/{path}"
cmd = [
"docker",
"exec",
"gitea",
"curl",
"-s",
"-H",
f"Authorization: token {token}",
"-H",
"Content-Type: application/json",
]
if method == "POST":
cmd += ["-X", "POST", "-d", json.dumps(data), url]
elif method == "PATCH":
cmd += ["-X", "PATCH", "-d", json.dumps(data), url]
else:
cmd += [url]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
print(f" API error: {result.stderr[:200]}")
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return None
def _get_milestone_id() -> int | None:
"""Find the P21 milestone ID."""
milestones = _api("GET", f"repos/{REPO}/milestones?limit=50")
if not milestones:
return None
for m in milestones:
if MILESTONE_TITLE in m.get("title", ""):
return m["id"]
return None
def _get_label_ids(names: list[str]) -> list[int]:
"""Resolve label names to IDs."""
labels = _api("GET", f"repos/{REPO}/labels?limit=50")
if not labels:
return []
name_to_id = {lb["name"]: lb["id"] for lb in labels}
return [name_to_id[n] for n in names if n in name_to_id]
def _find_existing_issues(prefix: str) -> dict[str, dict]:
"""Find open issues with titles starting with prefix."""
issues = _api(
"GET",
f"repos/{REPO}/issues?state=open&type=issues&limit=50&labels={LABEL_CI}",
)
if not issues:
return {}
return {i["title"]: i for i in issues if i["title"].startswith(prefix)}
def handle_drift(*, dry_run: bool = False) -> int:
"""Create issues for drift (missing/untracked packages)."""
# Run drift detection inline
sys.path.insert(0, str(ROOT / "dev" / "scripts"))
from pkg_drift import check_drift
drift = check_drift()
if not drift["has_drift"]:
print(" drift: no drift detected")
return 0
milestone_id = _get_milestone_id()
label_ids = _get_label_ids([LABEL_CI])
existing = _find_existing_issues("[pkg-missing]")
created = 0
for pkg_type in ("apt", "apk"):
section = drift.get(pkg_type, {})
for pkg in section.get("untracked", []):
title = f"[pkg-missing] {pkg_type}/{pkg}"
if title in existing:
continue
body = (
f"Package `{pkg}` found in source ({pkg_type}) "
f"but not in the package manifest.\n\n"
f"Run `uv run python dev/scripts/pkg_inventory.py` to update."
)
if dry_run:
print(f" would create: {title}")
else:
issue_data = {"title": title, "body": body, "labels": label_ids}
if milestone_id:
issue_data["milestone"] = milestone_id
result = _api("POST", f"repos/{REPO}/issues", issue_data)
if result:
print(f" created #{result['number']}: {title}")
created += 1
# PyPI sections
pypi_drift = drift.get("pypi", {})
for sub, d in pypi_drift.items():
for pkg in d.get("untracked", []):
title = f"[pkg-missing] pypi/{pkg} ({sub})"
if title in existing:
continue
body = (
f"PyPI package `{pkg}` found in source ({sub}) "
f"but not in the package manifest.\n\n"
f"Run `uv run python dev/scripts/pkg_inventory.py` to update."
)
if dry_run:
print(f" would create: {title}")
else:
issue_data = {"title": title, "body": body, "labels": label_ids}
if milestone_id:
issue_data["milestone"] = milestone_id
result = _api("POST", f"repos/{REPO}/issues", issue_data)
if result:
print(f" created #{result['number']}: {title}")
created += 1
# Close issues for packages that are no longer missing
for title, issue in existing.items():
# Extract package name from title
m = re.match(r"\[pkg-missing\] (\w+)/(.+?)(?:\s|$)", title)
if not m:
continue
pkg_type, pkg_name = m.group(1), m.group(2)
still_missing = False
if pkg_type in ("apt", "apk"):
still_missing = pkg_name in drift.get(pkg_type, {}).get("untracked", [])
elif pkg_type == "pypi":
for sub_d in pypi_drift.values():
if pkg_name in sub_d.get("untracked", []):
still_missing = True
break
if not still_missing:
if dry_run:
print(f" would close: {title}")
else:
_api(
"PATCH",
f"repos/{REPO}/issues/{issue['number']}",
{"state": "closed"},
)
print(f" closed #{issue['number']}: {title}")
return created
def handle_vulns(*, dry_run: bool = False) -> int:
"""Create issues for vulnerabilities found in package scans."""
if not VULN_PATH.exists():
print(" vulns: no scan results found (data/pkg-vulns.json)")
return 0
data = json.loads(VULN_PATH.read_text())
# trivy JSON format: {"Results": [{"Vulnerabilities": [...]}]}
vulns: list[dict] = []
for result in data.get("Results", []):
for v in result.get("Vulnerabilities", []):
vulns.append(v)
if not vulns:
print(" vulns: no vulnerabilities found")
return 0
milestone_id = _get_milestone_id()
label_ids = _get_label_ids([LABEL_CI, LABEL_QUALITY])
existing = _find_existing_issues("[pkg-vuln]")
created = 0
for v in vulns:
cve = v.get("VulnerabilityID", "UNKNOWN")
pkg = v.get("PkgName", "unknown")
version = v.get("InstalledVersion", "?")
severity = v.get("Severity", "UNKNOWN")
fixed = v.get("FixedVersion", "none")
desc = v.get("Description", "")[:500]
title = f"[pkg-vuln] {cve} in {pkg}@{version}"
if title in existing:
continue
body = (
f"**Severity:** {severity}\n"
f"**Package:** `{pkg}` @ `{version}`\n"
f"**Fixed in:** `{fixed}`\n\n"
f"{desc}\n\n"
f"**Reference:** https://nvd.nist.gov/vuln/detail/{cve}"
)
if dry_run:
print(f" would create: {title}")
else:
issue_data = {"title": title, "body": body, "labels": label_ids}
if milestone_id:
issue_data["milestone"] = milestone_id
result = _api("POST", f"repos/{REPO}/issues", issue_data)
if result:
print(f" created #{result['number']}: {title}")
created += 1
# Close issues for CVEs that are no longer present
active_cves = {
f"[pkg-vuln] {v.get('VulnerabilityID', '')} in "
f"{v.get('PkgName', '')}@{v.get('InstalledVersion', '')}"
for v in vulns
}
for title, issue in existing.items():
if title not in active_cves:
if dry_run:
print(f" would close: {title}")
else:
_api(
"PATCH",
f"repos/{REPO}/issues/{issue['number']}",
{"state": "closed"},
)
print(f" closed #{issue['number']}: {title}")
return created
def main() -> None:
import argparse
parser = argparse.ArgumentParser(
description="Auto-create Gitea issues for package drift and vulnerabilities"
)
parser.add_argument("--drift-only", action="store_true")
parser.add_argument("--vuln-only", action="store_true")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
do_drift = not args.vuln_only
do_vuln = not args.drift_only
total = 0
if do_drift:
print("Checking package drift...")
total += handle_drift(dry_run=args.dry_run)
if do_vuln:
print("Checking vulnerabilities...")
total += handle_vulns(dry_run=args.dry_run)
if total:
print(f"\nCreated {total} issue(s).")
else:
print("\nNo new issues to create.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,587 @@
"""Download packages from upstream and upload to Gitea package registry.
Reads data/pkg-manifest.json, downloads each package from its upstream
source (PyPI, Debian repos, Alpine repos), then pushes to Gitea's
built-in package registry so all builds pull exclusively from Gitea.
Usage:
uv run python dev/scripts/pkg_mirror_sync.py # sync all
uv run python dev/scripts/pkg_mirror_sync.py --type pypi # sync pypi only
uv run python dev/scripts/pkg_mirror_sync.py --dry-run # show what would change
"""
from __future__ import annotations
import json
import os
import subprocess
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MANIFEST_PATH = ROOT / "data" / "pkg-manifest.json"
CACHE_DIR = ROOT / "mirrors" / "cache"
# Gitea registry config — loaded from env or .env file
GITEA_URL = ""
GITEA_TOKEN = ""
GITEA_OWNER = "homelab"
def _load_env() -> None:
global GITEA_URL, GITEA_TOKEN, GITEA_OWNER
GITEA_URL = os.environ.get("GITEA_URL", "")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
if not GITEA_TOKEN:
env_file = ROOT / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
if line.startswith("GITEA_TOKEN="):
GITEA_TOKEN = line.split("=", 1)[1].strip().strip('"').strip("'")
if not GITEA_URL:
# Read from stack.toml [services]
toml_file = ROOT / "stack.toml"
if toml_file.exists():
for line in toml_file.read_text().splitlines():
if line.strip().startswith("gitea"):
parts = line.split("=", 1)
if len(parts) == 2:
GITEA_URL = parts[1].strip().strip('"').strip("'")
break
if not GITEA_URL:
GITEA_URL = "http://gitea:3000"
def _api_via_docker(method: str, path: str, file_path: str = "") -> tuple[int, str]:
"""Call Gitea API via docker exec (handles DNS resolution)."""
url = f"http://localhost:3000/api/v1/{path}"
cmd = [
"docker",
"exec",
"gitea",
"curl",
"-s",
"-w",
"\n%{http_code}",
"-H",
f"Authorization: token {GITEA_TOKEN}",
]
if method == "PUT" and file_path:
# For file uploads, we need to copy the file into the container first
tmp_name = f"/tmp/pkg_upload_{os.path.basename(file_path)}"
subprocess.run(
["docker", "cp", file_path, f"gitea:{tmp_name}"],
capture_output=True,
timeout=60,
)
cmd += ["-X", "PUT", "--upload-file", tmp_name, url]
elif method == "GET":
cmd += [url]
else:
cmd += ["-X", method, url]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
return 500, result.stderr[:200]
lines = result.stdout.strip().rsplit("\n", 1)
body = lines[0] if len(lines) > 1 else ""
status = int(lines[-1]) if lines[-1].isdigit() else 500
return status, body
def _gitea_pkg_exists(pkg_type: str, name: str) -> bool:
"""Check if a package already exists in Gitea registry."""
path = f"packages/{GITEA_OWNER}/{pkg_type}?q={name}&limit=1"
status, body = _api_via_docker("GET", path)
if status == 200:
try:
data = json.loads(body)
return len(data) > 0
except json.JSONDecodeError:
pass
return False
def _parse_dist_filename(filename: str) -> tuple[str, str]:
"""Extract package name and version from a wheel or sdist filename.
Examples:
pyasn1-0.6.3-py3-none-any.whl -> (pyasn1, 0.6.3)
sqlglot-26.0.0.tar.gz -> (sqlglot, 26.0.0)
"""
import re
# Wheel: {name}-{version}(-{build})?-{python}-{abi}-{platform}.whl
m = re.match(r"^(.+?)-(\d+[^-]*)-", filename)
if m:
return m.group(1).replace("_", "-").lower(), m.group(2)
# Sdist: {name}-{version}.tar.gz or {name}-{version}.zip
m = re.match(r"^(.+?)-(\d+\S+?)\.(?:tar\.gz|zip)$", filename)
if m:
return m.group(1).replace("_", "-").lower(), m.group(2)
return "", ""
def load_manifest() -> dict:
if not MANIFEST_PATH.exists():
print(f"ERROR: {MANIFEST_PATH} not found. Run pkg_inventory.py first.")
raise SystemExit(2)
return json.loads(MANIFEST_PATH.read_text())
def _flat_apt_packages(manifest: dict) -> list[str]:
pkgs: set[str] = set()
for pkg_list in manifest.get("apt", {}).values():
pkgs.update(pkg_list)
return sorted(pkgs)
def _flat_pypi_packages(manifest: dict) -> list[dict]:
seen: set[str] = set()
pkgs: list[dict] = []
for section in ("project_prod", "project_dev", "notebook", "dockerfile_adhoc"):
for pkg in manifest.get("pypi", {}).get(section, []):
if pkg["name"] not in seen:
seen.add(pkg["name"])
pkgs.append(pkg)
return sorted(pkgs, key=lambda p: p["name"])
def sync_pypi(manifest: dict, *, dry_run: bool = False) -> dict:
"""Download Python wheels from PyPI and upload to Gitea."""
pkgs = _flat_pypi_packages(manifest)
if not pkgs:
print(" pypi: no packages to mirror")
return {"uploaded": 0, "skipped": 0, "failed": []}
stats = {"uploaded": 0, "skipped": 0, "failed": []}
with tempfile.TemporaryDirectory(prefix="pkg_pypi_") as tmpdir:
# Build requirements spec
specs = []
for pkg in pkgs:
spec = pkg["name"]
if pkg.get("extras"):
spec += f"[{pkg['extras']}]"
if pkg.get("version"):
spec += pkg["version"]
specs.append(spec)
req_file = Path(tmpdir) / "requirements.txt"
req_file.write_text("\n".join(specs) + "\n")
if dry_run:
print(f" pypi: would download and upload {len(specs)} packages:")
for s in specs:
print(f" - {s}")
return stats
# Download wheels/sdists from upstream PyPI
print(f" pypi: downloading {len(specs)} packages from PyPI...")
dl_dir = Path(tmpdir) / "downloads"
dl_dir.mkdir()
result = subprocess.run(
[
"uvx",
"pip",
"download",
"--no-deps",
"--dest",
str(dl_dir),
"-r",
str(req_file),
],
capture_output=True,
text=True,
timeout=600,
)
if result.returncode != 0:
print(f" pypi: download failed: {result.stderr[:500]}")
stats["failed"].append("pip-download")
return stats
# Upload to Gitea PyPI registry (requires name, version, sha256_digest)
dist_files = list(dl_dir.iterdir())
print(f" pypi: uploading {len(dist_files)} files to Gitea...")
upload_url = f"http://localhost:3000/api/packages/{GITEA_OWNER}/pypi"
for dist in sorted(dist_files):
# Parse name and version from filename
pkg_name, pkg_version = _parse_dist_filename(dist.name)
if not pkg_name:
print(f" SKIP: {dist.name} (can't parse name/version)")
stats["skipped"] += 1
continue
# Compute SHA-256
import hashlib
sha256 = hashlib.sha256(dist.read_bytes()).hexdigest()
# Copy file into gitea container and upload
tmp_name = f"/tmp/pkg_{dist.name}"
subprocess.run(
["docker", "cp", str(dist), f"gitea:{tmp_name}"],
capture_output=True,
timeout=60,
)
result_up = subprocess.run(
[
"docker",
"exec",
"gitea",
"curl",
"-s",
"-w",
"\n%{http_code}",
"-H",
f"Authorization: token {GITEA_TOKEN}",
"-F",
f"content=@{tmp_name}",
"-F",
f"name={pkg_name}",
"-F",
f"version={pkg_version}",
"-F",
f"sha256_digest={sha256}",
upload_url,
],
capture_output=True,
text=True,
timeout=120,
)
lines = result_up.stdout.strip().rsplit("\n", 1)
status = int(lines[-1]) if lines[-1].isdigit() else 500
if status in (201, 409):
label = "uploaded" if status == 201 else "exists"
print(f" {label}: {dist.name}")
if status == 201:
stats["uploaded"] += 1
else:
stats["skipped"] += 1
else:
body = lines[0] if len(lines) > 1 else ""
print(f" FAILED ({status}): {dist.name} — {body[:120]}")
stats["failed"].append(dist.name)
# Cleanup
subprocess.run(
["docker", "exec", "gitea", "rm", "-f", tmp_name],
capture_output=True,
timeout=10,
)
return stats
def sync_apt(manifest: dict, *, dry_run: bool = False) -> dict:
"""Download .deb packages and upload to Gitea Debian registry."""
pkgs = _flat_apt_packages(manifest)
if not pkgs:
print(" apt: no packages to mirror")
return {"uploaded": 0, "skipped": 0, "failed": []}
stats = {"uploaded": 0, "skipped": 0, "failed": []}
if dry_run:
print(f" apt: would download and upload {len(pkgs)} packages:")
for p in pkgs:
print(f" - {p}")
return stats
# Use a subdirectory under mirrors/ for apt downloads (avoids tmpdir
# permission issues with Docker volume mounts)
dl_dir = ROOT / "mirrors" / "cache" / "apt"
dl_dir.mkdir(parents=True, exist_ok=True)
# Clean previous downloads
for old in dl_dir.glob("*.deb"):
old.unlink()
# Download .debs via Docker — try each package individually to handle
# third-party packages (e.g. zotero) that aren't in base Debian repos
print(f" apt: downloading {len(pkgs)} packages...")
# Build a script that tries each package, skipping failures
install_cmds = " && ".join(
f"(apt-get install --reinstall --download-only -y {p} 2>/dev/null || "
f"echo 'SKIP: {p} (not in base repos)')"
for p in pkgs
)
subprocess.run(
[
"docker",
"run",
"--rm",
"-v",
f"{dl_dir}:/debs",
"debian:bookworm-slim",
"bash",
"-c",
f"apt-get update -qq 2>/dev/null && {install_cmds}; "
f"cp /var/cache/apt/archives/*.deb /debs/ 2>/dev/null || true; "
f"chmod 644 /debs/*.deb 2>/dev/null || true",
],
capture_output=True,
text=True,
timeout=300,
)
deb_files = list(dl_dir.glob("*.deb"))
if not deb_files:
print(" apt: no .deb files downloaded")
stats["skipped"] = len(pkgs)
return stats
print(f" apt: uploading {len(deb_files)} .deb files to Gitea...")
for deb in sorted(deb_files):
tmp_name = f"/tmp/pkg_{deb.name}"
subprocess.run(
["docker", "cp", str(deb), f"gitea:{tmp_name}"],
capture_output=True,
timeout=60,
)
result_up = subprocess.run(
[
"docker",
"exec",
"gitea",
"curl",
"-s",
"-w",
"\n%{http_code}",
"-H",
f"Authorization: token {GITEA_TOKEN}",
"--upload-file",
tmp_name,
f"http://localhost:3000/api/packages/{GITEA_OWNER}"
f"/debian/pool/bookworm/main/upload",
],
capture_output=True,
text=True,
timeout=120,
)
lines = result_up.stdout.strip().rsplit("\n", 1)
status = int(lines[-1]) if lines[-1].isdigit() else 500
if status in (201, 409):
label = "uploaded" if status == 201 else "exists"
print(f" {label}: {deb.name}")
if status == 201:
stats["uploaded"] += 1
else:
stats["skipped"] += 1
else:
body = lines[0] if len(lines) > 1 else ""
print(f" FAILED ({status}): {deb.name} — {body[:120]}")
stats["failed"].append(deb.name)
subprocess.run(
["docker", "exec", "gitea", "rm", "-f", tmp_name],
capture_output=True,
timeout=10,
)
return stats
def sync_apk(manifest: dict, *, dry_run: bool = False) -> dict:
"""Download .apk packages from Alpine base images and upload to Gitea.
Even when no explicit `apk add` packages exist, `apk upgrade` in
Dockerfiles pulls updates for every installed package. We mirror
all installed packages from Alpine-based images so builds can run
fully offline.
"""
stats = {"uploaded": 0, "skipped": 0, "failed": []}
# Find Alpine-based images from the manifest
alpine_images: list[str] = []
for rel, images in manifest.get("base_images", {}).items():
for img in images:
if "alpine" in img.lower():
alpine_images.append(img)
if not alpine_images:
print(" apk: no Alpine-based images in manifest")
return stats
# Get the list of installed packages from each Alpine image
all_pkgs: set[str] = set()
for img in alpine_images:
result = subprocess.run(
[
"docker",
"run",
"--rm",
img,
"sh",
"-c",
"apk info 2>/dev/null",
],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode == 0:
for line in result.stdout.strip().splitlines():
line = line.strip()
if line:
all_pkgs.add(line)
if not all_pkgs:
print(" apk: could not enumerate packages from Alpine images")
return stats
if dry_run:
print(
f" apk: would download and upload {len(all_pkgs)} packages from Alpine images:"
)
for p in sorted(all_pkgs):
print(f" - {p}")
return stats
# Get Alpine version from first image
ver_result = subprocess.run(
["docker", "run", "--rm", alpine_images[0], "cat", "/etc/alpine-release"],
capture_output=True,
text=True,
timeout=30,
)
alpine_version = (
"v" + ".".join(ver_result.stdout.strip().split(".")[:2])
if ver_result.returncode == 0
else "v3.23"
)
# Download .apk files
dl_dir = ROOT / "mirrors" / "cache" / "apk"
dl_dir.mkdir(parents=True, exist_ok=True)
for old in dl_dir.glob("*.apk"):
old.unlink()
# Fetch each package individually — some (e.g. nginx modules) come
# from repos not in the default Alpine config, so batch fetch fails.
fetch_cmds = "; ".join(
f"apk fetch --no-cache -o /out {p} 2>/dev/null || true"
for p in sorted(all_pkgs)
)
print(
f" apk: downloading {len(all_pkgs)} packages from Alpine {alpine_version}..."
)
subprocess.run(
[
"docker",
"run",
"--rm",
"-v",
f"{dl_dir}:/out",
alpine_images[0],
"sh",
"-c",
f"{fetch_cmds}; chmod 644 /out/*.apk 2>/dev/null || true",
],
capture_output=True,
text=True,
timeout=300,
)
apk_files = list(dl_dir.glob("*.apk"))
if not apk_files:
print(" apk: no .apk files downloaded")
stats["skipped"] = len(all_pkgs)
return stats
print(f" apk: uploading {len(apk_files)} .apk files to Gitea...")
for apk in sorted(apk_files):
tmp_name = f"/tmp/pkg_{apk.name}"
subprocess.run(
["docker", "cp", str(apk), f"gitea:{tmp_name}"],
capture_output=True,
timeout=60,
)
result_up = subprocess.run(
[
"docker",
"exec",
"gitea",
"curl",
"-s",
"-w",
"\n%{http_code}",
"-H",
f"Authorization: token {GITEA_TOKEN}",
"--upload-file",
tmp_name,
f"http://localhost:3000/api/packages/{GITEA_OWNER}"
f"/alpine/{alpine_version}/main",
],
capture_output=True,
text=True,
timeout=120,
)
lines = result_up.stdout.strip().rsplit("\n", 1)
status = int(lines[-1]) if lines[-1].isdigit() else 500
if status in (201, 409):
label = "uploaded" if status == 201 else "exists"
print(f" {label}: {apk.name}")
if status == 201:
stats["uploaded"] += 1
else:
stats["skipped"] += 1
else:
body = lines[0] if len(lines) > 1 else ""
print(f" FAILED ({status}): {apk.name} — {body[:120]}")
stats["failed"].append(apk.name)
subprocess.run(
["docker", "exec", "gitea", "rm", "-f", tmp_name],
capture_output=True,
timeout=10,
)
return stats
def main() -> None:
import argparse
parser = argparse.ArgumentParser(
description="Download packages from upstream and upload to Gitea registry"
)
parser.add_argument(
"--type",
choices=["apt", "apk", "pypi", "all"],
default="all",
help="Which package type to sync",
)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
_load_env()
if not GITEA_TOKEN:
print("ERROR: GITEA_TOKEN not set. Set it in .env or environment.")
raise SystemExit(2)
manifest = load_manifest()
print("Syncing packages to Gitea registry...")
results: dict[str, dict] = {}
if args.type in ("pypi", "all"):
results["pypi"] = sync_pypi(manifest, dry_run=args.dry_run)
if args.type in ("apt", "all"):
results["apt"] = sync_apt(manifest, dry_run=args.dry_run)
if args.type in ("apk", "all"):
results["apk"] = sync_apk(manifest, dry_run=args.dry_run)
if not args.dry_run:
print("\n=== Summary ===")
total_up = sum(r.get("uploaded", 0) for r in results.values())
total_skip = sum(r.get("skipped", 0) for r in results.values())
total_fail = sum(len(r.get("failed", [])) for r in results.values())
print(f" Uploaded: {total_up} Skipped: {total_skip} Failed: {total_fail}")
if total_fail:
raise SystemExit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# test_network_isolation.sh — Verify Docker builds succeed without internet.
#
# Builds each Dockerfile with --network=none to prove all packages
# come from local mirrors. Requires mirrors to be running first.
#
# Usage:
# bash dev/scripts/test_network_isolation.sh # test all
# bash dev/scripts/test_network_isolation.sh api docs # test specific images
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
MANIFEST="${ROOT}/data/pkg-manifest.json"
if [ ! -f "$MANIFEST" ]; then
echo "ERROR: ${MANIFEST} not found. Run: uv run python dev/scripts/pkg_inventory.py"
exit 2
fi
# Image definitions — matches stack.toml [images]
declare -A DOCKERFILES=(
[api]="api/Dockerfile"
[notebooks]="notebooks/Dockerfile"
[docs]="docs/Dockerfile"
[zotero]="zotero/Dockerfile"
[mc]="rustfs/Dockerfile.mc"
)
declare -A CONTEXTS=(
[api]="."
[notebooks]="notebooks/"
[docs]="."
[zotero]="zotero/"
[mc]="rustfs/"
)
# Select images to test
if [ $# -gt 0 ]; then
IMAGES=("$@")
else
IMAGES=("${!DOCKERFILES[@]}")
fi
PASS=0
FAIL=0
SKIP=0
echo "=== Network Isolation Test ==="
echo "Testing ${#IMAGES[@]} image(s) with --network=none"
echo ""
for img in "${IMAGES[@]}"; do
dockerfile="${DOCKERFILES[$img]:-}"
context="${CONTEXTS[$img]:-}"
if [ -z "$dockerfile" ]; then
echo "SKIP $img (unknown image)"
((SKIP++))
continue
fi
echo -n "TEST $img ... "
# Build with no network — will fail if any RUN step needs internet
if docker build \
--network=none \
-f "${ROOT}/${dockerfile}" \
-t "isolation-test/${img}:test" \
"${ROOT}/${context}" \
> "/tmp/isolation-${img}.log" 2>&1; then
echo "PASS"
((PASS++))
# Clean up test image
docker rmi "isolation-test/${img}:test" > /dev/null 2>&1 || true
else
echo "FAIL"
echo " Build log: /tmp/isolation-${img}.log"
echo " Last 5 lines:"
tail -5 "/tmp/isolation-${img}.log" | sed 's/^/ /'
((FAIL++))
fi
done
echo ""
echo "=== Results ==="
echo " Pass: ${PASS} Fail: ${FAIL} Skip: ${SKIP}"
if [ "$FAIL" -gt 0 ]; then
echo ""
echo "FAILED — ${FAIL} image(s) require external network access."
echo "Ensure mirrors are running: docker compose up -d apt-cache devpi"
exit 1
fi
echo "All images build without external network access."
exit 0

View File

@@ -3,6 +3,9 @@ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
WORKDIR /app WORKDIR /app
# Local package registry (Gitea) — set via --build-arg to pull from mirror
ARG PYPI_INDEX_URL=""
# Patch base image CVEs # Patch base image CVEs
RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*
@@ -13,7 +16,8 @@ COPY src/ src/
# Install the package (no dev deps) # Install the package (no dev deps)
ENV UV_PYTHON_PREFERENCE=only-system \ ENV UV_PYTHON_PREFERENCE=only-system \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=.venv UV_PROJECT_ENVIRONMENT=.venv \
UV_INDEX_URL=${PYPI_INDEX_URL}
RUN uv sync --no-dev && uv pip install -e . RUN uv sync --no-dev && uv pip install -e .
# Config # Config

View File

@@ -6,6 +6,9 @@ ARG USER_UID=1000
ARG USER_GID=1000 ARG USER_GID=1000
ARG PYTHON_VERSION=3.13 ARG PYTHON_VERSION=3.13
# Local package registry (Gitea) — set via --build-arg to pull from mirror
ARG PYPI_INDEX_URL=""
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
HOME=/home/kert \ HOME=/home/kert \
PATH="/home/kert/.local/bin:${PATH}" \ PATH="/home/kert/.local/bin:${PATH}" \