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..fcc0735f1 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" @@ -17,59 +18,94 @@ type HostInfo interface { AvailableStorageBytes(path string) (uint64, error) } +// 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) + warnings = appendGPUWarning(warnings, reqs.GPU) 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 appendGPUWarning(warnings []string, gpu *GPURequirement) []string { + if gpu == nil { + return warnings + } + if gpu.Value == gpuOptional || gpu.Value == gpuFalse { + return warnings + } + required, err := strconv.ParseBool(gpu.Value) + if err != nil || !required { + return warnings + } + return append(warnings, + "gpu: required — availability will be verified at container creation", + ) } func appendStorageWarning( diff --git a/pkg/devcontainer/config/host_requirements_test.go b/pkg/devcontainer/config/host_requirements_test.go index 514fa6afd..6cd0d4615 100644 --- a/pkg/devcontainer/config/host_requirements_test.go +++ b/pkg/devcontainer/config/host_requirements_test.go @@ -1,7 +1,9 @@ package config import ( + "errors" "fmt" + "strings" "testing" ) @@ -28,7 +30,10 @@ func (m mockHostInfo) AvailableStorageBytes(_ string) (uint64, error) { } 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 +50,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 +62,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 warnings[0] != "cpus: required 8, available 4" { - t.Errorf("unexpected warning: %s", warnings[0]) + if !errors.Is(err, ErrHostRequirementsNotMet) { + t.Errorf("expected ErrHostRequirementsNotMet, got %v", err) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) } } @@ -69,16 +80,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 +103,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 +122,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 +138,59 @@ 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 + wantWarning bool + }{ + {"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} + warnings, err := ValidateHostRequirements(tt.reqs, host, testWorkspacePath) + if err != nil { + t.Fatalf("GPU should never hard-fail, got %v", err) + } + got := strings.Contains(strings.Join(warnings, " "), "gpu:") + if got != tt.wantWarning { + t.Errorf("gpu warning present=%v, want %v", got, tt.wantWarning) + } + }) + } +} + +func TestValidateHostRequirements_MultipleHardFailures(t *testing.T) { + reqs := &HostRequirements{ + CPUs: 16, + Memory: "256gb", + } + host := mockHostInfo{cpus: 4, memory: 8 * 1024 * 1024 * 1024} + _, 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"`