Skip to main content

enclavia_protocol/
staging.rs

1//! Wire DTOs for the staged-upgrade API surface.
2//!
3//! These types are shared between:
4//! - The backend's `POST /enclaves/{id}/upgrades` response and
5//!   `GET /enclaves/{id}/upgrades/{upgrade_id}` response.
6//! - The CLI's display and `--json` output.
7//! - Future SDK consumers that want to poll upgrade status.
8//!
9//! Deliberately NO `eif_path`: that is a server-internal filesystem path
10//! that must never appear in API responses.
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::chain::PcrsHex;
17
18/// Lifecycle of a staged upgrade. Transitions are monotonic except that
19/// `Staged` can transition to either `Confirmed` (happy path) or `Revoked`
20/// (operator calls revoke before `valid_from`).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum StagedUpgradeStatus {
24    /// The new EIF is being built. `pcrs` and `image_digest` are not yet
25    /// available.
26    Building,
27    /// Build finished; `pcrs` and `image_digest` are recorded. The upgrade
28    /// is awaiting operator confirmation; nothing has been sent to the
29    /// running enclave yet and no `valid_from` is set.
30    Staged,
31    /// The operator has called `POST /enclaves/{id}/upgrades/{uid}/confirm`:
32    /// the backend fixed `valid_from`, signed the upgrade-auth payload, and
33    /// dispatched it to the running enclave as a `PrepareUpgrade` control
34    /// command; the enclave emitted an `Upgrade` chain link and
35    /// acknowledged. The new version may launch once `valid_from` passes.
36    Confirmed,
37    /// The new enclave has started successfully with the new image. The
38    /// upgrade is complete.
39    Promoted,
40    /// The operator revoked a confirmed upgrade before `valid_from`. The
41    /// running enclave has rolled back its LUKS keyslot (if applicable) and
42    /// emitted a `Revocation` chain link.
43    Revoked,
44    /// Build or chain-link submission failed. See `error_message`.
45    Failed,
46    /// Staged but never confirmed within the backend's staleness window;
47    /// garbage-collected.
48    Expired,
49}
50
51/// API-facing representation of a staged upgrade. Returned by the backend on
52/// creation and on every subsequent status poll. Consumed by the CLI for
53/// display and by SDK consumers for programmatic workflows.
54///
55/// ## Field notes
56///
57/// - `image_digest`: not available while `status == Building`; the backend
58///   fills it in once the EIF build completes.
59/// - `pcrs`: not available while `status == Building`.
60/// - `valid_from`: chosen and signed into the upgrade-auth payload at
61///   confirm time; `None` until `status == Confirmed`.
62/// - `upgrade_link_id`: the chain entry UUID of the `Upgrade` link the
63///   running enclave emitted. Set once `status == Confirmed`.
64/// - `revocation_link_id`: set only when `status == Revoked`.
65/// - `error_message`: non-empty only on `status == Failed`.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct StagedUpgradeJson {
68    /// Backend-assigned UUID for this upgrade record.
69    pub id: Uuid,
70    /// UUID of the enclave this upgrade targets.
71    pub enclave_id: Uuid,
72    /// Current lifecycle status.
73    pub status: StagedUpgradeStatus,
74    /// Docker image reference (as supplied at creation, e.g.
75    /// `<registry>/<owner>/<repo>:<tag>`).
76    pub docker_image: String,
77    /// Manifest digest of the built image. `None` while `status ==
78    /// Building`.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub image_digest: Option<String>,
81    /// PCR0/1/2 for the new EIF. `None` while `status == Building`.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub pcrs: Option<PcrsHex>,
84    /// Wall-clock time after which the new enclave version may launch.
85    /// `None` until the operator confirms (it is chosen at confirm time).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub valid_from: Option<DateTime<Utc>>,
88    /// Chain entry UUID of the `Upgrade` link emitted by the running enclave.
89    /// `None` until the enclave acknowledges the `PrepareUpgrade` dispatched
90    /// at confirm time.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub upgrade_link_id: Option<Uuid>,
93    /// Chain entry UUID of the `Revocation` link, set only when `status ==
94    /// Revoked`.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub revocation_link_id: Option<Uuid>,
97    /// Human-readable error details. Non-empty only on `status == Failed`.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub error_message: Option<String>,
100    /// Git rev of the `builder` flake input the backend was running when
101    /// it built this upgrade's EIF. Recorded alongside `pcrs` when the
102    /// build completes, mirroring the genesis `enclaves.builder_rev`
103    /// stamping. Lets `enclavia reproduce --upgrade <id>` pin the local
104    /// rebuild to the exact sources, so a superseded version stays
105    /// deterministically reproducible. `None` on rows staged before this
106    /// field existed, or whose build never completed.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub builder_rev: Option<String>,
109    /// Git rev of the `enclavia` flake input. Same null semantics as
110    /// `builder_rev`.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub crates_rev: Option<String>,
113    /// Synchronizer trust anchors baked into this upgrade's EIF (the
114    /// exact `expected_pcrs` list the backend passed to the builder via
115    /// `--synchronizer-pcrs`). Recorded alongside `pcrs` when the build
116    /// completes so `enclavia reproduce --upgrade <id>` can inject the
117    /// EXACT anchor set the original build used; old images stay
118    /// reproducible after a cluster rotation. `None` on rows staged
119    /// before this field existed or built without the synchronizer
120    /// wiring.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub synchronizer_pcrs: Option<Vec<PcrsHex>>,
123    /// Whether `--synchronizer-enabled` was passed to the builder for
124    /// this EIF (`synchronizer.enabled = true` in the measured config:
125    /// the in-enclave anti-rollback wiring is on). `false` on rows
126    /// staged before this field existed or built without the wiring.
127    #[serde(default)]
128    pub synchronizer_enabled: bool,
129    /// Wall-clock time this upgrade record was created.
130    pub created_at: DateTime<Utc>,
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use chrono::TimeZone;
137
138    fn sample() -> StagedUpgradeJson {
139        StagedUpgradeJson {
140            id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
141            enclave_id: Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(),
142            status: StagedUpgradeStatus::Confirmed,
143            docker_image: "registry.example.com/owner/app:v2".into(),
144            image_digest: Some("sha256:abcdef".into()),
145            pcrs: Some(PcrsHex {
146                pcr0: "aa".repeat(24),
147                pcr1: "bb".repeat(24),
148                pcr2: "cc".repeat(24),
149            }),
150            valid_from: Some(Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()),
151            upgrade_link_id: Some(Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap()),
152            revocation_link_id: None,
153            error_message: None,
154            builder_rev: Some("08cc66bf46b79981253011725b9e792d6353a586".into()),
155            crates_rev: Some("842b3394f1699b1fc7ae376ec7741fa9e4029621".into()),
156            synchronizer_pcrs: Some(vec![PcrsHex {
157                pcr0: "dd".repeat(48),
158                pcr1: "ee".repeat(48),
159                pcr2: "ff".repeat(48),
160            }]),
161            synchronizer_enabled: true,
162            created_at: Utc.with_ymd_and_hms(2024, 12, 31, 0, 0, 0).unwrap(),
163        }
164    }
165
166    #[test]
167    fn staged_upgrade_json_round_trip() {
168        let orig = sample();
169        let json = serde_json::to_string(&orig).unwrap();
170        let back: StagedUpgradeJson = serde_json::from_str(&json).unwrap();
171        assert_eq!(back.id, orig.id);
172        assert_eq!(back.enclave_id, orig.enclave_id);
173        assert_eq!(back.status, orig.status);
174        assert_eq!(back.docker_image, orig.docker_image);
175        assert_eq!(back.image_digest, orig.image_digest);
176        assert_eq!(back.upgrade_link_id, orig.upgrade_link_id);
177        assert!(back.revocation_link_id.is_none());
178        assert!(back.error_message.is_none());
179    }
180
181    /// Lock the exact JSON field names. Changing these is a wire break.
182    #[test]
183    fn staged_upgrade_json_field_names() {
184        let v = serde_json::to_value(sample()).unwrap();
185        // Required fields always present.
186        assert!(v.get("id").is_some(), "missing id");
187        assert!(v.get("enclave_id").is_some(), "missing enclave_id");
188        assert!(v.get("status").is_some(), "missing status");
189        assert!(v.get("docker_image").is_some(), "missing docker_image");
190        assert!(v.get("created_at").is_some(), "missing created_at");
191        // Optional fields present when Some.
192        assert!(v.get("image_digest").is_some(), "missing image_digest");
193        assert!(v.get("pcrs").is_some(), "missing pcrs");
194        assert!(v.get("valid_from").is_some(), "missing valid_from");
195        assert!(
196            v.get("upgrade_link_id").is_some(),
197            "missing upgrade_link_id"
198        );
199        // Absent when None (skip_serializing_if).
200        assert!(
201            v.get("revocation_link_id").is_none(),
202            "revocation_link_id should be absent"
203        );
204        assert!(
205            v.get("error_message").is_none(),
206            "error_message should be absent"
207        );
208    }
209
210    #[test]
211    fn staged_upgrade_status_serde_lowercase() {
212        let cases = [
213            (StagedUpgradeStatus::Building, "building"),
214            (StagedUpgradeStatus::Staged, "staged"),
215            (StagedUpgradeStatus::Confirmed, "confirmed"),
216            (StagedUpgradeStatus::Promoted, "promoted"),
217            (StagedUpgradeStatus::Revoked, "revoked"),
218            (StagedUpgradeStatus::Failed, "failed"),
219            (StagedUpgradeStatus::Expired, "expired"),
220        ];
221        for (status, expected_json) in &cases {
222            let got = serde_json::to_string(status).unwrap();
223            assert_eq!(got, format!("\"{expected_json}\""), "status {status:?}");
224            let back: StagedUpgradeStatus = serde_json::from_str(&got).unwrap();
225            assert_eq!(back, *status);
226        }
227    }
228
229    #[test]
230    fn building_status_has_no_optional_fields() {
231        let building = StagedUpgradeJson {
232            id: Uuid::parse_str("00000000-0000-0000-0000-000000000010").unwrap(),
233            enclave_id: Uuid::parse_str("00000000-0000-0000-0000-000000000011").unwrap(),
234            status: StagedUpgradeStatus::Building,
235            docker_image: "registry.example.com/owner/app:v3".into(),
236            image_digest: None,
237            pcrs: None,
238            valid_from: None,
239            upgrade_link_id: None,
240            revocation_link_id: None,
241            error_message: None,
242            builder_rev: None,
243            crates_rev: None,
244            synchronizer_pcrs: None,
245            synchronizer_enabled: false,
246            created_at: Utc.with_ymd_and_hms(2024, 12, 31, 0, 0, 0).unwrap(),
247        };
248        let v = serde_json::to_value(&building).unwrap();
249        assert!(v.get("image_digest").is_none());
250        assert!(v.get("pcrs").is_none());
251        assert!(v.get("valid_from").is_none());
252        assert!(v.get("upgrade_link_id").is_none());
253        // Revs are skipped when None too.
254        assert!(v.get("builder_rev").is_none());
255        assert!(v.get("crates_rev").is_none());
256        // Synchronizer anchors are skipped when None; the enabled flag is
257        // always present (it is a plain bool).
258        assert!(v.get("synchronizer_pcrs").is_none());
259        assert_eq!(
260            v.get("synchronizer_enabled"),
261            Some(&serde_json::json!(false))
262        );
263    }
264
265    /// The revs serialise when present and round-trip back.
266    #[test]
267    fn revs_round_trip_when_present() {
268        let v = serde_json::to_value(sample()).unwrap();
269        assert_eq!(
270            v.get("builder_rev").and_then(|x| x.as_str()),
271            Some("08cc66bf46b79981253011725b9e792d6353a586")
272        );
273        assert_eq!(
274            v.get("crates_rev").and_then(|x| x.as_str()),
275            Some("842b3394f1699b1fc7ae376ec7741fa9e4029621")
276        );
277        let back: StagedUpgradeJson = serde_json::from_value(v).unwrap();
278        assert_eq!(
279            back.builder_rev.as_deref(),
280            Some("08cc66bf46b79981253011725b9e792d6353a586")
281        );
282        assert_eq!(
283            back.crates_rev.as_deref(),
284            Some("842b3394f1699b1fc7ae376ec7741fa9e4029621")
285        );
286    }
287
288    /// Old backend JSON (no `builder_rev` / `crates_rev` keys at all) still
289    /// deserialises: the serde defaults fill in `None`. This is the
290    /// backwards-compatibility guarantee for rows staged before the
291    /// per-version provenance work landed.
292    #[test]
293    fn deserialises_payload_without_revs() {
294        let json = r#"{
295            "id": "00000000-0000-0000-0000-000000000001",
296            "enclave_id": "00000000-0000-0000-0000-000000000002",
297            "status": "staged",
298            "docker_image": "registry.example.com/owner/app:v2",
299            "image_digest": "sha256:abcdef",
300            "created_at": "2024-12-31T00:00:00Z"
301        }"#;
302        let back: StagedUpgradeJson = serde_json::from_str(json).unwrap();
303        assert!(back.builder_rev.is_none());
304        assert!(back.crates_rev.is_none());
305        assert_eq!(back.image_digest.as_deref(), Some("sha256:abcdef"));
306        // Synchronizer fields default too (rows from a pre-anti-rollback
307        // backend carry neither key).
308        assert!(back.synchronizer_pcrs.is_none());
309        assert!(!back.synchronizer_enabled);
310    }
311
312    /// Synchronizer provenance round-trips when present: the anchors list
313    /// keeps the PCR0/PCR1/PCR2 key shape and the enabled flag survives.
314    #[test]
315    fn synchronizer_provenance_round_trips_when_present() {
316        let v = serde_json::to_value(sample()).unwrap();
317        let anchors = v
318            .get("synchronizer_pcrs")
319            .and_then(|x| x.as_array())
320            .expect("synchronizer_pcrs present as a list");
321        assert_eq!(anchors.len(), 1);
322        assert_eq!(
323            anchors[0].get("PCR0").and_then(|x| x.as_str()),
324            Some("dd".repeat(48).as_str())
325        );
326        assert_eq!(
327            v.get("synchronizer_enabled"),
328            Some(&serde_json::json!(true))
329        );
330
331        let back: StagedUpgradeJson = serde_json::from_value(v).unwrap();
332        let pcrs = back.synchronizer_pcrs.expect("anchors survive round-trip");
333        assert_eq!(pcrs[0].pcr1, "ee".repeat(48));
334        assert!(back.synchronizer_enabled);
335    }
336}