Skip to content

Commit ea97d6f

Browse files
committed
feat: port auto-allocation registry with conflict detection (ISS-40)
2 parents 5f60f0f + 1e45b1b commit ea97d6f

4 files changed

Lines changed: 496 additions & 6 deletions

File tree

internal/config/config.go

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,21 @@ func MergeResources(serverDefaults, workspaceOverride *Resources) Resources {
7777
return result
7878
}
7979

80+
// PortRangeConfig defines the allowed port range for auto-allocation.
81+
type PortRangeConfig struct {
82+
Min int `yaml:"min"`
83+
Max int `yaml:"max"`
84+
}
85+
8086
// DevboxConfig represents the per-project devbox.yaml configuration.
8187
type DevboxConfig struct {
82-
Name string `yaml:"name"`
83-
Server string `yaml:"server"`
84-
Repo string `yaml:"repo"`
85-
Branch string `yaml:"branch,omitempty"`
86-
Services []string `yaml:"services,omitempty"`
87-
Ports map[string]int `yaml:"ports,omitempty"`
88+
Name string `yaml:"name"`
89+
Server string `yaml:"server"`
90+
Repo string `yaml:"repo"`
91+
Branch string `yaml:"branch,omitempty"`
92+
Services []string `yaml:"services,omitempty"`
93+
Ports map[string]int `yaml:"ports,omitempty"`
94+
PortRange *PortRangeConfig `yaml:"port_range,omitempty"`
8895
Env map[string]string `yaml:"env,omitempty"`
8996
Resources *Resources `yaml:"resources,omitempty"`
9097
WorkspacesRoot string `yaml:"workspaces_root,omitempty"`
@@ -139,6 +146,23 @@ func Load(path string) (*DevboxConfig, error) {
139146
cfg.WorkspacesRoot = DefaultWorkspacesRoot
140147
}
141148

149+
if cfg.PortRange != nil {
150+
if cfg.PortRange.Min < 1024 {
151+
return nil, devboxerr.NewConfigError(
152+
fmt.Sprintf("config file %s: port_range.min must be >= 1024", path),
153+
"Set port_range.min to at least 1024",
154+
nil,
155+
)
156+
}
157+
if cfg.PortRange.Max <= cfg.PortRange.Min {
158+
return nil, devboxerr.NewConfigError(
159+
fmt.Sprintf("config file %s: port_range.max must be greater than port_range.min", path),
160+
"Ensure port_range.max > port_range.min (e.g. min: 10000, max: 60000)",
161+
nil,
162+
)
163+
}
164+
}
165+
142166
return &cfg, nil
143167
}
144168

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)