diff --git a/script/build-project-images b/script/build-project-images new file mode 100755 index 00000000..7b8a53fe --- /dev/null +++ b/script/build-project-images @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build all project images in dependency order: +# huskies → huskies-project-base → huskies-project- (one per stack fragment) +# +# Run this after `script/docker_rebuild` or whenever you add a new stack. +# Safe to re-run: each step re-tags the image with the latest layers. + +cd "$(dirname "$0")/.." + +if [[ -f .env ]]; then + set -a + source .env + set +a +fi + +CACHE_FLAG="" +if [[ "${1:-}" == "--no-cache" ]]; then + CACHE_FLAG="--no-cache" +fi + +echo "==> Building huskies" +docker build $CACHE_FLAG -t huskies -f docker/Dockerfile . + +echo "==> Building huskies-project-base" +docker build $CACHE_FLAG -t huskies-project-base -f docker/Dockerfile.base . + +for fragment in docker/stacks/*/Dockerfile.fragment; do + stack=$(basename "$(dirname "$fragment")") + image="huskies-project-${stack}" + echo "==> Building ${image}" + (printf 'FROM huskies-project-base\n'; cat "$fragment") \ + | docker build $CACHE_FLAG -t "$image" - +done + +echo "All project images built." diff --git a/script/docker_rebuild b/script/docker_rebuild index 876c85a8..280cebca 100755 --- a/script/docker_rebuild +++ b/script/docker_rebuild @@ -24,4 +24,6 @@ docker compose -f docker/docker-compose.yml down docker compose -f docker/docker-compose.yml build $CACHE_FLAG docker compose -f docker/docker-compose.yml up -d +script/build-project-images $CACHE_FLAG + echo "Rebuild complete. Logs: docker compose -f docker/docker-compose.yml logs -f" diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs index b85a84e1..27569de4 100644 --- a/server/src/chat/transport/matrix/new_project.rs +++ b/server/src/chat/transport/matrix/new_project.rs @@ -518,7 +518,7 @@ async fn handle_adopt_project( Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; - format!("Docker container launch failed: {}", stderr.trim()) + interpret_docker_run_error(&stderr, &image) } Err(e) => { let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; @@ -891,9 +891,9 @@ pub async fn handle_new_project( let stderr = String::from_utf8_lossy(&out.stderr); let _ = tokio::fs::remove_dir_all(&host_path).await; let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; + let base_msg = interpret_docker_run_error(&stderr, &image); format!( - "Docker container launch failed: {}\n\nPartial state removed at `{}`.", - stderr.trim(), + "{base_msg}\n\nPartial state removed at `{}`.", host_path.display() ) } @@ -908,6 +908,22 @@ pub async fn handle_new_project( } } +/// Convert a failed `docker run` stderr into an actionable chat message. +/// +/// When Docker cannot find the image locally it prints `Unable to find image`. +/// That bare error is unhelpful — this function maps it to a message that tells +/// the user which script to run. All other failures are passed through as-is. +fn interpret_docker_run_error(stderr: &str, image: &str) -> String { + if stderr.contains("Unable to find image") { + format!( + "Image `{image}` not found locally. \ + Build project images first by running `script/build-project-images`." + ) + } else { + format!("Docker container launch failed: {}", stderr.trim()) + } +} + /// Find a free TCP port by attempting to bind starting from `start`. /// /// Scans up to 100 ports above `start` and returns the first available one. @@ -1257,6 +1273,37 @@ mod tests { assert!(warnings.is_empty()); } + // ── Missing-image error path ───────────────────────────────────────────── + + #[test] + fn interpret_docker_run_error_missing_image_points_at_script() { + let stderr = "Unable to find image 'huskies-project-rust:latest' locally\n\ + docker: Error response from daemon: pull access denied"; + let msg = interpret_docker_run_error(stderr, "huskies-project-rust"); + assert!( + msg.contains("script/build-project-images"), + "expected script name in error message, got: {msg}" + ); + assert!( + msg.contains("huskies-project-rust"), + "expected image name in error message, got: {msg}" + ); + } + + #[test] + fn interpret_docker_run_error_other_failure_passes_through() { + let stderr = "Error response from daemon: container name already in use"; + let msg = interpret_docker_run_error(stderr, "huskies-project-rust"); + assert!( + msg.contains("Docker container launch failed"), + "expected generic error message, got: {msg}" + ); + assert!( + msg.contains("container name already in use"), + "expected original stderr in message, got: {msg}" + ); + } + #[test] fn detect_stack_jvm_build_gradle_kts() { let dir = tempfile::tempdir().unwrap();