Skip to main content

axon/
deployer.rs

1//! `axon deploy` — hot-deploy .axon source to a running AxonServer.
2//!
3//! Reads an .axon file, sends its source to the server's `/v1/deploy`
4//! endpoint via HTTP POST, and reports the result.
5//!
6//! Usage:
7//!   axon deploy myflow.axon --server http://localhost:8420
8//!   axon deploy myflow.axon --server http://prod:8420 --auth-token SECRET
9//!
10//! Exit codes:
11//!   0 — deploy succeeded
12//!   1 — deploy failed (compilation error on server)
13//!   2 — I/O or connection error
14
15use std::io::IsTerminal;
16use std::time::Duration;
17
18// ── Deploy configuration ──────────────────────────────────────────────────
19
20/// Configuration for a deploy operation.
21#[derive(Debug, Clone)]
22pub struct DeployConfig {
23    pub file: String,
24    pub server: String,
25    pub backend: String,
26    pub auth_token: String,
27}
28
29// ── Deploy response ───────────────────────────────────────────────────────
30
31/// Parsed response from the server's /v1/deploy endpoint.
32#[derive(Debug)]
33pub struct DeployResult {
34    pub success: bool,
35    pub deployed: Vec<String>,
36    pub error: Option<String>,
37    pub phase: Option<String>,
38    pub raw_json: serde_json::Value,
39}
40
41// ── Deploy execution ──────────────────────────────────────────────────────
42
43const DEPLOY_TIMEOUT: Duration = Duration::from_secs(30);
44
45/// Execute a deploy operation. Returns exit code.
46pub fn run_deploy(config: &DeployConfig) -> i32 {
47    let use_color = std::io::stdout().is_terminal();
48
49    // 1. Read the source file
50    let source = match std::fs::read_to_string(&config.file) {
51        Ok(s) => s,
52        Err(e) => {
53            let msg = format!("Cannot read '{}': {e}", config.file);
54            if use_color {
55                eprintln!("\x1b[1;31m{msg}\x1b[0m");
56            } else {
57                eprintln!("{msg}");
58            }
59            return 2;
60        }
61    };
62
63    // 2. Validate server URL
64    if !config.server.starts_with("http://") && !config.server.starts_with("https://") {
65        let msg = format!(
66            "Invalid server URL '{}'. Must start with http:// or https://.",
67            config.server
68        );
69        if use_color {
70            eprintln!("\x1b[1;31m{msg}\x1b[0m");
71        } else {
72            eprintln!("{msg}");
73        }
74        return 2;
75    }
76
77    // 3. Build the deploy URL
78    let deploy_url = format!(
79        "{}/v1/deploy",
80        config.server.trim_end_matches('/')
81    );
82
83    if use_color {
84        eprintln!(
85            "\x1b[1;36m⬡ Deploying '{}' to {}\x1b[0m",
86            config.file, config.server
87        );
88    } else {
89        eprintln!("Deploying '{}' to {}", config.file, config.server);
90    }
91
92    // 4. Send the deploy request
93    let result = send_deploy(&deploy_url, &config.file, &source, &config.backend, &config.auth_token);
94
95    match result {
96        Ok(deploy) => {
97            if deploy.success {
98                let names = deploy.deployed.join(", ");
99                if use_color {
100                    eprintln!(
101                        "\x1b[1;32m  ✓ Deployed: {names} ({} flow{})\x1b[0m",
102                        deploy.deployed.len(),
103                        if deploy.deployed.len() == 1 { "" } else { "s" },
104                    );
105                } else {
106                    eprintln!(
107                        "  Deployed: {names} ({} flow{})",
108                        deploy.deployed.len(),
109                        if deploy.deployed.len() == 1 { "" } else { "s" },
110                    );
111                }
112                0
113            } else {
114                let error = deploy.error.unwrap_or_else(|| "unknown error".to_string());
115                let phase = deploy.phase.unwrap_or_else(|| "unknown".to_string());
116                if use_color {
117                    eprintln!(
118                        "\x1b[1;31m  ✗ Deploy failed ({phase}): {error}\x1b[0m",
119                    );
120                } else {
121                    eprintln!("  Deploy failed ({phase}): {error}");
122                }
123                1
124            }
125        }
126        Err(e) => {
127            if use_color {
128                eprintln!("\x1b[1;31m  ✗ {e}\x1b[0m");
129            } else {
130                eprintln!("  {e}");
131            }
132            2
133        }
134    }
135}
136
137/// Send the deploy request to the server.
138fn send_deploy(
139    url: &str,
140    filename: &str,
141    source: &str,
142    backend: &str,
143    auth_token: &str,
144) -> Result<DeployResult, String> {
145    let client = reqwest::blocking::Client::builder()
146        .timeout(DEPLOY_TIMEOUT)
147        .build()
148        .map_err(|e| format!("Failed to create HTTP client: {e}"))?;
149
150    let body = serde_json::json!({
151        "source": source,
152        "filename": filename,
153        "backend": backend,
154    });
155
156    let mut request = client
157        .post(url)
158        .header("Content-Type", "application/json");
159
160    if !auth_token.is_empty() {
161        request = request.header("Authorization", format!("Bearer {auth_token}"));
162    }
163
164    let response = request
165        .body(body.to_string())
166        .send()
167        .map_err(|e| {
168            if e.is_timeout() {
169                format!("Server timed out after {}s", DEPLOY_TIMEOUT.as_secs())
170            } else if e.is_connect() {
171                format!("Cannot connect to server at {url}. Is `axon serve` running?")
172            } else {
173                format!("Request failed: {e}")
174            }
175        })?;
176
177    let status = response.status();
178
179    if status == reqwest::StatusCode::UNAUTHORIZED {
180        return Err("Authentication required. Use --auth-token <TOKEN>.".to_string());
181    }
182    if status == reqwest::StatusCode::FORBIDDEN {
183        return Err("Invalid auth token. Check your --auth-token value.".to_string());
184    }
185
186    let text = response
187        .text()
188        .map_err(|e| format!("Failed to read response: {e}"))?;
189
190    if !status.is_success() {
191        return Err(format!("Server returned HTTP {}: {text}", status.as_u16()));
192    }
193
194    let json: serde_json::Value = serde_json::from_str(&text)
195        .map_err(|e| format!("Invalid JSON response: {e}"))?;
196
197    let success = json["success"].as_bool().unwrap_or(false);
198    let deployed = json["deployed"]
199        .as_array()
200        .map(|arr| {
201            arr.iter()
202                .filter_map(|v| v.as_str().map(String::from))
203                .collect()
204        })
205        .unwrap_or_default();
206    let error = json["error"].as_str().map(String::from);
207    let phase = json["phase"].as_str().map(String::from);
208
209    Ok(DeployResult {
210        success,
211        deployed,
212        error,
213        phase,
214        raw_json: json,
215    })
216}
217
218// ── Tests ─────────────────────────────────────────────────────────────────
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn deploy_config_defaults() {
226        let cfg = DeployConfig {
227            file: "test.axon".into(),
228            server: "http://localhost:8420".into(),
229            backend: "anthropic".into(),
230            auth_token: String::new(),
231        };
232        assert_eq!(cfg.file, "test.axon");
233        assert_eq!(cfg.server, "http://localhost:8420");
234    }
235
236    #[test]
237    fn deploy_file_not_found() {
238        let cfg = DeployConfig {
239            file: "nonexistent_file_xyz.axon".into(),
240            server: "http://localhost:8420".into(),
241            backend: "anthropic".into(),
242            auth_token: String::new(),
243        };
244        assert_eq!(run_deploy(&cfg), 2);
245    }
246
247    #[test]
248    fn deploy_invalid_server_url() {
249        // Write a temp file so we get past the file-read check
250        let tmp = std::env::temp_dir().join("axon_test_deploy_url.axon");
251        std::fs::write(&tmp, "persona P { tone: \"analytical\" }\n").unwrap();
252
253        let cfg = DeployConfig {
254            file: tmp.to_str().unwrap().into(),
255            server: "ftp://badscheme".into(),
256            backend: "anthropic".into(),
257            auth_token: String::new(),
258        };
259        assert_eq!(run_deploy(&cfg), 2);
260        let _ = std::fs::remove_file(tmp);
261    }
262
263    #[test]
264    fn deploy_connection_refused() {
265        let tmp = std::env::temp_dir().join("axon_test_deploy_conn.axon");
266        std::fs::write(&tmp, "persona P { tone: \"analytical\" }\n").unwrap();
267
268        let cfg = DeployConfig {
269            file: tmp.to_str().unwrap().into(),
270            server: "http://127.0.0.1:1".into(), // unreachable port
271            backend: "anthropic".into(),
272            auth_token: String::new(),
273        };
274        assert_eq!(run_deploy(&cfg), 2);
275        let _ = std::fs::remove_file(tmp);
276    }
277
278    #[test]
279    fn deploy_result_parsing() {
280        let json: serde_json::Value = serde_json::json!({
281            "success": true,
282            "deployed": ["FlowA", "FlowB"],
283            "flow_count": 2,
284            "backend": "anthropic"
285        });
286
287        let success = json["success"].as_bool().unwrap_or(false);
288        let deployed: Vec<String> = json["deployed"]
289            .as_array()
290            .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
291            .unwrap_or_default();
292
293        assert!(success);
294        assert_eq!(deployed, vec!["FlowA", "FlowB"]);
295    }
296
297    #[test]
298    fn deploy_error_result_parsing() {
299        let json: serde_json::Value = serde_json::json!({
300            "success": false,
301            "error": "parse error: unexpected token",
302            "phase": "parser"
303        });
304
305        let success = json["success"].as_bool().unwrap_or(false);
306        let error = json["error"].as_str().map(String::from);
307        let phase = json["phase"].as_str().map(String::from);
308
309        assert!(!success);
310        assert_eq!(error.unwrap(), "parse error: unexpected token");
311        assert_eq!(phase.unwrap(), "parser");
312    }
313
314    #[test]
315    fn deploy_url_construction() {
316        let base = "http://localhost:8420";
317        let url = format!("{}/v1/deploy", base.trim_end_matches('/'));
318        assert_eq!(url, "http://localhost:8420/v1/deploy");
319
320        let base_trailing = "http://localhost:8420/";
321        let url = format!("{}/v1/deploy", base_trailing.trim_end_matches('/'));
322        assert_eq!(url, "http://localhost:8420/v1/deploy");
323    }
324}