From d03f8140ea5b904edc8b6602b792efa36dfbc322 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 3 May 2026 04:38:03 -0500 Subject: [PATCH 1/2] feat(config): add hostRequirements pre-flight validation Validate cpus, memory, and storage requirements from devcontainer.json against the host system before container creation. Uses an injectable HostInfo interface for testability. Warnings are logged but do not block container startup, matching the advisory nature of the spec. --- .../readconfiguration/readconfiguration.go | 28 ++++ .../.devcontainer/devcontainer.json | 9 + pkg/devcontainer/config/host_requirements.go | 124 ++++++++++++++ .../config/host_requirements_system.go | 61 +++++++ .../config/host_requirements_test.go | 155 ++++++++++++++++++ pkg/devcontainer/single.go | 6 + 6 files changed, 383 insertions(+) create mode 100644 e2e/tests/readconfiguration/testdata-host-requirements/.devcontainer/devcontainer.json create mode 100644 pkg/devcontainer/config/host_requirements.go create mode 100644 pkg/devcontainer/config/host_requirements_system.go create mode 100644 pkg/devcontainer/config/host_requirements_test.go diff --git a/e2e/tests/readconfiguration/readconfiguration.go b/e2e/tests/readconfiguration/readconfiguration.go index 1f2fd4a09..97bf0f1cf 100644 --- a/e2e/tests/readconfiguration/readconfiguration.go +++ b/e2e/tests/readconfiguration/readconfiguration.go @@ -218,4 +218,32 @@ var _ = ginkgo.Describe("read-configuration command", ginkgo.Label("read-configu gomega.Expect(opa["onAutoForward"]).To(gomega.Equal("ignore")) gomega.Expect(opa["label"]).To(gomega.Equal("default")) }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("preserves hostRequirements in configuration output", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDirWithoutChdir( + "tests/readconfiguration/testdata-host-requirements", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "read-configuration", + "--workspace-folder", tempDir, + }) + framework.ExpectNoError(err) + + var result map[string]any + err = json.Unmarshal([]byte(stdout), &result) + framework.ExpectNoError(err, "output should be valid JSON") + + config, ok := result["configuration"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), "configuration should be an object") + + hr, ok := config["hostRequirements"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), "hostRequirements should be an object") + gomega.Expect(hr["cpus"]).To(gomega.BeEquivalentTo(4)) + gomega.Expect(hr["memory"]).To(gomega.Equal("8gb")) + gomega.Expect(hr["storage"]).To(gomega.Equal("32gb")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) }) diff --git a/e2e/tests/readconfiguration/testdata-host-requirements/.devcontainer/devcontainer.json b/e2e/tests/readconfiguration/testdata-host-requirements/.devcontainer/devcontainer.json new file mode 100644 index 000000000..7ed7e3ec5 --- /dev/null +++ b/e2e/tests/readconfiguration/testdata-host-requirements/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "name": "Host Requirements Test", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "hostRequirements": { + "cpus": 4, + "memory": "8gb", + "storage": "32gb" + } +} diff --git a/pkg/devcontainer/config/host_requirements.go b/pkg/devcontainer/config/host_requirements.go new file mode 100644 index 000000000..9722a0b79 --- /dev/null +++ b/pkg/devcontainer/config/host_requirements.go @@ -0,0 +1,124 @@ +package config + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/devsy-org/devsy/pkg/log" +) + +// HostInfo provides system resource information for validation. +// Abstracted as an interface to allow testing with mock values. +type HostInfo interface { + NumCPU() int + TotalMemoryBytes() (uint64, error) + AvailableStorageBytes(path string) (uint64, error) +} + +// 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. +func ValidateHostRequirements( + reqs *HostRequirements, host HostInfo, workspacePath string, +) []string { + if reqs == nil { + return nil + } + + var warnings []string + warnings = appendCPUWarning(warnings, reqs.CPUs, host) + warnings = appendMemoryWarning(warnings, reqs.Memory, host) + warnings = appendStorageWarning(warnings, reqs.Storage, host, workspacePath) + + for _, w := range warnings { + log.Warnw("hostRequirements not met", "warning", w) + } + return warnings +} + +func appendCPUWarning(warnings []string, required int, host HostInfo) []string { + if required <= 0 { + return warnings + } + available := host.NumCPU() + if available < required { + return append(warnings, fmt.Sprintf( + "cpus: required %d, available %d", required, available, + )) + } + return warnings +} + +func appendMemoryWarning(warnings []string, required string, host HostInfo) []string { + if required == "" { + return warnings + } + reqBytes, err := ParseSizeToBytes(required) + if err != nil { + return append(warnings, fmt.Sprintf("memory: invalid value %q: %v", required, err)) + } + available, err := host.TotalMemoryBytes() + if err != nil { + return append(warnings, fmt.Sprintf("memory: unable to detect: %v", err)) + } + if available < reqBytes { + return append(warnings, fmt.Sprintf( + "memory: required %s (%d bytes), available %d bytes", + required, reqBytes, available, + )) + } + return warnings +} + +func appendStorageWarning( + warnings []string, required string, host HostInfo, path string, +) []string { + if required == "" { + return warnings + } + reqBytes, err := ParseSizeToBytes(required) + if err != nil { + return append(warnings, fmt.Sprintf("storage: invalid value %q: %v", required, err)) + } + available, err := host.AvailableStorageBytes(path) + if err != nil { + return append(warnings, fmt.Sprintf("storage: unable to detect at %q: %v", path, err)) + } + if available < reqBytes { + return append(warnings, fmt.Sprintf( + "storage: required %s (%d bytes), available %d bytes at %q", + required, reqBytes, available, path, + )) + } + return warnings +} + +var sizePattern = regexp.MustCompile(`(?i)^\s*(\d+)\s*(tb|gb|mb|kb)?\s*$`) + +// ParseSizeToBytes converts a human-readable size string (e.g. "8gb", "512mb") +// to bytes. If no unit suffix is present, the value is treated as bytes. +func ParseSizeToBytes(s string) (uint64, error) { + matches := sizePattern.FindStringSubmatch(s) + if matches == nil { + return 0, fmt.Errorf("unrecognized size format: %q", s) + } + val, err := strconv.ParseUint(matches[1], 10, 64) + if err != nil { + return 0, err + } + unit := strings.ToLower(matches[2]) + switch unit { + case "tb": + return val * 1024 * 1024 * 1024 * 1024, nil + case "gb": + return val * 1024 * 1024 * 1024, nil + case "mb": + return val * 1024 * 1024, nil + case "kb": + return val * 1024, nil + default: + return val, nil + } +} diff --git a/pkg/devcontainer/config/host_requirements_system.go b/pkg/devcontainer/config/host_requirements_system.go new file mode 100644 index 000000000..35d3abb0e --- /dev/null +++ b/pkg/devcontainer/config/host_requirements_system.go @@ -0,0 +1,61 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "runtime" + "strconv" + "strings" + "syscall" +) + +// SystemHostInfo provides real system resource information. +type SystemHostInfo struct { + WorkspacePath string +} + +func (s SystemHostInfo) NumCPU() int { + return runtime.NumCPU() +} + +func (s SystemHostInfo) TotalMemoryBytes() (uint64, error) { + return readMemTotalFromProc() +} + +func (s SystemHostInfo) AvailableStorageBytes(path string) (uint64, error) { + if path == "" { + path = "/" + } + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return 0, fmt.Errorf("statfs %q: %w", path, err) + } + return stat.Bavail * uint64(stat.Bsize), nil //nolint:gosec // Bsize type varies by platform +} + +func readMemTotalFromProc() (uint64, error) { + f, err := os.Open("/proc/meminfo") + if err != nil { + return 0, fmt.Errorf("open /proc/meminfo: %w", err) + } + defer f.Close() //nolint:errcheck // best-effort close on read-only file + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "MemTotal:") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + return 0, fmt.Errorf("unexpected MemTotal format: %q", line) + } + kb, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return 0, fmt.Errorf("parse MemTotal: %w", err) + } + return kb * 1024, nil + } + return 0, fmt.Errorf("MemTotal not found in /proc/meminfo") +} diff --git a/pkg/devcontainer/config/host_requirements_test.go b/pkg/devcontainer/config/host_requirements_test.go new file mode 100644 index 000000000..514fa6afd --- /dev/null +++ b/pkg/devcontainer/config/host_requirements_test.go @@ -0,0 +1,155 @@ +package config + +import ( + "fmt" + "testing" +) + +const testWorkspacePath = "/workspace" + +type mockHostInfo struct { + cpus int + memory uint64 + memErr error + storage uint64 + storErr error +} + +func (m mockHostInfo) NumCPU() int { + return m.cpus +} + +func (m mockHostInfo) TotalMemoryBytes() (uint64, error) { + return m.memory, m.memErr +} + +func (m mockHostInfo) AvailableStorageBytes(_ string) (uint64, error) { + return m.storage, m.storErr +} + +func TestValidateHostRequirements_Nil(t *testing.T) { + warnings := ValidateHostRequirements(nil, mockHostInfo{}, "/tmp") + if len(warnings) != 0 { + t.Errorf("expected no warnings for nil reqs, got %v", warnings) + } +} + +func TestValidateHostRequirements_AllMet(t *testing.T) { + reqs := &HostRequirements{ + CPUs: 2, + Memory: "3gb", + Storage: "10gb", + } + host := mockHostInfo{ + cpus: 4, + memory: 8 * 1024 * 1024 * 1024, + storage: 50 * 1024 * 1024 * 1024, + } + warnings := ValidateHostRequirements(reqs, host, testWorkspacePath) + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } +} + +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) + } + if warnings[0] != "cpus: required 8, available 4" { + t.Errorf("unexpected warning: %s", warnings[0]) + } +} + +func TestValidateHostRequirements_MemoryInsufficient(t *testing.T) { + reqs := &HostRequirements{Memory: "24gb"} + host := mockHostInfo{ + 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) + } + 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) + } +} + +func TestValidateHostRequirements_StorageInsufficient(t *testing.T) { + reqs := &HostRequirements{Storage: "200gb"} + host := mockHostInfo{ + cpus: 8, + memory: 32 * 1024 * 1024 * 1024, + storage: 20 * 1024 * 1024 * 1024, + } + warnings := ValidateHostRequirements(reqs, host, testWorkspacePath) + if len(warnings) != 1 { + t.Fatalf("expected 1 warning, got %d: %v", len(warnings), warnings) + } + expected := fmt.Sprintf( + "storage: required 200gb (%d bytes), available %d bytes at %q", + uint64(200)*1024*1024*1024, uint64(20)*1024*1024*1024, testWorkspacePath, + ) + if warnings[0] != expected { + t.Errorf("got %q, want %q", warnings[0], expected) + } +} + +func TestValidateHostRequirements_PartialOnlyCPUs(t *testing.T) { + reqs := &HostRequirements{CPUs: 2} + host := mockHostInfo{cpus: 4, memory: 0, storage: 0} + warnings := ValidateHostRequirements(reqs, host, testWorkspacePath) + if len(warnings) != 0 { + t.Errorf("expected no warnings for partial (cpus met), got %v", warnings) + } +} + +func TestValidateHostRequirements_DetectionErrors(t *testing.T) { + reqs := &HostRequirements{Memory: "6gb", Storage: "50gb"} + host := mockHostInfo{ + cpus: 4, + memErr: fmt.Errorf("no /proc/meminfo"), + storErr: fmt.Errorf("permission denied"), + } + warnings := ValidateHostRequirements(reqs, host, testWorkspacePath) + if len(warnings) != 2 { + t.Fatalf("expected 2 warnings, got %d: %v", len(warnings), warnings) + } +} + +func TestParseSizeToBytes(t *testing.T) { + tests := []struct { + input string + want uint64 + wantErr bool + }{ + {"7gb", 7 * 1024 * 1024 * 1024, false}, + {"768mb", 768 * 1024 * 1024, false}, + {"2tb", 2 * 1024 * 1024 * 1024 * 1024, false}, + {"128kb", 128 * 1024, false}, + {"7GB", 7 * 1024 * 1024 * 1024, false}, + {"5 gb", 5 * 1024 * 1024 * 1024, false}, + {"2048", 2048, false}, + {"", 0, true}, + {"abc", 0, true}, + {"gb", 0, true}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := ParseSizeToBytes(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseSizeToBytes(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("ParseSizeToBytes(%q) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index 0f41badbc..91b1856ec 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -220,6 +220,12 @@ func (r *runner) resolveNewContainer( ctx context.Context, p *resolveParams, ) (*resolvedContainer, error) { + config.ValidateHostRequirements( + p.parsedConfig.Config.HostRequirements, + config.SystemHostInfo{}, + p.substitutionContext.LocalWorkspaceFolder, + ) + buildInfo, err := r.build(ctx, p.parsedConfig, p.substitutionContext, provider2.BuildOptions{ CLIOptions: provider2.CLIOptions{ PrebuildRepositories: p.options.PrebuildRepositories, From 11e7dbf3f01972c1deab0924b312c84362c25396 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 3 May 2026 06:30:04 -0500 Subject: [PATCH 2/2] fix(config): use build tags for platform-specific storage detection Split syscall.Statfs usage into platform-specific files so the package compiles on Windows where syscall.Statfs_t is undefined. --- .../config/host_requirements_storage_unix.go | 13 +++++++++++++ .../config/host_requirements_storage_windows.go | 9 +++++++++ pkg/devcontainer/config/host_requirements_system.go | 7 +++---- 3 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 pkg/devcontainer/config/host_requirements_storage_unix.go create mode 100644 pkg/devcontainer/config/host_requirements_storage_windows.go diff --git a/pkg/devcontainer/config/host_requirements_storage_unix.go b/pkg/devcontainer/config/host_requirements_storage_unix.go new file mode 100644 index 000000000..ee8a23f3a --- /dev/null +++ b/pkg/devcontainer/config/host_requirements_storage_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package config + +import "syscall" + +func availableStorageBytes(path string) (uint64, error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return 0, err + } + return stat.Bavail * uint64(stat.Bsize), nil //nolint:gosec // Bsize type varies by platform +} diff --git a/pkg/devcontainer/config/host_requirements_storage_windows.go b/pkg/devcontainer/config/host_requirements_storage_windows.go new file mode 100644 index 000000000..0ef9e32c6 --- /dev/null +++ b/pkg/devcontainer/config/host_requirements_storage_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package config + +import "fmt" + +func availableStorageBytes(path string) (uint64, error) { + return 0, fmt.Errorf("storage detection not supported on windows") +} diff --git a/pkg/devcontainer/config/host_requirements_system.go b/pkg/devcontainer/config/host_requirements_system.go index 35d3abb0e..e0d29a13c 100644 --- a/pkg/devcontainer/config/host_requirements_system.go +++ b/pkg/devcontainer/config/host_requirements_system.go @@ -7,7 +7,6 @@ import ( "runtime" "strconv" "strings" - "syscall" ) // SystemHostInfo provides real system resource information. @@ -27,11 +26,11 @@ func (s SystemHostInfo) AvailableStorageBytes(path string) (uint64, error) { if path == "" { path = "/" } - var stat syscall.Statfs_t - if err := syscall.Statfs(path, &stat); err != nil { + bytes, err := availableStorageBytes(path) + if err != nil { return 0, fmt.Errorf("statfs %q: %w", path, err) } - return stat.Bavail * uint64(stat.Bsize), nil //nolint:gosec // Bsize type varies by platform + return bytes, nil } func readMemTotalFromProc() (uint64, error) {