Compare commits
1 Commits
4236538c8f
...
834cedf02a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
834cedf02a |
@ -9,6 +9,7 @@ metadata:
|
||||
labels:
|
||||
atlas.bstein.dev/workload: hermes-agent-image-builder
|
||||
spec:
|
||||
serviceAccountName: hermes-image-builder
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
restartPolicy: Never
|
||||
@ -240,10 +241,32 @@ spec:
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
success {
|
||||
sh '''
|
||||
set -eu
|
||||
expected_files="$(printf '%s\n' \
|
||||
build/hermes-agent.destination \
|
||||
build/hermes-agent.digest \
|
||||
build/hermes-agent.image \
|
||||
build/hermes-agent-release/hermes-agent-image.json \
|
||||
build/hermes-agent-release/hermes-image-update.patch \
|
||||
build/hermes-agent-release/hermes-kustomization.yaml \
|
||||
| LC_ALL=C sort)"
|
||||
actual_files="$(find build -type f -print | LC_ALL=C sort)"
|
||||
test "${actual_files}" = "${expected_files}"
|
||||
destination="$(cat build/hermes-agent.destination)"
|
||||
python3 ci/scripts/hermes_image_release.py verify-evidence \
|
||||
--digest-file build/hermes-agent.digest \
|
||||
--image-file build/hermes-agent.image \
|
||||
--source-revision "${EXPECTED_SOURCE_REVISION}" \
|
||||
--build-number "${BUILD_NUMBER}" \
|
||||
--destination "${destination}" \
|
||||
--kustomization services/hermes/kustomization.yaml \
|
||||
--output-dir build/hermes-agent-release
|
||||
'''
|
||||
archiveArtifacts(
|
||||
artifacts: 'build/hermes-agent.digest,build/hermes-agent.image,build/hermes-agent.destination,build/hermes-agent-release/**',
|
||||
allowEmptyArchive: true,
|
||||
artifacts: 'build/hermes-agent.destination,build/hermes-agent.digest,build/hermes-agent.image,build/hermes-agent-release/hermes-agent-image.json,build/hermes-agent-release/hermes-image-update.patch,build/hermes-agent-release/hermes-kustomization.yaml',
|
||||
allowEmptyArchive: false,
|
||||
fingerprint: true
|
||||
)
|
||||
}
|
||||
|
||||
@ -335,7 +335,6 @@ def write_release_artifacts(
|
||||
source_revision, build_number = validate_destination(
|
||||
destination, source_revision, build_number
|
||||
)
|
||||
|
||||
source = kustomization.read_text(encoding="utf-8")
|
||||
rendered = render_kustomization(source, digest)
|
||||
relative_name = kustomization.name
|
||||
@ -349,7 +348,6 @@ def write_release_artifacts(
|
||||
)
|
||||
if not patch:
|
||||
raise ValueError("published digest already matches the Flux manifest")
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
(output_dir / "hermes-kustomization.yaml").write_text(rendered, encoding="utf-8")
|
||||
(output_dir / "hermes-image-update.patch").write_text(patch, encoding="utf-8")
|
||||
@ -367,6 +365,64 @@ def write_release_artifacts(
|
||||
return metadata
|
||||
|
||||
|
||||
def validate_release_artifacts(
|
||||
*,
|
||||
digest_file: Path,
|
||||
image_file: Path,
|
||||
source_revision: str,
|
||||
build_number: str,
|
||||
destination: str,
|
||||
kustomization: Path,
|
||||
output_dir: Path,
|
||||
) -> None:
|
||||
"""Revalidate the exact successful-build evidence without rewriting it."""
|
||||
digest = validate_kaniko_evidence(
|
||||
digest_text=digest_file.read_text(encoding="utf-8"),
|
||||
image_text=image_file.read_text(encoding="utf-8"),
|
||||
destination=destination,
|
||||
)
|
||||
source_revision, build_number = validate_destination(
|
||||
destination, source_revision, build_number
|
||||
)
|
||||
expected_names = {
|
||||
"hermes-agent-image.json",
|
||||
"hermes-image-update.patch",
|
||||
"hermes-kustomization.yaml",
|
||||
}
|
||||
entries = list(output_dir.iterdir())
|
||||
if {entry.name for entry in entries} != expected_names or not all(
|
||||
entry.is_file() and not entry.is_symlink() for entry in entries
|
||||
):
|
||||
raise ValueError("release output must contain exactly three evidence files")
|
||||
source = kustomization.read_text(encoding="utf-8")
|
||||
rendered = render_kustomization(source, digest)
|
||||
relative_name = kustomization.name
|
||||
patch = "".join(
|
||||
difflib.unified_diff(
|
||||
source.splitlines(keepends=True),
|
||||
rendered.splitlines(keepends=True),
|
||||
fromfile=f"a/services/hermes/{relative_name}",
|
||||
tofile=f"b/services/hermes/{relative_name}",
|
||||
)
|
||||
)
|
||||
metadata = {
|
||||
"build_number": build_number,
|
||||
"digest": digest,
|
||||
"flux_image": f"{DEFAULT_IMAGE}@{digest}",
|
||||
"image": DEFAULT_IMAGE,
|
||||
"published_tag": destination,
|
||||
"source_revision": source_revision,
|
||||
}
|
||||
expected = {
|
||||
"hermes-agent-image.json": json.dumps(metadata, indent=2, sort_keys=True) + "\n",
|
||||
"hermes-image-update.patch": patch,
|
||||
"hermes-kustomization.yaml": rendered,
|
||||
}
|
||||
for name, expected_text in expected.items():
|
||||
if (output_dir / name).read_text(encoding="utf-8") != expected_text:
|
||||
raise ValueError(f"release evidence is incomplete or mismatched: {name}")
|
||||
|
||||
|
||||
def _credentials() -> tuple[str, str]:
|
||||
"""Read the masked, runtime-only Jenkins credential environment."""
|
||||
username = os.environ.get("HARBOR_USER", "")
|
||||
@ -394,9 +450,27 @@ def main() -> int:
|
||||
render.add_argument("--image-file", required=True, type=Path)
|
||||
render.add_argument("--kustomization", required=True, type=Path)
|
||||
render.add_argument("--output-dir", required=True, type=Path)
|
||||
verify = commands.add_parser("verify-evidence")
|
||||
_common_arguments(verify)
|
||||
verify.add_argument("--digest-file", required=True, type=Path)
|
||||
verify.add_argument("--image-file", required=True, type=Path)
|
||||
verify.add_argument("--kustomization", required=True, type=Path)
|
||||
verify.add_argument("--output-dir", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
validate_destination(args.destination, args.source_revision, args.build_number)
|
||||
if args.command == "verify-evidence":
|
||||
validate_release_artifacts(
|
||||
digest_file=args.digest_file,
|
||||
image_file=args.image_file,
|
||||
source_revision=args.source_revision,
|
||||
build_number=args.build_number,
|
||||
destination=args.destination,
|
||||
kustomization=args.kustomization,
|
||||
output_dir=args.output_dir,
|
||||
)
|
||||
return 0
|
||||
|
||||
username, password = _credentials()
|
||||
if args.command == "assert-absent":
|
||||
verify_immutable_policy(username=username, password=password)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replay the reviewed Hermes Dockerfile heredocs for pinned Kaniko.
|
||||
"""Replay the reviewed Hermes Dockerfile heredocs for pinned Kaniko v1.23.2.
|
||||
|
||||
Kaniko v1.24 parses shell heredoc bodies into ``RunCommand.Files`` but its RUN
|
||||
Kaniko v1.23.2 parses shell heredoc bodies into ``RunCommand.Files`` but its RUN
|
||||
implementation executes only ``CmdLine``. The interpreters therefore receive
|
||||
empty stdin and exit successfully. Docker and BuildKit execute these blocks
|
||||
normally, so this compatibility runner is enabled only by the Kaniko pipeline.
|
||||
@ -22,8 +22,17 @@ BLOCKS = (
|
||||
EXPECTED_COMMANDS = ("node", *("python",) * 7, "node")
|
||||
|
||||
|
||||
def _continued_run_header(lines: list[str], index: int) -> tuple[str, int]:
|
||||
"""Return one logical RUN header, including backslash continuations."""
|
||||
header = lines[index]
|
||||
while header.rstrip().endswith("\\") and index + 1 < len(lines):
|
||||
index += 1
|
||||
header = f"{header}\n{lines[index]}"
|
||||
return header, index
|
||||
|
||||
|
||||
def extract_blocks(source: str) -> list[tuple[tuple[str, ...], str]]:
|
||||
"""Return only exact supported RUN heredocs in their reviewed order."""
|
||||
"""Inventory every RUN heredoc, then return only the reviewed contract."""
|
||||
markers = {start: (end, command) for start, end, command in BLOCKS}
|
||||
lines = source.splitlines()
|
||||
blocks: list[tuple[tuple[str, ...], str]] = []
|
||||
@ -31,6 +40,15 @@ def extract_blocks(source: str) -> list[tuple[tuple[str, ...], str]]:
|
||||
while index < len(lines):
|
||||
marker = markers.get(lines[index])
|
||||
if marker is None:
|
||||
stripped = lines[index].lstrip()
|
||||
if stripped[:3].upper() == "RUN" and (
|
||||
len(stripped) == 3 or stripped[3].isspace()
|
||||
):
|
||||
header, index = _continued_run_header(lines, index)
|
||||
if "<<" in header:
|
||||
raise ValueError(
|
||||
"unsupported RUN heredoc outside the exact reviewed contract"
|
||||
)
|
||||
index += 1
|
||||
continue
|
||||
end, command = marker
|
||||
|
||||
@ -105,7 +105,8 @@ pretend direct Jenkins access succeeded.
|
||||
## Publishing an agent image after review
|
||||
|
||||
The `hermes-agent-image` Jenkins job is the only supported agent image builder.
|
||||
It runs daemonless Kaniko without a service-account token, host socket,
|
||||
It runs daemonless Kaniko v1.23.2 under the unbound `hermes-image-builder`
|
||||
ServiceAccount without a service-account token, host socket,
|
||||
privileged container, or writable Git credential. It accepts only an exact
|
||||
40-character revision that is both the checked-out commit and current
|
||||
`atlas/titan-iac` `main`, so a human must merge the source PR first.
|
||||
@ -123,15 +124,16 @@ root-in-the-build-pod risk, bounded to disposable `emptyDir` storage and a
|
||||
human-reviewed `main` revision; Dockerfile changes require the same scrutiny as
|
||||
executable cluster code.
|
||||
|
||||
Pinned Kaniko parses Dockerfile `RUN` heredocs but does not materialize their
|
||||
Pinned Kaniko v1.23.2 parses Dockerfile `RUN` heredocs but does not materialize their
|
||||
inline files when executing a command. The Jenkins lane therefore enables a
|
||||
bounded compatibility replay after copying the reviewed Dockerfile and runner
|
||||
from the same checked-out commit. The runner accepts only the exact nine
|
||||
Node/Python heredocs in their expected order and launches each in a separate
|
||||
interpreter process. Docker and BuildKit keep their native behavior because the
|
||||
compatibility argument defaults off. The final TypeScript build, Python compile,
|
||||
and source assertions remain mandatory, so an omitted or drifted replay fails
|
||||
before any image can pass release verification.
|
||||
from the same checked-out commit. The runner inventories all `RUN` heredoc
|
||||
headers before launching an interpreter, rejects every form outside the exact
|
||||
nine Node/Python blocks, and launches each accepted body in a separate process.
|
||||
Docker and BuildKit keep their native behavior because the compatibility
|
||||
argument defaults off. The final TypeScript build, Python compile, and source
|
||||
assertions remain mandatory, so an omitted or drifted replay fails before any
|
||||
image can pass release verification.
|
||||
|
||||
From agent.hermes, trigger that one fixed job with:
|
||||
|
||||
@ -145,8 +147,10 @@ also requires its fixed publish confirmation. Each run claims the unique tag
|
||||
`git-<reviewed-sha>-build-<jenkins-build-number>` and refuses to overwrite an
|
||||
existing tag. It cross-checks Kaniko's digest and tagged-image evidence, then
|
||||
independently reads the pushed tag from Harbor before archiving a JSON record
|
||||
and Flux digest patch. Apply that patch on a new branch and submit it for human
|
||||
review; the build never changes Git, reconciles Flux, or deploys by itself.
|
||||
and Flux digest patch. A successful post-condition then revalidates the exact
|
||||
six-file evidence set and archives those six explicit paths with empty archives
|
||||
forbidden. Apply that patch on a new branch and submit it for human review; the
|
||||
build never changes Git, reconciles Flux, or deploys by itself.
|
||||
The helper posts only to the fixed HTTPS Build Token Root endpoint and accepts
|
||||
only its real queue responses: HTTP 201 or a non-followed HTTP 303 with an exact
|
||||
same-origin `/queue/item/<number>/` location. An unauthenticated request is
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
# services/jenkins/hermes-image-builder-serviceaccount.yaml
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: hermes-image-builder
|
||||
namespace: jenkins
|
||||
automountServiceAccountToken: false
|
||||
@ -5,6 +5,7 @@ namespace: jenkins
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- serviceaccount.yaml
|
||||
- hermes-image-builder-serviceaccount.yaml
|
||||
- vault-serviceaccount.yaml
|
||||
- pvc.yaml
|
||||
- cache-pvc.yaml
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
secret_path="kv/atlas/hermes/developer-jenkins"
|
||||
secret_api_path="kv/data/atlas/hermes/developer-jenkins"
|
||||
jwt_file="${VAULT_K8S_JWT_FILE:-/var/run/secrets/kubernetes.io/serviceaccount/token}"
|
||||
vault_role="${VAULT_K8S_ROLE:-hermes-jenkins-token-seed}"
|
||||
@ -57,14 +58,28 @@ read_build_token() {
|
||||
secret_status=$?
|
||||
set -e
|
||||
if [ "${secret_status}" -eq 0 ]; then
|
||||
if printf '%s' "${secret_json}" | grep -Eq '"build_token"[[:space:]]*:[[:space:]]*"[0-9a-f]{64}"'; then
|
||||
unset secret_json
|
||||
return 0
|
||||
fi
|
||||
unset secret_json
|
||||
return 11
|
||||
fi
|
||||
if printf '%s' "${secret_json}" | grep -Eq 'Code: 404|No value found at'; then
|
||||
set +e
|
||||
build_token="$(vault kv get -field=build_token "${secret_path}" 2>&1)"
|
||||
field_status=$?
|
||||
set -e
|
||||
if [ "${field_status}" -eq 0 ]; then
|
||||
case "${build_token}" in
|
||||
''|*[!0-9a-f]*) unset build_token; return 11 ;;
|
||||
esac
|
||||
if [ "${#build_token}" -eq 64 ]; then
|
||||
unset build_token
|
||||
return 0
|
||||
fi
|
||||
unset build_token
|
||||
return 11
|
||||
fi
|
||||
if printf '%s' "${build_token}" | grep -q 'No value found'; then
|
||||
unset build_token
|
||||
return 11
|
||||
fi
|
||||
unset build_token
|
||||
elif printf '%s' "${secret_json}" | grep -Eq 'Code: 404|No value found at'; then
|
||||
unset secret_json
|
||||
return 10
|
||||
fi
|
||||
@ -109,7 +124,14 @@ new_token="$(retry_command vault write -field=random_bytes \
|
||||
log "Vault random token generation failed after retries"
|
||||
exit 1
|
||||
}
|
||||
if ! printf '%s' "${new_token}" | grep -Eq '^[0-9a-f]{64}$'; then
|
||||
case "${new_token}" in
|
||||
''|*[!0-9a-f]*)
|
||||
unset new_token
|
||||
log "Vault returned an invalid random token"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "${#new_token}" -ne 64 ]; then
|
||||
unset new_token
|
||||
log "Vault returned an invalid random token"
|
||||
exit 1
|
||||
|
||||
@ -59,6 +59,7 @@ def test_builder_pod_is_daemonless_and_kernel_bounded() -> None:
|
||||
"""The builder gets only proven build caps, never host, daemon, or K8s access."""
|
||||
source = PIPELINE_PATH.read_text(encoding="utf-8")
|
||||
spec = _pod_spec()
|
||||
assert spec["serviceAccountName"] == "hermes-image-builder"
|
||||
assert spec["automountServiceAccountToken"] is False
|
||||
assert spec["enableServiceLinks"] is False
|
||||
assert "hostPath" not in source
|
||||
@ -66,6 +67,7 @@ def test_builder_pod_is_daemonless_and_kernel_bounded() -> None:
|
||||
assert "tcp://" not in source
|
||||
assert "buildkitd" not in source.lower()
|
||||
assert "dind" not in source.lower()
|
||||
assert "serviceAccountToken" not in source
|
||||
|
||||
containers = {item["name"]: item for item in spec["containers"]}
|
||||
kaniko = containers["kaniko"]
|
||||
|
||||
236
testing/tests/test_hermes_image_builder_fresh_review.py
Normal file
236
testing/tests/test_hermes_image_builder_fresh_review.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""Fresh fail-closed review cases for the Hermes image builder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
PIPELINE = REPO_ROOT / "ci/Jenkinsfile.hermes-agent-image"
|
||||
DOCKERFILE = REPO_ROOT / "dockerfiles/Dockerfile.hermes-agent"
|
||||
RUNNER = REPO_ROOT / "dockerfiles/hermes-kaniko-heredoc-runner.py"
|
||||
RELEASE = REPO_ROOT / "ci/scripts/hermes_image_release.py"
|
||||
BUILDER_SA = REPO_ROOT / "services/jenkins/hermes-image-builder-serviceaccount.yaml"
|
||||
|
||||
|
||||
def _load(path: Path, name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _pod_spec() -> dict:
|
||||
source = PIPELINE.read_text(encoding="utf-8")
|
||||
pod_yaml = source.split('yaml """', 1)[1].split('"""', 1)[0]
|
||||
return yaml.safe_load(pod_yaml)["spec"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsupported",
|
||||
[
|
||||
"RUN cat <<EOF\nbody\nEOF",
|
||||
"run cat <<'EOF'\nbody\nEOF",
|
||||
' RUN cat <<"EOF"\nbody\nEOF',
|
||||
"RUN <<-EOF\n\tbody\n\tEOF",
|
||||
'RUN ["/bin/sh", "-c"] <<EOF\nbody\nEOF',
|
||||
"RUN echo before <<EOF after\nbody\nEOF",
|
||||
"RUN set -eu \\\n && cat <<'EOF'\nbody\nEOF",
|
||||
],
|
||||
)
|
||||
def test_all_unsupported_run_heredoc_forms_reject_before_execution(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, unsupported: str
|
||||
) -> None:
|
||||
"""Any RUN heredoc outside the reviewed nine is rejected before replay."""
|
||||
module = _load(RUNNER, f"heredoc_reject_{abs(hash(unsupported))}")
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.write_text(
|
||||
DOCKERFILE.read_text(encoding="utf-8") + "\n" + unsupported + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
calls = []
|
||||
monkeypatch.setattr(module.subprocess, "run", lambda *_args, **_kwargs: calls.append(1))
|
||||
with pytest.raises(ValueError, match="unsupported RUN heredoc"):
|
||||
module.replay(dockerfile)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_exact_reviewed_heredocs_remain_buildkit_native_by_default() -> None:
|
||||
"""Current source inventories cleanly and enables replay only for Kaniko."""
|
||||
module = _load(RUNNER, "heredoc_positive_contract")
|
||||
source = DOCKERFILE.read_text(encoding="utf-8")
|
||||
assert len(module.extract_blocks(source)) == 9
|
||||
assert source.count("ARG HERMES_KANIKO_HEREDOC_COMPAT=0") == 1
|
||||
assert "Kaniko v1.23.2" in RUNNER.read_text(encoding="utf-8")
|
||||
pipeline = PIPELINE.read_text(encoding="utf-8")
|
||||
assert pipeline.count("--build-arg=HERMES_KANIKO_HEREDOC_COMPAT=1") == 1
|
||||
|
||||
|
||||
def test_appended_tenth_reviewed_form_rejects_before_execution(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Even an otherwise supported marker cannot expand the nine-block set."""
|
||||
module = _load(RUNNER, "heredoc_tenth_block")
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.write_text(
|
||||
DOCKERFILE.read_text(encoding="utf-8")
|
||||
+ "\nRUN node <<'NODE'\nconsole.log('tenth');\nNODE\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
calls = []
|
||||
monkeypatch.setattr(module.subprocess, "run", lambda *_args, **_kwargs: calls.append(1))
|
||||
with pytest.raises(ValueError, match="contract changed"):
|
||||
module.replay(dockerfile)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_builder_service_account_is_explicit_tokenless_and_unbound() -> None:
|
||||
"""The build Pod selects one tokenless SA that no tracked RBAC grants bind."""
|
||||
account = yaml.safe_load(BUILDER_SA.read_text(encoding="utf-8"))
|
||||
assert account == {
|
||||
"apiVersion": "v1",
|
||||
"kind": "ServiceAccount",
|
||||
"metadata": {"name": "hermes-image-builder", "namespace": "jenkins"},
|
||||
"automountServiceAccountToken": False,
|
||||
}
|
||||
kustomization = yaml.safe_load(
|
||||
(REPO_ROOT / "services/jenkins/kustomization.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
assert "hermes-image-builder-serviceaccount.yaml" in kustomization["resources"]
|
||||
|
||||
spec = _pod_spec()
|
||||
assert spec["serviceAccountName"] == "hermes-image-builder"
|
||||
assert spec["automountServiceAccountToken"] is False
|
||||
for volume in spec.get("volumes", []):
|
||||
projected = volume.get("projected", {})
|
||||
assert all("serviceAccountToken" not in item for item in projected.get("sources", []))
|
||||
|
||||
for manifest in (REPO_ROOT / "services/jenkins").glob("*.yaml"):
|
||||
for document in yaml.safe_load_all(manifest.read_text(encoding="utf-8")):
|
||||
if not isinstance(document, dict) or document.get("kind") not in {
|
||||
"RoleBinding",
|
||||
"ClusterRoleBinding",
|
||||
}:
|
||||
continue
|
||||
subjects = document.get("subjects") or []
|
||||
assert not any(
|
||||
item.get("kind") == "ServiceAccount"
|
||||
and item.get("name") == "hermes-image-builder"
|
||||
for item in subjects
|
||||
)
|
||||
|
||||
|
||||
def _release_fixture(tmp_path: Path):
|
||||
module = _load(RELEASE, f"release_evidence_{tmp_path.name}")
|
||||
digest = "sha256:" + "7" * 64
|
||||
revision = "8" * 40
|
||||
build = "23"
|
||||
destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-{build}"
|
||||
digest_file = tmp_path / "hermes-agent.digest"
|
||||
image_file = tmp_path / "hermes-agent.image"
|
||||
manifest = tmp_path / "kustomization.yaml"
|
||||
output = tmp_path / "release"
|
||||
digest_file.write_text(digest + "\n", encoding="utf-8")
|
||||
image_file.write_text(f"{destination}@{digest}\n", encoding="utf-8")
|
||||
manifest.write_text(
|
||||
"images:\n"
|
||||
f" - name: {module.DEFAULT_IMAGE}\n"
|
||||
" digest: sha256:" + "0" * 64 + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
module.write_release_artifacts(
|
||||
digest=digest,
|
||||
source_revision=revision,
|
||||
build_number=build,
|
||||
destination=destination,
|
||||
kustomization=manifest,
|
||||
output_dir=output,
|
||||
)
|
||||
kwargs = {
|
||||
"digest_file": digest_file,
|
||||
"image_file": image_file,
|
||||
"source_revision": revision,
|
||||
"build_number": build,
|
||||
"destination": destination,
|
||||
"kustomization": manifest,
|
||||
"output_dir": output,
|
||||
}
|
||||
return module, kwargs
|
||||
|
||||
|
||||
def test_success_evidence_revalidation_accepts_only_exact_complete_set(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Exact evidence passes; an extra, missing, or altered file fails closed."""
|
||||
module, kwargs = _release_fixture(tmp_path)
|
||||
module.validate_release_artifacts(**kwargs)
|
||||
|
||||
extra = kwargs["output_dir"] / "unexpected"
|
||||
extra.write_text("surprise\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="exactly three"):
|
||||
module.validate_release_artifacts(**kwargs)
|
||||
extra.unlink()
|
||||
|
||||
metadata = kwargs["output_dir"] / "hermes-agent-image.json"
|
||||
original = metadata.read_text(encoding="utf-8")
|
||||
metadata.write_text(original.replace('"build_number": "23"', '"build_number": "24"'))
|
||||
with pytest.raises(ValueError, match="incomplete or mismatched"):
|
||||
module.validate_release_artifacts(**kwargs)
|
||||
metadata.write_text(original, encoding="utf-8")
|
||||
(kwargs["output_dir"] / "hermes-image-update.patch").unlink()
|
||||
with pytest.raises(ValueError, match="exactly three"):
|
||||
module.validate_release_artifacts(**kwargs)
|
||||
|
||||
|
||||
def test_verify_evidence_cli_needs_no_runtime_registry_credential(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Post-success validation is deterministic and cannot mask missing creds."""
|
||||
module, kwargs = _release_fixture(tmp_path)
|
||||
monkeypatch.delenv("HARBOR_USER", raising=False)
|
||||
monkeypatch.delenv("HARBOR_PASSWORD", raising=False)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"hermes_image_release.py",
|
||||
"verify-evidence",
|
||||
"--digest-file",
|
||||
str(kwargs["digest_file"]),
|
||||
"--image-file",
|
||||
str(kwargs["image_file"]),
|
||||
"--source-revision",
|
||||
kwargs["source_revision"],
|
||||
"--build-number",
|
||||
kwargs["build_number"],
|
||||
"--destination",
|
||||
kwargs["destination"],
|
||||
"--kustomization",
|
||||
str(kwargs["kustomization"]),
|
||||
"--output-dir",
|
||||
str(kwargs["output_dir"]),
|
||||
],
|
||||
)
|
||||
assert module.main() == 0
|
||||
|
||||
|
||||
def test_pipeline_success_post_requires_and_archives_exact_six_files() -> None:
|
||||
"""Missing or partial post-success evidence must change the build to failed."""
|
||||
source = PIPELINE.read_text(encoding="utf-8")
|
||||
post = source.split(" post {", 1)[1]
|
||||
assert "success {" in post
|
||||
assert "always {" not in post
|
||||
assert "verify-evidence" in post
|
||||
assert 'allowEmptyArchive: false' in post
|
||||
archive = post.split("artifacts: '", 1)[1].split("'", 1)[0]
|
||||
paths = archive.split(",")
|
||||
assert len(paths) == 6
|
||||
assert len(set(paths)) == 6
|
||||
assert all("*" not in path for path in paths)
|
||||
assert "find build -type f" in post
|
||||
@ -109,7 +109,9 @@ def test_seed_job_is_revisioned_bounded_and_tracks_fail_closed_script() -> None:
|
||||
assert security["capabilities"]["drop"] == ["ALL"]
|
||||
source = SEEDER.read_text(encoding="utf-8")
|
||||
assert '"options":{"cas":0}' in source
|
||||
assert 'vault kv get -field=build_token "${secret_path}"' in source
|
||||
assert "kv/data/atlas/hermes/developer-jenkins" in source
|
||||
assert '"build_token"[[:space:]]' not in source
|
||||
assert "kv patch" not in source
|
||||
assert "kv delete" not in source
|
||||
assert SEEDER.name not in VAULT_CONFIG.read_text(encoding="utf-8")
|
||||
@ -145,6 +147,34 @@ if [ "$1" = "write" ] && [ "$2" = "-field=random_bytes" ] && [ "$3" = "sys/tools
|
||||
printf '%064d\n' 0
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "kv" ] && [ "$2" = "get" ] && [ "$3" = "-field=build_token" ]; then
|
||||
case "$scenario" in
|
||||
existing)
|
||||
printf '%064d\n' 1
|
||||
exit 0
|
||||
;;
|
||||
cas-race)
|
||||
printf '%064d\n' 2
|
||||
exit 0
|
||||
;;
|
||||
field-prefix)
|
||||
printf 'prefix%064d\n' 3
|
||||
exit 0
|
||||
;;
|
||||
field-suffix)
|
||||
printf '%064dsuffix\n' 4
|
||||
exit 0
|
||||
;;
|
||||
field-multiline)
|
||||
printf '%064d\nextra\n' 5
|
||||
exit 0
|
||||
;;
|
||||
missing-field|metadata-only|custom-metadata)
|
||||
echo 'No value found at build_token' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [ "$1" = "write" ] && [ "$2" = "kv/data/atlas/hermes/developer-jenkins" ]; then
|
||||
payload="${3#@}"
|
||||
cp "$payload" "$FAKE_STDIN_CAPTURE"
|
||||
@ -158,13 +188,25 @@ fi
|
||||
if [ "$1" = "read" ] && [ "$2" = "-format=json" ]; then
|
||||
case "$scenario" in
|
||||
existing)
|
||||
printf '{"data":{"data":{"build_token":"%064d"}}}\n' 1
|
||||
printf '{"data":{"data":{"build_token":"%064d"},"metadata":{"build_token":"decoy"}}}\n' 1
|
||||
exit 0
|
||||
;;
|
||||
missing-field)
|
||||
printf '{"data":{"data":{"other":"preserve-me"}}}\n'
|
||||
exit 0
|
||||
;;
|
||||
metadata-only)
|
||||
printf '{"data":{"data":{"other":"preserve-me"},"metadata":{"build_token":"%064d"}}}\n' 6
|
||||
exit 0
|
||||
;;
|
||||
custom-metadata)
|
||||
printf '{"data":{"data":{"other":"preserve-me"},"metadata":{"custom_metadata":{"build_token":"%064d"}}}}\n' 7
|
||||
exit 0
|
||||
;;
|
||||
field-prefix|field-suffix|field-multiline)
|
||||
printf '{"data":{"data":{"build_token":"present"}}}\n'
|
||||
exit 0
|
||||
;;
|
||||
absent)
|
||||
echo 'Code: 404' >&2
|
||||
exit 2
|
||||
@ -230,7 +272,10 @@ def _run_seeder(
|
||||
return result, call_text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ["existing", "missing-field", "read-error"])
|
||||
@pytest.mark.parametrize(
|
||||
"scenario",
|
||||
["existing", "missing-field", "metadata-only", "custom-metadata", "read-error"],
|
||||
)
|
||||
def test_seeder_never_overwrites_existing_or_ambiguous_state(
|
||||
tmp_path: Path, scenario: str
|
||||
) -> None:
|
||||
@ -241,6 +286,18 @@ def test_seeder_never_overwrites_existing_or_ambiguous_state(
|
||||
assert "sys/tools/random" not in calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ["field-prefix", "field-suffix", "field-multiline"])
|
||||
def test_seeder_requires_the_entire_exact_data_field(
|
||||
tmp_path: Path, scenario: str
|
||||
) -> None:
|
||||
"""Prefix, suffix, or multiline values cannot satisfy readiness."""
|
||||
result, calls = _run_seeder(tmp_path, scenario)
|
||||
assert result.returncode != 0
|
||||
assert "kv get -field=build_token kv/atlas/hermes/developer-jenkins" in calls
|
||||
assert "write kv/data/atlas/hermes/developer-jenkins" not in calls
|
||||
assert "sys/tools/random" not in calls
|
||||
|
||||
|
||||
def test_seeder_uses_single_cas_create_and_stdin_for_absent_secret(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user