Resolve 8 conflicts, uniting #16's daemonless image-release lane with #14's SCM broker + node-audit boundary. Nothing dropped from either side. - quality_coverage.py / test_quality_coverage_helpers.py: take main's dual-metric gate (line+branch enforced per file at minimum_percent); drop #14's now-dead branch_tracked_files/minimum_branch_percent keys. - quality_contract.json: union all lists (managed_modules, lint_paths, coverage_sources, tracked_files, hygiene globs) so #14's scm-broker, git_pack_objects, receive_pack_scan, deadline_http and node_polkit_audit are branch-checked alongside #16's image-builder modules. - Vault auth (vault_k8s_auth_configure.sh): coexist both role sets. Keep #16's hermes-switchyard split + hermes-jenkins-token-seed; keep #14's hermes-scm-broker role. Preserve #14's security property: hermes-agent no longer holds developer-gitea (broker role carries it). - agent-deployment.yaml / stage_runtime_access.py: keep #16's jenkins-image-build-token injection/staging; keep #14's removal of the agent's gitea-token/gitea-username. - Bump vault-k8s-auth-hermes job -9 -> -10 (and its health check + test) so the merged auth config re-applies over any -9 already in-cluster. - flux hermes dependsOn: union jenkins + hermes-scm-broker + observer-rbac. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""Vault least-privilege and fail-closed seeding tests for image releases."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import stat
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
VAULT_CONFIG = REPO_ROOT / "services/vault/scripts/vault_k8s_auth_configure.sh"
|
|
SEEDER = (
|
|
REPO_ROOT
|
|
/ "services/vault-hermes-jenkins-token-seed/scripts/vault_hermes_jenkins_build_token_ensure.sh"
|
|
)
|
|
|
|
|
|
def test_developer_jenkins_access_is_read_only_and_bound_to_two_consumers() -> None:
|
|
"""Only the Jenkins controller and Hermes agent can read the fixed-job token."""
|
|
source = VAULT_CONFIG.read_text(encoding="utf-8")
|
|
assert 'write_policy_and_role "jenkins" "jenkins" "jenkins"' in source
|
|
assert (
|
|
'"jenkins/* shared/harbor-pull quality/sonarqube-oidc '
|
|
'hermes/developer-jenkins" ""'
|
|
) in source
|
|
assert 'write_policy_and_role "hermes-agent" "hermes" "hermes-agent"' in source
|
|
assert 'hermes/developer-jenkins hermes/developer-ssh" ""' in source
|
|
assert 'write_policy_and_role "hermes-switchyard" "hermes"' in source
|
|
assert '"hermes/chat-telegram" ""' in source
|
|
assert '"hermes/developer-jenkins"' not in source
|
|
|
|
jenkins_spc = yaml.safe_load(
|
|
(REPO_ROOT / "services/jenkins/secretproviderclass.yaml").read_text()
|
|
)
|
|
assert jenkins_spc["spec"]["parameters"]["roleName"] == "jenkins-vault-sync"
|
|
switchyard = yaml.safe_load(
|
|
(REPO_ROOT / "services/hermes/switchyard-deployment.yaml").read_text()
|
|
)
|
|
annotations = switchyard["spec"]["template"]["metadata"]["annotations"]
|
|
assert annotations["vault.hashicorp.com/role"] == "hermes-switchyard"
|
|
|
|
|
|
def test_flux_orders_seed_then_jenkins_then_hermes() -> None:
|
|
"""The fixed token must be Ready before either consumer is rolled."""
|
|
app_root = REPO_ROOT / "clusters/atlas/flux-system/applications"
|
|
vault = yaml.safe_load((app_root / "vault/kustomization.yaml").read_text())
|
|
seed = yaml.safe_load(
|
|
(app_root / "vault-hermes-jenkins-token-seed/kustomization.yaml").read_text()
|
|
)
|
|
jenkins = yaml.safe_load((app_root / "jenkins/kustomization.yaml").read_text())
|
|
hermes = yaml.safe_load((app_root / "hermes/kustomization.yaml").read_text())
|
|
role_check = {
|
|
"apiVersion": "batch/v1",
|
|
"kind": "Job",
|
|
"name": "vault-k8s-auth-hermes-10",
|
|
"namespace": "vault",
|
|
}
|
|
seed_check = {
|
|
"apiVersion": "batch/v1",
|
|
"kind": "Job",
|
|
"name": "vault-hermes-jenkins-build-token-seed-2",
|
|
"namespace": "vault",
|
|
}
|
|
assert role_check in vault["spec"]["healthChecks"]
|
|
assert {item["name"] for item in seed["spec"]["dependsOn"]} == {"vault"}
|
|
assert seed_check in seed["spec"]["healthChecks"]
|
|
assert jenkins["spec"]["suspend"] is False
|
|
assert {item["name"] for item in jenkins["spec"]["dependsOn"]} >= {
|
|
"helm",
|
|
"harbor",
|
|
"vault-hermes-jenkins-token-seed",
|
|
}
|
|
assert "jenkins" in {item["name"] for item in hermes["spec"]["dependsOn"]}
|
|
|
|
|
|
def test_seed_job_is_revisioned_bounded_and_tracks_fail_closed_script() -> None:
|
|
"""The prerequisite is a tracked one-shot on healthy non-reserved capacity."""
|
|
job = yaml.safe_load(
|
|
(
|
|
REPO_ROOT / "services/vault-hermes-jenkins-token-seed/job.yaml"
|
|
).read_text()
|
|
)
|
|
assert job["metadata"]["name"] == "vault-hermes-jenkins-build-token-seed-2"
|
|
pod = job["spec"]["template"]["spec"]
|
|
assert pod["serviceAccountName"] == "hermes-jenkins-token-seed"
|
|
assert pod["enableServiceLinks"] is False
|
|
assert pod["restartPolicy"] == "Never"
|
|
assert pod["nodeSelector"]["hardware"] == "rpi5"
|
|
expression = pod["affinity"]["nodeAffinity"][
|
|
"requiredDuringSchedulingIgnoredDuringExecution"
|
|
]["nodeSelectorTerms"][0]["matchExpressions"][0]
|
|
assert set(expression["values"]) >= {
|
|
"titan-04",
|
|
"titan-14",
|
|
"titan-18",
|
|
"titan-19",
|
|
"titan-24",
|
|
}
|
|
container = pod["containers"][0]
|
|
assert "@sha256:" in container["image"]
|
|
security = container["securityContext"]
|
|
assert security["runAsNonRoot"] is True
|
|
assert security["readOnlyRootFilesystem"] is True
|
|
assert security["allowPrivilegeEscalation"] is False
|
|
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")
|
|
|
|
|
|
def test_seed_vault_policy_is_exact_create_read_and_rng_only() -> None:
|
|
"""The seed identity cannot rotate, delete, list, or touch unrelated KV."""
|
|
source = VAULT_CONFIG.read_text(encoding="utf-8")
|
|
policy = source.split("hermes_jenkins_token_seed_policy='", 1)[1].split("'", 1)[0]
|
|
assert 'path "kv/data/atlas/hermes/developer-jenkins"' in policy
|
|
assert 'capabilities = ["create", "read"]' in policy
|
|
assert 'path "sys/tools/random/32"' in policy
|
|
assert 'capabilities = ["update"]' in policy
|
|
for forbidden in ("delete", "patch", "list", 'kv/data/atlas/hermes/*'):
|
|
assert forbidden not in policy
|
|
assert 'bound_service_account_names="hermes-jenkins-token-seed"' in source
|
|
assert 'bound_service_account_namespaces="vault"' in source
|
|
|
|
|
|
def _fake_vault_tools(tmp_path: Path) -> tuple[Path, Path, Path]:
|
|
"""Create deterministic Vault/sleep fakes and return their evidence paths."""
|
|
binary_dir = tmp_path / "bin"
|
|
binary_dir.mkdir(parents=True)
|
|
calls = tmp_path / "calls"
|
|
stdin_capture = tmp_path / "stdin"
|
|
vault = binary_dir / "vault"
|
|
vault.write_text(
|
|
"""#!/bin/sh
|
|
set -eu
|
|
printf '%s\n' "$*" >> "$FAKE_CALL_LOG"
|
|
scenario="$FAKE_SCENARIO"
|
|
if [ "$1" = "write" ] && [ "$2" = "-field=random_bytes" ] && [ "$3" = "sys/tools/random/32" ]; then
|
|
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"
|
|
if [ "$scenario" = "cas-race" ]; then
|
|
: > "$FAKE_WINNER"
|
|
echo 'check-and-set parameter did not match' >&2
|
|
exit 2
|
|
fi
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "read" ] && [ "$2" = "-format=json" ]; then
|
|
case "$scenario" in
|
|
existing)
|
|
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
|
|
;;
|
|
cas-race)
|
|
if [ -f "$FAKE_WINNER" ]; then
|
|
printf '{"data":{"data":{"build_token":"%064d"}}}\n' 2
|
|
exit 0
|
|
fi
|
|
echo 'Code: 404' >&2
|
|
exit 2
|
|
;;
|
|
transient)
|
|
count=0
|
|
if [ -f "$FAKE_COUNT" ]; then count="$(cat "$FAKE_COUNT")"; fi
|
|
count=$((count + 1))
|
|
printf '%s' "$count" > "$FAKE_COUNT"
|
|
if [ "$count" -lt 3 ]; then echo 'temporary upstream error' >&2; exit 2; fi
|
|
echo 'Code: 404' >&2
|
|
exit 2
|
|
;;
|
|
read-error)
|
|
echo 'permission denied' >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
fi
|
|
echo "unexpected fake Vault call: $*" >&2
|
|
exit 99
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
sleep = binary_dir / "sleep"
|
|
sleep.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
for executable in (vault, sleep):
|
|
executable.chmod(executable.stat().st_mode | stat.S_IXUSR)
|
|
return binary_dir, calls, stdin_capture
|
|
|
|
|
|
def _run_seeder(
|
|
tmp_path: Path, scenario: str
|
|
) -> tuple[subprocess.CompletedProcess, str]:
|
|
binary_dir, calls, stdin_capture = _fake_vault_tools(tmp_path)
|
|
env = {
|
|
**os.environ,
|
|
"PATH": f"{binary_dir}:{os.environ['PATH']}",
|
|
"VAULT_TOKEN": "test-token",
|
|
"FAKE_SCENARIO": scenario,
|
|
"FAKE_CALL_LOG": str(calls),
|
|
"FAKE_STDIN_CAPTURE": str(stdin_capture),
|
|
"FAKE_WINNER": str(tmp_path / "winner"),
|
|
"FAKE_COUNT": str(tmp_path / "count"),
|
|
"TMPDIR": str(tmp_path),
|
|
}
|
|
result = subprocess.run(
|
|
["sh", str(SEEDER)],
|
|
env=env,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
call_text = calls.read_text(encoding="utf-8") if calls.exists() else ""
|
|
return result, call_text
|
|
|
|
|
|
@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:
|
|
"""Existing fields and persistent read failures can never become a put."""
|
|
result, calls = _run_seeder(tmp_path, scenario)
|
|
assert (result.returncode == 0) is (scenario == "existing")
|
|
assert "write kv/data/atlas/hermes/developer-jenkins" 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(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A confirmed 404 produces one create-only write without a token argument."""
|
|
result, calls = _run_seeder(tmp_path, "absent")
|
|
assert result.returncode == 0, result.stderr
|
|
assert calls.count("write kv/data/atlas/hermes/developer-jenkins") == 1
|
|
assert "@" in calls
|
|
assert "0000000000000000000000000000000000000000000000000000000000000000" not in calls
|
|
payload = (tmp_path / "stdin").read_text(encoding="utf-8")
|
|
assert '"options":{"cas":0}' in payload
|
|
assert '"build_token":"' in payload
|
|
assert not (tmp_path / "hermes-jenkins-token.json").exists()
|
|
|
|
|
|
def test_seeder_retries_reads_and_accepts_only_a_verified_cas_winner(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Transient reads retry; a competing create is accepted only after reread."""
|
|
transient, transient_calls = _run_seeder(tmp_path / "transient", "transient")
|
|
assert transient.returncode == 0, transient.stderr
|
|
assert transient_calls.count("read -format=json kv/data/atlas/hermes/developer-jenkins") == 3
|
|
assert transient_calls.count("write kv/data/atlas/hermes/developer-jenkins") == 1
|
|
|
|
race, race_calls = _run_seeder(tmp_path / "race", "cas-race")
|
|
assert race.returncode == 0, race.stderr
|
|
assert race_calls.count("write kv/data/atlas/hermes/developer-jenkins") == 1
|
|
assert race_calls.count("read -format=json kv/data/atlas/hermes/developer-jenkins") == 2
|
|
assert "another seeder won CAS" in race.stderr
|