mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-13 00:34:43 +00:00
Continuously verify stable container aliases
This commit is contained in:
parent
161e322ee0
commit
7d9e77ff32
7 changed files with 268 additions and 6 deletions
15
.github/workflows/README.md
vendored
15
.github/workflows/README.md
vendored
|
|
@ -19,12 +19,15 @@ data and must never be interpolated into the generated shell program.
|
|||
|
||||
## Release Continuity
|
||||
|
||||
The weekly `security-scan.yml` backstop also verifies the latest advertised
|
||||
stable release from the public surfaces customers use. It binds the immutable
|
||||
GitHub release and activation marker to one source commit, authenticates every
|
||||
checksummed release asset and SSH signature, re-verifies release and build
|
||||
attestations, and requires the exact Docker Hub, GHCR, and OCI Helm identities
|
||||
to remain equal to the digests committed at activation. Every run retains a
|
||||
The weekly `security-scan.yml` backstop and an immediate read-back after every
|
||||
stable release convergence verify the latest advertised stable release from
|
||||
the public surfaces customers use. They bind the immutable
|
||||
GitHub release and activation marker to one source commit, authenticate every
|
||||
checksummed release asset and SSH signature, re-verify release and build
|
||||
attestations, and require the exact Docker Hub, GHCR, and OCI Helm identities
|
||||
to remain equal to the digests committed at activation. Stable Docker Hub and
|
||||
GHCR discovery aliases (`latest`, major, and major-minor) must also retain
|
||||
those identities. Every run retains a
|
||||
machine-readable evidence packet for 90 days, including partial outcomes when
|
||||
a check fails. The job is read-only and requires the public
|
||||
`PULSE_UPDATE_SIGNING_PUBLIC_KEY` repository variable.
|
||||
|
|
|
|||
3
.github/workflows/canonical-governance.yml
vendored
3
.github/workflows/canonical-governance.yml
vendored
|
|
@ -256,6 +256,9 @@ jobs:
|
|||
- name: Run release container identity verifier unit tests
|
||||
run: python3 scripts/release_control/verify_release_container_images_test.py
|
||||
|
||||
- name: Run stable container alias verifier unit tests
|
||||
run: python3 scripts/release_control/verify_stable_container_aliases_test.py
|
||||
|
||||
- name: Run immutable release integrity unit tests
|
||||
run: python3 scripts/release_control/verify_github_release_integrity_test.py
|
||||
|
||||
|
|
|
|||
45
.github/workflows/security-scan.yml
vendored
45
.github/workflows/security-scan.yml
vendored
|
|
@ -10,6 +10,9 @@ on:
|
|||
schedule:
|
||||
- cron: '30 5 * * 1' # weekly, Monday 05:30 UTC, before the 06:00 triage run
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: [Release Convergence]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -17,6 +20,10 @@ permissions:
|
|||
jobs:
|
||||
release-continuity:
|
||||
name: Latest stable release continuity
|
||||
# Stable convergence gets an immediate independent read-back. Prerelease
|
||||
# convergence is excluded because this job intentionally follows the
|
||||
# advertised stable channel; the weekly and manual backstops always run.
|
||||
if: ${{ github.event_name != 'workflow_run' || !contains(github.event.workflow_run.display_title, '-') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
|
|
@ -135,6 +142,19 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify stable container discovery aliases
|
||||
id: aliases
|
||||
if: ${{ always() && steps.release.outcome == 'success' }}
|
||||
env:
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
EXPECTED_SERVER_DIGEST: ${{ steps.release.outputs.server_image_digest }}
|
||||
EXPECTED_CONTROL_PLANE_DIGEST: ${{ steps.release.outputs.control_plane_image_digest }}
|
||||
REGISTRY_OWNER: ${{ github.repository_owner }}
|
||||
run: >-
|
||||
./scripts/verify-stable-container-aliases.sh
|
||||
"${TAG}" "${EXPECTED_SERVER_DIGEST}" "${EXPECTED_CONTROL_PLANE_DIGEST}"
|
||||
"${REGISTRY_OWNER}"
|
||||
|
||||
- name: Verify exact-version Helm identity
|
||||
id: helm
|
||||
if: ${{ always() && steps.release.outcome == 'success' }}
|
||||
|
|
@ -154,6 +174,11 @@ jobs:
|
|||
REPOSITORY: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
TRIGGER_EVENT: ${{ github.event_name }}
|
||||
CONVERGENCE_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
CONVERGENCE_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
|
||||
CONVERGENCE_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
|
||||
CONVERGENCE_DISPLAY_TITLE: ${{ github.event.workflow_run.display_title }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||
SOURCE_SHA: ${{ steps.release.outputs.source_sha }}
|
||||
|
|
@ -165,6 +190,7 @@ jobs:
|
|||
PACKET_RESULT: ${{ steps.packet.outcome }}
|
||||
ASSET_RESULT: ${{ steps.assets.outcome }}
|
||||
CONTAINER_RESULT: ${{ steps.containers.outcome }}
|
||||
ALIAS_RESULT: ${{ steps.aliases.outcome }}
|
||||
HELM_RESULT: ${{ steps.helm.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
@ -176,6 +202,11 @@ jobs:
|
|||
--arg repository "${REPOSITORY}" \
|
||||
--arg run_id "${RUN_ID}" \
|
||||
--arg run_attempt "${RUN_ATTEMPT}" \
|
||||
--arg trigger_event "${TRIGGER_EVENT}" \
|
||||
--arg convergence_run_id "${CONVERGENCE_RUN_ID}" \
|
||||
--arg convergence_run_attempt "${CONVERGENCE_RUN_ATTEMPT}" \
|
||||
--arg convergence_conclusion "${CONVERGENCE_CONCLUSION}" \
|
||||
--arg convergence_display_title "${CONVERGENCE_DISPLAY_TITLE}" \
|
||||
--arg tag "${TAG}" \
|
||||
--arg release_id "${RELEASE_ID}" \
|
||||
--arg source_sha "${SOURCE_SHA}" \
|
||||
|
|
@ -187,12 +218,22 @@ jobs:
|
|||
--arg packet_result "${PACKET_RESULT}" \
|
||||
--arg asset_result "${ASSET_RESULT}" \
|
||||
--arg container_result "${CONTAINER_RESULT}" \
|
||||
--arg alias_result "${ALIAS_RESULT}" \
|
||||
--arg helm_result "${HELM_RESULT}" \
|
||||
'{
|
||||
schema_version: ($schema_version | tonumber),
|
||||
checked_at: $checked_at,
|
||||
repository: $repository,
|
||||
workflow_run: {id: $run_id, attempt: $run_attempt},
|
||||
trigger: {
|
||||
event: $trigger_event,
|
||||
release_convergence_run: {
|
||||
id: $convergence_run_id,
|
||||
attempt: $convergence_run_attempt,
|
||||
conclusion: $convergence_conclusion,
|
||||
display_title: $convergence_display_title
|
||||
}
|
||||
},
|
||||
release: {
|
||||
tag: $tag,
|
||||
id: $release_id,
|
||||
|
|
@ -207,6 +248,7 @@ jobs:
|
|||
immutable_packet_and_provenance: $packet_result,
|
||||
authenticated_assets: $asset_result,
|
||||
container_identities: $container_result,
|
||||
stable_container_aliases: $alias_result,
|
||||
helm_identity: $helm_result
|
||||
}
|
||||
}' > release-continuity-evidence/continuity-evidence.json
|
||||
|
|
@ -223,6 +265,7 @@ jobs:
|
|||
|
||||
container-lifecycle:
|
||||
name: Container trust and support window
|
||||
if: ${{ github.event_name != 'workflow_run' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
|
@ -242,6 +285,7 @@ jobs:
|
|||
|
||||
govulncheck:
|
||||
name: Go Vulnerability Scan (${{ matrix.directory }})
|
||||
if: ${{ github.event_name != 'workflow_run' }}
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
@ -269,6 +313,7 @@ jobs:
|
|||
|
||||
npm-audit:
|
||||
name: npm Dependency Audit (${{ matrix.name }})
|
||||
if: ${{ github.event_name != 'workflow_run' }}
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -278,6 +278,7 @@ scripts/release_control/*
|
|||
!scripts/release_control/subsystem_lookup.py
|
||||
!scripts/release_control/subsystem_lookup_test.py
|
||||
!scripts/release_control/verify_github_release_integrity_test.py
|
||||
!scripts/release_control/verify_stable_container_aliases_test.py
|
||||
!scripts/release_control/ssh_host_key_policy_test.py
|
||||
!scripts/release_control/work_claim.py
|
||||
!scripts/release_control/work_claim_test.py
|
||||
|
|
|
|||
|
|
@ -151,6 +151,9 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) {
|
|||
}
|
||||
workflow := string(content)
|
||||
required := []string{
|
||||
"workflow_run:",
|
||||
"workflows: [Release Convergence]",
|
||||
`github.event_name != 'workflow_run' || !contains(github.event.workflow_run.display_title, '-')`,
|
||||
"release-continuity:",
|
||||
"Latest stable release continuity",
|
||||
"docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5",
|
||||
|
|
@ -163,6 +166,10 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) {
|
|||
`./scripts/verify-release-container-images.sh`,
|
||||
`EXPECTED_SERVER_DIGEST: ${{ steps.release.outputs.server_image_digest }}`,
|
||||
`EXPECTED_CONTROL_PLANE_DIGEST: ${{ steps.release.outputs.control_plane_image_digest }}`,
|
||||
`./scripts/verify-stable-container-aliases.sh`,
|
||||
`stable_container_aliases: $alias_result`,
|
||||
`CONVERGENCE_RUN_ID: ${{ github.event.workflow_run.id }}`,
|
||||
`release_convergence_run: {`,
|
||||
`./scripts/verify-release-helm-chart.sh`,
|
||||
`EXPECTED_HELM_DIGEST: ${{ steps.release.outputs.helm_chart_digest }}`,
|
||||
"continuity-evidence.json",
|
||||
|
|
@ -176,6 +183,11 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) {
|
|||
if strings.Contains(workflowJobBlock(t, workflow, "release-continuity"), "contents: write") {
|
||||
t.Fatal("scheduled release continuity check must remain read-only")
|
||||
}
|
||||
for _, jobName := range []string{"container-lifecycle", "govulncheck", "npm-audit"} {
|
||||
if !strings.Contains(workflowJobBlock(t, workflow, jobName), `github.event_name != 'workflow_run'`) {
|
||||
t.Fatalf("%s must not run for the post-convergence continuity trigger", jobName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProPackagingBuildsFrontendEmbedWithoutTransferringBundle(t *testing.T) {
|
||||
|
|
|
|||
124
scripts/release_control/verify_stable_container_aliases_test.py
Normal file
124
scripts/release_control/verify_stable_container_aliases_test.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "verify-stable-container-aliases.sh"
|
||||
SERVER_DIGEST = "sha256:" + "a" * 64
|
||||
CONTROL_PLANE_DIGEST = "sha256:" + "b" * 64
|
||||
|
||||
|
||||
class VerifyStableContainerAliasesTests(unittest.TestCase):
|
||||
def run_verifier(
|
||||
self,
|
||||
*,
|
||||
overrides: dict[str, str] | None = None,
|
||||
tag: str = "v6.4.1",
|
||||
owner: str = "rcourtman",
|
||||
) -> tuple[subprocess.CompletedProcess[str], list[str]]:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
temp_path = Path(temp)
|
||||
bin_path = temp_path / "bin"
|
||||
bin_path.mkdir()
|
||||
digest_file = temp_path / "digests"
|
||||
call_log = temp_path / "calls"
|
||||
values: dict[str, str] = {}
|
||||
for image, digest in (
|
||||
("pulse", SERVER_DIGEST),
|
||||
("pulse-control-plane", CONTROL_PLANE_DIGEST),
|
||||
):
|
||||
for registry in ("docker.io/rcourtman", f"ghcr.io/{owner}"):
|
||||
for alias in ("6.4", "6", "latest"):
|
||||
values[f"{registry}/{image}:{alias}"] = digest
|
||||
values.update(overrides or {})
|
||||
digest_file.write_text(
|
||||
"\n".join(f"{reference} {digest}" for reference, digest in values.items())
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bin_path / "docker").write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
#!/bin/sh
|
||||
reference="$4"
|
||||
printf '%s\n' "$reference" >> "$CALL_LOG"
|
||||
digest=$(awk -v ref="$reference" '$1 == ref { print $2 }' "$DIGEST_FILE")
|
||||
[ -n "$digest" ] || exit 1
|
||||
printf '{"digest":"%s"}\n' "$digest"
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bin_path / "docker").chmod(0o755)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"PATH": f"{bin_path}:{env['PATH']}",
|
||||
"DIGEST_FILE": str(digest_file),
|
||||
"CALL_LOG": str(call_log),
|
||||
}
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(SCRIPT),
|
||||
tag,
|
||||
SERVER_DIGEST,
|
||||
CONTROL_PLANE_DIGEST,
|
||||
owner,
|
||||
],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
calls = call_log.read_text(encoding="utf-8").splitlines() if call_log.exists() else []
|
||||
return result, calls
|
||||
|
||||
def test_verifies_every_stable_alias_in_both_registries(self) -> None:
|
||||
result, calls = self.run_verifier()
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(len(calls), 12)
|
||||
self.assertIn("docker.io/rcourtman/pulse:latest", calls)
|
||||
self.assertIn("ghcr.io/rcourtman/pulse-control-plane:6.4", calls)
|
||||
|
||||
def test_rejects_a_moved_alias(self) -> None:
|
||||
moved = "sha256:" + "c" * 64
|
||||
result, calls = self.run_verifier(
|
||||
overrides={"ghcr.io/rcourtman/pulse:latest": moved}
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("resolved to", result.stderr)
|
||||
self.assertIn("ghcr.io/rcourtman/pulse:latest", calls)
|
||||
|
||||
def test_rejects_prerelease_without_registry_calls(self) -> None:
|
||||
result, calls = self.run_verifier(tag="v6.4.2-rc.1")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Invalid stable release tag", result.stderr)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_rejects_invalid_digest_without_registry_calls(self) -> None:
|
||||
result = subprocess.run(
|
||||
[str(SCRIPT), "v6.4.2", "latest", CONTROL_PLANE_DIGEST],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Invalid server image digest", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
74
scripts/verify-stable-container-aliases.sh
Executable file
74
scripts/verify-stable-container-aliases.sh
Executable file
|
|
@ -0,0 +1,74 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Verify that every stable floating container alias still selects the exact
|
||||
# image digest committed by release activation. Exact-version provenance is
|
||||
# checked separately by verify-release-container-images.sh; this check covers
|
||||
# the mutable discovery paths customers can continue to pull after promotion.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -lt 3 ] || [ "$#" -gt 4 ]; then
|
||||
echo "Usage: $0 <stable-tag> <server-digest> <control-plane-digest> [owner]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
TAG="$1"
|
||||
SERVER_DIGEST="$2"
|
||||
CONTROL_PLANE_DIGEST="$3"
|
||||
OWNER="${4:-rcourtman}"
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Invalid stable release tag: ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$SERVER_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "Invalid server image digest: ${SERVER_DIGEST}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$CONTROL_PLANE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "Invalid control-plane image digest: ${CONTROL_PLANE_DIGEST}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$OWNER" =~ ^[A-Za-z0-9_.-]+$ ]]; then
|
||||
echo "Invalid registry owner: ${OWNER}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for command in docker jq; do
|
||||
if ! command -v "$command" >/dev/null 2>&1; then
|
||||
echo "${command} is required to verify stable container aliases." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
IFS='.' read -r MAJOR MINOR _PATCH <<<"$VERSION"
|
||||
ALIASES=("${MAJOR}.${MINOR}" "${MAJOR}" latest)
|
||||
|
||||
resolve_digest() {
|
||||
local reference="$1"
|
||||
local manifest
|
||||
manifest="$(docker buildx imagetools inspect "$reference" --format '{{json .Manifest}}')"
|
||||
jq -er '.digest | select(type == "string" and test("^sha256:[0-9a-f]{64}$"))' <<<"$manifest"
|
||||
}
|
||||
|
||||
verify_aliases() {
|
||||
local image="$1"
|
||||
local expected_digest="$2"
|
||||
local registry alias reference observed_digest
|
||||
|
||||
for registry in "docker.io/rcourtman" "ghcr.io/${OWNER}"; do
|
||||
for alias in "${ALIASES[@]}"; do
|
||||
reference="${registry}/${image}:${alias}"
|
||||
observed_digest="$(resolve_digest "$reference")"
|
||||
if [ "$observed_digest" != "$expected_digest" ]; then
|
||||
echo "Stable alias ${reference} resolved to ${observed_digest}, expected ${expected_digest}." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[OK] ${reference} -> ${expected_digest}" >&2
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
verify_aliases pulse "$SERVER_DIGEST"
|
||||
verify_aliases pulse-control-plane "$CONTROL_PLANE_DIGEST"
|
||||
Loading…
Add table
Add a link
Reference in a new issue