Skip to content

Commit 2ae5c5d

Browse files
committed
Merge remote-tracking branch 'origin/ISS-41-docker-resource-limits' into ISS-43-multi-server-distribution
2 parents 3b2fd2d + 00d6ae3 commit 2ae5c5d

8 files changed

Lines changed: 829 additions & 24 deletions

File tree

cmd/devbox/main.go

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,26 @@ func upCmd(wm workspace.Manager) *cobra.Command {
132132
return fmt.Errorf("devbox up: server is required — add 'server:' to devbox.yaml or use --server flag")
133133
}
134134

135+
// Merge resource limits: server defaults <- workspace overrides.
136+
globalCfg, err := config.LoadGlobal()
137+
if err != nil {
138+
slog.Warn("failed to load global config", "error", err)
139+
}
140+
resources := config.MergeResources(
141+
globalCfg.ServerResourceDefaults(cfg.Server),
142+
cfg.Resources,
143+
)
144+
135145
spin := ui.StartSpinner("Starting workspace...")
136146
ws, err := wm.Create(workspace.CreateParams{
137-
Name: cfg.Name,
138-
Server: cfg.Server,
139-
Repo: cfg.Repo,
140-
Branch: cfg.Branch,
141-
Services: cfg.Services,
142-
Ports: cfg.Ports,
143-
Env: cfg.Env,
147+
Name: cfg.Name,
148+
Server: cfg.Server,
149+
Repo: cfg.Repo,
150+
Branch: cfg.Branch,
151+
Services: cfg.Services,
152+
Ports: cfg.Ports,
153+
Env: cfg.Env,
154+
Resources: resources,
144155
})
145156
if err != nil {
146157
// If workspace already exists, start it instead.
@@ -226,7 +237,7 @@ func listCmd(wm workspace.Manager) *cobra.Command {
226237
Use: "list",
227238
Aliases: []string{"ls"},
228239
Short: "List all workspaces",
229-
Long: "List all workspaces across all configured servers.\nShows status, project, branch, and server for each workspace.",
240+
Long: "List all workspaces across all configured servers.\nShows status, resource limits, and server for each workspace.",
230241
RunE: func(cmd *cobra.Command, args []string) error {
231242
workspaces, err := wm.List()
232243
if err != nil {
@@ -238,19 +249,85 @@ func listCmd(wm workspace.Manager) *cobra.Command {
238249
return nil
239250
}
240251

241-
headers := []string{"NAME", "STATUS", "SERVER", "PORTS", "CREATED"}
252+
// Collect unique servers with running workspaces for live stats.
253+
serverHosts := make(map[string]bool)
254+
for _, ws := range workspaces {
255+
if ws.Status == workspace.StatusRunning {
256+
serverHosts[ws.ServerHost] = true
257+
}
258+
}
259+
260+
// Fetch live docker stats per server (best-effort).
261+
allStats := make(map[string]*workspace.ResourceUsage)
262+
serverInfos := make(map[string]*workspace.ServerResourceInfo)
263+
for host := range serverHosts {
264+
stats, err := wm.DockerStats(host)
265+
if err != nil {
266+
slog.Debug("failed to fetch docker stats", "host", host, "error", err)
267+
} else {
268+
for k, v := range stats {
269+
allStats[k] = v
270+
}
271+
}
272+
info, err := wm.ServerResources(host)
273+
if err != nil {
274+
slog.Debug("failed to fetch server resources", "host", host, "error", err)
275+
} else {
276+
// Aggregate used resources from container stats.
277+
for _, s := range stats {
278+
if info.TotalCPUs > 0 {
279+
info.UsedCPUs += s.CPUPercent / 100.0 * float64(info.TotalCPUs)
280+
}
281+
info.UsedMemoryBytes += s.MemoryUsed
282+
}
283+
serverInfos[host] = info
284+
}
285+
}
286+
287+
headers := []string{"NAME", "STATUS", "SERVER", "CPUS", "MEMORY", "CPU%", "MEM%", "PORTS", "CREATED"}
242288
rows := make([][]string, 0, len(workspaces))
243289
for _, ws := range workspaces {
290+
cpus := "-"
291+
mem := "-"
292+
if !ws.Resources.IsZero() {
293+
if ws.Resources.CPUs > 0 {
294+
cpus = fmt.Sprintf("%.1f", ws.Resources.CPUs)
295+
}
296+
if ws.Resources.Memory != "" {
297+
mem = ws.Resources.Memory
298+
}
299+
}
300+
cpuPct := "-"
301+
memPct := "-"
302+
// Match container name: workspace containers are named <name>-<service>-1
303+
for statName, ru := range allStats {
304+
if strings.HasPrefix(statName, ws.Name+"-") {
305+
cpuPct, memPct = workspace.FormatResourceUsage(ru)
306+
break
307+
}
308+
}
244309
rows = append(rows, []string{
245310
ws.Name,
246311
ui.StatusColor(ws.Status),
247312
ws.ServerHost,
313+
cpus,
314+
mem,
315+
cpuPct,
316+
memPct,
248317
formatPorts(ws.Ports),
249318
timeAgo(ws.CreatedAt),
250319
})
251320
}
252321
ui.PrintTable(headers, rows)
253322

323+
// Emit low-resource warnings to stderr.
324+
for host, info := range serverInfos {
325+
warnings := workspace.CheckLowResources(info, workspace.LowResourceThreshold)
326+
for _, w := range warnings {
327+
fmt.Fprintf(os.Stderr, "⚠ %s: %s\n", host, w)
328+
}
329+
}
330+
254331
return nil
255332
},
256333
}

internal/config/config.go

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,78 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"regexp"
9+
"strconv"
10+
"strings"
811

912
devboxerr "github.com/junixlabs/devbox/internal/errors"
1013
"gopkg.in/yaml.v3"
1114
)
1215

16+
// memPattern matches Docker-compatible memory strings like "512m", "4g".
17+
var memPattern = regexp.MustCompile(`^[0-9]+[gGmM]$`)
18+
19+
// Resources defines CPU and memory limits for a workspace container.
20+
type Resources struct {
21+
CPUs float64 `yaml:"cpus,omitempty"`
22+
Memory string `yaml:"memory,omitempty"`
23+
}
24+
25+
// IsZero returns true if no resource limits are configured.
26+
func (r Resources) IsZero() bool {
27+
return r.CPUs == 0 && r.Memory == ""
28+
}
29+
30+
// Validate checks that resource values are sensible.
31+
func (r Resources) Validate() error {
32+
if r.CPUs < 0 {
33+
return fmt.Errorf("resources.cpus must be positive, got %g", r.CPUs)
34+
}
35+
if r.Memory != "" && !memPattern.MatchString(r.Memory) {
36+
return fmt.Errorf("resources.memory must match <number>(g|m), got %q", r.Memory)
37+
}
38+
return nil
39+
}
40+
41+
// ParseMemoryBytes converts a Docker memory string like "4g" or "512m" to bytes.
42+
func ParseMemoryBytes(mem string) (int64, error) {
43+
if mem == "" {
44+
return 0, nil
45+
}
46+
mem = strings.ToLower(mem)
47+
suffix := mem[len(mem)-1]
48+
num, err := strconv.ParseInt(mem[:len(mem)-1], 10, 64)
49+
if err != nil {
50+
return 0, fmt.Errorf("invalid memory value %q: %w", mem, err)
51+
}
52+
switch suffix {
53+
case 'g':
54+
return num * 1024 * 1024 * 1024, nil
55+
case 'm':
56+
return num * 1024 * 1024, nil
57+
default:
58+
return 0, fmt.Errorf("unsupported memory suffix %q in %q", string(suffix), mem)
59+
}
60+
}
61+
62+
// MergeResources returns a Resources where workspace overrides take precedence
63+
// over server defaults for each field that is set.
64+
func MergeResources(serverDefaults, workspaceOverride *Resources) Resources {
65+
result := Resources{}
66+
if serverDefaults != nil {
67+
result = *serverDefaults
68+
}
69+
if workspaceOverride != nil {
70+
if workspaceOverride.CPUs > 0 {
71+
result.CPUs = workspaceOverride.CPUs
72+
}
73+
if workspaceOverride.Memory != "" {
74+
result.Memory = workspaceOverride.Memory
75+
}
76+
}
77+
return result
78+
}
79+
1380
// DevboxConfig represents the per-project devbox.yaml configuration.
1481
type DevboxConfig struct {
1582
Name string `yaml:"name"`
@@ -18,7 +85,8 @@ type DevboxConfig struct {
1885
Branch string `yaml:"branch,omitempty"`
1986
Services []string `yaml:"services,omitempty"`
2087
Ports map[string]int `yaml:"ports,omitempty"`
21-
Env map[string]string `yaml:"env,omitempty"`
88+
Env map[string]string `yaml:"env,omitempty"`
89+
Resources *Resources `yaml:"resources,omitempty"`
2290
}
2391

2492
// DefaultConfigFile is the default config filename looked up in the project root.
@@ -60,9 +128,64 @@ func Load(path string) (*DevboxConfig, error) {
60128
)
61129
}
62130

131+
if cfg.Resources != nil {
132+
if err := cfg.Resources.Validate(); err != nil {
133+
return nil, devboxerr.NewConfigError(
134+
fmt.Sprintf("config file %s: %v", path, err),
135+
"Example: resources: {cpus: 2, memory: 4g}",
136+
nil,
137+
)
138+
}
139+
}
140+
63141
return &cfg, nil
64142
}
65143

