Skip to main content

xbp_cli/commands/
api_control.rs

1use crate::cli::commands::{
2    ApiDaemonsCmd, ApiDaemonsHeartbeatCmd, ApiDaemonsRegisterCmd, ApiDaemonsSubCommand,
3    ApiDaemonsUpdateStatusCmd, ApiHealthCmd, ApiJobsCmd, ApiJobsCreateCmd, ApiJobsListCmd,
4    ApiJobsSubCommand, ApiJobsUpdateCmd, ApiProjectsCmd, ApiProjectsCreateCmd, ApiProjectsListCmd,
5    ApiProjectsSubCommand, ApiRoutesCmd, ApiRoutesCreateCmd, ApiRoutesDeleteCmd,
6    ApiRoutesSubCommand, ApiTargetOptions,
7};
8use crate::commands::api_request::{execute_api_request, ApiRequestExecution};
9use reqwest::Method;
10use serde_json::{Map, Value};
11
12pub async fn run_api_health(cmd: &ApiHealthCmd) -> Result<(), String> {
13    execute_without_body("/health", Method::GET, &cmd.target).await
14}
15
16pub async fn run_api_projects(cmd: &ApiProjectsCmd) -> Result<(), String> {
17    match &cmd.command {
18        ApiProjectsSubCommand::List(list_cmd) => run_api_projects_list(list_cmd).await,
19        ApiProjectsSubCommand::Create(create_cmd) => run_api_projects_create(create_cmd).await,
20    }
21}
22
23pub async fn run_api_daemons(cmd: &ApiDaemonsCmd) -> Result<(), String> {
24    match &cmd.command {
25        ApiDaemonsSubCommand::List(list_cmd) => {
26            execute_without_body("/daemons", Method::GET, &list_cmd.target).await
27        }
28        ApiDaemonsSubCommand::Register(register_cmd) => {
29            run_api_daemons_register(register_cmd).await
30        }
31        ApiDaemonsSubCommand::Heartbeat(heartbeat_cmd) => {
32            run_api_daemons_heartbeat(heartbeat_cmd).await
33        }
34        ApiDaemonsSubCommand::UpdateStatus(update_cmd) => {
35            run_api_daemons_update_status(update_cmd).await
36        }
37    }
38}
39
40pub async fn run_api_jobs(cmd: &ApiJobsCmd) -> Result<(), String> {
41    match &cmd.command {
42        ApiJobsSubCommand::List(list_cmd) => run_api_jobs_list(list_cmd).await,
43        ApiJobsSubCommand::Create(create_cmd) => run_api_jobs_create(create_cmd).await,
44        ApiJobsSubCommand::Claim(claim_cmd) => {
45            let mut body = Map::new();
46            insert_string(&mut body, "daemon_id", &claim_cmd.daemon_id);
47            insert_optional_string(&mut body, "locked_by", claim_cmd.locked_by.as_deref());
48            execute_with_json_body(
49                "/deployment-jobs/claim",
50                Method::POST,
51                Value::Object(body),
52                &claim_cmd.target,
53            )
54            .await
55        }
56        ApiJobsSubCommand::Update(update_cmd) => run_api_jobs_update(update_cmd).await,
57    }
58}
59
60pub async fn run_api_routes(cmd: &ApiRoutesCmd) -> Result<(), String> {
61    match &cmd.command {
62        ApiRoutesSubCommand::List(list_cmd) => {
63            execute_without_body("/routes", Method::GET, &list_cmd.target).await
64        }
65        ApiRoutesSubCommand::Create(create_cmd) => run_api_routes_create(create_cmd).await,
66        ApiRoutesSubCommand::Delete(delete_cmd) => run_api_routes_delete(delete_cmd).await,
67    }
68}
69
70async fn run_api_projects_list(cmd: &ApiProjectsListCmd) -> Result<(), String> {
71    let path = with_query(
72        "/projects",
73        &[("organization_id", cmd.organization_id.as_deref())],
74    );
75    execute_without_body(&path, Method::GET, &cmd.target).await
76}
77
78async fn run_api_projects_create(cmd: &ApiProjectsCreateCmd) -> Result<(), String> {
79    let mut body = Map::new();
80    insert_string(&mut body, "name", &cmd.name);
81    insert_string(&mut body, "path", &cmd.path);
82    insert_optional_string(&mut body, "organization_id", cmd.organization_id.as_deref());
83    insert_optional_string(&mut body, "slug", cmd.slug.as_deref());
84    insert_optional_string(&mut body, "version", cmd.version.as_deref());
85    insert_optional_string(&mut body, "build_dir", cmd.build_dir.as_deref());
86    insert_optional_string(&mut body, "runtime", cmd.runtime.as_deref());
87    insert_optional_string(&mut body, "default_branch", cmd.default_branch.as_deref());
88    insert_optional_string(&mut body, "root_directory", cmd.root_directory.as_deref());
89    insert_optional_string(&mut body, "build_command", cmd.build_command.as_deref());
90    insert_optional_string(&mut body, "install_command", cmd.install_command.as_deref());
91    insert_optional_string(&mut body, "start_command", cmd.start_command.as_deref());
92    insert_optional_string(
93        &mut body,
94        "output_directory",
95        cmd.output_directory.as_deref(),
96    );
97    insert_optional_json(&mut body, "repository", cmd.repository_json.as_deref())?;
98    insert_optional_json(
99        &mut body,
100        "runtime_policy",
101        cmd.runtime_policy_json.as_deref(),
102    )?;
103    insert_optional_json(&mut body, "metadata", cmd.metadata_json.as_deref())?;
104
105    execute_with_json_body("/projects", Method::POST, Value::Object(body), &cmd.target).await
106}
107
108async fn run_api_daemons_register(cmd: &ApiDaemonsRegisterCmd) -> Result<(), String> {
109    let mut body = Map::new();
110    insert_string(&mut body, "node_name", &cmd.node_name);
111    insert_string(&mut body, "hostname", &cmd.hostname);
112    insert_string(&mut body, "version", &cmd.version);
113    insert_optional_string(&mut body, "region", cmd.region.as_deref());
114    insert_optional_string(&mut body, "public_ip", cmd.public_ip.as_deref());
115    insert_optional_string(&mut body, "internal_ip", cmd.internal_ip.as_deref());
116    insert_optional_string(&mut body, "status", cmd.status.as_deref());
117    insert_optional_i32(&mut body, "cpu_cores", cmd.cpu_cores);
118    insert_optional_i32(&mut body, "memory_total_mb", cmd.memory_total_mb);
119    insert_optional_i32(&mut body, "disk_total_gb", cmd.disk_total_gb);
120    insert_optional_json(&mut body, "labels", cmd.labels_json.as_deref())?;
121    insert_optional_json(&mut body, "metadata", cmd.metadata_json.as_deref())?;
122
123    execute_with_json_body("/daemons", Method::POST, Value::Object(body), &cmd.target).await
124}
125
126async fn run_api_daemons_heartbeat(cmd: &ApiDaemonsHeartbeatCmd) -> Result<(), String> {
127    let mut body = Map::new();
128    insert_optional_string(&mut body, "status", cmd.status.as_deref());
129    insert_optional_string(&mut body, "version", cmd.version.as_deref());
130    insert_optional_string(&mut body, "public_ip", cmd.public_ip.as_deref());
131    insert_optional_string(&mut body, "internal_ip", cmd.internal_ip.as_deref());
132    insert_optional_i32(&mut body, "cpu_cores", cmd.cpu_cores);
133    insert_optional_i32(&mut body, "memory_total_mb", cmd.memory_total_mb);
134    insert_optional_i32(&mut body, "disk_total_gb", cmd.disk_total_gb);
135    insert_optional_json(&mut body, "labels", cmd.labels_json.as_deref())?;
136
137    execute_with_json_body(
138        &format!("/daemons/{}/heartbeat", cmd.daemon_id),
139        Method::POST,
140        Value::Object(body),
141        &cmd.target,
142    )
143    .await
144}
145
146async fn run_api_daemons_update_status(cmd: &ApiDaemonsUpdateStatusCmd) -> Result<(), String> {
147    let mut body = Map::new();
148    insert_string(&mut body, "status", &cmd.status);
149    execute_with_json_body(
150        &format!("/daemons/{}", cmd.daemon_id),
151        Method::PATCH,
152        Value::Object(body),
153        &cmd.target,
154    )
155    .await
156}
157
158async fn run_api_jobs_list(cmd: &ApiJobsListCmd) -> Result<(), String> {
159    let path = with_query(
160        "/deployment-jobs",
161        &[
162            ("project_id", cmd.project_id.as_deref()),
163            ("deployment_id", cmd.deployment_id.as_deref()),
164            ("daemon_id", cmd.daemon_id.as_deref()),
165            ("status", cmd.status.as_deref()),
166            ("limit", cmd.limit.map(|value| value.to_string()).as_deref()),
167        ],
168    );
169    execute_without_body(&path, Method::GET, &cmd.target).await
170}
171
172async fn run_api_jobs_create(cmd: &ApiJobsCreateCmd) -> Result<(), String> {
173    let mut body = Map::new();
174    insert_string(&mut body, "deployment_id", &cmd.deployment_id);
175    insert_optional_string(&mut body, "daemon_id", cmd.daemon_id.as_deref());
176    insert_optional_i32(&mut body, "priority", cmd.priority);
177    insert_optional_i32(&mut body, "max_attempts", cmd.max_attempts);
178    insert_optional_string(&mut body, "run_after", cmd.run_after.as_deref());
179    insert_optional_json(&mut body, "payload", cmd.payload_json.as_deref())?;
180
181    execute_with_json_body(
182        &format!("/projects/{}/deployment-jobs", cmd.project_id),
183        Method::POST,
184        Value::Object(body),
185        &cmd.target,
186    )
187    .await
188}
189
190async fn run_api_jobs_update(cmd: &ApiJobsUpdateCmd) -> Result<(), String> {
191    let mut body = Map::new();
192    insert_string(&mut body, "status", &cmd.status);
193    insert_optional_string(&mut body, "error_text", cmd.error_text.as_deref());
194    execute_with_json_body(
195        &format!("/deployment-jobs/{}", cmd.job_id),
196        Method::PATCH,
197        Value::Object(body),
198        &cmd.target,
199    )
200    .await
201}
202
203async fn run_api_routes_create(cmd: &ApiRoutesCreateCmd) -> Result<(), String> {
204    let mut targets = Vec::new();
205    for target in &cmd.target {
206        targets.push(serde_json::json!({
207            "url": target,
208            "weight": 1
209        }));
210    }
211    for entry in &cmd.weighted_target {
212        let (url, weight) = parse_weighted_target(entry)?;
213        targets.push(serde_json::json!({
214            "url": url,
215            "weight": weight
216        }));
217    }
218
219    if targets.is_empty() {
220        return Err("At least one --target or --weighted-target value is required.".to_string());
221    }
222
223    let mut body = Map::new();
224    insert_string(&mut body, "domain", &cmd.domain);
225    body.insert("targets".to_string(), Value::Array(targets));
226    let mut conditions = Map::new();
227    insert_optional_string(&mut conditions, "header", cmd.header_condition.as_deref());
228    insert_optional_string(&mut conditions, "path_prefix", cmd.path_prefix.as_deref());
229    if !conditions.is_empty() {
230        body.insert("conditions".to_string(), Value::Object(conditions));
231    }
232
233    execute_with_json_body(
234        "/routes",
235        Method::POST,
236        Value::Object(body),
237        &cmd.target_options,
238    )
239    .await
240}
241
242async fn run_api_routes_delete(cmd: &ApiRoutesDeleteCmd) -> Result<(), String> {
243    execute_without_body(
244        &format!("/routes/{}", cmd.domain),
245        Method::DELETE,
246        &cmd.target,
247    )
248    .await
249}
250
251async fn execute_without_body(
252    path: &str,
253    method: Method,
254    target: &ApiTargetOptions,
255) -> Result<(), String> {
256    execute_api_request(ApiRequestExecution {
257        path: path.to_string(),
258        method,
259        body: None,
260        body_file: None,
261        target: target.clone(),
262    })
263    .await
264}
265
266async fn execute_with_json_body(
267    path: &str,
268    method: Method,
269    body: Value,
270    target: &ApiTargetOptions,
271) -> Result<(), String> {
272    let body = serde_json::to_string(&body)
273        .map_err(|e| format!("Failed to serialize API request body: {}", e))?;
274    execute_api_request(ApiRequestExecution {
275        path: path.to_string(),
276        method,
277        body: Some(body),
278        body_file: None,
279        target: target.clone(),
280    })
281    .await
282}
283
284fn insert_string(map: &mut Map<String, Value>, key: &str, value: &str) {
285    map.insert(key.to_string(), Value::String(value.to_string()));
286}
287
288fn insert_optional_string(map: &mut Map<String, Value>, key: &str, value: Option<&str>) {
289    if let Some(value) = value {
290        insert_string(map, key, value);
291    }
292}
293
294fn insert_optional_i32(map: &mut Map<String, Value>, key: &str, value: Option<i32>) {
295    if let Some(value) = value {
296        map.insert(key.to_string(), Value::Number(value.into()));
297    }
298}
299
300fn insert_optional_json(
301    map: &mut Map<String, Value>,
302    key: &str,
303    raw: Option<&str>,
304) -> Result<(), String> {
305    if let Some(raw) = raw {
306        let parsed: Value = serde_json::from_str(raw)
307            .map_err(|e| format!("Failed to parse {} as JSON: {}", key, e))?;
308        map.insert(key.to_string(), parsed);
309    }
310    Ok(())
311}
312
313fn parse_weighted_target(input: &str) -> Result<(String, u32), String> {
314    let (url, weight) = input
315        .rsplit_once('=')
316        .ok_or_else(|| format!("Invalid weighted target `{}`. Use url=weight.", input))?;
317    let weight = weight
318        .trim()
319        .parse::<u32>()
320        .map_err(|e| format!("Invalid weighted target weight `{}`: {}", weight.trim(), e))?;
321    let url = url.trim();
322    if url.is_empty() {
323        return Err(format!(
324            "Invalid weighted target `{}`. URL is empty.",
325            input
326        ));
327    }
328    Ok((url.to_string(), weight))
329}
330
331fn with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
332    let rendered: Vec<String> = params
333        .iter()
334        .filter_map(|(key, value)| value.map(|value| format!("{}={}", key, value)))
335        .collect();
336    if rendered.is_empty() {
337        path.to_string()
338    } else {
339        format!("{}?{}", path, rendered.join("&"))
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::{parse_weighted_target, with_query};
346
347    #[test]
348    fn weighted_target_parser_accepts_url_equals_weight() {
349        let (url, weight) =
350            parse_weighted_target("http://127.0.0.1:3000=5").expect("parse weighted target");
351        assert_eq!(url, "http://127.0.0.1:3000");
352        assert_eq!(weight, 5);
353    }
354
355    #[test]
356    fn query_builder_skips_empty_values() {
357        let rendered = with_query(
358            "/deployment-jobs",
359            &[("status", Some("queued")), ("project_id", None)],
360        );
361        assert_eq!(rendered, "/deployment-jobs?status=queued");
362    }
363}