Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspellignore
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,7 @@ periodSeconds
persistentVolume
persistentVolumes
pfx
pgbackup
pkgs
plaidResource
plainHTTP
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/unit-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
brooke-hamilton marked this conversation as resolved.
2 changes: 2 additions & 0 deletions docs/architecture/rad-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, [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

- Commands should stay thin and use the shared framework.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
74 changes: 74 additions & 0 deletions pkg/cli/pgbackup/kubectl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
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 := 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
}
}

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, kubeContext, 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
}
158 changes: 158 additions & 0 deletions pkg/cli/pgbackup/kubectl_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*
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: "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"},
{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))
}
29 changes: 29 additions & 0 deletions pkg/cli/pgbackup/kubectl_other_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading