Skip to content

Commit 4ad7224

Browse files
author
vsilent
committed
statuspanel separate install
1 parent e15acc6 commit 4ad7224

File tree

8 files changed

+256
-0
lines changed

8 files changed

+256
-0
lines changed

src/bin/stacker.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,15 @@ enum AgentCommands {
564564
#[arg(long)]
565565
deployment: Option<String>,
566566
},
567+
/// Install the Status Panel agent on an existing deployed server
568+
Install {
569+
/// Path to stacker.yml (default: ./stacker.yml)
570+
#[arg(long, value_name = "FILE")]
571+
file: Option<String>,
572+
/// Output in JSON format
573+
#[arg(long)]
574+
json: bool,
575+
},
567576
}
568577

569578
/// Arguments for `stacker ai`.
@@ -883,6 +892,9 @@ fn get_command(
883892
AgentCommands::Exec { command_type, params, timeout, json, deployment } => Box::new(
884893
agent::AgentExecCommand::new(command_type, params, timeout, json, deployment),
885894
),
895+
AgentCommands::Install { file, json } => Box::new(
896+
agent::AgentInstallCommand::new(file, json),
897+
),
886898
}
887899
},
888900
// Completion is handled in main() before this function is called.

src/cli/stacker_client.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ pub struct ServerInfo {
7979
pub ssh_port: Option<i32>,
8080
pub ssh_user: Option<String>,
8181
pub name: Option<String>,
82+
pub vault_key_path: Option<String>,
8283
#[serde(default = "default_connection_mode")]
8384
pub connection_mode: String,
8485
#[serde(default = "default_key_status")]

src/connectors/install_service/client.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ impl InstallServiceConnector for InstallServiceClient {
2424
fc: String,
2525
mq_manager: &MqManager,
2626
server_public_key: Option<String>,
27+
server_private_key: Option<String>,
2728
) -> Result<i32, String> {
2829
// Build payload for the install service
2930
let mut payload = crate::forms::project::Payload::try_from(project)
@@ -44,6 +45,11 @@ impl InstallServiceConnector for InstallServiceClient {
4445
if srv.public_key.is_none() {
4546
srv.public_key = server_public_key;
4647
}
48+
// Include the SSH private key so the Install Service can SSH into
49+
// existing servers without relying on Redis-cached file paths.
50+
if srv.ssh_private_key.is_none() {
51+
srv.ssh_private_key = server_private_key;
52+
}
4753
}
4854
payload.cloud = Some(cloud_creds.into());
4955
payload.stack = form_stack.clone().into();

src/connectors/install_service/mock.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ impl InstallServiceConnector for MockInstallServiceConnector {
2323
_fc: String,
2424
_mq_manager: &MqManager,
2525
_server_public_key: Option<String>,
26+
_server_private_key: Option<String>,
2627
) -> Result<i32, String> {
2728
Ok(project_id)
2829
}

src/connectors/install_service/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@ pub trait InstallServiceConnector: Send + Sync {
3333
fc: String,
3434
mq_manager: &MqManager,
3535
server_public_key: Option<String>,
36+
server_private_key: Option<String>,
3637
) -> Result<i32, String>;
3738
}

src/console/commands/cli/agent.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,148 @@ impl CallableTrait for AgentHistoryCommand {
722722
}
723723
}
724724

