Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/architecture/rad-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ When `rad.exe` has no attached console, [pkg/process](../../pkg/process/) automa

When `rad.exe` has an attached console, child terminal access and interactive CLI behavior are unchanged. Azure Identity credentials create their own Azure CLI process and do not use `pkg/process`; automation that must guarantee windowless descendants should use a non-CLI authentication method such as `ServicePrincipal`, `ManagedIdentity`, or `UCPCredential`.

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.

## Invariants And Constraints

- Commands should stay thin and use the shared framework.
Expand Down
16 changes: 16 additions & 0 deletions pkg/process/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,36 @@ limitations under the License.
*/

// Package process constructs external commands using Radius process policies.
//
// In Windows windowless mode, commands default to explicit EOF on stdin. Go's
// exec.Cmd already connects nil stdin to the null device; this policy makes that
// default explicit, not a guarantee that arbitrary tools or SDK-owned credential
// helpers cannot prompt. Callers may still assign finite input to Cmd.Stdin.
package process

import (
"context"
"os/exec"
)

// IsWindowless reports whether Radius is running on Windows without an attached
// console. Tool adapters can use it to select their own non-interactive behavior.
// It checks console attachment when called, not during package initialization,
// and returns false on non-Windows platforms. It does not detect redirected
// streams or a general cross-platform automation mode.
func IsWindowless() bool {
return isWindowless()
}

// Command returns the Cmd to execute the named program with the given arguments.
// In windowless mode it defaults stdin to EOF; callers may replace Cmd.Stdin.
func Command(name string, args ...string) *exec.Cmd {
return configure(exec.Command(name, args...))
}

// CommandContext returns the Cmd to execute the named program with the given arguments.
// The provided context controls the command lifetime.
// In windowless mode it defaults stdin to EOF; callers may replace Cmd.Stdin.
func CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
return configure(exec.CommandContext(ctx, name, args...))
}
4 changes: 4 additions & 0 deletions pkg/process/process_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ package process

import "os/exec"

func isWindowless() bool {
return false
}

func configure(cmd *exec.Cmd) *exec.Cmd {
return cmd
}
34 changes: 31 additions & 3 deletions pkg/process/process_other_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,41 @@ limitations under the License.
package process

import (
"bytes"
"os/exec"
"syscall"
"testing"

"github.com/stretchr/testify/require"
)

func TestCommand_UnchangedOnNonWindows(t *testing.T) {
cmd := Command("test-command")
func TestCommands_UnchangedOnNonWindows(t *testing.T) {
t.Parallel()
require.False(t, IsWindowless())

for _, constructor := range commandConstructors {
t.Run(constructor.name, func(t *testing.T) {
cmd := constructor.command("test-command", "argument")
require.Equal(t, []string{"test-command", "argument"}, cmd.Args)
require.Nil(t, cmd.SysProcAttr)
require.Nil(t, cmd.Stdin)
})
}
testCommandInput(t)
t.Run("context cancellation", testCommandContextCancellation)
}

require.Nil(t, cmd.SysProcAttr)
func TestConfigure_UnchangedOnNonWindows(t *testing.T) {
t.Parallel()
cmd := exec.Command("test-command")
attrs := &syscall.SysProcAttr{}
input := bytes.NewReader([]byte("SELECT 1;\n"))
cmd.SysProcAttr = attrs
cmd.Stdin = input
expected := *cmd

require.Same(t, cmd, configure(cmd))
require.Equal(t, expected, *cmd)
require.Same(t, attrs, cmd.SysProcAttr)
require.Same(t, input, cmd.Stdin)
}
145 changes: 145 additions & 0 deletions pkg/process/process_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
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 process

import (
"bytes"
"context"
"io"
"os"
"os/exec"
"testing"
"time"

"github.com/stretchr/testify/require"
)

const processHelperEnv = "RADIUS_PROCESS_TEST_HELPER"

var commandConstructors = []struct {
name string
command func(string, ...string) *exec.Cmd
}{
{name: "Command", command: Command},
{name: "CommandContext", command: func(name string, args ...string) *exec.Cmd {
return CommandContext(context.Background(), name, args...)
}},
}

func testCommandInput(t *testing.T) {
t.Helper()
for _, constructor := range commandConstructors {
for _, tt := range []struct {
name string
payload string
exitCode int
}{
{name: "default EOF"},
{name: "finite input after construction", payload: "SELECT 1;\n"},
{name: "nonzero exit", payload: "SELECT 1;\n", exitCode: 7},
} {
t.Run(constructor.name+"/"+tt.name, func(t *testing.T) {
cmd := constructor.command(os.Args[0], "-test.run=^TestProcessHelper$")
if tt.payload != "" {
cmd.Stdin = bytes.NewReader([]byte(tt.payload))
}
runInputHelper(t, cmd, tt.payload, tt.exitCode)
})
}
}

t.Run("finite input before configuration", func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestProcessHelper$")
input := bytes.NewReader([]byte("SELECT 2;\n"))
cmd.Stdin = input
require.Same(t, cmd, configure(cmd))
require.Same(t, input, cmd.Stdin)
runInputHelper(t, cmd, "SELECT 2;\n", 0)
})
}

