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
28 changes: 28 additions & 0 deletions e2e/tests/readconfiguration/readconfiguration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "Host Requirements Test",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"hostRequirements": {
"cpus": 4,
"memory": "8gb",
"storage": "32gb"
}
}
124 changes: 124 additions & 0 deletions pkg/devcontainer/config/host_requirements.go
Original file line number Diff line number Diff line change
@@ -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
}
}
13 changes: 13 additions & 0 deletions pkg/devcontainer/config/host_requirements_storage_unix.go
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions pkg/devcontainer/config/host_requirements_storage_windows.go
Original file line number Diff line number Diff line change
@@ -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")
}
60 changes: 60 additions & 0 deletions pkg/devcontainer/config/host_requirements_system.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +25 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for existing build constraints in the config package
rg -n '//go:build' pkg/devcontainer/config/

# Check for any existing Windows-specific stub files
fd -e go . pkg/devcontainer/config/ --exec grep -l 'windows' {}

# Check if there are any CI matrix entries targeting Windows
rg -r 'windows' .github/ 2>/dev/null || true

Repository: devsy-org/devsy

Length of output: 1727


🏁 Script executed:

#!/bin/bash
# Check the actual file content - does it have a build constraint?
head -5 pkg/devcontainer/config/host_requirements_system.go

# Check if there's a Windows stub file for host requirements
fd -e go . pkg/devcontainer/config/ -exec grep -l 'AvailableStorageBytes\|TotalMemoryBytes' {} \;

# Check if Windows is actually a build target
rg -n '//go:build' pkg/devcontainer/config/host_requirements_system.go

# Look for windows stub variant
ls -la pkg/devcontainer/config/ | grep -i host

Repository: devsy-org/devsy

Length of output: 2627


🏁 Script executed:

#!/bin/bash
# Read the complete file to check for build constraints and syscalls
cat -n pkg/devcontainer/config/host_requirements_system.go

# Check if host_requirements.go (parent file) has constraint
head -5 pkg/devcontainer/config/host_requirements.go

# Search for TotalMemoryBytes implementation to find /proc/meminfo
rg -n 'TotalMemoryBytes' pkg/devcontainer/config/

Repository: devsy-org/devsy

Length of output: 2351


🏁 Script executed:

#!/bin/bash
# Check if Windows is a build target in CI
rg -n 'os.*windows|windows.*os' .github/workflows/ 2>/dev/null | head -20

# Check host_requirements.go to see how SystemHostInfo is used
cat -n pkg/devcontainer/config/host_requirements.go | head -40

# Check if there's any conditional compilation around this
rg -B5 -A5 'SystemHostInfo' pkg/devcontainer/config/

Repository: devsy-org/devsy

Length of output: 3148


🏁 Script executed:

#!/bin/bash
# Check main CI workflow for OS matrix
find .github/workflows -name "*.yml" -o -name "*.yaml" | xargs rg -l 'os.*matrix\|windows' 2>/dev/null | head -5

# For each workflow, check the actual matrix
rg -A10 'strategy:' .github/workflows/*.yml 2>/dev/null | head -30

# Check if any main entry point uses SystemHostInfo unconditionally
rg -B3 -A3 'SystemHostInfo{}' pkg/

Repository: devsy-org/devsy

Length of output: 1715


🏁 Script executed:

#!/bin/bash
# Check if SystemHostInfo is instantiated/used in any main packages
rg -n 'SystemHostInfo' pkg/ --type go

# Check what happens in main/entry points for Windows compatibility
rg -n 'main\|init' cmd/ --type go -A5 | head -40

# Check if there's build tag in any importing files
rg -B2 'host_requirements' pkg/ --type go | grep -E '//go:build|SystemHostInfo' | head -20

Repository: devsy-org/devsy

Length of output: 659


🏁 Script executed:

#!/bin/bash
# Check the context where SystemHostInfo is used
cat -n pkg/devcontainer/single.go | sed -n '220,230p'

# Verify Windows build would fail - check if there's any conditional around the instantiation
rg -B10 -A5 'SystemHostInfo\{\}' pkg/devcontainer/single.go

Repository: devsy-org/devsy

Length of output: 1069


Add build constraint to prevent Windows compilation errors — syscall.Statfs and /proc/meminfo are Unix-only.

host_requirements_system.go uses Unix-specific syscalls (syscall.Statfs at line 31 and /proc/meminfo at line 38) but has no //go:build constraint. Since Windows is a supported target (present in CI workflows), this file will fail to compile on Windows. Add //go:build !windows at the top and create a host_requirements_system_windows.go stub implementation following the existing pattern used by prepareprobe.go / prepareprobe_windows.go in the same package.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/devcontainer/config/host_requirements_system.go` around lines 26 - 35,
This file uses Unix-only APIs (syscall.Statfs and /proc/meminfo) and needs a
build constraint; add a top-line build tag "//go:build !windows" to
host_requirements_system.go and keep the rest unchanged, and add a new stub file
host_requirements_system_windows.go that provides Windows-safe implementations
(matching the same exported types and methods such as
SystemHostInfo.AvailableStorageBytes and any other functions referenced in this
file) returning appropriate zero values and errors or fallbacks consistent with
prepareprobe_windows.go pattern so the package compiles on Windows.


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")
}
Loading
Loading