Skip to main content

feltdb_client/
control.rs

1//! The control plane: server health, tenants, applications, the draft → revision → promotion
2//! lifecycle of an application's contract, and online backup.
3
4use feltdb::application::{
5    ApplicationDraft, ApplicationManifest, ApplicationRevision, ValidationReport,
6};
7use serde::Deserialize;
8use serde_json::{json, Value};
9
10use crate::{encode, Client, Method, Result};
11
12/// The server's own health report (`GET /health`): liveness, build identity and subsystem status.
13#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
14pub struct Health {
15    /// `healthy`, or the reason it is not.
16    pub status: String,
17    /// The server's package version.
18    pub version: String,
19    #[serde(default)]
20    pub git_commit: Option<String>,
21    #[serde(default)]
22    pub release_id: Option<String>,
23    #[serde(default)]
24    pub public_contract: Option<String>,
25    #[serde(default)]
26    pub capability_fingerprint: Option<String>,
27    #[serde(default)]
28    pub storage: Option<String>,
29    #[serde(default)]
30    pub membership: Option<String>,
31}
32
33/// A tenant on the server.
34#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
35pub struct Tenant {
36    pub id: String,
37    pub name: String,
38    #[serde(default)]
39    pub slug: Option<String>,
40}
41
42/// An application as the control plane records it. Which revision an environment runs is
43/// [`crate::Application::discover`]'s to answer.
44#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
45pub struct ApplicationRecord {
46    pub id: String,
47    pub tenant_id: String,
48    pub name: String,
49    #[serde(default)]
50    pub slug: Option<String>,
51    #[serde(default)]
52    pub status: Option<String>,
53}
54
55/// Moves an environment to a revision: [`Control::promote`].
56#[derive(Debug, Clone)]
57pub struct Promotion {
58    environment: String,
59    reason: String,
60    expected_current_revision: Option<String>,
61    approve_destructive_changes: bool,
62}
63
64impl Promotion {
65    pub fn new(environment: impl Into<String>) -> Self {
66        Self {
67            environment: environment.into(),
68            reason: String::new(),
69            expected_current_revision: None,
70            approve_destructive_changes: false,
71        }
72    }
73
74    /// Recorded in the environment's history.
75    pub fn reason(mut self, reason: impl Into<String>) -> Self {
76        self.reason = reason.into();
77        self
78    }
79
80    /// Promote only if the environment still runs `revision_id` (`None`: still runs nothing), so
81    /// two promotions racing from the same base cannot both win.
82    pub fn expect_current(mut self, revision_id: Option<String>) -> Self {
83        self.expected_current_revision = revision_id;
84        self
85    }
86
87    /// Allow a promotion whose schema change would drop data.
88    pub fn approve_destructive_changes(mut self) -> Self {
89        self.approve_destructive_changes = true;
90        self
91    }
92}
93
94/// The control-plane routes, from [`Client::control`]. Cheap to clone.
95#[derive(Debug, Clone)]
96pub struct Control {
97    client: Client,
98}
99
100impl Control {
101    fn application_path(application_id: &str, rest: &str) -> String {
102        format!("/api/applications/{}{rest}", encode(application_id))
103    }
104
105    fn draft_path(application_id: &str, draft_id: &str, rest: &str) -> String {
106        Self::application_path(
107            application_id,
108            &format!("/drafts/{}{rest}", encode(draft_id)),
109        )
110    }
111
112    /// The tenants this key can see.
113    pub async fn tenants(&self) -> Result<Vec<Tenant>> {
114        self.client
115            .send::<_, ()>(Method::GET, "/api/tenants", None)
116            .await
117    }
118
119    pub async fn create_tenant(&self, name: &str) -> Result<Tenant> {
120        self.client
121            .send(Method::POST, "/api/tenants", Some(&json!({ "name": name })))
122            .await
123    }
124
125    /// A tenant's applications that this key can see.
126    pub async fn applications(&self, tenant_id: &str) -> Result<Vec<ApplicationRecord>> {
127        let path = format!("/api/tenants/{}/applications", encode(tenant_id));
128        self.client.send::<_, ()>(Method::GET, &path, None).await
129    }
130
131    pub async fn create_application(
132        &self,
133        tenant_id: &str,
134        name: &str,
135    ) -> Result<ApplicationRecord> {
136        let path = format!("/api/tenants/{}/applications", encode(tenant_id));
137        self.client
138            .send(Method::POST, &path, Some(&json!({ "name": name })))
139            .await
140    }
141
142    pub async fn application(&self, application_id: &str) -> Result<ApplicationRecord> {
143        self.client
144            .send::<_, ()>(
145                Method::GET,
146                &Self::application_path(application_id, ""),
147                None,
148            )
149            .await
150    }
151
152    /// Start a draft of the application's contract from `base_revision_id`, or from an empty
153    /// manifest.
154    pub async fn create_draft(
155        &self,
156        application_id: &str,
157        base_revision_id: Option<&str>,
158    ) -> Result<ApplicationDraft> {
159        self.client
160            .send(
161                Method::POST,
162                &Self::application_path(application_id, "/drafts"),
163                Some(&json!({ "base_revision_id": base_revision_id })),
164            )
165            .await
166    }
167
168    pub async fn draft(&self, application_id: &str, draft_id: &str) -> Result<ApplicationDraft> {
169        self.client
170            .send::<_, ()>(
171                Method::GET,
172                &Self::draft_path(application_id, draft_id, ""),
173                None,
174            )
175            .await
176    }
177
178    /// Replace the draft's manifest, only if the draft is still at `version` (the
179    /// [`ApplicationDraft::version`] last read); a concurrent edit is refused, not overwritten.
180    pub async fn update_draft(
181        &self,
182        application_id: &str,
183        draft_id: &str,
184        version: u64,
185        manifest: &ApplicationManifest,
186    ) -> Result<ApplicationDraft> {
187        self.client
188            .send_with_headers(
189                Method::PUT,
190                &Self::draft_path(application_id, draft_id, ""),
191                &[("if-version", &version.to_string())],
192                Some(&json!({ "manifest": manifest })),
193            )
194            .await
195    }
196
197    /// Validate the draft without committing it. An invalid manifest is a report with
198    /// `valid: false` and its issues, not an error.
199    pub async fn validate_draft(
200        &self,
201        application_id: &str,
202        draft_id: &str,
203    ) -> Result<ValidationReport> {
204        self.client
205            .send(
206                Method::POST,
207                &Self::draft_path(application_id, draft_id, "/validate"),
208                Some(&json!({})),
209            )
210            .await
211    }
212
213    /// Commit the draft as an immutable revision. No environment runs it until it is promoted.
214    pub async fn commit_draft(
215        &self,
216        application_id: &str,
217        draft_id: &str,
218    ) -> Result<ApplicationRevision> {
219        self.client
220            .send(
221                Method::POST,
222                &Self::draft_path(application_id, draft_id, "/commit"),
223                Some(&json!({})),
224            )
225            .await
226    }
227
228    pub async fn revision(
229        &self,
230        application_id: &str,
231        revision_id: &str,
232    ) -> Result<ApplicationRevision> {
233        let path = Self::application_path(
234            application_id,
235            &format!("/revisions/{}", encode(revision_id)),
236        );
237        self.client.send::<_, ()>(Method::GET, &path, None).await
238    }
239
240    /// Make `revision_id` the revision an environment runs. Returns the server's promotion
241    /// record and readiness.
242    pub async fn promote(
243        &self,
244        application_id: &str,
245        revision_id: &str,
246        promotion: &Promotion,
247    ) -> Result<Value> {
248        let path = Self::application_path(
249            application_id,
250            &format!("/revisions/{}/promote", encode(revision_id)),
251        );
252        self.client
253            .send(
254                Method::POST,
255                &path,
256                Some(&json!({
257                    "environment": promotion.environment,
258                    "reason": promotion.reason,
259                    "expected_current_revision": promotion.expected_current_revision,
260                    "approve_destructive_changes": promotion.approve_destructive_changes,
261                })),
262            )
263            .await
264    }
265
266    /// An online backup: a consistent snapshot of the whole database written to `output`, a path
267    /// on the server's host. Returns the bundle's manifest. Requires `cluster:write`; verify the
268    /// bundle with `feltdb-server backup verify`.
269    pub async fn backup(&self, output: &str) -> Result<Value> {
270        self.client
271            .send(
272                Method::POST,
273                "/admin/backups",
274                Some(&json!({ "output": output })),
275            )
276            .await
277    }
278}
279
280impl Client {
281    /// The server's health report. Unauthenticated on the server.
282    pub async fn health(&self) -> Result<Health> {
283        self.send::<_, ()>(Method::GET, "/health", None).await
284    }
285
286    /// The control plane: tenants, applications, contract drafts, revisions, promotion, backup.
287    pub fn control(&self) -> Control {
288        Control {
289            client: self.clone(),
290        }
291    }
292}