Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/up/up_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions cmd/up/up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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) {
Expand Down
40 changes: 27 additions & 13 deletions e2e/tests/up-docker-compose/host_requirements.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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")
Expand All @@ -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()))
},
)
42 changes: 29 additions & 13 deletions e2e/tests/up/host_requirements.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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")
Expand All @@ -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()))
},
)
9 changes: 7 additions & 2 deletions pkg/devcontainer/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
72 changes: 54 additions & 18 deletions pkg/devcontainer/config/host_requirements.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"errors"
"fmt"
"regexp"
"strconv"
Expand All @@ -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(
Expand Down
Loading
Loading