Skip to content

Commit a5784b4

Browse files
chuongld20claude
andcommitted
feat: add multi-server workspace distribution (ISS-43)
Auto-select least-loaded server for workspace placement via parallel SSH resource queries (nproc, free -b, /proc/loadavg). Three-tier server resolution in devbox up: --server flag → config → pool auto-select. Cross-server filtering in devbox list with --server flag. Server field in devbox.yaml is now optional when a server pool is configured. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2ae5c5d commit a5784b4

10 files changed

Lines changed: 694 additions & 47 deletions

File tree

cmd/devbox/main.go

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -120,17 +120,61 @@ func upCmd(wm workspace.Manager) *cobra.Command {
120120
return fmt.Errorf("devbox up: %w", err)
121121
}
122122

123-
// Apply flag overrides
124-
if s, _ := cmd.Flags().GetString("server"); s != "" {
125-
cfg.Server = s
126-
}
127123
if b, _ := cmd.Flags().GetString("branch"); b != "" {
128124
cfg.Branch = b
129125
}
130126

131-
if cfg.Server == "" {
132-
return fmt.Errorf("devbox up: server is required — add 'server:' to devbox.yaml or use --server flag")
127+
// Load server pool (best-effort).
128+
configPath, _ := server.DefaultConfigPath()
129+
sshExec, err := devboxssh.New()
130+
if err != nil {
131+
return fmt.Errorf("devbox up: %w", err)
132+
}
133+
defer sshExec.Close()
134+
135+
pool, poolErr := server.NewFilePool(configPath, sshExec)
136+
var poolServers []server.Server
137+
if poolErr == nil {
138+
poolServers, _ = pool.List()
139+
}
140+
poolConfigured := len(poolServers) > 0
141+
142+
// 3-tier server resolution: --server flag → config.Server → auto-select from pool.
143+
targetServer := cfg.Server
144+
serverFlag, _ := cmd.Flags().GetString("server")
145+
146+
if serverFlag != "" {
147+
// Tier 1: --server flag — look up from pool by name.
148+
found := false
149+
for _, srv := range poolServers {
150+
if srv.Name == serverFlag {
151+
targetServer = server.SSHHost(&srv)
152+
found = true
153+
break
154+
}
155+
}
156+
if !found {
157+
// Not in pool — use as raw hostname (backward compat).
158+
targetServer = serverFlag
159+
}
160+
} else if targetServer == "" && poolConfigured {
161+
// Tier 3: Auto-select from pool.
162+
selector := server.NewLeastLoaded(sshExec)
163+
selected, err := selector.Select(cmd.Context(), poolServers)
164+
if err != nil {
165+
return fmt.Errorf("devbox up: %w", err)
166+
}
167+
targetServer = server.SSHHost(selected)
168+
fmt.Fprintf(os.Stderr, "Using server: %s (%s)\n", selected.Name, selected.Host)
133169
}
170+
// Tier 2: config.Server is already set in targetServer.
171+
172+
if err := cfg.ValidateForUp(poolConfigured); err != nil {
173+
if targetServer == "" {
174+
return fmt.Errorf("devbox up: %w", err)
175+
}
176+
}
177+
cfg.Server = targetServer
134178

135179
// Merge resource limits: server defaults <- workspace overrides.
136180
globalCfg, err := config.LoadGlobal()
@@ -173,13 +217,6 @@ func upCmd(wm workspace.Manager) *cobra.Command {
173217
}
174218

175219
// Expose ports via Tailscale on the remote server
176-
sshExec, err := devboxssh.New()
177-
if err != nil {
178-
ui.StopSpinner(spin, false)
179-
return fmt.Errorf("devbox up: %w", err)
180-
}
181-
defer sshExec.Close()
182-
183220
tm := tailscale.NewManager(remoteRunner(sshExec, cfg.Server))
184221
for name, port := range cfg.Ports {
185222
if err := tm.Serve(port, ws.Name); err != nil {
@@ -199,7 +236,7 @@ func upCmd(wm workspace.Manager) *cobra.Command {
199236
},
200237
}
201238
cmd.Flags().String("branch", "", "Git branch to checkout")
202-
cmd.Flags().String("server", "", "Target server (overrides devbox.yaml)")
239+
cmd.Flags().String("server", "", "Target server name from pool (or hostname)")
203240
return cmd
204241
}
205242

@@ -233,17 +270,42 @@ func stopCmd(wm workspace.Manager) *cobra.Command {
233270
}
234271

235272
func listCmd(wm workspace.Manager) *cobra.Command {
236-
return &cobra.Command{
273+
cmd := &cobra.Command{
237274
Use: "list",
238275
Aliases: []string{"ls"},
239276
Short: "List all workspaces",
240-
Long: "List all workspaces across all configured servers.\nShows status, resource limits, and server for each workspace.",
277+
Long: "List all workspaces across all configured servers.\nShows status, resource limits, and server for each workspace.\nUse --server to filter to a specific server.",
241278
RunE: func(cmd *cobra.Command, args []string) error {
242279
workspaces, err := wm.List()
243280
if err != nil {
244281
return fmt.Errorf("devbox list: %w", err)
245282
}
246283

284+
// Filter by --server if provided.
285+
serverFilter, _ := cmd.Flags().GetString("server")
286+
if serverFilter != "" {
287+
// Resolve server name from pool if possible.
288+
resolvedHost := serverFilter
289+
configPath, _ := server.DefaultConfigPath()
290+
if pool, err := server.NewFilePool(configPath, nil); err == nil {
291+
if servers, err := pool.List(); err == nil {
292+
for _, srv := range servers {
293+
if srv.Name == serverFilter {
294+
resolvedHost = server.SSHHost(&srv)
295+
break
296+
}
297+
}
298+
}
299+
}
300+
filtered := make([]workspace.Workspace, 0)
301+
for _, ws := range workspaces {
302+
if ws.ServerHost == resolvedHost || ws.ServerHost == serverFilter {
303+
filtered = append(filtered, ws)
304+
}
305+
}
306+
workspaces = filtered
307+
}
308+
247309
if len(workspaces) == 0 {
248310
fmt.Println("No workspaces found")
249311
return nil
@@ -331,6 +393,8 @@ func listCmd(wm workspace.Manager) *cobra.Command {
331393
return nil
332394
},
333395
}
396+
cmd.Flags().String("server", "", "Filter workspaces by server name or hostname")
397+
return cmd
334398
}
335399

336400
func destroyCmd(wm workspace.Manager) *cobra.Command {

internal/config/config.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,6 @@ func Load(path string) (*DevboxConfig, error) {
120120
)
121121
}
122122

123-
if cfg.Server == "" {
124-
return nil, devboxerr.NewConfigError(
125-
fmt.Sprintf("config file %s: 'server' is required", path),
126-
"Add 'server: your-server' to devbox.yaml",
127-
nil,
128-
)
129-
}
130-
131123
if cfg.Resources != nil {
132124
if err := cfg.Resources.Validate(); err != nil {
133125
return nil, devboxerr.NewConfigError(
@@ -141,6 +133,19 @@ func Load(path string) (*DevboxConfig, error) {
141133
return &cfg, nil
142134
}
143135

136+
// ValidateForUp checks that the config has enough info to create a workspace.
137+
// If poolConfigured is true, the server field is optional (auto-select from pool).
138+
func (c *DevboxConfig) ValidateForUp(poolConfigured bool) error {
139+
if c.Server == "" && !poolConfigured {
140+
return devboxerr.NewConfigError(
141+
"'server' is required when no server pool is configured",
142+
"Add 'server: your-server' to devbox.yaml, use --server flag, or run 'devbox server add'",
143+
nil,
144+
)
145+
}
146+
return nil
147+
}
148+
144149
// ServerDefaults holds per-server default settings.
145150
type ServerDefaults struct {
146151
Resources Resources `yaml:"resources"`

internal/config/config_test.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,17 +120,25 @@ func TestLoad_MissingServer(t *testing.T) {
120120

121121
os.WriteFile(path, []byte("name: myproject\n"), 0644)
122122

123-
_, err := Load(path)
124-
if err == nil {
125-
t.Fatal("expected error for missing server")
123+
// Load should succeed — server is now optional (validated at use time).
124+
cfg, err := Load(path)
125+
if err != nil {
126+
t.Fatalf("Load() unexpected error: %v", err)
126127
}
127-
if !strings.Contains(err.Error(), "'server' is required") {
128-
t.Errorf("error = %q, want it to contain 'server' is required", err.Error())
128+
if cfg.Server != "" {
129+
t.Errorf("Server = %q, want empty", cfg.Server)
129130
}
130131

131-
var ce *devboxerr.ConfigError
132-
if !errors.As(err, &ce) {
133-
t.Fatalf("expected ConfigError, got %T", err)
132+
// ValidateForUp without pool should error.
133+
err = cfg.ValidateForUp(false)
134+
if err == nil {
135+
t.Fatal("ValidateForUp(false) should error when server is empty")
136+
}
137+
138+
// ValidateForUp with pool should succeed.
139+
err = cfg.ValidateForUp(true)
140+
if err != nil {
141+
t.Fatalf("ValidateForUp(true) unexpected error: %v", err)
134142
}
135143
}
136144

internal/config/devcontainer_test.go

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"errors"
55
"os"
66
"path/filepath"
7-
"strings"
87
"testing"
98

109
devboxerr "github.com/junixlabs/devbox/internal/errors"
@@ -327,10 +326,10 @@ func TestLoadFromDir_NeitherExists(t *testing.T) {
327326
}
328327
}
329328

330-
func TestLoadFromDir_MalformedDevboxYaml_NoFallback(t *testing.T) {
329+
func TestLoadFromDir_DevboxYamlWithoutServer_NoFallback(t *testing.T) {
331330
dir := t.TempDir()
332331

333-
// Create a malformed devbox.yaml (missing server).
332+
// Create devbox.yaml without server (now valid — server is optional).
334333
os.WriteFile(
335334
filepath.Join(dir, "devbox.yaml"),
336335
[]byte("name: broken-project\n"),
@@ -346,18 +345,13 @@ func TestLoadFromDir_MalformedDevboxYaml_NoFallback(t *testing.T) {
346345
0644,
347346
)
348347

349-
_, err := LoadFromDir(dir)
350-
if err == nil {
351-
t.Fatal("expected error for malformed devbox.yaml, should not fall back to devcontainer.json")
352-
}
353-
354-
// Should report the devbox.yaml error, not silently fall back.
355-
var ce *devboxerr.ConfigError
356-
if !errors.As(err, &ce) {
357-
t.Fatalf("expected ConfigError, got %T", err)
348+
// LoadFromDir should use devbox.yaml (not fall back to devcontainer.json).
349+
cfg, err := LoadFromDir(dir)
350+
if err != nil {
351+
t.Fatalf("LoadFromDir() unexpected error: %v", err)
358352
}
359-
if !strings.Contains(err.Error(), "'server' is required") {
360-
t.Errorf("error = %q, want it to mention server is required", err.Error())
353+
if cfg.Name != "broken-project" {
354+
t.Errorf("Name = %q, want %q (from devbox.yaml, not devcontainer)", cfg.Name, "broken-project")
361355
}
362356
}
363357

internal/server/pool.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ func (p *filePool) checkServer(srv *Server) *HealthStatus {
142142
return status
143143
}
144144

145-
host := sshHost(srv)
145+
host := SSHHost(srv)
146146
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
147147
defer cancel()
148148

0 commit comments

Comments
 (0)