diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml index bea4914d0..f3a8ac5bc 100644 --- a/.github/workflows/desktop-ci.yml +++ b/.github/workflows/desktop-ci.yml @@ -76,25 +76,29 @@ jobs: - name: Build CLI (unix) if: runner.os != 'Windows' + env: + CGO_ENABLED: "0" run: | if [ "${{ matrix.go-arch }}" = "universal" ]; then # macOS universal binary: build both architectures and lipo them - GOOS=darwin GOARCH=amd64 go build -o devsy-amd64 . - GOOS=darwin GOARCH=arm64 go build -o devsy-arm64 . + GOOS=darwin GOARCH=amd64 go build -tags netgo,osusergo -o devsy-amd64 . + GOOS=darwin GOARCH=arm64 go build -tags netgo,osusergo -o devsy-arm64 . lipo -create -output desktop/resources/bin/devsy devsy-amd64 devsy-arm64 rm devsy-amd64 devsy-arm64 else - GOOS=${{ matrix.go-os }} GOARCH=${{ matrix.go-arch }} go build -o desktop/resources/bin/devsy . + GOOS=${{ matrix.go-os }} GOARCH=${{ matrix.go-arch }} go build -tags netgo,osusergo -o desktop/resources/bin/devsy . fi chmod +x desktop/resources/bin/devsy - name: Build CLI (windows) if: runner.os == 'Windows' shell: pwsh + env: + CGO_ENABLED: "0" run: | $env:GOOS = "windows" $env:GOARCH = "amd64" - go build -o desktop/resources/bin/devsy.exe . + go build -tags netgo,osusergo -o desktop/resources/bin/devsy.exe . - name: Configure Windows build tools if: runner.os == 'Windows' diff --git a/cmd/env_flags_test.go b/cmd/env_flags_test.go index 9f048d901..57e18dea4 100644 --- a/cmd/env_flags_test.go +++ b/cmd/env_flags_test.go @@ -13,6 +13,8 @@ const ( envDevsyHost = "DEVSY_HOST" envDevsyProject = "DEVSY_PROJECT" cmdWorkspace = "workspace" + cmdProvider = "provider" + cmdList = "list" ) // TestOptInEnvFlags_AppliesEnvValueToFlag verifies that for each opt-in flag, @@ -29,7 +31,7 @@ func TestOptInEnvFlags_AppliesEnvValueToFlag(t *testing.T) { }{ {flagName: "home", envName: "DEVSY_HOME", want: "/tmp/h", persistent: true}, {flagName: "context", envName: "DEVSY_CONTEXT", want: "ctx-a", persistent: true}, - {flagName: "provider", envName: "DEVSY_PROVIDER", want: "docker", persistent: true}, + {flagName: cmdProvider, envName: "DEVSY_PROVIDER", want: "docker", persistent: true}, {flagName: "debug", envName: "DEVSY_DEBUG", want: "true", persistent: true}, { cmdPath: "pro workspace list", @@ -110,7 +112,7 @@ func TestOptInEnvFlags_CLIOverridesEnv(t *testing.T) { seen, _ = c.Flags().GetString("host") return nil } - rootCmd.SetArgs([]string{"pro", cmdWorkspace, "list", "--host", "cli-value"}) + rootCmd.SetArgs([]string{"pro", cmdWorkspace, cmdList, "--host", "cli-value"}) require.NoError(t, rootCmd.Execute()) assert.Equal(t, "cli-value", seen, "CLI flag must override env value") } @@ -128,7 +130,7 @@ func TestOptInEnvFlags_EnvSatisfiesRequired(t *testing.T) { seen, _ = c.Flags().GetString("host") return nil } - rootCmd.SetArgs([]string{"pro", cmdWorkspace, "list"}) + rootCmd.SetArgs([]string{"pro", cmdWorkspace, cmdList}) require.NoError(t, rootCmd.Execute(), "required --host must be satisfied by env") assert.Equal(t, "from-env.example.com", seen) } diff --git a/cmd/flag_aliases_test.go b/cmd/flag_aliases_test.go index 0b9108c57..5e1a8d919 100644 --- a/cmd/flag_aliases_test.go +++ b/cmd/flag_aliases_test.go @@ -13,7 +13,6 @@ import ( const ( flagConfig = "--config" flagDevcontainerPath = "devcontainer-path" - flagLogFormat = "--log-format" flagOverrideConfig = "--override-config" flagExtraDevContainerPath = "extra-devcontainer-path" flagDotfilesRepository = "--dotfiles-repository" diff --git a/cmd/root.go b/cmd/root.go index 2b4c2ee05..a017ace95 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "strings" "github.com/devsy-org/devsy/cmd/completion" cliconfig "github.com/devsy-org/devsy/cmd/config" @@ -24,6 +25,7 @@ import ( "github.com/devsy-org/devsy/pkg/config" cliErrors "github.com/devsy-org/devsy/pkg/errors" "github.com/devsy-org/devsy/pkg/exitcode" + "github.com/devsy-org/devsy/pkg/flatpak" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/telemetry" "github.com/devsy-org/devsy/pkg/version" @@ -31,12 +33,17 @@ import ( "github.com/go-logr/logr" "github.com/spf13/cobra" "golang.org/x/crypto/ssh" + "golang.org/x/term" "k8s.io/klog/v2" ) const ( logOutputJSON = "json" logOutputLogfmt = "logfmt" + logOutputText = "text" + + flagLogOutput = "--log-output" + flagLogFormat = "--log-format" groupCore = "core" groupConfig = "config" @@ -48,6 +55,8 @@ const ( // feature is not ready for general use; set DEVSY_PRO_ENABLED=true to // expose it (e.g. for internal testing). envProEnabled = "DEVSY_PRO_ENABLED" + + internalCommand = "internal" ) func proEnabled() bool { @@ -61,23 +70,60 @@ func isMachineLogFormat(format string) bool { return format == logOutputJSON || format == logOutputLogfmt } -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. +func logOutputFromArgs(args []string) string { + for i, arg := range args { + for _, name := range []string{flagLogOutput, flagLogFormat} { + if val, ok := strings.CutPrefix(arg, name+"="); ok { + return val + } + if arg == name && i+1 < len(args) { + return args[i+1] + } + } + } + return "" +} + +func isMachineConsumer(logOutput string, isInternal bool) bool { + switch { + case isInternal: + return true + case os.Getenv(config.EnvUI) == config.BoolTrue: + return true + case logOutput != "": + return isMachineLogFormat(logOutput) + default: + return !term.IsTerminal(int(os.Stderr.Fd())) //nolint:gosec // fd fits in int + } +} + func Execute() { - rootCmd, globalFlags := BuildRoot() + os.Exit(run()) +} - // Bootstrap pre-Execute so subcommands that override PersistentPreRunE - // without chaining (e.g. pro, agent) still get telemetry. +func run() int { + rootCmd, globalFlags := BuildRoot() target := rootCmd if found, _, findErr := rootCmd.Find(os.Args[1:]); findErr == nil && found != nil { target = found } collector := telemetry.BootstrapCLI(target) rootCmd.SetContext(telemetry.WithCollector(gocontext.Background(), collector)) + defer func() { collector.Flush() }() - err := rootCmd.Execute() + isInternal := topLevelCommand(target) == internalCommand + machineMode := configureOutput(rootCmd, globalFlags, isInternal) + + if !isInternal { + if shouldExit, err := flatpak.ReexecOnHost(); err != nil { + collector.RecordCLI(err) + return exitCodeForError(err, machineMode) + } else if shouldExit { + return 0 + } + } - // Re-apply opt-out post-Execute for the same PreRunE-bypass case. + err := rootCmd.Execute() if devsyConfig, cfgErr := config.LoadConfig( globalFlags.Context, globalFlags.Provider, @@ -86,44 +132,91 @@ func Execute() { } collector.RecordCLI(err) - collector.Flush() if err != nil { - //nolint:all - if sshExitErr, ok := err.(*ssh.ExitError); ok { - log.Errorf("SSH command failed with exit code %d", sshExitErr.ExitStatus()) - os.Exit(sshExitErr.ExitStatus()) + return exitCodeForError(err, machineMode) + } + return 0 +} + +func configureOutput( + rootCmd *cobra.Command, + globalFlags *flags.GlobalFlags, + isInternal bool, +) bool { + logOutput := logOutputFromArgs(os.Args[1:]) + machineMode := isMachineConsumer(logOutput, isInternal) + rootCmd.SilenceErrors = machineMode + rootCmd.SilenceUsage = machineMode + + format := logOutput + if format == "" { + format = logOutputText + } + log.Init(log.Config{ + Verbosity: globalFlags.Verbosity, + Quiet: globalFlags.Quiet, + Debug: globalFlags.Debug, + Format: format, + }) + return machineMode +} + +func topLevelCommand(cmd *cobra.Command) string { + if cmd == nil { + return "" + } + for cmd.HasParent() { + if !cmd.Parent().HasParent() { + return cmd.Name() } + cmd = cmd.Parent() + } + return "" +} + +func exitCodeForError(err error, machineMode bool) int { + if err == nil { + return 0 + } + + if code, ok := passthroughExitCode(err, machineMode); ok { + return code + } + + renderCLIError(err, machineMode) + if errors.Is(err, workspace.ErrWorkspaceNotFound) { + return exitcode.WorkspaceNotFound + } + return 1 +} - //nolint:all - if execExitErr, ok := err.(*exec.ExitError); ok { +func passthroughExitCode(err error, machineMode bool) (int, bool) { + if sshExitErr, ok := errors.AsType[*ssh.ExitError](err); ok { + if machineMode { + log.Errorf("SSH command failed with exit code %d", sshExitErr.ExitStatus()) + } + return sshExitErr.ExitStatus(), true + } + if execExitErr, ok := errors.AsType[*exec.ExitError](err); ok { + if machineMode { log.Errorf("Command failed with exit code %d", execExitErr.ExitCode()) - os.Exit(execExitErr.ExitCode()) } + return execExitErr.ExitCode(), true + } + return 0, false +} - cliErr := cliErrors.Classify(err, cliErrors.ClassifyContext{}) - // Always emit the error through zap so the configured log encoder - // (json/logfmt/text) governs the wire format. JSONError preserves - // the full err.Error() chain in the top-level "msg" field and ships - // the structured CLIError under "cliError" for the desktop IPC. +func renderCLIError(err error, machineMode bool) { + cliErr := cliErrors.Classify(err, cliErrors.ClassifyContext{}) + if machineMode { log.JSONError(cliErr) - // In human-friendly text mode, follow up with hint/doc affordances - // that don't fit cleanly into the zap line. These extras are - // suppressed in machine-readable modes so log streams stay parseable. - if !isMachineLogFormat(globalFlags.LogOutput) { - if cliErr.Hint != "" { - fmt.Fprintf(os.Stderr, "Hint: %s\n", cliErr.Hint) - } - if cliErr.DocURL != "" { - fmt.Fprintf(os.Stderr, "See: %s\n", cliErr.DocURL) - } - } - // Signal workspace-not-found via a distinct exit code so parent - // processes (e.g. SetupBackhaul) can detect the registration race - // without parsing stderr. - if errors.Is(err, workspace.ErrWorkspaceNotFound) { - os.Exit(exitcode.WorkspaceNotFound) - } - os.Exit(1) + return + } + if cliErr.Hint != "" { + fmt.Fprintf(os.Stderr, "Hint: %s\n", cliErr.Hint) + } + if cliErr.DocURL != "" { + fmt.Fprintf(os.Stderr, "See: %s\n", cliErr.DocURL) } } @@ -144,6 +237,8 @@ func BuildRoot() (*cobra.Command, *flags.GlobalFlags) { _ = completion.RegisterFlagCompletionFuns(rootCmd, globalFlags) rootCmd.PersistentPreRunE = func(cobraCmd *cobra.Command, _ []string) error { + cobraCmd.SilenceUsage = true + log.Init(log.Config{ Verbosity: globalFlags.Verbosity, Quiet: globalFlags.Quiet, diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 000000000..1b46fd2c9 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTopLevelCommand(t *testing.T) { + rootCmd, _ := BuildRoot() + + cases := []struct { + name string + args []string + want string + }{ + {"daemon stays in sandbox", []string{internalCommand, "daemon-local"}, internalCommand}, + {"nested internal command", []string{internalCommand, "ssh-server"}, internalCommand}, + {"workspace up routes to host", []string{cmdWorkspace, "up", "."}, cmdWorkspace}, + {"workspace ssh routes to host", []string{cmdWorkspace, "ssh", "my-ws"}, cmdWorkspace}, + {"provider list routes to host", []string{cmdProvider, cmdList}, cmdProvider}, + {"bare root", []string{}, ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + found, _, err := rootCmd.Find(tc.args) + require.NoError(t, err) + assert.Equal(t, tc.want, topLevelCommand(found)) + }) + } +} + +func TestLogOutputFromArgs(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"absent returns empty", []string{cmdProvider, cmdList}, ""}, + {"space-separated log-output", []string{"up", flagLogOutput, logOutputJSON}, logOutputJSON}, + { + "equals-form log-output", + []string{"up", flagLogOutput + "=" + logOutputJSON}, + logOutputJSON, + }, + {"log-format alias", []string{"up", flagLogFormat, logOutputLogfmt}, logOutputLogfmt}, + { + "equals-form log-format alias", + []string{flagLogFormat + "=" + logOutputJSON, "up"}, + logOutputJSON, + }, + { + "flag before unknown command", + []string{flagLogOutput, logOutputJSON, "bogus"}, + logOutputJSON, + }, + {"trailing flag with no value returns empty", []string{"up", flagLogOutput}, ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, logOutputFromArgs(tc.args)) + }) + } +} + +func TestIsMachineLogFormat(t *testing.T) { + assert.True(t, isMachineLogFormat(logOutputJSON)) + assert.True(t, isMachineLogFormat(logOutputLogfmt)) + assert.False(t, isMachineLogFormat(logOutputText)) + assert.False(t, isMachineLogFormat("")) +} + +func TestIsMachineConsumer(t *testing.T) { + cases := []struct { + name string + logOutput string + isInternal bool + devsyUI string + want bool + }{ + {"internal subtree", "", true, "", true}, + {"desktop provenance", "", false, config.BoolTrue, true}, + {"explicit json", logOutputJSON, false, "", true}, + {"explicit logfmt", logOutputLogfmt, false, "", true}, + {"explicit text is human", logOutputText, false, "", false}, + {"internal wins over text", logOutputText, true, "", true}, + {"desktop wins over text", logOutputText, false, config.BoolTrue, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(config.EnvUI, tc.devsyUI) + assert.Equal(t, tc.want, isMachineConsumer(tc.logOutput, tc.isInternal)) + }) + } +} + +func TestIsMachineConsumer_TTYFallback(t *testing.T) { + t.Setenv(config.EnvUI, "") + assert.True(t, isMachineConsumer("", false), + "non-terminal stderr with no explicit format should be machine mode") +} + +func TestConfigureOutput_SilencesCobra(t *testing.T) { + cases := []struct { + name string + args []string + isInternal bool + wantSilent bool + }{ + {"json machine", []string{"up", flagLogOutput, logOutputJSON}, false, true}, + {"logfmt machine", []string{"up", flagLogOutput, logOutputLogfmt}, false, true}, + {"explicit text is human", []string{"up", flagLogOutput, logOutputText}, false, false}, + {"internal stays silent", []string{internalCommand}, true, true}, + } + + origArgs := os.Args + t.Cleanup(func() { os.Args = origArgs }) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(config.EnvUI, "") + rootCmd, globalFlags := BuildRoot() + os.Args = append([]string{"devsy"}, tc.args...) + machineMode := configureOutput(rootCmd, globalFlags, tc.isInternal) + assert.Equal(t, tc.wantSilent, machineMode) + assert.Equal(t, tc.wantSilent, rootCmd.SilenceErrors) + assert.Equal(t, tc.wantSilent, rootCmd.SilenceUsage) + }) + } +} diff --git a/desktop/flatpak/devsy-wrapper b/desktop/flatpak/devsy-wrapper index e21e66cc3..3c65fada5 100755 --- a/desktop/flatpak/devsy-wrapper +++ b/desktop/flatpak/devsy-wrapper @@ -5,8 +5,7 @@ export ELECTRON_IS_DEV=0 # Determine if running inside Flatpak if [ -f "/.flatpak-info" ]; then - # Sync the devsy binary to a host-accessible location - DEVSY_HOME="${HOME}/.local/share/devsy" + DEVSY_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/devsy" mkdir -p "${DEVSY_HOME}" if [ -f /app/bin/devsy ]; then diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index dd920de6f..9b1fdced7 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -294,6 +294,10 @@ func (r *runner) resolveNewContainer( return nil, err } + if w := r.lingerWarning(ctx); w != "" { + hostWarnings = append(hostWarnings, w) + } + return &resolvedContainer{ details: containerDetails, mergedConfig: mergedConfig, @@ -301,6 +305,18 @@ func (r *runner) resolveNewContainer( }, nil } +func (r *runner) lingerWarning(ctx context.Context) string { + dockerDriver, ok := r.driver.(driver.DockerDriver) + if !ok { + return "" + } + helper, err := dockerDriver.DockerHelper() + if err != nil { + return "" + } + return helper.LingerWarning(ctx) +} + // buildNewContainerConfig builds the image (deleting the existing container // first when recreating) and produces the merged devcontainer config from the // build's image metadata. diff --git a/pkg/docker/linger.go b/pkg/docker/linger.go new file mode 100644 index 000000000..7f75394e8 --- /dev/null +++ b/pkg/docker/linger.go @@ -0,0 +1,53 @@ +package docker + +import ( + "context" + "os" + "os/user" + "strings" + "time" +) + +func (r *DockerHelper) LingerWarning(ctx context.Context) string { + if !r.IsPodman() || !r.isRootless(ctx) { + return "" + } + if lingerEnabled() { + return "" + } + return "rootless Podman without systemd linger: the workspace container will " + + "stop when your login session ends. Run `loginctl enable-linger` to keep " + + "it running after logout (Docker does not require this)." +} + +func (r *DockerHelper) isRootless(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + out, err := r.buildCmd(ctx, "info", "--format", "{{.Host.Security.Rootless}}").Output() + if err != nil { + return true + } + return strings.TrimSpace(string(out)) != "false" +} + +var lingerDir = "/var/lib/systemd/linger" + +func lingerEnabled() bool { + u, err := user.Current() + if err != nil || u.Username == "" { + return true + } + return userHasLinger(u.Username) +} + +func userHasLinger(username string) bool { + _, err := os.Stat(lingerDir + "/" + username) + if err == nil { + return true + } + if os.IsNotExist(err) { + return false + } + return true +} diff --git a/pkg/docker/linger_test.go b/pkg/docker/linger_test.go new file mode 100644 index 000000000..710a88c97 --- /dev/null +++ b/pkg/docker/linger_test.go @@ -0,0 +1,28 @@ +package docker + +import ( + "os" + "path/filepath" + "testing" +) + +func TestUserHasLinger(t *testing.T) { + dir := t.TempDir() + orig := lingerDir + lingerDir = dir + t.Cleanup(func() { lingerDir = orig }) + + if userHasLinger("alice") { + t.Fatal("expected no linger when marker file is absent") + } + + if err := os.WriteFile(filepath.Join(dir, "alice"), nil, 0o600); err != nil { + t.Fatalf("write marker: %v", err) + } + if !userHasLinger("alice") { + t.Fatal("expected linger when marker file exists") + } + if userHasLinger("bob") { + t.Fatal("expected no linger for a different user") + } +} diff --git a/pkg/flatpak/flatpak.go b/pkg/flatpak/flatpak.go new file mode 100644 index 000000000..e2a08267d --- /dev/null +++ b/pkg/flatpak/flatpak.go @@ -0,0 +1,61 @@ +package flatpak + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/devsy-org/devsy/pkg/log" +) + +var flatpakInfoPath = "/.flatpak-info" + +const flatpakSpawn = "flatpak-spawn" + +func InSandbox() bool { + _, err := os.Stat(flatpakInfoPath) + return err == nil +} + +func HostBinaryPath() string { + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + dataHome = filepath.Join(os.Getenv("HOME"), ".local", "share") + } + return filepath.Join(dataHome, "devsy", "devsy") +} + +func ReexecOnHost() (bool, error) { + if !InSandbox() { + return false, nil + } + + hostBinary := HostBinaryPath() + if _, err := os.Stat(hostBinary); err != nil { + return false, fmt.Errorf( + "host devsy binary not found at %s; the Flatpak launcher should sync it on startup: %w", + hostBinary, err, + ) + } + + spawnArgs := append([]string{"--host", hostBinary}, os.Args[1:]...) + log.Debugf("re-executing on host via flatpak-spawn: args=%v", os.Args[1:]) + + //nolint:gosec + cmd := exec.Command(flatpakSpawn, spawnArgs...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + if err := cmd.Run(); err != nil { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + return true, exitErr + } + return false, err + } + + return true, nil +} diff --git a/pkg/flatpak/flatpak_test.go b/pkg/flatpak/flatpak_test.go new file mode 100644 index 000000000..648d9858b --- /dev/null +++ b/pkg/flatpak/flatpak_test.go @@ -0,0 +1,81 @@ +package flatpak + +import ( + "os" + "path/filepath" + "testing" +) + +func TestInSandbox(t *testing.T) { + t.Run("false when marker absent", func(t *testing.T) { + withFlatpakInfoPath(t, filepath.Join(t.TempDir(), "does-not-exist")) + if InSandbox() { + t.Fatal("expected InSandbox to be false when marker file is absent") + } + }) + + t.Run("true when marker present", func(t *testing.T) { + marker := filepath.Join(t.TempDir(), ".flatpak-info") + if err := os.WriteFile(marker, []byte("[Application]\n"), 0o600); err != nil { + t.Fatalf("write marker: %v", err) + } + withFlatpakInfoPath(t, marker) + if !InSandbox() { + t.Fatal("expected InSandbox to be true when marker file exists") + } + }) +} + +func TestHostBinaryPath(t *testing.T) { + t.Run("honors XDG_DATA_HOME", func(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/custom/data") + if got, want := HostBinaryPath(), "/custom/data/devsy/devsy"; got != want { + t.Fatalf("HostBinaryPath() = %q, want %q", got, want) + } + }) + + t.Run("falls back to HOME/.local/share", func(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "") + t.Setenv("HOME", "/home/tester") + if got, want := HostBinaryPath(), "/home/tester/.local/share/devsy/devsy"; got != want { + t.Fatalf("HostBinaryPath() = %q, want %q", got, want) + } + }) +} + +func TestReexecOnHost_NoopOutsideSandbox(t *testing.T) { + withFlatpakInfoPath(t, filepath.Join(t.TempDir(), "does-not-exist")) + + shouldExit, err := ReexecOnHost() + if err != nil { + t.Fatalf("ReexecOnHost() error = %v, want nil", err) + } + if shouldExit { + t.Fatal("ReexecOnHost() shouldExit = true outside sandbox, want false") + } +} + +func TestReexecOnHost_MissingHostBinary(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, ".flatpak-info") + if err := os.WriteFile(marker, []byte("[Application]\n"), 0o600); err != nil { + t.Fatalf("write marker: %v", err) + } + withFlatpakInfoPath(t, marker) + t.Setenv("XDG_DATA_HOME", dir) + + shouldExit, err := ReexecOnHost() + if err == nil { + t.Fatal("ReexecOnHost() error = nil, want missing-binary error") + } + if shouldExit { + t.Fatal("ReexecOnHost() shouldExit = true on pre-check failure, want false") + } +} + +func withFlatpakInfoPath(t *testing.T, path string) { + t.Helper() + orig := flatpakInfoPath + flatpakInfoPath = path + t.Cleanup(func() { flatpakInfoPath = orig }) +} diff --git a/pkg/workspace/exec.go b/pkg/workspace/exec.go index 198beefcd..60632e8e0 100644 --- a/pkg/workspace/exec.go +++ b/pkg/workspace/exec.go @@ -55,7 +55,10 @@ func ResolveDockerCommand( } if providerConfig.Agent.Docker.Path != "" { - if expanded := os.ExpandEnv(providerConfig.Agent.Docker.Path); expanded != "" { + if expanded := expandWithOptions( + providerConfig.Agent.Docker.Path, + workspace.Provider.Options, + ); expanded != "" { return expanded } } @@ -63,6 +66,15 @@ func ResolveDockerCommand( return DefaultDockerCommand } +func expandWithOptions(s string, options map[string]config.OptionValue) string { + return os.Expand(s, func(key string) string { + if opt, ok := options[key]; ok && opt.Value != "" { + return opt.Value + } + return os.Getenv(key) + }) +} + func LoadExecResult( workspaceConfig *provider2.Workspace, containerDetails *devcconfig.ContainerDetails, diff --git a/pkg/workspace/exec_test.go b/pkg/workspace/exec_test.go index 68b2d4138..28472b7ec 100644 --- a/pkg/workspace/exec_test.go +++ b/pkg/workspace/exec_test.go @@ -6,9 +6,37 @@ import ( "io" "testing" + "github.com/devsy-org/devsy/pkg/config" devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" ) +func TestExpandWithOptions(t *testing.T) { + t.Setenv("DEVSY_TEST_OSVAR", "from-os") + opts := map[string]config.OptionValue{ + "DOCKER_PATH": {Value: "podman"}, + "EMPTY": {Value: ""}, + } + + cases := []struct { + name string + in string + want string + }{ + {"resolves from options", "${DOCKER_PATH}", "podman"}, + {"literal passthrough", "docker", "docker"}, + {"empty option falls back to os env", "${EMPTY}", ""}, + {"unknown key falls back to os env", "${DEVSY_TEST_OSVAR}", "from-os"}, + {"missing everywhere is empty", "${DEVSY_TEST_ABSENT}", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := expandWithOptions(tc.in, opts); got != tc.want { + t.Fatalf("expandWithOptions(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + func TestExecOneShotOptions_ResolveTimeout_Clamp(t *testing.T) { opts := ExecOneShotOptions{ TimeoutSeconds: 10000,