huskies: merge 1108 story Chat bootstrap Phase 3: SSH-remote editor access into the project container (any editor)
This commit is contained in:
+21
-3
@@ -25,6 +25,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
libssl3 \
|
libssl3 \
|
||||||
procps \
|
procps \
|
||||||
|
openssh-server \
|
||||||
|
sudo \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy the huskies binary and entrypoint from the main image.
|
# Copy the huskies binary and entrypoint from the main image.
|
||||||
@@ -32,18 +34,34 @@ COPY --from=huskies-src /usr/local/bin/huskies /usr/local/bin/huskies
|
|||||||
COPY --from=huskies-src /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY --from=huskies-src /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
# Non-root user — Claude Code refuses --dangerously-skip-permissions as root.
|
# Non-root user — Claude Code refuses --dangerously-skip-permissions as root.
|
||||||
|
# -s /bin/bash required for SSH sessions to start a real shell.
|
||||||
RUN groupadd -r huskies \
|
RUN groupadd -r huskies \
|
||||||
&& useradd -r -g huskies -m -d /home/huskies huskies \
|
&& useradd -r -g huskies -m -d /home/huskies -s /bin/bash huskies \
|
||||||
&& mkdir -p /home/huskies/.claude \
|
&& mkdir -p /home/huskies/.claude \
|
||||||
|
&& mkdir -p /home/huskies/.ssh \
|
||||||
|
&& chmod 700 /home/huskies/.ssh \
|
||||||
&& chown -R huskies:huskies /home/huskies \
|
&& chown -R huskies:huskies /home/huskies \
|
||||||
&& mkdir -p /workspace \
|
&& mkdir -p /workspace \
|
||||||
&& chown huskies:huskies /workspace \
|
&& chown huskies:huskies /workspace \
|
||||||
&& git config --global init.defaultBranch master
|
&& git config --global init.defaultBranch master \
|
||||||
|
&& echo "huskies ALL=(root) NOPASSWD: /usr/sbin/sshd" > /etc/sudoers.d/huskies-sshd \
|
||||||
|
&& chmod 0440 /etc/sudoers.d/huskies-sshd \
|
||||||
|
&& mkdir -p /run/sshd \
|
||||||
|
&& sed -i \
|
||||||
|
-e 's/#PasswordAuthentication yes/PasswordAuthentication no/' \
|
||||||
|
-e 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' \
|
||||||
|
-e 's/UsePAM yes/UsePAM no/' \
|
||||||
|
/etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Shell profile for SSH sessions: land in /workspace and load toolchain paths.
|
||||||
|
RUN printf 'cd /workspace\n[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"\n' \
|
||||||
|
> /home/huskies/.profile \
|
||||||
|
&& chown huskies:huskies /home/huskies/.profile
|
||||||
|
|
||||||
USER huskies
|
USER huskies
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001 22
|
||||||
|
|
||||||
ENTRYPOINT ["entrypoint.sh"]
|
ENTRYPOINT ["entrypoint.sh"]
|
||||||
CMD ["huskies", "/workspace"]
|
CMD ["huskies", "/workspace"]
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# ── SSH authorized key ────────────────────────────────────────────────
|
||||||
|
# HUSKIES_SSH_PUBKEY is set by `new project` when it generates a keypair.
|
||||||
|
# Write it to authorized_keys so the user can connect with the matching
|
||||||
|
# private key stored at ~/.huskies/<project>/id_ed25519 on the host.
|
||||||
|
if [ -n "$HUSKIES_SSH_PUBKEY" ]; then
|
||||||
|
mkdir -p /home/huskies/.ssh
|
||||||
|
chmod 700 /home/huskies/.ssh
|
||||||
|
printf '%s\n' "$HUSKIES_SSH_PUBKEY" > /home/huskies/.ssh/authorized_keys
|
||||||
|
chmod 600 /home/huskies/.ssh/authorized_keys
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── SSH daemon ────────────────────────────────────────────────────────
|
||||||
|
# Start sshd in the background so the container accepts SSH connections.
|
||||||
|
# Uses sudo (huskies has NOPASSWD for /usr/sbin/sshd in sudoers.d).
|
||||||
|
sudo /usr/sbin/sshd -D -e &
|
||||||
|
|
||||||
# ── Git identity ─────────────────────────────────────────────────────
|
# ── Git identity ─────────────────────────────────────────────────────
|
||||||
# Agents commit code inside the container. Without a git identity,
|
# Agents commit code inside the container. Without a git identity,
|
||||||
# commits fail or use garbage defaults. Fail loudly at startup so the
|
# commits fail or use garbage defaults. Fail loudly at startup so the
|
||||||
|
|||||||
@@ -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.
|
//! Provisions a project container and registers it with the gateway.
|
||||||
//! The command is gateway-only: `new project <name> [--stack <stack>]`.
|
//! The command is gateway-only: `new project <name> [--stack <stack>]`.
|
||||||
@@ -12,6 +12,13 @@
|
|||||||
//! Stack images follow the naming convention `huskies-project-<stack>`.
|
//! Stack images follow the naming convention `huskies-project-<stack>`.
|
||||||
//! The base image (no language tooling) is `huskies-project-base`.
|
//! 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 2200–2300 range and recorded in
|
||||||
|
//! `projects.toml` as `ssh_port`.
|
||||||
|
//!
|
||||||
//! Adding a new stack requires only:
|
//! Adding a new stack requires only:
|
||||||
//! 1. `docker/stacks/<name>/Dockerfile.fragment` — overlay instructions
|
//! 1. `docker/stacks/<name>/Dockerfile.fragment` — overlay instructions
|
||||||
//! 2. `docker/stacks/<name>/markers` — detection marker filenames
|
//! 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.
|
/// Bootstrap a new project from the `new project <name> [--stack <stack>]` command.
|
||||||
///
|
///
|
||||||
/// Creates `~/huskies/<name>/`, scaffolds `.huskies/`, runs `git init`,
|
/// Creates `~/huskies/<name>/`, scaffolds `.huskies/`, runs `git init`,
|
||||||
/// auto-detects or honours the requested stack, launches the appropriate
|
/// auto-detects or honours the requested stack, generates an SSH keypair,
|
||||||
/// Docker container, and registers the project in both the gateway's
|
/// launches the appropriate Docker container, and registers the project in
|
||||||
/// in-memory store and the CRDT.
|
/// both the gateway's in-memory store and the CRDT.
|
||||||
///
|
///
|
||||||
/// On any failure after the host directory is created, the directory is removed
|
/// On any failure after the host directory is created, the directory is removed
|
||||||
/// and the error message includes "Partial state removed at `<path>`".
|
/// 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());
|
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 port = find_free_port(3100);
|
||||||
|
let ssh_port = find_free_port(2200);
|
||||||
let container_url = format!("http://127.0.0.1:{port}");
|
let container_url = format!("http://127.0.0.1:{port}");
|
||||||
let container_name = format!("huskies-{name}");
|
let container_name = format!("huskies-{name}");
|
||||||
|
|
||||||
@@ -277,6 +339,10 @@ pub async fn handle_new_project(
|
|||||||
&container_name,
|
&container_name,
|
||||||
"-p",
|
"-p",
|
||||||
&format!("127.0.0.1:{port}:3001"),
|
&format!("127.0.0.1:{port}:3001"),
|
||||||
|
"-p",
|
||||||
|
&format!("127.0.0.1:{ssh_port}:22"),
|
||||||
|
"-e",
|
||||||
|
&format!("HUSKIES_SSH_PUBKEY={pubkey}"),
|
||||||
"-v",
|
"-v",
|
||||||
&format!("{}:/workspace", host_path.display()),
|
&format!("{}:/workspace", host_path.display()),
|
||||||
"--restart",
|
"--restart",
|
||||||
@@ -296,12 +362,20 @@ pub async fn handle_new_project(
|
|||||||
// Update the in-memory projects store and persist to projects.toml.
|
// Update the in-memory projects store and persist to projects.toml.
|
||||||
{
|
{
|
||||||
let mut projects = projects_store.write().await;
|
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::service::gateway::io::save_config(&projects, config_dir).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::slog!(
|
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() {
|
let stack_note = match resolved_stack.as_deref() {
|
||||||
@@ -319,6 +393,8 @@ pub async fn handle_new_project(
|
|||||||
- Host path: `{host}`\n\
|
- Host path: `{host}`\n\
|
||||||
- Container: `{container_name}` → `{container_url}`\n\
|
- Container: `{container_name}` → `{container_url}`\n\
|
||||||
{stack_note}\
|
{stack_note}\
|
||||||
|
- SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \
|
||||||
|
-i ~/.huskies/{name}/id_ed25519`\n\
|
||||||
\n\
|
\n\
|
||||||
Use `switch {name}` then `status` to view the pipeline.",
|
Use `switch {name}` then `status` to view the pipeline.",
|
||||||
host = host_path.display()
|
host = host_path.display()
|
||||||
@@ -327,6 +403,7 @@ pub async fn handle_new_project(
|
|||||||
Ok(out) => {
|
Ok(out) => {
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
||||||
|
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
|
||||||
format!(
|
format!(
|
||||||
"Docker container launch failed: {}\n\nPartial state removed at `{}`.",
|
"Docker container launch failed: {}\n\nPartial state removed at `{}`.",
|
||||||
stderr.trim(),
|
stderr.trim(),
|
||||||
@@ -335,6 +412,7 @@ pub async fn handle_new_project(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
||||||
|
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
|
||||||
format!(
|
format!(
|
||||||
"Docker container launch failed: {e}\n\nPartial state removed at `{}`.",
|
"Docker container launch failed: {e}\n\nPartial state removed at `{}`.",
|
||||||
host_path.display()
|
host_path.display()
|
||||||
@@ -535,6 +613,45 @@ mod tests {
|
|||||||
assert!(warnings.is_empty());
|
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]
|
#[test]
|
||||||
fn detect_stack_go_mod() {
|
fn detect_stack_go_mod() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1175,6 +1175,7 @@ async fn ws_only_sled_handles_tools_list_and_tools_call() {
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("secret".into()),
|
auth_token: Some("secret".into()),
|
||||||
|
ssh_port: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
@@ -1244,6 +1245,7 @@ async fn two_concurrent_sleds_are_routed_by_active_project() {
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("alpha-tok".into()),
|
auth_token: Some("alpha-tok".into()),
|
||||||
|
ssh_port: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
projects.insert(
|
projects.insert(
|
||||||
@@ -1251,6 +1253,7 @@ async fn two_concurrent_sleds_are_routed_by_active_project() {
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("beta-tok".into()),
|
auth_token: Some("beta-tok".into()),
|
||||||
|
ssh_port: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ pub struct ProjectEntry {
|
|||||||
/// `[sled_tokens]` table for projects that set this field.
|
/// `[sled_tokens]` table for projects that set this field.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub auth_token: Option<String>,
|
pub auth_token: Option<String>,
|
||||||
|
/// Host-local port for SSH access into the project container.
|
||||||
|
///
|
||||||
|
/// Set by `new project` (story 1108). The container's SSH server is bound
|
||||||
|
/// to `127.0.0.1:<ssh_port>:22` so the user can connect with
|
||||||
|
/// `ssh huskies@127.0.0.1 -p <ssh_port> -i ~/.huskies/<name>/id_ed25519`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ssh_port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProjectEntry {
|
impl ProjectEntry {
|
||||||
@@ -36,6 +43,7 @@ impl ProjectEntry {
|
|||||||
Self {
|
Self {
|
||||||
url: Some(url.into()),
|
url: Some(url.into()),
|
||||||
auth_token: None,
|
auth_token: None,
|
||||||
|
ssh_port: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +213,7 @@ auth_token = "secret"
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("secret".into()),
|
auth_token: Some("secret".into()),
|
||||||
|
ssh_port: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
@@ -238,6 +247,7 @@ auth_token = "secret"
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("tok".into()),
|
auth_token: Some("tok".into()),
|
||||||
|
ssh_port: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(validate_project_exists(&projects, "ws").unwrap(), "");
|
assert_eq!(validate_project_exists(&projects, "ws").unwrap(), "");
|
||||||
@@ -256,6 +266,7 @@ auth_token = "secret"
|
|||||||
let e = ProjectEntry {
|
let e = ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("tok".into()),
|
auth_token: Some("tok".into()),
|
||||||
|
ssh_port: None,
|
||||||
};
|
};
|
||||||
assert!(!e.has_url());
|
assert!(!e.has_url());
|
||||||
}
|
}
|
||||||
@@ -297,6 +308,7 @@ auth_token = "secret"
|
|||||||
let entry = ProjectEntry {
|
let entry = ProjectEntry {
|
||||||
url: Some("http://a:3001".into()),
|
url: Some("http://a:3001".into()),
|
||||||
auth_token: Some("mysecret".into()),
|
auth_token: Some("mysecret".into()),
|
||||||
|
ssh_port: None,
|
||||||
};
|
};
|
||||||
let mut projects = BTreeMap::new();
|
let mut projects = BTreeMap::new();
|
||||||
projects.insert("myproj".into(), entry);
|
projects.insert("myproj".into(), entry);
|
||||||
@@ -315,4 +327,43 @@ auth_token = "secret"
|
|||||||
Some("mysecret")
|
Some("mysecret")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_project_entry_with_ssh_port() {
|
||||||
|
let entry = ProjectEntry {
|
||||||
|
url: Some("http://127.0.0.1:3101".into()),
|
||||||
|
auth_token: None,
|
||||||
|
ssh_port: Some(2201),
|
||||||
|
};
|
||||||
|
let mut projects = BTreeMap::new();
|
||||||
|
projects.insert("myproj".into(), entry);
|
||||||
|
let config = GatewayConfig {
|
||||||
|
projects,
|
||||||
|
sled_tokens: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
|
||||||
|
assert_eq!(parsed.projects["myproj"].ssh_port, Some(2201));
|
||||||
|
// ssh_port must appear in the serialised TOML.
|
||||||
|
assert!(
|
||||||
|
toml_str.contains("ssh_port = 2201"),
|
||||||
|
"ssh_port missing from TOML: {toml_str}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssh_port_none_is_omitted_from_toml() {
|
||||||
|
let entry = ProjectEntry::with_url("http://127.0.0.1:3101");
|
||||||
|
let mut projects = BTreeMap::new();
|
||||||
|
projects.insert("p".into(), entry);
|
||||||
|
let config = GatewayConfig {
|
||||||
|
projects,
|
||||||
|
sled_tokens: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
assert!(
|
||||||
|
!toml_str.contains("ssh_port"),
|
||||||
|
"ssh_port should be omitted when None: {toml_str}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -747,6 +747,7 @@ mod tests {
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("tok".into()),
|
auth_token: Some("tok".into()),
|
||||||
|
ssh_port: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
@@ -885,6 +886,7 @@ mod tests {
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: Some("http://huskies:3001".into()),
|
url: Some("http://huskies:3001".into()),
|
||||||
auth_token: Some("secret-token".into()),
|
auth_token: Some("secret-token".into()),
|
||||||
|
ssh_port: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user