Skip to content

Commit 7d754bf

Browse files
authored
Merge pull request #131 from trydirect/feature/pre-flight-connect-130
Feature/pre flight connect 130
2 parents da025f3 + b3d787e commit 7d754bf

14 files changed

Lines changed: 298 additions & 5 deletions

File tree

configuration.yaml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ vault:
3232
# Path under the mount (without deployment_hash), e.g. 'secret/debug/status_panel' or 'agent'
3333
# Final path: {address}/{api_prefix}/{agent_path_prefix}/{deployment_hash}/token
3434
agent_path_prefix: agent
35+
ssh_key_path_prefix: data/users
3536

3637
# External service connectors
3738
connectors:

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/deployment_lock.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ mod tests {
348348
server_ip: None,
349349
deployment_id: Some(1),
350350
project_id: Some(2),
351+
server_name: None,
351352
});
352353

353354
let enriched = lock.with_server_info(

src/cli/install_runner.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ pub struct DeployResult {
113113
pub deployment_id: Option<i64>,
114114
/// Stacker server project ID (set for remote orchestrator deploys).
115115
pub project_id: Option<i64>,
116+
/// Server name used/generated for this deploy (for lockfile persistence).
117+
pub server_name: Option<String>,
116118
}
117119

118120
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -212,6 +214,7 @@ impl DeployStrategy for LocalDeploy {
212214
server_ip: None,
213215
deployment_id: None,
214216
project_id: None,
217+
server_name: None,
215218
})
216219
}
217220

@@ -432,6 +435,7 @@ impl DeployStrategy for CloudDeploy {
432435
server_ip: None,
433436
deployment_id: None,
434437
project_id: None,
438+
server_name: None,
435439
});
436440
}
437441

@@ -466,7 +470,7 @@ impl DeployStrategy for CloudDeploy {
466470
reason: format!("Failed to initialize async runtime: {}", e),
467471
})?;
468472

469-
let response = rt.block_on(async {
473+
let (response, effective_server_name) = rt.block_on(async {
470474
let client = StackerClient::new(&base_url, &creds.access_token);
471475

472476
// Step 1: Resolve or auto-create project
@@ -629,6 +633,17 @@ impl DeployStrategy for CloudDeploy {
629633

630634
// Step 4: Build deploy form
631635
let mut deploy_form = stacker_client::build_deploy_form(config);
636+
637+
// Capture the server name from the form (auto-generated or overridden)
638+
// so we can persist it in the deployment lock even if the API fetch
639+
// after deploy doesn't return server details yet.
640+
let effective_server_name = server_name.clone().or_else(|| {
641+
deploy_form
642+
.get("server")
643+
.and_then(|s| s.get("name"))
644+
.and_then(|n| n.as_str())
645+
.map(|s| s.to_string())
646+
});
632647
if let Some(sid) = server_id {
633648
if let Some(server_obj) = deploy_form.get_mut("server") {
634649
if let Some(obj) = server_obj.as_object_mut() {
@@ -672,7 +687,7 @@ impl DeployStrategy for CloudDeploy {
672687
eprintln!(" Deploying project '{}' (id={})...", project_name, project.id);
673688
let resp = client.deploy(project.id, cloud_id, deploy_form).await?;
674689

675-
Ok(resp)
690+
Ok((resp, effective_server_name))
676691
}).map_err(|e: CliError| e)?;
677692

678693
let deploy_id = response
@@ -709,6 +724,7 @@ impl DeployStrategy for CloudDeploy {
709724
server_ip: None,
710725
deployment_id: deploy_id,
711726
project_id: project_id.map(|id| id as i64),
727+
server_name: effective_server_name,
712728
});
713729
}
714730
}
@@ -739,6 +755,7 @@ impl DeployStrategy for CloudDeploy {
739755
server_ip: extract_server_ip(&output.stdout),
740756
deployment_id: None,
741757
project_id: None,
758+
server_name: None,
742759
})
743760
}
744761

@@ -1211,6 +1228,7 @@ impl DeployStrategy for ServerDeploy {
12111228
server_ip: server_host,
12121229
deployment_id: None,
12131230
project_id: None,
1231+
server_name: None,
12141232
})
12151233
}
12161234

src/cli/stacker_client.rs

Lines changed: 8 additions & 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")]
@@ -1528,6 +1529,9 @@ pub fn build_project_body(config: &StackerConfig) -> serde_json::Value {
15281529
{"host_port": "5000", "container_port": "5000"},
15291530
],
15301531
"network": [],
1532+
"dockerhub_user": "trydirect",
1533+
"dockerhub_name": "status",
1534+
"dockerhub_tag": "latest",
15311535
}));
15321536
}
15331537

@@ -1936,6 +1940,10 @@ mod tests {
19361940
assert_eq!(ports.len(), 1);
19371941
assert_eq!(ports[0]["host_port"], "5000");
19381942
assert_eq!(ports[0]["container_port"], "5000");
1943+
// Image fields must be present so the install service can build a Docker image reference
1944+
assert_eq!(sp["dockerhub_user"], "trydirect");
1945+
assert_eq!(sp["dockerhub_name"], "status");
1946+
assert_eq!(sp["dockerhub_tag"], "latest");
19391947
}
19401948

19411949
#[test]

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/console/commands/cli/deploy.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ fn ensure_env_file_if_needed(config: &StackerConfig, project_dir: &Path) -> Resu
225225
}
226226

227227
/// SSH connection timeout for server pre-check (seconds).
228-
const SSH_CHECK_TIMEOUT_SECS: u64 = 15;
228+
const SSH_CHECK_TIMEOUT_SECS: u64 = 4;
229229

230230
/// Resolve the path to an SSH key, expanding `~` to the user's home directory.
231231
fn resolve_ssh_key_path(key_path: &Path) -> PathBuf {
@@ -1266,6 +1266,15 @@ impl DeployCommand {
12661266
}
12671267
}
12681268

1269+
// Fallback: if the API fetch didn't populate server_name,
1270+
// use the name from the deploy form so subsequent deploys
1271+
// can still find and reuse the server.
1272+
if l.server_name.is_none() {
1273+
if let Some(ref name) = result.server_name {
1274+
l.server_name = Some(name.clone());
1275+
}
1276+
}
1277+
12691278
l
12701279
}
12711280
};
@@ -2129,6 +2138,7 @@ services:
21292138
server_ip: None,
21302139
deployment_id: Some(42),
21312140
project_id: Some(7),
2141+
server_name: None,
21322142
};
21332143
assert_eq!(result.deployment_id, Some(42));
21342144
assert_eq!(result.project_id, Some(7));

0 commit comments

Comments
 (0)