From 55badc1e08456505f58e9a553da2d3c869156cac Mon Sep 17 00:00:00 2001 From: dave Date: Mon, 18 May 2026 13:49:07 +0000 Subject: [PATCH] huskies: merge 1139 story Per-project Dockerfile fragment so agents can extend their own sled image --- .../tech/CHAT_DRIVEN_PROJECT_BOOTSTRAP.md | 24 ++- .../src/chat/transport/matrix/new_project.rs | 177 +++++++++++++++++- 2 files changed, 196 insertions(+), 5 deletions(-) diff --git a/.huskies/specs/tech/CHAT_DRIVEN_PROJECT_BOOTSTRAP.md b/.huskies/specs/tech/CHAT_DRIVEN_PROJECT_BOOTSTRAP.md index f2cc6ac1..8dcc358f 100644 --- a/.huskies/specs/tech/CHAT_DRIVEN_PROJECT_BOOTSTRAP.md +++ b/.huskies/specs/tech/CHAT_DRIVEN_PROJECT_BOOTSTRAP.md @@ -113,9 +113,27 @@ Layered: - **`huskies-project-base`**: debian-slim + git + huskies binary + sshd + sudo + a `huskies` user with the SSH pubkey installed. -- **`huskies-stack-`**: per-stack additions. E.g. rust gets - `rustup` + `rust-analyzer` + `cargo-nextest`; node gets `node@22` + - `typescript-language-server`; etc. +- **`huskies-project-`**: per-stack additions, pre-built by + `script/build-project-images`. E.g. rust gets `rustup` + + `rust-analyzer` + `cargo-nextest`; node gets `node@22` + + `typescript-language-server`; etc. Stack fragments live in + `docker/stacks//Dockerfile.fragment`. +- **`huskies-project-local-`** *(optional)*: built on the fly at + container launch time when the project contains + `.huskies/Dockerfile.fragment`. This file is appended after the + stack overlay (`FROM huskies-project-`) so agents can extend + their own image without editing shared stack files. Because the + fragment lives inside the bind-mounted `/workspace/.huskies/`, changes + survive container recreation and are committed alongside the project + source. The `project-rebuild` command picks up the fragment + automatically when rebuilding. + + Example `.huskies/Dockerfile.fragment` that adds `jq`: + + ```dockerfile + RUN apt-get update && apt-get install -y jq + ``` + - **Project layer**: the bind-mounted `/workspace` is the project source, written by the host's editor, read by the in-container tooling. diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs index 29545016..8352e8c6 100644 --- a/server/src/chat/transport/matrix/new_project.rs +++ b/server/src/chat/transport/matrix/new_project.rs @@ -698,7 +698,11 @@ async fn handle_adopt_project( Some(s) => (Some(s.to_string()), vec![]), None => detect_stack(host_path, &stacks_dir), }; - let image = image_for_stack(resolved_stack.as_deref()); + let stack_image = image_for_stack(resolved_stack.as_deref()); + let image = match build_project_image(host_path, &stack_image, name).await { + Ok(img) => img, + Err(e) => return format!("Failed to build project-specific image: {e}"), + }; // ── Generate SSH keypair ───────────────────────────────────────────────── let ssh_key_dir = std::path::PathBuf::from(home).join(".huskies").join(name); @@ -1038,7 +1042,17 @@ pub async fn handle_new_project( Some(s) => (Some(s.to_string()), vec![]), None => detect_stack(&host_path, &stacks_dir), }; - let image = image_for_stack(resolved_stack.as_deref()); + let stack_image = image_for_stack(resolved_stack.as_deref()); + let image = match build_project_image(&host_path, &stack_image, name).await { + Ok(img) => img, + Err(e) => { + let _ = tokio::fs::remove_dir_all(&host_path).await; + return format!( + "Failed to build project-specific image: {e}\n\nPartial state removed at `{}`.", + host_path.display() + ); + } + }; // ── Generate SSH keypair ───────────────────────────────────────────────── // Private key: ~/.huskies//id_ed25519 (host-side, mode 600 by ssh-keygen) @@ -1248,6 +1262,67 @@ pub async fn handle_new_project( } } +/// Compose the Dockerfile content used to build a project-specific image. +/// +/// Concatenates `FROM {base_image}` with the project's own fragment so the +/// result can be piped directly to `docker build -`. +pub fn dockerfile_for_project(base_image: &str, fragment: &str) -> String { + format!("FROM {base_image}\n{fragment}") +} + +/// Extend `base_image` with the project's own `Dockerfile.fragment` and build +/// a project-specific Docker image. +/// +/// Looks for `{project_path}/.huskies/Dockerfile.fragment`. When the file is +/// absent the `base_image` is returned unchanged so the caller can skip the +/// build step entirely. When the file exists its content is appended to +/// `FROM {base_image}` and piped to `docker build -t huskies-project-local-{project_name} -`. +/// +/// Returns `Ok(image_name)` on success or `Err(message)` if the build fails. +pub async fn build_project_image( + project_path: &Path, + base_image: &str, + project_name: &str, +) -> Result { + let fragment_path = project_path.join(".huskies").join("Dockerfile.fragment"); + let Ok(fragment) = tokio::fs::read_to_string(&fragment_path).await else { + return Ok(base_image.to_string()); + }; + + let project_image = format!("huskies-project-local-{project_name}"); + let dockerfile_content = dockerfile_for_project(base_image, &fragment); + + let mut child = tokio::process::Command::new("docker") + .args(["build", "-t", &project_image, "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("docker build failed to start: {e}"))?; + + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + stdin + .write_all(dockerfile_content.as_bytes()) + .await + .map_err(|e| format!("Failed to write Dockerfile to docker build stdin: {e}"))?; + } + + let output = child + .wait_with_output() + .await + .map_err(|e| format!("docker build failed: {e}"))?; + + if output.status.success() { + Ok(project_image) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "docker build for project image `{project_image}` failed:\n{stderr}" + )) + } +} + /// Build the base `docker run` argument list for a project container. /// /// Includes `-e HUSKIES_HOST=0.0.0.0` so the server inside the container binds @@ -1830,6 +1905,104 @@ mod tests { ); } + // ── Dockerfile fragment ────────────────────────────────────────────────── + + #[test] + fn dockerfile_for_project_prepends_from() { + let content = dockerfile_for_project("huskies-project-rust", "RUN echo hello\n"); + assert_eq!( + content, "FROM huskies-project-rust\nRUN echo hello\n", + "Dockerfile should start with FROM line followed by fragment" + ); + } + + #[test] + fn dockerfile_for_project_base_image() { + let content = dockerfile_for_project("huskies-project-base", ""); + assert_eq!(content, "FROM huskies-project-base\n"); + } + + #[tokio::test] + async fn build_project_image_no_fragment_returns_base_image() { + let dir = tempfile::tempdir().unwrap(); + // .huskies/ exists but contains no Dockerfile.fragment + std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); + + let result = build_project_image(dir.path(), "huskies-project-rust", "myapp").await; + assert_eq!( + result.unwrap(), + "huskies-project-rust", + "should return base image unchanged when no fragment is present" + ); + } + + #[tokio::test] + async fn build_project_image_missing_huskies_dir_returns_base_image() { + // No .huskies/ directory at all. + let dir = tempfile::tempdir().unwrap(); + + let result = build_project_image(dir.path(), "huskies-project-base", "myapp").await; + assert_eq!( + result.unwrap(), + "huskies-project-base", + "should return base image unchanged when .huskies/ dir is absent" + ); + } + + /// End-to-end test: a project fragment that installs `jq` produces an + /// image where `docker run ... which jq` exits successfully. + /// + /// Skipped when Docker is not available in the test environment. + #[tokio::test] + async fn build_project_image_fragment_installs_jq() { + // Skip if docker is not available. + let docker_check = tokio::process::Command::new("docker") + .args(["info"]) + .output() + .await; + if docker_check.map(|o| !o.status.success()).unwrap_or(true) { + return; + } + + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); + std::fs::write( + dir.path().join(".huskies").join("Dockerfile.fragment"), + "RUN apt-get update && apt-get install -y jq\n", + ) + .unwrap(); + + let result = + build_project_image(dir.path(), "debian:bookworm-slim", "test-jq-fragment").await; + let image = match result { + Ok(img) => img, + Err(e) => { + // Docker is available but the build failed — likely the base image + // is not cached and pull failed in an offline environment. Skip. + eprintln!("build_project_image_fragment_installs_jq: skipped ({e})"); + return; + } + }; + + assert_eq!(image, "huskies-project-local-test-jq-fragment"); + + let which = tokio::process::Command::new("docker") + .args(["run", "--rm", &image, "which", "jq"]) + .output() + .await + .expect("docker run should not fail to start"); + assert!( + which.status.success(), + "`which jq` should succeed inside the built image" + ); + + // Clean up the test image. + let _ = tokio::process::Command::new("docker") + .args(["rmi", &image]) + .output() + .await; + } + #[test] fn detect_stack_jvm_build_gradle_kts() { let dir = tempfile::tempdir().unwrap();