From 167640167edb6dd4812be9ee8a068569ec3b1c47 Mon Sep 17 00:00:00 2001 From: vsilent Date: Mon, 9 Mar 2026 15:23:35 +0200 Subject: [PATCH 1/4] statuspanel default image --- src/cli/stacker_client.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 921fd2c..6059b6a 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -1528,6 +1528,9 @@ pub fn build_project_body(config: &StackerConfig) -> serde_json::Value { {"host_port": "5000", "container_port": "5000"}, ], "network": [], + "dockerhub_user": "trydirect", + "dockerhub_name": "status", + "dockerhub_tag": "latest", })); } @@ -1936,6 +1939,10 @@ mod tests { assert_eq!(ports.len(), 1); assert_eq!(ports[0]["host_port"], "5000"); assert_eq!(ports[0]["container_port"], "5000"); + // Image fields must be present so the install service can build a Docker image reference + assert_eq!(sp["dockerhub_user"], "trydirect"); + assert_eq!(sp["dockerhub_name"], "status"); + assert_eq!(sp["dockerhub_tag"], "latest"); } #[test] From e15acc6a6261b4ab151e2c079ec08a36d132a450 Mon Sep 17 00:00:00 2001 From: vsilent Date: Mon, 9 Mar 2026 16:42:10 +0200 Subject: [PATCH 2/4] deployment lock --- src/cli/deployment_lock.rs | 1 + src/cli/install_runner.rs | 22 ++++++++++++++++++++-- src/console/commands/cli/deploy.rs | 10 ++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/cli/deployment_lock.rs b/src/cli/deployment_lock.rs index 872bb98..fa68c09 100644 --- a/src/cli/deployment_lock.rs +++ b/src/cli/deployment_lock.rs @@ -348,6 +348,7 @@ mod tests { server_ip: None, deployment_id: Some(1), project_id: Some(2), + server_name: None, }); let enriched = lock.with_server_info( diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index f114c32..7d684d3 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -113,6 +113,8 @@ pub struct DeployResult { pub deployment_id: Option, /// Stacker server project ID (set for remote orchestrator deploys). pub project_id: Option, + /// Server name used/generated for this deploy (for lockfile persistence). + pub server_name: Option, } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -212,6 +214,7 @@ impl DeployStrategy for LocalDeploy { server_ip: None, deployment_id: None, project_id: None, + server_name: None, }) } @@ -432,6 +435,7 @@ impl DeployStrategy for CloudDeploy { server_ip: None, deployment_id: None, project_id: None, + server_name: None, }); } @@ -466,7 +470,7 @@ impl DeployStrategy for CloudDeploy { reason: format!("Failed to initialize async runtime: {}", e), })?; - let response = rt.block_on(async { + let (response, effective_server_name) = rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); // Step 1: Resolve or auto-create project @@ -629,6 +633,17 @@ impl DeployStrategy for CloudDeploy { // Step 4: Build deploy form let mut deploy_form = stacker_client::build_deploy_form(config); + + // Capture the server name from the form (auto-generated or overridden) + // so we can persist it in the deployment lock even if the API fetch + // after deploy doesn't return server details yet. + let effective_server_name = server_name.clone().or_else(|| { + deploy_form + .get("server") + .and_then(|s| s.get("name")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }); if let Some(sid) = server_id { if let Some(server_obj) = deploy_form.get_mut("server") { if let Some(obj) = server_obj.as_object_mut() { @@ -672,7 +687,7 @@ impl DeployStrategy for CloudDeploy { eprintln!(" Deploying project '{}' (id={})...", project_name, project.id); let resp = client.deploy(project.id, cloud_id, deploy_form).await?; - Ok(resp) + Ok((resp, effective_server_name)) }).map_err(|e: CliError| e)?; let deploy_id = response @@ -709,6 +724,7 @@ impl DeployStrategy for CloudDeploy { server_ip: None, deployment_id: deploy_id, project_id: project_id.map(|id| id as i64), + server_name: effective_server_name, }); } } @@ -739,6 +755,7 @@ impl DeployStrategy for CloudDeploy { server_ip: extract_server_ip(&output.stdout), deployment_id: None, project_id: None, + server_name: None, }) } @@ -1211,6 +1228,7 @@ impl DeployStrategy for ServerDeploy { server_ip: server_host, deployment_id: None, project_id: None, + server_name: None, }) } diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs index e003e4a..c3f8178 100644 --- a/src/console/commands/cli/deploy.rs +++ b/src/console/commands/cli/deploy.rs @@ -1266,6 +1266,15 @@ impl DeployCommand { } } + // Fallback: if the API fetch didn't populate server_name, + // use the name from the deploy form so subsequent deploys + // can still find and reuse the server. + if l.server_name.is_none() { + if let Some(ref name) = result.server_name { + l.server_name = Some(name.clone()); + } + } + l } }; @@ -2129,6 +2138,7 @@ services: server_ip: None, deployment_id: Some(42), project_id: Some(7), + server_name: None, }; assert_eq!(result.deployment_id, Some(42)); assert_eq!(result.project_id, Some(7)); From 4ad722456d839123f03a64b82fa8bf05f8706f69 Mon Sep 17 00:00:00 2001 From: vsilent Date: Mon, 9 Mar 2026 17:55:54 +0200 Subject: [PATCH 3/4] statuspanel separate install --- src/bin/stacker.rs | 12 ++ src/cli/stacker_client.rs | 1 + src/connectors/install_service/client.rs | 6 + src/connectors/install_service/mock.rs | 1 + src/connectors/install_service/mod.rs | 1 + src/console/commands/cli/agent.rs | 142 +++++++++++++++++++++++ src/forms/server.rs | 7 ++ src/routes/project/deploy.rs | 86 ++++++++++++++ 8 files changed, 256 insertions(+) diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index 232e953..c72c5fb 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -564,6 +564,15 @@ enum AgentCommands { #[arg(long)] deployment: Option, }, + /// Install the Status Panel agent on an existing deployed server + Install { + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + /// Output in JSON format + #[arg(long)] + json: bool, + }, } /// Arguments for `stacker ai`. @@ -883,6 +892,9 @@ fn get_command( AgentCommands::Exec { command_type, params, timeout, json, deployment } => Box::new( agent::AgentExecCommand::new(command_type, params, timeout, json, deployment), ), + AgentCommands::Install { file, json } => Box::new( + agent::AgentInstallCommand::new(file, json), + ), } }, // Completion is handled in main() before this function is called. diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 6059b6a..e6a218f 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -79,6 +79,7 @@ pub struct ServerInfo { pub ssh_port: Option, pub ssh_user: Option, pub name: Option, + pub vault_key_path: Option, #[serde(default = "default_connection_mode")] pub connection_mode: String, #[serde(default = "default_key_status")] diff --git a/src/connectors/install_service/client.rs b/src/connectors/install_service/client.rs index 1a3efbd..51cd599 100644 --- a/src/connectors/install_service/client.rs +++ b/src/connectors/install_service/client.rs @@ -24,6 +24,7 @@ impl InstallServiceConnector for InstallServiceClient { fc: String, mq_manager: &MqManager, server_public_key: Option, + server_private_key: Option, ) -> Result { // Build payload for the install service let mut payload = crate::forms::project::Payload::try_from(project) @@ -44,6 +45,11 @@ impl InstallServiceConnector for InstallServiceClient { if srv.public_key.is_none() { srv.public_key = server_public_key; } + // Include the SSH private key so the Install Service can SSH into + // existing servers without relying on Redis-cached file paths. + if srv.ssh_private_key.is_none() { + srv.ssh_private_key = server_private_key; + } } payload.cloud = Some(cloud_creds.into()); payload.stack = form_stack.clone().into(); diff --git a/src/connectors/install_service/mock.rs b/src/connectors/install_service/mock.rs index 5ad506c..4bb7228 100644 --- a/src/connectors/install_service/mock.rs +++ b/src/connectors/install_service/mock.rs @@ -23,6 +23,7 @@ impl InstallServiceConnector for MockInstallServiceConnector { _fc: String, _mq_manager: &MqManager, _server_public_key: Option, + _server_private_key: Option, ) -> Result { Ok(project_id) } diff --git a/src/connectors/install_service/mod.rs b/src/connectors/install_service/mod.rs index 65b0ff5..8cc78d1 100644 --- a/src/connectors/install_service/mod.rs +++ b/src/connectors/install_service/mod.rs @@ -33,5 +33,6 @@ pub trait InstallServiceConnector: Send + Sync { fc: String, mq_manager: &MqManager, server_public_key: Option, + server_private_key: Option, ) -> Result; } diff --git a/src/console/commands/cli/agent.rs b/src/console/commands/cli/agent.rs index 38bf235..16aeb36 100644 --- a/src/console/commands/cli/agent.rs +++ b/src/console/commands/cli/agent.rs @@ -722,6 +722,148 @@ impl CallableTrait for AgentHistoryCommand { } } +// ── Install (deploy Status Panel to existing server) ─ + +/// `stacker agent install [--file ] [--json]` +/// +/// Deploys the Status Panel agent to an existing server that was previously +/// deployed without it. Reads the project identity from stacker.yml, finds +/// the corresponding project and server on the Stacker API, and triggers +/// a deploy with only the statuspanel feature enabled. +pub struct AgentInstallCommand { + pub file: Option, + pub json: bool, +} + +impl AgentInstallCommand { + pub fn new(file: Option, json: bool) -> Self { + Self { file, json } + } +} + +impl CallableTrait for AgentInstallCommand { + fn call(&self) -> Result<(), Box> { + use crate::cli::stacker_client::{self, DEFAULT_VAULT_URL}; + + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let config_path = match &self.file { + Some(f) => project_dir.join(f), + None => project_dir.join("stacker.yml"), + }; + + let config = crate::cli::config_parser::StackerConfig::from_file(&config_path)?; + + let project_name = config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()); + + let ctx = CliRuntime::new("agent install")?; + let pb = progress::spinner("Installing Status Panel agent"); + + let result: Result = ctx.block_on(async { + // 1. Find the project + progress::update_message(&pb, "Finding project..."); + let project = ctx + .client + .find_project_by_name(&project_name) + .await? + .ok_or_else(|| CliError::ConfigValidation(format!( + "Project '{}' not found on the Stacker server.\n\ + Deploy the project first with: stacker deploy --target cloud", + project_name + )))?; + + // 2. Find the server for this project + progress::update_message(&pb, "Finding server..."); + let servers = ctx.client.list_servers().await?; + let server = servers + .into_iter() + .find(|s| s.project_id == project.id) + .ok_or_else(|| CliError::ConfigValidation(format!( + "No server found for project '{}' (id={}).\n\ + Deploy the project first with: stacker deploy --target cloud", + project_name, project.id + )))?; + + let cloud_id = server.cloud_id.ok_or_else(|| CliError::ConfigValidation( + "Server has no associated cloud credentials.\n\ + Cannot install Status Panel without cloud credentials." + .to_string(), + ))?; + + // 3. Build a minimal deploy form with only the statuspanel feature + progress::update_message(&pb, "Preparing deploy payload..."); + let vault_url = std::env::var("STACKER_VAULT_URL") + .unwrap_or_else(|_| DEFAULT_VAULT_URL.to_string()); + + let deploy_form = serde_json::json!({ + "cloud": { + "provider": server.cloud.clone().unwrap_or_else(|| "htz".to_string()), + "save_token": true, + }, + "server": { + "server_id": server.id, + "region": server.region, + "server": server.server, + "os": server.os, + "name": server.name, + "srv_ip": server.srv_ip, + "ssh_user": server.ssh_user, + "ssh_port": server.ssh_port, + "vault_key_path": server.vault_key_path, + "connection_mode": "status_panel", + }, + "stack": { + "stack_code": project_name, + "vars": [ + { "key": "vault_url", "value": vault_url }, + { "key": "status_panel_port", "value": "5000" }, + ], + "integrated_features": ["statuspanel"], + "extended_features": [], + "subscriptions": [], + }, + }); + + // 4. Trigger the deploy + progress::update_message(&pb, "Deploying Status Panel..."); + let resp = ctx.client.deploy(project.id, Some(cloud_id), deploy_form).await?; + Ok(resp) + }); + + match result { + Ok(resp) => { + progress::finish_success(&pb, "Status Panel agent installation triggered"); + + if self.json { + println!("{}", serde_json::to_string_pretty(&resp).unwrap_or_default()); + } else { + println!("Status Panel deploy queued for project '{}'", project_name); + if let Some(id) = resp.id { + println!("Project ID: {}", id); + } + if let Some(meta) = &resp.meta { + if let Some(dep_id) = meta.get("deployment_id") { + println!("Deployment ID: {}", dep_id); + } + } + println!(); + println!("The Status Panel agent will be installed on the server."); + println!("Once ready, use `stacker agent status` to verify connectivity."); + } + } + Err(e) => { + progress::finish_error(&pb, &format!("Install failed: {}", e)); + return Err(Box::new(e)); + } + } + + Ok(()) + } +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Tests // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/src/forms/server.rs b/src/forms/server.rs index 20ca277..b7e6e2a 100644 --- a/src/forms/server.rs +++ b/src/forms/server.rs @@ -30,6 +30,12 @@ pub struct ServerForm { /// Not persisted to the database. #[serde(skip_serializing_if = "Option::is_none")] pub public_key: Option, + /// The actual SSH private key content (PEM). + /// Populated at deploy time for "own" flow re-deploys so the Install Service + /// can SSH into the server without relying on a cached file path in Redis. + /// Not persisted to the database. + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_private_key: Option, } pub fn default_ssh_port() -> Option { @@ -64,6 +70,7 @@ impl From<&ServerForm> for models::Server { impl Into for models::Server { fn into(self) -> ServerForm { let mut form = ServerForm::default(); + form.server_id = Some(self.id); form.cloud_id = self.cloud_id; form.disk_type = self.disk_type; form.region = self.region; diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index c78a577..8abf275 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -266,6 +266,24 @@ pub async fn item( server }; + // For "own" flow (existing server with IP), SSH access is required. + // If we couldn't set up the SSH key, fail early instead of letting the + // Install Service crash when it cannot find the key. + let has_existing_ip = server.srv_ip.as_ref().map_or(false, |ip| !ip.is_empty()); + if has_existing_ip && new_public_key.is_none() && server.vault_key_path.is_none() { + tracing::error!( + "Cannot deploy to existing server {} (IP: {:?}): SSH key is not available. \ + vault_key_path is None and key generation failed.", + server.id, + server.srv_ip, + ); + return Err(JsonResponse::::build().bad_request( + "SSH key is not available for this server. \ + Please generate an SSH key first with `stacker ssh-key generate` \ + or re-add your server with SSH credentials.", + )); + } + // Store deployment attempts into deployment table in db let json_request = dc.project.metadata.clone(); let deployment_hash = format!("deployment_{}", Uuid::new_v4()); @@ -285,6 +303,30 @@ pub async fn item( let deployment_id = saved_deployment.id; + // For "own" flow, fetch the SSH private key from Vault so the Install Service + // can SSH into the server directly without relying on Redis-cached file paths. + let new_private_key = if has_existing_ip && server.vault_key_path.is_some() { + match vault_client + .get_ref() + .fetch_ssh_key(&user.id, server.id) + .await + { + Ok(pk) => { + tracing::info!("Fetched SSH private key from Vault for server {}", server.id); + Some(pk) + } + Err(e) => { + tracing::warn!( + "Failed to fetch SSH private key from Vault for server {}: {}", + server.id, e + ); + None + } + } + } else { + None + }; + // Delegate to install service connector install_service .deploy( @@ -301,6 +343,7 @@ pub async fn item( fc, mq_manager.get_ref(), new_public_key, + new_private_key, ) .await .map(|project_id| { @@ -571,6 +614,24 @@ pub async fn saved_item( server }; + // For "own" flow (existing server with IP), SSH access is required. + // If we couldn't set up the SSH key, fail early instead of letting the + // Install Service crash when it cannot find the key. + let has_existing_ip = server.srv_ip.as_ref().map_or(false, |ip| !ip.is_empty()); + if has_existing_ip && new_public_key.is_none() && server.vault_key_path.is_none() { + tracing::error!( + "Cannot deploy to existing server {} (IP: {:?}): SSH key is not available. \ + vault_key_path is None and key generation failed.", + server.id, + server.srv_ip, + ); + return Err(JsonResponse::::build().bad_request( + "SSH key is not available for this server. \ + Please generate an SSH key first with `stacker ssh-key generate` \ + or re-add your server with SSH credentials.", + )); + } + // Store deployment attempts into deployment table in db let json_request = dc.project.metadata.clone(); let deployment_hash = format!("deployment_{}", Uuid::new_v4()); @@ -592,6 +653,30 @@ pub async fn saved_item( tracing::debug!("Save deployment result: {:?}", result); + // For "own" flow, fetch the SSH private key from Vault so the Install Service + // can SSH into the server directly without relying on Redis-cached file paths. + let new_private_key = if has_existing_ip && server.vault_key_path.is_some() { + match vault_client + .get_ref() + .fetch_ssh_key(&user.id, server.id) + .await + { + Ok(pk) => { + tracing::info!("Fetched SSH private key from Vault for server {}", server.id); + Some(pk) + } + Err(e) => { + tracing::warn!( + "Failed to fetch SSH private key from Vault for server {}: {}", + server.id, e + ); + None + } + } + } else { + None + }; + // Delegate to install service connector (determines own vs tfa routing) install_service .deploy( @@ -608,6 +693,7 @@ pub async fn saved_item( fc, mq_manager.get_ref(), new_public_key, + new_private_key, ) .await .map(|project_id| { From b3d787e89ae5bfbe8902a8e88c833ac4f955802a Mon Sep 17 00:00:00 2001 From: vsilent Date: Tue, 10 Mar 2026 14:48:01 +0200 Subject: [PATCH 4/4] sshs key issue fix --- configuration.yaml.dist | 1 + src/console/commands/cli/deploy.rs | 2 +- src/console/commands/cli/ssh_key.rs | 2 +- src/routes/server/ssh_key.rs | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/configuration.yaml.dist b/configuration.yaml.dist index 87c340d..84570ee 100644 --- a/configuration.yaml.dist +++ b/configuration.yaml.dist @@ -32,6 +32,7 @@ vault: # Path under the mount (without deployment_hash), e.g. 'secret/debug/status_panel' or 'agent' # Final path: {address}/{api_prefix}/{agent_path_prefix}/{deployment_hash}/token agent_path_prefix: agent + ssh_key_path_prefix: data/users # External service connectors connectors: diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs index c3f8178..3ec2bde 100644 --- a/src/console/commands/cli/deploy.rs +++ b/src/console/commands/cli/deploy.rs @@ -225,7 +225,7 @@ fn ensure_env_file_if_needed(config: &StackerConfig, project_dir: &Path) -> Resu } /// SSH connection timeout for server pre-check (seconds). -const SSH_CHECK_TIMEOUT_SECS: u64 = 15; +const SSH_CHECK_TIMEOUT_SECS: u64 = 4; /// Resolve the path to an SSH key, expanding `~` to the user's home directory. fn resolve_ssh_key_path(key_path: &Path) -> PathBuf { diff --git a/src/console/commands/cli/ssh_key.rs b/src/console/commands/cli/ssh_key.rs index 74ac2c9..6753527 100644 --- a/src/console/commands/cli/ssh_key.rs +++ b/src/console/commands/cli/ssh_key.rs @@ -336,7 +336,7 @@ async fn inject_key_via_ssh( let addr = format!("{}:{}", host, port); let mut handle: Handle = - tokio::time::timeout(Duration::from_secs(15), russh::client::connect(config, addr, AcceptAllKeys)) + tokio::time::timeout(Duration::from_secs(4), russh::client::connect(config, addr, AcceptAllKeys)) .await .map_err(|_| CliError::ConfigValidation(format!("Connection to {}:{} timed out", host, port)))? .map_err(|e| CliError::ConfigValidation(format!("Connection failed: {}", e)))?; diff --git a/src/routes/server/ssh_key.rs b/src/routes/server/ssh_key.rs index 56a03cb..fb1fca0 100644 --- a/src/routes/server/ssh_key.rs +++ b/src/routes/server/ssh_key.rs @@ -390,7 +390,7 @@ pub async fn validate_key( ssh_port, &ssh_user, &private_key, - Duration::from_secs(30), + Duration::from_secs(4), ) .await;