huskies: merge 1139 story Per-project Dockerfile fragment so agents can extend their own sled image

This commit is contained in:
dave
2026-05-18 13:49:07 +00:00
parent bdc621fb36
commit 55badc1e08
2 changed files with 196 additions and 5 deletions
@@ -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-<stack>`**: per-stack additions. E.g. rust gets
`rustup` + `rust-analyzer` + `cargo-nextest`; node gets `node@22` +
`typescript-language-server`; etc.
- **`huskies-project-<stack>`**: 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/<stack>/Dockerfile.fragment`.
- **`huskies-project-local-<name>`** *(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-<stack>`) 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.
+175 -2
View File
@@ -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/<name>/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<String, String> {
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();