Plan: Dynamic Ephemeral Port Allocation

On this page

Status

Step Description Status

1

Plan document + GitLab issue + branch

Not started

2

Port registry in xtask/src/docker.rs (reserve, discover, .ports.env I/O)

Not started

3

docker-compose.yml — 20 port lines → ${CRAIG_PORT_*:-default}

Not started

4

xtask/src/cmd/dev.rs — wire reservation into start, URL table, dynamic status

Not started

5

xtask/src/cmd/security.rs — replace SERVICE_PORTS + 12 hardcoded URLs

Not started

6

xtask/src/cmd/perf.rs — dynamic Keycloak URL

Not started

7

xtask/src/cmd/e2e.rs — load .ports.env before compose

Not started

8

.gitignore + documentation updates

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.yml become ${CRAIG_PORT_*:-default}

  • xtask/src/docker.rs gains port reservation, discovery, and .ports.env I/O

  • All hardcoded localhost:PORT references in xtask commands become dynamic

  • .ports.env file persists port mappings across terminal sessions

  • cargo xtask dev start prints 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:8001 on 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

  1. Port Reservation: Before docker compose up, xtask binds TcpListener on 127.0.0.1:0 for each of 20 service ports. The OS assigns unique ephemeral ports. Listeners are dropped to release the ports.

  2. Compose Up: Port assignments are exported as CRAIG_PORT_* env vars. docker compose up inherits 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.

.ports.env Persistence

After health checks pass, cargo xtask dev start writes .ports.env to the project root (gitignored). Subsequent xtask commands (e2e, security, perf) load this file at startup to discover port mappings without re-querying Docker.

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_MAPPINGS constant: 16 entries mapping (service, container_port, default_host_port)

  • port_env_var(service, port) → String: generates CRAIG_PORT_CRAIG_WEB_8080 format

  • reserve_ports() → Result<HashMap<(String, u16), u16>>: binds TcpListener on 127.0.0.1:0, records port, drops listener

  • export_port_env_vars(ports): calls set_var for each CRAIG_PORT_* plus computed KC_HOSTNAME, OIDC_ISSUER, WEB_EXTERNAL_URL, CRAIG_E2E_BASE_URL, CRAIG_INTAKE_URL, CRAIG_INTAKE_STANDALONE_URL

  • discover_port(service, container_port) → Result<u16>: runs docker compose port <service> <port>, parses 0.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.env with all port + derived vars

  • load_and_export_ports_env() → Result<()>: read .ports.env, set_var each line; fall back to discover_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

xtask/src/docker.rs

+~150 lines: PORT_MAPPINGS, reserve/discover/env functions

docker-compose.yml

20 port lines → ${CRAIG_PORT_*:-default} interpolation

xtask/src/cmd/dev.rs

Wire reservation into start, URL table, dynamic status display

xtask/src/cmd/security.rs

Replace SERVICE_PORTS constant + 12 hardcoded localhost URLs

xtask/src/cmd/perf.rs

Dynamic Keycloak URL (1 line)

xtask/src/cmd/e2e.rs

Load .ports.env (2 lines)

.gitignore

Add .ports.env

Verification

  1. cargo nextest run -p xtask — unit tests pass (port naming, uniqueness)

  2. cargo xtask dev restart — starts with ephemeral ports, prints URL table

  3. .ports.env exists with valid port mappings after start

  4. cargo xtask dev status — shows discovered dynamic ports

  5. cargo xtask e2e — E2E passes with dynamic ports

  6. cargo xtask security --skip-zap --skip-fuzz — phases 2-4 pass with dynamic ports

  7. cargo xtask perf --profile smoke — k6 smoke passes

  8. Manual docker compose up (no xtask) — still works with default ports

  9. 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

Edit this page · latest