Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions configuration.yaml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions src/bin/stacker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,15 @@ enum AgentCommands {
#[arg(long)]
deployment: Option<String>,
},
/// 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<String>,
/// Output in JSON format
#[arg(long)]
json: bool,
},
}

/// Arguments for `stacker ai`.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/cli/deployment_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use chrono::Utc;
use serde::{Deserialize, Serialize};

use crate::cli::config_parser::{DeployTarget, ServerConfig, StackerConfig};

Check warning on line 6 in src/cli/deployment_lock.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

unused import: `DeployTarget`
use crate::cli::error::CliError;
use crate::cli::install_runner::DeployResult;

Expand Down Expand Up @@ -348,6 +348,7 @@
server_ip: None,
deployment_id: Some(1),
project_id: Some(2),
server_name: None,
});

let enriched = lock.with_server_info(
Expand Down
22 changes: 20 additions & 2 deletions src/cli/install_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ pub struct DeployResult {
pub deployment_id: Option<i64>,
/// Stacker server project ID (set for remote orchestrator deploys).
pub project_id: Option<i64>,
/// Server name used/generated for this deploy (for lockfile persistence).
pub server_name: Option<String>,
}

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Expand Down Expand Up @@ -212,6 +214,7 @@ impl DeployStrategy for LocalDeploy {
server_ip: None,
deployment_id: None,
project_id: None,
server_name: None,
})
}

Expand Down Expand Up @@ -432,6 +435,7 @@ impl DeployStrategy for CloudDeploy {
server_ip: None,
deployment_id: None,
project_id: None,
server_name: None,
});
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
}
}
Expand Down Expand Up @@ -739,6 +755,7 @@ impl DeployStrategy for CloudDeploy {
server_ip: extract_server_ip(&output.stdout),
deployment_id: None,
project_id: None,
server_name: None,
})
}

Expand Down Expand Up @@ -1211,6 +1228,7 @@ impl DeployStrategy for ServerDeploy {
server_ip: server_host,
deployment_id: None,
project_id: None,
server_name: None,
})
}

Expand Down
8 changes: 8 additions & 0 deletions src/cli/stacker_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ pub struct ServerInfo {
pub ssh_port: Option<i32>,
pub ssh_user: Option<String>,
pub name: Option<String>,
pub vault_key_path: Option<String>,
#[serde(default = "default_connection_mode")]
pub connection_mode: String,
#[serde(default = "default_key_status")]
Expand Down Expand Up @@ -1528,6 +1529,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",
}));
}

Expand Down Expand Up @@ -1936,6 +1940,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]
Expand Down
6 changes: 6 additions & 0 deletions src/connectors/install_service/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ impl InstallServiceConnector for InstallServiceClient {
fc: String,
mq_manager: &MqManager,
server_public_key: Option<String>,
server_private_key: Option<String>,
) -> Result<i32, String> {
// Build payload for the install service
let mut payload = crate::forms::project::Payload::try_from(project)
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/connectors/install_service/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ impl InstallServiceConnector for MockInstallServiceConnector {
_fc: String,
_mq_manager: &MqManager,
_server_public_key: Option<String>,
_server_private_key: Option<String>,
) -> Result<i32, String> {
Ok(project_id)
}
Expand Down
1 change: 1 addition & 0 deletions src/connectors/install_service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ pub trait InstallServiceConnector: Send + Sync {
fc: String,
mq_manager: &MqManager,
server_public_key: Option<String>,
server_private_key: Option<String>,
) -> Result<i32, String>;
}
142 changes: 142 additions & 0 deletions src/console/commands/cli/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,148 @@ impl CallableTrait for AgentHistoryCommand {
}
}

// ── Install (deploy Status Panel to existing server) ─

/// `stacker agent install [--file <path>] [--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<String>,
pub json: bool,
}

impl AgentInstallCommand {
pub fn new(file: Option<String>, json: bool) -> Self {
Self { file, json }
}
}

impl CallableTrait for AgentInstallCommand {
fn call(&self) -> Result<(), Box<dyn std::error::Error>> {
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<stacker_client::DeployResponse, CliError> = 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
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Expand Down
12 changes: 11 additions & 1 deletion src/console/commands/cli/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
};
Expand Down Expand Up @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion src/console/commands/cli/ssh_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ async fn inject_key_via_ssh(

let addr = format!("{}:{}", host, port);
let mut handle: Handle<AcceptAllKeys> =
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)))?;
Expand Down
Loading
Loading