From d66fc24ed1efbacc56c10797061cd9609ae6a0ab Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:20:12 -0400 Subject: [PATCH 1/4] Harden windowless kubectl exec authentication Reject selected kubeconfig exec-auth requiring interaction before pgbackup kubectl launches, while preserving finite restore stdin and command diagnostics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- .github/workflows/unit-tests.yaml | 2 +- docs/architecture/rad-cli.md | 2 + pkg/cli/pgbackup/kubectl.go | 67 ++++ pkg/cli/pgbackup/kubectl_config_test.go | 153 ++++++++ pkg/cli/pgbackup/kubectl_other_test.go | 29 ++ pkg/cli/pgbackup/kubectl_test.go | 438 +++++++++++++++++++++++ pkg/cli/pgbackup/kubectl_windows_test.go | 88 +++++ pkg/cli/pgbackup/pgbackup.go | 31 +- 8 files changed, 795 insertions(+), 15 deletions(-) create mode 100644 pkg/cli/pgbackup/kubectl.go create mode 100644 pkg/cli/pgbackup/kubectl_config_test.go create mode 100644 pkg/cli/pgbackup/kubectl_other_test.go create mode 100644 pkg/cli/pgbackup/kubectl_test.go create mode 100644 pkg/cli/pgbackup/kubectl_windows_test.go diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 168d44e5f0..9dc5b25143 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -139,4 +139,4 @@ jobs: - name: Run Windows CLI process tests env: CGO_ENABLED: 0 - run: go test ./pkg/process ./pkg/cli/style ./test/windowless -count=1 -timeout=2m + run: go test ./pkg/process ./pkg/cli/style ./pkg/cli/pgbackup ./test/windowless -count=1 -timeout=2m diff --git a/docs/architecture/rad-cli.md b/docs/architecture/rad-cli.md index 2ec6d63a7c..cf17210c45 100644 --- a/docs/architecture/rad-cli.md +++ b/docs/architecture/rad-cli.md @@ -100,6 +100,8 @@ When `rad.exe` has an attached console, child terminal access and interactive CL Tool adapters can query `process.IsWindowless()` for the same Windows no-console policy used by `Command` and `CommandContext`. The query uses `GetConsoleCP` when called, preserving classic console and Windows Terminal/ConPTY behavior; it does not probe during package initialization and returns false on non-Windows platforms. In windowless mode, command configuration supplies explicit EOF only when stdin is unset, preserves existing input readers, and allows callers to assign finite data to `Cmd.Stdin` after construction, as PostgreSQL restore does for SQL input. Go already connects nil stdin to the null device, so this clarifies the default rather than adding a universal anti-hang mechanism. It does not force arbitrary tools or SDK-owned credential helpers to be non-interactive; tool-specific prompt controls remain separate. +Before each PostgreSQL backup/restore kubectl invocation, [pkg/cli/pgbackup](../../pkg/cli/pgbackup/) checks the selected kubeconfig exec-auth configuration in Windows no-console mode. It honors the explicit context or current context and standard `KUBECONFIG` file-list merging/default loading, without changing kubeconfig or running a credential plugin during the check. A selected plugin declaring `interactiveMode: Always` is rejected with guidance to configure non-interactive Kubernetes credentials or use an attached console; `Never`, `IfAvailable`, and unused contexts are not rejected merely for using exec-auth. Lookup, readiness, and backup receive EOF input; restore keeps `kubectl exec -i` and the complete SQL input followed by EOF. Caller cancellation and kubectl's existing `--timeout=120s` readiness limit remain unchanged; no general operation timeout is added. This check does not guarantee completion for arbitrary credential plugins that ignore their interaction mode or cancellation. + ## Invariants And Constraints - Commands should stay thin and use the shared framework. diff --git a/pkg/cli/pgbackup/kubectl.go b/pkg/cli/pgbackup/kubectl.go new file mode 100644 index 0000000000..12621c03d9 --- /dev/null +++ b/pkg/cli/pgbackup/kubectl.go @@ -0,0 +1,67 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pgbackup + +import ( + "context" + "fmt" + "os/exec" + + "github.com/radius-project/radius/pkg/process" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" +) + +func kubectlCommand(ctx context.Context, kubeContext, namespace string, args ...string) (*exec.Cmd, error) { + if process.IsWindowless() { + if err := validateExecAuth(kubeContext); err != nil { + return nil, err + } + } + + args = append([]string{"--context", kubeContext, "-n", namespace}, args...) + return process.CommandContext(ctx, "kubectl", args...), nil +} + +func validateExecAuth(kubeContext string) error { + // Use kubectl's file-list merging and API defaults, but never migrate or write + // kubeconfig files during this read-only check. + rules := clientcmd.NewDefaultClientConfigLoadingRules() + rules.MigrationRules = nil + config, err := rules.Load() + if err != nil { + return fmt.Errorf("failed to load kubectl configuration: %w", err) + } + + overrides := &clientcmd.ConfigOverrides{ + CurrentContext: kubeContext, + ClusterDefaults: clientcmd.ClusterDefaults, + } + // MergedRawConfig validates only the selected configuration, without creating + // a transport, authenticating, or executing a credential plugin. + selected, err := clientcmd.NewNonInteractiveClientConfig(*config, "", overrides, rules).MergedRawConfig() + if err != nil { + return fmt.Errorf("invalid kubectl configuration: %w", err) + } + + selectedContext := selected.Contexts[selected.CurrentContext] + auth := selected.AuthInfos[selectedContext.AuthInfo] + if auth.Exec != nil && auth.Exec.InteractiveMode == api.AlwaysExecInteractiveMode { + return fmt.Errorf("kubectl context %q requires interactive exec authentication (interactiveMode: Always), but Radius is running without a Windows console; configure non-interactive Kubernetes credentials or run Radius from an attached console", selected.CurrentContext) + } + return nil +} diff --git a/pkg/cli/pgbackup/kubectl_config_test.go b/pkg/cli/pgbackup/kubectl_config_test.go new file mode 100644 index 0000000000..a94ea4af2f --- /dev/null +++ b/pkg/cli/pgbackup/kubectl_config_test.go @@ -0,0 +1,153 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pgbackup + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" +) + +func TestValidateExecAuth(t *testing.T) { + installKubectlHelpers(t) + for _, tt := range []struct { + name string + mode api.ExecInteractiveMode + version string + wantErr string + }{ + {name: "never", mode: api.NeverExecInteractiveMode, version: "v1"}, + {name: "if available", mode: api.IfAvailableExecInteractiveMode, version: "v1"}, + {name: "always", mode: api.AlwaysExecInteractiveMode, version: "v1", wantErr: "interactiveMode: Always"}, + {name: "v1 requires mode", version: "v1", wantErr: "interactiveMode must be specified"}, + {name: "v1beta1 defaults mode", version: "v1beta1"}, + {name: "invalid mode", mode: "sometimes", version: "v1beta1", wantErr: "invalid interactiveMode"}, + } { + t.Run(tt.name, func(t *testing.T) { + path := writeKubeconfig(t, execAuthConfig(tt.mode, "client.authentication.k8s.io/"+tt.version)) + t.Setenv("KUBECONFIG", path) + err := validateExecAuth(testContext) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + require.NoFileExists(t, os.Getenv(helperPluginEnv)) + }) + } + + for _, tt := range []struct { + name string + modify func(*api.Config) + context string + wantErr string + }{ + {name: "no exec auth", modify: func(c *api.Config) { c.AuthInfos["selected-user"].Exec = nil }}, + {name: "unused invalid exec", modify: func(c *api.Config) { c.AuthInfos["unused-user"].Exec.InteractiveMode = "invalid" }}, + {name: "explicit context overrides current", context: testContext, modify: func(c *api.Config) { c.CurrentContext = "unused" }}, + {name: "current context is used", modify: func(c *api.Config) { c.CurrentContext = "unused" }, wantErr: "interactiveMode: Always"}, + {name: "missing context", context: "missing", wantErr: "context"}, + {name: "missing current context", modify: func(c *api.Config) { c.CurrentContext = "missing" }, wantErr: "context"}, + {name: "missing command", modify: func(c *api.Config) { c.AuthInfos["selected-user"].Exec.Command = "" }, wantErr: "command must be specified"}, + {name: "missing API version", modify: func(c *api.Config) { c.AuthInfos["selected-user"].Exec.APIVersion = "" }, wantErr: "apiVersion must be specified"}, + {name: "conflicting auth", modify: func(c *api.Config) { + c.AuthInfos["selected-user"].AuthProvider = &api.AuthProviderConfig{Name: "unused"} + }, wantErr: "authProvider cannot be provided"}, + {name: "relative missing CA", modify: func(c *api.Config) { c.Clusters["cluster"].CertificateAuthority = "missing-ca" }, wantErr: "unable to read certificate-authority"}, + } { + t.Run(tt.name, func(t *testing.T) { + config := execAuthConfig(api.NeverExecInteractiveMode, "client.authentication.k8s.io/v1") + if tt.modify != nil { + tt.modify(&config) + } + t.Setenv("KUBECONFIG", writeKubeconfig(t, config)) + err := validateExecAuth(tt.context) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + require.NoFileExists(t, os.Getenv(helperPluginEnv)) + }) + } +} + +func mergedKubeconfig(t *testing.T, mode api.ExecInteractiveMode) string { + t.Helper() + first := *api.NewConfig() + first.CurrentContext = testContext + first.Contexts[testContext] = &api.Context{Cluster: "cluster", AuthInfo: "selected-user"} + first.AuthInfos["selected-user"] = &api.AuthInfo{Exec: &api.ExecConfig{ + Command: "credential-plugin", APIVersion: "client.authentication.k8s.io/v1", InteractiveMode: mode, + }} + second := execAuthConfig(api.AlwaysExecInteractiveMode, "client.authentication.k8s.io/v1") + second.CurrentContext = "unused" + second.Contexts[testContext] = &api.Context{Cluster: "cluster", AuthInfo: "unused-user"} + firstPath := writeKubeconfig(t, first) + return strings.Join([]string{firstPath, "", filepath.Join(t.TempDir(), "missing"), writeKubeconfig(t, second), firstPath}, string(os.PathListSeparator)) +} + +func TestValidateExecAuth_Merging(t *testing.T) { + installKubectlHelpers(t) + for _, mode := range []api.ExecInteractiveMode{api.NeverExecInteractiveMode, api.AlwaysExecInteractiveMode} { + t.Run(string(mode), func(t *testing.T) { + t.Setenv("KUBECONFIG", mergedKubeconfig(t, mode)) + for _, kubeContext := range []string{"", testContext} { + err := validateExecAuth(kubeContext) + if mode == api.AlwaysExecInteractiveMode { + require.ErrorContains(t, err, "interactiveMode: Always") + } else { + require.NoError(t, err, "first user and context win; cluster comes from the second file") + } + } + require.NoFileExists(t, os.Getenv(helperPluginEnv)) + }) + } + t.Run("bad second file is not ignored", func(t *testing.T) { + good := writeKubeconfig(t, execAuthConfig(api.NeverExecInteractiveMode, "client.authentication.k8s.io/v1")) + bad := filepath.Join(t.TempDir(), "bad") + require.NoError(t, os.WriteFile(bad, []byte("not: [valid yaml"), 0o600)) + t.Setenv("KUBECONFIG", good+string(os.PathListSeparator)+bad) + require.ErrorContains(t, validateExecAuth(testContext), "failed to load kubectl configuration") + }) +} + +func TestValidateExecAuth_DefaultHome(t *testing.T) { + const helperEnv = "RADIUS_PGBACKUP_TEST_HOME" + if os.Getenv(helperEnv) != "" { + require.Equal(t, filepath.Join(os.Getenv(helperEnv), ".kube", "config"), clientcmd.RecommendedHomeFile) + require.ErrorContains(t, validateExecAuth(""), "interactiveMode: Always") + return + } + home := t.TempDir() + config := execAuthConfig(api.AlwaysExecInteractiveMode, "client.authentication.k8s.io/v1") + require.NoError(t, clientcmd.WriteToFile(config, filepath.Join(home, ".kube", "config"))) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestValidateExecAuth_DefaultHome$") + cmd.Env = append(os.Environ(), helperEnv+"="+home, "HOME="+home, "USERPROFILE="+home, "KUBECONFIG=") + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) +} diff --git a/pkg/cli/pgbackup/kubectl_other_test.go b/pkg/cli/pgbackup/kubectl_other_test.go new file mode 100644 index 0000000000..5109293cda --- /dev/null +++ b/pkg/cli/pgbackup/kubectl_other_test.go @@ -0,0 +1,29 @@ +//go:build !windows + +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pgbackup + +import "testing" + +func TestKubectl(t *testing.T) { + testKubectl(t) +} + +func helperConsoleWindow() bool { + return false +} diff --git a/pkg/cli/pgbackup/kubectl_test.go b/pkg/cli/pgbackup/kubectl_test.go new file mode 100644 index 0000000000..dbb59c7a73 --- /dev/null +++ b/pkg/cli/pgbackup/kubectl_test.go @@ -0,0 +1,438 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pgbackup + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/radius-project/radius/pkg/process" + "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" +) + +const ( + helperLogEnv = "RADIUS_PGBACKUP_TEST_LOG" + helperModeEnv = "RADIUS_PGBACKUP_TEST_MODE" + helperTargetEnv = "RADIUS_PGBACKUP_TEST_TARGET" + helperPluginEnv = "RADIUS_PGBACKUP_TEST_PLUGIN" + helperPolicyEnv = "RADIUS_PGBACKUP_TEST_POLICY" + helperStderr = "credential or kubectl diagnostic" + testContext = "selected" + testNamespace = "custom-namespace" + testPod = "postgres-selected-0" +) + +type kubectlCall struct { + Args []string + Input []byte + Kubeconfig string + ConsoleWindow bool +} + +func TestMain(m *testing.M) { + switch strings.TrimSuffix(filepath.Base(os.Args[0]), ".exe") { + case "kubectl": + os.Exit(runKubectlHelper()) //nolint:forbidigo // Behave as the fake kubectl executable, without test-runner output. + case "credential-plugin": + if err := os.WriteFile(os.Getenv(helperPluginEnv), []byte("plugin ran"), 0o600); err != nil { + fmt.Fprintln(os.Stderr, err) + } + os.Exit(9) //nolint:forbidigo // A sentinel plugin must never run during preflight. + } + os.Exit(m.Run()) //nolint:forbidigo // Return the test suite's exit status. +} + +func runKubectlHelper() int { + input, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + call := kubectlCall{Args: os.Args[1:], Input: input, Kubeconfig: os.Getenv("KUBECONFIG"), ConsoleWindow: helperConsoleWindow()} + log, err := os.OpenFile(os.Getenv(helperLogEnv), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + err = json.NewEncoder(log).Encode(call) + closeErr := log.Close() + if err != nil || closeErr != nil { + fmt.Fprintln(os.Stderr, err, closeErr) + return 1 + } + operation := call.Args[4] + if operation == "exec" { + if call.Args[5] == "-i" { + operation = "psql" + } else { + operation = "pg_dump" + } + } + if operation == os.Getenv(helperTargetEnv) { + switch os.Getenv(helperModeEnv) { + case "fail": + fmt.Fprint(os.Stderr, helperStderr) + return 7 + case "wait": + time.Sleep(time.Minute) + return 1 + case "empty": + return 0 + } + } + if operation == "get" { + if os.Getenv(helperModeEnv) == "switch-auth" { + config, err := clientcmd.LoadFromFile(os.Getenv("KUBECONFIG")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + config.AuthInfos["selected-user"].Exec.InteractiveMode = api.AlwaysExecInteractiveMode + if err := clientcmd.WriteToFile(*config, os.Getenv("KUBECONFIG")); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + } + fmt.Fprint(os.Stdout, " \n"+testPod+"\n") + } else if operation == "pg_dump" { + fmt.Fprint(os.Stdout, "-- dump "+call.Args[len(call.Args)-1]+"\n") + } + return 0 +} + +func execAuthConfig(mode api.ExecInteractiveMode, version string) api.Config { + config := *api.NewConfig() + config.CurrentContext = testContext + config.Contexts[testContext] = &api.Context{Cluster: "cluster", AuthInfo: "selected-user"} + config.Contexts["unused"] = &api.Context{Cluster: "cluster", AuthInfo: "unused-user"} + config.Clusters["cluster"] = &api.Cluster{Server: "https://unused.invalid"} + config.AuthInfos["selected-user"] = &api.AuthInfo{Exec: &api.ExecConfig{ + Command: "credential-plugin", APIVersion: version, InteractiveMode: mode, + }} + config.AuthInfos["unused-user"] = &api.AuthInfo{Exec: &api.ExecConfig{ + Command: "credential-plugin", APIVersion: "client.authentication.k8s.io/v1", InteractiveMode: api.AlwaysExecInteractiveMode, + }} + return config +} + +func writeKubeconfig(t *testing.T, config api.Config) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config") + require.NoError(t, clientcmd.WriteToFile(config, path)) + return path +} + +func installKubectlHelpers(t *testing.T) { + t.Helper() + dir := t.TempDir() + executable, err := os.Executable() + require.NoError(t, err) + for _, name := range []string{"kubectl", "credential-plugin"} { + if runtime.GOOS == "windows" { + name += ".exe" + } + src, err := os.Open(executable) + require.NoError(t, err) + dst, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700) + require.NoError(t, err) + _, copyErr := io.Copy(dst, src) + require.NoError(t, src.Close()) + require.NoError(t, dst.Close()) + require.NoError(t, copyErr) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(helperPluginEnv, filepath.Join(dir, "plugin-ran")) +} + +func configureKubectlTest(t *testing.T, mode api.ExecInteractiveMode) string { + t.Helper() + configPath := writeKubeconfig(t, execAuthConfig(mode, "client.authentication.k8s.io/v1")) + t.Setenv("KUBECONFIG", configPath) + t.Setenv(helperLogEnv, filepath.Join(t.TempDir(), "calls.json")) + t.Setenv(helperModeEnv, "") + t.Setenv(helperTargetEnv, "") + t.Cleanup(func() { require.NoFileExists(t, os.Getenv(helperPluginEnv)) }) + return configPath +} + +func readKubectlCalls(t *testing.T) []kubectlCall { + t.Helper() + file, err := os.Open(os.Getenv(helperLogEnv)) + if os.IsNotExist(err) { + return nil + } + require.NoError(t, err) + defer file.Close() + var calls []kubectlCall + decoder := json.NewDecoder(file) + for { + var call kubectlCall + err := decoder.Decode(&call) + if err == io.EOF { + return calls + } + require.NoError(t, err) + calls = append(calls, call) + } +} + +func testSQL(db string) []byte { + return []byte("-- " + db + "\n" + strings.Repeat("SELECT 'finite input';\r\n", 8192) + "\x00\n") +} + +func runKubectlPath(ctx context.Context, operation, kubeContext, stateDir string) error { + switch operation { + case "get": + _, err := getPodName(ctx, kubeContext, testNamespace) + return err + case "wait": + return WaitForReady(ctx, kubeContext, testNamespace) + case "pg_dump": + return Backup(ctx, kubeContext, testNamespace, stateDir) + default: + return Restore(ctx, kubeContext, testNamespace, stateDir) + } +} + +func prepareDumps(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, db := range Databases { + require.NoError(t, os.WriteFile(filepath.Join(dir, db+".sql"), testSQL(db), 0o600)) + } + return dir +} + +func testKubectl(t *testing.T) { + switch os.Getenv(helperPolicyEnv) { + case "windowless": + require.True(t, process.IsWindowless(), "must exercise the live no-console policy") + case "attached": + require.False(t, process.IsWindowless(), "must exercise a real attached console") + } + if runtime.GOOS != "windows" { + require.False(t, process.IsWindowless()) + } + installKubectlHelpers(t) + + for _, mode := range []api.ExecInteractiveMode{api.NeverExecInteractiveMode, api.IfAvailableExecInteractiveMode, api.AlwaysExecInteractiveMode} { + for _, operation := range []string{"get", "wait", "pg_dump", "psql"} { + t.Run(string(mode)+"/"+operation, func(t *testing.T) { + configPath := configureKubectlTest(t, mode) + before, err := os.ReadFile(configPath) + require.NoError(t, err) + stdin := os.Stdin + stateDir := prepareDumps(t) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + err = runKubectlPath(ctx, operation, testContext, stateDir) + if mode == api.AlwaysExecInteractiveMode && process.IsWindowless() { + require.ErrorContains(t, err, "interactiveMode: Always") + require.ErrorContains(t, err, "non-interactive Kubernetes credentials") + require.Empty(t, readKubectlCalls(t), "preflight must reject before any kubectl launch") + } else { + require.NoError(t, err) + assertKubectlCalls(t, operation, testContext) + if operation == "pg_dump" { + for _, db := range Databases { + dump, err := os.ReadFile(filepath.Join(stateDir, db+".sql")) + require.NoError(t, err) + require.Equal(t, "-- dump "+db+"\n", string(dump)) + } + } + } + after, err := os.ReadFile(configPath) + require.NoError(t, err) + require.Equal(t, before, after, "preflight must not change kubeconfig") + require.Same(t, stdin, os.Stdin) + require.Equal(t, configPath, os.Getenv("KUBECONFIG")) + }) + } + } + + t.Run("exec paths recheck authentication after lookup", func(t *testing.T) { + for _, operation := range []string{"pg_dump", "psql"} { + t.Run(operation, func(t *testing.T) { + configureKubectlTest(t, api.NeverExecInteractiveMode) + t.Setenv(helperModeEnv, "switch-auth") + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + err := runKubectlPath(ctx, operation, testContext, prepareDumps(t)) + if process.IsWindowless() { + require.ErrorContains(t, err, "interactiveMode: Always") + calls := readKubectlCalls(t) + require.Len(t, calls, 1) + require.Equal(t, "get", calls[0].Args[4]) + } else { + require.NoError(t, err) + assertKubectlCalls(t, operation, testContext) + } + }) + } + }) + + t.Run("merged config reaches every command", func(t *testing.T) { + for _, operation := range []string{"get", "wait", "pg_dump", "psql"} { + t.Run(operation, func(t *testing.T) { + configureKubectlTest(t, api.NeverExecInteractiveMode) + t.Setenv("KUBECONFIG", mergedKubeconfig(t, api.NeverExecInteractiveMode)) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + require.NoError(t, runKubectlPath(ctx, operation, "", prepareDumps(t))) + assertKubectlCalls(t, operation, "") + }) + } + }) + + t.Run("broken config is inspected only without a console", func(t *testing.T) { + path := configureKubectlTest(t, api.NeverExecInteractiveMode) + require.NoError(t, os.WriteFile(path, []byte("not: [valid yaml"), 0o600)) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + err := WaitForReady(ctx, testContext, testNamespace) + if process.IsWindowless() { + require.ErrorContains(t, err, "failed to load kubectl configuration") + require.NotContains(t, err.Error(), "timed out") + require.Empty(t, readKubectlCalls(t)) + } else { + require.NoError(t, err) + assertKubectlCalls(t, "wait", testContext) + } + }) + + t.Run("no backup remains a no-op", func(t *testing.T) { + configureKubectlTest(t, api.AlwaysExecInteractiveMode) + require.NoError(t, Restore(t.Context(), testContext, testNamespace, t.TempDir())) + require.Empty(t, readKubectlCalls(t)) + }) + + t.Run("empty pod selection", func(t *testing.T) { + configureKubectlTest(t, api.NeverExecInteractiveMode) + t.Setenv(helperModeEnv, "empty") + t.Setenv(helperTargetEnv, "get") + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + _, err := getPodName(ctx, testContext, testNamespace) + require.ErrorContains(t, err, "no PostgreSQL pod found") + }) + + for _, operation := range []string{"get", "wait", "pg_dump", "psql"} { + t.Run(operation+"/ordinary failure", func(t *testing.T) { + configureKubectlTest(t, api.NeverExecInteractiveMode) + t.Setenv(helperModeEnv, "fail") + t.Setenv(helperTargetEnv, operation) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + err := runKubectlPath(ctx, operation, testContext, prepareDumps(t)) + require.ErrorContains(t, err, helperStderr) + require.NotContains(t, err.Error(), "timed out") + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 7, exitErr.ExitCode()) + require.NotErrorIs(t, err, context.Canceled) + }) + t.Run(operation+"/canceled before launch", func(t *testing.T) { + configureKubectlTest(t, api.NeverExecInteractiveMode) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err := runKubectlPath(ctx, operation, testContext, prepareDumps(t)) + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, readKubectlCalls(t)) + }) + t.Run(operation+"/cancel running child", func(t *testing.T) { + configureKubectlTest(t, api.NeverExecInteractiveMode) + t.Setenv(helperModeEnv, "wait") + t.Setenv(helperTargetEnv, operation) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + stateDir := prepareDumps(t) + done := make(chan error, 1) + finished := make(chan struct{}) + go func() { + defer close(finished) + done <- runKubectlPath(ctx, operation, testContext, stateDir) + }() + t.Cleanup(func() { + cancel() + <-finished + }) + // Wait for the child to finish reading stdin before exercising cancellation. + require.Eventually(t, func() bool { + data, err := os.ReadFile(os.Getenv(helperLogEnv)) + if err != nil { + return false + } + return strings.Contains(string(data), `"`+operation+`"`) && len(data) > 0 && data[len(data)-1] == '\n' + }, 10*time.Second, 10*time.Millisecond) + cancel() + err := <-done + require.Error(t, err) + require.ErrorIs(t, ctx.Err(), context.Canceled) + require.NotContains(t, err.Error(), "timed out") + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + }) + } +} + +func assertKubectlCalls(t *testing.T, operation, kubeContext string) { + t.Helper() + prefix := []string{"--context", kubeContext, "-n", testNamespace} + lookup := append(append([]string{}, prefix...), "get", "pods", "-l", PodLabelSelector, "-o", "jsonpath={.items[0].metadata.name}") + var want [][]string + switch operation { + case "get": + want = append(want, lookup) + case "wait": + want = append(want, append(append([]string{}, prefix...), "wait", "--for=condition=ready", "pod", "-l", PodLabelSelector, "--timeout=120s")) + default: + want = append(want, lookup) + for _, db := range Databases { + args := append([]string{}, prefix...) + if operation == "pg_dump" { + args = append(args, "exec", testPod, "--", "pg_dump", "-U", PostgresUser, "--format=plain", "--clean", "--if-exists", db) + } else { + args = append(args, "exec", "-i", testPod, "--", "psql", "-U", PostgresUser, "-d", db) + } + want = append(want, args) + } + } + calls := readKubectlCalls(t) + require.Len(t, calls, len(want)) + for i, call := range calls { + require.Equal(t, want[i], call.Args) + require.Equal(t, os.Getenv("KUBECONFIG"), call.Kubeconfig) + if process.IsWindowless() { + require.False(t, call.ConsoleWindow, "the child must not create a console window") + } + if operation == "psql" && i > 0 { + require.Equal(t, testSQL(Databases[i-1]), call.Input, "all SQL bytes must arrive before EOF") + } else { + require.Empty(t, call.Input, "commands without input must reach EOF") + } + } +} diff --git a/pkg/cli/pgbackup/kubectl_windows_test.go b/pkg/cli/pgbackup/kubectl_windows_test.go new file mode 100644 index 0000000000..8d9a5ad37d --- /dev/null +++ b/pkg/cli/pgbackup/kubectl_windows_test.go @@ -0,0 +1,88 @@ +//go:build windows + +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pgbackup + +import ( + "context" + "os" + "os/exec" + "syscall" + "testing" + "time" + "unsafe" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func TestKubectl_ConsolePolicy(t *testing.T) { + for _, tt := range []struct { + name string + flags uint32 + tests string + }{ + {name: "windowless", flags: windows.CREATE_NO_WINDOW, tests: "^TestKubectlHelper$"}, + {name: "attached", flags: windows.CREATE_NEW_CONSOLE, tests: "^TestKubectlHelper$/^(Always|broken_config)"}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run="+tt.tests, "-test.v") + cmd.Env = append(os.Environ(), helperPolicyEnv+"="+tt.name) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: tt.flags} + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + }) + } +} + +func TestKubectlHelper(t *testing.T) { + if os.Getenv(helperPolicyEnv) == "" { + t.Skip("invoked by the console-policy test in an isolated process") + } + if os.Getenv(helperPolicyEnv) == "windowless" { + // CREATE_NO_WINDOW can still supply a hidden console. Match an automation + // caller with no console attachment, as the existing windowless tests do. + freeConsole := windows.NewLazySystemDLL("kernel32.dll").NewProc("FreeConsole") + result, _, err := freeConsole.Call() + require.NotZero(t, result, "FreeConsole: %v", err) + } + job, err := windows.CreateJobObject(nil, nil) + require.NoError(t, err) + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))) + if err != nil { + _ = windows.CloseHandle(job) + t.Fatal(err) + } + if err := windows.AssignProcessToJobObject(job, windows.CurrentProcess()); err != nil { + _ = windows.CloseHandle(job) + t.Fatal(err) + } + // Keep the only job handle until process exit so a test-safety timeout also + // terminates any owned kubectl child, without breakaway or detached execution. + testKubectl(t) +} + +func helperConsoleWindow() bool { + getConsoleWindow := windows.NewLazySystemDLL("kernel32.dll").NewProc("GetConsoleWindow") + window, _, _ := getConsoleWindow.Call() + return window != 0 +} diff --git a/pkg/cli/pgbackup/pgbackup.go b/pkg/cli/pgbackup/pgbackup.go index 4dd6cbbe3a..312527e3aa 100644 --- a/pkg/cli/pgbackup/pgbackup.go +++ b/pkg/cli/pgbackup/pgbackup.go @@ -31,7 +31,6 @@ import ( "path/filepath" "strings" - "github.com/radius-project/radius/pkg/process" "github.com/radius-project/radius/pkg/ucp/ucplog" ) @@ -113,9 +112,7 @@ func Backup(ctx context.Context, kubeContext, namespace, stateDir string) error for _, db := range Databases { logger.Info("Backing up database", "database", db, "stateDir", stateDir) - cmd := process.CommandContext(ctx, "kubectl", - "--context", kubeContext, - "-n", namespace, + cmd, err := kubectlCommand(ctx, kubeContext, namespace, "exec", podName, "--", "pg_dump", "-U", PostgresUser, @@ -124,6 +121,9 @@ func Backup(ctx context.Context, kubeContext, namespace, stateDir string) error "--if-exists", db, ) + if err != nil { + return err + } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -168,14 +168,15 @@ func Restore(ctx context.Context, kubeContext, namespace, stateDir string) error return fmt.Errorf("failed to read backup file %q: %w", sqlPath, err) } - cmd := process.CommandContext(ctx, "kubectl", - "--context", kubeContext, - "-n", namespace, + cmd, err := kubectlCommand(ctx, kubeContext, namespace, "exec", "-i", podName, "--", "psql", "-U", PostgresUser, "-d", db, ) + if err != nil { + return err + } cmd.Stdin = bytes.NewReader(sqlData) var stderr bytes.Buffer @@ -196,21 +197,22 @@ func WaitForReady(ctx context.Context, kubeContext, namespace string) error { logger := ucplog.FromContextOrDiscard(ctx) logger.Info("Waiting for PostgreSQL pod to be ready") - cmd := process.CommandContext(ctx, "kubectl", - "--context", kubeContext, - "-n", namespace, + cmd, err := kubectlCommand(ctx, kubeContext, namespace, "wait", "--for=condition=ready", "pod", "-l", PodLabelSelector, "--timeout=120s", ) + if err != nil { + return err + } var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return fmt.Errorf("timed out waiting for PostgreSQL pod: %w: %s", err, stderr.String()) + return fmt.Errorf("failed waiting for PostgreSQL pod readiness: %w: %s", err, stderr.String()) } logger.Info("PostgreSQL pod is ready") @@ -219,13 +221,14 @@ func WaitForReady(ctx context.Context, kubeContext, namespace string) error { // getPodName resolves the name of the PostgreSQL pod via its label selector. func getPodName(ctx context.Context, kubeContext, namespace string) (string, error) { - cmd := process.CommandContext(ctx, "kubectl", - "--context", kubeContext, - "-n", namespace, + cmd, err := kubectlCommand(ctx, kubeContext, namespace, "get", "pods", "-l", PodLabelSelector, "-o", "jsonpath={.items[0].metadata.name}", ) + if err != nil { + return "", err + } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout From 91c2ddb43df5ba1c4af57526c0526609a40f1af6 Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:23:27 -0400 Subject: [PATCH 2/4] Clarify the PostgreSQL helper documentation link Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- docs/architecture/rad-cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/rad-cli.md b/docs/architecture/rad-cli.md index cf17210c45..223f58470c 100644 --- a/docs/architecture/rad-cli.md +++ b/docs/architecture/rad-cli.md @@ -100,7 +100,7 @@ When `rad.exe` has an attached console, child terminal access and interactive CL Tool adapters can query `process.IsWindowless()` for the same Windows no-console policy used by `Command` and `CommandContext`. The query uses `GetConsoleCP` when called, preserving classic console and Windows Terminal/ConPTY behavior; it does not probe during package initialization and returns false on non-Windows platforms. In windowless mode, command configuration supplies explicit EOF only when stdin is unset, preserves existing input readers, and allows callers to assign finite data to `Cmd.Stdin` after construction, as PostgreSQL restore does for SQL input. Go already connects nil stdin to the null device, so this clarifies the default rather than adding a universal anti-hang mechanism. It does not force arbitrary tools or SDK-owned credential helpers to be non-interactive; tool-specific prompt controls remain separate. -Before each PostgreSQL backup/restore kubectl invocation, [pkg/cli/pgbackup](../../pkg/cli/pgbackup/) checks the selected kubeconfig exec-auth configuration in Windows no-console mode. It honors the explicit context or current context and standard `KUBECONFIG` file-list merging/default loading, without changing kubeconfig or running a credential plugin during the check. A selected plugin declaring `interactiveMode: Always` is rejected with guidance to configure non-interactive Kubernetes credentials or use an attached console; `Never`, `IfAvailable`, and unused contexts are not rejected merely for using exec-auth. Lookup, readiness, and backup receive EOF input; restore keeps `kubectl exec -i` and the complete SQL input followed by EOF. Caller cancellation and kubectl's existing `--timeout=120s` readiness limit remain unchanged; no general operation timeout is added. This check does not guarantee completion for arbitrary credential plugins that ignore their interaction mode or cancellation. +Before each PostgreSQL backup/restore kubectl invocation, [the PostgreSQL backup helper](../../pkg/cli/pgbackup/) checks the selected kubeconfig exec-auth configuration in Windows no-console mode. It honors the explicit context or current context and standard `KUBECONFIG` file-list merging/default loading, without changing kubeconfig or running a credential plugin during the check. A selected plugin declaring `interactiveMode: Always` is rejected with guidance to configure non-interactive Kubernetes credentials or use an attached console; `Never`, `IfAvailable`, and unused contexts are not rejected merely for using exec-auth. Lookup, readiness, and backup receive EOF input; restore keeps `kubectl exec -i` and the complete SQL input followed by EOF. Caller cancellation and kubectl's existing `--timeout=120s` readiness limit remain unchanged; no general operation timeout is added. This check does not guarantee completion for arbitrary credential plugins that ignore their interaction mode or cancellation. ## Invariants And Constraints From c7414ba67a0e8b5624ee0066ab4f8b3bbc082979 Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:34:22 -0400 Subject: [PATCH 3/4] Recognize pgbackup in the spelling dictionary Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- .cspellignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.cspellignore b/.cspellignore index a8e8533a90..626a97acdc 100644 --- a/.cspellignore +++ b/.cspellignore @@ -961,6 +961,7 @@ periodSeconds persistentVolume persistentVolumes pfx +pgbackup pkgs plaidResource plainHTTP From da8010a1eae80e2dd9be8e1dd347690f10b3f50d Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:51:28 -0400 Subject: [PATCH 4/4] Preserve kubectl context selection and preflight cancellation Honor explicit contexts even when current-context is stale, prefer caller cancellation over preflight errors, and align the documented Windows regression command with CI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- .../contributing-code-cli/README.md | 4 +- pkg/cli/pgbackup/kubectl.go | 11 +++- pkg/cli/pgbackup/kubectl_config_test.go | 5 ++ pkg/cli/pgbackup/kubectl_test.go | 51 ++++++++++++++++--- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/docs/contributing/contributing-code/contributing-code-cli/README.md b/docs/contributing/contributing-code/contributing-code-cli/README.md index 1b6dd35804..6689e6f531 100644 --- a/docs/contributing/contributing-code/contributing-code-cli/README.md +++ b/docs/contributing/contributing-code/contributing-code-cli/README.md @@ -61,10 +61,10 @@ RAD_LOCATION=/my/custom/location/rad sudo make install Run the focused Windows process tests on a native Windows host: ```powershell -go test ./pkg/process ./pkg/cli/style ./test/windowless -count=1 -timeout=2m +go test ./pkg/process ./pkg/cli/style ./pkg/cli/pgbackup ./test/windowless -count=1 -timeout=2m ``` -The process unit tests verify the Windows no-window creation flags. The integration test builds `rad.exe`, launches it non-detached with piped output inside a kill-on-close Windows Job Object, and verifies `rad version --cli --output json`, a windowless Bicep child, and process-tree cancellation. CI runs these tests on Windows amd64 and arm64. +The process unit tests verify the Windows no-window creation flags. The PostgreSQL backup tests use fake executables and kubeconfigs to cover kubectl authentication preflight, EOF and SQL input delivery, command diagnostics, and cancellation in no-console and attached-console processes. The integration test builds `rad.exe`, launches it non-detached with piped output inside a kill-on-close Windows Job Object, and verifies `rad version --cli --output json`, a windowless Bicep child, and process-tree cancellation. CI runs these tests on Windows amd64 and arm64. ### Debug rad in VS Code diff --git a/pkg/cli/pgbackup/kubectl.go b/pkg/cli/pgbackup/kubectl.go index 12621c03d9..82956ffb50 100644 --- a/pkg/cli/pgbackup/kubectl.go +++ b/pkg/cli/pgbackup/kubectl.go @@ -28,7 +28,14 @@ import ( func kubectlCommand(ctx context.Context, kubeContext, namespace string, args ...string) (*exec.Cmd, error) { if process.IsWindowless() { - if err := validateExecAuth(kubeContext); err != nil { + if err := ctx.Err(); err != nil { + return nil, err + } + err := validateExecAuth(kubeContext) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + if err != nil { return nil, err } } @@ -53,7 +60,7 @@ func validateExecAuth(kubeContext string) error { } // MergedRawConfig validates only the selected configuration, without creating // a transport, authenticating, or executing a credential plugin. - selected, err := clientcmd.NewNonInteractiveClientConfig(*config, "", overrides, rules).MergedRawConfig() + selected, err := clientcmd.NewNonInteractiveClientConfig(*config, kubeContext, overrides, rules).MergedRawConfig() if err != nil { return fmt.Errorf("invalid kubectl configuration: %w", err) } diff --git a/pkg/cli/pgbackup/kubectl_config_test.go b/pkg/cli/pgbackup/kubectl_config_test.go index a94ea4af2f..5122a80c1f 100644 --- a/pkg/cli/pgbackup/kubectl_config_test.go +++ b/pkg/cli/pgbackup/kubectl_config_test.go @@ -67,6 +67,11 @@ func TestValidateExecAuth(t *testing.T) { {name: "no exec auth", modify: func(c *api.Config) { c.AuthInfos["selected-user"].Exec = nil }}, {name: "unused invalid exec", modify: func(c *api.Config) { c.AuthInfos["unused-user"].Exec.InteractiveMode = "invalid" }}, {name: "explicit context overrides current", context: testContext, modify: func(c *api.Config) { c.CurrentContext = "unused" }}, + {name: "explicit context ignores missing current", context: testContext, modify: func(c *api.Config) { c.CurrentContext = "missing" }}, + {name: "explicit context without exec ignores missing current", context: testContext, modify: func(c *api.Config) { + c.CurrentContext = "missing" + c.AuthInfos["selected-user"].Exec = nil + }}, {name: "current context is used", modify: func(c *api.Config) { c.CurrentContext = "unused" }, wantErr: "interactiveMode: Always"}, {name: "missing context", context: "missing", wantErr: "context"}, {name: "missing current context", modify: func(c *api.Config) { c.CurrentContext = "missing" }, wantErr: "context"}, diff --git a/pkg/cli/pgbackup/kubectl_test.go b/pkg/cli/pgbackup/kubectl_test.go index dbb59c7a73..33a3ba5f68 100644 --- a/pkg/cli/pgbackup/kubectl_test.go +++ b/pkg/cli/pgbackup/kubectl_test.go @@ -357,12 +357,19 @@ func testKubectl(t *testing.T) { require.NotErrorIs(t, err, context.Canceled) }) t.Run(operation+"/canceled before launch", func(t *testing.T) { - configureKubectlTest(t, api.NeverExecInteractiveMode) - ctx, cancel := context.WithCancel(t.Context()) - cancel() - err := runKubectlPath(ctx, operation, testContext, prepareDumps(t)) - require.ErrorIs(t, err, context.Canceled) - require.Empty(t, readKubectlCalls(t)) + for _, config := range []string{"Never", "Always", "malformed"} { + t.Run(config, func(t *testing.T) { + path := configureKubectlTest(t, api.ExecInteractiveMode(config)) + if config == "malformed" { + require.NoError(t, os.WriteFile(path, []byte("not: [valid yaml"), 0o600)) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err := runKubectlPath(ctx, operation, testContext, prepareDumps(t)) + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, readKubectlCalls(t)) + }) + } }) t.Run(operation+"/cancel running child", func(t *testing.T) { configureKubectlTest(t, api.NeverExecInteractiveMode) @@ -397,6 +404,38 @@ func testKubectl(t *testing.T) { require.ErrorAs(t, err, &exitErr) }) } + + t.Run("canceled during preflight", func(t *testing.T) { + if !process.IsWindowless() { + t.Skip("preflight only runs without a Windows console") + } + for _, config := range []string{"Always", "malformed"} { + t.Run(config, func(t *testing.T) { + path := configureKubectlTest(t, api.ExecInteractiveMode(config)) + if config == "malformed" { + require.NoError(t, os.WriteFile(path, []byte("not: [valid yaml"), 0o600)) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + err := WaitForReady(cancelAfterCheckContext{Context: ctx, cancel: cancel}, testContext, testNamespace) + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, readKubectlCalls(t)) + }) + } + }) +} + +type cancelAfterCheckContext struct { + context.Context + cancel context.CancelFunc +} + +func (ctx cancelAfterCheckContext) Err() error { + err := ctx.Context.Err() + // Cancel immediately after sampling the initial state, so validation's error + // competes with real caller cancellation without timing-dependent sleeps. + ctx.cancel() + return err } func assertKubectlCalls(t *testing.T, operation, kubeContext string) {