From 53daab8377387278aa726fe63b6200c5d30d581a Mon Sep 17 00:00:00 2001 From: Johnson Date: Sat, 27 Jun 2026 09:36:11 +0800 Subject: [PATCH] =?UTF-8?q?"=E6=B8=85=E7=90=86=E6=97=A0=E7=94=A8=E7=9A=84.?= =?UTF-8?q?codex=E5=8A=A0=E5=88=B0=E4=BB=A3=E7=A0=81=E4=B8=AD"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/skills/container-build-guard/SKILL.md | 157 -- .../container-build-guard/agents/openai.yaml | 4 - .../scripts/docker_runtime_config_check.py | 643 -------- .../scripts/preflight.py | 1405 ----------------- .gitignore | 1 + 5 files changed, 1 insertion(+), 2209 deletions(-) delete mode 100644 .codex/skills/container-build-guard/SKILL.md delete mode 100644 .codex/skills/container-build-guard/agents/openai.yaml delete mode 100644 .codex/skills/container-build-guard/scripts/docker_runtime_config_check.py delete mode 100644 .codex/skills/container-build-guard/scripts/preflight.py diff --git a/.codex/skills/container-build-guard/SKILL.md b/.codex/skills/container-build-guard/SKILL.md deleted file mode 100644 index 16297e5ed..000000000 --- a/.codex/skills/container-build-guard/SKILL.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -name: container-build-guard -description: Generic guardrail workflow for container image builds. Use when Codex is about to run, debug, review, or advise on docker build, docker compose build/up --build, podman/buildah builds, Dockerfile or Containerfile changes, CI image builds, or any repository where image construction may need network access, mirrors, proxies, long-running commands, controlled logs, or reliable success/failure handoff. ---- - -# Container Build Guard - -Use this skill to keep container image builds from wasting time or flooding context. It has three modules: - -1. **Build Preflight**: discover likely external sources and check network readiness before building. -2. **Build Execution**: run long builds in a foreground, log-to-file pattern so Codex regains control as soon as the build exits while only reading a useful log tail. -3. **Post-start Verification**: after `up`/run commands, inspect container state, ports, and logs before declaring success. - -## Workflow - -1. Identify the intended build command if the user gave one. Otherwise scan the repository for `Dockerfile*`, `Containerfile*`, `docker-compose*.yml`, `compose*.yaml`, CI files, `Makefile`, and package manifests. -2. Before running preflight, run the Docker runtime config check. It inspects and probes, without modifying files: - - Docker registry mirrors for Docker Hub pulls. - - Docker daemon proxy settings for registry access. - - Docker CLI default proxy settings for new containers and build steps. - -```bash -python /path/to/container-build-guard/scripts/docker_runtime_config_check.py -``` - -On Windows PowerShell: - -```powershell -python D:\code\skills\skills\container-build-guard\scripts\docker_runtime_config_check.py -``` - -If this check reports missing or partial config, explain the findings and ask the user for explicit confirmation before changing or overwriting Docker Desktop, daemon, CLI, or BuildKit configuration. Do not silently edit Docker runtime config. If the user declines or wants to continue anyway, proceed with preflight but call out the expected network risk. -Do not treat proxy fields as sufficient by themselves. A proxy can be configured but unreachable, blocked, or ignored by the relevant Docker path. The runtime check must verify the Docker daemon pull path and, when CLI default proxies are present, a new-container network path before reporting them as OK. -For Docker Hub mirrors in China-facing environments, compare the configured `registry-mirrors` against the current fallback set from the Tencent Cloud article at `https://cloud.tencent.com/developer/article/2647943`: `https://docker.xuanyuan.me`, `https://docker.1ms.run`, and `https://docker.m.daocloud.io`. If mirrors are missing, retired, or merely different, ask the user before changing the Docker runtime config instead of silently accepting them. -3. Run the bundled preflight script from the repository root. Prefer `--docker-probe` when Docker is available; it checks image manifests from the Docker daemon's network path without pulling layers. If Docker Hub host probes fail but the daemon may have registry mirrors, add `--docker-pull-probe` with a small limit to verify the actual daemon pull path. If package mirrors are configured, add `--artifact-probe` to test a small generic sample of real package artifact URLs discovered from manifests/lockfiles. - For first-time or cold builds, also add `--image-prep` to generate a pre-pull/tag plan for discovered container images before running the real build. This is especially important when `docker compose up` would otherwise lazily pull runtime images such as databases after a long build has already completed. - -```bash -python /path/to/container-build-guard/scripts/preflight.py . --docker-probe --image-prep -``` - -On Windows PowerShell: - -```powershell -python D:\code\skills\skills\container-build-guard\scripts\preflight.py . --docker-probe --docker-pull-probe --artifact-probe --image-prep -``` - -4. Read the report before building. Treat `BLOCKER` entries as likely build failures. Treat `WARN` entries as places where static analysis cannot prove success, especially dynamic `curl | sh`, private registries, package managers hidden in shell scripts, or a host/daemon network mismatch. -5. If the user wants JSON for CI or automation, rerun with `--json`. -6. Only proceed to `docker build`, `docker compose build`, `docker compose up --build`, `podman build`, or CI build commands after explaining any blockers and the recommended network, proxy, or mirror configuration. -7. For long builds, use the foreground logging pattern from the Build Execution section rather than fixed sleep polling. - -## Build Preflight - -Always consider both the host shell and the container builder environment. Host connectivity can differ from Docker Desktop, a remote Docker daemon, BuildKit, WSL, a corporate proxy, or a CI runner. - -Check these categories: - -- Container registries: Docker Hub, GHCR, Quay, GitLab registry, ECR/GCR/ACR, and private registries from `FROM` or compose `image:`. -- OS packages: `apt`, `apk`, `yum`, `dnf`, `microdnf`, `zypper`, plus repository URLs in Dockerfiles. -- Language packages: npm/yarn/pnpm, pip/uv/poetry, Go modules, Cargo, Maven/Gradle, RubyGems, NuGet. -- Browser and binary installers: Playwright, Puppeteer, Selenium drivers, Chromium, GitHub releases, `curl`, `wget`, and `git clone`. -- Proxy and trust basics: `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, Docker daemon reachability, DNS resolution, TCP 443/80, and TLS/HTTPS behavior. - -The script performs static discovery and lightweight probes only. It does not run a real image build. With `--docker-probe`, it runs `docker manifest inspect` for discovered images; this is still lightweight, but some Docker clients do not apply registry mirrors to manifest inspection the same way as image pulls. With `--docker-pull-probe`, it pulls a small sample of discovered Docker Hub images through the daemon; use this when host probes to `registry-1.docker.io` or `auth.docker.io` fail but Docker Desktop/daemon mirrors may make builds work. Keep the limit small unless the user explicitly wants stronger coverage. - -When using `--docker-probe`, make sure any report-handling code treats manifest probes and pull probes as different result types. Manifest probe results do not carry pull-specific fields such as `local_only_uncertain`; if a report crashes after probing, fix the preflight tool or rerun with `--json` to locate serialization errors before continuing to a real build. - -It also checks for common static blockers that often fail late: - -- CRLF shebangs in shell scripts referenced by Dockerfiles, which can produce `exec ... no such file or directory` in Linux containers. -- Python package index flags such as `-i`, `--index-url`, and `--default-index`, with warnings that mirrors can return 200 for `/simple` but 403 for wheel artifacts. -- Missing Dockerfile `COPY`/`ADD` sources inside the build context. -- Dynamic downloads (`curl`, `wget`, `git clone`, pipe-to-shell) that can hide extra network dependencies from static discovery. - -When `--artifact-probe` is enabled, the script chooses a generic sample of dependency names from `requirements*.txt`, `pyproject.toml`, and `uv.lock`, then checks artifact links from configured non-default Python indexes. Do not hardcode project-specific package names into the skill; let manifests and lockfiles drive the sample. - -## Preflight Decision Guidance - -- If Docker Hub host probes fail but `--docker-pull-probe` succeeds, treat the host probe as a warning about network asymmetry rather than an automatic build blocker. Report that the daemon mirror/cache path was verified. -- If an unqualified compose `image:` such as `my-service` fails a pull probe, first decide whether it is meant to be a locally built tag before treating it as a registry failure. -- If package index endpoint probes pass but artifact probes fail, fix the package source before building: choose a working mirror, use the official index, configure `pip.conf`/`UV_INDEX_URL`, or pass build args if the Dockerfile supports them. -- Prefer daemon-level registry mirrors for Docker Hub image pulls instead of rewriting `FROM node:...` or `image: postgres:...` to mirror domains. Direct image rewrites can break digests, provenance, auth, or compose semantics. -- When Docker Hub pulls are unreliable, pre-pull via a mirror and tag back to the canonical image name before building, instead of changing Dockerfiles or compose image names. For example, pull `mirror/library/postgres:16-alpine` then `docker tag ... postgres:16-alpine`. The generated `--image-prep` snippets do this generically. -- For apt/apk/pip/npm mirrors, prefer the repository's existing build args or config files when available. Patch Dockerfiles only when the mirror is hardcoded and the user has asked you to fix the build. -- Keep fixes generic and explain the class of problem. Avoid adding project-specific package names, image names, or local paths to this skill. - -## Build Execution - -Use this pattern when the user asks Codex to actually build an image after preflight: - -- Keep the build process in the foreground so Codex regains control immediately when it exits. -- Set the shell command timeout long enough for the build to finish naturally. -- Add plain progress to Docker builds when supported so logs are line-oriented and useful: `docker build --progress=plain ...` or `docker compose --progress plain build ...`. -- Write complete logs to a temp file, but return only the final 80-200 lines to Codex. -- Preserve the original exit code so success and failure are both handled correctly. - -PowerShell pattern: - -```powershell -$log = Join-Path $env:TEMP "container-build.log" -$env:PYTHONUTF8 = '1' -$env:PYTHONIOENCODING = 'utf-8' -docker build --progress=plain -t my-image . *> $log -$code = $LASTEXITCODE -Get-Content $log -Encoding UTF8 -Tail 160 -exit $code -``` - -On Windows PowerShell, always set `PYTHONUTF8=1` and `PYTHONIOENCODING=utf-8` before running Python-based build/deploy wrappers that may print non-ASCII text. GBK terminals can fail on Unicode output even when the build itself is healthy. Read captured logs with `-Encoding UTF8` so status text and errors are not mangled. - -Bash pattern: - -```bash -log="${TMPDIR:-/tmp}/container-build.log" -docker build --progress=plain -t my-image . >"$log" 2>&1 -code=$? -tail -n 160 "$log" -exit "$code" -``` - -Avoid backgrounding the build and then using fixed sleeps such as `Start-Sleep -Seconds 240` unless there is a specific reason to continue other independent work while the build runs. - -The preflight script can generate wrapper snippets without executing the build: - -```powershell -python D:\code\skills\skills\container-build-guard\scripts\preflight.py . --build-command "docker compose build" -``` - -For compose commands with global options, pass the command exactly as intended: - -```powershell -python D:\code\skills\skills\container-build-guard\scripts\preflight.py . --build-command "docker compose -f docker-compose.yml build --parallel" -``` - -## Post-start Verification - -After any successful `docker compose up`, `docker run`, or equivalent Podman command: - -- Inspect runtime state with `docker compose ps -a` or `docker ps -a`. -- Treat any `Exited`, restart loop, unhealthy service, or missing expected container as unfinished work. -- Read focused logs with `docker logs --tail 200 ` or `docker compose logs --tail 200 `. -- Look first for generic startup failures: `ImportError`, `ModuleNotFoundError`, `exec ... no such file or directory`, `permission denied`, `connection refused`, database migration errors, and missing environment variables. -- Probe declared ports with HTTP when the service is HTTP, otherwise use TCP connectivity. -- Report partial success clearly: images can build while containers still fail to start. - -## Reporting Guidance - -Keep the final report practical: - -- Lead with whether it is reasonable to build now. -- List `BLOCKER` sources first with the exact host or URL that failed. -- Mention warnings where the project probably depends on dynamic downloads or private credentials. -- Give concrete next steps: configure Docker registry mirrors, package mirrors, proxy variables, Docker daemon proxy, `NO_PROXY`, `.npmrc`, `pip.conf`, `UV_INDEX_URL`, `PLAYWRIGHT_DOWNLOAD_HOST`, `GOPROXY`, or offline `docker save/load`. -- If you actually run a build, report the exit code and summarize the final log tail. -- If you start containers, report container state, health status, exposed local URLs/ports, and any failed service logs. -- Avoid claiming a build is guaranteed to pass. Say the preflight only verifies likely network prerequisites. diff --git a/.codex/skills/container-build-guard/agents/openai.yaml b/.codex/skills/container-build-guard/agents/openai.yaml deleted file mode 100644 index 2370d7106..000000000 --- a/.codex/skills/container-build-guard/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Container Build Guard" - short_description: "Preflight networks and run long image builds safely." - default_prompt: "Guard this repository's container image build: check network blockers and risky external sources before building, then recommend or use a foreground log-wrapped build command that preserves the exit code." diff --git a/.codex/skills/container-build-guard/scripts/docker_runtime_config_check.py b/.codex/skills/container-build-guard/scripts/docker_runtime_config_check.py deleted file mode 100644 index 9cca0b5b5..000000000 --- a/.codex/skills/container-build-guard/scripts/docker_runtime_config_check.py +++ /dev/null @@ -1,643 +0,0 @@ -#!/usr/bin/env python3 -"""Check Docker runtime config before build preflight. - -This script inspects the three runtime-level knobs that usually decide whether -container pulls and build-time downloads will work: - -1. Docker registry mirrors -2. Docker daemon proxy settings -3. Docker CLI default proxy settings for new containers/builds - -It is intentionally read-only. If any item is missing or incomplete, the -report explains what is absent and leaves the actual change to the user after -explicit confirmation. -""" - -from __future__ import annotations - -import argparse -import json -import socket -import subprocess -import sys -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any -from urllib.error import HTTPError, URLError -from urllib.parse import urlparse -from urllib.request import Request, urlopen - - -ARTICLE_URL = "https://cloud.tencent.com/developer/article/2647943" -ARTICLE_MIRRORS = [ - "https://docker.xuanyuan.me", - "https://docker.1ms.run", - "https://docker.m.daocloud.io", -] -RETIRED_OR_UNSTABLE_MIRRORS = [ - "https://dockerhub.icu", - "https://dockerproxy.cn", - "https://dockerpull.com", - "https://lynn520.xyz", - "https://docker.mrxn.net", - "https://hub-mirror.c.163.com", - "https://docker.mirrors.ustc.edu.cn", - "https://registry.docker-cn.com", -] -DEFAULT_MIRRORS = [ - "https://docker.xuanyuan.me/", - "https://docker.1ms.run/", - "https://docker.m.daocloud.io/", -] -DEFAULT_DOCKER_PULL_PROBE_IMAGE = "hello-world:latest" -DEFAULT_DOCKER_MANIFEST_PROBE_IMAGE = "library/hello-world:latest" -DEFAULT_CONTAINER_PROBE_IMAGE = "busybox:1.36" -DEFAULT_CONTAINER_PROBE_URL = "https://registry.npmmirror.com/" - - -@dataclass -class Check: - name: str - status: str - source: str - summary: str - details: dict[str, Any] - - -@dataclass -class Probe: - name: str - status: str - summary: str - details: dict[str, Any] - - -def load_json(path: Path) -> tuple[dict[str, Any] | None, str]: - if not path.exists(): - return None, "missing" - try: - return json.loads(path.read_text(encoding="utf-8")), "ok" - except Exception as exc: # pragma: no cover - defensive - return None, f"error: {exc}" - - -def load_first_json(paths: list[Path]) -> tuple[dict[str, Any] | None, str, str]: - missing: list[str] = [] - for path in paths: - data, state = load_json(path) - if state == "ok": - return data, state, str(path) - if state == "missing": - missing.append(str(path)) - continue - return data, state, str(path) - return None, "missing", ", ".join(missing) - - -def run_docker_info() -> tuple[dict[str, Any] | None, str]: - try: - proc = subprocess.run( - ["docker", "info", "--format", "{{json .}}"], - capture_output=True, - text=True, - check=False, - ) - except FileNotFoundError: - return None, "docker-not-found" - - if proc.returncode != 0: - err = (proc.stderr or proc.stdout or "").strip() - return None, err or f"exit-{proc.returncode}" - - raw = (proc.stdout or "").strip() - if not raw: - return None, "empty-output" - - try: - return json.loads(raw), "ok" - except json.JSONDecodeError as exc: - return None, f"json-error: {exc}" - - -def normalize_list(value: Any) -> list[str]: - if isinstance(value, list): - return [str(item).strip() for item in value if str(item).strip()] - if isinstance(value, str) and value.strip(): - return [value.strip()] - return [] - - -def normalize_url(url: str) -> str: - return str(url).strip().rstrip("/").lower() - - -def mirror_probe_url(url: str) -> str: - parsed = urlparse(url) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - return url - return f"{parsed.scheme}://{parsed.netloc}/v2/" - - -def probe_http_url(url: str, timeout: float) -> dict[str, Any]: - target = mirror_probe_url(url) - started = time.monotonic() - try: - req = Request(target, method="HEAD", headers={"User-Agent": "container-build-guard/1.0"}) - with urlopen(req, timeout=timeout) as resp: - status = getattr(resp, "status", None) - ok = status is not None and status < 500 - return {"url": target, "ok": ok, "status": status, "elapsed_ms": int((time.monotonic() - started) * 1000)} - except HTTPError as exc: - ok = exc.code in {200, 301, 302, 307, 308, 401, 403, 404} - return {"url": target, "ok": ok, "status": exc.code, "elapsed_ms": int((time.monotonic() - started) * 1000)} - except (URLError, TimeoutError, socket.timeout, OSError) as exc: - return { - "url": target, - "ok": False, - "error": str(exc), - "elapsed_ms": int((time.monotonic() - started) * 1000), - } - - -def run_command(command: list[str], timeout: float) -> dict[str, Any]: - started = time.monotonic() - try: - proc = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False) - output = "\n".join(part.strip() for part in [proc.stdout, proc.stderr] if part and part.strip()) - return { - "ok": proc.returncode == 0, - "exit_code": proc.returncode, - "output_tail": output[-1200:], - "elapsed_ms": int((time.monotonic() - started) * 1000), - } - except FileNotFoundError as exc: - return {"ok": False, "error": str(exc), "elapsed_ms": int((time.monotonic() - started) * 1000)} - except subprocess.TimeoutExpired as exc: - output = "\n".join(part.strip() for part in [exc.stdout, exc.stderr] if part) - return { - "ok": False, - "error": f"timeout after {timeout}s", - "output_tail": output[-1200:], - "elapsed_ms": int((time.monotonic() - started) * 1000), - } - - -def check_registry_mirrors( - info: dict[str, Any] | None, - daemon_json: dict[str, Any] | None, - *, - probe: bool, - timeout: float, -) -> Check: - live_mirrors: list[str] = [] - source = "docker info" - if info: - registry = info.get("RegistryConfig") or {} - live_mirrors = normalize_list(registry.get("Mirrors")) - if not live_mirrors: - index_cfg = registry.get("IndexConfigs") or {} - docker_io = index_cfg.get("docker.io") or {} - live_mirrors = normalize_list(docker_io.get("Mirrors")) - - file_mirrors = [] - if daemon_json: - file_mirrors = normalize_list(daemon_json.get("registry-mirrors")) - if not live_mirrors and file_mirrors: - source = "~/.docker/daemon.json" - - mirrors = live_mirrors or file_mirrors - normalized = {normalize_url(item) for item in mirrors} - required = {normalize_url(item) for item in ARTICLE_MIRRORS} - retired = {normalize_url(item) for item in RETIRED_OR_UNSTABLE_MIRRORS} - missing_required = [item for item in ARTICLE_MIRRORS if normalize_url(item) not in normalized] - retired_present = [item for item in mirrors if normalize_url(item) in retired] - extra = [item for item in mirrors if normalize_url(item) not in required and normalize_url(item) not in retired] - probe_results = [probe_http_url(item, timeout) for item in ARTICLE_MIRRORS] if probe else [] - failed_article_probe = [item for item in probe_results if not item.get("ok")] - - details: dict[str, Any] = { - "mirrors": mirrors, - "requiredFromArticle": ARTICLE_MIRRORS, - "article": ARTICLE_URL, - } - if missing_required: - details["missingRequired"] = missing_required - if retired_present: - details["retiredOrUnstablePresent"] = retired_present - if extra: - details["extraMirrors"] = extra - if probe_results: - details["articleMirrorProbes"] = probe_results - - if not mirrors: - return Check( - name="registry_mirrors", - status="missing", - source=source, - summary="No registry mirrors are configured", - details={"recommended": DEFAULT_MIRRORS, "article": ARTICLE_URL}, - ) - - if missing_required or retired_present: - return Check( - name="registry_mirrors", - status="mismatch", - source=source, - summary="Registry mirrors do not match the Tencent Cloud article's recommended fallback set", - details=details, - ) - - if failed_article_probe: - return Check( - name="registry_mirrors", - status="probe-failed", - source=source, - summary="Registry mirrors are configured from the article, but one or more mirror endpoints failed a connectivity probe", - details=details, - ) - - if not probe: - return Check( - name="registry_mirrors", - status="configured-unverified", - source=source, - summary="Registry mirrors match the Tencent Cloud article, but mirror endpoint probes were skipped", - details=details, - ) - - if live_mirrors: - return Check( - name="registry_mirrors", - status="ok", - source=source, - summary="Registry mirrors match the Tencent Cloud article and passed mirror endpoint probes", - details=details, - ) - - return Check( - name="registry_mirrors", - status="file-only", - source="~/.docker/daemon.json", - summary="Article mirrors exist on disk but the live daemon state was not verified", - details=details, - ) - - -def check_docker_pull_path(image: str, *, probe: bool, timeout: float) -> Probe: - if not probe: - return Probe( - name="docker_pull_path", - status="skipped", - summary="Docker pull probe was skipped", - details={"image": image}, - ) - result = run_command(["docker", "pull", image], timeout) - return Probe( - name="docker_pull_path", - status="ok" if result.get("ok") else "failed", - summary=( - "Docker daemon successfully pulled the probe image through its configured network path" - if result.get("ok") - else "Docker daemon could not pull the probe image; mirror/proxy config is not sufficient" - ), - details={"image": image, **result}, - ) - - -def check_docker_manifest_path(image: str, *, probe: bool, timeout: float) -> Probe: - if not probe: - return Probe( - name="docker_manifest_path", - status="skipped", - summary="Docker manifest probe was skipped", - details={"image": image}, - ) - result = run_command(["docker", "manifest", "inspect", image], timeout) - return Probe( - name="docker_manifest_path", - status="ok" if result.get("ok") else "failed", - summary=( - "Docker client successfully inspected the remote manifest for the probe image" - if result.get("ok") - else "Docker client could not inspect the remote manifest; registry/proxy config is not sufficient" - ), - details={"image": image, **result}, - ) - - -def check_daemon_proxy( - info: dict[str, Any] | None, - daemon_json: dict[str, Any] | None, - docker_pull_probe: Probe, - docker_manifest_probe: Probe, -) -> Check: - http_proxy = "" - https_proxy = "" - no_proxy = "" - source = "docker info" - - if info: - http_proxy = str(info.get("HttpProxy") or "").strip() - https_proxy = str(info.get("HttpsProxy") or "").strip() - no_proxy = str(info.get("NoProxy") or "").strip() - - if not (http_proxy or https_proxy or no_proxy) and daemon_json: - proxies = daemon_json.get("proxies") or {} - http_proxy = str(proxies.get("http-proxy") or "").strip() - https_proxy = str(proxies.get("https-proxy") or "").strip() - no_proxy = str(proxies.get("no-proxy") or "").strip() - if http_proxy or https_proxy or no_proxy: - source = "~/.docker/daemon.json" - - configured = bool(http_proxy or https_proxy or no_proxy) - complete = bool(http_proxy and https_proxy and no_proxy) - - docker_path_verified = docker_pull_probe.status == "ok" and docker_manifest_probe.status == "ok" - - if complete and docker_path_verified: - return Check( - name="daemon_proxy", - status="ok", - source=source, - summary="Docker daemon proxy is configured and Docker registry paths were verified", - details={"httpProxy": http_proxy, "httpsProxy": https_proxy, "noProxy": no_proxy}, - ) - - if complete: - return Check( - name="daemon_proxy", - status="configured-unverified", - source=source, - summary="Docker daemon proxy is configured, but the daemon pull path did not verify successfully", - details={ - "httpProxy": http_proxy, - "httpsProxy": https_proxy, - "noProxy": no_proxy, - "dockerPullProbe": docker_pull_probe.status, - "dockerManifestProbe": docker_manifest_probe.status, - }, - ) - - if configured: - return Check( - name="daemon_proxy", - status="partial", - source=source, - summary="Docker daemon proxy is only partially configured", - details={"httpProxy": http_proxy, "httpsProxy": https_proxy, "noProxy": no_proxy}, - ) - - return Check( - name="daemon_proxy", - status="missing", - source=source, - summary="Docker daemon proxy is not configured", - details={ - "recommended": { - "http-proxy": "http://PROXY_HOST:PORT", - "https-proxy": "http://PROXY_HOST:PORT", - "no-proxy": "localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,.local,.internal", - } - }, - ) - - -def check_cli_proxy( - config_json: dict[str, Any] | None, - *, - probe: bool, - timeout: float, - probe_image: str, - probe_url: str, -) -> Check: - proxies = {} - if config_json: - proxies = (config_json.get("proxies") or {}).get("default") or {} - - http_proxy = str(proxies.get("httpProxy") or "").strip() - https_proxy = str(proxies.get("httpsProxy") or "").strip() - no_proxy = str(proxies.get("noProxy") or "").strip() - - configured = bool(http_proxy or https_proxy or no_proxy) - complete = bool(http_proxy and https_proxy and no_proxy) - - container_probe: dict[str, Any] | None = None - if complete and probe: - command = [ - "docker", - "run", - "--rm", - probe_image, - "sh", - "-c", - ( - "test -n \"$HTTP_PROXY$http_proxy\" && " - "test -n \"$HTTPS_PROXY$https_proxy\" && " - f"wget -T 10 -q --spider {probe_url}" - ), - ] - container_probe = run_command(command, timeout) - - if complete and not probe: - return Check( - name="cli_default_proxy", - status="configured-unverified", - source="~/.docker/config.json", - summary="Docker CLI default proxy is configured, but the new-container network path was not verified", - details={ - "httpProxy": http_proxy, - "httpsProxy": https_proxy, - "noProxy": no_proxy, - "containerProbe": "skipped", - }, - ) - - if complete and container_probe and container_probe.get("ok"): - return Check( - name="cli_default_proxy", - status="ok", - source="~/.docker/config.json", - summary="Docker CLI default proxy is configured and verified for a new container", - details={ - "httpProxy": http_proxy, - "httpsProxy": https_proxy, - "noProxy": no_proxy, - "containerProbe": container_probe or "skipped", - }, - ) - - if complete: - return Check( - name="cli_default_proxy", - status="probe-failed", - source="~/.docker/config.json", - summary="Docker CLI default proxy is configured, but a new container could not use it successfully", - details={ - "httpProxy": http_proxy, - "httpsProxy": https_proxy, - "noProxy": no_proxy, - "containerProbe": container_probe, - }, - ) - - if configured: - return Check( - name="cli_default_proxy", - status="partial", - source="~/.docker/config.json", - summary="Docker CLI default proxy is only partially configured", - details={"httpProxy": http_proxy, "httpsProxy": https_proxy, "noProxy": no_proxy}, - ) - - return Check( - name="cli_default_proxy", - status="missing", - source="~/.docker/config.json", - summary="Docker CLI default proxy is not configured", - details={ - "recommended": { - "httpProxy": "http://PROXY_HOST:PORT", - "httpsProxy": "http://PROXY_HOST:PORT", - "noProxy": "localhost,127.0.0.1,::1,host.docker.internal,gateway.docker.internal,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,.local,.internal", - } - }, - ) - - -def build_report(args: argparse.Namespace) -> dict[str, Any]: - home = Path.home() - docker_dir = home / ".docker" - daemon_json_paths = [docker_dir / "daemon.json", Path("/etc/docker/daemon.json")] - config_json_path = docker_dir / "config.json" - - docker_info, info_state = run_docker_info() - daemon_json, daemon_state, daemon_source = load_first_json(daemon_json_paths) - config_json, config_state = load_json(config_json_path) - - docker_pull_probe = check_docker_pull_path( - args.pull_image, - probe=not args.skip_probes, - timeout=args.probe_timeout, - ) - docker_manifest_probe = check_docker_manifest_path( - args.manifest_image, - probe=not args.skip_probes, - timeout=args.probe_timeout, - ) - - checks = [ - check_registry_mirrors( - docker_info, - daemon_json, - probe=not args.skip_probes, - timeout=args.probe_timeout, - ), - check_daemon_proxy(docker_info, daemon_json, docker_pull_probe, docker_manifest_probe), - check_cli_proxy( - config_json, - probe=not args.skip_probes, - timeout=args.probe_timeout, - probe_image=args.container_probe_image, - probe_url=args.container_probe_url, - ), - ] - - blocking_statuses = {"missing", "partial", "mismatch", "probe-failed", "configured-unverified"} - blocking = ( - docker_pull_probe.status == "failed" - or docker_manifest_probe.status == "failed" - or any(check.status in blocking_statuses for check in checks) - ) - overall = "warn" if blocking else "ok" - - return { - "overall": overall, - "sources": { - "docker_info": info_state, - "daemon_json": {"state": daemon_state, "path": daemon_source}, - "config_json": {"state": config_state, "path": str(config_json_path)}, - }, - "checks": [ - { - "name": check.name, - "status": check.status, - "source": check.source, - "summary": check.summary, - "details": check.details, - } - for check in checks - ], - "probes": [ - { - "name": docker_pull_probe.name, - "status": docker_pull_probe.status, - "summary": docker_pull_probe.summary, - "details": docker_pull_probe.details, - }, - { - "name": docker_manifest_probe.name, - "status": docker_manifest_probe.status, - "summary": docker_manifest_probe.summary, - "details": docker_manifest_probe.details, - } - ], - "next_step": ( - "Ask the user before changing or overwriting any Docker runtime config." - if blocking - else "Safe to continue to preflight." - ), - } - - -def format_text(report: dict[str, Any]) -> str: - lines = ["Docker runtime config check"] - for idx, check in enumerate(report["checks"], start=1): - lines.append(f"{idx}. {check['name']}: {check['status']} ({check['source']})") - lines.append(f" {check['summary']}") - details = check.get("details") or {} - if "mirrors" in details: - lines.append(f" mirrors: {', '.join(details['mirrors'])}") - if details.get("missingRequired"): - lines.append(f" missing required: {', '.join(details['missingRequired'])}") - if details.get("retiredOrUnstablePresent"): - lines.append(f" remove: {', '.join(details['retiredOrUnstablePresent'])}") - elif "httpProxy" in details or "httpsProxy" in details or "noProxy" in details: - lines.append( - " proxy: " - f"http={details.get('httpProxy', '') or '-'} " - f"https={details.get('httpsProxy', '') or '-'} " - f"no={details.get('noProxy', '') or '-'}" - ) - elif "recommended" in details: - lines.append(f" recommended: {json.dumps(details['recommended'], ensure_ascii=False)}") - for probe_item in report.get("probes", []): - lines.append(f"probe {probe_item['name']}: {probe_item['status']}") - lines.append(f" {probe_item['summary']}") - lines.append(f"overall: {report['overall']}") - lines.append(f"next: {report['next_step']}") - return "\n".join(lines) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--json", action="store_true", help="print JSON") - parser.add_argument("--skip-probes", action="store_true", help="only inspect config files and docker info") - parser.add_argument("--probe-timeout", type=float, default=90.0, help="timeout in seconds for each Docker/network probe") - parser.add_argument("--pull-image", default=DEFAULT_DOCKER_PULL_PROBE_IMAGE, help="small Docker Hub image used to verify daemon pull path") - parser.add_argument("--manifest-image", default=DEFAULT_DOCKER_MANIFEST_PROBE_IMAGE, help="Docker Hub image used to verify remote manifest access") - parser.add_argument("--container-probe-image", default=DEFAULT_CONTAINER_PROBE_IMAGE, help="small image used to verify Docker CLI default proxy inside a new container") - parser.add_argument("--container-probe-url", default=DEFAULT_CONTAINER_PROBE_URL, help="URL fetched from the container proxy probe") - args = parser.parse_args() - - report = build_report(args) - if args.json: - json.dump(report, sys.stdout, ensure_ascii=False, indent=2) - sys.stdout.write("\n") - else: - sys.stdout.write(format_text(report) + "\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.codex/skills/container-build-guard/scripts/preflight.py b/.codex/skills/container-build-guard/scripts/preflight.py deleted file mode 100644 index ed69f5daa..000000000 --- a/.codex/skills/container-build-guard/scripts/preflight.py +++ /dev/null @@ -1,1405 +0,0 @@ -#!/usr/bin/env python3 -"""Generic guardrail checks before container image builds. - -This script intentionally uses only the Python standard library. It scans a -repository for common container build inputs, infers external network sources, -performs lightweight DNS/TCP/HTTPS probes without downloading large files, and -can generate foreground log-wrapping snippets for long builds. -""" - -from __future__ import annotations - -import argparse -import datetime as _dt -import html -import json -import os -import re -import shlex -import shutil -import socket -import ssl -import subprocess -import sys -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Iterable -from urllib.parse import urljoin, urlparse -from urllib.request import Request, urlopen -from urllib.error import HTTPError, URLError - - -DEFAULT_TIMEOUT = 5.0 -MAX_FILE_BYTES = 2_000_000 -SKIP_DIRS = { - ".git", - ".hg", - ".svn", - "node_modules", - ".venv", - "venv", - "__pycache__", - "dist", - "build", - ".next", - "target", -} - - -@dataclass -class Finding: - severity: str - message: str - source: str = "" - - -@dataclass -class Endpoint: - key: str - host: str - port: int = 443 - scheme: str = "https" - path: str = "/" - reason: str = "" - source: str = "" - kind: str = "generic" - - def url(self) -> str: - return f"{self.scheme}://{self.host}{self.path or '/'}" - - -@dataclass -class ProbeResult: - endpoint: Endpoint - dns_ok: bool = False - tcp_ok: bool = False - https_ok: bool | None = None - status: int | None = None - error: str = "" - elapsed_ms: int = 0 - - @property - def ok(self) -> bool: - if not self.dns_ok or not self.tcp_ok: - return False - if self.endpoint.scheme == "https" and self.https_ok is False: - return False - return True - - -@dataclass -class DockerManifestResult: - image: str - source: str = "" - ok: bool = False - error: str = "" - elapsed_ms: int = 0 - - -@dataclass -class DockerPullResult: - image: str - source: str = "" - ok: bool = False - error: str = "" - local_only_uncertain: bool = False - elapsed_ms: int = 0 - - -@dataclass -class ArtifactTarget: - ecosystem: str - base_url: str - package: str - source: str - - -@dataclass -class ArtifactProbeResult: - ecosystem: str - base_url: str - package: str - source: str = "" - artifact_url: str = "" - ok: bool = False - status: int | None = None - error: str = "" - elapsed_ms: int = 0 - - -def iter_files(root: Path) -> Iterable[Path]: - for path in root.rglob("*"): - if path.is_dir(): - continue - rel_parts = path.relative_to(root).parts - if any(part in SKIP_DIRS for part in rel_parts): - continue - try: - if path.stat().st_size > MAX_FILE_BYTES: - continue - except OSError: - continue - yield path - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8", errors="ignore") - except OSError: - return "" - - -def rel(path: Path, root: Path) -> str: - try: - return str(path.relative_to(root)).replace("\\", "/") - except ValueError: - return str(path) - - -def endpoint_from_url(url: str, reason: str, source: str, kind: str = "url") -> Endpoint | None: - if "{" in url or "}" in url: - return None - if "://" not in url: - url = "https://" + url - parsed = urlparse(url) - if not parsed.hostname: - return None - if not is_external_host(parsed.hostname): - return None - scheme = parsed.scheme if parsed.scheme in {"http", "https"} else "https" - try: - port = parsed.port or (80 if scheme == "http" else 443) - except ValueError: - return None - path = parsed.path or "/" - return Endpoint( - key=f"{scheme}://{parsed.hostname}:{port}", - host=parsed.hostname, - port=port, - scheme=scheme, - path=path, - reason=reason, - source=source, - kind=kind, - ) - - -def add_endpoint(endpoints: dict[str, Endpoint], endpoint: Endpoint | None) -> None: - if endpoint is None: - return - existing = endpoints.get(endpoint.key) - if existing: - if endpoint.reason not in existing.reason: - existing.reason += f"; {endpoint.reason}" - if endpoint.source and endpoint.source not in existing.source: - existing.source += f"; {endpoint.source}" - return - endpoints[endpoint.key] = endpoint - - -def registry_hosts_for_image(image: str) -> list[str]: - image = image.strip().strip("'\"") - if not image or image.startswith("${") or "{" in image or "}" in image: - return [] - if "/" not in image: - if image in {"scratch"}: - return [] - return ["registry-1.docker.io", "auth.docker.io"] - first = image.split("/", 1)[0] - if "." in first or ":" in first or first == "localhost": - return [first.split(":")[0]] - return ["registry-1.docker.io", "auth.docker.io"] - - -def is_external_host(host: str) -> bool: - host = host.strip().lower() - if not host or host in {"localhost", "127.0.0.1", "::1"}: - return False - if re.match(r"^\d+\.\d+\.\d+\.\d+$", host): - return not ( - host.startswith("10.") - or host.startswith("192.168.") - or re.match(r"^172\.(1[6-9]|2\d|3[0-1])\.", host) - or host.startswith("127.") - ) - return "." in host - - -def discover_container_files(root: Path) -> list[Path]: - names = [] - for path in iter_files(root): - name = path.name.lower() - if ( - name.startswith("dockerfile") - or name.startswith("containerfile") - or name in {"docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"} - or re.match(r"docker-compose\..+\.(ya?ml)$", name) - or re.match(r"compose\..+\.(ya?ml)$", name) - ): - names.append(path) - return sorted(names) - - -def discover_package_manifests(root: Path) -> list[Path]: - interesting = { - "package.json", - "package-lock.json", - "pnpm-lock.yaml", - "yarn.lock", - ".npmrc", - "pyproject.toml", - "requirements.txt", - "requirements-dev.txt", - "uv.lock", - "poetry.lock", - "pip.conf", - "go.mod", - "go.sum", - "Cargo.toml", - "Cargo.lock", - "pom.xml", - "build.gradle", - "build.gradle.kts", - "Gemfile", - "Gemfile.lock", - "nuget.config", - } - found = [] - for path in iter_files(root): - if path.name in interesting: - found.append(path) - return sorted(found) - - -URL_RE = re.compile(r"https?://[^\s'\"<>\\)\|]+") -FROM_RE = re.compile(r"^\s*FROM\s+(?:--platform=\S+\s+)?([^\s]+)", re.IGNORECASE) -IMAGE_RE = re.compile(r"^\s*image:\s*([^\s#]+)", re.IGNORECASE) -PY_INDEX_RE = re.compile(r"(?:--index-url|--extra-index-url|--default-index|-i)\s+([^\s\\]+)") -COPY_RE = re.compile(r"^\s*(?:COPY|ADD)\s+(.*)$", re.IGNORECASE) -PY_PACKAGE_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*") -PY_INDEX_DEFAULTS = ( - "https://pypi.org/simple/", - "https://pypi.python.org/simple/", -) - - -def clean_image_ref(image: str) -> str: - image = image.strip().strip("'\"") - if " AS " in image.upper(): - image = re.split(r"\s+AS\s+", image, flags=re.IGNORECASE)[0] - return image - - -def is_dynamic_ref(value: str) -> bool: - return not value or value.startswith("${") or "{" in value or "}" in value or "$" in value - - -def normalize_package_name(name: str) -> str: - return re.sub(r"[-_.]+", "-", name).lower() - - -def is_default_python_index(index_url: str) -> bool: - return index_url.rstrip("/") in {url.rstrip("/") for url in PY_INDEX_DEFAULTS} - - -def add_image(images: dict[str, str], image: str, source: str) -> None: - image = clean_image_ref(image) - if is_dynamic_ref(image) or image == "scratch": - return - existing = images.get(image) - if existing: - if source not in existing: - images[image] += f"; {source}" - return - images[image] = source - - -def discover_shell_references(text: str) -> set[str]: - refs: set[str] = set() - for match in re.finditer(r"[\w./-]+\.sh\b", text): - refs.add(match.group(0).lstrip("./")) - return refs - - -def has_crlf_shebang(path: Path) -> bool: - try: - data = path.read_bytes()[:4096] - except OSError: - return False - first_line = data.split(b"\n", 1)[0] - return first_line.startswith(b"#!") and first_line.endswith(b"\r") - - -def scan_crlf_shebangs(root: Path, container_files: list[Path], findings: list[Finding]) -> None: - referenced: set[str] = set() - for path in container_files: - referenced.update(discover_shell_references(read_text(path))) - referenced_names = {Path(ref).name for ref in referenced} - - def is_referenced(source: str) -> bool: - basename = Path(source).name - for ref in referenced: - normalized_ref = ref.replace("\\", "/").lstrip("/") - if source == normalized_ref or source.endswith("/" + normalized_ref): - return True - return basename in referenced_names - - for path in iter_files(root): - source = rel(path, root) - if path.suffix.lower() not in {".sh", ".bash"} and not is_referenced(source): - continue - if not has_crlf_shebang(path): - continue - severity = "BLOCKER" if is_referenced(source) else "WARN" - findings.append( - Finding( - severity, - "Shell script has a CRLF shebang; Linux containers may fail with 'no such file or directory' when it is used as ENTRYPOINT/CMD or executed directly.", - source, - ) - ) - - -def parse_copy_sources(rest: str) -> list[str]: - rest = rest.split("#", 1)[0].strip() - if not rest: - return [] - try: - tokens = shlex.split(rest) - except ValueError: - tokens = rest.split() - tokens = [token for token in tokens if not token.startswith("--")] - if len(tokens) < 2: - return [] - return tokens[:-1] - - -def scan_missing_copy_sources(root: Path, container_files: list[Path], findings: list[Finding]) -> None: - for path in container_files: - lower_name = path.name.lower() - if not (lower_name.startswith("dockerfile") or lower_name.startswith("containerfile")): - continue - source = rel(path, root) - context_dir = path.parent - for line_no, line in enumerate(read_text(path).splitlines(), start=1): - match = COPY_RE.match(line) - if not match: - continue - for copy_source in parse_copy_sources(match.group(1)): - if is_dynamic_ref(copy_source) or copy_source.startswith("--from="): - continue - normalized = copy_source.strip().strip("'\"").replace("\\", "/") - if normalized.startswith("/") or normalized in {".", "./"}: - continue - if any(ch in normalized for ch in "*?["): - continue - candidate = (context_dir / normalized).resolve() - try: - candidate.relative_to(context_dir.resolve()) - except ValueError: - findings.append( - Finding( - "BLOCKER", - f"Dockerfile {line_no} copies '{copy_source}', which escapes the build context.", - source, - ) - ) - continue - if not candidate.exists(): - findings.append( - Finding( - "BLOCKER", - f"Dockerfile {line_no} copies missing build-context path '{copy_source}'.", - source, - ) - ) - - -def add_python_index_checks( - text: str, - source: str, - endpoints: dict[str, Endpoint], - findings: list[Finding], - warned_indexes: set[tuple[str, str]], - python_indexes: dict[str, set[str]], -) -> None: - for match in PY_INDEX_RE.finditer(text): - raw = match.group(1).strip().strip("'\"") - endpoint = endpoint_from_url(raw, "Python package index configured in build command", source, "python-index") - add_endpoint(endpoints, endpoint) - if endpoint: - sample = urljoin(endpoint.url().rstrip("/") + "/", "wheel/") - add_endpoint( - endpoints, - endpoint_from_url(sample, "Python package index simple page for wheel", source, "python-index"), - ) - warn_key = (raw, source) - if not is_default_python_index(raw) and warn_key not in warned_indexes: - warned_indexes.add(warn_key) - findings.append( - Finding( - "WARN", - f"Python package index '{raw}' should be artifact-tested; some mirrors return 200 for /simple but 403 for wheel files.", - source, - ) - ) - python_indexes.setdefault(raw, set()).add(source) - - -def extract_python_package_names(text: str, path: Path) -> list[str]: - names: list[str] = [] - seen: set[str] = set() - - def add(name: str) -> None: - cleaned = normalize_package_name(name.strip()) - if cleaned and cleaned not in seen: - seen.add(cleaned) - names.append(cleaned) - - if path.name in {"requirements.txt", "requirements-dev.txt"}: - for raw_line in text.splitlines(): - line = raw_line.split("#", 1)[0].strip() - if not line or line.startswith(("-", "http://", "https://", "git+")): - continue - match = PY_PACKAGE_TOKEN_RE.match(line) - if match: - add(match.group(0)) - return names - - if path.name == "pyproject.toml": - for match in re.finditer(r"['\"]([A-Za-z0-9][A-Za-z0-9_.-]*)\s*(?:\[.*?\])?\s*(?:[<>=!~]=|[<>=~])", text): - add(match.group(1)) - for match in re.finditer(r"^\s*([A-Za-z0-9][A-Za-z0-9_.-]*)\s*=", text, flags=re.MULTILINE): - if "[project]" not in text[: match.start()] and "[tool.poetry.dependencies]" not in text[: match.start()]: - continue - add(match.group(1)) - return names - - if path.name == "uv.lock": - for match in re.finditer(r'^\s*name\s*=\s*"([^"]+)"', text, flags=re.MULTILINE): - add(match.group(1)) - return names - - return names - - -def choose_artifact_targets( - python_indexes: dict[str, set[str]], - python_packages: dict[str, set[str]], - limit: int, -) -> list[ArtifactTarget]: - if not python_indexes or not python_packages or limit == 0: - return [] - package_order = sorted(python_packages, key=lambda name: (-len(python_packages[name]), name)) - targets: list[ArtifactTarget] = [] - max_targets = max(0, limit) - for index_url, sources in sorted(python_indexes.items()): - if is_default_python_index(index_url): - continue - for package in package_order[:3]: - targets.append( - ArtifactTarget( - ecosystem="python", - base_url=index_url, - package=package, - source="; ".join(sorted(sources | python_packages.get(package, set()))), - ) - ) - if len(targets) >= max_targets: - return targets - return targets - - -def analyze_text( - path: Path, - root: Path, - text: str, - endpoints: dict[str, Endpoint], - findings: list[Finding], - images: dict[str, str], - warned_indexes: set[tuple[str, str]], - python_indexes: dict[str, set[str]], - python_packages: dict[str, set[str]], -) -> None: - source = rel(path, root) - lower = text.lower() - is_lockfile = path.name in {"package-lock.json", "pnpm-lock.yaml", "yarn.lock", "uv.lock", "poetry.lock", "Cargo.lock", "go.sum", "Gemfile.lock"} - lower_name = path.name.lower() - is_container_file = ( - lower_name.startswith("dockerfile") - or lower_name.startswith("containerfile") - or lower_name in {"docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"} - or bool(re.match(r"docker-compose\..+\.(ya?ml)$", lower_name)) - or bool(re.match(r"compose\..+\.(ya?ml)$", lower_name)) - ) - explicit_url_scan = is_container_file or path.name in {"requirements.txt", "requirements-dev.txt", "pyproject.toml", "pip.conf", ".npmrc", "nuget.config"} - - if explicit_url_scan and not is_lockfile: - for raw_line in text.splitlines(): - line = raw_line.strip() - if line.startswith("#"): - continue - for url in URL_RE.findall(raw_line): - clean = url.rstrip(".,;") - add_endpoint(endpoints, endpoint_from_url(clean, "explicit URL in build-related files", source, "url")) - add_python_index_checks(text, source, endpoints, findings, warned_indexes, python_indexes) - - for package in extract_python_package_names(text, path): - python_packages.setdefault(package, set()).add(source) - - for line in text.splitlines(): - from_match = FROM_RE.match(line) - if from_match: - image = clean_image_ref(from_match.group(1)) - add_image(images, image, source) - for host in registry_hosts_for_image(image): - add_endpoint( - endpoints, - Endpoint( - key=f"https://{host}:443", - host=host, - reason=f"base image registry for {image}", - source=source, - kind="registry", - ), - ) - image_match = IMAGE_RE.match(line) - if image_match: - image = clean_image_ref(image_match.group(1)) - add_image(images, image, source) - for host in registry_hosts_for_image(image): - add_endpoint( - endpoints, - Endpoint( - key=f"https://{host}:443", - host=host, - reason=f"compose image registry for {image}", - source=source, - kind="registry", - ), - ) - - if re.search(r"\bapt(-get)?\s+.*\b(update|install)\b", lower): - for host in ("deb.debian.org", "security.debian.org"): - add_endpoint(endpoints, Endpoint(f"https://{host}:443", host, reason="Debian/Ubuntu apt packages", source=source, kind="apt")) - - if re.search(r"\bapk\s+add\b|\bapk\s+update\b", lower): - host = "dl-cdn.alpinelinux.org" - add_endpoint(endpoints, Endpoint(f"https://{host}:443", host, reason="Alpine apk packages", source=source, kind="apk")) - - if re.search(r"\b(yum|dnf|microdnf)\s+", lower): - findings.append(Finding("WARN", "RPM package manager found; repository hosts depend on the base image repo files.", source)) - - if re.search(r"\b(npm|npx|yarn|pnpm)\s+(install|ci|add|dlx|exec|create|i)\b", lower) or path.name in {"package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"}: - host = "registry.npmjs.org" - add_endpoint(endpoints, Endpoint(f"https://{host}:443", host, reason="Node package registry", source=source, kind="npm")) - - if re.search(r"\b(pip|uv|poetry)\s+", lower) or path.name in {"pyproject.toml", "requirements.txt", "uv.lock", "poetry.lock"}: - for host in ("pypi.org", "files.pythonhosted.org"): - add_endpoint(endpoints, Endpoint(f"https://{host}:443", host, reason="Python package index/artifacts", source=source, kind="python")) - - if path.name == "go.mod" or re.search(r"\bgo\s+(mod\s+download|get|install|build)\b", lower): - for host in ("proxy.golang.org", "sum.golang.org"): - add_endpoint(endpoints, Endpoint(f"https://{host}:443", host, reason="Go modules", source=source, kind="go")) - - if path.name in {"Cargo.toml", "Cargo.lock"} or re.search(r"\bcargo\s+(build|fetch|install|update)\b", lower): - for host in ("index.crates.io", "static.crates.io", "crates.io"): - add_endpoint(endpoints, Endpoint(f"https://{host}:443", host, reason="Rust crates", source=source, kind="cargo")) - - if path.name in {"pom.xml", "build.gradle", "build.gradle.kts"} or re.search(r"\b(mvn|gradle|gradlew)\b", lower): - for host in ("repo.maven.apache.org", "plugins.gradle.org", "services.gradle.org"): - add_endpoint(endpoints, Endpoint(f"https://{host}:443", host, reason="Java/Gradle dependencies", source=source, kind="java")) - - if "playwright" in lower: - for host in ("cdn.playwright.dev", "playwright.azureedge.net"): - add_endpoint(endpoints, Endpoint(f"https://{host}:443", host, reason="Playwright browser downloads", source=source, kind="browser")) - - if "puppeteer" in lower: - for host in ("storage.googleapis.com", "chrome-for-testing-public.storage.googleapis.com"): - add_endpoint(endpoints, Endpoint(f"https://{host}:443", host, reason="Puppeteer/Chrome downloads", source=source, kind="browser")) - - if re.search(r"\bgit\s+clone\b|\bcurl\b|\bwget\b", lower): - findings.append(Finding("WARN", "Dynamic download command found; static scan may miss URLs generated by scripts.", source)) - - if re.search(r"\|\s*(bash|sh|powershell|pwsh)\b", lower): - findings.append(Finding("WARN", "Pipe-to-shell installer found; it may download additional resources during build.", source)) - - -def env_findings() -> list[Finding]: - findings = [] - proxies = {k: os.environ.get(k) for k in ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy") if os.environ.get(k)} - if proxies: - keys = ", ".join(sorted(proxies)) - findings.append(Finding("INFO", f"Proxy environment variables are set: {keys}")) - else: - findings.append(Finding("INFO", "No HTTP(S) proxy environment variables detected in this shell.")) - - docker_host = os.environ.get("DOCKER_HOST") - if docker_host: - findings.append(Finding("INFO", f"DOCKER_HOST is set: {docker_host}. Builder networking may differ from this shell.")) - return findings - - -def docker_findings(timeout: float) -> list[Finding]: - findings = [] - docker = shutil.which("docker") - if not docker: - findings.append(Finding("WARN", "docker CLI was not found on PATH; skipping Docker daemon checks.")) - return findings - try: - result = subprocess.run( - [docker, "info", "--format", "{{.ServerVersion}}"], - capture_output=True, - text=True, - timeout=timeout, - check=False, - ) - except Exception as exc: - findings.append(Finding("WARN", f"Could not query Docker daemon: {exc}")) - return findings - if result.returncode == 0: - findings.append(Finding("OK", f"Docker daemon is reachable; server version {result.stdout.strip()}.")) - else: - msg = (result.stderr or result.stdout).strip().splitlines() - findings.append(Finding("WARN", f"Docker daemon check failed: {msg[0] if msg else 'unknown error'}")) - return findings - - -def docker_manifest_probes(images: dict[str, str], timeout: float, limit: int = 0) -> list[DockerManifestResult]: - docker = shutil.which("docker") - if not docker: - return [] - selected = sorted(images.items()) - if limit > 0: - selected = selected[:limit] - results: list[DockerManifestResult] = [] - for image, source in selected: - start = time.time() - try: - result = subprocess.run( - [docker, "manifest", "inspect", image], - capture_output=True, - text=True, - timeout=timeout, - check=False, - ) - ok = result.returncode == 0 - output = (result.stderr or result.stdout or "").strip().splitlines() - error = "" if ok else (output[-1] if output else f"exit code {result.returncode}") - except Exception as exc: - ok = False - error = str(exc) - results.append( - DockerManifestResult( - image=image, - source=source, - ok=ok, - error=error, - elapsed_ms=int((time.time() - start) * 1000), - ) - ) - return results - - -def docker_pull_probes(images: dict[str, str], timeout: float, limit: int = 0) -> list[DockerPullResult]: - docker = shutil.which("docker") - if not docker: - return [] - docker_hub_images = [ - (image, source) - for image, source in sorted(images.items()) - if "registry-1.docker.io" in registry_hosts_for_image(image) - ] - if limit > 0: - docker_hub_images = docker_hub_images[:limit] - results: list[DockerPullResult] = [] - for image, source in docker_hub_images: - start = time.time() - local_only_uncertain = "/" not in image and "docker-compose" in source.lower() - try: - inspect_result = subprocess.run( - [docker, "image", "inspect", image], - capture_output=True, - text=True, - timeout=min(timeout, 10.0), - check=False, - ) - if inspect_result.returncode == 0: - results.append( - DockerPullResult( - image=image, - source=source, - ok=True, - elapsed_ms=int((time.time() - start) * 1000), - ) - ) - continue - result = subprocess.run( - [docker, "pull", image], - capture_output=True, - text=True, - timeout=timeout, - check=False, - ) - ok = result.returncode == 0 - output = (result.stderr or result.stdout or "").strip().splitlines() - error = "" if ok else (output[-1] if output else f"exit code {result.returncode}") - except Exception as exc: - ok = False - error = str(exc) - results.append( - DockerPullResult( - image=image, - source=source, - ok=ok, - error=error, - local_only_uncertain=local_only_uncertain and not ok, - elapsed_ms=int((time.time() - start) * 1000), - ) - ) - return results - - -def http_head(url: str, timeout: float) -> tuple[bool, int | None, str]: - try: - request = Request(url, method="HEAD", headers={"User-Agent": "container-build-guard/1.0"}) - with urlopen(request, timeout=timeout, context=ssl.create_default_context()) as response: - return True, response.status, "" - except HTTPError as exc: - return exc.code < 500, exc.code, "" - except URLError as exc: - return False, None, f"HTTPS failed: {exc.reason}" - except Exception as exc: - return False, None, f"HTTPS failed: {exc}" - - -def find_python_artifact_url(index_url: str, package: str, timeout: float) -> tuple[str, int | None, str]: - simple_url = urljoin(index_url.rstrip("/") + "/", normalize_package_name(package) + "/") - try: - request = Request(simple_url, method="GET", headers={"User-Agent": "container-build-guard/1.0"}) - with urlopen(request, timeout=timeout, context=ssl.create_default_context()) as response: - html_text = response.read(400_000).decode("utf-8", errors="ignore") - except HTTPError as exc: - return "", exc.code, f"simple page HTTP {exc.code}" - except URLError as exc: - return "", None, f"simple page HTTPS failed: {exc.reason}" - except Exception as exc: - return "", None, f"simple page HTTPS failed: {exc}" - - links = re.findall(r"""href=["']([^"']+)["']""", html_text, flags=re.IGNORECASE) - for raw_link in links: - link = html.unescape(raw_link).split("#", 1)[0] - if re.search(r"\.(whl|tar\.gz|zip)$", urlparse(link).path, flags=re.IGNORECASE): - return urljoin(simple_url, link), None, "" - return "", None, "no wheel/sdist link found on simple page" - - -def artifact_probes(targets: list[ArtifactTarget], timeout: float) -> list[ArtifactProbeResult]: - results: list[ArtifactProbeResult] = [] - for target in targets: - start = time.time() - artifact_url = "" - ok = False - status_code = None - error = "" - if target.ecosystem == "python": - artifact_url, status_code, error = find_python_artifact_url(target.base_url, target.package, timeout) - if artifact_url: - ok, status_code, error = http_head(artifact_url, timeout) - if status_code and status_code >= 400: - ok = False - error = f"artifact HTTP {status_code}" - results.append( - ArtifactProbeResult( - ecosystem=target.ecosystem, - base_url=target.base_url, - package=target.package, - source=target.source, - artifact_url=artifact_url, - ok=ok, - status=status_code, - error=error, - elapsed_ms=int((time.time() - start) * 1000), - ) - ) - return results - - -def probe(endpoint: Endpoint, timeout: float) -> ProbeResult: - start = time.time() - result = ProbeResult(endpoint=endpoint) - try: - infos = socket.getaddrinfo(endpoint.host, endpoint.port, type=socket.SOCK_STREAM) - result.dns_ok = bool(infos) - except socket.gaierror as exc: - result.error = f"DNS failed: {exc}" - result.elapsed_ms = int((time.time() - start) * 1000) - return result - - try: - with socket.create_connection((endpoint.host, endpoint.port), timeout=timeout): - result.tcp_ok = True - except OSError as exc: - result.error = f"TCP failed: {exc}" - result.elapsed_ms = int((time.time() - start) * 1000) - return result - - if endpoint.scheme != "https": - result.elapsed_ms = int((time.time() - start) * 1000) - return result - - try: - request = Request(endpoint.url(), method="HEAD", headers={"User-Agent": "container-build-guard/1.0"}) - with urlopen(request, timeout=timeout, context=ssl.create_default_context()) as response: - result.https_ok = True - result.status = response.status - except HTTPError as exc: - result.https_ok = True - result.status = exc.code - except URLError as exc: - result.https_ok = False - result.error = f"HTTPS failed: {exc.reason}" - except Exception as exc: - result.https_ok = False - result.error = f"HTTPS failed: {exc}" - - result.elapsed_ms = int((time.time() - start) * 1000) - return result - - -def build_report( - root: Path, - endpoints: dict[str, Endpoint], - findings: list[Finding], - probes: list[ProbeResult], - manifest_probes: list[DockerManifestResult], - pull_probes: list[DockerPullResult], - package_artifact_probes: list[ArtifactProbeResult], -) -> dict: - blockers = [] - warnings = [f for f in findings if f.severity == "WARN"] - infos = [f for f in findings if f.severity in {"INFO", "OK"}] - static_blockers = [f for f in findings if f.severity == "BLOCKER"] - blockers.extend(static_blockers) - - docker_hub_pull_ok = bool(pull_probes) and any(item.ok for item in pull_probes) - for item in probes: - if not item.ok: - if docker_hub_pull_ok and item.endpoint.host in {"registry-1.docker.io", "auth.docker.io"}: - warnings.append( - Finding( - "WARN", - f"{item.endpoint.host}:{item.endpoint.port} is not reachable from the host shell, but Docker pull probes succeeded; the daemon may be using registry mirrors or a different network path.", - item.endpoint.source, - ) - ) - continue - blockers.append( - Finding( - "BLOCKER", - f"{item.endpoint.host}:{item.endpoint.port} is not reachable for {item.endpoint.reason}. {item.error}".strip(), - item.endpoint.source, - ) - ) - for item in manifest_probes: - if not item.ok: - if docker_hub_pull_ok and "registry-1.docker.io" in registry_hosts_for_image(item.image): - warnings.append( - Finding( - "WARN", - f"Docker manifest inspect failed for {item.image}, but Docker pull probes succeeded for Docker Hub images; verify the daemon mirror/cache path before treating this as fatal.", - item.source, - ) - ) - continue - blockers.append( - Finding( - "BLOCKER", - f"Docker daemon cannot inspect manifest for {item.image}: {item.error}", - item.source, - ) - ) - for item in pull_probes: - if not item.ok: - if item.local_only_uncertain: - warnings.append( - Finding( - "WARN", - f"Docker daemon cannot pull {item.image}, but the unqualified compose image name may refer to a locally built tag: {item.error}", - item.source, - ) - ) - continue - blockers.append( - Finding( - "BLOCKER", - f"Docker daemon cannot pull {item.image}: {item.error}", - item.source, - ) - ) - for item in package_artifact_probes: - if not item.ok: - blockers.append( - Finding( - "BLOCKER", - f"{item.ecosystem} index artifact probe failed for {item.package} from {item.base_url}: {item.error}", - item.source, - ) - ) - - return { - "root": str(root), - "summary": { - "endpoints_checked": len(probes), - "manifests_checked": len(manifest_probes), - "pulls_checked": len(pull_probes), - "artifacts_checked": len(package_artifact_probes), - "blockers": len(blockers), - "warnings": len(warnings), - "status": "BLOCKED" if blockers else ("WARN" if warnings else "OK"), - }, - "endpoints": [ - { - "host": e.host, - "port": e.port, - "scheme": e.scheme, - "reason": e.reason, - "source": e.source, - "kind": e.kind, - } - for e in endpoints.values() - ], - "probes": [ - { - "host": p.endpoint.host, - "port": p.endpoint.port, - "reason": p.endpoint.reason, - "source": p.endpoint.source, - "ok": p.ok, - "dns_ok": p.dns_ok, - "tcp_ok": p.tcp_ok, - "https_ok": p.https_ok, - "status": p.status, - "error": p.error, - "elapsed_ms": p.elapsed_ms, - } - for p in probes - ], - "manifest_probes": [ - { - "image": p.image, - "source": p.source, - "ok": p.ok, - "error": p.error, - "elapsed_ms": p.elapsed_ms, - } - for p in manifest_probes - ], - "pull_probes": [ - { - "image": p.image, - "source": p.source, - "ok": p.ok, - "error": p.error, - "elapsed_ms": p.elapsed_ms, - } - for p in pull_probes - ], - "artifact_probes": [ - { - "ecosystem": p.ecosystem, - "base_url": p.base_url, - "package": p.package, - "source": p.source, - "artifact_url": p.artifact_url, - "ok": p.ok, - "status": p.status, - "error": p.error, - "elapsed_ms": p.elapsed_ms, - } - for p in package_artifact_probes - ], - "findings": [ - {"severity": f.severity, "message": f.message, "source": f.source} - for f in blockers + warnings + infos - ], - } - - -def ps_single_quote(value: str) -> str: - return "'" + value.replace("'", "''") + "'" - - -def sh_single_quote(value: str) -> str: - return "'" + value.replace("'", "'\"'\"'") + "'" - - -def ensure_plain_progress(command: str) -> str: - stripped = command.strip() - if "--progress=" in stripped or "--progress " in stripped: - return stripped - if re.search(r"\bdocker\s+build\b", stripped): - return re.sub(r"\bdocker\s+build\b", "docker build --progress=plain", stripped, count=1) - compose_match = re.search(r"\bdocker\s+compose\b", stripped) - if compose_match: - insert_at = compose_match.end() - return stripped[:insert_at] + " --progress plain" + stripped[insert_at:] - return stripped - - -def wrapper_snippets(command: str, tail_lines: int, log_stem: str) -> dict[str, str]: - command = ensure_plain_progress(command) - safe_tail = max(20, tail_lines) - log_name = f"{log_stem}.log" - powershell = "\n".join( - [ - f"$log = Join-Path $env:TEMP {ps_single_quote(log_name)}", - "$env:PYTHONUTF8 = '1'", - "$env:PYTHONIOENCODING = 'utf-8'", - f"{command} *> $log", - "$code = $LASTEXITCODE", - f"Get-Content $log -Encoding UTF8 -Tail {safe_tail}", - "exit $code", - ] - ) - bash = "\n".join( - [ - f"log=\"${{TMPDIR:-/tmp}}/{log_name}\"", - f"{command} >\"$log\" 2>&1", - "code=$?", - f"tail -n {safe_tail} \"$log\"", - "exit \"$code\"", - ] - ) - return {"powershell": powershell, "bash": bash, "command": command} - - -DEFAULT_DOCKERHUB_MIRRORS = [ - "docker.m.daocloud.io", - "docker.xuanyuan.me", - "docker.1ms.run", -] - - -def is_dockerhub_image(image: str) -> bool: - hosts = registry_hosts_for_image(image) - return "registry-1.docker.io" in hosts - - -def dockerhub_mirror_ref(image: str, mirror: str) -> str: - name, sep, digest = image.partition("@") - if "/" in name and not name.startswith("library/"): - path = name - else: - path = f"library/{name}" - return f"{mirror.rstrip('/')}/{path}{sep}{digest}" - - -def is_probably_local_image(image: str) -> bool: - if image.startswith("${") or "{" in image or "}" in image: - return False - if "/" in image: - first = image.split("/", 1)[0] - return not ("." in first or ":" in first or first == "localhost") - return ":" not in image and "@" not in image - - -def image_prep_snippets(images: dict[str, str], mirrors: list[str]) -> dict[str, str]: - selected = sorted( - image - for image in images - if image and image != "scratch" and not is_probably_local_image(image) - ) - ps_lines = [ - "$ErrorActionPreference = 'Continue'", - "$mirrors = @(" + ", ".join(ps_single_quote(mirror) for mirror in mirrors) + ")", - "$images = @(" + ", ".join(ps_single_quote(image) for image in selected) + ")", - "$pullTimeoutSeconds = 90", - "foreach ($image in $images) {", - " docker image inspect $image *> $null", - " if ($LASTEXITCODE -eq 0) { Write-Host \"ready $image\"; continue }", - " $pulled = $false", - " $candidates = @()", - " $name = ($image -split '@', 2)[0]", - " $digest = if ($image.Contains('@')) { '@' + ($image -split '@', 2)[1] } else { '' }", - " if ($name -notmatch '^[^/]+\\.[^/]+/') {", - " foreach ($mirror in $mirrors) {", - " if ($name -match '/') { $candidates += \"$mirror/$name$digest\" } else { $candidates += \"$mirror/library/$name$digest\" }", - " }", - " }", - " $candidates += $image", - " foreach ($candidate in $candidates) {", - " Write-Host \"pull $candidate\"", - " $job = Start-Job -ScriptBlock { param($ref) docker pull $ref; if ($LASTEXITCODE -ne 0) { throw \"docker pull exited $LASTEXITCODE\" } } -ArgumentList $candidate", - " if (-not (Wait-Job $job -Timeout $pullTimeoutSeconds)) {", - " Stop-Job $job | Out-Null", - " Receive-Job $job | Out-String | Write-Host", - " Remove-Job $job -Force", - " Write-Warning \"timed out pulling $candidate\"", - " continue", - " }", - " Receive-Job $job", - " $pullCode = if ($job.State -eq 'Completed') { 0 } else { 1 }", - " Remove-Job $job -Force", - " if ($pullCode -eq 0) {", - " if ($candidate -ne $image) { docker tag $candidate $image }", - " $pulled = $true", - " break", - " }", - " }", - " if (-not $pulled) { throw \"failed to pull $image\" }", - "}", - ] - sh_lines = [ - "set -e", - "mirrors=(" + " ".join(sh_single_quote(mirror) for mirror in mirrors) + ")", - "images=(" + " ".join(sh_single_quote(image) for image in selected) + ")", - "pull_timeout_seconds=90", - "for image in \"${images[@]}\"; do", - " if docker image inspect \"$image\" >/dev/null 2>&1; then echo \"ready $image\"; continue; fi", - " candidates=()", - " name=\"${image%@*}\"", - " if [ \"$name\" = \"$image\" ]; then digest=''; else digest=\"@${image#*@}\"; fi", - " if ! printf '%s' \"$name\" | grep -Eq '^[^/]+\\.[^/]+/'; then", - " for mirror in \"${mirrors[@]}\"; do", - " if printf '%s' \"$name\" | grep -q '/'; then candidates+=(\"$mirror/$name$digest\"); else candidates+=(\"$mirror/library/$name$digest\"); fi", - " done", - " fi", - " candidates+=(\"$image\")", - " pulled=0", - " for candidate in \"${candidates[@]}\"; do", - " echo \"pull $candidate\"", - " if timeout \"$pull_timeout_seconds\" docker pull \"$candidate\"; then", - " if [ \"$candidate\" != \"$image\" ]; then docker tag \"$candidate\" \"$image\"; fi", - " pulled=1", - " break", - " fi", - " done", - " if [ \"$pulled\" -ne 1 ]; then echo \"failed to pull $image\" >&2; exit 1; fi", - "done", - ] - return {"powershell": "\n".join(ps_lines), "bash": "\n".join(sh_lines)} - - -def print_text_report(report: dict, build_command: str | None = None, tail_lines: int = 160, log_stem: str = "container-build") -> None: - summary = report["summary"] - print("Container build guard") - print(f"Root: {report['root']}") - print( - f"Status: {summary['status']} " - f"({summary['blockers']} blockers, {summary['warnings']} warnings, " - f"{summary['endpoints_checked']} endpoints checked, {summary.get('manifests_checked', 0)} manifests checked, " - f"{summary.get('pulls_checked', 0)} pulls checked, {summary.get('artifacts_checked', 0)} artifacts checked)" - ) - print() - - findings = report["findings"] - for severity in ("BLOCKER", "WARN", "OK", "INFO"): - group = [f for f in findings if f["severity"] == severity] - if not group: - continue - print(f"{severity}:") - for item in group: - source = f" [{item['source']}]" if item.get("source") else "" - print(f" - {item['message']}{source}") - print() - - if report["endpoints"]: - print("Discovered endpoints:") - for e in report["endpoints"]: - source = f" [{e['source']}]" if e.get("source") else "" - print(f" - {e['host']}:{e['port']} - {e['reason']}{source}") - print() - - if report["probes"]: - print("Endpoint probes:") - for p in report["probes"]: - mark = "OK" if p["ok"] else "FAIL" - detail = f"HTTP {p['status']}" if p.get("status") else p.get("error", "") - print(f" - {mark} {p['host']}:{p['port']} - {p['reason']} {detail}".rstrip()) - print() - - if report.get("manifest_probes"): - print("Docker daemon manifest probes:") - for p in report["manifest_probes"]: - mark = "OK" if p["ok"] else "FAIL" - detail = "" if p["ok"] else p.get("error", "") - source = f" [{p['source']}]" if p.get("source") else "" - print(f" - {mark} {p['image']} {detail}{source}".rstrip()) - print() - - if report.get("pull_probes"): - print("Docker daemon pull probes:") - for p in report["pull_probes"]: - mark = "OK" if p["ok"] else "FAIL" - detail = "" if p["ok"] else p.get("error", "") - if p.get("local_only_uncertain"): - detail = f"{detail} (may be a local compose image tag)".strip() - source = f" [{p['source']}]" if p.get("source") else "" - print(f" - {mark} {p['image']} {detail}{source}".rstrip()) - print() - - if report.get("artifact_probes"): - print("Package artifact probes:") - for p in report["artifact_probes"]: - mark = "OK" if p["ok"] else "FAIL" - detail = f"HTTP {p['status']}" if p.get("status") else p.get("error", "") - source = f" [{p['source']}]" if p.get("source") else "" - print(f" - {mark} {p['ecosystem']} {p['package']} via {p['base_url']} {detail}{source}".rstrip()) - print() - - if report.get("images"): - print("Discovered container images:") - for image in report["images"]: - source = f" [{image['source']}]" if image.get("source") else "" - print(f" - {image['image']}{source}") - print() - - if summary["status"] == "BLOCKED": - print("Recommended next steps:") - print(" - Configure proxy variables and Docker daemon proxy if the network requires a proxy.") - print(" - Configure registry/package mirrors such as Docker Hub mirror, .npmrc, pip.conf/UV_INDEX_URL, apt/apk mirrors, GOPROXY, or PLAYWRIGHT_DOWNLOAD_HOST.") - print(" - Fix static blockers such as CRLF shebangs before rebuilding; they usually fail only after image build time has already been spent.") - print(" - For offline targets, build on a connected machine and transfer images with docker save/load.") - elif summary["warnings"]: - print("Recommended next steps:") - print(" - Review warnings before building; dynamic installers may still fail even when the visible endpoints are reachable.") - else: - print("No obvious network blockers found. This guardrail check is not a guarantee that the build will pass.") - - if build_command: - snippets = wrapper_snippets(build_command, tail_lines, log_stem) - print() - print("Foreground build wrapper:") - print(" Use this after resolving blockers. It waits for the build to exit, writes full logs to a temp file, returns only the tail, and preserves the exit code.") - print() - print("PowerShell:") - print("```powershell") - print(snippets["powershell"]) - print("```") - print() - print("Bash:") - print("```bash") - print(snippets["bash"]) - print("```") - - -def main() -> int: - parser = argparse.ArgumentParser(description="Check likely network prerequisites and generate safe long-build wrappers.") - parser.add_argument("path", nargs="?", default=".", help="Repository root or build context to scan.") - parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="Per-endpoint timeout in seconds.") - parser.add_argument("--limit", type=int, default=0, help="Maximum endpoints to probe; 0 means no limit.") - parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") - parser.add_argument("--no-network", action="store_true", help="Only scan files; skip DNS/TCP/HTTPS probes.") - parser.add_argument("--docker-probe", action="store_true", help="Ask the Docker daemon to inspect discovered image manifests without pulling layers.") - parser.add_argument("--docker-probe-limit", type=int, default=20, help="Maximum image manifests to inspect when --docker-probe is used; 0 means no limit.") - parser.add_argument("--docker-pull-probe", action="store_true", help="Ask the Docker daemon to pull a small sample of discovered Docker Hub images; useful when registry mirrors make host probes misleading.") - parser.add_argument("--docker-pull-probe-limit", type=int, default=3, help="Maximum Docker Hub images to pull when --docker-pull-probe is used; 0 means no limit.") - parser.add_argument("--artifact-probe", action="store_true", help="Probe package index artifact URLs for a generic sample of discovered dependencies.") - parser.add_argument("--artifact-probe-limit", type=int, default=6, help="Maximum package artifact probes when --artifact-probe is used.") - parser.add_argument("--build-command", help="Optional build command to wrap with foreground logging guidance.") - parser.add_argument("--tail-lines", type=int, default=160, help="Log tail lines for generated build wrappers.") - parser.add_argument("--image-prep", action="store_true", help="Emit pre-pull/tag scripts for discovered container images.") - parser.add_argument("--dockerhub-mirror", action="append", default=[], help="Docker Hub mirror host for --image-prep; may be repeated.") - args = parser.parse_args() - - root = Path(args.path).resolve() - if not root.exists(): - print(f"Path does not exist: {root}", file=sys.stderr) - return 2 - if root.is_file(): - root = root.parent - - endpoints: dict[str, Endpoint] = {} - findings: list[Finding] = [] - images: dict[str, str] = {} - warned_indexes: set[tuple[str, str]] = set() - python_indexes: dict[str, set[str]] = {} - python_packages: dict[str, set[str]] = {} - - container_files = discover_container_files(root) - package_files = discover_package_manifests(root) - - if not container_files: - findings.append(Finding("WARN", "No Dockerfile, Containerfile, or compose file found. Scan will rely on package manifests only.")) - - for path in container_files + package_files: - analyze_text( - path, - root, - read_text(path), - endpoints, - findings, - images, - warned_indexes, - python_indexes, - python_packages, - ) - scan_crlf_shebangs(root, container_files, findings) - scan_missing_copy_sources(root, container_files, findings) - - findings.extend(env_findings()) - findings.extend(docker_findings(args.timeout)) - - probes = [] - if not args.no_network: - selected = sorted(endpoints.values(), key=lambda e: (e.host, e.port)) - if args.limit > 0: - selected = selected[: args.limit] - if len(endpoints) > args.limit: - findings.append(Finding("WARN", f"Probe limit applied: checked {args.limit} of {len(endpoints)} discovered endpoints.")) - for endpoint in selected: - probes.append(probe(endpoint, args.timeout)) - - manifest_probes = [] - if args.docker_probe: - manifest_probes = docker_manifest_probes(images, args.timeout, args.docker_probe_limit) - if args.docker_probe_limit > 0 and len(images) > args.docker_probe_limit: - findings.append(Finding("WARN", f"Docker manifest probe limit applied: checked {args.docker_probe_limit} of {len(images)} discovered images.")) - - pull_probes = [] - if args.docker_pull_probe: - pull_probes = docker_pull_probes(images, max(args.timeout, 30.0), args.docker_pull_probe_limit) - docker_hub_count = sum(1 for image in images if "registry-1.docker.io" in registry_hosts_for_image(image)) - if args.docker_pull_probe_limit > 0 and docker_hub_count > args.docker_pull_probe_limit: - findings.append(Finding("WARN", f"Docker pull probe limit applied: checked {args.docker_pull_probe_limit} of {docker_hub_count} discovered Docker Hub images.")) - - package_artifacts = [] - if args.artifact_probe and not args.no_network: - targets = choose_artifact_targets(python_indexes, python_packages, args.artifact_probe_limit) - package_artifacts = artifact_probes(targets, args.timeout) - if not targets and python_indexes: - findings.append(Finding("WARN", "Python indexes were discovered, but no package names were available for artifact probes.")) - - report = build_report(root, endpoints, findings, probes, manifest_probes, pull_probes, package_artifacts) - report["images"] = [{"image": image, "source": source} for image, source in sorted(images.items())] - log_stem = "container-build-" + _dt.datetime.now().strftime("%Y%m%d-%H%M%S") - if args.build_command: - report["build_wrapper"] = wrapper_snippets(args.build_command, args.tail_lines, log_stem) - if args.image_prep: - mirrors = args.dockerhub_mirror or DEFAULT_DOCKERHUB_MIRRORS - report["image_prep"] = image_prep_snippets(images, mirrors) - if args.json: - print(json.dumps(report, indent=2, ensure_ascii=False)) - else: - print_text_report(report, args.build_command, args.tail_lines, log_stem) - if args.image_prep: - snippets = report["image_prep"] - print() - print("Image pre-pull/tag script:") - print("PowerShell:") - print("```powershell") - print(snippets["powershell"]) - print("```") - print() - print("Bash:") - print("```bash") - print(snippets["bash"]) - print("```") - - return 1 if report["summary"]["blockers"] else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.gitignore b/.gitignore index 0da52598c..36320452b 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ cache .parquet .claude .claude/settings.local.json +.codex .hermes .qoder