From eafd48d197c5ee14481892e7c6c0df4779297648 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Tue, 29 Sep 2026 16:42:35 +0100 Subject: [PATCH] Give RC qualification a local host-interface canary The immutable RC systemd lab failed before installed acceptance because its --network none container had no non-loopback IPv4 address to prove the helper's private network cannot reach. Add a veth canary wholly inside that namespace and assert no default route before the lab runs. Also check out the repository in the demo resolver before it calls scripts/write_github_output.py, so verification-only dry runs reach their demo checks. Split out of #2323 so the v6.4.5 promotion is not held behind the unrelated action-review UI work in that PR. --- .../qualify-secure-runtime-release.yml | 16 +++++++- .github/workflows/update-demo-server.yml | 14 +++---- .../subsystems/deployment-installability.md | 12 ++++++ .../installtests/build_release_assets_test.go | 38 ++++++++++++++++++- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/.github/workflows/qualify-secure-runtime-release.yml b/.github/workflows/qualify-secure-runtime-release.yml index d0fcc1a2dd..348c337f31 100644 --- a/.github/workflows/qualify-secure-runtime-release.yml +++ b/.github/workflows/qualify-secure-runtime-release.yml @@ -224,7 +224,7 @@ jobs: COPY --from=dind /usr/local/bin/ /usr/local/bin/ COPY pulse-qualification-docker.service /etc/systemd/system/pulse-qualification-docker.service RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - ca-certificates curl dbus systemd systemd-sysv util-linux && \ + ca-certificates curl dbus systemd systemd-sysv util-linux iproute2 && \ apt-get clean && \ systemctl mask dev-hugepages.mount sys-fs-fuse-connections.mount systemd-remount-fs.service docker.service docker.socket && \ systemctl enable pulse-qualification-docker.service @@ -269,7 +269,19 @@ jobs: sleep 2 done docker exec "${container}" sh -c \ - 'for command in curl docker dockerd id nsenter runuser systemctl; do command -v "$command" >/dev/null || exit 1; done' + 'for command in curl docker dockerd id ip nsenter runuser systemctl; do command -v "$command" >/dev/null || exit 1; done' + # The immutable RC test needs a reachable non-loopback host-interface + # canary to prove that the helper's private network cannot reach it. + # Both veth ends stay inside this --network none namespace; neither + # endpoint is attached to the runner or an egress bridge. + docker exec "${container}" sh -ceu ' + ip link add pulse-can0 type veth peer name pulse-can1 + ip address add 192.0.2.1/32 dev pulse-can0 + ip link set pulse-can1 up + ip link set pulse-can0 up + test -z "$(ip -4 route show default)" + ip -4 address show dev pulse-can0 | grep -F "192.0.2.1/32" + ' docker exec "${container}" systemctl start pulse-qualification-docker.service for attempt in $(seq 1 60); do if docker exec "${container}" docker info >/dev/null 2>&1; then diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index c457385175..996f7bda56 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -74,6 +74,13 @@ jobs: mock_update_interval: ${{ steps.runtime_profile.outputs.mock_update_interval }} steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + fetch-tags: true + - name: Resolve target tag and demo environment id: target env: @@ -137,13 +144,6 @@ jobs: echo "environment_name=$ENVIRONMENT_NAME" >> "$GITHUB_OUTPUT" echo "Resolved demo deployment: tag=${TAG}, target=${TARGET}, environment=${ENVIRONMENT_NAME}" - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - fetch-depth: 0 - fetch-tags: true - - name: Validate governed release line for selected tag env: WORKFLOW_OUTPUT_1: ${{ steps.target.outputs.tag }} diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 169e32b9b8..2520ea971c 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1296,6 +1296,14 @@ artifact-selection behaviour. privileged systemd container must mount that exact snapshot read-only and must never execute directly from the download directory. The post-run attester must consume the same snapshot paths. + The outer systemd qualification container must retain `--network none`: + it may create a non-loopback host-interface canary only as a veth pair + wholly inside that container's network namespace, with no runner bridge, + egress attachment, or default IPv4 route. The immutable RC lab uses this + reachable local canary to distinguish host-network access from the typed + helper's private network isolation. Canary setup and the no-default-route + check must fail qualification before that lab if the boundary is absent; + they must not skip the lab or change the release packet under test. PVE jobs must consume their runner users' persistent local Go and npm caches directly; disposable-runner Actions cache restore/save phases must remain disabled because archiving those same caches adds network work after the @@ -3831,6 +3839,10 @@ bypass the preflight. That dry run must call `update-demo-server.yml` in verification-only mode against the latest stable release. It must prove Tailscale, SSH host identity, runtime version, frontend parity, public health, and browser smoke without changing the host. +The reusable demo workflow's resolver must check out the repository before it +calls `scripts/write_github_output.py` to emit the selected tag and target. +This checkout is credential-free; a verification-only dry run must not fail +before its demo checks merely because the output helper is absent. That same release-validation boundary also owns draft-versus-published asset state. Every normal `.github/workflows/create-release.yml` cut validates the uploaded packet while the release is still a draft and must pass `draft=true` diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 8f89371cc1..96e889fd40 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2392,6 +2392,29 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { } } +func TestUpdateDemoResolverChecksOutOutputHelperBeforeUse(t *testing.T) { + workflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "update-demo-server.yml")) + if err != nil { + t.Fatalf("read update-demo-server workflow: %v", err) + } + workflow := string(workflowBytes) + resolveStart := strings.Index(workflow, "\n resolve:\n") + updateStart := strings.Index(workflow, "\n update-demo:\n") + if resolveStart < 0 || updateStart <= resolveStart { + t.Fatal("update-demo-server workflow must retain separate resolve and update-demo jobs") + } + resolve := workflow[resolveStart:updateStart] + checkout := strings.Index(resolve, "- name: Checkout repository") + target := strings.Index(resolve, "- name: Resolve target tag and demo environment") + helper := strings.Index(resolve, "python3 scripts/write_github_output.py tag \"$TAG\"") + if checkout < 0 || target <= checkout || helper <= target { + t.Fatal("demo resolver must check out its source before using scripts/write_github_output.py") + } + if !strings.Contains(resolve[checkout:target], "persist-credentials: false") { + t.Fatal("demo resolver checkout must not persist GitHub credentials") + } +} + func TestDemoMutationAndRecoverySharePhysicalTargetLock(t *testing.T) { updateBytes, err := os.ReadFile(repoFile(".github", "workflows", "update-demo-server.yml")) if err != nil { @@ -2824,12 +2847,15 @@ func TestSecureRuntimeQualificationPacketIsHostedAndReleaseBound(t *testing.T) { `git remote set-url origin https://github.com/rcourtman/Pulse.git`, "ca-certificates curl dbus systemd systemd-sysv util-linux", `docker:27.5.1-dind@sha256:f649ef046008ca7f926a2571c32b0ac22e5c59eb61b959617f9acc2a4c638cf5`, - `for command in curl docker dockerd id nsenter runuser systemctl`, + `for command in curl docker dockerd id ip nsenter runuser systemctl`, `--host=unix:///var/run/docker.sock`, `--storage-driver=vfs`, `--bridge=none`, `--iptables=false`, `--network none`, + `ip link add pulse-can0 type veth peer name pulse-can1`, + `ip address add 192.0.2.1/32 dev pulse-can0`, + `test -z "$(ip -4 route show default)"`, `pulse-secure-runtime-fixture:v7`, "--verify-release-packet-only", "--verified-packet-dir", @@ -2847,6 +2873,16 @@ func TestSecureRuntimeQualificationPacketIsHostedAndReleaseBound(t *testing.T) { t.Fatalf("post-publication secure-runtime qualification missing %q", required) } } + canaryIndex := strings.Index(qualificationWorkflow, `ip link add pulse-can0 type veth peer name pulse-can1`) + labIndex := strings.Index(qualificationWorkflow, `--env PULSE_SECURE_RUNTIME_SYSTEMD_LAB=1`) + if canaryIndex <= privilegedExecutionIndex || labIndex <= canaryIndex { + t.Fatal("the isolated host-interface canary must be configured before running the immutable RC lab") + } + if !strings.Contains(qualificationWorkflow, `--privileged \ + --network none \ + --cgroupns=host`) { + t.Fatal("the outer systemd lab must retain its no-network namespace") + } for _, forbidden := range []string{ `/var/run/docker.sock:/var/run/docker.sock`, `--host=tcp://`,