Compare commits
1 Commits
codex/host
...
codex/cicd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c43b3da2e |
@@ -133,10 +133,7 @@ jobs:
|
||||
# 2026-06-28 Codex: awoooi-non110-host maps to the dedicated
|
||||
# non-110 runner lane. Bootstrap tools defensively because host
|
||||
# runners can start without the CI toolchain preinstalled.
|
||||
run: |
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache nodejs npm git curl bash coreutils python3 openssh-client docker-cli docker-cli-buildx
|
||||
fi
|
||||
run: bash scripts/ci/bootstrap-host-runner-tools.sh
|
||||
|
||||
- name: Checkout source from Gitea
|
||||
env:
|
||||
@@ -1454,10 +1451,7 @@ jobs:
|
||||
- name: Bootstrap Host Runner Tools
|
||||
# 2026-05-05 Codex: keep the host-mode runner self-healing before
|
||||
# actions/checkout@v4 and Telegram failure notifications run.
|
||||
run: |
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache nodejs npm git curl bash coreutils python3 openssh-client docker-cli docker-cli-buildx
|
||||
fi
|
||||
run: bash scripts/ci/bootstrap-host-runner-tools.sh
|
||||
|
||||
- name: Checkout source from Gitea
|
||||
env:
|
||||
@@ -5226,10 +5220,7 @@ jobs:
|
||||
- name: Bootstrap Host Runner Tools
|
||||
# 2026-05-05 Codex: post-deploy also uses checkout and curl-based
|
||||
# notifications, so it needs the same runner bootstrap as earlier jobs.
|
||||
run: |
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache nodejs npm git curl bash coreutils python3 openssh-client docker-cli docker-cli-buildx
|
||||
fi
|
||||
run: bash scripts/ci/bootstrap-host-runner-tools.sh
|
||||
|
||||
- name: Checkout source from Gitea
|
||||
env:
|
||||
|
||||
43
scripts/ci/bootstrap-host-runner-tools.sh
Executable file
43
scripts/ci/bootstrap-host-runner-tools.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
required_commands=(node npm git curl bash timeout python3 ssh docker)
|
||||
missing_commands=()
|
||||
|
||||
for command_name in "${required_commands[@]}"; do
|
||||
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||||
missing_commands+=("${command_name}")
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v docker >/dev/null 2>&1 && \
|
||||
! docker buildx version >/dev/null 2>&1; then
|
||||
missing_commands+=(docker-buildx)
|
||||
fi
|
||||
|
||||
if [ "${#missing_commands[@]}" -eq 0 ]; then
|
||||
echo "host_runner_tools_ready=1 bootstrap_install_performed=0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'host_runner_tools_missing=%s\n' "$(IFS=,; echo "${missing_commands[*]}")"
|
||||
if ! command -v apk >/dev/null 2>&1; then
|
||||
echo "BLOCKER host_runner_tools_missing_and_apk_unavailable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apk add --no-cache \
|
||||
nodejs npm git curl bash coreutils python3 openssh-client docker-cli docker-cli-buildx
|
||||
|
||||
for command_name in "${required_commands[@]}"; do
|
||||
command -v "${command_name}" >/dev/null 2>&1 || {
|
||||
echo "BLOCKER host_runner_tool_install_failed command=${command_name}"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
docker buildx version >/dev/null 2>&1 || {
|
||||
echo "BLOCKER host_runner_tool_install_failed command=docker-buildx"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "host_runner_tools_ready=1 bootstrap_install_performed=1"
|
||||
96
scripts/ci/tests/test_bootstrap_host_runner_tools.py
Normal file
96
scripts/ci/tests/test_bootstrap_host_runner_tools.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPT = ROOT / "scripts/ci/bootstrap-host-runner-tools.sh"
|
||||
REQUIRED = ("node", "npm", "git", "curl", "bash", "timeout", "python3", "ssh")
|
||||
|
||||
|
||||
def _write_command(path: Path, body: str = "exit 0\n") -> None:
|
||||
path.write_text("#!/bin/sh\n" + body, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def _fake_tool_path(directory: Path, *, include_npm: bool) -> None:
|
||||
for command in REQUIRED:
|
||||
if command != "npm" or include_npm:
|
||||
_write_command(directory / command)
|
||||
_write_command(directory / "docker")
|
||||
|
||||
|
||||
def _run(directory: Path) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = str(directory)
|
||||
return subprocess.run(
|
||||
["/bin/bash", str(SCRIPT)],
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def test_ready_runner_skips_package_install() -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
directory = Path(temp)
|
||||
_fake_tool_path(directory, include_npm=True)
|
||||
|
||||
completed = _run(directory)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert "host_runner_tools_ready=1 bootstrap_install_performed=0" in completed.stdout
|
||||
|
||||
|
||||
def test_missing_tool_without_apk_fails_closed() -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
directory = Path(temp)
|
||||
_fake_tool_path(directory, include_npm=False)
|
||||
|
||||
completed = _run(directory)
|
||||
|
||||
assert completed.returncode == 1
|
||||
assert "host_runner_tools_missing=npm" in completed.stdout
|
||||
assert "BLOCKER host_runner_tools_missing_and_apk_unavailable" in completed.stdout
|
||||
|
||||
|
||||
def test_missing_tool_uses_apk_once_and_revalidates() -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
directory = Path(temp)
|
||||
_fake_tool_path(directory, include_npm=False)
|
||||
apk_log = directory / "apk.log"
|
||||
_write_command(
|
||||
directory / "apk",
|
||||
'printf "%s\\n" "$*" > "$APK_LOG"\n'
|
||||
'printf "#!/bin/sh\\nexit 0\\n" > "$FAKE_BIN/npm"\n'
|
||||
'/bin/chmod +x "$FAKE_BIN/npm"\n',
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"PATH": str(directory),
|
||||
"APK_LOG": str(apk_log),
|
||||
"FAKE_BIN": str(directory),
|
||||
}
|
||||
)
|
||||
|
||||
completed = subprocess.run(
|
||||
["/bin/bash", str(SCRIPT)],
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
)
|
||||
|
||||
install_args = apk_log.read_text(encoding="utf-8")
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert "bootstrap_install_performed=1" in completed.stdout
|
||||
assert install_args.startswith("add --no-cache ")
|
||||
assert "docker-cli-buildx" in install_args
|
||||
@@ -19,9 +19,9 @@ from typing import Any, Sequence
|
||||
|
||||
|
||||
SCHEMA_VERSION = "awoooi_runtime_image_mirror_policy_v1"
|
||||
RECEIPT_SCHEMA_VERSION = "awoooi_runtime_image_mirror_receipt_v1"
|
||||
RECEIPT_SCHEMA_VERSION = "awoooi_runtime_image_mirror_receipt_v2"
|
||||
STAGING_SCHEMA_VERSION = "awoooi_runtime_image_mirror_staging_policy_v1"
|
||||
STAGING_RECEIPT_SCHEMA_VERSION = "awoooi_runtime_image_mirror_staging_receipt_v1"
|
||||
STAGING_RECEIPT_SCHEMA_VERSION = "awoooi_runtime_image_mirror_staging_receipt_v2"
|
||||
ALLOWED_KINDS = {"deployment", "daemonset", "statefulset"}
|
||||
ALLOWED_CONTAINER_KINDS = {"container", "init_container"}
|
||||
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
@@ -665,16 +665,25 @@ def mirror_images(
|
||||
known_hosts: Path,
|
||||
apply: bool,
|
||||
) -> dict[str, Any]:
|
||||
cache = RuntimeCache(policy, ssh_key, known_hosts)
|
||||
cache: RuntimeCache | None = None
|
||||
image_receipts: list[dict[str, Any]] = []
|
||||
missing_count = 0
|
||||
for image in policy.images:
|
||||
source_ref = cache.verify_source(image)
|
||||
source_fingerprint = "sha256:" + hashlib.sha256(source_ref.encode()).hexdigest()
|
||||
target_present = _target_digest_present(image)
|
||||
source_fingerprint = None
|
||||
source_cache_verified = False
|
||||
source_cache_verification_skipped_reason = "target_digest_already_verified"
|
||||
execution = "already_present"
|
||||
if not target_present:
|
||||
missing_count += 1
|
||||
if cache is None:
|
||||
cache = RuntimeCache(policy, ssh_key, known_hosts)
|
||||
source_ref = cache.verify_source(image)
|
||||
source_fingerprint = (
|
||||
"sha256:" + hashlib.sha256(source_ref.encode()).hexdigest()
|
||||
)
|
||||
source_cache_verified = True
|
||||
source_cache_verification_skipped_reason = None
|
||||
execution = "check_only_missing"
|
||||
if apply:
|
||||
source_preexisting = _local_image_present(source_ref)
|
||||
@@ -727,7 +736,10 @@ def mirror_images(
|
||||
"platform_digest": image.platform_digest,
|
||||
"target_registry_digest": image.target_registry_digest,
|
||||
"target_digest_ref": _target_digest_ref(image),
|
||||
"source_cache_verified": True,
|
||||
"source_cache_verified": source_cache_verified,
|
||||
"source_cache_verification_skipped_reason": (
|
||||
source_cache_verification_skipped_reason
|
||||
),
|
||||
"target_digest_verified": target_present,
|
||||
"execution": execution,
|
||||
}
|
||||
@@ -743,6 +755,7 @@ def mirror_images(
|
||||
"mode": "apply" if apply else "check",
|
||||
"external_pull_allowed": False,
|
||||
"source_cache_only": True,
|
||||
"source_cache_accessed": cache is not None,
|
||||
"candidate_count": len(image_receipts),
|
||||
"missing_before_count": missing_count,
|
||||
"verified_count": sum(
|
||||
@@ -766,20 +779,30 @@ def stage_images(
|
||||
known_hosts: Path,
|
||||
apply: bool,
|
||||
) -> dict[str, Any]:
|
||||
cache = RuntimeCache(policy, ssh_key, known_hosts)
|
||||
cache: RuntimeCache | None = None
|
||||
image_receipts: list[dict[str, Any]] = []
|
||||
missing_count = 0
|
||||
for image in policy.images:
|
||||
source_ref = cache.verify_source(image)
|
||||
source_fingerprint = "sha256:" + hashlib.sha256(source_ref.encode()).hexdigest()
|
||||
source_config_digest = cache.source_config_digest(image.platform_digest)
|
||||
if source_config_digest != image.source_config_digest:
|
||||
raise ControllerError("runtime_cache_config_digest_mismatch")
|
||||
|
||||
descriptor = _registry_descriptor(image.push_ref)
|
||||
source_fingerprint = None
|
||||
source_cache_verified = False
|
||||
source_cache_verification_skipped_reason = (
|
||||
"internal_registry_provenance_already_verified"
|
||||
)
|
||||
execution = "already_present"
|
||||
if descriptor is None:
|
||||
missing_count += 1
|
||||
if cache is None:
|
||||
cache = RuntimeCache(policy, ssh_key, known_hosts)
|
||||
source_ref = cache.verify_source(image)
|
||||
source_fingerprint = (
|
||||
"sha256:" + hashlib.sha256(source_ref.encode()).hexdigest()
|
||||
)
|
||||
source_config_digest = cache.source_config_digest(image.platform_digest)
|
||||
if source_config_digest != image.source_config_digest:
|
||||
raise ControllerError("runtime_cache_config_digest_mismatch")
|
||||
source_cache_verified = True
|
||||
source_cache_verification_skipped_reason = None
|
||||
execution = "check_only_missing"
|
||||
if apply:
|
||||
source_preexisting = _local_image_present(source_ref)
|
||||
@@ -836,7 +859,10 @@ def stage_images(
|
||||
raise ControllerError("internal_registry_descriptor_missing")
|
||||
execution = "staged_from_runtime_cache"
|
||||
|
||||
if descriptor is not None and descriptor.config_digest != source_config_digest:
|
||||
if (
|
||||
descriptor is not None
|
||||
and descriptor.config_digest != image.source_config_digest
|
||||
):
|
||||
raise ControllerError("internal_registry_provenance_mismatch")
|
||||
target_digest_ref = None
|
||||
runtime_ref = None
|
||||
@@ -858,13 +884,16 @@ def stage_images(
|
||||
"source_ref_fingerprint": source_fingerprint,
|
||||
"source_index_digest": image.source_index_digest,
|
||||
"platform_digest": image.platform_digest,
|
||||
"source_config_digest": source_config_digest,
|
||||
"source_config_digest": image.source_config_digest,
|
||||
"target_registry_digest": (
|
||||
descriptor.digest if descriptor is not None else None
|
||||
),
|
||||
"target_digest_ref": target_digest_ref,
|
||||
"runtime_ref": runtime_ref,
|
||||
"source_cache_verified": True,
|
||||
"source_cache_verified": source_cache_verified,
|
||||
"source_cache_verification_skipped_reason": (
|
||||
source_cache_verification_skipped_reason
|
||||
),
|
||||
"target_digest_verified": target_verified,
|
||||
"provenance_config_digest_match": descriptor is not None,
|
||||
"execution": execution,
|
||||
@@ -889,6 +918,7 @@ def stage_images(
|
||||
"risk_level": policy.risk_level,
|
||||
"external_pull_allowed": False,
|
||||
"source_cache_only": True,
|
||||
"source_cache_accessed": cache is not None,
|
||||
"candidate_count": len(image_receipts),
|
||||
"missing_before_count": missing_count,
|
||||
"verified_count": sum(
|
||||
|
||||
@@ -46,6 +46,10 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
|
||||
policy = controller.load_staging_policy(STAGING_POLICY_PATH)
|
||||
return replace(policy, images=(policy.images[0],))
|
||||
|
||||
def _single_image_policy(self):
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
return replace(policy, images=(policy.images[0],))
|
||||
|
||||
def _image_with_availability_guard(self):
|
||||
image = controller.load_policy(POLICY_PATH).images[0]
|
||||
return replace(
|
||||
@@ -904,12 +908,12 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
|
||||
controller.RuntimeCache,
|
||||
"verify_source",
|
||||
return_value="quay.io/argoproj/argocd:v3.3.6",
|
||||
),
|
||||
) as verify_source,
|
||||
patch.object(
|
||||
controller.RuntimeCache,
|
||||
"source_config_digest",
|
||||
return_value=image.source_config_digest,
|
||||
),
|
||||
) as source_config_digest,
|
||||
patch.object(controller.RuntimeCache, "export") as export,
|
||||
patch.object(
|
||||
controller, "_registry_descriptor", return_value=descriptor
|
||||
@@ -928,11 +932,85 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(receipt["state"], "verified")
|
||||
self.assertEqual(receipt["missing_before_count"], 0)
|
||||
self.assertFalse(receipt["source_cache_accessed"])
|
||||
self.assertEqual(receipt["images"][0]["execution"], "already_present")
|
||||
self.assertTrue(receipt["images"][0]["provenance_config_digest_match"])
|
||||
self.assertFalse(receipt["images"][0]["source_cache_verified"])
|
||||
self.assertEqual(
|
||||
receipt["images"][0]["source_cache_verification_skipped_reason"],
|
||||
"internal_registry_provenance_already_verified",
|
||||
)
|
||||
verify_source.assert_not_called()
|
||||
source_config_digest.assert_not_called()
|
||||
export.assert_not_called()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_mirror_reuses_verified_target_digest_without_runtime_cache(self) -> None:
|
||||
policy = self._single_image_policy()
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
receipt_path = Path(temp) / "receipt.json"
|
||||
with (
|
||||
patch.object(
|
||||
controller.RuntimeCache,
|
||||
"verify_source",
|
||||
return_value="quay.io/example/image:v1",
|
||||
) as verify_source,
|
||||
patch.object(controller.RuntimeCache, "export") as export,
|
||||
patch.object(controller, "_target_digest_present", return_value=True),
|
||||
patch.object(controller, "_run") as run,
|
||||
):
|
||||
receipt = controller.mirror_images(
|
||||
policy,
|
||||
trace_id="aia-p0-006-02a-existing",
|
||||
receipt_path=receipt_path,
|
||||
ssh_key=Path("/unused/key"),
|
||||
known_hosts=Path("/unused/known-hosts"),
|
||||
apply=True,
|
||||
)
|
||||
|
||||
self.assertEqual(receipt["state"], "verified")
|
||||
self.assertEqual(receipt["missing_before_count"], 0)
|
||||
self.assertFalse(receipt["source_cache_accessed"])
|
||||
self.assertEqual(receipt["images"][0]["execution"], "already_present")
|
||||
self.assertFalse(receipt["images"][0]["source_cache_verified"])
|
||||
self.assertEqual(
|
||||
receipt["images"][0]["source_cache_verification_skipped_reason"],
|
||||
"target_digest_already_verified",
|
||||
)
|
||||
verify_source.assert_not_called()
|
||||
export.assert_not_called()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_mirror_missing_target_still_verifies_runtime_cache(self) -> None:
|
||||
policy = self._single_image_policy()
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
receipt_path = Path(temp) / "receipt.json"
|
||||
with (
|
||||
patch.object(
|
||||
controller.RuntimeCache,
|
||||
"verify_source",
|
||||
return_value="quay.io/example/image:v1",
|
||||
) as verify_source,
|
||||
patch.object(controller.RuntimeCache, "export") as export,
|
||||
patch.object(controller, "_target_digest_present", return_value=False),
|
||||
):
|
||||
receipt = controller.mirror_images(
|
||||
policy,
|
||||
trace_id="aia-p0-006-02a-missing-check",
|
||||
receipt_path=receipt_path,
|
||||
ssh_key=Path("/unused/key"),
|
||||
known_hosts=Path("/unused/known-hosts"),
|
||||
apply=False,
|
||||
)
|
||||
|
||||
self.assertEqual(receipt["state"], "blocked_with_safe_next_action")
|
||||
self.assertEqual(receipt["missing_before_count"], 1)
|
||||
self.assertTrue(receipt["source_cache_accessed"])
|
||||
self.assertEqual(receipt["images"][0]["execution"], "check_only_missing")
|
||||
self.assertTrue(receipt["images"][0]["source_cache_verified"])
|
||||
verify_source.assert_called_once_with(policy.images[0])
|
||||
export.assert_not_called()
|
||||
|
||||
def test_staging_exports_cached_source_and_verifies_remote_provenance(
|
||||
self,
|
||||
) -> None:
|
||||
@@ -985,6 +1063,7 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(receipt["state"], "verified")
|
||||
self.assertEqual(receipt["missing_before_count"], 1)
|
||||
self.assertTrue(receipt["source_cache_accessed"])
|
||||
self.assertEqual(receipt["images"][0]["execution"], "staged_from_runtime_cache")
|
||||
self.assertEqual(
|
||||
receipt["images"][0]["target_registry_digest"], descriptor.digest
|
||||
|
||||
Reference in New Issue
Block a user