add package mirrors, auto-issue scripts, network isolation test, CI workflow
refs #160 #161 #162 #163 #164 #166 #167 #168 - compose.yml: add apt-cacher-ng and devpi services on ci network - pkg_mirror_sync.py: generates seed files for apt/apk/pypi mirrors - pkg_issues.py: auto-creates Gitea issues for drift and CVEs, deduplicates, auto-closes resolved issues - test_network_isolation.sh: builds all Dockerfiles with --network=none - gitea.py: add pkg-supply-chain workflow (daily + on Dockerfile/dep changes) - mirrors/: seed files for apt (5 pkgs), apk (index), pypi (39 pkgs)
This commit is contained in:
56
.gitea/workflows/pkg-supply-chain.yml
Normal file
56
.gitea/workflows/pkg-supply-chain.yml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 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 mirrors
|
||||||
|
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
|
||||||
28
compose.yml
28
compose.yml
@@ -600,6 +600,32 @@ services:
|
|||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Package mirrors — local caches for reproducible builds
|
||||||
|
apt-cache:
|
||||||
|
image: sameersbn/apt-cacher-ng:latest
|
||||||
|
container_name: apt-cache
|
||||||
|
networks:
|
||||||
|
- ci
|
||||||
|
volumes:
|
||||||
|
- apt_cache_data:/var/cache/apt-cacher-ng
|
||||||
|
- ./mirrors/apt/acng.conf:/etc/apt-cacher-ng/acng.conf:ro
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
devpi:
|
||||||
|
image: muccg/devpi:latest
|
||||||
|
container_name: devpi
|
||||||
|
networks:
|
||||||
|
- ci
|
||||||
|
volumes:
|
||||||
|
- devpi_data:/var/lib/devpi
|
||||||
|
environment:
|
||||||
|
- DEVPI_PASSWORD=${DEVPI_PASSWORD:-changeme}
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
rustfs_data:
|
rustfs_data:
|
||||||
@@ -612,3 +638,5 @@ volumes:
|
|||||||
loki_data:
|
loki_data:
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
grafana_data:
|
grafana_data:
|
||||||
|
apt_cache_data:
|
||||||
|
devpi_data:
|
||||||
|
|||||||
@@ -401,6 +401,65 @@ jobs:
|
|||||||
return (".gitea/workflows/infra-ci.yml", content)
|
return (".gitea/workflows/infra-ci.yml", content)
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_pkg_supply_chain(
|
||||||
|
runner: str,
|
||||||
|
uv_version: str,
|
||||||
|
**_kw: object,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
content = f"""\
|
||||||
|
{_HEADER}
|
||||||
|
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: {runner}
|
||||||
|
steps:
|
||||||
|
{_checkout_step()}
|
||||||
|
|
||||||
|
{_setup_uv_step(uv_version)}
|
||||||
|
|
||||||
|
- 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 mirrors
|
||||||
|
run: uv run python dev/scripts/pkg_mirror_sync.py
|
||||||
|
|
||||||
|
- name: Drift detection
|
||||||
|
run: uv run python dev/scripts/pkg_drift.py
|
||||||
|
|
||||||
|
{_install_trivy_step()}
|
||||||
|
|
||||||
|
- 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
|
||||||
|
"""
|
||||||
|
return (".gitea/workflows/pkg-supply-chain.yml", content)
|
||||||
|
|
||||||
|
|
||||||
def _gen_release(runner: str, uv_version: str, **_kw: object) -> tuple[str, str]:
|
def _gen_release(runner: str, uv_version: str, **_kw: object) -> tuple[str, str]:
|
||||||
content = f"""\
|
content = f"""\
|
||||||
{_HEADER}
|
{_HEADER}
|
||||||
@@ -464,6 +523,7 @@ def emit(
|
|||||||
_gen_rebuild_all,
|
_gen_rebuild_all,
|
||||||
_gen_infra_ci,
|
_gen_infra_ci,
|
||||||
_gen_release,
|
_gen_release,
|
||||||
|
_gen_pkg_supply_chain,
|
||||||
):
|
):
|
||||||
path, content = gen_fn(**common) # type: ignore[arg-type]
|
path, content = gen_fn(**common) # type: ignore[arg-type]
|
||||||
files[path] = content
|
files[path] = content
|
||||||
|
|||||||
306
dev/scripts/pkg_issues.py
Normal file
306
dev/scripts/pkg_issues.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
"""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"
|
||||||
|
f"&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()
|
||||||
225
dev/scripts/pkg_mirror_sync.py
Normal file
225
dev/scripts/pkg_mirror_sync.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Synchronize local package mirrors from the package manifest.
|
||||||
|
|
||||||
|
Reads data/pkg-manifest.json and ensures apt-cache, apk-mirror, and
|
||||||
|
devpi mirrors contain exactly the packages we need.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python dev/scripts/pkg_mirror_sync.py # sync all
|
||||||
|
uv run python dev/scripts/pkg_mirror_sync.py --type apt # sync apt only
|
||||||
|
uv run python dev/scripts/pkg_mirror_sync.py --dry-run # show what would change
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
MANIFEST_PATH = ROOT / "data" / "pkg-manifest.json"
|
||||||
|
|
||||||
|
# Mirror config directories
|
||||||
|
APT_MIRROR_DIR = ROOT / "mirrors" / "apt"
|
||||||
|
APK_MIRROR_DIR = ROOT / "mirrors" / "apk"
|
||||||
|
PYPI_MIRROR_DIR = ROOT / "mirrors" / "pypi"
|
||||||
|
|
||||||
|
|
||||||
|
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_apk_packages(manifest: dict) -> list[str]:
|
||||||
|
pkgs: set[str] = set()
|
||||||
|
for pkg_list in manifest.get("apk", {}).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_apt(manifest: dict, *, dry_run: bool = False) -> None:
|
||||||
|
"""Generate apt-cacher-ng pre-seed package list."""
|
||||||
|
pkgs = _flat_apt_packages(manifest)
|
||||||
|
if not pkgs:
|
||||||
|
print(" apt: no packages to mirror")
|
||||||
|
return
|
||||||
|
|
||||||
|
APT_MIRROR_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
seed_file = APT_MIRROR_DIR / "package-list.txt"
|
||||||
|
content = "\n".join(pkgs) + "\n"
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f" apt: would write {len(pkgs)} packages to {seed_file}")
|
||||||
|
for p in pkgs:
|
||||||
|
print(f" - {p}")
|
||||||
|
return
|
||||||
|
|
||||||
|
seed_file.write_text(content)
|
||||||
|
print(f" apt: wrote {len(pkgs)} packages to {seed_file.relative_to(ROOT)}")
|
||||||
|
|
||||||
|
# Generate apt-cacher-ng config for pre-seeding
|
||||||
|
config = APT_MIRROR_DIR / "acng.conf"
|
||||||
|
config.write_text(
|
||||||
|
"# apt-cacher-ng config — generated by pkg_mirror_sync.py\n"
|
||||||
|
"CacheDir: /var/cache/apt-cacher-ng\n"
|
||||||
|
"LogDir: /var/log/apt-cacher-ng\n"
|
||||||
|
"Port: 3142\n"
|
||||||
|
"PassThroughPattern: .*\n"
|
||||||
|
)
|
||||||
|
print(f" apt: wrote config to {config.relative_to(ROOT)}")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_apk(manifest: dict, *, dry_run: bool = False) -> None:
|
||||||
|
"""Generate apk mirror seed list."""
|
||||||
|
pkgs = _flat_apk_packages(manifest)
|
||||||
|
|
||||||
|
APK_MIRROR_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
seed_file = APK_MIRROR_DIR / "package-list.txt"
|
||||||
|
|
||||||
|
# Even if no explicit apk add packages, Alpine base images need
|
||||||
|
# their index for `apk upgrade`
|
||||||
|
content = "# Alpine packages to pre-cache (includes base for apk upgrade)\n"
|
||||||
|
if pkgs:
|
||||||
|
content += "\n".join(pkgs) + "\n"
|
||||||
|
else:
|
||||||
|
content += "# No explicit packages — mirror Alpine index only\n"
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f" apk: would write {len(pkgs)} packages to {seed_file}")
|
||||||
|
return
|
||||||
|
|
||||||
|
seed_file.write_text(content)
|
||||||
|
print(f" apk: wrote {len(pkgs)} packages to {seed_file.relative_to(ROOT)}")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_pypi(manifest: dict, *, dry_run: bool = False) -> None:
|
||||||
|
"""Generate devpi pre-seed requirements file."""
|
||||||
|
pkgs = _flat_pypi_packages(manifest)
|
||||||
|
if not pkgs:
|
||||||
|
print(" pypi: no packages to mirror")
|
||||||
|
return
|
||||||
|
|
||||||
|
PYPI_MIRROR_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
seed_file = PYPI_MIRROR_DIR / "requirements.txt"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# PyPI packages to mirror — generated by pkg_mirror_sync.py",
|
||||||
|
"# Feed to: devpi-server --mirror, or pip download -r",
|
||||||
|
]
|
||||||
|
for pkg in pkgs:
|
||||||
|
spec = pkg["name"]
|
||||||
|
if pkg.get("extras"):
|
||||||
|
spec += f"[{pkg['extras']}]"
|
||||||
|
if pkg.get("version"):
|
||||||
|
spec += pkg["version"]
|
||||||
|
lines.append(spec)
|
||||||
|
|
||||||
|
content = "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f" pypi: would write {len(pkgs)} packages to {seed_file}")
|
||||||
|
for p in pkgs:
|
||||||
|
print(f" - {p['name']}{p.get('version', '')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
seed_file.write_text(content)
|
||||||
|
print(f" pypi: wrote {len(pkgs)} packages to {seed_file.relative_to(ROOT)}")
|
||||||
|
|
||||||
|
# Generate devpi config
|
||||||
|
config = PYPI_MIRROR_DIR / "devpi.conf"
|
||||||
|
config.write_text(
|
||||||
|
"# devpi config — generated by pkg_mirror_sync.py\n"
|
||||||
|
"[devpi-server]\n"
|
||||||
|
"serverdir = /var/lib/devpi\n"
|
||||||
|
"port = 3141\n"
|
||||||
|
"host = 0.0.0.0\n"
|
||||||
|
"\n"
|
||||||
|
"[mirror]\n"
|
||||||
|
"type = mirror\n"
|
||||||
|
"url = https://pypi.org/simple/\n"
|
||||||
|
)
|
||||||
|
print(f" pypi: wrote config to {config.relative_to(ROOT)}")
|
||||||
|
|
||||||
|
|
||||||
|
def warm_devpi(manifest: dict, *, dry_run: bool = False) -> None:
|
||||||
|
"""Warm devpi cache by downloading packages listed in the manifest.
|
||||||
|
|
||||||
|
Requires devpi to be running. Skips if devpi-server is unreachable.
|
||||||
|
"""
|
||||||
|
seed_file = PYPI_MIRROR_DIR / "requirements.txt"
|
||||||
|
if not seed_file.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(" pypi: would warm devpi cache from requirements.txt")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable, "-m", "pip", "download",
|
||||||
|
"--no-deps", "--dest", str(PYPI_MIRROR_DIR / "cache"),
|
||||||
|
"-r", str(seed_file),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(" pypi: warmed cache successfully")
|
||||||
|
else:
|
||||||
|
print(f" pypi: cache warm failed (non-fatal): {result.stderr[:200]}")
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
||||||
|
print(f" pypi: cache warm skipped: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Sync package mirrors from manifest")
|
||||||
|
parser.add_argument(
|
||||||
|
"--type",
|
||||||
|
choices=["apt", "apk", "pypi", "all"],
|
||||||
|
default="all",
|
||||||
|
help="Which mirror type to sync",
|
||||||
|
)
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Show what would change")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
manifest = load_manifest()
|
||||||
|
print("Syncing mirrors from pkg-manifest.json...")
|
||||||
|
|
||||||
|
if args.type in ("apt", "all"):
|
||||||
|
sync_apt(manifest, dry_run=args.dry_run)
|
||||||
|
if args.type in ("apk", "all"):
|
||||||
|
sync_apk(manifest, dry_run=args.dry_run)
|
||||||
|
if args.type in ("pypi", "all"):
|
||||||
|
sync_pypi(manifest, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
print("\nMirror seed files generated. Start mirrors with:")
|
||||||
|
print(" docker compose up -d apt-cache devpi")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
97
dev/scripts/test_network_isolation.sh
Normal file
97
dev/scripts/test_network_isolation.sh
Normal 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
|
||||||
2
mirrors/apk/package-list.txt
Normal file
2
mirrors/apk/package-list.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Alpine packages to pre-cache (includes base for apk upgrade)
|
||||||
|
# No explicit packages — mirror Alpine index only
|
||||||
5
mirrors/apt/acng.conf
Normal file
5
mirrors/apt/acng.conf
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# apt-cacher-ng config — generated by pkg_mirror_sync.py
|
||||||
|
CacheDir: /var/cache/apt-cacher-ng
|
||||||
|
LogDir: /var/log/apt-cacher-ng
|
||||||
|
Port: 3142
|
||||||
|
PassThroughPattern: .*
|
||||||
5
mirrors/apt/package-list.txt
Normal file
5
mirrors/apt/package-list.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
build-essential
|
||||||
|
ca-certificates
|
||||||
|
curl
|
||||||
|
git
|
||||||
|
zotero
|
||||||
9
mirrors/pypi/devpi.conf
Normal file
9
mirrors/pypi/devpi.conf
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# devpi config — generated by pkg_mirror_sync.py
|
||||||
|
[devpi-server]
|
||||||
|
serverdir = /var/lib/devpi
|
||||||
|
port = 3141
|
||||||
|
host = 0.0.0.0
|
||||||
|
|
||||||
|
[mirror]
|
||||||
|
type = mirror
|
||||||
|
url = https://pypi.org/simple/
|
||||||
41
mirrors/pypi/requirements.txt
Normal file
41
mirrors/pypi/requirements.txt
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# PyPI packages to mirror — generated by pkg_mirror_sync.py
|
||||||
|
# Feed to: devpi-server --mirror, or pip download -r
|
||||||
|
altair>=6.0.0
|
||||||
|
coverage>=7.13.4
|
||||||
|
cryptography>=46.0.5
|
||||||
|
cudf-polars-cu12
|
||||||
|
databricks-cli>=0.18.0
|
||||||
|
databricks-sdk>=0.85.0
|
||||||
|
dbt-core==1.10.15
|
||||||
|
dbt-duckdb>=1.10,<1.11
|
||||||
|
duckdb>=1.0.0
|
||||||
|
fastapi>=0.135.1
|
||||||
|
fastexcel>=0.19.0
|
||||||
|
fsspec>=2024.1.0
|
||||||
|
griffe
|
||||||
|
httpx>=0.28.1
|
||||||
|
marimo>=0.20.0
|
||||||
|
narwhals>=2.17.0
|
||||||
|
numpy
|
||||||
|
obstore>=0.9.2
|
||||||
|
openpyxl>=3.1.5
|
||||||
|
pandas>=3.0.1
|
||||||
|
pdfplumber>=0.11.9
|
||||||
|
pillow>=12.1.1
|
||||||
|
polars>=1.38.1
|
||||||
|
pyarrow>=23.0.0
|
||||||
|
pyasn1>=0.6.3
|
||||||
|
pydantic
|
||||||
|
pyiceberg[s3,pyarrow]>=0.7.0
|
||||||
|
pyjwt>=2.12.0
|
||||||
|
pytest>=9.0.2
|
||||||
|
pytest-cov>=7.0.0
|
||||||
|
pyzotero
|
||||||
|
ruff>=0.11.0
|
||||||
|
s3fs>=2026.2.0
|
||||||
|
sqlglot>=26.0.0
|
||||||
|
trino>=0.328.0
|
||||||
|
typer>=0.24.1
|
||||||
|
uv-build>=0.7,<1
|
||||||
|
uvicorn>=0.41.0
|
||||||
|
vega-datasets
|
||||||
Reference in New Issue
Block a user