huskies: merge 1108 story Chat bootstrap Phase 3: SSH-remote editor access into the project container (any editor)

This commit is contained in:
dave
2026-05-16 23:32:33 +00:00
parent efafe44db1
commit 59302b465d
6 changed files with 217 additions and 10 deletions
+124 -7
View File
@@ -1,4 +1,4 @@
//! `new project <name>` chat command — Phase 2a stack-overlay framework.
//! `new project <name>` chat command — Phase 3: SSH-remote editor access.
//!
//! Provisions a project container and registers it with the gateway.
//! The command is gateway-only: `new project <name> [--stack <stack>]`.
@@ -12,6 +12,13 @@
//! Stack images follow the naming convention `huskies-project-<stack>`.
//! The base image (no language tooling) is `huskies-project-base`.
//!
//! Phase 3 (story 1108): an ed25519 SSH keypair is generated per project.
//! The private key is stored at `~/.huskies/<name>/id_ed25519` on the host.
//! The public key is passed to the container as `HUSKIES_SSH_PUBKEY` and
//! installed in `~/.ssh/authorized_keys` by the entrypoint. The SSH server
//! is bound to a host-local port in the 22002300 range and recorded in
//! `projects.toml` as `ssh_port`.
//!
//! Adding a new stack requires only:
//! 1. `docker/stacks/<name>/Dockerfile.fragment` — overlay instructions
//! 2. `docker/stacks/<name>/markers` — detection marker filenames
@@ -157,12 +164,39 @@ pub fn image_for_stack(stack: Option<&str>) -> String {
}
}
/// Generate an ed25519 SSH keypair at `key_path` (private) and `key_path.pub` (public).
///
/// Calls `ssh-keygen -t ed25519 -N "" -f <key_path>` with no passphrase.
/// Returns the public key string (trimmed) on success.
async fn generate_ssh_keypair(key_path: &std::path::Path) -> Result<String, String> {
let out = tokio::process::Command::new("ssh-keygen")
.args(["-t", "ed25519", "-N", ""])
.arg("-f")
.arg(key_path)
.output()
.await
.map_err(|e| format!("ssh-keygen not available: {e}"))?;
if !out.status.success() {
return Err(format!(
"ssh-keygen failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
let pub_path = key_path.with_extension("pub");
tokio::fs::read_to_string(&pub_path)
.await
.map(|s| s.trim().to_string())
.map_err(|e| format!("Cannot read public key {}: {e}", pub_path.display()))
}
/// Bootstrap a new project from the `new project <name> [--stack <stack>]` command.
///
/// Creates `~/huskies/<name>/`, scaffolds `.huskies/`, runs `git init`,
/// auto-detects or honours the requested stack, launches the appropriate
/// Docker container, and registers the project in both the gateway's
/// in-memory store and the CRDT.
/// auto-detects or honours the requested stack, generates an SSH keypair,
/// launches the appropriate Docker container, and registers the project in
/// both the gateway's in-memory store and the CRDT.
///
/// On any failure after the host directory is created, the directory is removed
/// and the error message includes "Partial state removed at `<path>`".
@@ -263,9 +297,37 @@ pub async fn handle_new_project(
};
let image = image_for_stack(resolved_stack.as_deref());
// ── Allocate port and launch container ───────────────────────────────────
// ── Generate SSH keypair ─────────────────────────────────────────────────
// Private key: ~/.huskies/<name>/id_ed25519 (host-side, mode 600 by ssh-keygen)
// Public key: installed in the container via HUSKIES_SSH_PUBKEY env var
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string());
let ssh_key_dir = std::path::PathBuf::from(&home).join(".huskies").join(name);
if let Err(e) = tokio::fs::create_dir_all(&ssh_key_dir).await {
let _ = tokio::fs::remove_dir_all(&host_path).await;
return format!(
"Failed to create SSH key directory `{}`: {e}\n\nPartial state removed at `{}`.",
ssh_key_dir.display(),
host_path.display()
);
}
let private_key_path = ssh_key_dir.join("id_ed25519");
let pubkey = match generate_ssh_keypair(&private_key_path).await {
Ok(k) => k,
Err(e) => {
let _ = tokio::fs::remove_dir_all(&host_path).await;
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
return format!(
"SSH keypair generation failed: {e}\n\nPartial state removed at `{}`.",
host_path.display()
);
}
};
// ── Allocate ports and launch container ──────────────────────────────────
let port = find_free_port(3100);
let ssh_port = find_free_port(2200);
let container_url = format!("http://127.0.0.1:{port}");
let container_name = format!("huskies-{name}");
@@ -277,6 +339,10 @@ pub async fn handle_new_project(
&container_name,
"-p",
&format!("127.0.0.1:{port}:3001"),
"-p",
&format!("127.0.0.1:{ssh_port}:22"),
"-e",
&format!("HUSKIES_SSH_PUBKEY={pubkey}"),
"-v",
&format!("{}:/workspace", host_path.display()),
"--restart",
@@ -296,12 +362,20 @@ pub async fn handle_new_project(
// Update the in-memory projects store and persist to projects.toml.
{
let mut projects = projects_store.write().await;
projects.insert(name.to_string(), ProjectEntry::with_url(&container_url));
projects.insert(
name.to_string(),
ProjectEntry {
url: Some(container_url.clone()),
auth_token: None,
ssh_port: Some(ssh_port),
},
);
crate::service::gateway::io::save_config(&projects, config_dir).await;
}
crate::slog!(
"[new-project] Created project '{name}' at {container_url} (image={image})"
"[new-project] Created project '{name}' at {container_url} \
ssh=127.0.0.1:{ssh_port} (image={image})"
);
let stack_note = match resolved_stack.as_deref() {
@@ -319,6 +393,8 @@ pub async fn handle_new_project(
- Host path: `{host}`\n\
- Container: `{container_name}` → `{container_url}`\n\
{stack_note}\
- SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \
-i ~/.huskies/{name}/id_ed25519`\n\
\n\
Use `switch {name}` then `status` to view the pipeline.",
host = host_path.display()
@@ -327,6 +403,7 @@ pub async fn handle_new_project(
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
let _ = tokio::fs::remove_dir_all(&host_path).await;
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
format!(
"Docker container launch failed: {}\n\nPartial state removed at `{}`.",
stderr.trim(),
@@ -335,6 +412,7 @@ pub async fn handle_new_project(
}
Err(e) => {
let _ = tokio::fs::remove_dir_all(&host_path).await;
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
format!(
"Docker container launch failed: {e}\n\nPartial state removed at `{}`.",
host_path.display()
@@ -535,6 +613,45 @@ mod tests {
assert!(warnings.is_empty());
}
#[tokio::test]
async fn generate_ssh_keypair_creates_key_files() {
// Skip if ssh-keygen is not available in this environment.
if tokio::process::Command::new("ssh-keygen")
.arg("-V")
.output()
.await
.is_err()
{
return;
}
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("id_ed25519");
let pubkey = generate_ssh_keypair(&key_path).await.unwrap();
// Private key file must exist and be non-empty.
assert!(key_path.exists(), "private key file not created");
// Public key is returned as a trimmed string starting with the key type.
assert!(
pubkey.starts_with("ssh-ed25519"),
"public key should start with ssh-ed25519, got: {pubkey}"
);
// Public key file must also exist.
let pub_path = dir.path().join("id_ed25519.pub");
assert!(pub_path.exists(), "public key file not created");
let pub_contents = std::fs::read_to_string(&pub_path).unwrap();
assert_eq!(pub_contents.trim(), pubkey);
}
#[test]
fn find_free_port_returns_bindable_port() {
let port = find_free_port(2200);
// The returned port must be in range and actually bindable.
assert!((2200..2300).contains(&port));
let listener = std::net::TcpListener::bind(("127.0.0.1", port));
assert!(listener.is_ok(), "returned port {port} is not bindable");
}
#[test]
fn detect_stack_go_mod() {
let dir = tempfile::tempdir().unwrap();