mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
Observe NAS removal controls before native frontend preparation
Move the existing generated watchdog and collector contract to an early, fail-closed native CI phase. Discover the complete inventory in both phases, execute every check exactly once, and preserve the remaining group assignments, native runtime tools and safety deadlines. Exercise phase admission, omission, duplicate and failure controls. Contract-Neutral: Native CI phase ordering and inventory partition only; production installers, runtime behaviour, required checks and safety deadlines are unchanged. Change-source: pulse-maintainer
This commit is contained in:
@@ -148,21 +148,6 @@ jobs:
|
||||
if: matrix.unix
|
||||
run: go test -count=1 -timeout 3m -run '^TestIntegrationContainer' ./scripts/installtests
|
||||
|
||||
- name: Set up Node.js for native runtime test assets
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend-modern/package-lock.json'
|
||||
|
||||
- name: Install frontend test dependencies
|
||||
working-directory: frontend-modern
|
||||
run: npm ci
|
||||
|
||||
- name: Build real frontend assets for API-linked native tests
|
||||
working-directory: frontend-modern
|
||||
run: npm run build
|
||||
|
||||
- name: Supply GNU timeout for macOS guidance checks
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
@@ -183,35 +168,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
timeout --version | grep -q 'GNU coreutils'
|
||||
|
||||
- name: Test native agent runtime
|
||||
run: >-
|
||||
go test
|
||||
./cmd/pulse-agent
|
||||
./internal/agenttls
|
||||
./internal/agentupdate
|
||||
./internal/dockeragent
|
||||
./internal/hostagent
|
||||
./internal/kubernetesagent
|
||||
./internal/remoteconfig
|
||||
|
||||
# The privileged helper is Linux-only. Keep its protocol, executable
|
||||
# identity and shared filesystem fences in both native Linux jobs, even
|
||||
# when a change leaves the unprivileged agent's source untouched. These
|
||||
# tests run as the ordinary runner user; no helper service is installed.
|
||||
- name: Test native Linux helper boundaries
|
||||
if: runner.os == 'Linux'
|
||||
run: >-
|
||||
go test -race -count=1 -timeout 5m
|
||||
./cmd/pulse-agent-helper
|
||||
./internal/agenthelper
|
||||
./internal/filesystemprobe
|
||||
./internal/updatesignature
|
||||
./pkg/agents/host
|
||||
|
||||
- name: Build native Linux helper
|
||||
if: runner.os == 'Linux'
|
||||
run: go build -o pulse-agent-helper-native ./cmd/pulse-agent-helper
|
||||
|
||||
# Server/LXC fixtures execute Linux-only scripts, not a macOS installer.
|
||||
# Keep their Bash/GNU metadata, archive and rename semantics local to that step;
|
||||
# native macOS agent/runtime tests still use the runner's own utilities.
|
||||
@@ -246,6 +202,64 @@ jobs:
|
||||
ln -s "$gnu_awk" "$fixture_bin/awk"
|
||||
printf 'PULSE_INSTALLER_FIXTURE_BIN=%s\n' "$fixture_bin" >> "$GITHUB_ENV"
|
||||
|
||||
# Observe the existing generated NAS watchdog/collector control before
|
||||
# Node acquisition and the large installer inventory. The runner's two
|
||||
# phases partition the same discovered checks; no check runs twice or is
|
||||
# dropped, and the production process/cleanup deadlines stay unchanged.
|
||||
- name: Test NAS removal contracts before frontend preparation
|
||||
if: matrix.unix
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${PULSE_INSTALLER_FIXTURE_BIN:-}" ]]; then
|
||||
export PATH="$PULSE_INSTALLER_FIXTURE_BIN:$PATH"
|
||||
fi
|
||||
python3 scripts/installtests/run_unix_contracts.py --phase preflight
|
||||
|
||||
- name: Set up Node.js for native runtime test assets
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend-modern/package-lock.json'
|
||||
|
||||
- name: Install frontend test dependencies
|
||||
working-directory: frontend-modern
|
||||
run: npm ci
|
||||
|
||||
- name: Build real frontend assets for API-linked native tests
|
||||
working-directory: frontend-modern
|
||||
run: npm run build
|
||||
|
||||
- name: Test native agent runtime
|
||||
run: >-
|
||||
go test
|
||||
./cmd/pulse-agent
|
||||
./internal/agenttls
|
||||
./internal/agentupdate
|
||||
./internal/dockeragent
|
||||
./internal/hostagent
|
||||
./internal/kubernetesagent
|
||||
./internal/remoteconfig
|
||||
|
||||
# The privileged helper is Linux-only. Keep its protocol, executable
|
||||
# identity and shared filesystem fences in both native Linux jobs, even
|
||||
# when a change leaves the unprivileged agent's source untouched. These
|
||||
# tests run as the ordinary runner user; no helper service is installed.
|
||||
- name: Test native Linux helper boundaries
|
||||
if: runner.os == 'Linux'
|
||||
run: >-
|
||||
go test -race -count=1 -timeout 5m
|
||||
./cmd/pulse-agent-helper
|
||||
./internal/agenthelper
|
||||
./internal/filesystemprobe
|
||||
./internal/updatesignature
|
||||
./pkg/agents/host
|
||||
|
||||
- name: Build native Linux helper
|
||||
if: runner.os == 'Linux'
|
||||
run: go build -o pulse-agent-helper-native ./cmd/pulse-agent-helper
|
||||
|
||||
- name: Test native Unix installer contracts
|
||||
if: matrix.unix
|
||||
shell: bash
|
||||
@@ -254,7 +268,7 @@ jobs:
|
||||
if [[ -n "${PULSE_INSTALLER_FIXTURE_BIN:-}" ]]; then
|
||||
export PATH="$PULSE_INSTALLER_FIXTURE_BIN:$PATH"
|
||||
fi
|
||||
python3 scripts/installtests/run_unix_contracts.py
|
||||
python3 scripts/installtests/run_unix_contracts.py --phase remaining
|
||||
|
||||
- name: Test native Windows installer contracts
|
||||
if: ${{ !matrix.unix }}
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
|
||||
type nativeGuidanceStep struct {
|
||||
Name, If, Shell, Run string
|
||||
ContinueOnError bool `yaml:"continue-on-error"`
|
||||
WorkingDirectory string `yaml:"working-directory"`
|
||||
}
|
||||
|
||||
func nativeGuidanceToolSteps(content []byte) (nativeGuidanceStep, nativeGuidanceStep, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ package installtests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -14,7 +15,60 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func nativeInstallerFixtureSteps(t *testing.T) (nativeGuidanceStep, nativeGuidanceStep) {
|
||||
func nativeInstallerPhaseRun(phase string) string {
|
||||
return `set -euo pipefail
|
||||
if [[ -n "${PULSE_INSTALLER_FIXTURE_BIN:-}" ]]; then
|
||||
export PATH="$PULSE_INSTALLER_FIXTURE_BIN:$PATH"
|
||||
fi
|
||||
python3 scripts/installtests/run_unix_contracts.py --phase ` + phase
|
||||
}
|
||||
|
||||
func validateNativeInstallerPhaseOrder(steps []nativeGuidanceStep) error {
|
||||
indices := map[string]int{}
|
||||
names := []string{
|
||||
"Set up Go",
|
||||
"Supply GNU timeout for macOS guidance checks",
|
||||
"Verify native Unix timeout prerequisite",
|
||||
"Supply Linux server installer fixture tools on macOS",
|
||||
"Test NAS removal contracts before frontend preparation",
|
||||
"Set up Node.js for native runtime test assets",
|
||||
"Test native agent runtime",
|
||||
"Test native Unix installer contracts",
|
||||
}
|
||||
for i, step := range steps {
|
||||
for _, name := range names {
|
||||
if step.Name == name {
|
||||
if _, exists := indices[name]; exists {
|
||||
return fmt.Errorf("native installer phase step must run once: %s", name)
|
||||
}
|
||||
indices[name] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
previous := -1
|
||||
for _, name := range names {
|
||||
index, exists := indices[name]
|
||||
if !exists || index <= previous {
|
||||
return fmt.Errorf("native installer preparation and both phases must stay in order: %s", name)
|
||||
}
|
||||
previous = index
|
||||
}
|
||||
supply := steps[indices[names[3]]]
|
||||
if supply.If != "runner.os == 'macOS'" || supply.Shell != "bash" || supply.ContinueOnError ||
|
||||
supply.WorkingDirectory != "" || strings.Contains(supply.Run, "GITHUB_PATH") {
|
||||
return fmt.Errorf("Linux fixture tools must not replace the native runtime tools or hide a failure")
|
||||
}
|
||||
for name, phase := range map[string]string{names[4]: "preflight", names[7]: "remaining"} {
|
||||
step := steps[indices[name]]
|
||||
if step.If != "matrix.unix" || step.Shell != "bash" || step.ContinueOnError || step.WorkingDirectory != "" ||
|
||||
strings.TrimSpace(step.Run) != nativeInstallerPhaseRun(phase) {
|
||||
return fmt.Errorf("both native installer phases must execute the exact fail-closed runner with step-local tools: %s", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nativeInstallerFixtureSteps(t *testing.T) (nativeGuidanceStep, nativeGuidanceStep, nativeGuidanceStep) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(repoFile(".github", "workflows", "unified-agent-native.yml"))
|
||||
if err != nil {
|
||||
@@ -27,31 +81,85 @@ func nativeInstallerFixtureSteps(t *testing.T) (nativeGuidanceStep, nativeGuidan
|
||||
t.Fatal(err)
|
||||
}
|
||||
steps := workflow.Jobs["native-agent"].Steps
|
||||
runtimeAt, supplyAt, installerAt := -1, -1, -1
|
||||
for i, step := range steps {
|
||||
if err := validateNativeInstallerPhaseOrder(steps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var supply, preflight, installer nativeGuidanceStep
|
||||
for _, step := range steps {
|
||||
switch step.Name {
|
||||
case "Test native agent runtime":
|
||||
runtimeAt = i
|
||||
case "Supply Linux server installer fixture tools on macOS":
|
||||
supplyAt = i
|
||||
supply = step
|
||||
case "Test NAS removal contracts before frontend preparation":
|
||||
preflight = step
|
||||
case "Test native Unix installer contracts":
|
||||
installerAt = i
|
||||
installer = step
|
||||
}
|
||||
}
|
||||
if runtimeAt < 0 || supplyAt <= runtimeAt || installerAt <= supplyAt {
|
||||
t.Fatal("Linux fixture tool supply must follow native runtime testing and precede Unix installer checks")
|
||||
return supply, preflight, installer
|
||||
}
|
||||
|
||||
func TestNativeInstallerPhasesKeepNASBeforeFrontendAndAllChecksGating(t *testing.T) {
|
||||
content, err := os.ReadFile(repoFile(".github", "workflows", "unified-agent-native.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
supply, installer := steps[supplyAt], steps[installerAt]
|
||||
if supply.If != "runner.os == 'macOS'" || supply.Shell != "bash" || installer.If != "matrix.unix" || installer.Shell != "bash" ||
|
||||
strings.Contains(supply.Run, "GITHUB_PATH") || !strings.Contains(installer.Run, `export PATH="$PULSE_INSTALLER_FIXTURE_BIN:$PATH"`) ||
|
||||
!strings.Contains(installer.Run, "python3 scripts/installtests/run_unix_contracts.py") {
|
||||
t.Fatal("Linux tools must stay local to complete Unix installer qualification")
|
||||
var workflow struct {
|
||||
Jobs map[string]struct{ Steps []nativeGuidanceStep }
|
||||
}
|
||||
if err := yaml.Unmarshal(content, &workflow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
steps := workflow.Jobs["native-agent"].Steps
|
||||
if err := validateNativeInstallerPhaseOrder(steps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
indices := map[string]int{}
|
||||
for i, step := range steps {
|
||||
indices[step.Name] = i
|
||||
}
|
||||
early := indices["Test NAS removal contracts before frontend preparation"]
|
||||
remaining := indices["Test native Unix installer contracts"]
|
||||
for _, scenario := range []string{"missing preflight", "missing remaining", "duplicate preflight", "after Node", "before tools", "Linux only", "hidden early failure", "hidden remaining failure", "all checks repeated", "no remaining checks", "nonlocal tools", "wrong directory"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
changed := append([]nativeGuidanceStep(nil), steps...)
|
||||
switch scenario {
|
||||
case "missing preflight":
|
||||
changed = append(changed[:early], changed[early+1:]...)
|
||||
case "missing remaining":
|
||||
changed = append(changed[:remaining], changed[remaining+1:]...)
|
||||
case "duplicate preflight":
|
||||
changed = append(changed, changed[early])
|
||||
case "after Node":
|
||||
i := indices["Set up Node.js for native runtime test assets"]
|
||||
changed[early], changed[i] = changed[i], changed[early]
|
||||
case "before tools":
|
||||
i := indices["Supply Linux server installer fixture tools on macOS"]
|
||||
changed[early], changed[i] = changed[i], changed[early]
|
||||
case "Linux only":
|
||||
changed[early].If = "runner.os == 'Linux'"
|
||||
case "hidden early failure":
|
||||
changed[early].ContinueOnError = true
|
||||
case "hidden remaining failure":
|
||||
changed[remaining].ContinueOnError = true
|
||||
case "all checks repeated":
|
||||
changed[remaining].Run = nativeInstallerPhaseRun("all")
|
||||
case "no remaining checks":
|
||||
changed[remaining].Run = nativeInstallerPhaseRun("preflight")
|
||||
case "nonlocal tools":
|
||||
i := indices["Supply Linux server installer fixture tools on macOS"]
|
||||
changed[i].Run += "\nprintf '%s\\n' \"$fixture_bin\" >> \"$GITHUB_PATH\"\n"
|
||||
case "wrong directory":
|
||||
changed[early].WorkingDirectory = "frontend-modern"
|
||||
}
|
||||
if err := validateNativeInstallerPhaseOrder(changed); err == nil {
|
||||
t.Fatalf("accepted %s native installer phase", scenario)
|
||||
}
|
||||
})
|
||||
}
|
||||
return supply, installer
|
||||
}
|
||||
|
||||
func TestNativeInstallerFixtureToolsAreRealAndStepLocal(t *testing.T) {
|
||||
supply, installer := nativeInstallerFixtureSteps(t)
|
||||
supply, preflight, installer := nativeInstallerFixtureSteps(t)
|
||||
for _, scenario := range []string{"real tools", "install failure", "missing bash", "legacy bash", "missing stat", "non-GNU chmod", "missing mv", "non-GNU mv", "missing tar", "non-GNU tar", "missing awk", "non-GNU awk"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
@@ -175,7 +283,7 @@ esac
|
||||
// only Python's containing suite to avoid recursively starting Go.
|
||||
probe := `#!/bin/sh
|
||||
set -eu
|
||||
test "$*" = 'scripts/installtests/run_unix_contracts.py' || exit 91
|
||||
test "$*" = "scripts/installtests/run_unix_contracts.py --phase $EXPECTED_PHASE" || exit 91
|
||||
test "$(command -v bash)" = "$PULSE_INSTALLER_FIXTURE_BIN/bash" || exit 92
|
||||
for tool in stat chmod chown mv tar awk; do
|
||||
test "$(command -v "$tool")" = "$PULSE_INSTALLER_FIXTURE_BIN/$tool" || exit 93
|
||||
@@ -208,15 +316,17 @@ exit "${SUITE_EXIT:-0}"
|
||||
if err := os.WriteFile(filepath.Join(bin, "python3"), []byte(probe), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, code := range []string{"0", "23"} {
|
||||
cmd := exec.CommandContext(ctx, "bash", "-c", installer.Run)
|
||||
cmd.Env = append(env, "PULSE_INSTALLER_FIXTURE_BIN="+fixtureBin, "PROBE_ROOT="+root, "SUITE_EXIT="+code)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if code == "0" && err != nil {
|
||||
t.Fatalf("fixture-local path did not reach the suite: %v: %s", err, output)
|
||||
} else if code == "23" {
|
||||
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 23 {
|
||||
t.Fatalf("containing suite failure hidden: %v: %s", err, output)
|
||||
for phase, step := range map[string]nativeGuidanceStep{"preflight": preflight, "remaining": installer} {
|
||||
for _, code := range []string{"0", "23"} {
|
||||
cmd := exec.CommandContext(ctx, "bash", "-c", step.Run)
|
||||
cmd.Env = append(env, "PULSE_INSTALLER_FIXTURE_BIN="+fixtureBin, "PROBE_ROOT="+root, "SUITE_EXIT="+code, "EXPECTED_PHASE="+phase)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if code == "0" && err != nil {
|
||||
t.Fatalf("fixture-local path did not reach the suite: %v: %s", err, output)
|
||||
} else if code == "23" {
|
||||
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 23 {
|
||||
t.Fatalf("containing suite failure hidden: %v: %s", err, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// Exercise the actual Python runner without recursively running the Go suite.
|
||||
// The hosted failure had 500 checks under one package alarm; this control uses
|
||||
// that inventory size and verifies admission, exact-once execution and failure
|
||||
// propagation, not a claimed reproduction of macOS timing.
|
||||
// propagation across both workflow phases, not a macOS timing reproduction.
|
||||
func TestNativeUnixContractRunnerPartitionsWithoutDroppingChecks(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -30,16 +30,18 @@ spec.loader.exec_module(runner)
|
||||
names = [f"TestFixture{i:03d}" for i in range(497)]
|
||||
names += ["ExampleInstaller", "FuzzInstaller", "TestFutureFixture"]
|
||||
reset = ["TestRootInstallResetSafety", "TestRootInstallResetPersistence"]
|
||||
inventory = "\n".join(names + reset + ["BenchmarkIgnored", "ok package 0.1s"])
|
||||
preflight = ["TestInstallSHNASBootOwnersQuiesceBeforeRecoveryErasure"]
|
||||
inventory = "\n".join(names + reset + preflight + ["BenchmarkIgnored", "ok package 0.1s"])
|
||||
groups = runner.partition(inventory)
|
||||
assert tuple(runner.GROUPS) == ("general-0", "general-1", "reset")
|
||||
assert [len(group) for group in groups] == [250, 250, 2]
|
||||
assert sorted(sum(groups, [])) == sorted(names + reset)
|
||||
assert len(set(sum(groups, []))) == 502
|
||||
assert groups[2] == sorted(reset)
|
||||
assert groups == runner.partition("\n".join(reversed(names + reset)))
|
||||
assert tuple(runner.GROUPS) == ("nas-preflight", "general-0", "general-1", "reset")
|
||||
assert [len(group) for group in groups] == [1, 250, 250, 2]
|
||||
assert sorted(sum(groups, [])) == sorted(names + reset + preflight)
|
||||
assert len(set(sum(groups, []))) == 503
|
||||
assert groups[0] == preflight and groups[3] == sorted(reset)
|
||||
assert groups == runner.partition("\n".join(reversed(names + reset + preflight)))
|
||||
for unsafe in ["", "TestOnly", "TestOne\nTestTwo", inventory + "\nTestFixture000",
|
||||
inventory + "\nTestUnsafe|TestInjected", inventory + "\nFuzzBad.*"]:
|
||||
inventory + "\nTestUnsafe|TestInjected", inventory + "\nFuzzBad.*",
|
||||
"\n".join(names + reset)]:
|
||||
try:
|
||||
runner.partition(unsafe)
|
||||
except ValueError:
|
||||
@@ -47,8 +49,9 @@ for unsafe in ["", "TestOnly", "TestOne\nTestTwo", inventory + "\nTestFixture000
|
||||
else:
|
||||
raise AssertionError("unsafe or incomplete inventory admitted")
|
||||
|
||||
def execute(failure_at=None, output=inventory):
|
||||
def execute(failure_at=None, output=inventory, phase="all"):
|
||||
calls = []
|
||||
selected = groups if phase == "all" else groups[:1] if phase == "preflight" else groups[1:]
|
||||
def run(argv, **kwargs):
|
||||
calls.append(argv)
|
||||
assert kwargs["cwd"] == runner.ROOT and kwargs["check"] is True
|
||||
@@ -60,12 +63,12 @@ def execute(failure_at=None, output=inventory):
|
||||
return SimpleNamespace(stdout=output)
|
||||
assert argv[:6] == ["go", "test", "-count=1", "-timeout", "10m", "-run"]
|
||||
assert argv[-1] == runner.PACKAGE
|
||||
assert argv[6] == "^(" + "|".join(groups[len(calls) - 2]) + ")$"
|
||||
assert argv[6] == "^(" + "|".join(selected[len(calls) - 2]) + ")$"
|
||||
assert "-parallel" not in argv
|
||||
return SimpleNamespace(returncode=0)
|
||||
with patch.object(runner.subprocess, "run", side_effect=run):
|
||||
try:
|
||||
runner.main()
|
||||
runner.main(phase)
|
||||
except subprocess.CalledProcessError as error:
|
||||
assert failure_at == len(calls) and error.returncode == 23
|
||||
except ValueError:
|
||||
@@ -75,15 +78,21 @@ def execute(failure_at=None, output=inventory):
|
||||
return calls
|
||||
|
||||
calls = execute()
|
||||
assert len(calls) == 4
|
||||
assert len(calls) == 5
|
||||
patterns = [re.compile(call[6]) for call in calls[1:]]
|
||||
for name in names + reset:
|
||||
for name in names + reset + preflight:
|
||||
assert sum(bool(pattern.fullmatch(name)) for pattern in patterns) == 1
|
||||
for name in ["TestFixture000/child", "TestInjected", "BenchmarkIgnored", "TestFixture000Extra"]:
|
||||
assert not any(pattern.fullmatch(name) for pattern in patterns)
|
||||
for failure_at in range(1, 5):
|
||||
for failure_at in range(1, 6):
|
||||
assert len(execute(failure_at=failure_at)) == failure_at
|
||||
assert len(execute(output=inventory + "\nTestFixture000")) == 1
|
||||
early = execute(phase="preflight")
|
||||
remaining = execute(phase="remaining")
|
||||
assert early[1:] + remaining[1:] == calls[1:]
|
||||
for phase, count in [("preflight", 2), ("remaining", 4)]:
|
||||
for failure_at in range(1, count + 1):
|
||||
assert len(execute(failure_at=failure_at, phase=phase)) == failure_at
|
||||
`, repoFile("scripts", "installtests", "run_unix_contracts.py"))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
|
||||
@@ -6,19 +6,24 @@ fixture, transport and cleanup deadlines are unchanged. Discover the actual
|
||||
package inventory so future tests, examples and fuzz seeds remain included.
|
||||
The general inventory grew to 500 checks and exhausted that package alarm on
|
||||
macOS Intel. Split it deterministically rather than extending safety deadlines.
|
||||
Run the existing NAS process/boot-owner control before frontend preparation;
|
||||
the remaining phase still requires that identity in the discovered inventory
|
||||
and excludes only the check already executed by the workflow's preflight.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PACKAGE = "./scripts/installtests"
|
||||
GROUPS = ("general-0", "general-1", "reset")
|
||||
PREFLIGHT = ("TestInstallSHNASBootOwnersQuiesceBeforeRecoveryErasure",)
|
||||
GROUPS = ("nas-preflight", "general-0", "general-1", "reset")
|
||||
|
||||
|
||||
def partition(output):
|
||||
general, reset = [], []
|
||||
preflight, general, reset = [], [], []
|
||||
seen = set()
|
||||
for name in output.splitlines():
|
||||
if not name.startswith(("Test", "Example", "Fuzz")):
|
||||
@@ -26,21 +31,34 @@ def partition(output):
|
||||
if not name.isidentifier() or name in seen:
|
||||
raise ValueError("invalid or repeated Go test identity")
|
||||
seen.add(name)
|
||||
if name in PREFLIGHT:
|
||||
preflight.append(name)
|
||||
(reset if name.startswith("TestRootInstallReset") else general).append(name)
|
||||
if len(general) < 2 or not reset:
|
||||
raise ValueError("missing general or reset installer inventory")
|
||||
if set(preflight) != set(PREFLIGHT) or len(general) - len(preflight) < 2 or not reset:
|
||||
raise ValueError("missing preflight, general or reset installer inventory")
|
||||
# Interleave sorted identities so adjacent fixture families do not all
|
||||
# consume the same package budget. Never omit or retry a discovered check.
|
||||
general.sort()
|
||||
return general[::2], general[1::2], sorted(reset)
|
||||
# Remove the preflight only after partitioning so every other check keeps
|
||||
# its existing group and order, rather than reshuffling the package budgets.
|
||||
return (sorted(preflight),
|
||||
[name for name in general[::2] if name not in PREFLIGHT],
|
||||
[name for name in general[1::2] if name not in PREFLIGHT],
|
||||
sorted(reset))
|
||||
|
||||
|
||||
def main():
|
||||
def main(phase="all"):
|
||||
if phase not in ("all", "preflight", "remaining"):
|
||||
raise ValueError("unknown native installer phase")
|
||||
inventory = subprocess.run(
|
||||
["go", "test", "-list", ".", PACKAGE], cwd=ROOT,
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
for group, names in zip(GROUPS, partition(inventory.stdout)):
|
||||
if phase == "preflight" and group != "nas-preflight":
|
||||
continue
|
||||
if phase == "remaining" and group == "nas-preflight":
|
||||
continue
|
||||
print(f"Native Unix installer {group}: {len(names)} discovered checks", flush=True)
|
||||
subprocess.run(
|
||||
["go", "test", "-count=1", "-timeout", "10m", "-run", "^(" + "|".join(names) + ")$", PACKAGE],
|
||||
@@ -49,4 +67,6 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--phase", choices=("all", "preflight", "remaining"), default="all")
|
||||
main(parser.parse_args().phase)
|
||||
|
||||
@@ -17,33 +17,44 @@ spec.loader.exec_module(runner)
|
||||
|
||||
|
||||
class NativeInstallerRunnerTest(unittest.TestCase):
|
||||
inventory = "TestInstaller\nTestRootInstallResetSafety\nExample\nFuzzInput\nTestFuture\nTestRootInstallResetFuture\nok\tpackage\n"
|
||||
inventory = "TestInstaller\nTestRootInstallResetSafety\nExample\nFuzzInput\nTestFuture\nTestRootInstallResetFuture\nTestInstallSHNASBootOwnersQuiesceBeforeRecoveryErasure\nok\tpackage\n"
|
||||
expected_groups = (
|
||||
["Example", "TestFuture"],
|
||||
["FuzzInput", "TestInstaller"],
|
||||
["TestInstallSHNASBootOwnersQuiesceBeforeRecoveryErasure"],
|
||||
["Example", "TestFuture", "TestInstaller"],
|
||||
["FuzzInput"],
|
||||
["TestRootInstallResetFuture", "TestRootInstallResetSafety"],
|
||||
)
|
||||
|
||||
def test_every_discovered_check_runs_once_in_one_group(self):
|
||||
groups = runner.partition(self.inventory)
|
||||
self.assertEqual(runner.GROUPS, ("general-0", "general-1", "reset"))
|
||||
self.assertEqual(runner.GROUPS, ("nas-preflight", "general-0", "general-1", "reset"))
|
||||
self.assertEqual(groups, self.expected_groups)
|
||||
selected = [name for group in groups for name in group]
|
||||
self.assertEqual(len(selected), 6)
|
||||
self.assertEqual(len(set(selected)), 6)
|
||||
self.assertEqual(len(selected), 7)
|
||||
self.assertEqual(len(set(selected)), 7)
|
||||
|
||||
def test_partition_order_and_odd_inventory_are_exhaustive(self):
|
||||
reversed_inventory = "\n".join(reversed(self.inventory.splitlines()))
|
||||
self.assertEqual(runner.partition(reversed_inventory), self.expected_groups)
|
||||
self.assertEqual(runner.partition(self.inventory + "TestZ\n"), (
|
||||
["Example", "TestFuture", "TestZ"],
|
||||
self.expected_groups[0],
|
||||
self.expected_groups[1],
|
||||
self.expected_groups[2],
|
||||
["FuzzInput", "TestZ"],
|
||||
self.expected_groups[3],
|
||||
))
|
||||
|
||||
def test_other_checks_keep_their_previous_group_and_order(self):
|
||||
general = sorted(name for name in self.inventory.splitlines()
|
||||
if name.startswith(("Test", "Example", "Fuzz"))
|
||||
and not name.startswith("TestRootInstallReset"))
|
||||
groups = runner.partition(self.inventory)
|
||||
for group, previous in zip(groups[1:3], (general[::2], general[1::2])):
|
||||
self.assertEqual(group, [name for name in previous if name not in runner.PREFLIGHT])
|
||||
|
||||
def test_missing_inventory_is_not_a_passing_empty_run(self):
|
||||
for inventory in ("", "ok\tpackage\n", "TestInstaller\n", "TestRootInstallResetSafety\n",
|
||||
"TestInstaller\nTestRootInstallResetSafety\n"):
|
||||
"TestInstaller\nTestRootInstallResetSafety\n",
|
||||
self.inventory.replace(runner.PREFLIGHT[0] + "\n", "")):
|
||||
with self.subTest(inventory=inventory), self.assertRaises(ValueError):
|
||||
runner.partition(inventory)
|
||||
|
||||
@@ -54,9 +65,9 @@ class NativeInstallerRunnerTest(unittest.TestCase):
|
||||
|
||||
def test_unicode_go_identifiers_remain_included(self):
|
||||
groups = runner.partition(self.inventory + "TestΣ\n")
|
||||
self.assertIn("TestΣ", [name for group in groups[:2] for name in group])
|
||||
self.assertIn("TestΣ", [name for group in groups[1:3] for name in group])
|
||||
|
||||
def run_main(self, fault=None):
|
||||
def run_main(self, fault=None, phase="all"):
|
||||
calls = []
|
||||
|
||||
def run(command, **kwargs):
|
||||
@@ -68,10 +79,10 @@ class NativeInstallerRunnerTest(unittest.TestCase):
|
||||
with mock.patch.object(runner.subprocess, "run", side_effect=run), mock.patch("builtins.print"):
|
||||
if fault:
|
||||
with self.assertRaises(subprocess.CalledProcessError) as raised:
|
||||
runner.main()
|
||||
runner.main(phase)
|
||||
self.assertEqual(raised.exception.returncode, 23)
|
||||
else:
|
||||
runner.main()
|
||||
runner.main(phase)
|
||||
return calls
|
||||
|
||||
def test_actual_commands_preserve_deadlines_count_and_complete_selection(self):
|
||||
@@ -81,7 +92,7 @@ class NativeInstallerRunnerTest(unittest.TestCase):
|
||||
self.assertTrue(calls[0][1]["text"])
|
||||
# Check the count before zip: a missing final reset invocation must not
|
||||
# silently truncate the comparison and become a passing control.
|
||||
self.assertEqual(len(calls), 4)
|
||||
self.assertEqual(len(calls), 5)
|
||||
for (command, kwargs), names in zip(calls[1:], self.expected_groups):
|
||||
self.assertEqual(command, ["go", "test", "-count=1", "-timeout", "10m", "-run", "^(" + "|".join(names) + ")$", runner.PACKAGE])
|
||||
self.assertTrue(kwargs["check"])
|
||||
@@ -89,7 +100,7 @@ class NativeInstallerRunnerTest(unittest.TestCase):
|
||||
|
||||
def test_command_selectors_do_not_broaden_the_inventory(self):
|
||||
calls = self.run_main()
|
||||
self.assertEqual(len(calls), 4)
|
||||
self.assertEqual(len(calls), 5)
|
||||
patterns = [re.compile(command[6]) for command, _ in calls[1:]]
|
||||
for name in (name for group in self.expected_groups for name in group):
|
||||
with self.subTest(name=name):
|
||||
@@ -101,15 +112,46 @@ class NativeInstallerRunnerTest(unittest.TestCase):
|
||||
def test_inventory_failure_does_not_run_any_group(self):
|
||||
self.assertEqual(len(self.run_main(1)), 1)
|
||||
|
||||
def test_first_general_failure_cannot_be_hidden_by_later_groups(self):
|
||||
def test_preflight_failure_cannot_be_hidden_by_later_groups(self):
|
||||
self.assertEqual(len(self.run_main(2)), 2)
|
||||
|
||||
def test_second_general_failure_remains_terminal(self):
|
||||
def test_first_general_failure_remains_terminal(self):
|
||||
self.assertEqual(len(self.run_main(3)), 3)
|
||||
|
||||
def test_reset_failure_remains_terminal(self):
|
||||
def test_second_general_failure_remains_terminal(self):
|
||||
self.assertEqual(len(self.run_main(4)), 4)
|
||||
|
||||
def test_reset_failure_remains_terminal(self):
|
||||
self.assertEqual(len(self.run_main(5)), 5)
|
||||
|
||||
def test_workflow_phases_execute_the_same_inventory_once(self):
|
||||
early = self.run_main(phase="preflight")
|
||||
remaining = self.run_main(phase="remaining")
|
||||
self.assertEqual(len(early), 2)
|
||||
self.assertEqual(len(remaining), 4)
|
||||
self.assertEqual(early[1][0], self.run_main()[1][0])
|
||||
self.assertEqual(remaining[1:], self.run_main()[2:])
|
||||
patterns = [re.compile(command[6]) for command, _ in early[1:] + remaining[1:]]
|
||||
for name in (name for group in self.expected_groups for name in group):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(sum(bool(pattern.fullmatch(name)) for pattern in patterns), 1)
|
||||
|
||||
def test_each_phased_failure_stops_without_retry(self):
|
||||
for phase, count in (("preflight", 2), ("remaining", 4)):
|
||||
for fault in range(1, count + 1):
|
||||
with self.subTest(phase=phase, fault=fault):
|
||||
self.assertEqual(len(self.run_main(fault, phase)), fault)
|
||||
|
||||
def test_unknown_phase_cannot_start_go_or_hide_checks(self):
|
||||
with mock.patch.object(runner.subprocess, "run") as run:
|
||||
with self.assertRaises(ValueError):
|
||||
runner.main("skip")
|
||||
run.assert_not_called()
|
||||
result = subprocess.run(["python3", str(ROOT / "scripts/installtests/run_unix_contracts.py"),
|
||||
"--phase", "skip"], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("invalid choice", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user