498 lines
21 KiB
Python
498 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Prepare a hash-locked, source-checked ContextForge candidate build recipe."""
|
|
|
|
# Exact image references, source anchors, and Dockerfile instructions exceed 100 columns.
|
|
# ruff: noqa: E501
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
LOCK_DIR = ROOT / "infrastructure" / "images" / "contextforge" / "candidate"
|
|
LOCK_MANIFEST = LOCK_DIR / "locks.json"
|
|
PRODUCTION_LOCK = LOCK_DIR / "production-requirements.lock"
|
|
BUILD_TOOLS_LOCK = LOCK_DIR / "build-tools.lock"
|
|
|
|
UPSTREAM_COMMIT = "1e58a0434a78bbdbe1e975abeb0bed1043ff14c1"
|
|
UV_VERSION = "0.12.10"
|
|
LOCK_TIMESTAMP = "2026-10-02T00:00:00Z"
|
|
GLOBAL_LOCK_CUTOFF = "2026-09-22T00:00:00Z"
|
|
EXTRAS = ("redis", "postgres", "observability", "granian", "plugins", "llmchat")
|
|
CONSTRAINTS = ("mcp==1.30.0", "PyJWT==2.15.0", "urllib3==2.8.0")
|
|
BUILD_TOOLS = ("pip==26.2.1", "setuptools==82.0.1", "wheel==0.46.2")
|
|
|
|
SOURCE_HASHES = {
|
|
"Containerfile": "2ab0187f3a63eced093a1dec3141ec3e30ef879eb7de0dfd9e4bc5fce33f64f2",
|
|
"pyproject.toml": "cd365566f922546e569c65475d78cd53e5d49f10df2c82534efa607ffcf55e4b",
|
|
"uv.lock": "c5297a4701e4a0ecb9903bd3f4aa1c8cf40cf4e5568880fcb8f4078703589df1",
|
|
}
|
|
|
|
BASE_IMAGES = (
|
|
{
|
|
"arg": "UBI_BASE",
|
|
"sourceTag": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-builder:sha256-c6bb70a7a340ed1047e7cb934aa55b086ee68a5eb9ba6b2ad2e6825c7184a3e8",
|
|
"reference": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-builder@sha256:e23a733abd28376eb3328676a3ab1e6d153122228110de0c881bb5f3c38a4483",
|
|
"parentReference": "registry.access.redhat.com/ubi10@sha256:1e32d829a4de713a835d702d3bc05f0c448a08590796281b7dec181912e9aeb8",
|
|
"imageId": "sha256:c6bb70a7a340ed1047e7cb934aa55b086ee68a5eb9ba6b2ad2e6825c7184a3e8",
|
|
"rpmInventorySha256": "6a8c0d4f66fc5a38008541ad15614711e7f536cd18c70c02d2eb0691552b88b0",
|
|
},
|
|
{
|
|
"arg": "NODEJS_IMAGE",
|
|
"sourceTag": "registry.access.redhat.com/ubi10/nodejs-24:1784092609",
|
|
"reference": "registry.access.redhat.com/ubi10/nodejs-24@sha256:0f110e68682f4a49188ba4db786eb4f207b119420913fc76bb5ba9512ffbef06",
|
|
},
|
|
{
|
|
"arg": "UBI_MINIMAL",
|
|
"sourceTag": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime:sha256-fd94e2643dfc0401790d0528d3740bb2f0c04709e743f6c774a4e8a38606e40b",
|
|
"reference": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime@sha256:9b23d8152f253b10e47ce077bb9c5f8c3c370f3e0d5533f8a4a0e986ff176236",
|
|
"parentReference": "registry.access.redhat.com/ubi10/ubi-minimal@sha256:afbfb5c18c628796fed3ad3122dcf096e0b2dec9dad3fbabe8620de06bc6f078",
|
|
"imageId": "sha256:fd94e2643dfc0401790d0528d3740bb2f0c04709e743f6c774a4e8a38606e40b",
|
|
"rpmInventorySha256": "a74d60ecb43d1aa561874305d1a9c62ac35c99a14c1dc275ba1656f14df7d034",
|
|
},
|
|
)
|
|
|
|
LOCK_OUTPUT_NAMES = {
|
|
"production-requirements.lock": "contextforge-candidate-production-requirements.txt",
|
|
"build-tools.lock": "contextforge-candidate-build-tools.txt",
|
|
}
|
|
|
|
INSTALL_START = (
|
|
"RUN set -euo pipefail \\\n"
|
|
" && . /etc/profile.d/use-openssl.sh \\\n"
|
|
" && python3 -m venv /app/.venv"
|
|
)
|
|
INSTALL_END = "\n# ----------------------------------------------------------------------------\n# Copy Rust runtime binaries and frontend static output from their builder\n"
|
|
LOCK_COPY_ANCHOR = "COPY pyproject.toml /app/\n"
|
|
|
|
|
|
class CandidateRecipeError(ValueError):
|
|
"""Raised when a candidate recipe input is absent or differs from contract."""
|
|
|
|
|
|
def sha256_hex(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def _require_file(path: Path) -> bytes:
|
|
if not path.is_file() or path.is_symlink():
|
|
raise CandidateRecipeError(f"required regular file is missing: {path.name}")
|
|
return path.read_bytes()
|
|
|
|
|
|
def verify_upstream_source(
|
|
source_dir: Path, expected_hashes: dict[str, str] | None = None
|
|
) -> dict[str, bytes]:
|
|
files: dict[str, bytes] = {}
|
|
for name, expected in (expected_hashes or SOURCE_HASHES).items():
|
|
content = _require_file(source_dir / name)
|
|
actual = sha256_hex(content)
|
|
if actual != expected:
|
|
raise CandidateRecipeError(f"pinned upstream {name} hash mismatch")
|
|
files[name] = content
|
|
return files
|
|
|
|
|
|
def _table_range(text: str, table: str) -> tuple[int, int, str]:
|
|
header = f"[{table}]"
|
|
matches = list(re.finditer(rf"(?m)^{re.escape(header)}\s*$", text))
|
|
if len(matches) != 1:
|
|
raise CandidateRecipeError(f"expected exactly one TOML table {header}")
|
|
start = matches[0].start()
|
|
next_header = re.search(r"(?m)^\[[^\r\n]+\]\s*$", text[matches[0].end() :])
|
|
end = matches[0].end() + next_header.start() if next_header else len(text)
|
|
return start, end, text[start:end]
|
|
|
|
|
|
def candidate_pyproject(source_text: str) -> str:
|
|
"""Apply the narrowly scoped candidate-only dependency resolution policy."""
|
|
try:
|
|
import tomllib
|
|
|
|
source = tomllib.loads(source_text)
|
|
except (ImportError, ValueError) as exc:
|
|
raise CandidateRecipeError("upstream pyproject is not valid TOML") from exc
|
|
|
|
uv = source.get("tool", {}).get("uv", {})
|
|
if uv.get("exclude-newer") != "10 days":
|
|
raise CandidateRecipeError("upstream uv freshness policy changed")
|
|
groups = source.get("dependency-groups", {})
|
|
if set(groups) != {"dev"}:
|
|
raise CandidateRecipeError("upstream dependency-group shape changed")
|
|
sources = uv.get("sources", {})
|
|
if sources != {
|
|
"compliance-reference-server": {
|
|
"path": "mcp-servers/python/compliance_reference_server",
|
|
"editable": True,
|
|
}
|
|
}:
|
|
raise CandidateRecipeError("upstream uv source override shape changed")
|
|
|
|
text = source_text.replace("\r\n", "\n")
|
|
source_start, source_end, source_table = _table_range(text, "tool.uv.sources")
|
|
source_lines = source_table.strip().splitlines()
|
|
expected_source_lines = [
|
|
"[tool.uv.sources]",
|
|
'compliance-reference-server = { path = "mcp-servers/python/compliance_reference_server", editable = true }',
|
|
]
|
|
if source_lines[:2] != expected_source_lines or any(
|
|
line.strip() and not line.lstrip().startswith("#") for line in source_lines[2:]
|
|
):
|
|
raise CandidateRecipeError("upstream local development source table changed")
|
|
text = text[:source_start] + text[source_end:]
|
|
|
|
group_start, group_end, _group_table = _table_range(text, "dependency-groups")
|
|
text = text[:group_start] + text[group_end:]
|
|
|
|
uv_anchor = 'exclude-newer = "10 days"\n'
|
|
if text.count(uv_anchor) != 1:
|
|
raise CandidateRecipeError("upstream uv table anchor changed")
|
|
text = text.replace(
|
|
uv_anchor,
|
|
f'exclude-newer = "{GLOBAL_LOCK_CUTOFF}"\n'
|
|
+ 'constraint-dependencies = ["mcp==1.30.0", "PyJWT==2.15.0", "urllib3==2.8.0"]\n',
|
|
1,
|
|
)
|
|
|
|
package_table = "[tool.uv.exclude-newer-package]\n"
|
|
if text.count(package_table) != 1:
|
|
raise CandidateRecipeError("upstream package freshness table is missing")
|
|
package_overrides = "".join(
|
|
f'{name.lower()} = "{LOCK_TIMESTAMP}"\n'
|
|
for name in ("mcp", "pyjwt", "urllib3", "pip", "setuptools", "wheel")
|
|
)
|
|
text = text.replace(package_table, package_table + package_overrides, 1)
|
|
|
|
text = text.rstrip() + "\n\n[dependency-groups]\nbuild-tools = [\n"
|
|
text += "".join(f' "{requirement}",\n' for requirement in BUILD_TOOLS)
|
|
text += "]\n"
|
|
try:
|
|
tomllib.loads(text)
|
|
except ValueError as exc:
|
|
raise CandidateRecipeError("generated candidate pyproject is invalid TOML") from exc
|
|
return text
|
|
|
|
|
|
def _run_uv(args: list[str], cwd: Path) -> None:
|
|
completed = subprocess.run( # noqa: S603 - fixed executable and structured arguments
|
|
[_uv_binary(), *args],
|
|
cwd=cwd,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if completed.returncode:
|
|
diagnostic = completed.stderr.strip().splitlines()[-1:] or ["uv command failed"]
|
|
raise CandidateRecipeError(diagnostic[0])
|
|
|
|
|
|
def _uv_binary() -> str:
|
|
binary = shutil.which("uv")
|
|
if binary is None or not Path(binary).is_absolute():
|
|
raise CandidateRecipeError("uv executable is missing or non-absolute")
|
|
return binary
|
|
|
|
|
|
def generate_locks(source_dir: Path, output_dir: Path) -> None:
|
|
"""Regenerate locks from the exact upstream project using the pinned uv tool."""
|
|
source = verify_upstream_source(source_dir)
|
|
version = subprocess.run( # noqa: S603 - fixed version check before lock generation
|
|
[_uv_binary(), "--version"], check=False, capture_output=True, text=True
|
|
)
|
|
version_parts = version.stdout.strip().split()
|
|
if version.returncode or version_parts[:2] != ["uv", UV_VERSION]:
|
|
raise CandidateRecipeError(f"lock generation requires uv {UV_VERSION}")
|
|
|
|
output_dir = output_dir.resolve()
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(prefix="contextforge-lock-") as temp_name:
|
|
temp = Path(temp_name)
|
|
(temp / "pyproject.toml").write_text(
|
|
candidate_pyproject(source["pyproject.toml"].decode("utf-8")), encoding="utf-8"
|
|
)
|
|
(temp / "uv.lock").write_bytes(source["uv.lock"])
|
|
_run_uv(["lock", "--project", str(temp)], temp)
|
|
|
|
production_path = output_dir / "production-requirements.lock"
|
|
build_path = output_dir / "build-tools.lock"
|
|
_run_uv(
|
|
[
|
|
"export",
|
|
"--project",
|
|
str(temp),
|
|
"--format",
|
|
"requirements-txt",
|
|
"--locked",
|
|
"--no-dev",
|
|
"--no-default-groups",
|
|
"--no-emit-project",
|
|
"--no-header",
|
|
"--no-annotate",
|
|
*[value for extra in EXTRAS for value in ("--extra", extra)],
|
|
"--output-file",
|
|
str(production_path),
|
|
],
|
|
temp,
|
|
)
|
|
_run_uv(
|
|
[
|
|
"export",
|
|
"--project",
|
|
str(temp),
|
|
"--format",
|
|
"requirements-txt",
|
|
"--locked",
|
|
"--only-group",
|
|
"build-tools",
|
|
"--no-default-groups",
|
|
"--no-emit-project",
|
|
"--no-header",
|
|
"--no-annotate",
|
|
"--output-file",
|
|
str(build_path),
|
|
],
|
|
temp,
|
|
)
|
|
|
|
manifest = _make_manifest(
|
|
source_hashes=SOURCE_HASHES,
|
|
production_hash=sha256_hex(_require_file(output_dir / "production-requirements.lock")),
|
|
build_tools_hash=sha256_hex(_require_file(output_dir / "build-tools.lock")),
|
|
)
|
|
_write_atomic(output_dir / "locks.json", _canonical_json(manifest))
|
|
|
|
|
|
def _make_manifest(
|
|
*, source_hashes: dict[str, str], production_hash: str, build_tools_hash: str
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"schema": "itworx.contextforge-candidate-locks/v1",
|
|
"upstreamCommit": UPSTREAM_COMMIT,
|
|
"upstreamFiles": source_hashes,
|
|
"uvVersion": UV_VERSION,
|
|
"python": "3.12",
|
|
"platform": "linux/amd64",
|
|
"extras": list(EXTRAS),
|
|
"constraints": list(CONSTRAINTS),
|
|
"buildTools": list(BUILD_TOOLS),
|
|
"globalExcludeNewerUtc": GLOBAL_LOCK_CUTOFF,
|
|
"freshnessOverrideUtc": LOCK_TIMESTAMP,
|
|
"requirements": {
|
|
"production-requirements.lock": production_hash,
|
|
"build-tools.lock": build_tools_hash,
|
|
},
|
|
"baseImages": [
|
|
{
|
|
**base,
|
|
"platform": "linux/amd64",
|
|
"childManifestDigest": base["reference"].split("@", 1)[1],
|
|
}
|
|
for base in BASE_IMAGES
|
|
],
|
|
}
|
|
|
|
|
|
def _canonical_json(value: Any) -> bytes:
|
|
return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode("utf-8")
|
|
|
|
|
|
def _write_atomic(path: Path, content: bytes) -> None:
|
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
try:
|
|
temporary.write_bytes(content)
|
|
os.replace(temporary, path)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def _read_lock_manifest() -> dict[str, Any]:
|
|
try:
|
|
payload = json.loads(_require_file(LOCK_MANIFEST).decode("utf-8"))
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
raise CandidateRecipeError("candidate lock manifest is invalid JSON") from exc
|
|
expected = _make_manifest(
|
|
source_hashes=SOURCE_HASHES,
|
|
production_hash=str(
|
|
payload.get("requirements", {}).get("production-requirements.lock", "")
|
|
),
|
|
build_tools_hash=str(payload.get("requirements", {}).get("build-tools.lock", "")),
|
|
)
|
|
if payload != expected:
|
|
raise CandidateRecipeError("candidate lock manifest differs from the reviewed contract")
|
|
for name, expected_hash in payload["requirements"].items():
|
|
if sha256_hex(_require_file(LOCK_DIR / name)) != expected_hash:
|
|
raise CandidateRecipeError(f"candidate lock hash mismatch: {name}")
|
|
return payload
|
|
|
|
|
|
def verify_lock_contents(manifest: dict[str, Any]) -> None:
|
|
production = _require_file(LOCK_DIR / "production-requirements.lock").decode("utf-8")
|
|
build_tools = _require_file(LOCK_DIR / "build-tools.lock").decode("utf-8")
|
|
for requirement in CONSTRAINTS:
|
|
name, version = requirement.split("==", 1)
|
|
match = re.search(rf"(?im)^{re.escape(name)}==([^ ;\\]+)", production)
|
|
if match is None or match.group(1) != version:
|
|
raise CandidateRecipeError(f"production lock does not pin {name}=={version}")
|
|
for requirement in BUILD_TOOLS:
|
|
name, version = requirement.split("==", 1)
|
|
match = re.search(rf"(?im)^{re.escape(name)}==([^ ;\\]+)", build_tools)
|
|
if match is None or match.group(1) != version:
|
|
raise CandidateRecipeError(f"build-tool lock does not pin {name}=={version}")
|
|
for name, requirements in (
|
|
("production", production),
|
|
("build-tool", build_tools),
|
|
):
|
|
blocks = re.split(r"(?m)(?=^[A-Za-z0-9_.-]+==)", requirements)
|
|
blocks = [block for block in blocks if re.match(r"(?m)^[A-Za-z0-9_.-]+==", block)]
|
|
if not blocks or any(
|
|
not re.search(r"(?m)^\s*--hash=sha256:[0-9a-f]{64}\s*$", block) for block in blocks
|
|
):
|
|
raise CandidateRecipeError(f"candidate {name} requirements must pin every wheel hash")
|
|
if "pip==26.2.1" not in build_tools:
|
|
raise CandidateRecipeError("candidate build tools must include the removable pip bootstrap")
|
|
if manifest["platform"] != "linux/amd64":
|
|
raise CandidateRecipeError("candidate lock platform differs from the fixed build platform")
|
|
|
|
|
|
def freeze_os_layers(text: str) -> str:
|
|
"""Replace the four upstream RPM transactions with frozen-base assertions."""
|
|
guards = (
|
|
(
|
|
'RUN if [ "$ENABLE_RUST" = "true" ]; then',
|
|
'RUN test "$ENABLE_RUST" = "false" && test "$ENABLE_RUST_MCP_RMCP" = "false"',
|
|
),
|
|
(
|
|
"RUN set -euo pipefail",
|
|
'RUN test "$PYTHON_VERSION" = "3.12" && test -s /usr/share/itworx/rpm-inventory.txt',
|
|
),
|
|
(
|
|
"RUN microdnf upgrade",
|
|
'RUN test "$PYTHON_VERSION" = "3.12" && test "$ENABLE_PROFILING" = "false" '
|
|
'&& test "$(id -u app):$(id -g app)" = "10001:10001" '
|
|
"&& test -s /usr/share/itworx/rpm-inventory.txt",
|
|
),
|
|
('RUN if [ "$ENABLE_FIPS" = "true" ]; then', 'RUN test "$ENABLE_FIPS" = "false"'),
|
|
)
|
|
transactions = [
|
|
match
|
|
for match in re.finditer(r"(?m)^RUN [^\n]*(?:\n(?<=\\\n)[^\n]*)*", text)
|
|
if re.search(r"\b(?:microdnf|dnf) (?:upgrade|install)\b", match.group())
|
|
]
|
|
if len(transactions) != len(guards):
|
|
raise CandidateRecipeError("upstream RPM transaction count changed")
|
|
for match, (prefix, guard) in reversed(list(zip(transactions, guards, strict=True))):
|
|
if not match.group().startswith(prefix):
|
|
raise CandidateRecipeError("upstream RPM transaction order changed")
|
|
text = text[: match.start()] + guard + text[match.end() :]
|
|
return text
|
|
|
|
|
|
def transform_containerfile(containerfile: bytes, expected_hash: str | None = None) -> bytes:
|
|
if sha256_hex(containerfile) != (expected_hash or SOURCE_HASHES["Containerfile"]):
|
|
raise CandidateRecipeError("pinned upstream Containerfile hash mismatch")
|
|
text = freeze_os_layers(containerfile.decode("utf-8"))
|
|
if text.count(LOCK_COPY_ANCHOR) != 1:
|
|
raise CandidateRecipeError("upstream dependency-copy anchor count changed")
|
|
text = text.replace(
|
|
LOCK_COPY_ANCHOR,
|
|
LOCK_COPY_ANCHOR
|
|
+ "COPY contextforge-candidate-production-requirements.txt /tmp/contextforge-production-requirements.txt\n"
|
|
+ "COPY contextforge-candidate-build-tools.txt /tmp/contextforge-build-tools.txt\n",
|
|
1,
|
|
)
|
|
|
|
start = text.find(INSTALL_START)
|
|
end = text.find(INSTALL_END, start)
|
|
if start < 0 or end < 0 or text.find(INSTALL_START, start + 1) >= 0:
|
|
raise CandidateRecipeError("upstream dependency-install block anchors changed")
|
|
locked_install = """RUN set -euo pipefail \\
|
|
&& . /etc/profile.d/use-openssl.sh \\
|
|
&& python3 -m venv /app/.venv \\
|
|
&& /app/.venv/bin/python -m ensurepip --upgrade \\
|
|
&& /app/.venv/bin/python -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --only-binary=:all: -r /tmp/contextforge-build-tools.txt \\
|
|
&& /app/.venv/bin/python -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --only-binary=:all: -r /tmp/contextforge-production-requirements.txt \\
|
|
&& /app/.venv/bin/python -m pip install --disable-pip-version-check --no-cache-dir --no-deps --no-build-isolation ".[redis,postgres,observability,granian,plugins,llmchat]" \\
|
|
&& /app/.venv/bin/python -m pip check \\
|
|
&& /app/.venv/bin/python -c "import importlib.metadata as md; assert md.version('mcp') == '1.30.0'; assert md.version('PyJWT') == '2.15.0'; assert md.version('urllib3') == '2.8.0'" \\
|
|
&& /app/.venv/bin/python -m pip uninstall --yes pip setuptools wheel \\
|
|
&& (find /app/.venv -name "*.dist-info" -type d \\
|
|
\\( -name "pip-*" -o -name "setuptools-*" -o -name "wheel-*" \\) \\
|
|
-exec rm -rf {} + 2>/dev/null || true) \\
|
|
&& rm -rf /app/.venv/share/python-wheels \\
|
|
&& rm -rf "/app/"*.egg-info /app/build /app/dist /app/.eggs \\
|
|
&& rm -rf /root/.cache /var/cache/dnf
|
|
"""
|
|
text = text[:start] + locked_install + text[end:]
|
|
return text.encode("utf-8")
|
|
|
|
|
|
def prepare_source(source_dir: Path) -> None:
|
|
source = verify_upstream_source(source_dir)
|
|
manifest = _read_lock_manifest()
|
|
verify_lock_contents(manifest)
|
|
transformed = transform_containerfile(source["Containerfile"])
|
|
|
|
for source_name, target_name in LOCK_OUTPUT_NAMES.items():
|
|
_write_atomic(source_dir / target_name, _require_file(LOCK_DIR / source_name))
|
|
_write_atomic(source_dir / "Containerfile.itworx-candidate", transformed)
|
|
|
|
|
|
def print_build_args() -> None:
|
|
for key, value in candidate_build_args().items():
|
|
print(f"{key}={value}")
|
|
|
|
|
|
def candidate_build_args() -> dict[str, str]:
|
|
manifest = _read_lock_manifest()
|
|
verify_lock_contents(manifest)
|
|
overrides = {item["arg"]: item["reference"] for item in BASE_IMAGES}
|
|
overrides.update(
|
|
{
|
|
"WHEELS_REF": overrides["UBI_MINIMAL"],
|
|
"PYTHON_VERSION": "3.12",
|
|
"ENABLE_RUST": "false",
|
|
"ENABLE_RUST_MCP_RMCP": "false",
|
|
"ENABLE_PROFILING": "false",
|
|
"ENABLE_FIPS": "false",
|
|
}
|
|
)
|
|
return overrides
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
prepare = commands.add_parser(
|
|
"prepare", help="verify and transform an exact fetched source tree"
|
|
)
|
|
prepare.add_argument("--source-dir", type=Path, required=True)
|
|
generate = commands.add_parser("generate-locks", help="regenerate lock files with pinned uv")
|
|
generate.add_argument("--source-dir", type=Path, required=True)
|
|
generate.add_argument("--output-dir", type=Path, default=LOCK_DIR)
|
|
commands.add_parser("build-args", help="print fixed platform/base image build arguments")
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
if args.command == "prepare":
|
|
prepare_source(args.source_dir)
|
|
elif args.command == "generate-locks":
|
|
generate_locks(args.source_dir, args.output_dir)
|
|
else:
|
|
print_build_args()
|
|
except (CandidateRecipeError, OSError, subprocess.SubprocessError) as exc:
|
|
parser.exit(1, f"FATAL: {exc}\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|