diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 53353260fd..faea5224d7 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1515,6 +1515,24 @@ router, customer data, production Docker socket, licence service or release publisher. The offline process transport verifies the daemon but is not proof of the native Compose mount or a full provider installation/tenant lifecycle. +The startup acceptance helper treats only command stdout as machine readback. +Successful Compose stderr warnings remain in the test log, never in container +identities, published loopback ports, mount hashes, bootstrap facts or teardown +inventories. Failed commands still fail acceptance and retain both output streams. +`TestProviderMSPStartupCommandReadbackSeparatesStderr` exercises all six readback +shapes with a real child process that emits an unset-variable warning; +`TestProviderMSPStartupCommandFailureRetainsBothStreams` verifies non-zero exit +and diagnostic retention. These offline controls do not establish hosted Compose +readiness, mounted-secret identity, persisted restart or clean owned teardown. + +The Linux-only startup fixture and its command controls must be excluded at +compile time on other platforms. A runtime platform skip cannot prevent the +control-plane's Unix-only dependencies from breaking Windows installer checks. +`TestProviderMSPStartupFixturesStayLinuxOnly` exercises Go's file-selection +rules for Linux, Windows and macOS; cross-compilation of the complete installer +test package must also succeed. This does not substitute for execution of the +Windows lifecycle controls or macOS installer and logging checks on native CI. + ## Canonical Files 1. `internal/updates/` diff --git a/scripts/installtests/provider_msp_startup_command_test.go b/scripts/installtests/provider_msp_startup_command_test.go new file mode 100644 index 0000000000..5d99adde72 --- /dev/null +++ b/scripts/installtests/provider_msp_startup_command_test.go @@ -0,0 +1,104 @@ +//go:build linux + +package installtests + +import ( + "fmt" + "go/build" + "os" + "os/exec" + "strings" + "testing" +) + +func TestProviderMSPStartupFixturesStayLinuxOnly(t *testing.T) { + for _, platform := range []string{"linux", "windows", "darwin"} { + for _, name := range []string{"provider_msp_startup_test.go", "provider_msp_startup_command_test.go"} { + t.Run(platform+"/"+name, func(t *testing.T) { + ctx := build.Default + ctx.GOOS, ctx.GOARCH = platform, "amd64" + included, err := ctx.MatchFile(repoFile("scripts", "installtests"), name) + if err != nil { + t.Fatal(err) + } + if included != (platform == "linux") { + t.Fatalf("%s fixture inclusion on %s is %v, want %v", name, platform, included, platform == "linux") + } + }) + } + } +} + +// Exercise the real command helper with separate child-process output streams. +// Compose emits unset-variable warnings on stderr even when a read succeeds. +func TestProviderMSPStartupCommandReadbackSeparatesStderr(t *testing.T) { + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, stdout string + }{ + {"container_identity", strings.Repeat("a", 64) + "\n"}, + {"mapped_port", "127.0.0.1:38443\n"}, + {"mount_digest", strings.Repeat("b", 64) + " /run/secrets/provider_msp_license\n"}, + {"bootstrap_facts", "provider_msp_bootstrap_ok=true\naccount_id=synthetic\n"}, + {"stopped_state", "false 0\n"}, + {"no_owned_resources", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + env := append(os.Environ(), + "PULSE_TEST_MSP_COMMAND_HELPER=emit", + "PULSE_TEST_MSP_COMMAND_STDOUT="+tc.stdout, + `PULSE_TEST_MSP_COMMAND_STDERR=time="synthetic" level=warning msg="The RESEND_API_KEY variable is not set. Defaulting to a blank string."`+"\n", + ) + got := startupCommand(t, env, t.TempDir(), binary, "-test.run=^TestProviderMSPStartupCommandHelper$") + if got != tc.stdout { + t.Fatalf("machine readback contains stderr or loses stdout: got %q, want %q", got, tc.stdout) + } + }) + } +} + +func TestProviderMSPStartupCommandFailureRetainsBothStreams(t *testing.T) { + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(binary, "-test.run=^TestProviderMSPStartupCommandHelper$") + cmd.Env = append(os.Environ(), "PULSE_TEST_MSP_COMMAND_HELPER=fail-wrapper") + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatal("a failed command must fail acceptance") + } + for _, want := range []string{"command failed: exit status 23", "synthetic stdout diagnostic", "synthetic stderr diagnostic"} { + if !strings.Contains(string(output), want) { + t.Fatalf("failed command loses diagnostic %q: %s", want, output) + } + } +} + +func TestProviderMSPStartupCommandHelper(t *testing.T) { + switch os.Getenv("PULSE_TEST_MSP_COMMAND_HELPER") { + case "": + return + case "emit": + fmt.Fprint(os.Stdout, os.Getenv("PULSE_TEST_MSP_COMMAND_STDOUT")) + fmt.Fprint(os.Stderr, os.Getenv("PULSE_TEST_MSP_COMMAND_STDERR")) + os.Exit(0) + case "emit-failure": + fmt.Fprintln(os.Stdout, "synthetic stdout diagnostic") + fmt.Fprintln(os.Stderr, "synthetic stderr diagnostic") + os.Exit(23) + case "fail-wrapper": + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + env := append(os.Environ(), "PULSE_TEST_MSP_COMMAND_HELPER=emit-failure") + startupCommand(t, env, t.TempDir(), binary, "-test.run=^TestProviderMSPStartupCommandHelper$") + t.Fatal("failed command was accepted") + default: + t.Fatal("unknown synthetic command helper mode") + } +} diff --git a/scripts/installtests/provider_msp_startup_test.go b/scripts/installtests/provider_msp_startup_test.go index 5a525d1aef..9a988dc87a 100644 --- a/scripts/installtests/provider_msp_startup_test.go +++ b/scripts/installtests/provider_msp_startup_test.go @@ -267,15 +267,18 @@ func startupCommand(t *testing.T, env []string, dir, program string, args ...str defer cancel() cmd := exec.CommandContext(ctx, program, args...) cmd.Dir, cmd.Env = dir, env + // Compose may warn on stderr even for a successful ps/port/config read. + // Only stdout is machine readback: a warning must never become a container + // identity, URL, mount digest, bootstrap fact or owned-resource observation. var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr - if err := cmd.Run(); err != nil { + err := cmd.Run() + if err != nil { // Environment and licence contents are never diagnostic output. - t.Fatalf("%s command failed: %v\n%s\n%s", filepath.Base(program), err, stdout.String(), stderr.String()) + t.Fatalf("%s command failed: %v\nstdout:\n%s\nstderr:\n%s", filepath.Base(program), err, stdout.String(), stderr.String()) } if stderr.Len() != 0 { - // Compose warnings are evidence, not container IDs, ports or CLI facts. - t.Logf("%s diagnostics: %s", filepath.Base(program), stderr.String()) + t.Logf("%s stderr: %s", filepath.Base(program), stderr.String()) } return stdout.String() }