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
|
- **`huskies-project-base`**: debian-slim + git + huskies binary + sshd
|
||||||
+ sudo + a `huskies` user with the SSH pubkey installed.
|
+ sudo + a `huskies` user with the SSH pubkey installed.
|
||||||
- **`huskies-stack-<stack>`**: per-stack additions. E.g. rust gets
|
- **`huskies-project-<stack>`**: per-stack additions, pre-built by
|
||||||
`rustup` + `rust-analyzer` + `cargo-nextest`; node gets `node@22` +
|
`script/build-project-images`. E.g. rust gets `rustup` +
|
||||||
`typescript-language-server`; etc.
|
`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,
|
- **Project layer**: the bind-mounted `/workspace` is the project source,
|
||||||
written by the host's editor, read by the in-container tooling.
|
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![]),
|
Some(s) => (Some(s.to_string()), vec![]),
|
||||||
None => detect_stack(host_path, &stacks_dir),
|
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 ─────────────────────────────────────────────────
|
// ── Generate SSH keypair ─────────────────────────────────────────────────
|
||||||
let ssh_key_dir = std::path::PathBuf::from(home).join(".huskies").join(name);
|
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![]),
|
Some(s) => (Some(s.to_string()), vec![]),
|
||||||
None => detect_stack(&host_path, &stacks_dir),
|
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 ─────────────────────────────────────────────────
|
// ── Generate SSH keypair ─────────────────────────────────────────────────
|
||||||
// Private key: ~/.huskies/<name>/id_ed25519 (host-side, mode 600 by ssh-keygen)
|
// 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.
|
/// 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
|
/// 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]
|
#[test]
|
||||||
fn detect_stack_jvm_build_gradle_kts() {
|
fn detect_stack_jvm_build_gradle_kts() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user