Merge remote-tracking branch 'origin/main' into codex/p0-obs-001-20260714
This commit is contained in:
@@ -417,116 +417,18 @@ from pathlib import Path
|
||||
envelope = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
source_revision = envelope["sourceRevision"]
|
||||
manifest_sha256 = envelope["manifestSha256"]
|
||||
fixed_names = [row["name"] for row in envelope["files"]]
|
||||
fixed_rows = ",\n ".join("'" + name.replace("'", "''") + "'" for name in fixed_names)
|
||||
stage_identity = f"check||||{source_revision}"
|
||||
stage_token = hashlib.sha256(stage_identity.encode("utf-8")).hexdigest()[:20]
|
||||
script = r'''$ErrorActionPreference = "Stop"
|
||||
$agentRoot = "C:\Wooo\Agent99"
|
||||
$binDir = Join-Path $agentRoot "bin"
|
||||
$manifestPath = Join-Path $agentRoot "state\runtime-manifest.json"
|
||||
$fixedFiles = @(
|
||||
__FIXED_ROWS__
|
||||
)
|
||||
$current = [pscustomobject]@{
|
||||
exists = $false
|
||||
sourceRevision = ""
|
||||
runtimeMatched = $false
|
||||
recordedRuntimeMatched = $false
|
||||
fileCount = 0
|
||||
mismatchCount = -1
|
||||
recordedMismatchCount = -1
|
||||
parseError = ""
|
||||
}
|
||||
if (Test-Path -LiteralPath $manifestPath -PathType Leaf) {
|
||||
try {
|
||||
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
||||
$rows = @($manifest.files)
|
||||
$byName = @{}
|
||||
$shapeValid = [bool]($rows.Count -eq $fixedFiles.Count)
|
||||
foreach ($row in $rows) {
|
||||
$name = [string]$row.name
|
||||
if (-not $name -or $byName.ContainsKey($name)) {
|
||||
$shapeValid = $false
|
||||
continue
|
||||
}
|
||||
$byName[$name] = $row
|
||||
}
|
||||
$actualMismatchCount = 0
|
||||
foreach ($name in $fixedFiles) {
|
||||
if (-not $byName.ContainsKey($name)) {
|
||||
$shapeValid = $false
|
||||
$actualMismatchCount += 1
|
||||
continue
|
||||
}
|
||||
$recordedSha = ([string]$byName[$name].runtimeSha256).ToLowerInvariant()
|
||||
$runtimePath = Join-Path $binDir $name
|
||||
$actualSha = if (Test-Path -LiteralPath $runtimePath -PathType Leaf) {
|
||||
(Get-FileHash -LiteralPath $runtimePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
} else {
|
||||
"missing"
|
||||
}
|
||||
if (-not $recordedSha -or $recordedSha -ne $actualSha) {
|
||||
$actualMismatchCount += 1
|
||||
}
|
||||
}
|
||||
$runtimeMatched = [bool](
|
||||
$shapeValid -and
|
||||
[bool]$manifest.runtimeMatched -and
|
||||
[int]$manifest.fileCount -eq $fixedFiles.Count -and
|
||||
[int]$manifest.mismatchCount -eq 0 -and
|
||||
$actualMismatchCount -eq 0
|
||||
)
|
||||
$current = [pscustomobject]@{
|
||||
exists = $true
|
||||
sourceRevision = [string]$manifest.sourceRevision
|
||||
runtimeMatched = $runtimeMatched
|
||||
recordedRuntimeMatched = [bool]$manifest.runtimeMatched
|
||||
fileCount = [int]$manifest.fileCount
|
||||
mismatchCount = $actualMismatchCount
|
||||
recordedMismatchCount = [int]$manifest.mismatchCount
|
||||
parseError = ""
|
||||
}
|
||||
} catch {
|
||||
$current.exists = $true
|
||||
$current.parseError = $_.Exception.GetType().Name
|
||||
}
|
||||
}
|
||||
[pscustomobject]@{
|
||||
schemaVersion = "agent99_remote_atomic_deploy_receipt_v1"
|
||||
status = "check_ready"
|
||||
mode = "check"
|
||||
sourceRevision = "__SOURCE_REVISION__"
|
||||
expectedRuntimeFileCount = 14
|
||||
manifestSha256 = "__MANIFEST_SHA256__"
|
||||
plannedStagingPath = "C:\Wooo\Agent99\deploy\remote-source-__STAGE_TOKEN__"
|
||||
runtimeManifest = $current
|
||||
livePreflight = [pscustomobject]@{ executed = $false; reason = "apply_only_after_validate_only" }
|
||||
transportPayloadMode = "control_command_no_stdin"
|
||||
transientTransportPayloadStored = $false
|
||||
transientTransportPayloadDeletedBeforeReceiver = $false
|
||||
transportPayloadContainsSecrets = $false
|
||||
operationBoundaries = [pscustomobject]@{
|
||||
remoteWritePerformed = $false
|
||||
liveRuntimeWritePerformed = $false
|
||||
livePromotionAttempted = $false
|
||||
livePromotionPerformed = $false
|
||||
secretValueRead = $false
|
||||
privateKeyValueRead = $false
|
||||
tokenValueRead = $false
|
||||
environmentSecretRead = $false
|
||||
uiInteraction = $false
|
||||
vmPowerChange = $false
|
||||
hostReboot = $false
|
||||
serviceRestart = $false
|
||||
scheduledTaskRestart = $false
|
||||
scheduledTaskModification = $false
|
||||
}
|
||||
nextSafeAction = "rerun_with_apply_and_trace_run_work_item_after_source_commit_and_transport_check"
|
||||
} | ConvertTo-Json -Compress -Depth 8'''
|
||||
script = r'''$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue'
|
||||
$a='C:\Wooo\Agent99';$b=Join-Path $a 'bin';$p=Join-Path $a 'state\runtime-manifest.json'
|
||||
$r=[ordered]@{exists=$false;sourceRevision='';runtimeMatched=$false;recordedRuntimeMatched=$false;fileCount=0;mismatchCount=-1;recordedMismatchCount=-1;parseError=''}
|
||||
if(Test-Path -LiteralPath $p -PathType Leaf){try{$m=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json;$rows=@($m.files);$seen=@{};$bad=0;foreach($x in $rows){$n=[string]$x.name;if(!$n-or[IO.Path]::GetFileName($n)-ne$n-or$seen.ContainsKey($n)){$bad++;continue};$seen[$n]=1;$q=Join-Path $b $n;$h=if(Test-Path -LiteralPath $q -PathType Leaf){(Get-FileHash -LiteralPath $q -Algorithm SHA256).Hash.ToLowerInvariant()}else{'missing'};if(([string]$x.runtimeSha256).ToLowerInvariant()-ne$h){$bad++}};$r.exists=$true;$r.sourceRevision=[string]$m.sourceRevision;$r.recordedRuntimeMatched=[bool]$m.runtimeMatched;$r.fileCount=[int]$m.fileCount;$r.mismatchCount=$bad;$r.recordedMismatchCount=[int]$m.mismatchCount;$r.runtimeMatched=[bool]($m.runtimeMatched-and$m.fileCount-eq 14-and$m.mismatchCount-eq 0-and$rows.Count-eq 14-and$seen.Count-eq 14-and$bad-eq 0)}catch{$r.exists=$true;$r.parseError=$_.Exception.GetType().Name}}
|
||||
$c=[ordered]@{executed=$false;ok=$false;exitCode=-1;errorType=''}
|
||||
try{& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path $b 'agent99-contract-check.ps1') -SourceRoot $b 2>$null|Out-Null;$c.executed=$true;$c.exitCode=$LASTEXITCODE;$c.ok=[bool]($c.exitCode-eq 0)}catch{$c.executed=$true;$c.errorType=$_.Exception.GetType().Name}
|
||||
$z=[ordered]@{};foreach($n in 'remoteWritePerformed,liveRuntimeWritePerformed,livePromotionAttempted,livePromotionPerformed,secretValueRead,privateKeyValueRead,tokenValueRead,environmentSecretRead,uiInteraction,vmPowerChange,hostReboot,serviceRestart,scheduledTaskRestart,scheduledTaskModification'.Split(',')){$z[$n]=$false}
|
||||
[ordered]@{schemaVersion='agent99_remote_atomic_deploy_receipt_v1';status='check_ready';mode='check';sourceRevision='__SOURCE_REVISION__';expectedRuntimeFileCount=14;manifestSha256='__MANIFEST_SHA256__';plannedStagingPath='C:\Wooo\Agent99\deploy\remote-source-__STAGE_TOKEN__';runtimeManifest=[pscustomobject]$r;runtimeContract=[pscustomobject]$c;livePreflight=[ordered]@{executed=$false;reason='apply_only_after_validate_only'};transportPayloadMode='control_command_no_stdin';transientTransportPayloadStored=$false;transientTransportPayloadDeletedBeforeReceiver=$false;transportPayloadContainsSecrets=$false;operationBoundaries=$z;nextSafeAction='rerun_with_apply_and_trace_run_work_item_after_source_commit_and_transport_check'}|ConvertTo-Json -Compress -Depth 8'''
|
||||
script = (
|
||||
script.replace("__FIXED_ROWS__", fixed_rows)
|
||||
.replace("__SOURCE_REVISION__", source_revision)
|
||||
script.replace("__SOURCE_REVISION__", source_revision)
|
||||
.replace("__MANIFEST_SHA256__", manifest_sha256)
|
||||
.replace("__STAGE_TOKEN__", stage_token)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
@@ -658,11 +659,31 @@ def test_check_mode_uses_bounded_control_command_without_ssh_stdin(
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert '"status":"check_ready"' in result.stdout
|
||||
args = args_path.read_text(encoding="utf-8")
|
||||
remote_command = args.splitlines()[-1]
|
||||
assert "BatchMode=yes" in args
|
||||
assert "StrictHostKeyChecking=yes" in args
|
||||
assert "PasswordAuthentication=no" in args
|
||||
assert "Administrator@192.168.0.99" in args
|
||||
assert str(identity) in args
|
||||
assert len(remote_command) < 8191
|
||||
encoded_command = remote_command.split("-EncodedCommand ", 1)[1]
|
||||
check_script = base64.b64decode(encoded_command).decode("utf-16le")
|
||||
assert "agent99-contract-check.ps1" in check_script
|
||||
assert "Get-FileHash" in check_script
|
||||
assert "runtimeContract" in check_script
|
||||
assert check_script.count("ProgressPreference") == 1
|
||||
assert "remoteWritePerformed" in check_script
|
||||
assert "$z[$n]=$false" in check_script
|
||||
for write_command in (
|
||||
"New-Item",
|
||||
"Set-Content",
|
||||
"Add-Content",
|
||||
"Out-File",
|
||||
"Remove-Item",
|
||||
"Move-Item",
|
||||
"Copy-Item",
|
||||
):
|
||||
assert write_command not in check_script
|
||||
stdin_bytes = int(bytes_path.read_text(encoding="utf-8"))
|
||||
assert stdin_bytes == 0
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ from typing import Any, Sequence
|
||||
|
||||
SCHEMA_VERSION = "awoooi_runtime_image_mirror_policy_v1"
|
||||
RECEIPT_SCHEMA_VERSION = "awoooi_runtime_image_mirror_receipt_v1"
|
||||
STAGING_SCHEMA_VERSION = "awoooi_runtime_image_mirror_staging_policy_v1"
|
||||
STAGING_RECEIPT_SCHEMA_VERSION = "awoooi_runtime_image_mirror_staging_receipt_v1"
|
||||
ALLOWED_KINDS = {"deployment", "daemonset", "statefulset"}
|
||||
ALLOWED_CONTAINER_KINDS = {"container", "init_container"}
|
||||
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
|
||||
TRACE_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{3,160}$")
|
||||
@@ -35,6 +38,7 @@ class Workload:
|
||||
namespace: str
|
||||
name: str
|
||||
container: str
|
||||
container_kind: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -48,6 +52,17 @@ class ImagePolicy:
|
||||
workload: Workload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StagingImage:
|
||||
asset_id: str
|
||||
source_index_digest: str
|
||||
platform_digest: str
|
||||
source_config_digest: str
|
||||
push_ref: str
|
||||
runtime_repository: str
|
||||
workload: Workload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Policy:
|
||||
work_item_id: str
|
||||
@@ -59,6 +74,24 @@ class Policy:
|
||||
checksum: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StagingPolicy:
|
||||
work_item_id: str
|
||||
risk_level: str
|
||||
cache_host: str
|
||||
cache_user: str
|
||||
platform: str
|
||||
runtime_registry_prefix: str
|
||||
images: tuple[StagingImage, ...]
|
||||
checksum: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistryDescriptor:
|
||||
digest: str
|
||||
config_digest: str
|
||||
|
||||
|
||||
def _require_text(value: Any, label: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ControllerError(f"invalid_{label}")
|
||||
@@ -119,7 +152,7 @@ def load_policy(path: Path) -> Policy:
|
||||
raise ControllerError("image_policy_empty")
|
||||
images: list[ImagePolicy] = []
|
||||
asset_ids: set[str] = set()
|
||||
workloads: set[tuple[str, str, str, str]] = set()
|
||||
workloads: set[tuple[str, str, str, str, str]] = set()
|
||||
for row in image_rows:
|
||||
if not isinstance(row, dict) or not isinstance(row.get("workload"), dict):
|
||||
raise ControllerError("image_policy_invalid")
|
||||
@@ -152,12 +185,18 @@ def load_policy(path: Path) -> Policy:
|
||||
namespace=_require_name(workload_row.get("namespace"), "namespace"),
|
||||
name=_require_name(workload_row.get("name"), "workload_name"),
|
||||
container=_require_name(workload_row.get("container"), "container"),
|
||||
container_kind=_require_text(
|
||||
workload_row.get("container_kind", "container"), "container_kind"
|
||||
).lower(),
|
||||
)
|
||||
if workload.container_kind not in ALLOWED_CONTAINER_KINDS:
|
||||
raise ControllerError("container_kind_not_allowed")
|
||||
workload_key = (
|
||||
workload.kind,
|
||||
workload.namespace,
|
||||
workload.name,
|
||||
workload.container,
|
||||
workload.container_kind,
|
||||
)
|
||||
if workload_key in workloads:
|
||||
raise ControllerError("duplicate_workload_target")
|
||||
@@ -185,6 +224,121 @@ def load_policy(path: Path) -> Policy:
|
||||
)
|
||||
|
||||
|
||||
def load_staging_policy(path: Path) -> StagingPolicy:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ControllerError("staging_policy_unreadable") from exc
|
||||
if (
|
||||
not isinstance(payload, dict)
|
||||
or payload.get("schema_version") != STAGING_SCHEMA_VERSION
|
||||
):
|
||||
raise ControllerError("staging_policy_schema_invalid")
|
||||
|
||||
source = payload.get("source_contract")
|
||||
target = payload.get("target_contract")
|
||||
if not isinstance(source, dict) or not isinstance(target, dict):
|
||||
raise ControllerError("staging_policy_contract_missing")
|
||||
if source.get("origin") != "runtime_cache_only":
|
||||
raise ControllerError("runtime_cache_only_required")
|
||||
if source.get("external_pull_allowed") is not False:
|
||||
raise ControllerError("external_pull_must_be_disabled")
|
||||
if source.get("platform") != "linux/amd64":
|
||||
raise ControllerError("unsupported_runtime_platform")
|
||||
if target.get("digest_pin_required") is not True:
|
||||
raise ControllerError("digest_pin_required")
|
||||
|
||||
push_prefix = _require_text(target.get("push_registry_prefix"), "push_prefix")
|
||||
runtime_prefix = _require_text(
|
||||
target.get("runtime_registry_prefix"), "runtime_prefix"
|
||||
)
|
||||
if push_prefix != "registry.wooo.work/awoooi/runtime-mirror/":
|
||||
raise ControllerError("push_registry_not_internal")
|
||||
if runtime_prefix != "192.168.0.110:5000/awoooi/runtime-mirror/":
|
||||
raise ControllerError("runtime_registry_not_internal")
|
||||
|
||||
image_rows = payload.get("images")
|
||||
if not isinstance(image_rows, list) or not image_rows:
|
||||
raise ControllerError("staging_image_policy_empty")
|
||||
images: list[StagingImage] = []
|
||||
asset_ids: set[str] = set()
|
||||
workloads: set[tuple[str, str, str, str, str]] = set()
|
||||
for row in image_rows:
|
||||
if not isinstance(row, dict) or not isinstance(row.get("workload"), dict):
|
||||
raise ControllerError("staging_image_policy_invalid")
|
||||
asset_id = _require_text(row.get("asset_id"), "asset_id")
|
||||
if asset_id in asset_ids:
|
||||
raise ControllerError("duplicate_asset_id")
|
||||
asset_ids.add(asset_id)
|
||||
|
||||
push_ref = _require_text(row.get("push_ref"), "push_ref")
|
||||
if not push_ref.startswith(push_prefix) or "@" in push_ref:
|
||||
raise ControllerError("push_ref_invalid")
|
||||
push_suffix = push_ref.removeprefix(push_prefix)
|
||||
if ":" not in push_suffix:
|
||||
raise ControllerError("push_ref_tag_required")
|
||||
runtime_repository, push_tag = push_suffix.rsplit(":", 1)
|
||||
if not runtime_repository or any(
|
||||
not NAME_PATTERN.fullmatch(part) for part in runtime_repository.split("/")
|
||||
):
|
||||
raise ControllerError("runtime_repository_invalid")
|
||||
_require_name(push_tag, "push_tag")
|
||||
|
||||
workload_row = row["workload"]
|
||||
kind = _require_text(workload_row.get("kind"), "workload_kind").lower()
|
||||
if kind not in ALLOWED_KINDS:
|
||||
raise ControllerError("workload_kind_not_allowed")
|
||||
workload = Workload(
|
||||
kind=kind,
|
||||
namespace=_require_name(workload_row.get("namespace"), "namespace"),
|
||||
name=_require_name(workload_row.get("name"), "workload_name"),
|
||||
container=_require_name(workload_row.get("container"), "container"),
|
||||
container_kind=_require_text(
|
||||
workload_row.get("container_kind", "container"), "container_kind"
|
||||
).lower(),
|
||||
)
|
||||
if workload.container_kind not in ALLOWED_CONTAINER_KINDS:
|
||||
raise ControllerError("container_kind_not_allowed")
|
||||
workload_key = (
|
||||
workload.kind,
|
||||
workload.namespace,
|
||||
workload.name,
|
||||
workload.container,
|
||||
workload.container_kind,
|
||||
)
|
||||
if workload_key in workloads:
|
||||
raise ControllerError("duplicate_workload_target")
|
||||
workloads.add(workload_key)
|
||||
images.append(
|
||||
StagingImage(
|
||||
asset_id=asset_id,
|
||||
source_index_digest=_require_digest(
|
||||
row.get("source_index_digest"), "source_index_digest"
|
||||
),
|
||||
platform_digest=_require_digest(
|
||||
row.get("platform_digest"), "platform_digest"
|
||||
),
|
||||
source_config_digest=_require_digest(
|
||||
row.get("source_config_digest"), "source_config_digest"
|
||||
),
|
||||
push_ref=push_ref,
|
||||
runtime_repository=runtime_repository,
|
||||
workload=workload,
|
||||
)
|
||||
)
|
||||
|
||||
return StagingPolicy(
|
||||
work_item_id=_require_text(payload.get("work_item_id"), "work_item_id"),
|
||||
risk_level=_require_text(payload.get("risk_level"), "risk_level"),
|
||||
cache_host=_require_text(source.get("cache_host"), "cache_host"),
|
||||
cache_user=_require_name(source.get("cache_user"), "cache_user"),
|
||||
platform="linux/amd64",
|
||||
runtime_registry_prefix=runtime_prefix,
|
||||
images=tuple(images),
|
||||
checksum=_policy_checksum(payload),
|
||||
)
|
||||
|
||||
|
||||
def _run(
|
||||
command: Sequence[str],
|
||||
*,
|
||||
@@ -214,7 +368,9 @@ def _run(
|
||||
|
||||
|
||||
class RuntimeCache:
|
||||
def __init__(self, policy: Policy, ssh_key: Path, known_hosts: Path) -> None:
|
||||
def __init__(
|
||||
self, policy: Policy | StagingPolicy, ssh_key: Path, known_hosts: Path
|
||||
) -> None:
|
||||
self.policy = policy
|
||||
self.ssh = [
|
||||
"ssh",
|
||||
@@ -246,7 +402,7 @@ class RuntimeCache:
|
||||
raise ControllerError("runtime_cache_digest_missing")
|
||||
return sorted(set(matches))[0]
|
||||
|
||||
def verify_source(self, image: ImagePolicy) -> str:
|
||||
def verify_source(self, image: ImagePolicy | StagingImage) -> str:
|
||||
source_ref = self.source_ref(image.source_index_digest)
|
||||
check = _run(self._command("images", "check", f"name=={source_ref}"))
|
||||
if "complete" not in (check.stdout or ""):
|
||||
@@ -269,6 +425,17 @@ class RuntimeCache:
|
||||
raise ControllerError("runtime_cache_platform_digest_mismatch")
|
||||
return source_ref
|
||||
|
||||
def source_config_digest(self, platform_digest: str) -> str:
|
||||
content = _run(self._command("content", "get", platform_digest)).stdout
|
||||
try:
|
||||
manifest = json.loads(content or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ControllerError("runtime_cache_platform_manifest_invalid") from exc
|
||||
config = manifest.get("config")
|
||||
if not isinstance(config, dict):
|
||||
raise ControllerError("runtime_cache_config_digest_missing")
|
||||
return _require_digest(config.get("digest"), "runtime_cache_config_digest")
|
||||
|
||||
def export(self, source_ref: str, output: Path) -> None:
|
||||
with output.open("wb") as handle:
|
||||
completed = subprocess.run(
|
||||
@@ -296,7 +463,7 @@ def _target_digest_ref(image: ImagePolicy) -> str:
|
||||
return f"{_target_repository(image.push_ref)}@{image.target_registry_digest}"
|
||||
|
||||
|
||||
def _target_digest_present(image: ImagePolicy) -> bool:
|
||||
def _registry_digest_present(digest_ref: str) -> bool:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
@@ -304,7 +471,7 @@ def _target_digest_present(image: ImagePolicy) -> bool:
|
||||
"manifest",
|
||||
"inspect",
|
||||
"--insecure",
|
||||
_target_digest_ref(image),
|
||||
digest_ref,
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
@@ -316,6 +483,52 @@ def _target_digest_present(image: ImagePolicy) -> bool:
|
||||
return completed.returncode == 0
|
||||
|
||||
|
||||
def _target_digest_present(image: ImagePolicy) -> bool:
|
||||
return _registry_digest_present(_target_digest_ref(image))
|
||||
|
||||
|
||||
def _registry_descriptor(image_ref: str) -> RegistryDescriptor | None:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["docker", "manifest", "inspect", "--verbose", image_ref],
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=20,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise ControllerError("internal_registry_descriptor_unavailable") from exc
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(completed.stdout or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ControllerError("internal_registry_descriptor_invalid") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ControllerError("internal_registry_descriptor_invalid")
|
||||
descriptor = payload.get("Descriptor")
|
||||
manifest = payload.get("SchemaV2Manifest")
|
||||
if not isinstance(descriptor, dict) or not isinstance(manifest, dict):
|
||||
raise ControllerError("internal_registry_descriptor_invalid")
|
||||
config = manifest.get("config")
|
||||
if not isinstance(config, dict):
|
||||
raise ControllerError("internal_registry_descriptor_invalid")
|
||||
return RegistryDescriptor(
|
||||
digest=_require_digest(
|
||||
descriptor.get("digest"), "internal_registry_descriptor_digest"
|
||||
),
|
||||
config_digest=_require_digest(
|
||||
config.get("digest"), "internal_registry_config_digest"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _local_config_digest(image_ref: str) -> str:
|
||||
output = _run(["docker", "image", "inspect", "--format={{.Id}}", image_ref]).stdout
|
||||
return _require_digest((output or "").strip(), "local_config_digest")
|
||||
|
||||
|
||||
def _local_image_present(image_ref: str) -> bool:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
@@ -437,6 +650,127 @@ def mirror_images(
|
||||
return receipt
|
||||
|
||||
|
||||
def stage_images(
|
||||
policy: StagingPolicy,
|
||||
*,
|
||||
trace_id: str,
|
||||
receipt_path: Path,
|
||||
ssh_key: Path,
|
||||
known_hosts: Path,
|
||||
apply: bool,
|
||||
) -> dict[str, Any]:
|
||||
cache = RuntimeCache(policy, ssh_key, known_hosts)
|
||||
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)
|
||||
execution = "already_present"
|
||||
if descriptor is None:
|
||||
missing_count += 1
|
||||
execution = "check_only_missing"
|
||||
if apply:
|
||||
source_preexisting = _local_image_present(source_ref)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="awoooi-runtime-image-stage-"
|
||||
) as temp:
|
||||
archive = Path(temp) / "image.oci.tar"
|
||||
cache.export(source_ref, archive)
|
||||
if not _archive_contains_digest(archive, image.platform_digest):
|
||||
raise ControllerError("runtime_cache_export_digest_missing")
|
||||
_run(
|
||||
["docker", "load", "--input", str(archive)],
|
||||
quiet=True,
|
||||
)
|
||||
if _local_config_digest(source_ref) != source_config_digest:
|
||||
raise ControllerError("local_image_config_digest_mismatch")
|
||||
_run(
|
||||
["docker", "tag", source_ref, image.push_ref],
|
||||
quiet=True,
|
||||
)
|
||||
_run(["docker", "push", image.push_ref], quiet=True)
|
||||
finally:
|
||||
_remove_local_image(image.push_ref)
|
||||
if not source_preexisting:
|
||||
_remove_local_image(source_ref)
|
||||
descriptor = _registry_descriptor(image.push_ref)
|
||||
if descriptor is None:
|
||||
raise ControllerError("internal_registry_descriptor_missing")
|
||||
execution = "staged_from_runtime_cache"
|
||||
|
||||
if descriptor is not None and descriptor.config_digest != source_config_digest:
|
||||
raise ControllerError("internal_registry_provenance_mismatch")
|
||||
target_digest_ref = None
|
||||
runtime_ref = None
|
||||
target_verified = False
|
||||
if descriptor is not None:
|
||||
target_digest_ref = (
|
||||
f"{_target_repository(image.push_ref)}@{descriptor.digest}"
|
||||
)
|
||||
runtime_ref = (
|
||||
f"{policy.runtime_registry_prefix}{image.runtime_repository}"
|
||||
f"@{descriptor.digest}"
|
||||
)
|
||||
target_verified = _registry_digest_present(target_digest_ref)
|
||||
if apply and not target_verified:
|
||||
raise ControllerError("internal_registry_digest_verifier_failed")
|
||||
image_receipts.append(
|
||||
{
|
||||
"asset_id": image.asset_id,
|
||||
"source_ref_fingerprint": source_fingerprint,
|
||||
"source_index_digest": image.source_index_digest,
|
||||
"platform_digest": image.platform_digest,
|
||||
"source_config_digest": 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,
|
||||
"target_digest_verified": target_verified,
|
||||
"provenance_config_digest_match": descriptor is not None,
|
||||
"execution": execution,
|
||||
"workload": {
|
||||
"kind": image.workload.kind,
|
||||
"namespace": image.workload.namespace,
|
||||
"name": image.workload.name,
|
||||
"container": image.workload.container,
|
||||
"container_kind": image.workload.container_kind,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
verified = all(row["target_digest_verified"] for row in image_receipts)
|
||||
receipt = {
|
||||
"schema_version": STAGING_RECEIPT_SCHEMA_VERSION,
|
||||
"trace_id": trace_id,
|
||||
"work_item_id": policy.work_item_id,
|
||||
"policy_checksum": policy.checksum,
|
||||
"stage": "runtime_cache_staging",
|
||||
"mode": "apply" if apply else "check",
|
||||
"risk_level": policy.risk_level,
|
||||
"external_pull_allowed": False,
|
||||
"source_cache_only": True,
|
||||
"candidate_count": len(image_receipts),
|
||||
"missing_before_count": missing_count,
|
||||
"verified_count": sum(
|
||||
1 for row in image_receipts if row["target_digest_verified"]
|
||||
),
|
||||
"state": "verified" if verified else "blocked_with_safe_next_action",
|
||||
"images": image_receipts,
|
||||
}
|
||||
receipt_path.write_text(
|
||||
json.dumps(receipt, ensure_ascii=True, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
return receipt
|
||||
|
||||
|
||||
class Kubernetes:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -476,13 +810,26 @@ class Kubernetes:
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ControllerError("workload_readback_invalid") from exc
|
||||
|
||||
@staticmethod
|
||||
def _spec_container_field(item: ImagePolicy) -> str:
|
||||
if item.workload.container_kind == "init_container":
|
||||
return "initContainers"
|
||||
return "containers"
|
||||
|
||||
@staticmethod
|
||||
def _status_container_field(item: ImagePolicy) -> str:
|
||||
if item.workload.container_kind == "init_container":
|
||||
return "initContainerStatuses"
|
||||
return "containerStatuses"
|
||||
|
||||
def current_image(self, item: ImagePolicy) -> str:
|
||||
workload = self.workload(item)
|
||||
container_field = self._spec_container_field(item)
|
||||
containers = (
|
||||
workload.get("spec", {})
|
||||
.get("template", {})
|
||||
.get("spec", {})
|
||||
.get("containers", [])
|
||||
.get(container_field, [])
|
||||
)
|
||||
values = [
|
||||
row.get("image")
|
||||
@@ -494,12 +841,13 @@ class Kubernetes:
|
||||
return values[0]
|
||||
|
||||
def patch_image(self, item: ImagePolicy, image_ref: str, *, dry_run: bool) -> None:
|
||||
container_field = self._spec_container_field(item)
|
||||
patch = json.dumps(
|
||||
{
|
||||
"spec": {
|
||||
"template": {
|
||||
"spec": {
|
||||
"containers": [
|
||||
container_field: [
|
||||
{"name": item.workload.container, "image": image_ref}
|
||||
]
|
||||
}
|
||||
@@ -566,7 +914,8 @@ class Kubernetes:
|
||||
raise ControllerError("workload_pods_missing")
|
||||
verified = 0
|
||||
for pod in active:
|
||||
statuses = pod.get("status", {}).get("containerStatuses", [])
|
||||
status_field = self._status_container_field(item)
|
||||
statuses = pod.get("status", {}).get(status_field, [])
|
||||
matches = [
|
||||
status
|
||||
for status in statuses
|
||||
@@ -576,7 +925,14 @@ class Kubernetes:
|
||||
if len(matches) != 1:
|
||||
raise ControllerError("pod_container_status_missing")
|
||||
status = matches[0]
|
||||
if status.get("ready") is not True:
|
||||
if item.workload.container_kind == "init_container":
|
||||
terminated = status.get("state", {}).get("terminated", {})
|
||||
completed = status.get("ready") is True or (
|
||||
isinstance(terminated, dict) and terminated.get("exitCode") == 0
|
||||
)
|
||||
if not completed:
|
||||
raise ControllerError("pod_init_container_not_completed")
|
||||
elif status.get("ready") is not True:
|
||||
raise ControllerError("pod_container_not_ready")
|
||||
image_id = status.get("imageID")
|
||||
if not isinstance(image_id, str) or not image_id.endswith(
|
||||
@@ -715,6 +1071,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
mirror.add_argument("--ssh-key", type=Path, required=True)
|
||||
mirror.add_argument("--known-hosts", type=Path, required=True)
|
||||
|
||||
stage = subparsers.add_parser("stage")
|
||||
stage.add_argument("--apply", action="store_true")
|
||||
stage.add_argument("--receipt", type=Path, required=True)
|
||||
stage.add_argument("--ssh-key", type=Path, required=True)
|
||||
stage.add_argument("--known-hosts", type=Path, required=True)
|
||||
|
||||
apply_parser = subparsers.add_parser("apply")
|
||||
apply_parser.add_argument("--apply", action="store_true")
|
||||
apply_parser.add_argument("--mirror-receipt", type=Path, required=True)
|
||||
@@ -727,7 +1089,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
policy = load_policy(args.policy)
|
||||
if args.command == "stage":
|
||||
staging_policy = load_staging_policy(args.policy)
|
||||
receipt = stage_images(
|
||||
staging_policy,
|
||||
trace_id=args.trace_id,
|
||||
receipt_path=args.receipt,
|
||||
ssh_key=args.ssh_key,
|
||||
known_hosts=args.known_hosts,
|
||||
apply=args.apply,
|
||||
)
|
||||
else:
|
||||
policy = load_policy(args.policy)
|
||||
if args.command == "mirror":
|
||||
receipt = mirror_images(
|
||||
policy,
|
||||
@@ -737,7 +1110,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
known_hosts=args.known_hosts,
|
||||
apply=args.apply,
|
||||
)
|
||||
else:
|
||||
elif args.command == "apply":
|
||||
receipt = apply_policy(
|
||||
policy,
|
||||
trace_id=args.trace_id,
|
||||
@@ -753,7 +1126,11 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": RECEIPT_SCHEMA_VERSION,
|
||||
"schema_version": (
|
||||
STAGING_RECEIPT_SCHEMA_VERSION
|
||||
if args.command == "stage"
|
||||
else RECEIPT_SCHEMA_VERSION
|
||||
),
|
||||
"state": "blocked_with_safe_next_action",
|
||||
"error_code": str(exc),
|
||||
"secret_value_exposed": False,
|
||||
|
||||
@@ -14,6 +14,7 @@ from unittest.mock import patch
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CONTROLLER_PATH = ROOT / "scripts/security/runtime_image_mirror_controller.py"
|
||||
POLICY_PATH = ROOT / "config/security/runtime-image-mirror-policy.json"
|
||||
STAGING_POLICY_PATH = ROOT / "config/security/runtime-image-mirror-staging-policy.json"
|
||||
|
||||
|
||||
def _load_controller():
|
||||
@@ -31,6 +32,15 @@ controller = _load_controller()
|
||||
|
||||
|
||||
class RuntimeImageMirrorControllerTest(unittest.TestCase):
|
||||
def _image_with_container_kind(self, container_kind: str):
|
||||
payload = json.loads(POLICY_PATH.read_text(encoding="utf-8"))
|
||||
payload["images"] = [payload["images"][0]]
|
||||
payload["images"][0]["workload"]["container_kind"] = container_kind
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "policy.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return controller.load_policy(path).images[0]
|
||||
|
||||
def test_policy_is_internal_digest_pinned_and_runtime_cache_only(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
raw = POLICY_PATH.read_text(encoding="utf-8")
|
||||
@@ -61,6 +71,37 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
|
||||
for image in policy.images
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(image.workload.container_kind == "container" for image in policy.images)
|
||||
)
|
||||
|
||||
def test_policy_loads_and_validates_init_container_kind(self) -> None:
|
||||
image = self._image_with_container_kind("init_container")
|
||||
|
||||
self.assertEqual(image.workload.container_kind, "init_container")
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
controller.ControllerError, "container_kind_not_allowed"
|
||||
):
|
||||
self._image_with_container_kind("ephemeral_container")
|
||||
|
||||
def test_staging_policy_is_runtime_cache_only_and_init_canary_scoped(
|
||||
self,
|
||||
) -> None:
|
||||
policy = controller.load_staging_policy(STAGING_POLICY_PATH)
|
||||
raw = STAGING_POLICY_PATH.read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(policy.work_item_id, "AIA-P0-006-02B")
|
||||
self.assertEqual(policy.risk_level, "high")
|
||||
self.assertEqual(len(policy.images), 1)
|
||||
image = policy.images[0]
|
||||
self.assertEqual(image.runtime_repository, "argocd")
|
||||
self.assertEqual(image.workload.name, "argocd-dex-server")
|
||||
self.assertEqual(image.workload.container, "copyutil")
|
||||
self.assertEqual(image.workload.container_kind, "init_container")
|
||||
self.assertNotIn("github.com", raw)
|
||||
self.assertNotIn("ghcr.io", raw)
|
||||
self.assertIn('"external_pull_allowed": false', raw)
|
||||
|
||||
def test_policy_rejects_external_pull_and_mutable_runtime_ref(self) -> None:
|
||||
payload = json.loads(POLICY_PATH.read_text(encoding="utf-8"))
|
||||
@@ -152,6 +193,8 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
|
||||
self.assertNotIn('"images", "pull"', controller_source)
|
||||
self.assertIn("Mirror Runtime Cache Images to Harbor", workflow)
|
||||
self.assertIn("Apply Runtime Image Mirror Policy", workflow)
|
||||
self.assertIn("runtime-image-mirror-staging-policy.json", workflow)
|
||||
self.assertIn("runtime_image_staging_stage=verified", workflow)
|
||||
self.assertIn("runtime_image_mirror_controller.py", workflow)
|
||||
mirror_block = workflow[
|
||||
workflow.index(
|
||||
@@ -160,6 +203,145 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
|
||||
]
|
||||
self.assertNotIn("HARBOR_PASSWORD", mirror_block)
|
||||
|
||||
def test_staging_reuses_verified_internal_artifact_without_export(self) -> None:
|
||||
policy = controller.load_staging_policy(STAGING_POLICY_PATH)
|
||||
image = policy.images[0]
|
||||
descriptor = controller.RegistryDescriptor(
|
||||
digest="sha256:" + "1" * 64,
|
||||
config_digest=image.source_config_digest,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
receipt_path = Path(temp) / "receipt.json"
|
||||
with (
|
||||
patch.object(
|
||||
controller.RuntimeCache,
|
||||
"verify_source",
|
||||
return_value="quay.io/argoproj/argocd:v3.3.6",
|
||||
),
|
||||
patch.object(
|
||||
controller.RuntimeCache,
|
||||
"source_config_digest",
|
||||
return_value=image.source_config_digest,
|
||||
),
|
||||
patch.object(controller.RuntimeCache, "export") as export,
|
||||
patch.object(
|
||||
controller, "_registry_descriptor", return_value=descriptor
|
||||
),
|
||||
patch.object(controller, "_registry_digest_present", return_value=True),
|
||||
patch.object(controller, "_run") as run,
|
||||
):
|
||||
receipt = controller.stage_images(
|
||||
policy,
|
||||
trace_id="aia-p0-006-02b-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.assertEqual(receipt["images"][0]["execution"], "already_present")
|
||||
self.assertTrue(receipt["images"][0]["provenance_config_digest_match"])
|
||||
export.assert_not_called()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_staging_exports_cached_source_and_verifies_remote_provenance(
|
||||
self,
|
||||
) -> None:
|
||||
policy = controller.load_staging_policy(STAGING_POLICY_PATH)
|
||||
image = policy.images[0]
|
||||
descriptor = controller.RegistryDescriptor(
|
||||
digest="sha256:" + "2" * 64,
|
||||
config_digest=image.source_config_digest,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
receipt_path = Path(temp) / "receipt.json"
|
||||
with (
|
||||
patch.object(
|
||||
controller.RuntimeCache,
|
||||
"verify_source",
|
||||
return_value="quay.io/argoproj/argocd:v3.3.6",
|
||||
),
|
||||
patch.object(
|
||||
controller.RuntimeCache,
|
||||
"source_config_digest",
|
||||
return_value=image.source_config_digest,
|
||||
),
|
||||
patch.object(controller.RuntimeCache, "export") as export,
|
||||
patch.object(
|
||||
controller,
|
||||
"_registry_descriptor",
|
||||
side_effect=[None, descriptor],
|
||||
),
|
||||
patch.object(controller, "_local_image_present", return_value=False),
|
||||
patch.object(controller, "_archive_contains_digest", return_value=True),
|
||||
patch.object(
|
||||
controller,
|
||||
"_local_config_digest",
|
||||
return_value=image.source_config_digest,
|
||||
),
|
||||
patch.object(controller, "_registry_digest_present", return_value=True),
|
||||
patch.object(controller, "_remove_local_image") as remove,
|
||||
patch.object(controller, "_run") as run,
|
||||
):
|
||||
receipt = controller.stage_images(
|
||||
policy,
|
||||
trace_id="aia-p0-006-02b-stage",
|
||||
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"], 1)
|
||||
self.assertEqual(receipt["images"][0]["execution"], "staged_from_runtime_cache")
|
||||
self.assertEqual(
|
||||
receipt["images"][0]["target_registry_digest"], descriptor.digest
|
||||
)
|
||||
export.assert_called_once()
|
||||
commands = [call.args[0] for call in run.call_args_list]
|
||||
self.assertEqual([command[1] for command in commands], ["load", "tag", "push"])
|
||||
self.assertTrue(all("pull" not in command for command in commands))
|
||||
self.assertEqual(remove.call_count, 2)
|
||||
|
||||
def test_staging_rejects_internal_artifact_with_wrong_provenance(self) -> None:
|
||||
policy = controller.load_staging_policy(STAGING_POLICY_PATH)
|
||||
image = policy.images[0]
|
||||
descriptor = controller.RegistryDescriptor(
|
||||
digest="sha256:" + "3" * 64,
|
||||
config_digest="sha256:" + "4" * 64,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
with (
|
||||
patch.object(
|
||||
controller.RuntimeCache,
|
||||
"verify_source",
|
||||
return_value="quay.io/argoproj/argocd:v3.3.6",
|
||||
),
|
||||
patch.object(
|
||||
controller.RuntimeCache,
|
||||
"source_config_digest",
|
||||
return_value=image.source_config_digest,
|
||||
),
|
||||
patch.object(
|
||||
controller, "_registry_descriptor", return_value=descriptor
|
||||
),
|
||||
self.assertRaisesRegex(
|
||||
controller.ControllerError,
|
||||
"internal_registry_provenance_mismatch",
|
||||
),
|
||||
):
|
||||
controller.stage_images(
|
||||
policy,
|
||||
trace_id="aia-p0-006-02b-wrong-provenance",
|
||||
receipt_path=Path(temp) / "receipt.json",
|
||||
ssh_key=Path("/unused/key"),
|
||||
known_hosts=Path("/unused/known-hosts"),
|
||||
apply=True,
|
||||
)
|
||||
|
||||
def test_verified_mirror_receipt_is_bound_to_trace_and_policy(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
receipt = {
|
||||
@@ -182,6 +364,123 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
|
||||
):
|
||||
controller._read_mirror_receipt(path, policy, "different-trace")
|
||||
|
||||
def test_init_container_read_and_patch_use_init_container_spec(self) -> None:
|
||||
image = self._image_with_container_kind("init_container")
|
||||
kubernetes = controller.Kubernetes(
|
||||
use_sudo=False,
|
||||
kubeconfig=None,
|
||||
server=None,
|
||||
)
|
||||
workload = {
|
||||
"spec": {
|
||||
"template": {
|
||||
"spec": {
|
||||
"containers": [{"name": "other", "image": "other:image"}],
|
||||
"initContainers": [
|
||||
{
|
||||
"name": image.workload.container,
|
||||
"image": "source:init",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch.object(kubernetes, "workload", return_value=workload):
|
||||
self.assertEqual(kubernetes.current_image(image), "source:init")
|
||||
|
||||
with patch.object(kubernetes, "run") as run:
|
||||
kubernetes.patch_image(image, image.runtime_ref, dry_run=True)
|
||||
|
||||
args = run.call_args.args[0]
|
||||
patch_payload = json.loads(args[args.index("--patch") + 1])
|
||||
pod_spec = patch_payload["spec"]["template"]["spec"]
|
||||
self.assertNotIn("containers", pod_spec)
|
||||
self.assertEqual(
|
||||
pod_spec["initContainers"],
|
||||
[{"name": image.workload.container, "image": image.runtime_ref}],
|
||||
)
|
||||
self.assertIn("--dry-run=server", args)
|
||||
|
||||
def test_init_container_verifier_accepts_completed_digest_pinned_status(
|
||||
self,
|
||||
) -> None:
|
||||
image = self._image_with_container_kind("init_container")
|
||||
kubernetes = controller.Kubernetes(
|
||||
use_sudo=False,
|
||||
kubeconfig=None,
|
||||
server=None,
|
||||
)
|
||||
workload = {
|
||||
"spec": {"selector": {"matchLabels": {"app": "runtime-mirror-test"}}}
|
||||
}
|
||||
pods = {
|
||||
"items": [
|
||||
{
|
||||
"metadata": {"name": "runtime-mirror-test-1"},
|
||||
"status": {
|
||||
"initContainerStatuses": [
|
||||
{
|
||||
"name": image.workload.container,
|
||||
"ready": False,
|
||||
"state": {"terminated": {"exitCode": 0}},
|
||||
"imageID": "registry.local/repo@"
|
||||
+ image.target_registry_digest,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
with (
|
||||
patch.object(kubernetes, "workload", return_value=workload),
|
||||
patch.object(kubernetes, "run") as run,
|
||||
):
|
||||
run.return_value.stdout = json.dumps(pods)
|
||||
self.assertEqual(kubernetes.verify_pods(image), 1)
|
||||
|
||||
def test_init_container_verifier_rejects_incomplete_or_wrong_digest(self) -> None:
|
||||
image = self._image_with_container_kind("init_container")
|
||||
workload = {
|
||||
"spec": {"selector": {"matchLabels": {"app": "runtime-mirror-test"}}}
|
||||
}
|
||||
cases = (
|
||||
(
|
||||
{
|
||||
"state": {"running": {}},
|
||||
"imageID": "repo@" + image.target_registry_digest,
|
||||
},
|
||||
"pod_init_container_not_completed",
|
||||
),
|
||||
(
|
||||
{
|
||||
"state": {"terminated": {"exitCode": 0}},
|
||||
"imageID": "repo@sha256:" + "0" * 64,
|
||||
},
|
||||
"pod_image_digest_mismatch",
|
||||
),
|
||||
)
|
||||
for status, error_code in cases:
|
||||
with self.subTest(error_code=error_code):
|
||||
status["name"] = image.workload.container
|
||||
pods = {
|
||||
"items": [
|
||||
{"metadata": {}, "status": {"initContainerStatuses": [status]}}
|
||||
]
|
||||
}
|
||||
kubernetes = controller.Kubernetes(
|
||||
use_sudo=False,
|
||||
kubeconfig=None,
|
||||
server=None,
|
||||
)
|
||||
with (
|
||||
patch.object(kubernetes, "workload", return_value=workload),
|
||||
patch.object(kubernetes, "run") as run,
|
||||
self.assertRaisesRegex(controller.ControllerError, error_code),
|
||||
):
|
||||
run.return_value.stdout = json.dumps(pods)
|
||||
kubernetes.verify_pods(image)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user