Compare commits
1 Commits
4236538c8f
...
834cedf02a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
834cedf02a |
@ -9,6 +9,7 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
atlas.bstein.dev/workload: hermes-agent-image-builder
|
atlas.bstein.dev/workload: hermes-agent-image-builder
|
||||||
spec:
|
spec:
|
||||||
|
serviceAccountName: hermes-image-builder
|
||||||
automountServiceAccountToken: false
|
automountServiceAccountToken: false
|
||||||
enableServiceLinks: false
|
enableServiceLinks: false
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
@ -240,10 +241,32 @@ spec:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
post {
|
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(
|
archiveArtifacts(
|
||||||
artifacts: 'build/hermes-agent.digest,build/hermes-agent.image,build/hermes-agent.destination,build/hermes-agent-release/**',
|
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: true,
|
allowEmptyArchive: false,
|
||||||
fingerprint: true
|
fingerprint: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -335,7 +335,6 @@ def write_release_artifacts(
|
|||||||
source_revision, build_number = validate_destination(
|
source_revision, build_number = validate_destination(
|
||||||
destination, source_revision, build_number
|
destination, source_revision, build_number
|
||||||
)
|
)
|
||||||
|
|
||||||
source = kustomization.read_text(encoding="utf-8")
|
source = kustomization.read_text(encoding="utf-8")
|
||||||
rendered = render_kustomization(source, digest)
|
rendered = render_kustomization(source, digest)
|
||||||
relative_name = kustomization.name
|
relative_name = kustomization.name
|
||||||
@ -349,7 +348,6 @@ def write_release_artifacts(
|
|||||||
)
|
)
|
||||||
if not patch:
|
if not patch:
|
||||||
raise ValueError("published digest already matches the Flux manifest")
|
raise ValueError("published digest already matches the Flux manifest")
|
||||||
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(output_dir / "hermes-kustomization.yaml").write_text(rendered, encoding="utf-8")
|
(output_dir / "hermes-kustomization.yaml").write_text(rendered, encoding="utf-8")
|
||||||
(output_dir / "hermes-image-update.patch").write_text(patch, 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
|
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]:
|
def _credentials() -> tuple[str, str]:
|
||||||
"""Read the masked, runtime-only Jenkins credential environment."""
|
"""Read the masked, runtime-only Jenkins credential environment."""
|
||||||
username = os.environ.get("HARBOR_USER", "")
|
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("--image-file", required=True, type=Path)
|
||||||
render.add_argument("--kustomization", required=True, type=Path)
|
render.add_argument("--kustomization", required=True, type=Path)
|
||||||
render.add_argument("--output-dir", 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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
validate_destination(args.destination, args.source_revision, args.build_number)
|
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()
|
username, password = _credentials()
|
||||||
if args.command == "assert-absent":
|
if args.command == "assert-absent":
|
||||||
verify_immutable_policy(username=username, password=password)
|
verify_immutable_policy(username=username, password=password)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
implementation executes only ``CmdLine``. The interpreters therefore receive
|
||||||
empty stdin and exit successfully. Docker and BuildKit execute these blocks
|
empty stdin and exit successfully. Docker and BuildKit execute these blocks
|
||||||
normally, so this compatibility runner is enabled only by the Kaniko pipeline.
|
normally, so this compatibility runner is enabled only by the Kaniko pipeline.
|
||||||
@ -22,8 +22,17 @@ BLOCKS = (
|
|||||||
EXPECTED_COMMANDS = ("node", *("python",) * 7, "node")
|
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]]:
|
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}
|
markers = {start: (end, command) for start, end, command in BLOCKS}
|
||||||
lines = source.splitlines()
|
lines = source.splitlines()
|
||||||
blocks: list[tuple[tuple[str, ...], str]] = []
|
blocks: list[tuple[tuple[str, ...], str]] = []
|
||||||
@ -31,6 +40,15 @@ def extract_blocks(source: str) -> list[tuple[tuple[str, ...], str]]:
|
|||||||
while index < len(lines):
|
while index < len(lines):
|
||||||
marker = markers.get(lines[index])
|
marker = markers.get(lines[index])
|
||||||
if marker is None:
|
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
|
index += 1
|
||||||
continue
|
continue
|
||||||
end, command = marker
|
end, command = marker
|
||||||
|
|||||||
@ -105,7 +105,8 @@ pretend direct Jenkins access succeeded.
|
|||||||
## Publishing an agent image after review
|
## Publishing an agent image after review
|
||||||
|
|
||||||
The `hermes-agent-image` Jenkins job is the only supported agent image builder.
|
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
|
privileged container, or writable Git credential. It accepts only an exact
|
||||||
40-character revision that is both the checked-out commit and current
|
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.
|
`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
|
human-reviewed `main` revision; Dockerfile changes require the same scrutiny as
|
||||||
executable cluster code.
|
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
|
inline files when executing a command. The Jenkins lane therefore enables a
|
||||||
bounded compatibility replay after copying the reviewed Dockerfile and runner
|
bounded compatibility replay after copying the reviewed Dockerfile and runner
|
||||||
from the same checked-out commit. The runner accepts only the exact nine
|
from the same checked-out commit. The runner inventories all `RUN` heredoc
|
||||||
Node/Python heredocs in their expected order and launches each in a separate
|
headers before launching an interpreter, rejects every form outside the exact
|
||||||
interpreter process. Docker and BuildKit keep their native behavior because the
|
nine Node/Python blocks, and launches each accepted body in a separate process.
|
||||||
compatibility argument defaults off. The final TypeScript build, Python compile,
|
Docker and BuildKit keep their native behavior because the compatibility
|
||||||
and source assertions remain mandatory, so an omitted or drifted replay fails
|
argument defaults off. The final TypeScript build, Python compile, and source
|
||||||
before any image can pass release verification.
|
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:
|
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
|
`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
|
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
|
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
|
and Flux digest patch. A successful post-condition then revalidates the exact
|
||||||
review; the build never changes Git, reconciles Flux, or deploys by itself.
|
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
|
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
|
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
|
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:
|
resources:
|
||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
- serviceaccount.yaml
|
- serviceaccount.yaml
|
||||||
|
- hermes-image-builder-serviceaccount.yaml
|
||||||
- vault-serviceaccount.yaml
|
- vault-serviceaccount.yaml
|
||||||
- pvc.yaml
|
- pvc.yaml
|
||||||
- cache-pvc.yaml
|
- cache-pvc.yaml
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
secret_path="kv/atlas/hermes/developer-jenkins"
|
||||||
secret_api_path="kv/data/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}"
|
jwt_file="${VAULT_K8S_JWT_FILE:-/var/run/secrets/kubernetes.io/serviceaccount/token}"
|
||||||
vault_role="${VAULT_K8S_ROLE:-hermes-jenkins-token-seed}"
|
vault_role="${VAULT_K8S_ROLE:-hermes-jenkins-token-seed}"
|
||||||
@ -57,14 +58,28 @@ read_build_token() {
|
|||||||
secret_status=$?
|
secret_status=$?
|
||||||
set -e
|
set -e
|
||||||
if [ "${secret_status}" -eq 0 ]; then
|
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
|
unset secret_json
|
||||||
return 11
|
set +e
|
||||||
fi
|
build_token="$(vault kv get -field=build_token "${secret_path}" 2>&1)"
|
||||||
if printf '%s' "${secret_json}" | grep -Eq 'Code: 404|No value found at'; then
|
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
|
unset secret_json
|
||||||
return 10
|
return 10
|
||||||
fi
|
fi
|
||||||
@ -109,7 +124,14 @@ new_token="$(retry_command vault write -field=random_bytes \
|
|||||||
log "Vault random token generation failed after retries"
|
log "Vault random token generation failed after retries"
|
||||||
exit 1
|
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
|
unset new_token
|
||||||
log "Vault returned an invalid random token"
|
log "Vault returned an invalid random token"
|
||||||
exit 1
|
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."""
|
"""The builder gets only proven build caps, never host, daemon, or K8s access."""
|
||||||
source = PIPELINE_PATH.read_text(encoding="utf-8")
|
source = PIPELINE_PATH.read_text(encoding="utf-8")
|
||||||
spec = _pod_spec()
|
spec = _pod_spec()
|
||||||
|
assert spec["serviceAccountName"] == "hermes-image-builder"
|
||||||
assert spec["automountServiceAccountToken"] is False
|
assert spec["automountServiceAccountToken"] is False
|
||||||
assert spec["enableServiceLinks"] is False
|
assert spec["enableServiceLinks"] is False
|
||||||
assert "hostPath" not in source
|
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 "tcp://" not in source
|
||||||
assert "buildkitd" not in source.lower()
|
assert "buildkitd" not in source.lower()
|
||||||
assert "dind" not in source.lower()
|
assert "dind" not in source.lower()
|
||||||
|
assert "serviceAccountToken" not in source
|
||||||
|
|
||||||
containers = {item["name"]: item for item in spec["containers"]}
|
containers = {item["name"]: item for item in spec["containers"]}
|
||||||
kaniko = containers["kaniko"]
|
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"]
|
assert security["capabilities"]["drop"] == ["ALL"]
|
||||||
source = SEEDER.read_text(encoding="utf-8")
|
source = SEEDER.read_text(encoding="utf-8")
|
||||||
assert '"options":{"cas":0}' in source
|
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 "kv/data/atlas/hermes/developer-jenkins" in source
|
||||||
|
assert '"build_token"[[:space:]]' not in source
|
||||||
assert "kv patch" not in source
|
assert "kv patch" not in source
|
||||||
assert "kv delete" not in source
|
assert "kv delete" not in source
|
||||||
assert SEEDER.name not in VAULT_CONFIG.read_text(encoding="utf-8")
|
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
|
printf '%064d\n' 0
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
if [ "$1" = "write" ] && [ "$2" = "kv/data/atlas/hermes/developer-jenkins" ]; then
|
||||||
payload="${3#@}"
|
payload="${3#@}"
|
||||||
cp "$payload" "$FAKE_STDIN_CAPTURE"
|
cp "$payload" "$FAKE_STDIN_CAPTURE"
|
||||||
@ -158,13 +188,25 @@ fi
|
|||||||
if [ "$1" = "read" ] && [ "$2" = "-format=json" ]; then
|
if [ "$1" = "read" ] && [ "$2" = "-format=json" ]; then
|
||||||
case "$scenario" in
|
case "$scenario" in
|
||||||
existing)
|
existing)
|
||||||
printf '{"data":{"data":{"build_token":"%064d"}}}\n' 1
|
printf '{"data":{"data":{"build_token":"%064d"},"metadata":{"build_token":"decoy"}}}\n' 1
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
missing-field)
|
missing-field)
|
||||||
printf '{"data":{"data":{"other":"preserve-me"}}}\n'
|
printf '{"data":{"data":{"other":"preserve-me"}}}\n'
|
||||||
exit 0
|
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)
|
absent)
|
||||||
echo 'Code: 404' >&2
|
echo 'Code: 404' >&2
|
||||||
exit 2
|
exit 2
|
||||||
@ -230,7 +272,10 @@ def _run_seeder(
|
|||||||
return result, call_text
|
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(
|
def test_seeder_never_overwrites_existing_or_ambiguous_state(
|
||||||
tmp_path: Path, scenario: str
|
tmp_path: Path, scenario: str
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -241,6 +286,18 @@ def test_seeder_never_overwrites_existing_or_ambiguous_state(
|
|||||||
assert "sys/tools/random" not in calls
|
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(
|
def test_seeder_uses_single_cas_create_and_stdin_for_absent_secret(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user