Skip to content

Commit 1e45b1b

Browse files
chuongld20claude
andcommitted
feat: add port auto-allocation registry with conflict detection
Add internal/port package with Registry interface and file-backed implementation. Supports auto-allocation from configurable range, manual overrides, conflict detection, and persistent state via JSON. Add PortRange config field to DevboxConfig with validation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a427454 commit 1e45b1b

4 files changed

Lines changed: 497 additions & 7 deletions

File tree

internal/config/config.go

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,22 @@ import (
88
"gopkg.in/yaml.v3"
99
)
1010

11+
// PortRangeConfig defines the allowed port range for auto-allocation.
12+
type PortRangeConfig struct {
13+
Min int `yaml:"min"`
14+
Max int `yaml:"max"`
15+
}
16+
1117
// DevboxConfig represents the per-project devbox.yaml configuration.
1218
type DevboxConfig struct {
13-
Name string `yaml:"name"`
14-
Server string `yaml:"server"`
15-
Repo string `yaml:"repo"`
16-
Branch string `yaml:"branch,omitempty"`
17-
Services []string `yaml:"services,omitempty"`
18-
Ports map[string]int `yaml:"ports,omitempty"`
19-
Env map[string]string `yaml:"env,omitempty"`
19+
Name string `yaml:"name"`
20+
Server string `yaml:"server"`
21+
Repo string `yaml:"repo"`
22+
Branch string `yaml:"branch,omitempty"`
23+
Services []string `yaml:"services,omitempty"`
24+
Ports map[string]int `yaml:"ports,omitempty"`
25+
PortRange *PortRangeConfig `yaml:"port_range,omitempty"`
26+
Env map[string]string `yaml:"env,omitempty"`
2027
}
2128

2229
// DefaultConfigFile is the default config filename looked up in the project root.
@@ -58,6 +65,23 @@ func Load(path string) (*DevboxConfig, error) {
5865
)
5966
}
6067

68+
if cfg.PortRange != nil {
69+
if cfg.PortRange.Min < 1024 {
70+
return nil, devboxerr.NewConfigError(
71+
fmt.Sprintf("config file %s: port_range.min must be >= 1024", path),
72+
"Set port_range.min to at least 1024",
73+
nil,
74+
)
75+
}
76+
if cfg.PortRange.Max <= cfg.PortRange.Min {
77+
return nil, devboxerr.NewConfigError(
78+
fmt.Sprintf("config file %s: port_range.max must be greater than port_range.min", path),
79+
"Ensure port_range.max > port_range.min (e.g. min: 10000, max: 60000)",
80+
nil,
81+
)
82+
}
83+
}
84+
6185
return &cfg, nil
6286
}
6387

internal/port/port.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package port
2+
3+
// DefaultPortRange is the default range for auto-allocated ports.
4+
var DefaultPortRange = PortRange{Min: 10000, Max: 60000}
5+
6+
// PortRange defines the inclusive range of ports available for auto-allocation.
7+
type PortRange struct {
8+
Min int
9+
Max int
10+
}
11+
12+
// Allocation represents a single port assignment for a workspace service.
13+
type Allocation struct {
14+
WorkspaceName string `json:"workspace_name"`
15+
ServiceName string `json:"service_name"`
16+
Port int `json:"port"`
17+
Manual bool `json:"manual"`
18+
}
19+
20+
// Conflict describes a port collision between two workspace services.
21+
type Conflict struct {
22+
Port int
23+
WorkspaceA string
24+
WorkspaceB string
25+
Service string
26+
}
27+
28+
// Registry manages port allocations across workspaces.
29+
type Registry interface {
30+
// Allocate assigns a port for the given workspace service.
31+
// If override is non-nil, it uses that port (checking for conflicts).
32+
// Otherwise it auto-assigns from the configured range.
33+
Allocate(workspace, service string, override *int) (int, error)
34+
35+
// Release removes all port allocations for the given workspace.
36+
Release(workspace string) error
37+
38+
// GetAllocations returns the port map for a workspace (service → port).
39+
GetAllocations(workspace string) (map[string]int, error)
40+
41+
// CheckConflicts detects duplicate port assignments across all workspaces.
42+
CheckConflicts() ([]Conflict, error)
43+
44+
// ListAll returns every current allocation.
45+
ListAll() ([]Allocation, error)
46+
}

