From 9a5b6f4d920877b854eae63a321373120bb4e3ba Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 19 May 2026 17:50:01 +0000 Subject: [PATCH] huskies: merge 1152 story Set HUSKIES_GATEWAY_URL on every sled container so 1136's relay actually spawns --- docker/docker-compose.yml | 3 + .../src/chat/transport/matrix/new_project.rs | 50 +++++++- .../chat/transport/matrix/project_rebuild.rs | 1 + server/src/gateway_relay.rs | 111 ++++++++++++++++++ 4 files changed, 164 insertions(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ae21b3c8..b2167d3a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -29,6 +29,9 @@ services: - HUSKIES_PORT=3001 # Bind to all interfaces so Docker port forwarding works. - HUSKIES_HOST=0.0.0.0 + # Gateway URL so this sled's relay task forwards CRDT events to the gateway. + # Uses host.docker.internal so the container can reach the gateway on the host. + - HUSKIES_GATEWAY_URL=http://host.docker.internal:3000 # Optional: Matrix bot credentials (if using Matrix integration) - MATRIX_HOMESERVER=${MATRIX_HOMESERVER:-} - MATRIX_USER=${MATRIX_USER:-} diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs index 16c1d6f4..7bfb1d5d 100644 --- a/server/src/chat/transport/matrix/new_project.rs +++ b/server/src/chat/transport/matrix/new_project.rs @@ -756,6 +756,7 @@ async fn handle_adopt_project( &git_user_name, &git_user_email, Some(&credentials_file), + &resolve_gateway_url(), ); docker_args.push("-v".into()); @@ -1142,6 +1143,7 @@ pub async fn handle_new_project( &git_user_name, &git_user_email, Some(&credentials_file), + &resolve_gateway_url(), ); // HTTPS push token: passed as env vars consumed by the entrypoint credential helper. @@ -1330,11 +1332,15 @@ pub async fn build_project_image( /// Without this the server defaults to `127.0.0.1` inside the container — /// reachable only from within the container itself, not via `docker -p`. /// +/// When `gateway_url` is non-empty, `-e HUSKIES_GATEWAY_URL=` is added so +/// the sled's relay task connects back to the gateway and forwards CRDT events. +/// /// When `credentials_path` is `Some`, the file is bind-mounted read-only at /// `/run/claude-credentials-src` so the container entrypoint can copy it into /// `/home/huskies/.claude/.credentials.json` with mode 0600. Mounting to an /// intermediate path (rather than directly to the destination) ensures the /// huskies user owns the copy regardless of the host user's UID. +#[allow(clippy::too_many_arguments)] pub(crate) fn project_docker_run_args( container_name: &str, port: u16, @@ -1343,6 +1349,7 @@ pub(crate) fn project_docker_run_args( git_user_name: &str, git_user_email: &str, credentials_path: Option<&std::path::Path>, + gateway_url: &str, ) -> Vec { let mut args = vec![ "run".into(), @@ -1364,6 +1371,10 @@ pub(crate) fn project_docker_run_args( "-e".into(), format!("GIT_USER_EMAIL={git_user_email}"), ]; + if !gateway_url.is_empty() { + args.push("-e".into()); + args.push(format!("HUSKIES_GATEWAY_URL={gateway_url}")); + } if let Some(creds) = credentials_path { args.push("-v".into()); args.push(format!( @@ -1374,6 +1385,16 @@ pub(crate) fn project_docker_run_args( args } +/// Resolve the gateway URL to inject into project sled containers. +/// +/// Reads `HUSKIES_GATEWAY_URL` from the environment first; falls back to +/// `http://host.docker.internal:3000` so containers launched by the gateway +/// can always relay events back without explicit configuration. +pub(crate) fn resolve_gateway_url() -> String { + std::env::var("HUSKIES_GATEWAY_URL") + .unwrap_or_else(|_| "http://host.docker.internal:3000".to_string()) +} + /// Convert a failed `docker run` stderr into an actionable chat message. /// /// When Docker cannot find the image locally it prints `Unable to find image`. @@ -1792,8 +1813,8 @@ mod tests { "Test User", "test@example.com", None, + "http://host.docker.internal:3000", ); - // Find "-e" followed by "HUSKIES_HOST=0.0.0.0" let pairs: Vec<_> = args.windows(2).collect(); assert!( pairs @@ -1807,6 +1828,31 @@ mod tests { .any(|w| w[0] == "-e" && w[1] == "HUSKIES_PORT=3001"), "expected -e HUSKIES_PORT=3001 in docker args, got: {args:?}" ); + assert!( + pairs + .iter() + .any(|w| w[0] == "-e" + && w[1] == "HUSKIES_GATEWAY_URL=http://host.docker.internal:3000"), + "expected -e HUSKIES_GATEWAY_URL=http://host.docker.internal:3000 in docker args, got: {args:?}" + ); + } + + #[test] + fn project_docker_args_no_gateway_url_when_empty() { + let args = project_docker_run_args( + "huskies-myapp", + 3100, + 2200, + "ssh-ed25519 AAAA...", + "Test User", + "test@example.com", + None, + "", + ); + assert!( + !args.iter().any(|a| a.contains("HUSKIES_GATEWAY_URL")), + "expected no HUSKIES_GATEWAY_URL when gateway_url is empty, got: {args:?}" + ); } #[test] @@ -1820,6 +1866,7 @@ mod tests { "Test User", "test@example.com", Some(creds), + "", ); let pairs: Vec<_> = args.windows(2).collect(); assert!( @@ -1839,6 +1886,7 @@ mod tests { "Test User", "test@example.com", None, + "", ); assert!( !args.iter().any(|a| a.contains("claude-credentials-src")), diff --git a/server/src/chat/transport/matrix/project_rebuild.rs b/server/src/chat/transport/matrix/project_rebuild.rs index bce83e83..75fd434f 100644 --- a/server/src/chat/transport/matrix/project_rebuild.rs +++ b/server/src/chat/transport/matrix/project_rebuild.rs @@ -233,6 +233,7 @@ pub async fn handle_project_rebuild( &git_user_name, &git_user_email, creds_opt, + &super::new_project::resolve_gateway_url(), ); docker_args.push("-v".into()); diff --git a/server/src/gateway_relay.rs b/server/src/gateway_relay.rs index 8f0dc2ab..5d2c84b8 100644 --- a/server/src/gateway_relay.rs +++ b/server/src/gateway_relay.rs @@ -365,4 +365,115 @@ mod tests { received.event ); } + + /// Extends `relay_end_to_end_stage_transition_reaches_gateway_broadcast` to + /// cover the full wiring path: `project_docker_run_args` embeds + /// `HUSKIES_GATEWAY_URL` in the sled's argv; when that URL is used to start + /// the relay, a transition fired inside the sled reaches the gateway's CRDT + /// event_log within 1 second. + #[tokio::test] + async fn project_docker_run_args_gateway_url_wires_relay() { + use crate::chat::transport::matrix::new_project::project_docker_run_args; + use crate::http::gateway::{gateway_event_push_handler, gateway_generate_token_handler}; + use crate::service::gateway::{GatewayConfig, GatewayState, ProjectEntry}; + use poem::EndpointExt as _; + use poem::listener::TcpAcceptor; + use std::collections::BTreeMap; + use std::path::PathBuf; + use tokio::net::TcpListener; + + crate::crdt_state::init_for_test(); + + // Spin up an in-process gateway server on an ephemeral port so we have + // a real URL to embed in the docker run args. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let gateway_url = format!("http://127.0.0.1:{}", addr.port()); + + // project_docker_run_args embeds the gateway URL: this is the production + // code path that sets HUSKIES_GATEWAY_URL on the sled container. + let docker_args = project_docker_run_args( + "huskies-sled-relay", + 3200, + 2300, + "ssh-ed25519 AAAA...", + "Test User", + "test@example.com", + None, + &gateway_url, + ); + + // Extract the injected URL exactly as the sled would read it from its env. + let injected_url = docker_args + .windows(2) + .find(|w| w[0] == "-e" && w[1].starts_with("HUSKIES_GATEWAY_URL=")) + .map(|w| w[1].trim_start_matches("HUSKIES_GATEWAY_URL=").to_string()) + .expect("project_docker_run_args must inject HUSKIES_GATEWAY_URL"); + + assert_eq!(injected_url, gateway_url, "injected URL must match input"); + + // Set up gateway state for the relay project. + let mut projects = BTreeMap::new(); + projects.insert( + "sled-relay".to_string(), + ProjectEntry::with_url("http://sled-relay:3001"), + ); + let config = GatewayConfig { + projects, + sled_tokens: BTreeMap::new(), + }; + let state = Arc::new(GatewayState::new(config, PathBuf::new(), 9001).unwrap()); + let mut gw_rx = state.event_tx.subscribe(); + + let route = poem::Route::new() + .at( + "/gateway/tokens", + poem::post(gateway_generate_token_handler), + ) + .at( + "/gateway/events/push", + poem::get(gateway_event_push_handler), + ) + .data(state.clone()); + + tokio::spawn(async move { + let acceptor = TcpAcceptor::from_tokio(listener).unwrap(); + let _ = poem::Server::new_with_acceptor(acceptor).run(route).await; + }); + + // Spawn the relay using the URL extracted from the docker run args — + // this simulates what the sled does when it reads HUSKIES_GATEWAY_URL + // from its container environment. + let broadcaster = Arc::new(StatusBroadcaster::new()); + spawn_relay_task( + injected_url, + "sled-relay".into(), + Arc::clone(&broadcaster), + reqwest::Client::new(), + ); + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + broadcaster.publish(StatusEvent::StageTransition { + story_id: "99_docker_args_relay".into(), + story_name: "Docker Args Relay".into(), + from_stage: "1_backlog".into(), + to_stage: "2_current".into(), + }); + + let received = tokio::time::timeout(std::time::Duration::from_secs(1), gw_rx.recv()) + .await + .expect("timed out: event did not reach gateway within 1 s") + .expect("gateway broadcast channel closed unexpectedly"); + + assert_eq!(received.project, "sled-relay"); + assert!( + matches!( + received.event, + StoredEvent::StageTransition { ref story_id, .. } if story_id == "99_docker_args_relay" + ), + "unexpected gateway event: {:?}", + received.event + ); + } }