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
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:
58
.gitea/workflows/pkg-supply-chain.yml
Normal file
58
.gitea/workflows/pkg-supply-chain.yml
Normal 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
|
||||||
61
dev/pipelines/pkg-supply-chain.yml
Normal file
61
dev/pipelines/pkg-supply-chain.yml
Normal 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
142
dev/scripts/pkg_drift.py
Normal 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()
|
||||||
339
dev/scripts/pkg_inventory.py
Normal file
339
dev/scripts/pkg_inventory.py
Normal 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
307
dev/scripts/pkg_issues.py
Normal 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()
|
||||||
587
dev/scripts/pkg_mirror_sync.py
Normal file
587
dev/scripts/pkg_mirror_sync.py
Normal 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()
|
||||||
97
dev/scripts/test_network_isolation.sh
Executable file
97
dev/scripts/test_network_isolation.sh
Executable 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}" \
|
||||||
|
|||||||
Reference in New Issue
Block a user