374 lines
13 KiB
Python
374 lines
13 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Acceptance checks for forge authority, node accounts, images, and regressions.
|
||
|
|
|
||
|
|
What the platform is allowed to do outside its own namespace: which forge
|
||
|
|
authority a worker holds, what a node account is and is not, how an image is
|
||
|
|
built and pinned, and whether the fixes the release depends on are actually
|
||
|
|
running rather than merely merged.
|
||
|
|
|
||
|
|
Where a property has no side-effect-free live probe — a merge that must never be
|
||
|
|
attempted, a branch protection the harness identity cannot read — the entry says
|
||
|
|
so in its rationale and asserts the nearest fact that *is* observable, rather
|
||
|
|
than quietly asserting nothing.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from hermes_handoff_catalog import (
|
||
|
|
FORGE_CREDENTIAL_NAMES,
|
||
|
|
INIT_NAME_PROJECTION,
|
||
|
|
NODE,
|
||
|
|
NODE_NAME_PROJECTION,
|
||
|
|
OPERATOR,
|
||
|
|
SELF,
|
||
|
|
Targets,
|
||
|
|
check,
|
||
|
|
step,
|
||
|
|
)
|
||
|
|
from hermes_handoff_model import ATTEMPT, CheckSpec
|
||
|
|
from hermes_handoff_policy import GITEA_CLIENT, shell
|
||
|
|
|
||
|
|
HOST_ETC = "/host-etc"
|
||
|
|
HOST_HOME = "/host-home"
|
||
|
|
|
||
|
|
|
||
|
|
def _forge(targets: Targets) -> list[CheckSpec]:
|
||
|
|
api = f"/api/v1/repos/{targets.repo}"
|
||
|
|
return [
|
||
|
|
check(
|
||
|
|
"forge.worker-holds-no-git-credential-variable",
|
||
|
|
"No forge credential is exported into the worker process",
|
||
|
|
"forge",
|
||
|
|
"names_absent",
|
||
|
|
[step("env", SELF, *shell("env_names"))],
|
||
|
|
{"step": "env", "names": FORGE_CREDENTIAL_NAMES},
|
||
|
|
rationale="Authentication reaches Git through an askpass helper that reads runtime state, never through the environment.",
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"forge.worker-holds-no-credential-file",
|
||
|
|
"The worker home has no stored Git credential file",
|
||
|
|
"forge",
|
||
|
|
"stdout_matches",
|
||
|
|
[step("credentials", SELF, *shell("path_readable", path="/opt/data/.git-credentials"))],
|
||
|
|
{"step": "credentials", "equals": "unreadable"},
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"forge.identity-is-not-an-administrator",
|
||
|
|
"The forge identity the worker uses holds no administrative rights",
|
||
|
|
"forge",
|
||
|
|
"json_field",
|
||
|
|
[step("user", SELF, GITEA_CLIENT, "GET", "/api/v1/user")],
|
||
|
|
{"step": "user", "fields": {"is_admin": False}},
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"forge.repository-authority-is-push-only",
|
||
|
|
"The worker can push a branch but cannot administer the repository",
|
||
|
|
"forge",
|
||
|
|
"json_field",
|
||
|
|
[step("repo", SELF, GITEA_CLIENT, "GET", api)],
|
||
|
|
{
|
||
|
|
"step": "repo",
|
||
|
|
"fields": {
|
||
|
|
"permissions.admin": False,
|
||
|
|
"permissions.push": True,
|
||
|
|
"permissions.pull": True,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
rationale="Merge, approve, close, and protection changes all require authority this identity is proven not to hold.",
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"forge.administrative-route-is-refused",
|
||
|
|
"An administrative forge route is refused for this identity",
|
||
|
|
"forge",
|
||
|
|
"denied",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"attempt",
|
||
|
|
SELF,
|
||
|
|
GITEA_CLIENT,
|
||
|
|
"GET",
|
||
|
|
f"{api}/branch_protections",
|
||
|
|
kind=ATTEMPT,
|
||
|
|
)
|
||
|
|
],
|
||
|
|
rationale="A live refusal, not a configuration reading. The same lack of authority is what blocks a merge route.",
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"forge.clone-and-fetch-still-work",
|
||
|
|
"The worker can still read the repository it is expected to work in",
|
||
|
|
"forge",
|
||
|
|
"allowed",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"ls-remote",
|
||
|
|
SELF,
|
||
|
|
*shell(
|
||
|
|
"git_ls_remote",
|
||
|
|
askpass=targets.git_askpass,
|
||
|
|
url=f"https://scm.bstein.dev/{targets.repo}.git",
|
||
|
|
),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def _nodes(targets: Targets) -> list[CheckSpec]:
|
||
|
|
account = targets.node_account
|
||
|
|
return [
|
||
|
|
check(
|
||
|
|
"nodes.account-password-is-locked",
|
||
|
|
"The dedicated node account has no usable password",
|
||
|
|
"nodes",
|
||
|
|
"stdout_matches",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"lock",
|
||
|
|
NODE,
|
||
|
|
*shell("account_lock_state", account=account, path=f"{HOST_ETC}/shadow"),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
{"step": "lock", "equals": "locked"},
|
||
|
|
rationale="The probe reports the lock marker only; the password hash is never read.",
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"nodes.account-identity-is-dedicated",
|
||
|
|
"The node account is the dedicated Hermes identity, not a shared one",
|
||
|
|
"nodes",
|
||
|
|
"stdout_matches",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"identity",
|
||
|
|
NODE,
|
||
|
|
*shell("account_identity", account=account, path=f"{HOST_ETC}/passwd"),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
{"step": "identity", "equals": targets.node_account_identity},
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"nodes.account-holds-no-supplementary-groups",
|
||
|
|
"The node account joins no supplementary or administrative group",
|
||
|
|
"nodes",
|
||
|
|
"stdout_matches",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"groups",
|
||
|
|
NODE,
|
||
|
|
*shell("account_groups", account=account, path=f"{HOST_ETC}/group"),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
{"step": "groups", "equals": ""},
|
||
|
|
rationale="sudo, docker, adm, and disk are all group grants; an empty membership list rules out every one of them.",
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"nodes.account-has-no-sudo-policy",
|
||
|
|
"No sudoers fragment names the node account",
|
||
|
|
"nodes",
|
||
|
|
"stdout_matches",
|
||
|
|
[step("sudoers", NODE, *shell("path_mode", path=f"{HOST_ETC}/sudoers.d/{account}"))],
|
||
|
|
{"step": "sudoers", "equals": "absent"},
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"nodes.account-holds-a-single-authorized-key",
|
||
|
|
"Exactly one authorized key is installed for the node account",
|
||
|
|
"nodes",
|
||
|
|
"stdout_matches",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"keys",
|
||
|
|
NODE,
|
||
|
|
*shell("line_count", path=f"{HOST_HOME}/{account}/.ssh/authorized_keys"),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
{"step": "keys", "equals": "1"},
|
||
|
|
rationale="A count, never the key material. A second key is how a human identity re-enters an account that is meant to be dedicated.",
|
||
|
|
),
|
||
|
|
*[
|
||
|
|
check(
|
||
|
|
f"nodes.{label}-is-root-only",
|
||
|
|
f"{title} remains unreadable to the node account",
|
||
|
|
"nodes",
|
||
|
|
"stdout_matches",
|
||
|
|
[step("mode", NODE, *shell("path_mode", path=path))],
|
||
|
|
{"step": "mode", "equals": expected},
|
||
|
|
)
|
||
|
|
for label, title, path, expected in (
|
||
|
|
(
|
||
|
|
"k3s-credentials",
|
||
|
|
"The K3s admin kubeconfig",
|
||
|
|
f"{HOST_ETC}/rancher/k3s/k3s.yaml",
|
||
|
|
["600 root:root", "absent"],
|
||
|
|
),
|
||
|
|
("k3s-state", "K3s server state", "/host-k3s", ["700 root:root", "absent"]),
|
||
|
|
("kubelet-state", "Kubelet state", "/host-kubelet", ["700 root:root", "750 root:root", "absent"]),
|
||
|
|
("container-runtime", "The container runtime socket directory", "/host-run-containerd", ["710 root:root", "700 root:root", "absent"]),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
check(
|
||
|
|
"nodes.hardening-covers-every-node",
|
||
|
|
"The node account audit runs on every node in the cluster",
|
||
|
|
"nodes",
|
||
|
|
"distinct_count",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"coverage",
|
||
|
|
OPERATOR,
|
||
|
|
"kubectl",
|
||
|
|
"--namespace",
|
||
|
|
targets.namespace,
|
||
|
|
"get",
|
||
|
|
"pods",
|
||
|
|
"--selector",
|
||
|
|
f"app={targets.node_daemonset}",
|
||
|
|
"-o",
|
||
|
|
NODE_NAME_PROJECTION,
|
||
|
|
)
|
||
|
|
],
|
||
|
|
{"step": "coverage", "minimum": targets.node_count},
|
||
|
|
rationale="A hardened fleet with one unenrolled node is an unhardened fleet.",
|
||
|
|
),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def _build(targets: Targets) -> list[CheckSpec]:
|
||
|
|
pipeline = f"{targets.remote}/main:{targets.builder_pipeline}"
|
||
|
|
return [
|
||
|
|
check(
|
||
|
|
"build.builder-service-account-is-tokenless",
|
||
|
|
"The image builder service account mounts no API token",
|
||
|
|
"build",
|
||
|
|
"json_field",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"serviceaccount",
|
||
|
|
OPERATOR,
|
||
|
|
"kubectl",
|
||
|
|
"--namespace",
|
||
|
|
targets.builder_namespace,
|
||
|
|
"get",
|
||
|
|
"serviceaccount",
|
||
|
|
targets.builder_serviceaccount,
|
||
|
|
"-o",
|
||
|
|
"json",
|
||
|
|
)
|
||
|
|
],
|
||
|
|
{"step": "serviceaccount", "fields": {"automountServiceAccountToken": False}},
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"build.builder-runs-unprivileged-with-exact-capabilities",
|
||
|
|
"The build pod is unprivileged and adds only the expected capabilities",
|
||
|
|
"build",
|
||
|
|
"names_present",
|
||
|
|
[step("pipeline", OPERATOR, "git", "show", pipeline, record=False)],
|
||
|
|
{
|
||
|
|
"step": "pipeline",
|
||
|
|
"names": (
|
||
|
|
targets.builder_capabilities,
|
||
|
|
"privileged: false",
|
||
|
|
"allowPrivilegeEscalation: false",
|
||
|
|
"automountServiceAccountToken: false",
|
||
|
|
'drop: ["ALL"]',
|
||
|
|
),
|
||
|
|
},
|
||
|
|
rationale="Exact lines, not a substring sweep: a widened capability set has to change one of these lines to take effect.",
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"build.harbor-immutability-rule-is-applied",
|
||
|
|
"The Harbor immutable-tag rule for the agent image has been applied",
|
||
|
|
"build",
|
||
|
|
"json_numeric",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"job",
|
||
|
|
OPERATOR,
|
||
|
|
"kubectl",
|
||
|
|
"--namespace",
|
||
|
|
targets.harbor_namespace,
|
||
|
|
"get",
|
||
|
|
"job",
|
||
|
|
targets.harbor_immutability_job,
|
||
|
|
"-o",
|
||
|
|
"json",
|
||
|
|
)
|
||
|
|
],
|
||
|
|
{"step": "job", "fields": {"status.succeeded": {"min": 1}}},
|
||
|
|
rationale="The job verifies the rule it creates, so a completed run is evidence the rule matched the expected contract.",
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"build.deployed-image-is-digest-pinned",
|
||
|
|
"The running agent image is pinned by digest, not by a mutable tag",
|
||
|
|
"build",
|
||
|
|
"names_present",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"image",
|
||
|
|
OPERATOR,
|
||
|
|
"kubectl",
|
||
|
|
"--namespace",
|
||
|
|
targets.namespace,
|
||
|
|
"get",
|
||
|
|
f"deploy/{targets.agent_deployment}",
|
||
|
|
"-o",
|
||
|
|
'jsonpath={range .spec.template.spec.containers[*]}{.image}{"\\n"}{end}',
|
||
|
|
)
|
||
|
|
],
|
||
|
|
{"step": "image", "contains": ("@sha256:",)},
|
||
|
|
),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def _reliability(targets: Targets) -> list[CheckSpec]:
|
||
|
|
return [
|
||
|
|
check(
|
||
|
|
"reliability.finalization-and-replay-patches-are-deployed",
|
||
|
|
"The agent workload runs the finalization, replay, and session patches",
|
||
|
|
"reliability",
|
||
|
|
"names_present",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"init",
|
||
|
|
OPERATOR,
|
||
|
|
"kubectl",
|
||
|
|
"--namespace",
|
||
|
|
targets.namespace,
|
||
|
|
"get",
|
||
|
|
f"deploy/{targets.agent_deployment}",
|
||
|
|
"-o",
|
||
|
|
INIT_NAME_PROJECTION,
|
||
|
|
)
|
||
|
|
],
|
||
|
|
{"step": "init", "names": targets.agent_init_containers},
|
||
|
|
rationale="The regression suites live in the repository; this asserts the fixes they cover are actually running.",
|
||
|
|
),
|
||
|
|
check(
|
||
|
|
"reliability.regression-suites-are-present-on-main",
|
||
|
|
"The decomposition and worker-recovery regression suites are on main",
|
||
|
|
"reliability",
|
||
|
|
"names_present",
|
||
|
|
[
|
||
|
|
step(
|
||
|
|
"suites",
|
||
|
|
OPERATOR,
|
||
|
|
"git",
|
||
|
|
"ls-tree",
|
||
|
|
"--name-only",
|
||
|
|
f"{targets.remote}/main",
|
||
|
|
"testing/tests/",
|
||
|
|
)
|
||
|
|
],
|
||
|
|
{
|
||
|
|
"step": "suites",
|
||
|
|
"names": (
|
||
|
|
"testing/tests/test_hermes_cli_lanes.py",
|
||
|
|
"testing/tests/test_hermes_coordinator.py",
|
||
|
|
"testing/tests/test_hermes_worker_recovery.py",
|
||
|
|
),
|
||
|
|
},
|
||
|
|
),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def delivery_checks(targets: Targets) -> list[CheckSpec]:
|
||
|
|
"""Return the forge, node, build, and reliability checks."""
|
||
|
|
return [*_forge(targets), *_nodes(targets), *_build(targets), *_reliability(targets)]
|