@@ -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// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
0 commit comments