725+
// ── Install (deploy Status Panel to existing server) ─
726+
727+
/// `stacker agent install [--file <path>] [--json]`
728+
///
729+
/// Deploys the Status Panel agent to an existing server that was previously
730+
/// deployed without it. Reads the project identity from stacker.yml, finds
731+
/// the corresponding project and server on the Stacker API, and triggers
732+
/// a deploy with only the statuspanel feature enabled.
733+
pub struct AgentInstallCommand {
734+
pub file: Option<String>,
735+
pub json: bool,
736+
}
737+
738+
impl AgentInstallCommand {
739+
pub fn new(file: Option<String>, json: bool) -> Self {
740+
Self { file, json }
741+
}
742+
}
743+
744+
impl CallableTrait for AgentInstallCommand {
745+
fn call(&self) -> Result<(), Box<dyn std::error::Error>> {
746+
use crate::cli::stacker_client::{self, DEFAULT_VAULT_URL};
747+
748+
let project_dir = std::env::current_dir().map_err(CliError::Io)?;
749+
let config_path = match &self.file {
750+
Some(f) => project_dir.join(f),
751+
None => project_dir.join("stacker.yml"),
752+
};
753+
754+
let config = crate::cli::config_parser::StackerConfig::from_file(&config_path)?;
755+
756+
let project_name = config
757+
.project
758+
.identity
759+
.clone()
760+
.unwrap_or_else(|| config.name.clone());
761+
762+
let ctx = CliRuntime::new("agent install")?;
763+
let pb = progress::spinner("Installing Status Panel agent");
764+
765+
let result: Result<stacker_client::DeployResponse, CliError> = ctx.block_on(async {
766+
// 1. Find the project
767+
progress::update_message(&pb, "Finding project...");
768+
let project = ctx
769+
.client
770+
.find_project_by_name(&project_name)
771+
.await?
772+
.ok_or_else(|| CliError::ConfigValidation(format!(
773+
"Project '{}' not found on the Stacker server.\n\
774+
Deploy the project first with: stacker deploy --target cloud",
775+
project_name
776+
)))?;
777+
778+
// 2. Find the server for this project
779+
progress::update_message(&pb, "Finding server...");
780+
let servers = ctx.client.list_servers().await?;
781+
let server = servers
782+
.into_iter()
783+
.find(|s| s.project_id == project.id)
784+
.ok_or_else(|| CliError::ConfigValidation(format!(
785+
"No server found for project '{}' (id={}).\n\
786+
Deploy the project first with: stacker deploy --target cloud",
787+
project_name, project.id
788+
)))?;
789+
790+
let cloud_id = server.cloud_id.ok_or_else(|| CliError::ConfigValidation(
791+
"Server has no associated cloud credentials.\n\
792+
Cannot install Status Panel without cloud credentials."
793+
.to_string(),
794+
))?;
795+
796+
// 3. Build a minimal deploy form with only the statuspanel feature
797+
progress::update_message(&pb, "Preparing deploy payload...");
798+
let vault_url = std::env::var("STACKER_VAULT_URL")
799+
.unwrap_or_else(|_| DEFAULT_VAULT_URL.to_string());
800+
801+
let deploy_form = serde_json::json!({
802+
"cloud": {
803+
"provider": server.cloud.clone().unwrap_or_else(|| "htz".to_string()),
804+
"save_token": true,
805+
},
806+
"server": {
807+
"server_id": server.id,
808+
"region": server.region,
809+
"server": server.server,
810+
"os": server.os,
811+
"name": server.name,
812+
"srv_ip": server.srv_ip,
813+
"ssh_user": server.ssh_user,
814+
"ssh_port": server.ssh_port,
815+
"vault_key_path": server.vault_key_path,
816+
"connection_mode": "status_panel",
817+
},
818+
"stack": {
819+
"stack_code": project_name,
820+
"vars": [
821+
{ "key": "vault_url", "value": vault_url },
822+
{ "key": "status_panel_port", "value": "5000" },
823+
],
824+
"integrated_features": ["statuspanel"],
825+
"extended_features": [],
826+
"subscriptions": [],
827+
},
828+
});
829+
830+
// 4. Trigger the deploy
831+
progress::update_message(&pb, "Deploying Status Panel...");
832+
let resp = ctx.client.deploy(project.id, Some(cloud_id), deploy_form).await?;
833+
Ok(resp)
834+
});
835+
836+
match result {
837+
Ok(resp) => {
838+
progress::finish_success(&pb, "Status Panel agent installation triggered");
839+
840+
if self.json {
841+
println!("{}", serde_json::to_string_pretty(&resp).unwrap_or_default());
842+
} else {
843+
println!("Status Panel deploy queued for project '{}'", project_name);
844+
if let Some(id) = resp.id {
845+
println!("Project ID: {}", id);
846+
}
847+
if let Some(meta) = &resp.meta {
848+
if let Some(dep_id) = meta.get("deployment_id") {
849+
println!("Deployment ID: {}", dep_id);
850+
}
851+
}
852+
println!();
853+
println!("The Status Panel agent will be installed on the server.");
854+
println!("Once ready, use `stacker agent status` to verify connectivity.");
855+
}
856+
}
857+
Err(e) => {
858+
progress::finish_error(&pb, &format!("Install failed: {}", e));
859+
return Err(Box::new(e));
860+
}
861+
}
862+
863+
Ok(())
864+
}
865+
}
866+
725867
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
726868
// Tests
727869
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

src/forms/server.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ pub struct ServerForm {
3030
/// Not persisted to the database.
3131
#[serde(skip_serializing_if = "Option::is_none")]
3232
pub public_key: Option<String>,
33+
/// The actual SSH private key content (PEM).
34+
/// Populated at deploy time for "own" flow re-deploys so the Install Service
35+
/// can SSH into the server without relying on a cached file path in Redis.
36+
/// Not persisted to the database.
37+
#[serde(skip_serializing_if = "Option::is_none")]
38+
pub ssh_private_key: Option<String>,
3339
}
3440

