mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Keep release toolchains within support
This commit is contained in:
parent
366fe3f293
commit
0d32dac16e
32 changed files with 359 additions and 58 deletions
|
|
@ -389,7 +389,7 @@ func TestReleaseContainerTargetsConsumeImmutableCandidate(t *testing.T) {
|
|||
"COPY --from=release_payload /amd64/bin/pulse /opt/pulse/bin/pulse-linux-amd64",
|
||||
"COPY --from=release_payload /arm64/bin/pulse /opt/pulse/bin/pulse-linux-arm64",
|
||||
"! -name '*.sig' ! -name '*.sshsig' -exec chmod 755 {} +",
|
||||
"FROM alpine:3.20@sha256:",
|
||||
"FROM alpine:3.24@sha256:",
|
||||
"AS agent_runtime_prebuilt",
|
||||
} {
|
||||
if !strings.Contains(dockerfile, needle) {
|
||||
|
|
@ -1443,7 +1443,7 @@ func TestDockerBuildUsesCanonicalReleaseLdflags(t *testing.T) {
|
|||
}
|
||||
dockerfile := string(dockerfileBytes)
|
||||
dockerRequired := []string{
|
||||
`FROM --platform=linux/amd64 node:20-alpine@sha256:`,
|
||||
`FROM --platform=linux/amd64 node:24-alpine@sha256:`,
|
||||
`FROM --platform=linux/amd64 golang:1.26.7-alpine@sha256:`,
|
||||
`FROM backend-builder AS release-assets-builder`,
|
||||
`AS agent_runtime`,
|
||||
|
|
@ -1478,10 +1478,10 @@ func TestDockerBuildUsesCanonicalReleaseLdflags(t *testing.T) {
|
|||
t.Fatalf("Dockerfile missing canonical release ldflags usage: %s", needle)
|
||||
}
|
||||
}
|
||||
assertDigestPinnedDockerStage(t, dockerfile, `FROM --platform=linux/amd64 node:20-alpine@sha256:`, ` AS frontend-builder`)
|
||||
assertDigestPinnedDockerStage(t, dockerfile, `FROM --platform=linux/amd64 node:24-alpine@sha256:`, ` AS frontend-builder`)
|
||||
assertDigestPinnedDockerStage(t, dockerfile, `FROM --platform=linux/amd64 golang:1.26.7-alpine@sha256:`, ` AS backend-builder`)
|
||||
assertDigestPinnedDockerStage(t, dockerfile, `FROM alpine:3.20@sha256:`, ` AS agent_runtime`)
|
||||
assertDigestPinnedDockerStage(t, dockerfile, `FROM alpine:3.20@sha256:`, ` AS pulse-runtime-foundation`)
|
||||
assertDigestPinnedDockerStage(t, dockerfile, `FROM alpine:3.24@sha256:`, ` AS agent_runtime`)
|
||||
assertDigestPinnedDockerStage(t, dockerfile, `FROM alpine:3.24@sha256:`, ` AS pulse-runtime-foundation`)
|
||||
hostedStart := strings.Index(dockerfile, `FROM pulse-runtime-base AS hosted_runtime`)
|
||||
runtimeStart := strings.Index(dockerfile, `FROM pulse-runtime-base AS runtime`)
|
||||
if hostedStart == -1 || runtimeStart == -1 || hostedStart > runtimeStart {
|
||||
|
|
@ -1491,10 +1491,10 @@ func TestDockerBuildUsesCanonicalReleaseLdflags(t *testing.T) {
|
|||
if strings.Contains(hostedStage, "rendered-installers") || strings.Contains(hostedStage, "/opt/pulse/bin") {
|
||||
t.Fatalf("hosted_runtime target must not depend on installer rendering or embedded agent artifacts:\n%s", hostedStage)
|
||||
}
|
||||
if strings.Contains(dockerfile, `FROM --platform=linux/amd64 node:20-alpine AS frontend-builder`) ||
|
||||
if strings.Contains(dockerfile, `FROM --platform=linux/amd64 node:24-alpine AS frontend-builder`) ||
|
||||
strings.Contains(dockerfile, `FROM --platform=linux/amd64 golang:1.26.7-alpine AS backend-builder`) ||
|
||||
strings.Contains(dockerfile, `FROM alpine:3.20 AS agent_runtime`) ||
|
||||
strings.Contains(dockerfile, `FROM alpine:3.20 AS pulse-runtime-base`) {
|
||||
strings.Contains(dockerfile, `FROM alpine:3.24 AS agent_runtime`) ||
|
||||
strings.Contains(dockerfile, `FROM alpine:3.24 AS pulse-runtime-base`) {
|
||||
t.Fatal("Dockerfile base images must be pinned by immutable @sha256 digests")
|
||||
}
|
||||
if builds, cleanBuilds := strings.Count(dockerfile, " go build \\"), strings.Count(dockerfile, "-buildvcs=false"); builds != cleanBuilds {
|
||||
|
|
|
|||
113
scripts/installtests/container_dependency_contract_test.go
Normal file
113
scripts/installtests/container_dependency_contract_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package installtests
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var externalImageDigest = regexp.MustCompile(`@sha256:[0-9a-f]{64}$`)
|
||||
|
||||
func TestEveryDockerfilePinsExternalBases(t *testing.T) {
|
||||
err := filepath.WalkDir(repoFile("."), func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if entry.Name() == "node_modules" || entry.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(entry.Name(), "Dockerfile") {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
stages := make(map[string]bool)
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNumber := 0
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 || !strings.EqualFold(fields[0], "FROM") {
|
||||
continue
|
||||
}
|
||||
imageIndex := 1
|
||||
if strings.HasPrefix(fields[imageIndex], "--platform=") {
|
||||
imageIndex++
|
||||
}
|
||||
if imageIndex >= len(fields) {
|
||||
t.Fatalf("%s:%d has malformed FROM instruction", path, lineNumber)
|
||||
}
|
||||
image := fields[imageIndex]
|
||||
if !stages[image] && !externalImageDigest.MatchString(image) {
|
||||
t.Fatalf("%s:%d external base image %q is not pinned to a full sha256 digest", path, lineNumber, image)
|
||||
}
|
||||
for index := imageIndex + 1; index+1 < len(fields); index++ {
|
||||
if strings.EqualFold(fields[index], "AS") {
|
||||
stages[fields[index+1]] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("audit Dockerfiles: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeToolchainParity(t *testing.T) {
|
||||
workflowPaths, err := filepath.Glob(repoFile(".github", "workflows", "*.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("list workflows: %v", err)
|
||||
}
|
||||
versionPattern := regexp.MustCompile(`node-version:\s*['"]?([^'"\s#]+)`)
|
||||
setupCount := 0
|
||||
versionCount := 0
|
||||
for _, path := range workflowPaths {
|
||||
contentBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
content := string(contentBytes)
|
||||
setupCount += strings.Count(content, "actions/setup-node@")
|
||||
for _, match := range versionPattern.FindAllStringSubmatch(content, -1) {
|
||||
versionCount++
|
||||
if match[1] != nodeToolchainLine {
|
||||
t.Fatalf("%s selects Node.js %s; all workflows must use governed line %s", path, match[1], nodeToolchainLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
if setupCount == 0 || versionCount != setupCount {
|
||||
t.Fatalf("every setup-node step must select the governed line: setup=%d versions=%d", setupCount, versionCount)
|
||||
}
|
||||
|
||||
workerBytes, err := os.ReadFile(repoFile("scripts", "release-preflight-worker.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read release preflight worker: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(workerBytes), `!= "`+nodeToolchainLine+`"`) {
|
||||
t.Fatalf("release preflight worker must enforce Node.js %s parity", nodeToolchainLine)
|
||||
}
|
||||
|
||||
devcontainerBytes, err := os.ReadFile(repoFile(".devcontainer", "devcontainer.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read devcontainer config: %v", err)
|
||||
}
|
||||
devcontainer := string(devcontainerBytes)
|
||||
if !strings.Contains(devcontainer, `"version": "`+nodeToolchainLine+`"`) ||
|
||||
!regexp.MustCompile(`ghcr\.io/devcontainers/features/node@sha256:[0-9a-f]{64}`).MatchString(devcontainer) {
|
||||
t.Fatalf("devcontainer must use Node.js %s from a digest-pinned feature", nodeToolchainLine)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,15 @@ package installtests
|
|||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
alpineRuntimeLine = "3.24"
|
||||
alpineRuntimeSupportEnd = "2028-06-01"
|
||||
nodeToolchainLine = "24"
|
||||
nodeToolchainSupportEnd = "2028-04-30"
|
||||
containerSupportLead = 180 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// assertDigestPinnedDockerStage allows automated digest refreshes while still
|
||||
|
|
@ -21,3 +30,31 @@ func assertDigestPinnedDockerStage(t *testing.T, dockerfile, prefix, suffix stri
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGovernedContainerBaseSupportWindow turns the upstream lifecycle date
|
||||
// into an advance operational signal. Digest automation keeps a selected line
|
||||
// patched, but it cannot move a deliberately governed major/minor tag.
|
||||
func TestGovernedContainerBaseSupportWindow(t *testing.T) {
|
||||
for _, policy := range []struct {
|
||||
name string
|
||||
line string
|
||||
supportEnd string
|
||||
}{
|
||||
{name: "Alpine", line: alpineRuntimeLine, supportEnd: alpineRuntimeSupportEnd},
|
||||
{name: "Node.js", line: nodeToolchainLine, supportEnd: nodeToolchainSupportEnd},
|
||||
} {
|
||||
supportEnd, err := time.Parse("2006-01-02", policy.supportEnd)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s %s support end: %v", policy.name, policy.line, err)
|
||||
}
|
||||
remaining := time.Until(supportEnd)
|
||||
if remaining < containerSupportLead {
|
||||
t.Fatalf(
|
||||
"%s %s has less than 180 days of normal support remaining (support ends %s); select and qualify a newer line",
|
||||
policy.name,
|
||||
policy.line,
|
||||
policy.supportEnd,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ func TestNativeWindowsSelfTestDoesNotPreseedLifecycleState(t *testing.T) {
|
|||
|
||||
workflow := string(content)
|
||||
required := []string{
|
||||
`node-version: '24'`,
|
||||
`$selfTestStateDir = Join-Path $env:RUNNER_TEMP 'pulse-agent-self-test'`,
|
||||
`$selfTestLogFile = Join-Path $selfTestStateDir 'pulse-agent.log'`,
|
||||
`--self-test --state-dir $selfTestStateDir --log-file $selfTestLogFile`,
|
||||
|
|
@ -119,7 +120,7 @@ func TestNativeWindowsSelfTestDoesNotPreseedLifecycleState(t *testing.T) {
|
|||
}
|
||||
for _, needle := range required {
|
||||
if !strings.Contains(workflow, needle) {
|
||||
t.Fatalf("native Windows self-test must keep lifecycle state isolated: %s", needle)
|
||||
t.Fatalf("native Windows workflow must keep its governed lifecycle proof intact: %s", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
scripts/installtests/integration_container_test.go
Normal file
35
scripts/installtests/integration_container_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package installtests
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIntegrationContainersUseGovernedImmutableBases(t *testing.T) {
|
||||
dockerfileBytes, err := os.ReadFile(repoFile("tests", "integration", "mock-github-server", "Dockerfile"))
|
||||
if err != nil {
|
||||
t.Fatalf("read mock GitHub Dockerfile: %v", err)
|
||||
}
|
||||
dockerfile := string(dockerfileBytes)
|
||||
assertDigestPinnedDockerStage(t, dockerfile, `FROM golang:1.26.7-alpine@sha256:`, ` AS builder`)
|
||||
assertDigestPinnedDockerStage(t, dockerfile, `FROM alpine:3.24@sha256:`, ``)
|
||||
|
||||
composeBytes, err := os.ReadFile(repoFile("tests", "integration", "docker-compose.test.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read integration compose file: %v", err)
|
||||
}
|
||||
compose := string(composeBytes)
|
||||
if !regexpDigestPinnedImage(`alpine:3.24`, compose) {
|
||||
t.Fatal("integration seed image must use the governed Alpine line pinned by a full immutable digest")
|
||||
}
|
||||
if strings.Contains(dockerfile, "golang:1.23-alpine") || strings.Contains(compose, "image: alpine:3.20\n") {
|
||||
t.Fatal("integration containers must not restore retired toolchain or runtime lines")
|
||||
}
|
||||
}
|
||||
|
||||
func regexpDigestPinnedImage(image string, content string) bool {
|
||||
pattern := regexp.MustCompile(`(?m)^\s*image:\s*` + regexp.QuoteMeta(image) + `@sha256:[0-9a-f]{64}\s*$`)
|
||||
return pattern.MatchString(content)
|
||||
}
|
||||
|
|
@ -303,11 +303,11 @@ func TestProviderMSPControlPlaneDockerfileBuildsReleaseLicenseBinary(t *testing.
|
|||
text := string(dockerfileBytes)
|
||||
assertContainsAll(t, text,
|
||||
"# syntax=docker/dockerfile:1.7",
|
||||
"FROM --platform=linux/amd64 node:20-alpine@sha256:",
|
||||
"FROM --platform=linux/amd64 node:24-alpine@sha256:",
|
||||
"npm ci",
|
||||
"npm run build",
|
||||
"FROM --platform=$BUILDPLATFORM golang:1.26.7-alpine@sha256:",
|
||||
"FROM alpine:3.20@sha256:",
|
||||
"FROM alpine:3.24@sha256:",
|
||||
"ARG PULSE_LICENSE_PUBLIC_KEY_SHA256",
|
||||
"ARG TARGETOS",
|
||||
"ARG TARGETARCH",
|
||||
|
|
@ -332,9 +332,9 @@ func TestProviderMSPControlPlaneDockerfileBuildsReleaseLicenseBinary(t *testing.
|
|||
"FROM alpine:3.21",
|
||||
"CGO_ENABLED=0 go build -o /pulse-control-plane ./cmd/pulse-control-plane",
|
||||
)
|
||||
assertDigestPinnedDockerStage(t, text, `FROM --platform=linux/amd64 node:20-alpine@sha256:`, ` AS frontend-builder`)
|
||||
assertDigestPinnedDockerStage(t, text, `FROM --platform=linux/amd64 node:24-alpine@sha256:`, ` AS frontend-builder`)
|
||||
assertDigestPinnedDockerStage(t, text, `FROM --platform=$BUILDPLATFORM golang:1.26.7-alpine@sha256:`, ` AS builder`)
|
||||
assertDigestPinnedDockerStage(t, text, `FROM alpine:3.20@sha256:`, ` AS control-plane-runtime-foundation`)
|
||||
assertDigestPinnedDockerStage(t, text, `FROM alpine:3.24@sha256:`, ` AS control-plane-runtime-foundation`)
|
||||
}
|
||||
|
||||
func assertContainsAll(t *testing.T, text string, required ...string) {
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ if ! docker compose version >/dev/null 2>&1; then
|
|||
echo "Error: Docker Compose v2 is required on the worker." >&2
|
||||
exit 3
|
||||
fi
|
||||
if [ "$(node -p "process.versions.node.split('.')[0]")" != "20" ]; then
|
||||
echo "Error: the worker must use Node.js 20 to match the release workflows." >&2
|
||||
if [ "$(node -p "process.versions.node.split('.')[0]")" != "24" ]; then
|
||||
echo "Error: the worker must use Node.js 24 to match the release workflows." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,18 @@ def manifest_directories(filename: str) -> list[str]:
|
|||
return sorted(directories)
|
||||
|
||||
|
||||
def dockerfile_directories() -> list[str]:
|
||||
"""Return every directory containing a Docker build manifest."""
|
||||
directories = set()
|
||||
for manifest in ROOT.rglob("Dockerfile*"):
|
||||
relative = manifest.relative_to(ROOT)
|
||||
if "node_modules" in relative.parts:
|
||||
continue
|
||||
parent = relative.parent.as_posix()
|
||||
directories.add("/" if parent == "." else f"/{parent}")
|
||||
return sorted(directories)
|
||||
|
||||
|
||||
class DependabotConfigTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
|
|
@ -34,7 +46,15 @@ class DependabotConfigTest(unittest.TestCase):
|
|||
|
||||
def test_covers_every_shipped_dependency_ecosystem(self) -> None:
|
||||
self.assertEqual(
|
||||
set(self.updates), {"github-actions", "gomod", "npm", "docker"}
|
||||
set(self.updates),
|
||||
{
|
||||
"github-actions",
|
||||
"gomod",
|
||||
"npm",
|
||||
"docker",
|
||||
"docker-compose",
|
||||
"devcontainers",
|
||||
},
|
||||
)
|
||||
self.assertEqual(self.updates["github-actions"]["directory"], "/")
|
||||
self.assertEqual(
|
||||
|
|
@ -46,9 +66,14 @@ class DependabotConfigTest(unittest.TestCase):
|
|||
manifest_directories("package-lock.json"),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.updates["docker"]["directories"],
|
||||
["/", "/deploy/provider-msp"],
|
||||
sorted(self.updates["docker"]["directories"]),
|
||||
dockerfile_directories(),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.updates["docker-compose"]["directory"],
|
||||
"/tests/integration",
|
||||
)
|
||||
self.assertEqual(self.updates["devcontainers"]["directory"], "/")
|
||||
|
||||
def test_updates_are_weekly_staggered_and_bounded(self) -> None:
|
||||
self.assertEqual(self.config["version"], 2)
|
||||
|
|
@ -100,6 +125,17 @@ class DependabotConfigTest(unittest.TestCase):
|
|||
self.assertEqual(set(ignored), {"node", "golang", "alpine"})
|
||||
self.assertTrue(all(types == all_semver for types in ignored.values()))
|
||||
|
||||
compose_ignored = self.updates["docker-compose"]["ignore"]
|
||||
self.assertEqual(
|
||||
compose_ignored,
|
||||
[
|
||||
{
|
||||
"dependency-name": "alpine",
|
||||
"update-types": sorted(all_semver),
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_weekly_scan_covers_the_same_lockfiles(self) -> None:
|
||||
workflow = yaml.safe_load(SECURITY_SCAN.read_text(encoding="utf-8"))
|
||||
jobs = workflow["jobs"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue