huskies: merge 1118 story Automate per-project docker image builds (huskies-project-base + per-stack overlays)

This commit is contained in:
dave
2026-05-17 16:21:11 +00:00
parent 265e6f9a15
commit f8b1e14b74
3 changed files with 89 additions and 3 deletions
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
# Build all project images in dependency order:
# huskies → huskies-project-base → huskies-project-<stack> (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."
+2
View File
@@ -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"
@@ -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();