3541
pub fn default_ssh_port() -> Option<i32> {
@@ -64,6 +70,7 @@ impl From<&ServerForm> for models::Server {
6470
impl Into<ServerForm> for models::Server {
6571
fn into(self) -> ServerForm {
6672
let mut form = ServerForm::default();
73+
form.server_id = Some(self.id);
6774
form.cloud_id = self.cloud_id;
6875
form.disk_type = self.disk_type;
6976
form.region = self.region;

src/routes/project/deploy.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,24 @@ pub async fn item(
266266
server
267267
};
268268

269+
// For "own" flow (existing server with IP), SSH access is required.
270+
// If we couldn't set up the SSH key, fail early instead of letting the
271+
// Install Service crash when it cannot find the key.
272+
let has_existing_ip = server.srv_ip.as_ref().map_or(false, |ip| !ip.is_empty());
273+
if has_existing_ip && new_public_key.is_none() && server.vault_key_path.is_none() {
274+
tracing::error!(
275+
"Cannot deploy to existing server {} (IP: {:?}): SSH key is not available. \
276+
vault_key_path is None and key generation failed.",
277+
server.id,
278+
server.srv_ip,
279+
);
280+
return Err(JsonResponse::<models::Project>::build().bad_request(
281+
"SSH key is not available for this server. \
282+
Please generate an SSH key first with `stacker ssh-key generate` \
283+
or re-add your server with SSH credentials.",
284+
));
285+
}
286+
269287
// Store deployment attempts into deployment table in db
270288
let json_request = dc.project.metadata.clone();
271289
let deployment_hash = format!("deployment_{}", Uuid::new_v4());
@@ -285,6 +303,30 @@ pub async fn item(
285303

286304
let deployment_id = saved_deployment.id;
287305

306+
// For "own" flow, fetch the SSH private key from Vault so the Install Service
307+
// can SSH into the server directly without relying on Redis-cached file paths.
308+
let new_private_key = if has_existing_ip && server.vault_key_path.is_some() {
309+
match vault_client
310+
.get_ref()
311+
.fetch_ssh_key(&user.id, server.id)
312+
.await
313+
{
314+
Ok(pk) => {
315+
tracing::info!("Fetched SSH private key from Vault for server {}", server.id);
316+
Some(pk)
317+
}
318+
Err(e) => {
319+
tracing::warn!(
320+
"Failed to fetch SSH private key from Vault for server {}: {}",
321+
server.id, e
322+
);
323+
None
324+
}
325+
}
326+
} else {
327+
None
328+
};
329+
288330
// Delegate to install service connector
289331
install_service
290332
.deploy(
@@ -301,6 +343,7 @@ pub async fn item(
301343
fc,
302344
mq_manager.get_ref(),
303345
new_public_key,
346+
new_private_key,
304347
)
305348
.await
306349
.map(|project_id| {
@@ -571,6 +614,24 @@ pub async fn saved_item(
571614
server
572615
};
573616

617+
// For "own" flow (existing server with IP), SSH access is required.
618+
// If we couldn't set up the SSH key, fail early instead of letting the
619+
// Install Service crash when it cannot find the key.
620+
let has_existing_ip = server.srv_ip.as_ref().map_or(false, |ip| !ip.is_empty());
621+
if has_existing_ip && new_public_key.is_none() && server.vault_key_path.is_none() {
622+
tracing::error!(
623+
"Cannot deploy to existing server {} (IP: {:?}): SSH key is not available. \
624+
vault_key_path is None and key generation failed.",
625+
server.id,
626+
server.srv_ip,
627+
);
628+
return Err(JsonResponse::<models::Project>::build().bad_request(
629+
"SSH key is not available for this server. \
630+
Please generate an SSH key first with `stacker ssh-key generate` \
631+
or re-add your server with SSH credentials.",
632+
));
633+
}
634+
574635
// Store deployment attempts into deployment table in db
575636
let json_request = dc.project.metadata.clone();
576637
let deployment_hash = format!("deployment_{}", Uuid::new_v4());
@@ -592,6 +653,30 @@ pub async fn saved_item(
592653

593654
tracing::debug!("Save deployment result: {:?}", result);
594655

656+
// For "own" flow, fetch the SSH private key from Vault so the Install Service
657+
// can SSH into the server directly without relying on Redis-cached file paths.
658+
let new_private_key = if has_existing_ip && server.vault_key_path.is_some() {
659+
match vault_client
660+
.get_ref()
661+
.fetch_ssh_key(&user.id, server.id)
662+
.await
663+
{
664+
Ok(pk) => {
665+
tracing::info!("Fetched SSH private key from Vault for server {}", server.id);
666+
Some(pk)
667+
}
668+
Err(e) => {
669+
tracing::warn!(
670+
"Failed to fetch SSH private key from Vault for server {}: {}",
671+
server.id, e
672+
);
673+
None
674+
}
675+
}
676+
} else {
677+
None
678+
};
679+
595680
// Delegate to install service connector (determines own vs tfa routing)
596681
install_service
597682
.deploy(
@@ -608,6 +693,7 @@ pub async fn saved_item(
608693
fc,
609694
mq_manager.get_ref(),
610695
new_public_key,
696+
new_private_key,
611697
)
612698
.await
613699
.map(|project_id| {

0 commit comments

Comments
 (0)