diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index 50dd4aefe..82668a8a4 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -196,6 +196,9 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { ), flags.Bool(&cmd.Reset, names.Reset, false, "Remove and recreate existing containers, including their sources"), + flags.Bool(&cmd.NoAutoStart, names.NoAutoStart, false, + "Do not auto-start a stopped provider backend (e.g. Podman machine); "+ + "report it instead"), flags.StringSlice(&cmd.PrebuildRepositories, names.PrebuildRepo, nil, "Docker repository hosting prebuilds for this workspace"), flags.StringArray(&cmd.WorkspaceEnv, names.WorkspaceEnv, nil, diff --git a/pkg/apple/helper.go b/pkg/apple/helper.go index 99caed353..2ede04ab7 100644 --- a/pkg/apple/helper.go +++ b/pkg/apple/helper.go @@ -34,7 +34,7 @@ type AppleHelper struct { // EnsureSystemRunning starts the container system service if it is not running; // it must be running before any container operation. func (h *AppleHelper) EnsureSystemRunning(ctx context.Context) error { - if h.systemRunning(ctx) { + if h.SystemRunning(ctx) { return nil } @@ -358,7 +358,8 @@ func (h *AppleHelper) buildCmd(ctx context.Context, args ...string) *exec.Cmd { return cmd } -func (h *AppleHelper) systemRunning(ctx context.Context) bool { +// SystemRunning reports whether the container system service is running. +func (h *AppleHelper) SystemRunning(ctx context.Context) bool { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() out, err := h.buildCmd(cctx, "system", "status").CombinedOutput() diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go index 311100f25..eb29045fc 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -92,6 +92,13 @@ func NewRunner( return nil, err } + preflightOpts := driver.PreflightOptions{ + DisableAutoStart: workspaceConfig.CLIOptions.NoAutoStart || driver.AutoStartDisabledByEnv(), + } + if err := driver.DriverPreflight(ctx, drv, preflightOpts); err != nil { + return nil, err + } + return &runner{ driver: drv, agentPath: agentPath, diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index 442f11e4a..b8bec9d2e 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -127,6 +127,43 @@ func (r *DockerHelper) ClientVersion(ctx context.Context) string { return strings.TrimSpace(string(out)) } +// podmanMachineStartTimeout bounds a Podman machine boot, which spins up a VM. +const podmanMachineStartTimeout = 90 * time.Second + +// Ping reports whether the runtime daemon is reachable, returning its own +// message (e.g. "Cannot connect to Podman") on failure. It runs a bare `info` +// and judges reachability by exit status: `--format` field names differ +// between docker (.ServerVersion) and podman/nerdctl, so a shared template +// would falsely fail non-docker runtimes. +func (r *DockerHelper) Ping(ctx context.Context) error { + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + out, err := r.buildCmd(cctx, "info").CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return fmt.Errorf("%s: %w", msg, err) + } + return err + } + return nil +} + +// StartPodmanMachine starts the default Podman machine, which must already exist. +func (r *DockerHelper) StartPodmanMachine(ctx context.Context) error { + cctx, cancel := context.WithTimeout(ctx, podmanMachineStartTimeout) + defer cancel() + + out, err := r.buildCmd(cctx, "machine", "start").CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return fmt.Errorf("%s: %w", msg, err) + } + return err + } + return nil +} + func (r *DockerHelper) FindDevContainer( ctx context.Context, labels []string, diff --git a/pkg/driver/apple/client.go b/pkg/driver/apple/client.go index 05f6be599..5687a4d5b 100644 --- a/pkg/driver/apple/client.go +++ b/pkg/driver/apple/client.go @@ -11,6 +11,8 @@ import ( // appleClient is the subset of *apple.AppleHelper the driver depends on, so its // orchestration can be unit-tested with a mock instead of the real CLI. type appleClient interface { + EnsureSystemRunning(ctx context.Context) error + SystemRunning(ctx context.Context) bool EnsureBuilderRunning(ctx context.Context) error FindDevContainer(ctx context.Context, labels []string) (*config.ContainerDetails, error) FindContainerByID(ctx context.Context, ids []string) (*config.ContainerDetails, error) diff --git a/pkg/driver/apple/driver.go b/pkg/driver/apple/driver.go index 0e7494806..1725f1425 100644 --- a/pkg/driver/apple/driver.go +++ b/pkg/driver/apple/driver.go @@ -6,7 +6,9 @@ package apple import ( "context" + "errors" "fmt" + "os/exec" "runtime" "github.com/devsy-org/devsy/pkg/apple" @@ -30,12 +32,15 @@ type appleDriver struct { containerID string // set when the workspace source is an existing container } -var _ driver.ImageDriver = (*appleDriver)(nil) +var ( + _ driver.ImageDriver = (*appleDriver)(nil) + _ driver.Preflighter = (*appleDriver)(nil) +) -// NewAppleDriver verifies the host is supported and the container system service -// is running (ctx bounds the potentially-slow `container system start`). +// NewAppleDriver verifies the host is supported and constructs the driver; +// Preflight validates the container system service. func NewAppleDriver( - ctx context.Context, + _ context.Context, workspaceInfo *provider.AgentWorkspaceInfo, ) (driver.ImageDriver, error) { if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { @@ -55,10 +60,6 @@ func NewAppleDriver( Environment: makeEnvironment(workspaceInfo.Agent.Apple.Env), } - if err := helper.EnsureSystemRunning(ctx); err != nil { - return nil, err - } - rosetta, err := workspaceInfo.Agent.Apple.Rosetta.Bool() if err != nil { log.Warnf( @@ -78,6 +79,34 @@ func NewAppleDriver( }, nil } +// Preflight checks the `container` binary is installed and the system service +// running, starting it unless auto-start is disabled. +func (d *appleDriver) Preflight(ctx context.Context, opts driver.PreflightOptions) error { + if _, err := exec.LookPath(d.command); err != nil { + return &driver.PreflightError{ + Provider: provider.AppleDriver, + Err: fmt.Errorf("%s is not installed or not on PATH: %w", d.command, err), + } + } + + if opts.DisableAutoStart { + if !d.Apple.SystemRunning(ctx) { + return &driver.PreflightError{ + Provider: provider.AppleDriver, + Err: errors.New( + "container system service is not running (run `container system start`)", + ), + } + } + return nil + } + + if err := d.Apple.EnsureSystemRunning(ctx); err != nil { + return &driver.PreflightError{Provider: provider.AppleDriver, Err: err} + } + return nil +} + func makeEnvironment(env map[string]string) []string { if len(env) == 0 { return nil diff --git a/pkg/driver/apple/lifecycle_test.go b/pkg/driver/apple/lifecycle_test.go index d7ce67880..d70e6614f 100644 --- a/pkg/driver/apple/lifecycle_test.go +++ b/pkg/driver/apple/lifecycle_test.go @@ -32,8 +32,12 @@ type mockClient struct { calls []string // ordered event log, e.g. "stop:c1", "remove:c1" ranArgs [][]string // exec/run argument lists, in call order runWithDir string // dir passed to the last RunWithDir call + systemDown bool // SystemRunning reports false + ensureErr error // error returned by EnsureSystemRunning } +func (m *mockClient) EnsureSystemRunning(context.Context) error { return m.ensureErr } +func (m *mockClient) SystemRunning(context.Context) bool { return !m.systemDown } func (m *mockClient) EnsureBuilderRunning(context.Context) error { return nil } func (m *mockClient) FindDevContainer(context.Context, []string) (*config.ContainerDetails, error) { return m.found, m.foundErr diff --git a/pkg/driver/apple/preflight_test.go b/pkg/driver/apple/preflight_test.go new file mode 100644 index 000000000..2089d69ce --- /dev/null +++ b/pkg/driver/apple/preflight_test.go @@ -0,0 +1,39 @@ +package apple + +import ( + "context" + "errors" + "testing" + + "github.com/devsy-org/devsy/pkg/driver" +) + +func TestApplePreflightBinaryMissing(t *testing.T) { + d := &appleDriver{command: "devsy-nonexistent-container-binary", Apple: &mockClient{}} + + err := d.Preflight(context.Background(), driver.PreflightOptions{}) + var perr *driver.PreflightError + if !errors.As(err, &perr) { + t.Fatalf("expected *driver.PreflightError, got %v (%T)", err, err) + } +} + +func TestApplePreflightAutoStart(t *testing.T) { + m := &mockClient{systemDown: true} + d := &appleDriver{command: "sh", Apple: m} + + if err := d.Preflight(context.Background(), driver.PreflightOptions{}); err != nil { + t.Fatalf("Preflight with auto-start = %v, want nil", err) + } +} + +func TestApplePreflightOptOutSystemDown(t *testing.T) { + m := &mockClient{systemDown: true} + d := &appleDriver{command: "sh", Apple: m} + + err := d.Preflight(context.Background(), driver.PreflightOptions{DisableAutoStart: true}) + var perr *driver.PreflightError + if !errors.As(err, &perr) { + t.Fatalf("expected *driver.PreflightError when opted out and system down, got %v", err) + } +} diff --git a/pkg/driver/docker/docker.go b/pkg/driver/docker/docker.go index 9fd4dc0f0..86c6d6bae 100644 --- a/pkg/driver/docker/docker.go +++ b/pkg/driver/docker/docker.go @@ -3,6 +3,7 @@ package docker import ( "context" "fmt" + "os/exec" "runtime" "github.com/devsy-org/devsy/pkg/compose" @@ -100,8 +101,60 @@ var ( _ driver.ImageDriver = (*dockerDriver)(nil) _ driver.ComposeDriver = (*dockerDriver)(nil) _ driver.DockerHelperProvider = (*dockerDriver)(nil) + _ driver.Preflighter = (*dockerDriver)(nil) ) +// Preflight checks the runtime binary is installed and its daemon reachable, +// starting a stopped Podman machine unless auto-start is disabled. +func (d *dockerDriver) Preflight(ctx context.Context, opts driver.PreflightOptions) error { + return runPreflight(ctx, opts, dockerProbe{ + command: d.Docker.DockerCommand, + runtime: d.Docker.GetRuntime().Name(), + lookPath: exec.LookPath, + ping: d.Docker.Ping, + start: d.Docker.StartPodmanMachine, + }) +} + +// dockerProbe bundles the operations runPreflight depends on so the branching +// can be exercised with fakes, without abstracting the whole DockerHelper. +type dockerProbe struct { + command string + runtime docker.RuntimeName + lookPath func(string) (string, error) + ping func(context.Context) error + start func(context.Context) error +} + +func runPreflight(ctx context.Context, opts driver.PreflightOptions, p dockerProbe) error { + runtimeName := string(p.runtime) + + if _, err := p.lookPath(p.command); err != nil { + return &driver.PreflightError{ + Provider: runtimeName, + Err: fmt.Errorf("%s is not installed or not on PATH: %w", p.command, err), + } + } + + err := p.ping(ctx) + if err == nil { + return nil + } + + if p.runtime == docker.RuntimePodman && !opts.DisableAutoStart { + log.Infof( + "Podman machine is not running, attempting to start it (this may take a while)...", + ) + if startErr := p.start(ctx); startErr != nil { + log.Warnf("failed to start Podman machine: %v", startErr) + } else if err = p.ping(ctx); err == nil { + return nil + } + } + + return &driver.PreflightError{Provider: runtimeName, Err: err} +} + func (d *dockerDriver) TargetArchitecture(ctx context.Context, workspaceId string) (string, error) { return runtime.GOARCH, nil } diff --git a/pkg/driver/docker/preflight_test.go b/pkg/driver/docker/preflight_test.go new file mode 100644 index 000000000..e73a3db1c --- /dev/null +++ b/pkg/driver/docker/preflight_test.go @@ -0,0 +1,124 @@ +package docker + +import ( + "context" + "errors" + "testing" + + "github.com/devsy-org/devsy/pkg/docker" + "github.com/devsy-org/devsy/pkg/driver" +) + +func TestDockerPreflightBinaryMissing(t *testing.T) { + rt, err := docker.RuntimeFromName(string(docker.RuntimeDocker)) + if err != nil { + t.Fatalf("RuntimeFromName: %v", err) + } + + d := &dockerDriver{ + Docker: &docker.DockerHelper{ + DockerCommand: "devsy-nonexistent-runtime-binary", + Runtime: rt, + }, + } + + err = d.Preflight(context.Background(), driver.PreflightOptions{}) + if err == nil { + t.Fatal("expected preflight error for missing binary") + } + + var perr *driver.PreflightError + if !errors.As(err, &perr) { + t.Fatalf("expected *driver.PreflightError, got %T", err) + } + if perr.Provider != string(docker.RuntimeDocker) { + t.Fatalf("Provider = %q, want docker", perr.Provider) + } +} + +// installed makes lookPath report the binary as present. +func installed(string) (string, error) { return "/usr/bin/probe", nil } + +func alwaysErr(error) func(context.Context) error { + return func(context.Context) error { return errors.New("boom") } +} + +func TestRunPreflightDaemonReachable(t *testing.T) { + p := dockerProbe{ + runtime: docker.RuntimeDocker, + lookPath: installed, + ping: func(context.Context) error { return nil }, + } + if err := runPreflight(context.Background(), driver.PreflightOptions{}, p); err != nil { + t.Fatalf("reachable daemon = %v, want nil", err) + } +} + +func TestRunPreflightDaemonDownNoAutoStart(t *testing.T) { + daemonDown := errors.New("Cannot connect to the Docker daemon") + p := dockerProbe{ + runtime: docker.RuntimeDocker, + lookPath: installed, + ping: func(context.Context) error { return daemonDown }, + } + err := runPreflight(context.Background(), driver.PreflightOptions{}, p) + if !errors.Is(err, daemonDown) { + t.Fatalf("want wrapped daemon error, got %v", err) + } +} + +func TestRunPreflightPodmanAutoStartRecovers(t *testing.T) { + pinged := 0 + p := dockerProbe{ + runtime: docker.RuntimePodman, + lookPath: installed, + ping: func(context.Context) error { + pinged++ + if pinged == 1 { + return errors.New("Cannot connect to Podman") + } + return nil // reachable after the machine starts + }, + start: func(context.Context) error { return nil }, + } + if err := runPreflight(context.Background(), driver.PreflightOptions{}, p); err != nil { + t.Fatalf("podman auto-start recovery = %v, want nil", err) + } + if pinged != 2 { + t.Errorf("expected a re-ping after start, ping count = %d", pinged) + } +} + +func TestRunPreflightPodmanAutoStartFails(t *testing.T) { + down := errors.New("Cannot connect to Podman") + p := dockerProbe{ + runtime: docker.RuntimePodman, + lookPath: installed, + ping: func(context.Context) error { return down }, + start: alwaysErr(nil), // start fails; original ping error is surfaced + } + err := runPreflight(context.Background(), driver.PreflightOptions{}, p) + if !errors.Is(err, down) { + t.Fatalf("want original daemon error surfaced, got %v", err) + } +} + +func TestRunPreflightPodmanOptOutSkipsStart(t *testing.T) { + started := false + p := dockerProbe{ + runtime: docker.RuntimePodman, + lookPath: installed, + ping: func(context.Context) error { return errors.New("Cannot connect to Podman") }, + start: func(context.Context) error { + started = true + return nil + }, + } + err := runPreflight(context.Background(), driver.PreflightOptions{DisableAutoStart: true}, p) + if err == nil { + t.Fatal("expected error when opted out and daemon down") + } + if started { + t.Error("auto-start must not run when DisableAutoStart is set") + } +} diff --git a/pkg/driver/kubernetes/client.go b/pkg/driver/kubernetes/client.go index dc99ce30e..904e8c236 100644 --- a/pkg/driver/kubernetes/client.go +++ b/pkg/driver/kubernetes/client.go @@ -67,6 +67,11 @@ func (c *Client) Client() *kubernetes.Clientset { return c.client } +// Ping reports whether the API server is reachable via its /version endpoint. +func (c *Client) Ping(ctx context.Context) error { + return c.client.Discovery().RESTClient().Get().AbsPath("/version").Do(ctx).Error() +} + func (c *Client) Config() *rest.Config { return c.config } diff --git a/pkg/driver/kubernetes/driver.go b/pkg/driver/kubernetes/driver.go index 7b1ab2310..67432a195 100644 --- a/pkg/driver/kubernetes/driver.go +++ b/pkg/driver/kubernetes/driver.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "time" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/log" @@ -46,7 +47,22 @@ func NewKubernetesDriver( } // The kubernetes driver runs devcontainers as pods and can reprovision them. -var _ driver.ReprovisioningDriver = (*KubernetesDriver)(nil) +var ( + _ driver.ReprovisioningDriver = (*KubernetesDriver)(nil) + _ driver.Preflighter = (*KubernetesDriver)(nil) +) + +// Preflight verifies the cluster's API server is reachable. A remote cluster +// cannot be auto-started, so failures are surfaced as-is. +func (k *KubernetesDriver) Preflight(ctx context.Context, _ driver.PreflightOptions) error { + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if err := k.client.Ping(cctx); err != nil { + return &driver.PreflightError{Provider: provider2.KubernetesDriver, Err: err} + } + return nil +} type KubernetesDriver struct { namespace string diff --git a/pkg/driver/kubernetes/preflight_test.go b/pkg/driver/kubernetes/preflight_test.go new file mode 100644 index 000000000..d779c58a0 --- /dev/null +++ b/pkg/driver/kubernetes/preflight_test.go @@ -0,0 +1,54 @@ +package kubernetes + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/devsy-org/devsy/pkg/driver" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func newTestDriver(t *testing.T, host string) *KubernetesDriver { + t.Helper() + cs, err := kubernetes.NewForConfig(&rest.Config{Host: host}) + if err != nil { + t.Fatalf("NewForConfig: %v", err) + } + return &KubernetesDriver{client: &Client{client: cs, config: &rest.Config{Host: host}}} +} + +func TestKubernetesPreflightReachable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/version" { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"major":"1","minor":"29","gitVersion":"v1.29.0"}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + if err := newTestDriver( + t, + srv.URL, + ).Preflight(context.Background(), driver.PreflightOptions{}); err != nil { + t.Fatalf("Preflight against reachable API = %v, want nil", err) + } +} + +func TestKubernetesPreflightUnreachable(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + host := srv.URL + srv.Close() // nothing is listening on host anymore + + err := newTestDriver(t, host).Preflight(context.Background(), driver.PreflightOptions{}) + var perr *driver.PreflightError + if !errors.As(err, &perr) { + t.Fatalf("expected *driver.PreflightError for unreachable cluster, got %v (%T)", err, err) + } +} diff --git a/pkg/driver/microsandbox/microsandbox.go b/pkg/driver/microsandbox/microsandbox.go index ba254b717..e7c197f27 100644 --- a/pkg/driver/microsandbox/microsandbox.go +++ b/pkg/driver/microsandbox/microsandbox.go @@ -47,8 +47,18 @@ var ( _ driver.RunOptionsDriver = (*microsandboxDriver)(nil) _ driver.ReprovisioningDriver = (*microsandboxDriver)(nil) _ driver.ImageDriver = (*microsandboxDriver)(nil) + _ driver.Preflighter = (*microsandboxDriver)(nil) ) +// Preflight checks the microsandbox runtime binary is installed. There is no +// daemon to auto-start, so a missing binary is surfaced for the user. +func (d *microsandboxDriver) Preflight(ctx context.Context, _ driver.PreflightOptions) error { + if err := d.client.EnsureInstalled(ctx); err != nil { + return &driver.PreflightError{Provider: provider.MicrosandboxDriver, Err: err} + } + return nil +} + // CanReprovision returns false: in-place reprovision passes nil options, which // a microVM cannot rebuild from. --recreate still works via delete+create. func (d *microsandboxDriver) CanReprovision() bool { @@ -56,13 +66,10 @@ func (d *microsandboxDriver) CanReprovision() bool { } func NewMicrosandboxDriver( - ctx context.Context, + _ context.Context, workspaceInfo *provider.AgentWorkspaceInfo, ) (driver.Driver, error) { client := cliClient{} - if err := client.EnsureInstalled(ctx); err != nil { - return nil, fmt.Errorf("microsandbox runtime is not available: %w", err) - } cfg := workspaceInfo.Agent.Microsandbox defaults := specDefaults{ diff --git a/pkg/driver/microsandbox/microsandbox_test.go b/pkg/driver/microsandbox/microsandbox_test.go index d4fc75d3b..0d27f8ffd 100644 --- a/pkg/driver/microsandbox/microsandbox_test.go +++ b/pkg/driver/microsandbox/microsandbox_test.go @@ -28,22 +28,23 @@ const ( // fakeClient is an in-memory sandboxClient that records calls, so the driver's // lifecycle logic can be tested without a live runtime. type fakeClient struct { - created map[string]sandboxSpec - info map[string]*sandboxInfo - calls []string - execReq execRequest - execName string - failFind error - failStop error - failCreat error - failEnsure error + created map[string]sandboxSpec + info map[string]*sandboxInfo + calls []string + execReq execRequest + execName string + failFind error + failStop error + failCreat error + failEnsure error + failInstall error } func newFakeClient() *fakeClient { return &fakeClient{created: map[string]sandboxSpec{}, info: map[string]*sandboxInfo{}} } -func (f *fakeClient) EnsureInstalled(context.Context) error { return nil } +func (f *fakeClient) EnsureInstalled(context.Context) error { return f.failInstall } func (f *fakeClient) EnsureImage(_ context.Context, image string) error { f.calls = append(f.calls, "ensure:"+image) diff --git a/pkg/driver/microsandbox/preflight_test.go b/pkg/driver/microsandbox/preflight_test.go new file mode 100644 index 000000000..253aaa5e0 --- /dev/null +++ b/pkg/driver/microsandbox/preflight_test.go @@ -0,0 +1,31 @@ +package microsandbox + +import ( + "context" + "errors" + "testing" + + "github.com/devsy-org/devsy/pkg/driver" +) + +func TestPreflightInstalled(t *testing.T) { + d := newDriver(newFakeClient(), nil, specDefaults{}) + if err := d.Preflight(context.Background(), driver.PreflightOptions{}); err != nil { + t.Fatalf("Preflight with runtime installed = %v, want nil", err) + } +} + +func TestPreflightNotInstalled(t *testing.T) { + c := newFakeClient() + c.failInstall = errors.New("msb not found") + d := newDriver(c, nil, specDefaults{}) + + err := d.Preflight(context.Background(), driver.PreflightOptions{}) + var perr *driver.PreflightError + if !errors.As(err, &perr) { + t.Fatalf("expected *driver.PreflightError, got %v (%T)", err, err) + } + if perr.Provider != "microsandbox" { + t.Fatalf("Provider = %q, want microsandbox", perr.Provider) + } +} diff --git a/pkg/driver/preflight.go b/pkg/driver/preflight.go new file mode 100644 index 000000000..3614d3ead --- /dev/null +++ b/pkg/driver/preflight.go @@ -0,0 +1,51 @@ +package driver + +import ( + "context" + "os" + "strconv" +) + +// NoAutoStartEnv disables preflight auto-starting a stopped backend. +const NoAutoStartEnv = "DEVSY_NO_AUTOSTART" + +// PreflightOptions carries per-invocation preflight settings, passed explicitly +// so behavior is not coupled to process-global state. +type PreflightOptions struct { + // DisableAutoStart reports whether a stopped backend should be left as-is + // (reported) rather than started. + DisableAutoStart bool +} + +// Preflighter is implemented by drivers that can validate their backend before +// use and optionally start it. Callers reach it via DriverPreflight. +type Preflighter interface { + Driver + + Preflight(ctx context.Context, opts PreflightOptions) error +} + +// DriverPreflight runs the driver's preflight check, or nothing if it has none. +func DriverPreflight(ctx context.Context, d Driver, opts PreflightOptions) error { + if p, ok := d.(Preflighter); ok { + return p.Preflight(ctx, opts) + } + return nil +} + +// AutoStartDisabledByEnv reports whether NoAutoStartEnv opts out of auto-start. +func AutoStartDisabledByEnv() bool { + v, err := strconv.ParseBool(os.Getenv(NoAutoStartEnv)) + return err == nil && v +} + +// PreflightError lets callers recognize a backend-readiness failure (errors.As) +// and surface the runtime's own message rather than wrapping it. Provider holds +// the runtime or driver identifier (e.g. "podman", "kubernetes") for grouping. +type PreflightError struct { + Provider string + Err error +} + +func (e *PreflightError) Error() string { return e.Err.Error() } +func (e *PreflightError) Unwrap() error { return e.Err } diff --git a/pkg/driver/preflight_test.go b/pkg/driver/preflight_test.go new file mode 100644 index 000000000..c1b8e2cfe --- /dev/null +++ b/pkg/driver/preflight_test.go @@ -0,0 +1,104 @@ +package driver + +import ( + "context" + "errors" + "io" + "testing" + + "github.com/devsy-org/devsy/pkg/devcontainer/config" +) + +func TestPreflightErrorUnwrapAndMessage(t *testing.T) { + cause := errors.New("Cannot connect to Podman") + perr := &PreflightError{Provider: "podman", Err: cause} + + if perr.Error() != cause.Error() { + t.Fatalf("Error() = %q, want %q", perr.Error(), cause.Error()) + } + if !errors.Is(perr, cause) { + t.Fatal("errors.Is did not unwrap to the cause") + } + + var target *PreflightError + if !errors.As(error(perr), &target) { + t.Fatal("errors.As did not recognize *PreflightError") + } + if target.Provider != "podman" { + t.Fatalf("Provider = %q, want podman", target.Provider) + } +} + +func TestAutoStartDisabledByEnv(t *testing.T) { + for _, tc := range []struct { + val string + want bool + }{ + {"", false}, + {"0", false}, + {"false", false}, + {"1", true}, + {"true", true}, + {"garbage", false}, + } { + t.Setenv(NoAutoStartEnv, tc.val) + if got := AutoStartDisabledByEnv(); got != tc.want { + t.Errorf("AutoStartDisabledByEnv() with %q = %v, want %v", tc.val, got, tc.want) + } + } +} + +// noPreflightDriver implements Driver but not Preflighter. +type noPreflightDriver struct{} + +func (noPreflightDriver) FindDevContainer( + context.Context, + string, +) (*config.ContainerDetails, error) { + return nil, nil +} +func (noPreflightDriver) CommandDevContainer(context.Context, *CommandParams) error { return nil } +func (noPreflightDriver) TargetArchitecture(context.Context, string) (string, error) { + return "", nil +} +func (noPreflightDriver) DeleteDevContainer(context.Context, string) error { return nil } +func (noPreflightDriver) StartDevContainer(context.Context, string) error { return nil } +func (noPreflightDriver) StopDevContainer(context.Context, string) error { return nil } +func (noPreflightDriver) GetDevContainerLogs(context.Context, string, io.Writer, io.Writer) error { + return nil +} + +func TestDriverPreflightNoOpWithoutCapability(t *testing.T) { + if err := DriverPreflight( + context.Background(), + noPreflightDriver{}, + PreflightOptions{}, + ); err != nil { + t.Fatalf("DriverPreflight on non-Preflighter driver = %v, want nil", err) + } +} + +// preflightSpy is a Preflighter that records the options it received. +type preflightSpy struct { + noPreflightDriver + got PreflightOptions +} + +func (s *preflightSpy) Preflight(_ context.Context, opts PreflightOptions) error { + s.got = opts + return nil +} + +func TestDriverPreflightForwardsOptions(t *testing.T) { + spy := &preflightSpy{} + if err := DriverPreflight( + context.Background(), + spy, + PreflightOptions{DisableAutoStart: true}, + ); err != nil { + t.Fatalf("DriverPreflight = %v, want nil", err) + } + if !spy.got.DisableAutoStart { + t.Error("DriverPreflight did not forward DisableAutoStart to the driver") + } +} diff --git a/pkg/flags/names/names.go b/pkg/flags/names/names.go index ae98062a6..d78612534 100644 --- a/pkg/flags/names/names.go +++ b/pkg/flags/names/names.go @@ -40,6 +40,7 @@ const ( MachineReuse = "machine-reuse" Mount = "mount" MountWorkspaceGitRoot = "mount-workspace-git-root" + NoAutoStart = "no-auto-start" NoCache = "no-cache" Platform = "platform" Prebuild = "prebuild" diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 09a939d02..197910ad0 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -245,6 +245,7 @@ type CLIOptions struct { Recreate bool `json:"recreate,omitempty"` Prebuild bool `json:"prebuild,omitempty"` Reset bool `json:"reset,omitempty"` + NoAutoStart bool `json:"noAutoStart,omitempty"` DisableDaemon bool `json:"disableDaemon,omitempty"` DaemonInterval string `json:"daemonInterval,omitempty"` GitCloneStrategy git.CloneStrategy `json:"gitCloneStrategy,omitempty"`