huskies: merge 1139 story Per-project Dockerfile fragment so agents can extend their own sled image
This commit is contained in:
@@ -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