huskies: merge 1139 story Per-project Dockerfile fragment so agents can extend their own sled image
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user