From 5c46015c6c59f89ba60c2d51128e60f60e39914e Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 16:34:07 -0500 Subject: [PATCH 1/3] feat(hostRequirements): enforce blocking validation with GPU support and skip flag ValidateHostRequirements now returns errors (not just warnings) for hard failures: insufficient CPUs, memory, and required-but-missing GPU. Storage issues and detection errors remain soft warnings. Callers in single.go and compose.go fail with a clear error when the host does not meet minimum requirements. Add --skip-host-requirements flag to allow users to override enforcement. When set, hard failures are downgraded to warnings and container creation proceeds. Add GPU availability check to HostInfo interface using nvidia-smi detection on the host. GPU requirement "true" blocks if unavailable; "optional" and "false" never block. --- cmd/up/up_flags.go | 3 + cmd/up/up_test.go | 12 ++ .../up-docker-compose/host_requirements.go | 40 ++++-- e2e/tests/up/host_requirements.go | 42 +++++-- pkg/devcontainer/compose.go | 9 +- pkg/devcontainer/config/host_requirements.go | 74 ++++++++--- .../config/host_requirements_system.go | 8 ++ .../config/host_requirements_test.go | 118 +++++++++++++++--- pkg/devcontainer/single.go | 8 +- pkg/provider/workspace.go | 7 +- 10 files changed, 251 insertions(+), 70 deletions(-) diff --git a/cmd/up/up_flags.go b/cmd/up/up_flags.go index 98f41d525..3f4096122 100644 --- a/cmd/up/up_flags.go +++ b/cmd/up/up_flags.go @@ -115,6 +115,9 @@ func (cmd *UpCmd) registerLifecycleFlags(upCmd *cobra.Command) { upCmd.Flags(). BoolVar(&cmd.SkipPostAttach, "skip-post-attach", false, "Skip running postAttachCommand") + upCmd.Flags(). + BoolVar(&cmd.SkipHostRequirements, "skip-host-requirements", false, + "Skip host requirements validation and allow container creation even if the host does not meet minimum requirements") } func (cmd *UpCmd) registerContainerOverrideFlags(upCmd *cobra.Command) { diff --git a/cmd/up/up_test.go b/cmd/up/up_test.go index 030203f70..f60f664ae 100644 --- a/cmd/up/up_test.go +++ b/cmd/up/up_test.go @@ -134,12 +134,20 @@ func TestUpCmd_SkipPostAttachFlag(t *testing.T) { assert.Equal(t, "false", flag.DefValue) } +func TestUpCmd_SkipHostRequirementsFlag(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + flag := upCmd.Flags().Lookup("skip-host-requirements") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) +} + func TestUpCmd_SkipFlagsParseValues(t *testing.T) { upCmd := NewUpCmd(&flags.GlobalFlags{}) err := upCmd.ParseFlags([]string{ "--skip-post-create", "--skip-post-start", "--skip-post-attach", + "--skip-host-requirements", }) require.NoError(t, err) @@ -154,6 +162,10 @@ func TestUpCmd_SkipFlagsParseValues(t *testing.T) { val, err = upCmd.Flags().GetBool("skip-post-attach") require.NoError(t, err) assert.True(t, val) + + val, err = upCmd.Flags().GetBool("skip-host-requirements") + require.NoError(t, err) + assert.True(t, val) } func TestUpCmd_ContainerUserFlag(t *testing.T) { diff --git a/e2e/tests/up-docker-compose/host_requirements.go b/e2e/tests/up-docker-compose/host_requirements.go index 33538e64d..b6837d34a 100644 --- a/e2e/tests/up-docker-compose/host_requirements.go +++ b/e2e/tests/up-docker-compose/host_requirements.go @@ -16,7 +16,7 @@ import ( ) var _ = ginkgo.Describe( - "testing up docker-compose command host requirements warnings", + "testing up docker-compose command host requirements enforcement", ginkgo.Label("up-docker-compose-host-requirements"), func() { var btc *baseTestContext @@ -33,7 +33,7 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) }) - ginkgo.It("surfaces hostRequirements warnings in JSON envelope", func(ctx context.Context) { + ginkgo.It("blocks when host requirements unmet", func(ctx context.Context) { tempDir, err := setupWorkspace( "tests/up-docker-compose/testdata/compose-host-requirements", btc.initialDir, @@ -42,6 +42,31 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) stdout, _, err := btc.f.DevsyUpStreams(ctx, tempDir) + gomega.Expect(err).To(gomega.HaveOccurred(), + "expected devsy up to fail when host requirements are not met") + + lines := strings.Split(strings.TrimSpace(stdout), "\n") + gomega.Expect(lines).NotTo(gomega.BeEmpty()) + lastLine := lines[len(lines)-1] + + var envelope config.ErrorEnvelope + err = json.Unmarshal([]byte(lastLine), &envelope) + framework.ExpectNoError(err) + gomega.Expect(envelope.Outcome).To(gomega.Equal("error")) + gomega.Expect(envelope.Message).To( + gomega.ContainSubstring("host does not meet minimum requirements"), + ) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("skip-host-requirements bypasses enforcement", func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up-docker-compose/testdata/compose-host-requirements", + btc.initialDir, + btc.f, + ) + framework.ExpectNoError(err) + + stdout, _, err := btc.f.DevsyUpStreams(ctx, tempDir, "--skip-host-requirements") framework.ExpectNoError(err) lines := strings.Split(strings.TrimSpace(stdout), "\n") @@ -53,17 +78,6 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) gomega.Expect(envelope.Outcome).To(gomega.Equal("success")) - gomega.Expect(envelope.Warnings).NotTo(gomega.BeEmpty()) - - found := false - for _, w := range envelope.Warnings { - if strings.Contains(w, "cpus:") { - found = true - break - } - } - gomega.Expect(found).To(gomega.BeTrue(), - "expected a cpus warning in %v", envelope.Warnings) }, ginkgo.SpecTimeout(framework.TimeoutShort())) }, ) diff --git a/e2e/tests/up/host_requirements.go b/e2e/tests/up/host_requirements.go index 2dad621b4..d2a6dc553 100644 --- a/e2e/tests/up/host_requirements.go +++ b/e2e/tests/up/host_requirements.go @@ -14,7 +14,7 @@ import ( ) var _ = ginkgo.Describe( - "testing up command host requirements warnings", + "testing up command host requirements enforcement", ginkgo.Label("up-host-requirements"), func() { var dtc *dockerTestContext @@ -31,7 +31,7 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) }) - ginkgo.It("surfaces hostRequirements warnings in JSON envelope", func(ctx context.Context) { + ginkgo.It("blocks when host requirements unmet", func(ctx context.Context) { tempDir, err := setupWorkspace( "tests/up/testdata/docker-host-requirements", dtc.initialDir, @@ -40,6 +40,33 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) stdout, _, err := dtc.f.DevsyUpStreams(ctx, tempDir) + gomega.Expect(err).To(gomega.HaveOccurred(), + "devsy up should fail when host requirements not met") + + lines := strings.Split(strings.TrimSpace(stdout), "\n") + gomega.Expect(lines).NotTo(gomega.BeEmpty()) + lastLine := lines[len(lines)-1] + + var envelope config.ErrorEnvelope + err = json.Unmarshal([]byte(lastLine), &envelope) + framework.ExpectNoError(err) + gomega.Expect(envelope.Outcome).To(gomega.Equal("error")) + gomega.Expect(envelope.Message).To( + gomega.ContainSubstring("minimum requirements"), + ) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("skip-host-requirements bypasses enforcement", func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-host-requirements", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + stdout, _, err := dtc.f.DevsyUpStreams( + ctx, tempDir, "--skip-host-requirements", + ) framework.ExpectNoError(err) lines := strings.Split(strings.TrimSpace(stdout), "\n") @@ -51,17 +78,6 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) gomega.Expect(envelope.Outcome).To(gomega.Equal("success")) - gomega.Expect(envelope.Warnings).NotTo(gomega.BeEmpty()) - - found := false - for _, w := range envelope.Warnings { - if strings.Contains(w, "cpus:") { - found = true - break - } - } - gomega.Expect(found).To(gomega.BeTrue(), - "expected a cpus warning in %v", envelope.Warnings) }, ginkgo.SpecTimeout(framework.TimeoutShort())) }, ) diff --git a/pkg/devcontainer/compose.go b/pkg/devcontainer/compose.go index d69bd3025..f8d7852b4 100644 --- a/pkg/devcontainer/compose.go +++ b/pkg/devcontainer/compose.go @@ -309,12 +309,17 @@ func (r *runner) runDockerCompose( composeAlias := project.Name mergedConfig.RemoteEnv["COMPOSE_PROJECT_NAME"] = &composeAlias - // validate host requirements (advisory only — warnings never block creation) - hostWarnings := config.ValidateHostRequirements( + hostWarnings, hostErr := config.ValidateHostRequirements( parsedConfig.Config.HostRequirements, config.SystemHostInfo{}, substitutionContext.LocalWorkspaceFolder, ) + if hostErr != nil { + if !options.SkipHostRequirements { + return nil, hostErr + } + hostWarnings = append(hostWarnings, hostErr.Error()) + } return r.setupContainer(ctx, &setupContainerParams{ rawConfig: parsedConfig.Raw, diff --git a/pkg/devcontainer/config/host_requirements.go b/pkg/devcontainer/config/host_requirements.go index 9722a0b79..c96c5cb83 100644 --- a/pkg/devcontainer/config/host_requirements.go +++ b/pkg/devcontainer/config/host_requirements.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "regexp" "strconv" @@ -15,61 +16,98 @@ type HostInfo interface { NumCPU() int TotalMemoryBytes() (uint64, error) AvailableStorageBytes(path string) (uint64, error) + GPUAvailable() bool } +// ErrHostRequirementsNotMet is returned when the host does not satisfy hard +// requirements (CPU count, memory, or GPU) declared in devcontainer.json. +var ErrHostRequirementsNotMet = errors.New("host does not meet minimum requirements") + // ValidateHostRequirements checks whether the host satisfies the resource -// requirements declared in devcontainer.json. Returns warning strings for -// each unmet requirement; an empty slice means all requirements are met. +// requirements declared in devcontainer.json. Hard failures (insufficient CPUs, +// memory, or required GPU unavailable) are collected into a non-nil error. +// Soft issues (detection errors, insufficient storage) are returned as warnings. func ValidateHostRequirements( reqs *HostRequirements, host HostInfo, workspacePath string, -) []string { +) (warnings []string, err error) { if reqs == nil { - return nil + return nil, nil } - var warnings []string - warnings = appendCPUWarning(warnings, reqs.CPUs, host) - warnings = appendMemoryWarning(warnings, reqs.Memory, host) + var hardFailures []string + hardFailures, warnings = appendCPUCheck(hardFailures, warnings, reqs.CPUs, host) + hardFailures, warnings = appendMemoryCheck(hardFailures, warnings, reqs.Memory, host) warnings = appendStorageWarning(warnings, reqs.Storage, host, workspacePath) + hardFailures = appendGPUCheck(hardFailures, reqs.GPU, host) for _, w := range warnings { log.Warnw("hostRequirements not met", "warning", w) } - return warnings + for _, f := range hardFailures { + log.Warnw("hostRequirements not met", "error", f) + } + + if len(hardFailures) > 0 { + return warnings, fmt.Errorf( + "%w: %s", ErrHostRequirementsNotMet, strings.Join(hardFailures, "; "), + ) + } + return warnings, nil } -func appendCPUWarning(warnings []string, required int, host HostInfo) []string { +func appendCPUCheck(failures, warnings []string, required int, host HostInfo) ([]string, []string) { if required <= 0 { - return warnings + return failures, warnings } available := host.NumCPU() if available < required { - return append(warnings, fmt.Sprintf( + failures = append(failures, fmt.Sprintf( "cpus: required %d, available %d", required, available, )) } - return warnings + return failures, warnings } -func appendMemoryWarning(warnings []string, required string, host HostInfo) []string { +func appendMemoryCheck( + failures, warnings []string, required string, host HostInfo, +) ([]string, []string) { if required == "" { - return warnings + return failures, warnings } reqBytes, err := ParseSizeToBytes(required) if err != nil { - return append(warnings, fmt.Sprintf("memory: invalid value %q: %v", required, err)) + warnings = append(warnings, fmt.Sprintf("memory: invalid value %q: %v", required, err)) + return failures, warnings } available, err := host.TotalMemoryBytes() if err != nil { - return append(warnings, fmt.Sprintf("memory: unable to detect: %v", err)) + warnings = append(warnings, fmt.Sprintf("memory: unable to detect: %v", err)) + return failures, warnings } if available < reqBytes { - return append(warnings, fmt.Sprintf( + failures = append(failures, fmt.Sprintf( "memory: required %s (%d bytes), available %d bytes", required, reqBytes, available, )) } - return warnings + return failures, warnings +} + +func appendGPUCheck(failures []string, gpu *GPURequirement, host HostInfo) []string { + if gpu == nil { + return failures + } + if gpu.Value == gpuOptional || gpu.Value == gpuFalse { + return failures + } + required, err := strconv.ParseBool(gpu.Value) + if err != nil || !required { + return failures + } + if !host.GPUAvailable() { + return append(failures, "gpu: required but not available on host") + } + return failures } func appendStorageWarning( diff --git a/pkg/devcontainer/config/host_requirements_system.go b/pkg/devcontainer/config/host_requirements_system.go index e0d29a13c..1ae15a925 100644 --- a/pkg/devcontainer/config/host_requirements_system.go +++ b/pkg/devcontainer/config/host_requirements_system.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "os" + "os/exec" "runtime" "strconv" "strings" @@ -33,6 +34,13 @@ func (s SystemHostInfo) AvailableStorageBytes(path string) (uint64, error) { return bytes, nil } +func (s SystemHostInfo) GPUAvailable() bool { + if err := exec.Command("nvidia-smi").Run(); err == nil { + return true + } + return false +} + func readMemTotalFromProc() (uint64, error) { f, err := os.Open("/proc/meminfo") if err != nil { diff --git a/pkg/devcontainer/config/host_requirements_test.go b/pkg/devcontainer/config/host_requirements_test.go index 514fa6afd..4e5662abe 100644 --- a/pkg/devcontainer/config/host_requirements_test.go +++ b/pkg/devcontainer/config/host_requirements_test.go @@ -1,18 +1,21 @@ package config import ( + "errors" "fmt" + "strings" "testing" ) const testWorkspacePath = "/workspace" type mockHostInfo struct { - cpus int - memory uint64 - memErr error - storage uint64 - storErr error + cpus int + memory uint64 + memErr error + storage uint64 + storErr error + gpuPresent bool } func (m mockHostInfo) NumCPU() int { @@ -27,8 +30,15 @@ func (m mockHostInfo) AvailableStorageBytes(_ string) (uint64, error) { return m.storage, m.storErr } +func (m mockHostInfo) GPUAvailable() bool { + return m.gpuPresent +} + func TestValidateHostRequirements_Nil(t *testing.T) { - warnings := ValidateHostRequirements(nil, mockHostInfo{}, "/tmp") + warnings, err := ValidateHostRequirements(nil, mockHostInfo{}, "/tmp") + if err != nil { + t.Errorf("expected no error for nil reqs, got %v", err) + } if len(warnings) != 0 { t.Errorf("expected no warnings for nil reqs, got %v", warnings) } @@ -45,7 +55,10 @@ func TestValidateHostRequirements_AllMet(t *testing.T) { memory: 8 * 1024 * 1024 * 1024, storage: 50 * 1024 * 1024 * 1024, } - warnings := ValidateHostRequirements(reqs, host, testWorkspacePath) + warnings, err := ValidateHostRequirements(reqs, host, testWorkspacePath) + if err != nil { + t.Errorf("expected no error, got %v", err) + } if len(warnings) != 0 { t.Errorf("expected no warnings, got %v", warnings) } @@ -54,12 +67,15 @@ func TestValidateHostRequirements_AllMet(t *testing.T) { func TestValidateHostRequirements_CPUsInsufficient(t *testing.T) { reqs := &HostRequirements{CPUs: 8} host := mockHostInfo{cpus: 4} - warnings := ValidateHostRequirements(reqs, host, testWorkspacePath) - if len(warnings) != 1 { - t.Fatalf("expected 1 warning, got %d: %v", len(warnings), warnings) + warnings, err := ValidateHostRequirements(reqs, host, testWorkspacePath) + if err == nil { + t.Fatal("expected error for insufficient CPUs, got nil") + } + if !errors.Is(err, ErrHostRequirementsNotMet) { + t.Errorf("expected ErrHostRequirementsNotMet, got %v", err) } - if warnings[0] != "cpus: required 8, available 4" { - t.Errorf("unexpected warning: %s", warnings[0]) + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) } } @@ -69,16 +85,19 @@ func TestValidateHostRequirements_MemoryInsufficient(t *testing.T) { cpus: 8, memory: 8 * 1024 * 1024 * 1024, } - warnings := ValidateHostRequirements(reqs, host, testWorkspacePath) - if len(warnings) != 1 { - t.Fatalf("expected 1 warning, got %d: %v", len(warnings), warnings) + _, err := ValidateHostRequirements(reqs, host, testWorkspacePath) + if err == nil { + t.Fatal("expected error for insufficient memory, got nil") + } + if !errors.Is(err, ErrHostRequirementsNotMet) { + t.Errorf("expected ErrHostRequirementsNotMet, got %v", err) } expected := fmt.Sprintf( "memory: required 24gb (%d bytes), available %d bytes", uint64(24)*1024*1024*1024, uint64(8)*1024*1024*1024, ) - if warnings[0] != expected { - t.Errorf("got %q, want %q", warnings[0], expected) + if errMsg := err.Error(); !strings.Contains(errMsg, expected) { + t.Errorf("error message %q should contain %q", errMsg, expected) } } @@ -89,7 +108,10 @@ func TestValidateHostRequirements_StorageInsufficient(t *testing.T) { memory: 32 * 1024 * 1024 * 1024, storage: 20 * 1024 * 1024 * 1024, } - warnings := ValidateHostRequirements(reqs, host, testWorkspacePath) + warnings, err := ValidateHostRequirements(reqs, host, testWorkspacePath) + if err != nil { + t.Errorf("expected no error for storage (soft warning), got %v", err) + } if len(warnings) != 1 { t.Fatalf("expected 1 warning, got %d: %v", len(warnings), warnings) } @@ -105,7 +127,10 @@ func TestValidateHostRequirements_StorageInsufficient(t *testing.T) { func TestValidateHostRequirements_PartialOnlyCPUs(t *testing.T) { reqs := &HostRequirements{CPUs: 2} host := mockHostInfo{cpus: 4, memory: 0, storage: 0} - warnings := ValidateHostRequirements(reqs, host, testWorkspacePath) + warnings, err := ValidateHostRequirements(reqs, host, testWorkspacePath) + if err != nil { + t.Errorf("expected no error, got %v", err) + } if len(warnings) != 0 { t.Errorf("expected no warnings for partial (cpus met), got %v", warnings) } @@ -118,12 +143,65 @@ func TestValidateHostRequirements_DetectionErrors(t *testing.T) { memErr: fmt.Errorf("no /proc/meminfo"), storErr: fmt.Errorf("permission denied"), } - warnings := ValidateHostRequirements(reqs, host, testWorkspacePath) + warnings, err := ValidateHostRequirements(reqs, host, testWorkspacePath) + if err != nil { + t.Errorf("detection errors should be soft warnings, got error: %v", err) + } if len(warnings) != 2 { t.Fatalf("expected 2 warnings, got %d: %v", len(warnings), warnings) } } +func TestValidateHostRequirements_GPU(t *testing.T) { + gpu := func(val string) *GPURequirement { + return &GPURequirement{Value: val} + } + + tests := []struct { + name string + reqs *HostRequirements + gpu bool + wantErr bool + }{ + {"required available", &HostRequirements{GPU: gpu(gpuTrue)}, true, false}, + {"required unavailable", &HostRequirements{GPU: gpu(gpuTrue)}, false, true}, + {"optional unavailable", &HostRequirements{GPU: gpu(gpuOptional)}, false, false}, + {"false unavailable", &HostRequirements{GPU: gpu(gpuFalse)}, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host := mockHostInfo{cpus: 4, gpuPresent: tt.gpu} + _, err := ValidateHostRequirements(tt.reqs, host, testWorkspacePath) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, ErrHostRequirementsNotMet) { + t.Errorf("expected ErrHostRequirementsNotMet, got %v", err) + } + } else if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + } +} + +func TestValidateHostRequirements_MultipleHardFailures(t *testing.T) { + reqs := &HostRequirements{ + CPUs: 16, + Memory: "256gb", + GPU: &GPURequirement{Value: gpuTrue}, + } + host := mockHostInfo{cpus: 4, memory: 8 * 1024 * 1024 * 1024, gpuPresent: false} + _, err := ValidateHostRequirements(reqs, host, testWorkspacePath) + if err == nil { + t.Fatal("expected error for multiple failures") + } + if !errors.Is(err, ErrHostRequirementsNotMet) { + t.Errorf("expected ErrHostRequirementsNotMet, got %v", err) + } +} + func TestParseSizeToBytes(t *testing.T) { tests := []struct { input string diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index a4adf329d..47f5e2649 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -224,11 +224,17 @@ func (r *runner) resolveNewContainer( ctx context.Context, p *resolveParams, ) (*resolvedContainer, error) { - hostWarnings := config.ValidateHostRequirements( + hostWarnings, hostErr := config.ValidateHostRequirements( p.parsedConfig.Config.HostRequirements, config.SystemHostInfo{}, p.substitutionContext.LocalWorkspaceFolder, ) + if hostErr != nil { + if !p.options.SkipHostRequirements { + return nil, hostErr + } + hostWarnings = append(hostWarnings, hostErr.Error()) + } buildInfo, err := r.build(ctx, p.parsedConfig, p.substitutionContext, provider2.BuildOptions{ CLIOptions: provider2.CLIOptions{ diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index a6f93e5c1..7b413f3c4 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -250,9 +250,10 @@ type CLIOptions struct { RemoteUser string `json:"remoteUser,omitempty"` // skip lifecycle hook options - SkipPostCreate bool `json:"skipPostCreate,omitempty"` - SkipPostStart bool `json:"skipPostStart,omitempty"` - SkipPostAttach bool `json:"skipPostAttach,omitempty"` + SkipPostCreate bool `json:"skipPostCreate,omitempty"` + SkipPostStart bool `json:"skipPostStart,omitempty"` + SkipPostAttach bool `json:"skipPostAttach,omitempty"` + SkipHostRequirements bool `json:"skipHostRequirements,omitempty"` // dotfiles options DotfilesRepo string `json:"dotfilesRepo,omitempty"` From 630ab466550647edc4095123cfb47914e46e72a8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 16:41:39 -0500 Subject: [PATCH 2/3] fix(hostRequirements): make GPU a soft warning instead of hard block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove nvidia-smi GPU detection from HostInfo interface — it falsely blocks AMD/ROCm, Intel oneAPI, and Apple Silicon GPU users. GPU availability is already verified at container creation time via the Docker/Podman runtime (pkg/docker/runtime.go), so the pre-flight check now emits an informational warning instead of failing. --- pkg/devcontainer/config/host_requirements.go | 18 +++--- .../config/host_requirements_system.go | 8 --- .../config/host_requirements_test.go | 57 +++++++++---------- 3 files changed, 35 insertions(+), 48 deletions(-) diff --git a/pkg/devcontainer/config/host_requirements.go b/pkg/devcontainer/config/host_requirements.go index c96c5cb83..fcc0735f1 100644 --- a/pkg/devcontainer/config/host_requirements.go +++ b/pkg/devcontainer/config/host_requirements.go @@ -16,7 +16,6 @@ type HostInfo interface { NumCPU() int TotalMemoryBytes() (uint64, error) AvailableStorageBytes(path string) (uint64, error) - GPUAvailable() bool } // ErrHostRequirementsNotMet is returned when the host does not satisfy hard @@ -38,7 +37,7 @@ func ValidateHostRequirements( hardFailures, warnings = appendCPUCheck(hardFailures, warnings, reqs.CPUs, host) hardFailures, warnings = appendMemoryCheck(hardFailures, warnings, reqs.Memory, host) warnings = appendStorageWarning(warnings, reqs.Storage, host, workspacePath) - hardFailures = appendGPUCheck(hardFailures, reqs.GPU, host) + warnings = appendGPUWarning(warnings, reqs.GPU) for _, w := range warnings { log.Warnw("hostRequirements not met", "warning", w) @@ -93,21 +92,20 @@ func appendMemoryCheck( return failures, warnings } -func appendGPUCheck(failures []string, gpu *GPURequirement, host HostInfo) []string { +func appendGPUWarning(warnings []string, gpu *GPURequirement) []string { if gpu == nil { - return failures + return warnings } if gpu.Value == gpuOptional || gpu.Value == gpuFalse { - return failures + return warnings } required, err := strconv.ParseBool(gpu.Value) if err != nil || !required { - return failures - } - if !host.GPUAvailable() { - return append(failures, "gpu: required but not available on host") + return warnings } - return failures + return append(warnings, + "gpu: required — availability will be verified at container creation", + ) } func appendStorageWarning( diff --git a/pkg/devcontainer/config/host_requirements_system.go b/pkg/devcontainer/config/host_requirements_system.go index 1ae15a925..e0d29a13c 100644 --- a/pkg/devcontainer/config/host_requirements_system.go +++ b/pkg/devcontainer/config/host_requirements_system.go @@ -4,7 +4,6 @@ import ( "bufio" "fmt" "os" - "os/exec" "runtime" "strconv" "strings" @@ -34,13 +33,6 @@ func (s SystemHostInfo) AvailableStorageBytes(path string) (uint64, error) { return bytes, nil } -func (s SystemHostInfo) GPUAvailable() bool { - if err := exec.Command("nvidia-smi").Run(); err == nil { - return true - } - return false -} - func readMemTotalFromProc() (uint64, error) { f, err := os.Open("/proc/meminfo") if err != nil { diff --git a/pkg/devcontainer/config/host_requirements_test.go b/pkg/devcontainer/config/host_requirements_test.go index 4e5662abe..bb1304c26 100644 --- a/pkg/devcontainer/config/host_requirements_test.go +++ b/pkg/devcontainer/config/host_requirements_test.go @@ -10,12 +10,11 @@ import ( const testWorkspacePath = "/workspace" type mockHostInfo struct { - cpus int - memory uint64 - memErr error - storage uint64 - storErr error - gpuPresent bool + cpus int + memory uint64 + memErr error + storage uint64 + storErr error } func (m mockHostInfo) NumCPU() int { @@ -30,10 +29,6 @@ func (m mockHostInfo) AvailableStorageBytes(_ string) (uint64, error) { return m.storage, m.storErr } -func (m mockHostInfo) GPUAvailable() bool { - return m.gpuPresent -} - func TestValidateHostRequirements_Nil(t *testing.T) { warnings, err := ValidateHostRequirements(nil, mockHostInfo{}, "/tmp") if err != nil { @@ -158,29 +153,32 @@ func TestValidateHostRequirements_GPU(t *testing.T) { } tests := []struct { - name string - reqs *HostRequirements - gpu bool - wantErr bool + name string + reqs *HostRequirements + wantWarning bool }{ - {"required available", &HostRequirements{GPU: gpu(gpuTrue)}, true, false}, - {"required unavailable", &HostRequirements{GPU: gpu(gpuTrue)}, false, true}, - {"optional unavailable", &HostRequirements{GPU: gpu(gpuOptional)}, false, false}, - {"false unavailable", &HostRequirements{GPU: gpu(gpuFalse)}, false, false}, + {"required emits warning", &HostRequirements{GPU: gpu(gpuTrue)}, true}, + {"optional no warning", &HostRequirements{GPU: gpu(gpuOptional)}, false}, + {"false no warning", &HostRequirements{GPU: gpu(gpuFalse)}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - host := mockHostInfo{cpus: 4, gpuPresent: tt.gpu} - _, err := ValidateHostRequirements(tt.reqs, host, testWorkspacePath) - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - if !errors.Is(err, ErrHostRequirementsNotMet) { - t.Errorf("expected ErrHostRequirementsNotMet, got %v", err) + host := mockHostInfo{cpus: 4} + warnings, err := ValidateHostRequirements(tt.reqs, host, testWorkspacePath) + if err != nil { + t.Errorf("GPU should never hard-fail, got %v", err) + } + hasGPUWarning := false + for _, w := range warnings { + if strings.Contains(w, "gpu:") { + hasGPUWarning = true } - } else if err != nil { - t.Errorf("expected no error, got %v", err) + } + if tt.wantWarning && !hasGPUWarning { + t.Error("expected GPU warning, got none") + } + if !tt.wantWarning && hasGPUWarning { + t.Error("unexpected GPU warning") } }) } @@ -190,9 +188,8 @@ func TestValidateHostRequirements_MultipleHardFailures(t *testing.T) { reqs := &HostRequirements{ CPUs: 16, Memory: "256gb", - GPU: &GPURequirement{Value: gpuTrue}, } - host := mockHostInfo{cpus: 4, memory: 8 * 1024 * 1024 * 1024, gpuPresent: false} + host := mockHostInfo{cpus: 4, memory: 8 * 1024 * 1024 * 1024} _, err := ValidateHostRequirements(reqs, host, testWorkspacePath) if err == nil { t.Fatal("expected error for multiple failures") From 66833850e1d68e6caf5f39191e4c81372e5a1979 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 16:52:38 -0500 Subject: [PATCH 3/3] fix(hostRequirements): reduce GPU test cyclomatic complexity Simplify the test loop body to stay under the cyclop threshold of 8. --- .../config/host_requirements_test.go | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/pkg/devcontainer/config/host_requirements_test.go b/pkg/devcontainer/config/host_requirements_test.go index bb1304c26..6cd0d4615 100644 --- a/pkg/devcontainer/config/host_requirements_test.go +++ b/pkg/devcontainer/config/host_requirements_test.go @@ -166,19 +166,11 @@ func TestValidateHostRequirements_GPU(t *testing.T) { host := mockHostInfo{cpus: 4} warnings, err := ValidateHostRequirements(tt.reqs, host, testWorkspacePath) if err != nil { - t.Errorf("GPU should never hard-fail, got %v", err) + t.Fatalf("GPU should never hard-fail, got %v", err) } - hasGPUWarning := false - for _, w := range warnings { - if strings.Contains(w, "gpu:") { - hasGPUWarning = true - } - } - if tt.wantWarning && !hasGPUWarning { - t.Error("expected GPU warning, got none") - } - if !tt.wantWarning && hasGPUWarning { - t.Error("unexpected GPU warning") + got := strings.Contains(strings.Join(warnings, " "), "gpu:") + if got != tt.wantWarning { + t.Errorf("gpu warning present=%v, want %v", got, tt.wantWarning) } }) }