diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 5d4698886..a07129c0c 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -405,6 +405,13 @@ jobs: install-kind: true requires-secret: false + - label: up-provider-microsandbox + runner: ubuntu-latest + free-disk-space: true + install-kind: false + requires-secret: false + install-microsandbox: true + # Up Docker Compose tests - label: up-docker-compose @@ -599,6 +606,14 @@ jobs: echo "DOCKER_HOST=unix:///run/podman/podman.sock" >> "$GITHUB_ENV" sudo podman info + - name: Install microsandbox (Linux) + if: matrix.install-microsandbox == true && runner.os == 'Linux' + run: | + curl -fsSL https://install.microsandbox.dev | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + # The e2e test skips gracefully if KVM is unavailable on the runner. + "$HOME/.local/bin/msb" doctor || true + - name: remove docker if: matrix.label == 'docker-install' && (matrix.requires-secret == false || needs.can-read-secret.outputs.secret-set == 'true') run: | diff --git a/desktop/src/renderer/public/icons/providers/microsandbox.svg b/desktop/src/renderer/public/icons/providers/microsandbox.svg new file mode 100644 index 000000000..9a8d684af --- /dev/null +++ b/desktop/src/renderer/public/icons/providers/microsandbox.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderIcon.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderIcon.svelte index 86abf2a28..e7e4b9d29 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderIcon.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderIcon.svelte @@ -18,6 +18,8 @@ const ICON_MAP: Record = { kubernetes: "./icons/providers/kubernetes.svg", k8s: "./icons/providers/kubernetes.svg", k3s: "./icons/providers/kubernetes.svg", + microsandbox: "./icons/providers/microsandbox.svg", + microvm: "./icons/providers/microsandbox.svg", digitalocean: "./icons/providers/digitalocean.svg", ssh: "./icons/providers/ssh.svg", civo: "./icons/providers/civo.svg", diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte index 80c77b070..31e5d2b3c 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte @@ -38,6 +38,7 @@ const PRESETS = [ { name: "docker", description: "Local Docker containers" }, { name: "podman", description: "Local Podman containers" }, { name: "apple", description: "Apple containers (macOS 26+, Apple silicon)" }, + { name: "microsandbox", description: "Hardware-isolated microVMs" }, { name: "lima", description: "Linux VMs via Lima (macOS)" }, { name: "orbstack", description: "Fast containers and VMs via OrbStack (macOS)" }, { name: "ssh", description: "Remote SSH machines" }, diff --git a/e2e/tests/up/provider_microsandbox.go b/e2e/tests/up/provider_microsandbox.go new file mode 100644 index 000000000..fa4d33389 --- /dev/null +++ b/e2e/tests/up/provider_microsandbox.go @@ -0,0 +1,83 @@ +package up + +import ( + "context" + "os" + "os/exec" + "runtime" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" +) + +const osLinux = "linux" + +// skipIfNoMicrosandbox skips when the microsandbox runtime or hardware +// virtualization is unavailable, mirroring how other providers guard on their +// runtime being present. +func skipIfNoMicrosandbox() { + if _, err := exec.LookPath("msb"); err != nil { + ginkgo.Skip("microsandbox runtime (msb) not found on PATH") + } + switch { + case runtime.GOOS == osLinux: + kvm, err := os.OpenFile("/dev/kvm", os.O_RDWR, 0) + if err != nil { + ginkgo.Skip("microsandbox requires KVM (/dev/kvm not accessible)") + } + _ = kvm.Close() + case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": + // Apple silicon supports microsandbox via the hypervisor framework. + default: + ginkgo.Skip("microsandbox requires Apple silicon or Linux with KVM") + } +} + +var _ = ginkgo.Describe( + "testing up command for microsandbox provider", + ginkgo.Label("up-provider-microsandbox"), + func() { + var initialDir string + + ginkgo.BeforeEach(func() { + skipIfNoMicrosandbox() + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It("runs devsy in a microsandbox microVM", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDir("tests/up/testdata/microsandbox") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + + _ = f.DevsyProviderDelete(ctx, "microsandbox") + err = f.DevsyProviderAdd(ctx, "microsandbox") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + err := f.DevsyProviderDelete(cleanupCtx, "microsandbox") + framework.ExpectNoError(err) + }) + + // full up: boots the microVM, streams in the agent, opens the tunnel + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, tempDir) + + // the workspace is reachable over SSH + err = f.DevsySSHEchoTestString(ctx, tempDir) + framework.ExpectNoError(err) + + // stop, then bring it back up and confirm it is reachable again + err = f.DevsyWorkspaceStop(ctx, tempDir) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + err = f.DevsySSHEchoTestString(ctx, tempDir) + framework.ExpectNoError(err) + }, ginkgo.SpecTimeout(framework.TimeoutModerate())) + }, +) diff --git a/e2e/tests/up/testdata/microsandbox/.devcontainer.json b/e2e/tests/up/testdata/microsandbox/.devcontainer.json new file mode 100644 index 000000000..ca9e68883 --- /dev/null +++ b/e2e/tests/up/testdata/microsandbox/.devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "microsandbox", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu" +} diff --git a/e2e/tests/up/up_behaviors.go b/e2e/tests/up/up_behaviors.go index 8ad62e2a9..de58bc4fb 100644 --- a/e2e/tests/up/up_behaviors.go +++ b/e2e/tests/up/up_behaviors.go @@ -118,7 +118,7 @@ var _ = ginkgo.Describe("up command behaviors", ginkgo.Label("up-behaviors"), fu ) ginkgo.It("should accept --update-remote-user-uid=on", func(ctx context.Context) { - if runtime.GOOS != "linux" { + if runtime.GOOS != osLinux { ginkgo.Skip("updateRemoteUserUID only applies on Linux") } _, err := dtc.setupAndUp(ctx, "tests/up/testdata/docker", @@ -127,7 +127,7 @@ var _ = ginkgo.Describe("up command behaviors", ginkgo.Label("up-behaviors"), fu }, ginkgo.SpecTimeout(framework.TimeoutShort())) ginkgo.It("should accept --update-remote-user-uid=off", func(ctx context.Context) { - if runtime.GOOS != "linux" { + if runtime.GOOS != osLinux { ginkgo.Skip("updateRemoteUserUID only applies on Linux") } tempDir, err := dtc.setupAndUp(ctx, "tests/up/testdata/docker", diff --git a/pkg/agent/binary.go b/pkg/agent/binary.go index b1e01ba6b..bac72614f 100644 --- a/pkg/agent/binary.go +++ b/pkg/agent/binary.go @@ -37,6 +37,7 @@ func NewBinaryManager(downloadURL string) (*BinaryManager, error) { return &BinaryManager{ sources: []BinarySource{ + &EnvPathSource{EnvVar: config.EnvAgentBinary}, &InjectSource{}, &FileCacheSource{Cache: cache, ExpectedVersion: expectedVersion}, &HTTPDownloadSource{BaseURL: downloadURL, Cache: cache, Version: expectedVersion}, @@ -44,6 +45,25 @@ func NewBinaryManager(downloadURL string) (*BinaryManager, error) { }, nil } +// EnvPathSource injects an agent binary from a local path named by an +// environment variable, taking precedence over the built-in sources so an +// offline host can supply the Linux binary. The operator must match its arch. +type EnvPathSource struct { + EnvVar string +} + +func (s *EnvPathSource) GetBinary(_ context.Context, _ string) (io.ReadCloser, error) { + path := strings.TrimSpace(os.Getenv(s.EnvVar)) + if path == "" { + return nil, fmt.Errorf("no agent binary path set in %s", s.EnvVar) + } + return os.Open(path) // #nosec G304 G703 -- operator-provided local agent binary +} + +func (s *EnvPathSource) SourceName() string { + return "local path override (" + s.EnvVar + ")" +} + func versionFromDownloadURL(downloadURL string) string { parts := strings.Split(strings.TrimRight(downloadURL, "/"), "/") if len(parts) == 0 { diff --git a/pkg/agent/binary_env_test.go b/pkg/agent/binary_env_test.go new file mode 100644 index 000000000..cf6a9a45e --- /dev/null +++ b/pkg/agent/binary_env_test.go @@ -0,0 +1,37 @@ +package agent + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" +) + +func TestEnvPathSourceUsesLocalBinary(t *testing.T) { + dir := t.TempDir() + binPath := filepath.Join(dir, "devsy-linux-arm64") + if err := os.WriteFile(binPath, []byte("agent-bytes"), 0o755); err != nil { //nolint:gosec + t.Fatal(err) + } + t.Setenv("DEVSY_TEST_AGENT_BIN", binPath) + + src := &EnvPathSource{EnvVar: "DEVSY_TEST_AGENT_BIN"} + rc, err := src.GetBinary(context.Background(), "arm64") + if err != nil { + t.Fatalf("GetBinary: %v", err) + } + defer func() { _ = rc.Close() }() + + data, _ := io.ReadAll(rc) + if string(data) != "agent-bytes" { + t.Errorf("got %q, want the local binary contents", data) + } +} + +func TestEnvPathSourceUnsetFallsThrough(t *testing.T) { + src := &EnvPathSource{EnvVar: "DEVSY_TEST_AGENT_BIN_UNSET"} + if _, err := src.GetBinary(context.Background(), "arm64"); err == nil { + t.Fatal("expected an error when the env var is unset so later sources are tried") + } +} diff --git a/pkg/agent/delivery/factory.go b/pkg/agent/delivery/factory.go index 7701c0a42..efdf0a23d 100644 --- a/pkg/agent/delivery/factory.go +++ b/pkg/agent/delivery/factory.go @@ -38,6 +38,15 @@ func NewAgentDelivery(opts FactoryOptions) AgentDelivery { log.Debugf("using shell-based delivery for apple driver") return &LegacyShellDelivery{ExecFunc: opts.ExecFunc, DownloadURL: ""} + case driverType == provider.MicrosandboxDriver: + // Stream the agent binary over the SDK's guest exec (as kubernetes does); + // fall back to shell delivery when the driver exposes no argv exec. + if opts.PodExec == nil { + return legacyShellDelivery(opts, "microsandbox argv exec unavailable") + } + log.Debugf("using stream delivery (exec stream) for microsandbox") + return &KubernetesDelivery{Exec: opts.PodExec} + case opts.IsRemoteDocker: log.Debugf("using remote docker delivery (docker cp)") return remoteDockerDelivery(opts) diff --git a/pkg/agent/delivery/factory_test.go b/pkg/agent/delivery/factory_test.go index a2672ac26..5d23ed38c 100644 --- a/pkg/agent/delivery/factory_test.go +++ b/pkg/agent/delivery/factory_test.go @@ -95,6 +95,26 @@ func TestNewAgentDelivery_KubernetesDriver_Native(t *testing.T) { assert.Equal(t, PhasePostStart, d.Phase()) } +func TestNewAgentDelivery_MicrosandboxUsesStreamDelivery(t *testing.T) { + podExec := func(_ context.Context, _ []string, _ driver.Streams) error { + return nil + } + + opts := FactoryOptions{ + WorkspaceConfig: &provider.AgentWorkspaceInfo{ + Agent: provider.ProviderAgentConfig{ + Driver: provider.MicrosandboxDriver, + }, + }, + PodExec: podExec, + } + + d := NewAgentDelivery(opts) + native, ok := d.(*KubernetesDelivery) + require.True(t, ok) + assert.NotNil(t, native.Exec) +} + func TestNewAgentDelivery_KubernetesDriver_FallsBackWhenNoPodExec(t *testing.T) { execFn := func(ctx context.Context, cmd string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { return nil @@ -168,3 +188,15 @@ func TestNewAgentDelivery_AppleUsesShellDelivery(t *testing.T) { t.Fatalf("apple driver must use shell delivery, got %T", d) } } + +func TestNewAgentDelivery_MicrosandboxUsesShellDelivery(t *testing.T) { + opts := FactoryOptions{ + WorkspaceConfig: &provider.AgentWorkspaceInfo{ + Agent: provider.ProviderAgentConfig{Driver: provider.MicrosandboxDriver}, + }, + } + d := NewAgentDelivery(opts) + if _, ok := d.(*LegacyShellDelivery); !ok { + t.Fatalf("microsandbox driver must use shell delivery, got %T", d) + } +} diff --git a/pkg/config/env.go b/pkg/config/env.go index 29990484d..410f0e5d5 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -45,6 +45,11 @@ const ( // EnvHelperImage overrides the helper image for agent volume operations. EnvHelperImage = "DEVSY_HELPER_IMAGE" + // EnvAgentBinary points to a local agent binary (matching the workspace's + // Linux architecture) to inject instead of downloading. Enables offline and + // air-gapped delivery on hosts that cannot supply the binary directly. + EnvAgentBinary = "DEVSY_AGENT_BINARY" + // EnvAgentPreferDownload forces agent binary download even if a local copy exists. EnvAgentPreferDownload = "DEVSY_AGENT_PREFER_DOWNLOAD" diff --git a/pkg/credentials/server.go b/pkg/credentials/server.go index 03a8fd4a4..5e3c37377 100644 --- a/pkg/credentials/server.go +++ b/pkg/credentials/server.go @@ -79,6 +79,12 @@ type credentialsHandlerFunc func( func newCredentialsHandler(ctx context.Context, client CredentialsClient) http.Handler { routes := map[string]credentialsHandlerFunc{ + // Root is a readiness probe (see waitForServer); it must return 200 so the + // server is detected as up. Unknown paths still 404 below. + "/": func(_ context.Context, writer http.ResponseWriter, _ *http.Request, _ CredentialsClient) error { + writer.WriteHeader(http.StatusOK) + return nil + }, "/git-credentials": handleGitCredentialsRequest, "/docker-credentials": handleDockerCredentialsRequest, "/git-ssh-signature": handleGitSSHSignatureRequest, diff --git a/pkg/driver/drivercreate/create.go b/pkg/driver/drivercreate/create.go index e690824db..e085bca9d 100644 --- a/pkg/driver/drivercreate/create.go +++ b/pkg/driver/drivercreate/create.go @@ -9,6 +9,7 @@ import ( "github.com/devsy-org/devsy/pkg/driver/custom" "github.com/devsy-org/devsy/pkg/driver/docker" "github.com/devsy-org/devsy/pkg/driver/kubernetes" + "github.com/devsy-org/devsy/pkg/driver/microsandbox" provider2 "github.com/devsy-org/devsy/pkg/provider" ) @@ -26,9 +27,12 @@ func NewDriver( return kubernetes.NewKubernetesDriver(workspaceInfo) case provider2.AppleDriver: return apple.NewAppleDriver(ctx, workspaceInfo) + case provider2.MicrosandboxDriver: + return microsandbox.NewMicrosandboxDriver(ctx, workspaceInfo) } - return nil, fmt.Errorf("unrecognized driver %q, possible values are %s, %s, %s or %s", + return nil, fmt.Errorf( + "unrecognized driver %q, possible values are %s, %s, %s, %s or %s", driver, provider2.DockerDriver, provider2.CustomDriver, provider2.KubernetesDriver, - provider2.AppleDriver) + provider2.AppleDriver, provider2.MicrosandboxDriver) } diff --git a/pkg/driver/microsandbox/cliclient.go b/pkg/driver/microsandbox/cliclient.go new file mode 100644 index 000000000..0585cbdde --- /dev/null +++ b/pkg/driver/microsandbox/cliclient.go @@ -0,0 +1,336 @@ +package microsandbox + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/image" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/tarball" +) + +type cliClient struct{} + +var _ sandboxClient = cliClient{} + +func (cliClient) EnsureInstalled(_ context.Context) error { + bin := msbBinary() + if filepath.IsAbs(bin) { + if _, err := os.Stat(bin); err == nil { + return nil + } + } else if _, err := exec.LookPath(bin); err == nil { + return nil + } + return fmt.Errorf( + "microsandbox runtime (msb) not found; install it from https://install.microsandbox.dev", + ) +} + +func (cliClient) EnsureImage(ctx context.Context, imageRef string) error { + if dockerImageExists(ctx, imageRef) { + return loadFromDocker(ctx, imageRef) + } + // #nosec G204 -- args are a resolved binary path and a validated image ref + out, err := exec.CommandContext(ctx, msbBinary(), "pull", imageRef).CombinedOutput() + if err == nil { + return nil + } + if loadErr := loadViaRegistry(ctx, imageRef); loadErr != nil { + return fmt.Errorf("msb pull %s: %s: %w; registry fallback: %v", imageRef, out, err, loadErr) + } + return nil +} + +func (c cliClient) Create(ctx context.Context, sandbox string, spec sandboxSpec) error { + if err := c.ensureVolumes(ctx, spec.Mounts); err != nil { + return err + } + return msbRun(ctx, createArgs(sandbox, spec)...) +} + +func (cliClient) Find(ctx context.Context, sandbox string) (*sandboxInfo, error) { + // #nosec G204 -- args are a resolved binary path and a derived sandbox name + out, err := exec.CommandContext(ctx, msbBinary(), "inspect", sandbox, "--format", "json"). + Output() + if err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("inspect microsandbox VM %q: %w", sandbox, ctx.Err()) + } + // A genuine "not found" means the sandbox is absent (nil, nil). Any other + // failure (permission, crash, bad invocation) is a real error to surface, + // so callers do not mistake it for an absent sandbox. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && strings.Contains(string(exitErr.Stderr), "not found") { + return nil, nil + } + return nil, fmt.Errorf("inspect microsandbox VM %q: %w", sandbox, err) + } + var raw struct { + Name string `json:"name"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + ActiveConfig struct { + Labels map[string]string `json:"labels"` + } `json:"active_config"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return nil, fmt.Errorf("parse microsandbox inspect output: %w", err) + } + created, _ := time.Parse(time.RFC3339Nano, raw.CreatedAt) + return &sandboxInfo{ + Name: raw.Name, + Running: strings.EqualFold(raw.Status, "running"), + CreatedAt: created, + Labels: raw.ActiveConfig.Labels, + }, nil +} + +func (cliClient) Start(ctx context.Context, sandbox string) error { + return msbRun(ctx, "start", sandbox) +} + +func (cliClient) Stop(ctx context.Context, sandbox string) error { + return msbRun(ctx, "stop", sandbox) +} + +func (cliClient) Remove(ctx context.Context, sandbox string) error { + return msbRun(ctx, "remove", sandbox) +} + +// Exec uses --stream (byte-faithful stdio, no PTY) because the agent binary +// injection and the SSH-over-stdio tunnel require it; a PTY stalls the tunnel. +func (cliClient) Exec(ctx context.Context, sandbox string, req execRequest) error { + args := []string{"exec", "--stream"} + if req.User != "" { + args = append(args, names.Flag(names.User), req.User) + } + args = append(args, sandbox, "--") + if len(req.Argv) > 0 { + args = append(args, req.Argv...) + } else { + args = append(args, "/bin/sh", "-c", req.Command) + } + // #nosec G204 -- args are a resolved binary path plus the caller's command + cmd := exec.CommandContext(ctx, msbBinary(), args...) + cmd.Stdin = req.Stdin + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} + +func (cliClient) Logs(ctx context.Context, sandbox string, w io.Writer) error { + // #nosec G204 -- args are a resolved binary path and a derived sandbox name + cmd := exec.CommandContext(ctx, msbBinary(), "logs", sandbox) + cmd.Stdout = w + cmd.Stderr = w + return cmd.Run() +} + +func (cliClient) ensureVolumes(ctx context.Context, mounts []volumeMount) error { + for _, vol := range namedVolumes(mounts) { + // #nosec G204 -- args are a resolved binary path and a named-volume name + out, err := exec.CommandContext(ctx, msbBinary(), "volume", "create", vol).CombinedOutput() + if err != nil && !strings.Contains(strings.ToLower(string(out)), "already exists") { + return fmt.Errorf("create microsandbox volume %q: %s: %w", vol, out, err) + } + } + return nil +} + +func createArgs(sandbox string, spec sandboxSpec) []string { + args := []string{names.Create, names.Flag(names.Name), sandbox} + args = append(args, runtimeArgs(spec)...) + args = append(args, resourceArgs(spec)...) + args = append(args, mountArgs(spec.Mounts)...) + return append(args, spec.Image) +} + +func runtimeArgs(spec sandboxSpec) []string { + var args []string + if len(spec.Entrypoint) > 0 { + args = append(args, "--entrypoint", strings.Join(spec.Entrypoint, " ")) + } + for k, v := range spec.Env { + args = append(args, names.Flag(names.Env), k+"="+v) + } + for k, v := range spec.Labels { + args = append(args, names.Flag(names.Label), k+"="+v) + } + if spec.IdleTimeout > 0 { + args = append(args, "--idle-timeout", spec.IdleTimeout.String()) + } + if spec.BlockEgress { + args = append(args, "--net-default-egress", "deny") + } + return args +} + +func resourceArgs(spec sandboxSpec) []string { + var args []string + if spec.Memory > 0 { + args = append(args, "--memory", fmt.Sprintf("%dM", spec.Memory)) + } + if spec.MaxMemory > 0 { + args = append(args, "--max-memory", fmt.Sprintf("%dM", spec.MaxMemory)) + } + if spec.CPUs > 0 { + args = append(args, "--cpus", strconv.Itoa(int(spec.CPUs))) + } + if spec.MaxCPUs > 0 { + args = append(args, "--max-cpus", strconv.Itoa(int(spec.MaxCPUs))) + } + return args +} + +func mountArgs(mounts []volumeMount) []string { + var args []string + for _, m := range mounts { + switch { + case m.Tmpfs: + args = append(args, "--tmpfs", m.Target) + case m.Volume != "": + args = append(args, "--mount-named", m.Volume+":"+m.Target) + } + } + return args +} + +func namedVolumes(mounts []volumeMount) []string { + var vols []string + for _, m := range mounts { + if !m.Tmpfs && m.Volume != "" { + vols = append(vols, m.Volume) + } + } + return vols +} + +func msbRun(ctx context.Context, args ...string) error { + // #nosec G204 -- args are a resolved binary path and controlled config values + out, err := exec.CommandContext(ctx, msbBinary(), args...).CombinedOutput() + if err != nil { + return fmt.Errorf( + "msb %s: %s: %w", + redactArgs(args), + strings.TrimSpace(string(out)), + err, + ) + } + return nil +} + +// redactArgs joins args for an error message while masking --env values, which +// may carry secrets from the devcontainer configuration. +func redactArgs(args []string) string { + out := make([]string, len(args)) + copy(out, args) + envFlag := names.Flag(names.Env) + for i := 0; i+1 < len(out); i++ { + if out[i] != envFlag { + continue + } + if k, _, ok := strings.Cut(out[i+1], "="); ok { + out[i+1] = k + "=***" + } + } + return strings.Join(out, " ") +} + +func dockerImageExists(ctx context.Context, image string) bool { + docker, err := exec.LookPath("docker") + if err != nil { + return false + } + // #nosec G204 -- docker path is resolved and the image ref is validated + return exec.CommandContext(ctx, docker, "image", "inspect", image).Run() == nil +} + +func loadFromDocker(ctx context.Context, image string) error { + docker, err := exec.LookPath("docker") + if err != nil { + return fmt.Errorf("docker not found to load built image %q: %w", image, err) + } + // #nosec G204 -- docker/msb paths are resolved and the image ref is validated + save := exec.CommandContext(ctx, docker, "save", image) + // #nosec G204 -- docker/msb paths are resolved and the image ref is validated + load := exec.CommandContext(ctx, msbBinary(), "load", "-t", image) + pipe, err := save.StdoutPipe() + if err != nil { + return fmt.Errorf("pipe docker save: %w", err) + } + load.Stdin = pipe + var loadErr strings.Builder + load.Stderr = &loadErr + if err := load.Start(); err != nil { + return fmt.Errorf("start msb load: %w", err) + } + if err := save.Run(); err != nil { + // load is still running on the broken pipe; kill and reap it. + _ = load.Process.Kill() + _ = load.Wait() + return fmt.Errorf("docker save %q: %w", image, err) + } + if err := load.Wait(); err != nil { + return fmt.Errorf("msb load %q: %s: %w", image, loadErr.String(), err) + } + return nil +} + +func loadViaRegistry(ctx context.Context, imageRef string) error { + ref, err := name.ParseReference(imageRef) + if err != nil { + return fmt.Errorf("parse image reference %q: %w", imageRef, err) + } + img, err := image.GetImageForArch(ctx, imageRef, runtime.GOARCH) + if err != nil { + return fmt.Errorf("pull image %q: %w", imageRef, err) + } + + tmp, err := os.CreateTemp("", "devsy-msb-*.tar") + if err != nil { + return fmt.Errorf("create image tarball: %w", err) + } + tmpPath := tmp.Name() + _ = tmp.Close() + defer func() { _ = os.Remove(tmpPath) }() + + if err := tarball.WriteToFile(tmpPath, ref, img); err != nil { + return fmt.Errorf("write image tarball: %w", err) + } + + // #nosec G204 -- args are a resolved binary path and an internally-created file + load := exec.CommandContext(ctx, msbBinary(), "load", "-i", tmpPath, "-t", imageRef) + if out, err := load.CombinedOutput(); err != nil { + return fmt.Errorf("msb load %q: %s: %w", imageRef, out, err) + } + return nil +} + +func msbBinary() string { + if p, err := exec.LookPath("msb"); err == nil { + return p + } + if home, err := os.UserHomeDir(); err == nil { + for _, c := range []string{ + filepath.Join(home, ".local", "bin", "msb"), + filepath.Join(home, ".microsandbox", "bin", "msb"), + } { + if _, statErr := os.Stat(c); statErr == nil { + return c + } + } + } + return "msb" +} diff --git a/pkg/driver/microsandbox/cliclient_test.go b/pkg/driver/microsandbox/cliclient_test.go new file mode 100644 index 000000000..a7136f899 --- /dev/null +++ b/pkg/driver/microsandbox/cliclient_test.go @@ -0,0 +1,134 @@ +package microsandbox + +import ( + "slices" + "strings" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/flags/names" +) + +func TestCreateArgsFull(t *testing.T) { + spec := sandboxSpec{ + Image: testImg, + Entrypoint: []string{shPath, "-c", "sleep infinity"}, + Env: map[string]string{"K": "v"}, + Labels: map[string]string{"devsy.sh/user": "vscode"}, + IdleTimeout: 90 * time.Second, + BlockEgress: true, + Memory: 2048, + MaxMemory: 4096, + CPUs: 2, + MaxCPUs: 4, + Mounts: []volumeMount{ + {Target: "/cache", Volume: "cache-vol"}, + {Target: "/tmp", Tmpfs: true}, + }, + } + args := createArgs(wsName, spec) + + // name comes first, image last. + if args[0] != names.Create || args[1] != names.Flag(names.Name) || args[2] != wsName { + t.Errorf("prefix = %v", args[:3]) + } + if args[len(args)-1] != testImg { + t.Errorf("image should be the final arg, got %q", args[len(args)-1]) + } + + want := [][2]string{ + {"--entrypoint", "/bin/sh -c sleep infinity"}, + {names.Flag(names.Env), "K=v"}, + {"--label", "devsy.sh/user=vscode"}, + {"--idle-timeout", "1m30s"}, + {"--memory", "2048M"}, + {"--max-memory", "4096M"}, + {"--cpus", "2"}, + {"--max-cpus", "4"}, + {"--mount-named", "cache-vol:/cache"}, + {"--tmpfs", "/tmp"}, + } + for _, kv := range want { + if !hasFlagValue(args, kv[0], kv[1]) { + t.Errorf("missing %s %q in %v", kv[0], kv[1], args) + } + } + if hasFlag(args, "--net-default-egress") == false { + t.Errorf("expected egress deny flag in %v", args) + } +} + +func TestCreateArgsMinimal(t *testing.T) { + args := createArgs(wsName, sandboxSpec{Image: testImg}) + // Only name + image; no sizing/runtime flags for a bare spec. + want := []string{names.Create, names.Flag(names.Name), wsName, testImg} + if !slices.Equal(args, want) { + t.Errorf("args = %v, want %v", args, want) + } +} + +func TestMountArgsAndNamedVolumes(t *testing.T) { + mounts := []volumeMount{ + {Target: "/a", Volume: "vol-a"}, + {Target: "/b", Tmpfs: true}, + {Target: "/c", Volume: "vol-c"}, + } + if got := namedVolumes(mounts); !slices.Equal(got, []string{"vol-a", "vol-c"}) { + t.Errorf("namedVolumes = %v, want [vol-a vol-c]", got) + } + args := mountArgs(mounts) + if !hasFlagValue(args, "--mount-named", "vol-a:/a") || + !hasFlagValue(args, "--tmpfs", "/b") || + !hasFlagValue(args, "--mount-named", "vol-c:/c") { + t.Errorf("mountArgs = %v", args) + } +} + +func TestResourceArgsOmitsZero(t *testing.T) { + if got := resourceArgs(sandboxSpec{}); len(got) != 0 { + t.Errorf("zero spec should produce no resource args, got %v", got) + } + if got := resourceArgs( + sandboxSpec{Memory: 512}, + ); !slices.Equal( + got, + []string{"--memory", "512M"}, + ) { + t.Errorf("resourceArgs = %v", got) + } +} + +func TestRedactArgsMasksEnvValues(t *testing.T) { + args := []string{ + names.Create, + names.Flag(names.Env), + "TOKEN=s3cret", + "--label", + "k=v", + names.Flag(names.Env), + "PLAIN=ok", + } + got := redactArgs(args) + if strings.Contains(got, "s3cret") || strings.Contains(got, "ok") { + t.Errorf("env values leaked: %q", got) + } + if !strings.Contains(got, "TOKEN=***") || !strings.Contains(got, "PLAIN=***") { + t.Errorf("env keys should be preserved with masked values: %q", got) + } + if !strings.Contains(got, "--label k=v") { + t.Errorf("non-env args should be untouched: %q", got) + } +} + +func hasFlag(args []string, flag string) bool { + return slices.Contains(args, flag) +} + +func hasFlagValue(args []string, flag, value string) bool { + for i := 0; i+1 < len(args); i++ { + if args[i] == flag && args[i+1] == value { + return true + } + } + return false +} diff --git a/pkg/driver/microsandbox/client.go b/pkg/driver/microsandbox/client.go new file mode 100644 index 000000000..aa2000cbc --- /dev/null +++ b/pkg/driver/microsandbox/client.go @@ -0,0 +1,59 @@ +package microsandbox + +import ( + "context" + "io" + "time" +) + +type sandboxSpec struct { + Image string + Entrypoint []string + Memory uint32 + CPUs uint8 + Env map[string]string + Labels map[string]string + Ephemeral bool + IdleTimeout time.Duration + Mounts []volumeMount + MaxMemory uint32 + MaxCPUs uint8 + BlockEgress bool +} + +type volumeMount struct { + Target string + Volume string + Tmpfs bool +} + +type sandboxInfo struct { + Name string + Running bool + CreatedAt time.Time + Labels map[string]string +} + +type execRequest struct { + Command string + Argv []string + User string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// sandboxClient abstracts the microsandbox runtime so the driver can be tested +// against a fake. Lifecycle ordering (e.g. stop before remove) is the driver's +// job, not the client's. +type sandboxClient interface { + EnsureInstalled(ctx context.Context) error + EnsureImage(ctx context.Context, image string) error + Create(ctx context.Context, name string, spec sandboxSpec) error + Find(ctx context.Context, name string) (*sandboxInfo, error) + Start(ctx context.Context, name string) error + Stop(ctx context.Context, name string) error + Remove(ctx context.Context, name string) error + Exec(ctx context.Context, name string, req execRequest) error + Logs(ctx context.Context, name string, w io.Writer) error +} diff --git a/pkg/driver/microsandbox/integration_test.go b/pkg/driver/microsandbox/integration_test.go new file mode 100644 index 000000000..08203f3ce --- /dev/null +++ b/pkg/driver/microsandbox/integration_test.go @@ -0,0 +1,85 @@ +//go:build microsandbox_integration + +// Integration test that drives the microsandbox driver against the real runtime. +// Requires the microsandbox runtime installed and network access to pull the +// image. Run with: go test -tags microsandbox_integration ./pkg/driver/microsandbox/ +package microsandbox + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/driver" +) + +func TestDriverLifecycleIntegration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + d := newDriver(cliClient{}, nil, specDefaults{memory: 2048, cpus: 2}) + const workspaceID = "integration-test" + + // Clean up any residue from a prior run, and always at the end. + _ = d.DeleteDevContainer(ctx, workspaceID) + defer func() { _ = d.DeleteDevContainer(ctx, workspaceID) }() + + if err := d.RunDevContainer(ctx, workspaceID, &driver.RunOptions{ + Image: "mcr.microsoft.com/devcontainers/base:ubuntu", + }); err != nil { + t.Fatalf("RunDevContainer: %v", err) + } + + details, err := d.FindDevContainer(ctx, workspaceID) + if err != nil { + t.Fatalf("FindDevContainer: %v", err) + } + if details == nil { + t.Fatal("FindDevContainer returned nil for a running workspace") + } + if details.State.Status != "running" { + t.Errorf("status = %q, want running", details.State.Status) + } + + var stdout, stderr bytes.Buffer + if err := d.CommandDevContainer(ctx, &driver.CommandParams{ + WorkspaceID: workspaceID, + Command: "echo hello-from-vm && uname -r", + Stdout: &stdout, + Stderr: &stderr, + }); err != nil { + t.Fatalf("CommandDevContainer: %v (stderr=%s)", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "hello-from-vm") { + t.Errorf("command stdout = %q, want it to contain hello-from-vm", stdout.String()) + } + t.Logf("command output:\n%s", stdout.String()) +} + +func TestLoadViaRegistryIntegration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const img = "mcr.microsoft.com/devcontainers/base:ubuntu" + if err := loadViaRegistry(ctx, img); err != nil { + t.Fatalf("loadViaRegistry (go-containerregistry pull -> tarball -> msb load): %v", err) + } + // Boot the loaded image and confirm it runs. + d := newDriver(cliClient{}, nil, specDefaults{}) + const workspaceID = "gcrload-test" + defer func() { _ = d.DeleteDevContainer(ctx, workspaceID) }() + if err := d.client.Create(ctx, sandboxName(workspaceID), sandboxSpec{Image: img}); err != nil { + t.Fatalf("create from registry-loaded image: %v", err) + } + var out bytes.Buffer + if err := d.CommandDevContainer(ctx, &driver.CommandParams{ + WorkspaceID: workspaceID, Command: "echo gcr-load-ok", Stdout: &out, + }); err != nil { + t.Fatalf("exec: %v", err) + } + if !strings.Contains(out.String(), "gcr-load-ok") { + t.Errorf("output = %q", out.String()) + } +} diff --git a/pkg/driver/microsandbox/microsandbox.go b/pkg/driver/microsandbox/microsandbox.go new file mode 100644 index 000000000..ba254b717 --- /dev/null +++ b/pkg/driver/microsandbox/microsandbox.go @@ -0,0 +1,479 @@ +// Package microsandbox is a Devsy driver that runs devcontainers as microVMs. +package microsandbox + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "runtime" + "strconv" + "strings" + "time" + + pkgconfig "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/driver" + dockerdriver "github.com/devsy-org/devsy/pkg/driver/docker" + "github.com/devsy-org/devsy/pkg/image" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/provider" +) + +const userLabel = "devsy.sh/user" + +const createdAtLayout = "2006-01-02T15:04:05Z07:00" + +type specDefaults struct { + memory uint32 + cpus uint8 + ephemeral bool + idleTimeout time.Duration + maxMemory uint32 + maxCPUs uint8 + blockEgress bool +} + +type microsandboxDriver struct { + client sandboxClient + idLabels []string + defaults specDefaults + workspaceInfo *provider.AgentWorkspaceInfo + docker driver.ImageDriver +} + +var ( + _ driver.RunOptionsDriver = (*microsandboxDriver)(nil) + _ driver.ReprovisioningDriver = (*microsandboxDriver)(nil) + _ driver.ImageDriver = (*microsandboxDriver)(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 { + return false +} + +func NewMicrosandboxDriver( + ctx 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{ + memory: parseUint32(cfg.Memory), + cpus: parseUint8(cfg.CPUs), + ephemeral: cfg.Ephemeral == pkgconfig.BoolTrue, + idleTimeout: parseDuration(workspaceInfo.Agent.ContainerTimeout), + maxMemory: parseUint32(cfg.MaxMemory), + maxCPUs: parseUint8(cfg.MaxCPUs), + blockEgress: cfg.BlockEgress == pkgconfig.BoolTrue, + } + + log.Debugf( + "using microsandbox driver: memory=%dMiB cpus=%d ephemeral=%t idleTimeout=%s", + defaults.memory, defaults.cpus, defaults.ephemeral, defaults.idleTimeout, + ) + d := newDriver(client, workspaceInfo.CLIOptions.IDLabels, defaults) + d.workspaceInfo = workspaceInfo + return d, nil +} + +func newDriver(client sandboxClient, idLabels []string, defaults specDefaults) *microsandboxDriver { + return µsandboxDriver{client: client, idLabels: idLabels, defaults: defaults} +} + +func (d *microsandboxDriver) RunDevContainer( + ctx context.Context, + workspaceID string, + options *driver.RunOptions, +) error { + return d.runFromOptions(ctx, workspaceID, options) +} + +func (d *microsandboxDriver) RunImageDevContainer( + ctx context.Context, + params *driver.RunImageDevContainerParams, +) error { + if err := checkGPURequirement(params.ParsedConfig); err != nil { + return err + } + return d.runFromOptions(ctx, params.WorkspaceID, params.Options) +} + +func checkGPURequirement(parsedConfig *config.DevContainerConfig) error { + if parsedConfig == nil || + parsedConfig.HostRequirements == nil || + parsedConfig.HostRequirements.GPU == nil { + return nil + } + switch parsedConfig.HostRequirements.GPU.Value { + case "true": + return fmt.Errorf( + "microsandbox does not support GPU passthrough, but hostRequirements.gpu is required; " + + "use a GPU-capable provider or set gpu to \"optional\"", + ) + case "optional": + log.Warnf( + "hostRequirements.gpu is optional; microsandbox provides no GPU, continuing without one", + ) + } + return nil +} + +func (d *microsandboxDriver) FindDevContainer( + ctx context.Context, + workspaceID string, +) (*config.ContainerDetails, error) { + info, err := d.client.Find(ctx, sandboxName(workspaceID)) + if err != nil || info == nil { + return nil, err + } + return toContainerDetails(info), nil +} + +func (d *microsandboxDriver) CommandDevContainer( + ctx context.Context, + params *driver.CommandParams, +) error { + return d.client.Exec(ctx, sandboxName(params.WorkspaceID), execRequest{ + Command: params.Command, + User: params.User, + Stdin: params.Stdin, + Stdout: params.Stdout, + Stderr: params.Stderr, + }) +} + +// CommandContainerArgv satisfies the podExecCapableDriver interface that +// pkg/devcontainer detects to stream the agent binary in over stdin. +func (d *microsandboxDriver) CommandContainerArgv( + ctx context.Context, + workspaceID string, + argv []string, + streams driver.Streams, +) error { + return d.client.Exec(ctx, sandboxName(workspaceID), execRequest{ + Argv: argv, + User: "root", + Stdin: streams.Stdin, + Stdout: streams.Stdout, + Stderr: streams.Stderr, + }) +} + +func (d *microsandboxDriver) TargetArchitecture( + ctx context.Context, + workspaceID string, +) (string, error) { + switch runtime.GOARCH { + case "amd64", "arm64": + return runtime.GOARCH, nil + default: + return "", fmt.Errorf("unsupported architecture %q for microsandbox", runtime.GOARCH) + } +} + +func (d *microsandboxDriver) InspectImage( + ctx context.Context, + imageName string, +) (*config.ImageDetails, error) { + arch, err := d.TargetArchitecture(ctx, "") + if err != nil { + return nil, err + } + cfg, _, err := image.GetImageConfigForArch(ctx, imageName, arch) + if err != nil { + return nil, fmt.Errorf("get image config for %s: %w", imageName, err) + } + return &config.ImageDetails{ + ID: imageName, + Config: config.ImageDetailsConfig{ + User: cfg.Config.User, + Env: cfg.Config.Env, + Labels: cfg.Config.Labels, + Entrypoint: cfg.Config.Entrypoint, + Cmd: cfg.Config.Cmd, + }, + }, nil +} + +func (d *microsandboxDriver) GetImageTag(_ context.Context, imageName string) (string, error) { + return imageName, nil +} + +func (d *microsandboxDriver) BuildDevContainer( + ctx context.Context, + req driver.BuildRequest, +) (*config.BuildInfo, error) { + dockerDriver, err := d.dockerImageDriver() + if err != nil { + return nil, err + } + return dockerDriver.BuildDevContainer(ctx, req) +} + +func (d *microsandboxDriver) PushDevContainer(ctx context.Context, image string) error { + dockerDriver, err := d.dockerImageDriver() + if err != nil { + return err + } + return dockerDriver.PushDevContainer(ctx, image) +} + +func (d *microsandboxDriver) TagDevContainer(ctx context.Context, image, tag string) error { + dockerDriver, err := d.dockerImageDriver() + if err != nil { + return err + } + return dockerDriver.TagDevContainer(ctx, image, tag) +} + +func (d *microsandboxDriver) UpdateContainerUserUID( + _ context.Context, + _ string, + _ *config.DevContainerConfig, + _ io.Writer, +) error { + return nil +} + +func (d *microsandboxDriver) StartDevContainer(ctx context.Context, workspaceID string) error { + if err := d.client.Start(ctx, sandboxName(workspaceID)); err != nil { + return fmt.Errorf("start microsandbox VM: %w", err) + } + return nil +} + +func (d *microsandboxDriver) StopDevContainer(ctx context.Context, workspaceID string) error { + if err := d.client.Stop(ctx, sandboxName(workspaceID)); err != nil { + return fmt.Errorf("stop microsandbox VM: %w", err) + } + return nil +} + +// DeleteDevContainer stops before removing; the runtime only removes stopped +// sandboxes. +func (d *microsandboxDriver) DeleteDevContainer(ctx context.Context, workspaceID string) error { + name := sandboxName(workspaceID) + info, err := d.client.Find(ctx, name) + if err != nil { + return err + } + if info == nil { + return nil + } + if info.Running { + if err := d.client.Stop(ctx, name); err != nil { + return fmt.Errorf("stop microsandbox VM before delete: %w", err) + } + } + if err := d.client.Remove(ctx, name); err != nil { + return fmt.Errorf("remove microsandbox VM: %w", err) + } + return nil +} + +func (d *microsandboxDriver) GetDevContainerLogs( + ctx context.Context, + workspaceID string, + stdout io.Writer, + stderr io.Writer, +) error { + return d.client.Logs(ctx, sandboxName(workspaceID), stdout) +} + +func (d *microsandboxDriver) runFromOptions( + ctx context.Context, + workspaceID string, + options *driver.RunOptions, +) error { + if options == nil { + return fmt.Errorf( + "microsandbox driver requires run options (in-place reprovision is unsupported)", + ) + } + if options.Image == "" { + return fmt.Errorf("microsandbox driver requires an image to run") + } + warnUnsupportedOptions(options) + if err := d.DeleteDevContainer(ctx, workspaceID); err != nil { + return fmt.Errorf("clean stale microsandbox VM before create: %w", err) + } + if err := d.client.EnsureImage(ctx, options.Image); err != nil { + log.Debugf("microsandbox ensure image %q failed (continuing): %v", options.Image, err) + } + if err := d.client.Create( + ctx, + sandboxName(workspaceID), + d.buildSpec(workspaceID, options), + ); err != nil { + return fmt.Errorf("create microsandbox VM: %w", err) + } + return nil +} + +func (d *microsandboxDriver) dockerImageDriver() (driver.ImageDriver, error) { + if d.docker != nil { + return d.docker, nil + } + if d.workspaceInfo == nil { + return nil, fmt.Errorf("microsandbox build requires workspace info") + } + dd, err := dockerdriver.NewDockerDriver(d.workspaceInfo) + if err != nil { + return nil, fmt.Errorf("microsandbox needs docker to build this devcontainer: %w", err) + } + d.docker = dd + return dd, nil +} + +func (d *microsandboxDriver) buildSpec(workspaceID string, options *driver.RunOptions) sandboxSpec { + labels := config.ListToObject(config.GetIDLabels(workspaceID, d.idLabels)) + if labels == nil { + labels = map[string]string{} + } + if options.User != "" { + labels[userLabel] = options.User + } + return sandboxSpec{ + Image: options.Image, + Entrypoint: entrypointArgv(options), + Memory: d.defaults.memory, + CPUs: d.defaults.cpus, + Env: options.Env, + Labels: labels, + Ephemeral: d.defaults.ephemeral, + IdleTimeout: d.defaults.idleTimeout, + Mounts: volumeMounts(options.Mounts), + MaxMemory: d.defaults.maxMemory, + MaxCPUs: d.defaults.maxCPUs, + BlockEgress: d.defaults.blockEgress, + } +} + +func volumeMounts(mounts []*config.Mount) []volumeMount { + var out []volumeMount + for _, m := range mounts { + if m == nil || m.Target == "" { + continue + } + switch m.Type { + case driver.MountTypeVolume: + out = append(out, volumeMount{Target: m.Target, Volume: m.Source}) + case driver.MountTypeTmpfs: + out = append(out, volumeMount{Target: m.Target, Tmpfs: true}) + } + } + return out +} + +func warnUnsupportedOptions(options *driver.RunOptions) { + var ignored []string + if isPrivileged(options) { + ignored = append(ignored, "privileged") + } + if len(options.CapAdd) > 0 { + ignored = append(ignored, "capAdd") + } + if len(options.SecurityOpt) > 0 { + ignored = append(ignored, "securityOpt") + } + if hasUserNSMapping(options) { + ignored = append(ignored, "user-namespace mapping") + } + if len(ignored) > 0 { + log.Warnf( + "microsandbox ignores container-runtime options not applicable to a microVM: %s", + strings.Join(ignored, ", "), + ) + } +} + +func isPrivileged(options *driver.RunOptions) bool { + return options.Privileged != nil && *options.Privileged +} + +func hasUserNSMapping(options *driver.RunOptions) bool { + return options.Userns != "" || len(options.UidMap) > 0 || len(options.GidMap) > 0 +} + +func entrypointArgv(options *driver.RunOptions) []string { + var argv []string + if options.Entrypoint != "" { + argv = append(argv, options.Entrypoint) + } + return append(argv, options.Cmd...) +} + +func toContainerDetails(info *sandboxInfo) *config.ContainerDetails { + status := "exited" + if info.Running { + status = "running" + } + return &config.ContainerDetails{ + ID: info.Name, + Created: info.CreatedAt.Format(createdAtLayout), + State: config.ContainerDetailsState{Status: status}, + Config: config.ContainerDetailsConfig{ + Labels: info.Labels, + User: info.Labels[userLabel], + }, + } +} + +const maxSandboxNameLen = 128 + +func sandboxName(workspaceID string) string { + name := "devsy-" + workspaceID + if len(name) <= maxSandboxNameLen { + return name + } + sum := sha256.Sum256([]byte(workspaceID)) + suffix := "-" + hex.EncodeToString(sum[:])[:12] + return name[:maxSandboxNameLen-len(suffix)] + suffix +} + +func parseUint32(s string) uint32 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + v, err := strconv.ParseUint(s, 10, 32) + if err != nil { + log.Warnf("invalid microsandbox memory value %q, using runtime default", s) + return 0 + } + return uint32(v) +} + +func parseDuration(s string) time.Duration { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + d, err := time.ParseDuration(s) + if err != nil || d < 0 { + log.Warnf("invalid microsandbox inactivity timeout %q, disabling idle shutdown", s) + return 0 + } + return d +} + +func parseUint8(s string) uint8 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + v, err := strconv.ParseUint(s, 10, 8) + if err != nil { + log.Warnf("invalid microsandbox cpus value %q, using runtime default", s) + return 0 + } + return uint8(v) +} diff --git a/pkg/driver/microsandbox/microsandbox_test.go b/pkg/driver/microsandbox/microsandbox_test.go new file mode 100644 index 000000000..d4fc75d3b --- /dev/null +++ b/pkg/driver/microsandbox/microsandbox_test.go @@ -0,0 +1,503 @@ +package microsandbox + +import ( + "bytes" + "context" + "errors" + "io" + "slices" + "strings" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/driver" +) + +const ( + wsName = "devsy-ws1" + testImage = "example:latest" + testUser = "vscode" + imgX = "x:1" + testImg = "img:1" + shPath = "/bin/sh" + callFind = "find:" + wsName + callRemove = "remove:" + wsName +) + +// 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 +} + +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) EnsureImage(_ context.Context, image string) error { + f.calls = append(f.calls, "ensure:"+image) + return f.failEnsure +} + +func (f *fakeClient) Create(_ context.Context, name string, spec sandboxSpec) error { + f.calls = append(f.calls, "create:"+name) + if f.failCreat != nil { + return f.failCreat + } + f.created[name] = spec + f.info[name] = &sandboxInfo{ + Name: name, + Running: true, + CreatedAt: time.Unix(0, 0), + Labels: spec.Labels, + } + return nil +} + +func (f *fakeClient) Find(_ context.Context, name string) (*sandboxInfo, error) { + f.calls = append(f.calls, "find:"+name) + if f.failFind != nil { + return nil, f.failFind + } + return f.info[name], nil +} + +func (f *fakeClient) Start(_ context.Context, name string) error { + f.calls = append(f.calls, "start:"+name) + return nil +} + +func (f *fakeClient) Stop(_ context.Context, name string) error { + f.calls = append(f.calls, "stop:"+name) + if f.failStop != nil { + return f.failStop + } + if info := f.info[name]; info != nil { + info.Running = false + } + return nil +} + +func (f *fakeClient) Remove(_ context.Context, name string) error { + f.calls = append(f.calls, "remove:"+name) + delete(f.info, name) + return nil +} + +func (f *fakeClient) Exec(_ context.Context, name string, req execRequest) error { + f.calls = append(f.calls, "exec:"+name) + f.execName = name + f.execReq = req + return nil +} + +func (f *fakeClient) Logs(_ context.Context, name string, _ io.Writer) error { + f.calls = append(f.calls, "logs:"+name) + return nil +} + +var _ sandboxClient = (*fakeClient)(nil) + +func TestRunDevContainerBuildsSpec(t *testing.T) { + f := newFakeClient() + d := newDriver(f, nil, specDefaults{memory: 2048, cpus: 4, ephemeral: true}) + + err := d.RunDevContainer(context.Background(), "ws1", &driver.RunOptions{ + Image: testImage, + User: testUser, + Env: map[string]string{"FOO": "bar"}, + }) + if err != nil { + t.Fatalf("RunDevContainer: %v", err) + } + + spec, ok := f.created[wsName] + if !ok { + t.Fatal("expected a sandbox named devsy-ws1 to be created") + } + got := struct { + image string + memory uint32 + cpus uint8 + ephemeral bool + }{spec.Image, spec.Memory, spec.CPUs, spec.Ephemeral} + want := struct { + image string + memory uint32 + cpus uint8 + ephemeral bool + }{testImage, 2048, 4, true} + if got != want { + t.Errorf("spec = %+v, want %+v", got, want) + } + if spec.Labels[userLabel] != testUser { + t.Errorf("user label = %q, want vscode", spec.Labels[userLabel]) + } + if spec.Env["FOO"] != "bar" { + t.Errorf("env not propagated: %+v", spec.Env) + } +} + +func TestRunDevContainerReplacesStaleSandbox(t *testing.T) { + f := newFakeClient() + f.info[wsName] = &sandboxInfo{Name: wsName, Running: true} + d := newDriver(f, nil, specDefaults{}) + + err := d.RunDevContainer(context.Background(), "ws1", &driver.RunOptions{Image: imgX}) + if err != nil { + t.Fatalf("RunDevContainer: %v", err) + } + want := []string{ + callFind, + "stop:devsy-ws1", + callRemove, + "ensure:x:1", + "create:devsy-ws1", + } + if !slices.Equal(f.calls, want) { + t.Errorf("call order = %v, want %v", f.calls, want) + } +} + +func TestRunDevContainerContinuesWhenPrePullFails(t *testing.T) { + f := newFakeClient() + f.failEnsure = errors.New("registry hiccup") + d := newDriver(f, nil, specDefaults{}) + + // Pre-pull failure must not abort the run; create should still be attempted. + if err := d.RunDevContainer( + context.Background(), + "ws1", + &driver.RunOptions{Image: imgX}, + ); err != nil { + t.Fatalf("RunDevContainer should proceed despite pull failure: %v", err) + } + if _, ok := f.created[wsName]; !ok { + t.Error("expected create to run even though pre-pull failed") + } +} + +func TestRunImageDevContainerRunsFromParams(t *testing.T) { + f := newFakeClient() + d := newDriver(f, nil, specDefaults{}) + + err := d.RunImageDevContainer(context.Background(), &driver.RunImageDevContainerParams{ + WorkspaceID: "ws1", + Options: &driver.RunOptions{Image: "built-local:latest"}, + }) + if err != nil { + t.Fatalf("RunImageDevContainer: %v", err) + } + want := []string{callFind, "ensure:built-local:latest", "create:devsy-ws1"} + if !slices.Equal(f.calls, want) { + t.Errorf("call order = %v, want %v", f.calls, want) + } +} + +func TestCheckGPURequirement(t *testing.T) { + // required GPU -> error + req := &config.DevContainerConfig{} + req.HostRequirements = &config.HostRequirements{GPU: &config.GPURequirement{Value: "true"}} + if err := checkGPURequirement(req); err == nil { + t.Error("expected an error when a GPU is required") + } + // optional GPU -> no error + opt := &config.DevContainerConfig{} + opt.HostRequirements = &config.HostRequirements{GPU: &config.GPURequirement{Value: "optional"}} + if err := checkGPURequirement(opt); err != nil { + t.Errorf("optional GPU should not error, got %v", err) + } + // no GPU / nil config -> no error + if err := checkGPURequirement(nil); err != nil { + t.Errorf("nil config should not error, got %v", err) + } + if err := checkGPURequirement(&config.DevContainerConfig{}); err != nil { + t.Errorf("no host requirements should not error, got %v", err) + } +} + +func TestUpdateContainerUserUIDIsNoop(t *testing.T) { + d := newDriver(newFakeClient(), nil, specDefaults{}) + if err := d.UpdateContainerUserUID(context.Background(), "ws1", nil, nil); err != nil { + t.Errorf("UpdateContainerUserUID should be a no-op, got %v", err) + } +} + +func TestRunDevContainerRequiresImage(t *testing.T) { + d := newDriver(newFakeClient(), nil, specDefaults{}) + if err := d.RunDevContainer(context.Background(), "ws1", &driver.RunOptions{}); err == nil { + t.Fatal("expected an error when image is empty") + } +} + +func TestFindDevContainerMapsState(t *testing.T) { + f := newFakeClient() + f.info[wsName] = &sandboxInfo{ + Name: wsName, + Running: true, + CreatedAt: time.Unix(0, 0), + Labels: map[string]string{userLabel: testUser}, + } + d := newDriver(f, nil, specDefaults{}) + + details, err := d.FindDevContainer(context.Background(), "ws1") + if err != nil { + t.Fatalf("FindDevContainer: %v", err) + } + if details == nil { + t.Fatal("expected details for an existing sandbox") + } + if details.State.Status != "running" { + t.Errorf("status = %q, want running", details.State.Status) + } + if details.Config.User != testUser { + t.Errorf("user = %q, want vscode", details.Config.User) + } +} + +func TestFindDevContainerAbsent(t *testing.T) { + d := newDriver(newFakeClient(), nil, specDefaults{}) + details, err := d.FindDevContainer(context.Background(), "missing") + if err != nil { + t.Fatalf("FindDevContainer: %v", err) + } + if details != nil { + t.Errorf("expected nil details for a missing sandbox, got %+v", details) + } +} + +func TestDeleteStopsRunningThenRemoves(t *testing.T) { + f := newFakeClient() + f.info[wsName] = &sandboxInfo{Name: wsName, Running: true} + d := newDriver(f, nil, specDefaults{}) + + if err := d.DeleteDevContainer(context.Background(), "ws1"); err != nil { + t.Fatalf("DeleteDevContainer: %v", err) + } + want := []string{callFind, "stop:devsy-ws1", callRemove} + if !slices.Equal(f.calls, want) { + t.Errorf("call order = %v, want %v", f.calls, want) + } +} + +func TestDeleteStoppedSkipsStop(t *testing.T) { + f := newFakeClient() + f.info[wsName] = &sandboxInfo{Name: wsName, Running: false} + d := newDriver(f, nil, specDefaults{}) + + if err := d.DeleteDevContainer(context.Background(), "ws1"); err != nil { + t.Fatalf("DeleteDevContainer: %v", err) + } + want := []string{callFind, callRemove} + if !slices.Equal(f.calls, want) { + t.Errorf("call order = %v, want %v", f.calls, want) + } +} + +func TestDeleteAbsentIsNoop(t *testing.T) { + f := newFakeClient() + d := newDriver(f, nil, specDefaults{}) + if err := d.DeleteDevContainer(context.Background(), "missing"); err != nil { + t.Fatalf("DeleteDevContainer: %v", err) + } + want := []string{"find:devsy-missing"} + if !slices.Equal(f.calls, want) { + t.Errorf("calls = %v, want just a find", f.calls) + } +} + +func TestDeletePropagatesStopError(t *testing.T) { + f := newFakeClient() + f.info[wsName] = &sandboxInfo{Name: wsName, Running: true} + f.failStop = errors.New("boom") + d := newDriver(f, nil, specDefaults{}) + + if err := d.DeleteDevContainer(context.Background(), "ws1"); err == nil { + t.Fatal("expected the stop error to propagate") + } +} + +func TestCommandDevContainerForwardsRequest(t *testing.T) { + f := newFakeClient() + d := newDriver(f, nil, specDefaults{}) + var out bytes.Buffer + + err := d.CommandDevContainer(context.Background(), &driver.CommandParams{ + WorkspaceID: "ws1", + User: testUser, + Command: "echo hi", + Stdout: &out, + }) + if err != nil { + t.Fatalf("CommandDevContainer: %v", err) + } + if f.execName != wsName { + t.Errorf("exec name = %q, want devsy-ws1", f.execName) + } + if f.execReq.Command != "echo hi" || f.execReq.User != testUser { + t.Errorf("unexpected exec request: %+v", f.execReq) + } +} + +func TestRunDevContainerSetsEntrypoint(t *testing.T) { + f := newFakeClient() + d := newDriver(f, nil, specDefaults{}) + + err := d.RunDevContainer(context.Background(), "ws1", &driver.RunOptions{ + Image: testImage, + Entrypoint: shPath, + Cmd: []string{"-c", "start", "-"}, + }) + if err != nil { + t.Fatalf("RunDevContainer: %v", err) + } + got := f.created[wsName].Entrypoint + want := []string{shPath, "-c", "start", "-"} + if len(got) != len(want) { + t.Fatalf("entrypoint = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("entrypoint = %v, want %v", got, want) + } + } +} + +func TestCommandContainerArgvForwardsArgv(t *testing.T) { + f := newFakeClient() + d := newDriver(f, nil, specDefaults{}) + + argv := []string{"sh", "-c", "cat > /usr/local/bin/devsy"} + err := d.CommandContainerArgv(context.Background(), "ws1", argv, driver.Streams{}) + if err != nil { + t.Fatalf("CommandContainerArgv: %v", err) + } + if f.execName != wsName { + t.Errorf("exec name = %q, want devsy-ws1", f.execName) + } + if len(f.execReq.Argv) != len(argv) || f.execReq.Argv[0] != "sh" { + t.Errorf("argv = %v, want %v", f.execReq.Argv, argv) + } + if f.execReq.Command != "" { + t.Errorf("argv exec must not set a shell command, got %q", f.execReq.Command) + } + if f.execReq.User != "root" { + t.Errorf("argv exec user = %q, want root", f.execReq.User) + } +} + +func TestSandboxName(t *testing.T) { + if got := sandboxName("my-workspace"); got != "devsy-my-workspace" { + t.Errorf("sandboxName = %q, want %q", got, "devsy-my-workspace") + } +} + +func TestSandboxNameClampsLongIDs(t *testing.T) { + long := strings.Repeat("a", 200) + got := sandboxName(long) + if len(got) != maxSandboxNameLen { + t.Errorf("clamped name length = %d, want %d", len(got), maxSandboxNameLen) + } + // Distinct long ids must not collide after clamping. + other := sandboxName(strings.Repeat("a", 199) + "b") + if got == other { + t.Error("distinct long ids produced the same clamped name") + } + // Short ids stay verbatim. + if sandboxName("x") != "devsy-x" { + t.Errorf("short id should not be clamped, got %q", sandboxName("x")) + } +} + +func TestParseUint32(t *testing.T) { + cases := []struct { + in string + want uint32 + }{{"", 0}, {" ", 0}, {"2048", 2048}, {"not-num", 0}, {"-5", 0}} + for _, c := range cases { + if got := parseUint32(c.in); got != c.want { + t.Errorf("parseUint32(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestParseDuration(t *testing.T) { + cases := map[string]time.Duration{ + "": 0, + " ": 0, + "20s": 20 * time.Second, + "1h": time.Hour, + "bogus": 0, + "-5m": 0, + } + for in, want := range cases { + if got := parseDuration(in); got != want { + t.Errorf("parseDuration(%q) = %s, want %s", in, got, want) + } + } +} + +func TestBuildSpecMapsVolumeMountsSkipsBind(t *testing.T) { + d := newDriver(newFakeClient(), nil, specDefaults{}) + spec := d.buildSpec("ws1", &driver.RunOptions{ + Image: imgX, + Mounts: []*config.Mount{ + {Type: driver.MountTypeVolume, Source: "vol1", Target: "/data"}, + {Type: driver.MountTypeTmpfs, Target: "/scratch"}, + {Type: driver.MountTypeBind, Source: "/host", Target: "/mnt"}, + nil, + }, + }) + if len(spec.Mounts) != 2 { + t.Fatalf("got %d mounts, want 2 (bind + nil skipped): %+v", len(spec.Mounts), spec.Mounts) + } + if spec.Mounts[0].Target != "/data" || spec.Mounts[0].Volume != "vol1" || spec.Mounts[0].Tmpfs { + t.Errorf("volume mount mapped wrong: %+v", spec.Mounts[0]) + } + if spec.Mounts[1].Target != "/scratch" || !spec.Mounts[1].Tmpfs { + t.Errorf("tmpfs mount mapped wrong: %+v", spec.Mounts[1]) + } +} + +func TestBuildSpecCarriesIdleTimeout(t *testing.T) { + d := newDriver(newFakeClient(), nil, specDefaults{idleTimeout: 90 * time.Second}) + spec := d.buildSpec("ws1", &driver.RunOptions{Image: imgX}) + if spec.IdleTimeout != 90*time.Second { + t.Errorf("idle timeout = %s, want 90s", spec.IdleTimeout) + } +} + +func TestBuildSpecCarriesCeilingsAndEgress(t *testing.T) { + d := newDriver( + newFakeClient(), + nil, + specDefaults{maxMemory: 4096, maxCPUs: 4, blockEgress: true}, + ) + spec := d.buildSpec("ws1", &driver.RunOptions{Image: imgX}) + if spec.MaxMemory != 4096 || spec.MaxCPUs != 4 || !spec.BlockEgress { + t.Errorf("unexpected spec ceilings/egress: %+v", spec) + } +} + +func TestParseUint8(t *testing.T) { + cases := []struct { + in string + want uint8 + }{{"", 0}, {"2", 2}, {"999", 0}, {"abc", 0}, {" 4 ", 4}} + for _, c := range cases { + if got := parseUint8(c.in); got != c.want { + t.Errorf("parseUint8(%q) = %d, want %d", c.in, got, c.want) + } + } +} diff --git a/pkg/gitsshsigning/helper.go b/pkg/gitsshsigning/helper.go index ae57c0eb0..53fe1c3fe 100644 --- a/pkg/gitsshsigning/helper.go +++ b/pkg/gitsshsigning/helper.go @@ -176,7 +176,9 @@ type gitConfigFilter struct { func removeSignatureHelper(content string) string { f := &gitConfigFilter{current: sectionNone} for line := range strings.Lines(content) { - f.process(strings.TrimRight(line, "\r\n")) + // Trim only the LF so a CRLF file keeps its \r and is not rewritten to + // LF on the lines this filter passes through unchanged. + f.process(strings.TrimRight(line, "\n")) } f.flush() diff --git a/pkg/options/resolve.go b/pkg/options/resolve.go index 80729caa3..aabef7d68 100644 --- a/pkg/options/resolve.go +++ b/pkg/options/resolve.go @@ -254,6 +254,7 @@ func ResolveAgentConfig( resolveAgentDockerConfig(&agentConfig, options) resolveAgentKubernetesConfig(&agentConfig, options) resolveAgentAppleConfig(&agentConfig, options) + resolveAgentMicrosandboxConfig(&agentConfig, options) resolveAgentPathAndURL(&agentConfig, options, devConfig) resolveAgentCredentials(&agentConfig, options, devConfig) @@ -342,6 +343,34 @@ func resolveAgentAppleConfig( agentConfig.Apple.Env = resolver.ResolveDefaultValues(agentConfig.Apple.Env, options) } +func resolveAgentMicrosandboxConfig( + agentConfig *provider.ProviderAgentConfig, + options map[string]string, +) { + agentConfig.Microsandbox.Memory = resolver.ResolveDefaultValue( + agentConfig.Microsandbox.Memory, + options, + ) + agentConfig.Microsandbox.CPUs = resolver.ResolveDefaultValue( + agentConfig.Microsandbox.CPUs, + options, + ) + agentConfig.Microsandbox.MaxMemory = resolver.ResolveDefaultValue( + agentConfig.Microsandbox.MaxMemory, + options, + ) + agentConfig.Microsandbox.MaxCPUs = resolver.ResolveDefaultValue( + agentConfig.Microsandbox.MaxCPUs, + options, + ) + agentConfig.Microsandbox.Ephemeral = types.StrBool( + resolver.ResolveDefaultValue(string(agentConfig.Microsandbox.Ephemeral), options), + ) + agentConfig.Microsandbox.BlockEgress = types.StrBool( + resolver.ResolveDefaultValue(string(agentConfig.Microsandbox.BlockEgress), options), + ) +} + func resolveAgentPathAndURL( agentConfig *provider.ProviderAgentConfig, options map[string]string, diff --git a/pkg/options/resolve_test.go b/pkg/options/resolve_test.go index 9a4347e9b..aeecfa647 100644 --- a/pkg/options/resolve_test.go +++ b/pkg/options/resolve_test.go @@ -751,3 +751,29 @@ func TestResolveAgentAppleConfig(t *testing.T) { assert.Equal(t, types.StrBool("true"), agentConfig.Apple.Rosetta) assert.Equal(t, "resolved", agentConfig.Apple.Env["KEY"]) } + +func TestResolveAgentMicrosandboxConfig(t *testing.T) { + agentConfig := &provider.ProviderAgentConfig{} + agentConfig.Microsandbox.Memory = "${MICROSANDBOX_MEMORY}" + agentConfig.Microsandbox.CPUs = "${MICROSANDBOX_CPUS}" + agentConfig.Microsandbox.Ephemeral = types.StrBool("${MICROSANDBOX_EPHEMERAL}") + + agentConfig.Microsandbox.MaxMemory = "${MICROSANDBOX_MAX_MEMORY}" + agentConfig.Microsandbox.BlockEgress = types.StrBool("${MICROSANDBOX_BLOCK_EGRESS}") + + options := map[string]string{ + "MICROSANDBOX_MEMORY": "2048", + "MICROSANDBOX_CPUS": "4", + "MICROSANDBOX_EPHEMERAL": "true", + "MICROSANDBOX_MAX_MEMORY": "8192", + "MICROSANDBOX_BLOCK_EGRESS": "true", + } + + resolveAgentMicrosandboxConfig(agentConfig, options) + + assert.Equal(t, "2048", agentConfig.Microsandbox.Memory) + assert.Equal(t, "4", agentConfig.Microsandbox.CPUs) + assert.Equal(t, types.StrBool("true"), agentConfig.Microsandbox.Ephemeral) + assert.Equal(t, "8192", agentConfig.Microsandbox.MaxMemory) + assert.Equal(t, types.StrBool("true"), agentConfig.Microsandbox.BlockEgress) +} diff --git a/pkg/provider/parse.go b/pkg/provider/parse.go index f01f9c39e..4c47718cb 100644 --- a/pkg/provider/parse.go +++ b/pkg/provider/parse.go @@ -280,8 +280,11 @@ func validateAgentDriver(config *ProviderConfig) error { if config.Agent.Driver != "" && config.Agent.Driver != CustomDriver && config.Agent.Driver != DockerDriver && config.Agent.Driver != KubernetesDriver && - config.Agent.Driver != AppleDriver { - return fmt.Errorf("agent.driver can only be docker, kubernetes, apple or custom") + config.Agent.Driver != AppleDriver && + config.Agent.Driver != MicrosandboxDriver { + return fmt.Errorf( + "agent.driver can only be docker, kubernetes, apple, microsandbox or custom", + ) } if config.Agent.Driver == CustomDriver { diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 1cdadb0c1..d86e92fb5 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -127,6 +127,9 @@ type ProviderAgentConfig struct { // Apple holds Apple container specific configuration Apple ProviderAppleDriverConfig `json:"apple"` + + // Microsandbox holds microsandbox microVM specific configuration + Microsandbox ProviderMicrosandboxDriverConfig `json:"microsandbox"` } type ProviderDockerlessOptions struct { @@ -151,10 +154,11 @@ func (a ProviderAgentConfig) IsDockerDriver() bool { } const ( - DockerDriver = "docker" - KubernetesDriver = "kubernetes" - CustomDriver = "custom" - AppleDriver = "apple" + DockerDriver = "docker" + KubernetesDriver = "kubernetes" + CustomDriver = "custom" + AppleDriver = "apple" + MicrosandboxDriver = "microsandbox" ) // ProviderAppleDriverConfig holds configuration for the Apple container driver, @@ -170,6 +174,29 @@ type ProviderAppleDriverConfig struct { Env map[string]string `json:"env,omitempty"` } +// ProviderMicrosandboxDriverConfig holds configuration for the microsandbox +// driver, which boots the devcontainer OCI image as a hardware-isolated microVM +// (libkrun) via the microsandbox runtime. +type ProviderMicrosandboxDriverConfig struct { + // Memory is the guest memory limit in MiB. Empty uses the runtime default. + Memory string `json:"memory,omitempty"` + + // CPUs is the number of virtual CPUs. Empty uses the runtime default. + CPUs string `json:"cpus,omitempty"` + + // MaxMemory is the hotplug memory ceiling in MiB. Empty uses the default. + MaxMemory string `json:"maxMemory,omitempty"` + + // MaxCPUs is the hotplug CPU ceiling. Empty uses the default. + MaxCPUs string `json:"maxCpus,omitempty"` + + // BlockEgress denies outbound public network (sandbox hardening). + BlockEgress types.StrBool `json:"blockEgress,omitempty"` + + // Ephemeral removes the sandbox's disk state when it stops. + Ephemeral types.StrBool `json:"ephemeral,omitempty"` +} + type ProviderCustomDriverConfig struct { // FindDevContainer is used to find an existing devcontainer FindDevContainer types.StrArray `json:"findDevContainer,omitempty"` diff --git a/providers/microsandbox/provider.yaml b/providers/microsandbox/provider.yaml new file mode 100644 index 000000000..6c432d3de --- /dev/null +++ b/providers/microsandbox/provider.yaml @@ -0,0 +1,49 @@ +name: microsandbox +version: v1.0.0 +icon: https://dl.devsy.sh/assets/devsy.svg +home: https://github.com/devsy-org/devsy +description: |- + Devsy on microsandbox (hardware-isolated microVMs, libkrun) +optionGroups: + - options: + - MICROSANDBOX_MEMORY + - MICROSANDBOX_CPUS + - MICROSANDBOX_MAX_MEMORY + - MICROSANDBOX_MAX_CPUS + - MICROSANDBOX_BLOCK_EGRESS + - MICROSANDBOX_EPHEMERAL + - INACTIVITY_TIMEOUT + name: "Advanced Options" +options: + MICROSANDBOX_MEMORY: + description: "Guest memory limit in MiB. Empty uses the runtime default." + MICROSANDBOX_CPUS: + description: "Number of virtual CPUs. Empty uses the runtime default." + MICROSANDBOX_MAX_MEMORY: + description: "Hotplug memory ceiling in MiB. Empty uses the runtime default." + MICROSANDBOX_MAX_CPUS: + description: "Hotplug CPU ceiling. Empty uses the runtime default." + MICROSANDBOX_BLOCK_EGRESS: + description: "If true, deny the microVM outbound public network (sandbox hardening)." + default: "false" + type: boolean + MICROSANDBOX_EPHEMERAL: + description: "If true, the microVM's disk state is discarded when it stops." + default: "false" + type: boolean + INACTIVITY_TIMEOUT: + description: "If defined, will automatically stop the microVM after the inactivity period. Examples: 10m, 1h" +agent: + containerInactivityTimeout: ${INACTIVITY_TIMEOUT} + local: true + driver: microsandbox + microsandbox: + memory: ${MICROSANDBOX_MEMORY} + cpus: ${MICROSANDBOX_CPUS} + maxMemory: ${MICROSANDBOX_MAX_MEMORY} + maxCpus: ${MICROSANDBOX_MAX_CPUS} + blockEgress: ${MICROSANDBOX_BLOCK_EGRESS} + ephemeral: ${MICROSANDBOX_EPHEMERAL} +exec: + command: |- + "${DEVSY}" internal sh -c "${COMMAND}" diff --git a/providers/providers.go b/providers/providers.go index d1dbe5a27..ff8c00cf7 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -13,6 +13,9 @@ var DockerProvider string //go:embed kubernetes/provider.yaml var KubernetesProvider string +//go:embed microsandbox/provider.yaml +var MicrosandboxProvider string + //go:embed podman/provider.yaml var PodmanProvider string @@ -22,10 +25,11 @@ var ProProvider string // GetBuiltInProviders retrieves the built in providers. func GetBuiltInProviders() map[string]string { return map[string]string{ - "apple": AppleProvider, - "docker": DockerProvider, - "kubernetes": KubernetesProvider, - "podman": PodmanProvider, - "pro": ProProvider, + "apple": AppleProvider, + "docker": DockerProvider, + "kubernetes": KubernetesProvider, + "microsandbox": MicrosandboxProvider, + "podman": PodmanProvider, + "pro": ProProvider, } } diff --git a/providers/providers_test.go b/providers/providers_test.go index aef5993e7..fe0b8d280 100644 --- a/providers/providers_test.go +++ b/providers/providers_test.go @@ -33,3 +33,16 @@ func TestAppleProviderUsesAppleDriver(t *testing.T) { t.Errorf("apple provider driver = %q, want %q", cfg.Agent.Driver, provider.AppleDriver) } } + +func TestMicrosandboxProviderUsesMicrosandboxDriver(t *testing.T) { + cfg, err := provider.ParseProvider(strings.NewReader(providers.MicrosandboxProvider)) + if err != nil { + t.Fatalf("parse microsandbox provider: %v", err) + } + if cfg.Agent.Driver != provider.MicrosandboxDriver { + t.Errorf( + "microsandbox provider driver = %q, want %q", + cfg.Agent.Driver, provider.MicrosandboxDriver, + ) + } +}