func runInputHelper(t *testing.T, cmd *exec.Cmd, want string, exitCode int) {
t.Helper()
mode := "echo"
if exitCode != 0 {
mode = "echo-error"
}
cmd.Env = append(os.Environ(), processHelperEnv+"="+mode)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
require.NoError(t, cmd.Start())
// Bound plain Command tests too, without changing production lifetimes.
timer := time.AfterFunc(10*time.Second, func() {
_ = cmd.Process.Kill()
})
defer timer.Stop()
err := cmd.Wait()
if exitCode == 0 {
require.NoError(t, err, stderr.String())
} else {
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)
require.Equal(t, exitCode, exitErr.ExitCode())
}
require.Equal(t, want, stdout.String())
require.Equal(t, "helper stderr", stderr.String())
require.Equal(t, exitCode, cmd.ProcessState.ExitCode())
}

func testCommandContextCancellation(t *testing.T) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
cmd := CommandContext(ctx, os.Args[0], "-test.run=^TestProcessHelper$")
cmd.Env = append(os.Environ(), processHelperEnv+"=wait")
stdout, err := cmd.StdoutPipe()
require.NoError(t, err)
require.NoError(t, cmd.Start())
defer func() {
if cmd.ProcessState == nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
}()
ready := make([]byte, len("ready"))
_, err = io.ReadFull(stdout, ready)
require.NoError(t, err)
require.Equal(t, "ready", string(ready))
cancel()
require.Error(t, cmd.Wait())
require.ErrorIs(t, ctx.Err(), context.Canceled)
Comment thread
brooke-hamilton marked this conversation as resolved.
}

func TestProcessHelper(t *testing.T) {
switch os.Getenv(processHelperEnv) {
case "echo", "echo-error":
_, err := io.Copy(os.Stdout, os.Stdin)
require.NoError(t, err)
_, err = io.WriteString(os.Stderr, "helper stderr")
require.NoError(t, err)
if os.Getenv(processHelperEnv) == "echo-error" {
os.Exit(7) //nolint:forbidigo // Exercise exec.ExitError without losing the helper's output.
}
os.Exit(0) //nolint:forbidigo // Return only the helper output, without the test runner's PASS message.
case "wait":
_, err := io.WriteString(os.Stdout, "ready")
require.NoError(t, err)
time.Sleep(time.Minute)
os.Exit(1) //nolint:forbidigo // The parent must cancel this helper before it exits on its own.
}
}
10 changes: 9 additions & 1 deletion pkg/process/process_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package process

import (
"os/exec"
"strings"
"syscall"

"golang.org/x/sys/windows"
Expand All @@ -33,11 +34,18 @@ var hasConsole = func() bool {
return err == nil
}

func isWindowless() bool {
return !hasConsole()
}

func configure(cmd *exec.Cmd) *exec.Cmd {
if hasConsole() {
if !IsWindowless() {
return cmd
}

if cmd.Stdin == nil {
cmd.Stdin = strings.NewReader("")
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
Expand Down
Loading
Loading