Skip to main content

sail/
app.rs

1//! Typed app discovery over the central API.
2//!
3//! `find` hits the central API host (`/v1/apps/find`); `list` rolls up the
4//! sailbox overview on the sailbox-API host. Both map non-2xx responses onto
5//! the canonical [`SailError`] taxonomy.
6
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9
10use crate::error::SailError;
11use crate::http::{HttpCore, IdempotencyKey, Method, RequestSpec};
12use crate::retry::DEFAULT_RETRY_POLICY;
13
14/// A Sail application.
15#[derive(Debug, Clone, Serialize)]
16pub struct App {
17    /// Stable server-assigned app identifier.
18    pub id: String,
19    /// Human-readable app name unique within the owning org.
20    pub name: String,
21    /// App creation time as a Unix timestamp in seconds.
22    pub created_at: i64,
23}
24
25/// Per-app rollup of lifecycle counts and resource usage.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct AppOverview {
28    /// Identifier of the app this rollup describes.
29    #[serde(default)]
30    pub app_id: String,
31    /// Name of the app this rollup describes.
32    #[serde(default)]
33    pub app_name: String,
34    /// Total number of sailboxes owned by the app across all states.
35    #[serde(default)]
36    pub sailbox_count: i64,
37    /// Number of sailboxes currently running.
38    #[serde(default)]
39    pub running_count: i64,
40    /// Number of sailboxes currently paused.
41    #[serde(default)]
42    pub paused_count: i64,
43    /// Number of sailboxes currently sleeping.
44    #[serde(default)]
45    pub sleeping_count: i64,
46    /// Number of sailboxes currently in a failed state.
47    #[serde(default)]
48    pub failed_count: i64,
49    /// Sum of configured CPU across the app's sailboxes, in vCPUs.
50    #[serde(default)]
51    pub cpu_requested_vcpu: i64,
52    /// Sum of observed CPU usage across the app's sailboxes, in vCPUs.
53    #[serde(default)]
54    pub cpu_used_vcpu: f64,
55    /// Sum of configured memory across the app's sailboxes, in bytes.
56    #[serde(default)]
57    pub memory_requested_bytes: i64,
58    /// Sum of observed memory usage across the app's sailboxes, in bytes.
59    #[serde(default)]
60    pub memory_used_bytes: i64,
61    /// Sum of configured state-disk capacity across the app's sailboxes, in bytes.
62    #[serde(default)]
63    pub disk_requested_bytes: i64,
64    /// Sum of observed state-disk usage across the app's sailboxes, in bytes.
65    #[serde(default)]
66    pub disk_used_bytes: i64,
67}
68
69/// Find an app by name on the central API, optionally minting it.
70pub async fn find_app(
71    api_http: &HttpCore,
72    name: &str,
73    mint_if_missing: bool,
74) -> Result<App, SailError> {
75    let body = json!({ "name": name, "mint_if_missing": mint_if_missing });
76    let bytes = serde_json::to_vec(&body).map_err(|e| SailError::Internal {
77        message: format!("failed to serialize request body: {e}"),
78    })?;
79    let (status, data) = api_http
80        .request(&RequestSpec {
81            method: Method::Post,
82            path: "/v1/apps/find".to_string(),
83            query: Vec::new(),
84            body: Some(bytes),
85            extra_headers: Vec::new(),
86            timeout: None,
87            policy: DEFAULT_RETRY_POLICY,
88            idempotency_key: IdempotencyKey::Auto,
89        })
90        .await?;
91    // find returns 404 for any miss (not type-gated, unlike sailbox lookups).
92    if status == 404 {
93        return Err(SailError::NotFound {
94            message: format!("App {name:?} not found"),
95        });
96    }
97    raise_app_error(status, &data, "Failed to find app")?;
98    Ok(App {
99        id: data
100            .get("id")
101            .and_then(Value::as_str)
102            .unwrap_or("")
103            .to_string(),
104        name: data
105            .get("name")
106            .and_then(Value::as_str)
107            .unwrap_or(name)
108            .to_string(),
109        created_at: data.get("created_at").and_then(Value::as_i64).unwrap_or(0),
110    })
111}
112
113/// One rollup row per app the org owns (via the sailbox overview).
114pub async fn list_apps(sailbox_http: &HttpCore) -> Result<Vec<AppOverview>, SailError> {
115    let (status, data) = sailbox_http
116        .request(&RequestSpec {
117            method: Method::Get,
118            path: "/v1/sailboxes/overview".to_string(),
119            query: Vec::new(),
120            body: None,
121            extra_headers: Vec::new(),
122            timeout: None,
123            policy: DEFAULT_RETRY_POLICY,
124            idempotency_key: IdempotencyKey::Auto,
125        })
126        .await?;
127    raise_app_error(status, &data, "Failed to list apps")?;
128    let Some(rows) = data.get("data").and_then(Value::as_array) else {
129        return Ok(Vec::new());
130    };
131    rows.iter()
132        .map(|row| {
133            serde_json::from_value::<AppOverview>(row.clone()).map_err(|e| SailError::Internal {
134                message: format!("failed to parse app overview: {e}"),
135            })
136        })
137        .collect()
138}
139
140/// App ladder: 401/403 → PermissionDenied, any other non-2xx → Api
141/// (transient/unexpected). 404 is handled by the caller where it is meaningful.
142fn raise_app_error(status: u16, data: &Value, context: &str) -> Result<(), SailError> {
143    if status < 300 {
144        return Ok(());
145    }
146    let detail = data
147        .get("error")
148        .and_then(|e| e.get("message"))
149        .and_then(Value::as_str)
150        .filter(|s| !s.is_empty())
151        .unwrap_or("unknown error");
152    let message = format!("{context}: {detail}");
153    if status == 401 || status == 403 {
154        return Err(SailError::PermissionDenied { message });
155    }
156    Err(SailError::Api {
157        status,
158        message,
159        body: data.clone(),
160    })
161}