144+
// ServerDefaults holds per-server default settings.
145+
type ServerDefaults struct {
146+
Resources Resources `yaml:"resources"`
147+
}
148+
149+
// GlobalConfig represents the user-level ~/.devbox/config.yaml.
150+
type GlobalConfig struct {
151+
Servers map[string]ServerDefaults `yaml:"servers"`
152+
}
153+
154+
// LoadGlobal reads the global config from ~/.devbox/config.yaml.
155+
// Returns an empty config (not an error) if the file doesn't exist.
156+
func LoadGlobal() (*GlobalConfig, error) {
157+
home, err := os.UserHomeDir()
158+
if err != nil {
159+
return &GlobalConfig{}, nil
160+
}
161+
path := filepath.Join(home, ".devbox", "config.yaml")
162+
data, err := os.ReadFile(path)
163+
if err != nil {
164+
return &GlobalConfig{}, nil
165+
}
166+
var gc GlobalConfig
167+
if err := yaml.Unmarshal(data, &gc); err != nil {
168+
return nil, devboxerr.NewConfigError(
169+
fmt.Sprintf("failed to parse global config %s", path),
170+
"Check YAML syntax in ~/.devbox/config.yaml",
171+
err,
172+
)
173+
}
174+
return &gc, nil
175+
}
176+
177+
// ServerResourceDefaults returns the resource defaults for a given server,
178+
// or nil if no defaults are configured.
179+
func (gc *GlobalConfig) ServerResourceDefaults(server string) *Resources {
180+
if gc == nil || gc.Servers == nil {
181+
return nil
182+
}
183+
if sd, ok := gc.Servers[server]; ok {
184+
return &sd.Resources
185+
}
186+
return nil
187+
}
188+
66189
// LoadFromDir looks for devbox.yaml in the given directory and loads it.
67190
// If devbox.yaml is not found, it falls back to .devcontainer/devcontainer.json.
68191
func LoadFromDir(dir string) (*DevboxConfig, error) {

0 commit comments

Comments
 (0)