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_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 new file mode 100644 index 000000000..e0d29a13c --- /dev/null +++ b/pkg/devcontainer/config/host_requirements_system.go @@ -0,0 +1,60 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "runtime" + "strconv" + "strings" +) + +// 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 = "/" + } + bytes, err := availableStorageBytes(path) + if err != nil { + return 0, fmt.Errorf("statfs %q: %w", path, err) + } + return bytes, nil +} + +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,