Skip to main content

life_cli/deploy/
mod.rs

1//! Deploy orchestrator — provisions agent stacks on cloud targets.
2
3mod backend;
4mod railway;
5mod state;
6
7use std::collections::HashMap;
8use std::time::Duration;
9
10use anyhow::{Context, Result};
11use tracing::info;
12
13use crate::cli::{DeployArgs, DestroyArgs, ListArgs};
14use crate::template::load_template;
15
16pub use backend::DeployBackend;
17pub use state::DeploymentState;
18
19/// Execute a full agent deployment.
20pub async fn run(args: DeployArgs) -> Result<()> {
21    let template = load_template(&args.agent, args.template_path.as_deref())?;
22
23    info!(
24        agent = %args.agent,
25        target = %args.target,
26        services = template.services.len(),
27        "deploying agent"
28    );
29
30    let project_name = args
31        .project_name
32        .unwrap_or_else(|| format!("life-{}", args.agent));
33
34    // Parse extra env vars from --env KEY=VALUE
35    let mut extra_env: HashMap<String, String> = HashMap::new();
36    for kv in &args.env {
37        if let Some((k, v)) = kv.split_once('=') {
38            extra_env.insert(k.to_string(), v.to_string());
39        }
40    }
41
42    // Inject provider override into arcan env
43    if let Some(arcan) = template.services.get("arcan") {
44        let _ = arcan; // template is immutable; we inject at deploy time
45        extra_env
46            .entry("ARCAN_PROVIDER".to_string())
47            .or_insert_with(|| args.provider.clone());
48    }
49
50    let backend = create_backend(&args.target)?;
51
52    println!(
53        "Deploying {agent} to {target}...",
54        agent = args.agent,
55        target = args.target
56    );
57    println!(
58        "  Template: {name} — {desc}",
59        name = template.meta.name,
60        desc = template.meta.description
61    );
62    println!(
63        "  Services: {svcs}",
64        svcs = template
65            .services
66            .keys()
67            .cloned()
68            .collect::<Vec<_>>()
69            .join(", ")
70    );
71    println!();
72
73    // ── Provision ────────────────────────────────────────────────────────────
74    let result = backend.deploy(&project_name, &template, &extra_env).await?;
75
76    // Save deployment state for future status/destroy commands
77    let state = DeploymentState {
78        agent_name: args.agent.clone(),
79        project_name: project_name.clone(),
80        target: args.target.clone(),
81        project_id: result.project_id.clone(),
82        environment_id: result.environment_id.clone(),
83        services: result.services.clone(),
84        deployed_at: chrono::Utc::now(),
85        template_name: template.meta.name.clone(),
86    };
87    state.save()?;
88
89    println!("Deployment initiated:");
90    for (name, svc) in &result.services {
91        let url = svc.url.as_deref().unwrap_or("(internal)");
92        println!("  {name}: {url} (service_id: {id})", id = svc.service_id);
93    }
94    println!();
95
96    // ── Health check polling ─────────────────────────────────────────────────
97    if !args.no_wait {
98        println!("Waiting for services to become healthy...");
99        let timeout = Duration::from_secs(300);
100        let poll_interval = Duration::from_secs(10);
101        let start = std::time::Instant::now();
102
103        loop {
104            if start.elapsed() > timeout {
105                eprintln!("Timeout: not all services became healthy within 5 minutes.");
106                eprintln!(
107                    "Run `life status --agent {agent}` to check progress.",
108                    agent = args.agent
109                );
110                break;
111            }
112
113            let status = backend.status(&result.project_id).await;
114            match status {
115                Ok(statuses) => {
116                    let all_healthy = statuses.iter().all(|(_, s)| {
117                        matches!(
118                            s.status.as_str(),
119                            "SUCCESS" | "HEALTHY" | "RUNNING" | "ACTIVE"
120                        )
121                    });
122
123                    if all_healthy && !statuses.is_empty() {
124                        println!();
125                        println!("All services healthy!");
126                        for (name, s) in &statuses {
127                            println!("  {name}: {} {}", s.status, s.url.as_deref().unwrap_or(""));
128                        }
129                        break;
130                    }
131
132                    print!(".");
133                }
134                Err(_) => {
135                    print!("?");
136                }
137            }
138
139            tokio::time::sleep(poll_interval).await;
140        }
141    }
142
143    println!();
144    println!("Agent deployed: {project_name}");
145    println!("  life status --agent {agent}", agent = args.agent);
146    println!("  life cost   --agent {agent}", agent = args.agent);
147    println!("  life destroy --agent {agent}", agent = args.agent);
148
149    Ok(())
150}
151
152/// Tear down a deployed agent.
153pub async fn destroy(args: DestroyArgs) -> Result<()> {
154    let state = DeploymentState::load(&args.agent)
155        .with_context(|| format!("no deployment found for agent '{}'", args.agent))?;
156
157    if !args.yes {
158        println!(
159            "This will permanently destroy agent '{agent}' (project: {project}).",
160            agent = args.agent,
161            project = state.project_name,
162        );
163        println!("  Target: {}", state.target);
164        println!(
165            "  Services: {}",
166            state
167                .services
168                .keys()
169                .cloned()
170                .collect::<Vec<_>>()
171                .join(", ")
172        );
173        println!();
174        println!("Type 'yes' to confirm:");
175
176        let mut input = String::new();
177        std::io::stdin().read_line(&mut input)?;
178        if input.trim() != "yes" {
179            println!("Aborted.");
180            return Ok(());
181        }
182    }
183
184    let backend = create_backend(&state.target)?;
185
186    println!("Destroying agent '{}'...", args.agent);
187    backend.destroy(&state.project_id).await?;
188
189    // Remove local state file
190    state.remove()?;
191
192    println!("Agent '{}' destroyed.", args.agent);
193    Ok(())
194}
195
196/// List all deployed agents.
197pub async fn list(args: ListArgs) -> Result<()> {
198    let states = DeploymentState::list_all()?;
199
200    if states.is_empty() {
201        println!("No deployed agents found.");
202        println!("Deploy one with: life deploy --agent coding-agent --target railway");
203        return Ok(());
204    }
205
206    match &args.format[..] {
207        "json" => {
208            let output: Vec<serde_json::Value> = states
209                .iter()
210                .map(|s| {
211                    serde_json::json!({
212                        "agent": s.agent_name,
213                        "project": s.project_name,
214                        "target": s.target,
215                        "template": s.template_name,
216                        "services": s.services.len(),
217                        "deployed_at": s.deployed_at.to_rfc3339(),
218                    })
219                })
220                .collect();
221            println!("{}", serde_json::to_string_pretty(&output)?);
222        }
223        _ => {
224            println!(
225                "{:<20} {:<25} {:<10} {:<18} {:<6} Deployed",
226                "Agent", "Project", "Target", "Template", "Svcs"
227            );
228            println!("{}", "─".repeat(95));
229
230            for s in &states {
231                println!(
232                    "{:<20} {:<25} {:<10} {:<18} {:<6} {}",
233                    s.agent_name,
234                    s.project_name,
235                    s.target,
236                    s.template_name,
237                    s.services.len(),
238                    s.deployed_at.format("%Y-%m-%d %H:%M"),
239                );
240            }
241        }
242    }
243
244    Ok(())
245}
246
247/// Create the appropriate backend based on the target name.
248pub fn create_backend(target: &str) -> Result<Box<dyn DeployBackend>> {
249    match target {
250        "railway" => {
251            let token = std::env::var("RAILWAY_API_TOKEN").context(
252                "RAILWAY_API_TOKEN environment variable is required for Railway deploys",
253            )?;
254            Ok(Box::new(railway::RailwayBackend::new(token)))
255        }
256        "flyio" | "fly" => {
257            anyhow::bail!(
258                "Fly.io backend is planned but not yet implemented. Use --target railway."
259            );
260        }
261        "ecs" | "aws" => {
262            anyhow::bail!(
263                "AWS ECS backend is planned but not yet implemented. Use --target railway."
264            );
265        }
266        other => {
267            anyhow::bail!(
268                "Unknown deploy target: '{other}'. Supported: railway, flyio (planned), ecs (planned)."
269            );
270        }
271    }
272}