Plan: Dynamic Ephemeral Port Allocation
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1: Port registry in
xtask/src/docker.rs - Step 2:
docker-compose.yml— variable port mappings - Step 3: Wire into
xtask/src/cmd/dev.rs - Step 4: Dynamic URLs in
xtask/src/cmd/security.rs - Step 5: Dynamic Keycloak in
xtask/src/cmd/perf.rs - Step 6: Load ports in
xtask/src/cmd/e2e.rs - Step 7:
.gitignore+ documentation - Step 8: Unit tests
- Step 1: Port registry in
- Files Touched
- Verification
- Documentation Updates
Status
| Step | Description | Status |
|---|---|---|
1 |
Plan document + GitLab issue + branch |
Not started |
2 |
Port registry in |
Not started |
3 |
|
Not started |
4 |
|
Not started |
5 |
|
Not started |
6 |
|
Not started |
7 |
|
Not started |
8 |
|
Not started |
9 |
Unit tests + full verification |
Not started |
Issue: TBD
Branch: feature/ephemeral-ports
Context
CRAIG and Canopy both use Docker Compose with hardcoded host ports (5432, 8080, etc.). When both devstacks run simultaneously on the same machine, port collisions cause startup failures. Additionally, Hyper-V dynamic port reservations on Windows can steal ports (e.g., range 8148-8247 blocking Keycloak’s 8180).
The fix: let Docker assign random ephemeral host ports and have all xtask commands discover them at runtime. Container-to-container communication stays on internal Docker DNS (unchanged — services still talk via http://craig-rules:8001 on the craig_default network).
Scope
In scope:
-
All 20 host port mappings in
docker-compose.ymlbecome${CRAIG_PORT_*:-default} -
xtask/src/docker.rsgains port reservation, discovery, and.ports.envI/O -
All hardcoded
localhost:PORTreferences in xtask commands become dynamic -
.ports.envfile persists port mappings across terminal sessions -
cargo xtask dev startprints a URL table for developer convenience -
Backward compatibility: manual
docker compose up(without xtask) uses default ports
Out of scope:
-
Internal Docker DNS names (these never change —
http://craig-rules:8001on the network) -
Container-internal ports (each service still listens on its fixed port inside the container)
-
Production/Kubernetes deployment (Helm handles port allocation differently)
Design
Two-Phase Start
-
Port Reservation: Before
docker compose up, xtask bindsTcpListeneron127.0.0.1:0for each of 20 service ports. The OS assigns unique ephemeral ports. Listeners are dropped to release the ports. -
Compose Up: Port assignments are exported as
CRAIG_PORT_*env vars.docker compose upinherits them via${CRAIG_PORT_*:-default}interpolation in the compose file.
Keycloak Constraint
Keycloak’s KC_HOSTNAME bakes the host port into JWT iss claims at startup. Every CRAIG service validates iss at token verification. The browser also hits Keycloak at this URL for OIDC redirects. Therefore, KC_HOSTNAME, OIDC_ISSUER, and WEB_EXTERNAL_URL must all agree on the Keycloak host port, and this port must be known before containers start.
Solution: export_port_env_vars() computes these derived values from the reserved ports and sets them as env vars before invoking docker compose up. The compose file’s existing ${KC_HOSTNAME:-…} and ${OIDC_ISSUER:-…} interpolation picks them up.
Steps
Step 1: Port registry in xtask/src/docker.rs
Files: xtask/src/docker.rs
Add after line 70 (after is_healthy). ~150 lines of new code:
-
PORT_MAPPINGSconstant: 16 entries mapping(service, container_port, default_host_port) -
port_env_var(service, port) → String: generatesCRAIG_PORT_CRAIG_WEB_8080format -
reserve_ports() → Result<HashMap<(String, u16), u16>>: bindsTcpListeneron127.0.0.1:0, records port, drops listener -
export_port_env_vars(ports): callsset_varfor eachCRAIG_PORT_*plus computedKC_HOSTNAME,OIDC_ISSUER,WEB_EXTERNAL_URL,CRAIG_E2E_BASE_URL,CRAIG_INTAKE_URL,CRAIG_INTAKE_STANDALONE_URL -
discover_port(service, container_port) → Result<u16>: runsdocker compose port <service> <port>, parses0.0.0.0:NNNNN -
discover_all_ports() → Result<HashMap>: bulk discovery for all PORT_MAPPINGS -
get_host_port(service, container_port) → Result<u16>: check env var first, fall back to discovery -
get_host_url(service, container_port) → Result<String>:format!("http://localhost:{port}") -
write_ports_env(ports) → Result<()>: write.ports.envwith all port + derived vars -
load_and_export_ports_env() → Result<()>: read.ports.env,set_vareach line; fall back todiscover_all_ports()if file missing
pub const PORT_MAPPINGS: &[(&str, u16, u16)] = &[
("postgres", 5432, 5432),
("rabbitmq", 5672, 5672),
("rabbitmq", 15672, 15672),
("keycloak", 8080, 8180),
("garage", 3900, 3900),
("garage", 3903, 3903),
("craig-rules", 8001, 8001),
("craig-cases", 8002, 8002),
("craig-placement", 8003, 8003),
("craig-exchange", 8004, 8004),
("craig-financial", 8005, 8005),
("craig-reporting", 8006, 8006),
("craig-security", 8007, 8007),
("craig-intake", 8008, 8008),
("craig-intake-standalone", 8009, 8009),
("craig-web", 8080, 8080),
];
pub fn reserve_ports() -> Result<HashMap<(String, u16), u16>> {
let mut ports = HashMap::new();
for &(service, container_port, _default) in PORT_MAPPINGS {
let listener = TcpListener::bind("127.0.0.1:0")
.with_context(|| format!("failed to reserve port for {service}:{container_port}"))?;
let host_port = listener.local_addr()?.port();
drop(listener);
ports.insert((service.to_string(), container_port), host_port);
}
Ok(ports)
}
Step 2: docker-compose.yml — variable port mappings
Files: docker-compose.yml
Change all 20 ports: entries from "HOST:CONTAINER" to "${CRAIG_PORT_SERVICE_CONTAINER:-DEFAULT}:CONTAINER". Environment sections (OIDC_ISSUER, KC_HOSTNAME, etc.) are unchanged — xtask sets these as fully-formed env vars.
Example:
# Before
craig-web:
ports:
- "8080:8080"
# After
craig-web:
ports:
- "${CRAIG_PORT_CRAIG_WEB_8080:-8080}:8080"
Step 3: Wire into xtask/src/cmd/dev.rs
Files: xtask/src/cmd/dev.rs
In start(): call reserve_ports() before compose up, export_port_env_vars(), then after health checks call write_ports_env() and print_url_table().
Replace hardcoded service_ports() match with docker::get_host_port() lookups.
Step 4: Dynamic URLs in xtask/src/cmd/security.rs
Files: xtask/src/cmd/security.rs
Replace SERVICE_PORTS constant and all 12+ hardcoded localhost:PORT URLs with docker::get_host_url() calls. ZAP auth hook gets dynamic Keycloak port. ffuf (Phase 5) uses Docker network — no change.
Step 5: Dynamic Keycloak in xtask/src/cmd/perf.rs
Files: xtask/src/cmd/perf.rs
Replace "OIDC_INTERNAL_URL=http://host.docker.internal:8180" with discovered port.
Step 6: Load ports in xtask/src/cmd/e2e.rs
Files: xtask/src/cmd/e2e.rs
Add docker::load_and_export_ports_env()? at start of run().
Step 7: .gitignore + documentation
Files: .gitignore, .claude/docs/local-dev.md, .claude/docs/testing.md, CHANGELOG.adoc
Add .ports.env to .gitignore. Document ephemeral ports, .ports.env, dev status for URLs.
Step 8: Unit tests
Files: xtask/src/docker.rs
#[test]
fn port_env_var_naming() {
assert_eq!(port_env_var("craig-web", 8080), "CRAIG_PORT_CRAIG_WEB_8080");
assert_eq!(port_env_var("postgres", 5432), "CRAIG_PORT_POSTGRES_5432");
assert_eq!(port_env_var("craig-intake-standalone", 8009), "CRAIG_PORT_CRAIG_INTAKE_STANDALONE_8009");
}
#[test]
fn reserve_ports_returns_unique() {
let ports = reserve_ports().unwrap();
let values: Vec<u16> = ports.values().copied().collect();
let unique: HashSet<u16> = values.iter().copied().collect();
assert_eq!(values.len(), unique.len());
}
Files Touched
| File | Change |
|---|---|
|
+~150 lines: PORT_MAPPINGS, reserve/discover/env functions |
|
20 port lines → |
|
Wire reservation into start, URL table, dynamic status display |
|
Replace SERVICE_PORTS constant + 12 hardcoded localhost URLs |
|
Dynamic Keycloak URL (1 line) |
|
Load .ports.env (2 lines) |
|
Add .ports.env |
Verification
-
cargo nextest run -p xtask— unit tests pass (port naming, uniqueness) -
cargo xtask dev restart— starts with ephemeral ports, prints URL table -
.ports.envexists with valid port mappings after start -
cargo xtask dev status— shows discovered dynamic ports -
cargo xtask e2e— E2E passes with dynamic ports -
cargo xtask security --skip-zap --skip-fuzz— phases 2-4 pass with dynamic ports -
cargo xtask perf --profile smoke— k6 smoke passes -
Manual
docker compose up(no xtask) — still works with default ports -
Run CRAIG and Canopy devstacks simultaneously — no port collisions
Documentation Updates
-
.claude/docs/local-dev.md— ephemeral ports,.ports.env, how to find URLs -
.claude/docs/testing.md— note dynamic ports in pre-push -
CHANGELOG.adoc— entry under== Unreleased