internal/port/registry.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package port
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"sync"
9+
)
10+
11+
// fileRegistry implements Registry by persisting allocations to a JSON file.
12+
type fileRegistry struct {
13+
path string
14+
portRange PortRange
15+
mu sync.Mutex
16+
}
17+
18+
// NewFileRegistry creates a Registry backed by a JSON state file.
19+
// TODO: In-process mutex only protects single-process access. For concurrent
20+
// devbox processes, file-level locking (flock) would be needed.
21+
func NewFileRegistry(path string, portRange PortRange) Registry {
22+
return &fileRegistry{
23+
path: path,
24+
portRange: portRange,
25+
}
26+
}
27+
28+
func (r *fileRegistry) Allocate(workspace, service string, override *int) (int, error) {
29+
r.mu.Lock()
30+
defer r.mu.Unlock()
31+
32+
allocs, err := r.load()
33+
if err != nil {
34+
return 0, err
35+
}
36+
37+
// Check if this workspace+service already has an allocation.
38+
for _, a := range allocs {
39+
if a.WorkspaceName == workspace && a.ServiceName == service {
40+
return a.Port, nil
41+
}
42+
}
43+
44+
usedPorts := make(map[int]string) // port → workspace
45+
for _, a := range allocs {
46+
usedPorts[a.Port] = a.WorkspaceName
47+
}
48+
49+
var port int
50+
var manual bool
51+
52+
if override != nil {
53+
port = *override
54+
manual = true
55+
if owner, taken := usedPorts[port]; taken {
56+
return 0, fmt.Errorf("port %d already allocated to workspace %q", port, owner)
57+
}
58+
} else {
59+
port, err = r.findFreePort(usedPorts)
60+
if err != nil {
61+
return 0, err
62+
}
63+
}
64+
65+
allocs = append(allocs, Allocation{
66+
WorkspaceName: workspace,
67+
ServiceName: service,
68+
Port: port,
69+
Manual: manual,
70+
})
71+
72+
if err := r.save(allocs); err != nil {
73+
return 0, err
74+
}
75+
76+
return port, nil
77+
}
78+
79+
func (r *fileRegistry) Release(workspace string) error {
80+
r.mu.Lock()
81+
defer r.mu.Unlock()
82+
83+
allocs, err := r.load()
84+
if err != nil {
85+
return err
86+
}
87+
88+
filtered := make([]Allocation, 0, len(allocs))
89+
for _, a := range allocs {
90+
if a.WorkspaceName != workspace {
91+
filtered = append(filtered, a)
92+
}
93+
}
94+
95+
return r.save(filtered)
96+
}
97+
98+
func (r *fileRegistry) GetAllocations(workspace string) (map[string]int, error) {
99+
r.mu.Lock()
100+
defer r.mu.Unlock()
101+
102+
allocs, err := r.load()
103+
if err != nil {
104+
return nil, err
105+
}
106+
107+
result := make(map[string]int)
108+
for _, a := range allocs {
109+
if a.WorkspaceName == workspace {
110+
result[a.ServiceName] = a.Port
111+
}
112+
}
113+
114+
return result, nil
115+
}
116+
117+
func (r *fileRegistry) CheckConflicts() ([]Conflict, error) {
118+
r.mu.Lock()
119+
defer r.mu.Unlock()
120+
121+
allocs, err := r.load()
122+
if err != nil {
123+
return nil, err
124+
}
125+
126+
// Group allocations by port.
127+
byPort := make(map[int][]Allocation)
128+
for _, a := range allocs {
129+
byPort[a.Port] = append(byPort[a.Port], a)
130+
}
131+
132+
var conflicts []Conflict
133+
for port, group := range byPort {
134+
if len(group) < 2 {
135+
continue
136+
}
137+
for i := 0; i < len(group); i++ {
138+
for j := i + 1; j < len(group); j++ {
139+
conflicts = append(conflicts, Conflict{
140+
Port: port,
141+
WorkspaceA: group[i].WorkspaceName,
142+
WorkspaceB: group[j].WorkspaceName,
143+
Service: group[i].ServiceName,
144+
})
145+
}
146+
}
147+
}
148+
149+
return conflicts, nil
150+
}
151+
152+
func (r *fileRegistry) ListAll() ([]Allocation, error) {
153+
r.mu.Lock()
154+
defer r.mu.Unlock()
155+
156+
return r.load()
157+
}
158+
159+
// findFreePort scans the configured range for the first unoccupied port.
160+
func (r *fileRegistry) findFreePort(usedPorts map[int]string) (int, error) {
161+
for p := r.portRange.Min; p <= r.portRange.Max; p++ {
162+
if _, taken := usedPorts[p]; !taken {
163+
return p, nil
164+
}
165+
}
166+
return 0, fmt.Errorf("no free ports in range %d-%d", r.portRange.Min, r.portRange.Max)
167+
}
168+
169+
// load reads allocations from the state file. Returns empty slice if file doesn't exist.
170+
func (r *fileRegistry) load() ([]Allocation, error) {
171+
data, err := os.ReadFile(r.path)
172+
if err != nil {
173+
if os.IsNotExist(err) {
174+
return nil, nil
175+
}
176+
return nil, fmt.Errorf("reading port state file: %w", err)
177+
}
178+
179+
var allocs []Allocation
180+
if err := json.Unmarshal(data, &allocs); err != nil {
181+
return nil, fmt.Errorf("parsing port state file: %w", err)
182+
}
183+
184+
return allocs, nil
185+
}
186+
187+
// save writes allocations to the state file, creating directories if needed.
188+
func (r *fileRegistry) save(allocs []Allocation) error {
189+
if err := os.MkdirAll(filepath.Dir(r.path), 0o755); err != nil {
190+
return fmt.Errorf("creating port state directory: %w", err)
191+
}
192+
193+
data, err := json.MarshalIndent(allocs, "", " ")
194+
if err != nil {
195+
return fmt.Errorf("marshalling port state: %w", err)
196+
}
197+
198+
if err := os.WriteFile(r.path, data, 0o644); err != nil {
199+
return fmt.Errorf("writing port state file: %w", err)
200+
}
201+
202+
return nil
203+
}

0 commit comments

Comments
 (0)