feat: add hub-managed port pool for agent containers#298
Conversation
Agents that run local web servers (dev servers, preview apps, test
harnesses) need guaranteed unique host ports. This adds a port pool
managed by the runtime broker that allocates unique ports from a
configurable range at agent startup and releases them on stop/delete.
Each agent receives environment variables:
- AVAILABLE_LOCALHOST_PORT_A, _B (port numbers)
- AVAILABLE_LOCALHOST_URL_A, _B (full URLs, when host_url is configured)
Ports are published via docker -p so they're reachable from the host.
Configuration in settings.yaml:
server:
broker:
port_pool:
range: "8000-9000"
ports_per_agent: 2
host_url: "http://broker-host-ip"
Defaults: range 8000-9000, 2 ports per agent, disabled unless configured.
There was a problem hiding this comment.
Code Review
This pull request introduces a hub-managed port pool mechanism to allocate unique host ports for agent containers. It adds configuration options, implements the PortPool logic with concurrency support and tests, and integrates port allocation and release into the agent manager's lifecycle (start, stop, delete). It also injects these allocated ports as environment variables and publishes them in both Docker and Kubernetes runtimes. Feedback on the PR highlights two issues: first, recreating or restarting an agent can bypass the manager's Delete method, leading to leaked ports and eventual pool exhaustion, so releasing existing ports before allocation is suggested; second, if the host URL is configured with a trailing slash, the constructed agent port URLs will be invalid, so trimming trailing slashes during initialization is recommended.
| if m.PortPool != nil { | ||
| ports, err := m.PortPool.Allocate(opts.Name, m.PortPool.PerAgent()) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("port allocation failed: %w", err) | ||
| } | ||
| allocatedPorts = ports | ||
| } |
There was a problem hiding this comment.
When an agent container is recreated or restarted, the existing container is deleted via m.Runtime.Delete directly, which bypasses the manager's Delete method and does not release the allocated ports back to the pool. Consequently, calling Allocate again for the same agent name will leak the previously allocated ports, eventually leading to port pool exhaustion.
To prevent this resource leak, release any existing ports for the agent name from the pool before allocating new ones.
| if m.PortPool != nil { | |
| ports, err := m.PortPool.Allocate(opts.Name, m.PortPool.PerAgent()) | |
| if err != nil { | |
| return nil, fmt.Errorf("port allocation failed: %w", err) | |
| } | |
| allocatedPorts = ports | |
| } | |
| if m.PortPool != nil { | |
| m.PortPool.Release(opts.Name) | |
| ports, err := m.PortPool.Allocate(opts.Name, m.PortPool.PerAgent()) | |
| if err != nil { | |
| return nil, fmt.Errorf("port allocation failed: %w", err) | |
| } | |
| allocatedPorts = ports | |
| } |
| } else { | ||
| mgr.PortPool = runtime.NewPortPool(pMin, pMax, perAgent, pp.HostURL) | ||
| log.Printf("Port pool initialized: range %d-%d, %d ports per agent, host_url=%q", pMin, pMax, perAgent, pp.HostURL) | ||
| } |
There was a problem hiding this comment.
If pp.HostURL is configured with a trailing slash (e.g., http://35.232.118.211/), the constructed agent port URLs will contain an invalid double-slash/colon sequence (e.g., http://35.232.118.211/:8000).
Trimming any trailing slashes from the host URL during initialization ensures that the constructed URLs are always valid.
} else {
hostURL := strings.TrimRight(pp.HostURL, "/")
mgr.PortPool = runtime.NewPortPool(pMin, pMax, perAgent, hostURL)
log.Printf("Port pool initialized: range %d-%d, %d ports per agent, host_url=%q", pMin, pMax, perAgent, hostURL)
}
Why
Agents frequently need to run local web servers — dev servers, preview apps, browser-based verification — but have no guaranteed way to get unique, non-colliding host ports. With multiple agents running concurrently on the same broker, even if you exported ports, they would collide. Agents also have no way to tell users a URL where they can access a locally running service.
What
A broker-managed port pool that allocates unique host ports to each agent at container creation time, exposed as environment variables:
AVAILABLE_LOCALHOST_PORT_A,AVAILABLE_LOCALHOST_PORT_B— raw port numbersAVAILABLE_LOCALHOST_URL_A,AVAILABLE_LOCALHOST_URL_B— full URLs (whenhost_urlis configured)Ports are published via
docker -pso they're reachable from the host. Allocation happens onscion start, release onscion stop/scion delete/ start failure.Configuration
Agent usage
How
New files
pkg/runtime/portpool.go— Thread-safe port allocator (sync.Mutex, lowest-available-port selection, allocate/release/query)pkg/runtime/portpool_test.go— Table-driven tests covering allocation, multi-agent, exhaustion, release/reuse, concurrent access (50 goroutines)Modified files
pkg/runtime/interface.go— AllocatedPorts and PortHostURL fields on RunConfigpkg/runtime/common.go— Env var injection + -p port publishing in buildCommonRunArgs() (covers Docker, Podman, Apple Container)pkg/runtime/k8s_runtime.go— Env var injection in buildPod() for Kubernetespkg/api/types.go— PortPoolConfig struct + ParsePortRange() with validation (1-65535)pkg/config/settings_v1.go— V1PortPoolConfig on V1BrokerConfig for settings.yamlpkg/agent/run.go— Port allocation before RunConfig construction, release on start failurepkg/agent/manager.go— Port release in Stop() and Delete() paths, PortPool fieldcmd/server_foreground.go— Pool initialization from broker settings at startupDesign decisions
Test plan