Skip to main content

boatramp_types/
deploy.rs

1//! Deployment **wire structs**: the serde models the server writes, the CLI
2//! reads, and the web console renders — provenance metadata ([`DeployMeta`]),
3//! activation history ([`HistoryEntry`]/[`DeploymentList`]), and the
4//! garbage-collection / integrity-scrub reports ([`GcReport`]/[`ScrubReport`]).
5//!
6//! These are pure serde (no IO/KV/Storage), so they live in `boatramp-types`
7//! (one canonical definition, wasm-clean) and `boatramp-core::deploy`
8//! re-exports them. The `DeployStore` plumbing that produces them stays in core.
9
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12
13/// Metadata about a deployment, captured at publish time and kept alongside the
14/// (content-addressed, immutable) manifest. It is stored separately from the
15/// manifest precisely *because* it is mutable provenance — putting it inside the
16/// manifest would change the content id.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct DeployMeta {
19    /// Schema version, pinned at [`crate::SCHEMA_VERSION`].
20    #[serde(default = "crate::schema_version")]
21    pub version: u32,
22    /// Unix timestamp (seconds) when this manifest was *first* stored. The GC
23    /// grace window keys off this, so it is never overwritten on re-deploy.
24    pub created_at: u64,
25    /// Number of files in the deployment.
26    pub file_count: u64,
27    /// Total bytes across all files.
28    pub total_size: u64,
29    /// Source revision (e.g. a git commit SHA), if the client supplied one.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub source: Option<String>,
32    /// Source branch, if known.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub branch: Option<String>,
35    /// Deploy author, if known.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub author: Option<String>,
38    /// Free-form deploy message, if any.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub message: Option<String>,
41    /// Release tag the deploy was cut from (e.g. `git describe --tags`), if
42    /// known — the human-readable "which version" a bare `source` SHA lacks.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub tag: Option<String>,
45    /// Arbitrary operator-supplied key-value tags (e.g. `env=prod`,
46    /// `ticket=ABC-123`) for identifying and filtering deploys from the CLI and
47    /// console. Ordered (`BTreeMap`) so the serialized form is stable.
48    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
49    pub tags: BTreeMap<String, String>,
50}
51
52/// Client-supplied provenance for a deployment (the mutable subset of
53/// [`DeployMeta`]; sizes and `created_at` are filled in server-side).
54#[derive(Debug, Clone, Default)]
55pub struct DeployMetaInput {
56    /// Source revision (git SHA).
57    pub source: Option<String>,
58    /// Source branch.
59    pub branch: Option<String>,
60    /// Deploy author.
61    pub author: Option<String>,
62    /// Deploy message.
63    pub message: Option<String>,
64    /// Release tag (e.g. `git describe --tags`).
65    pub tag: Option<String>,
66    /// Arbitrary key-value tags.
67    pub tags: BTreeMap<String, String>,
68}
69
70/// An entry in a site's activation history.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct HistoryEntry {
73    /// Deployment id that was activated.
74    pub id: String,
75    /// Unix timestamp (seconds) of the activation.
76    pub at: u64,
77    /// Provenance for this deployment, joined in when the list is read (never
78    /// persisted in the history record itself — see `DeployStore::deployments`).
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub meta: Option<DeployMeta>,
81}
82
83/// A site's current deployment plus its activation history.
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
85pub struct DeploymentList {
86    /// The currently-active deployment id, if any.
87    pub current: Option<String>,
88    /// Activation history, most recent first.
89    pub deployments: Vec<HistoryEntry>,
90}
91
92/// What a garbage-collection pass found (or removed).
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94pub struct GcReport {
95    /// Total deploy manifests present.
96    pub manifests_total: usize,
97    /// Orphan manifests removed (or removable, in a dry run).
98    pub manifests_removed: usize,
99    /// Total blobs present.
100    pub blobs_total: usize,
101    /// Unreferenced blobs removed (or removable, in a dry run).
102    pub blobs_removed: usize,
103    /// Bytes reclaimed (or reclaimable) from removed blobs.
104    pub bytes_reclaimed: u64,
105}
106
107/// One blob whose content no longer hashes to its key (a scrub finding).
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct BlobMismatch {
110    /// The storage key (`blobs/<shard>/<hash>`).
111    pub key: String,
112    /// The hash the key claims the bytes have.
113    pub expected: String,
114    /// The hash actually computed from the stored bytes.
115    pub actual: String,
116}
117
118/// One blob that could not be read during a scrub.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct BlobReadError {
121    /// The storage key.
122    pub key: String,
123    /// Why the read failed.
124    pub error: String,
125}
126
127/// What a blob integrity scrub found. Read-only — a scrub never
128/// deletes; it reports so an operator can re-deploy or restore the affected
129/// content.
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ScrubReport {
132    /// Blobs checked.
133    pub checked: usize,
134    /// Blobs whose content no longer matches their key (corruption/tampering).
135    pub mismatched: Vec<BlobMismatch>,
136    /// Blobs that could not be read.
137    pub errors: Vec<BlobReadError>,
138}
139
140impl ScrubReport {
141    /// Whether the scrub found no corruption and no read errors.
142    pub fn is_clean(&self) -> bool {
143        self.mismatched.is_empty() && self.errors.is_empty()
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn deploy_meta_tag_and_tags_round_trip() {
153        let meta = DeployMeta {
154            version: 1,
155            created_at: 42,
156            file_count: 3,
157            total_size: 99,
158            source: Some("abc123".into()),
159            branch: Some("main".into()),
160            author: None,
161            message: Some("ship it".into()),
162            tag: Some("v1.2.3".into()),
163            tags: BTreeMap::from([
164                ("env".to_string(), "prod".to_string()),
165                ("ticket".to_string(), "ABC-123".to_string()),
166            ]),
167        };
168        let json = serde_json::to_string(&meta).unwrap();
169        assert_eq!(serde_json::from_str::<DeployMeta>(&json).unwrap(), meta);
170    }
171
172    #[test]
173    fn empty_tag_and_tags_are_omitted() {
174        let meta = DeployMeta {
175            version: 1,
176            created_at: 1,
177            file_count: 0,
178            total_size: 0,
179            source: None,
180            branch: None,
181            author: None,
182            message: None,
183            tag: None,
184            tags: BTreeMap::new(),
185        };
186        let json = serde_json::to_string(&meta).unwrap();
187        assert!(
188            !json.contains("\"tag\""),
189            "empty tag/tags must not serialize: {json}"
190        );
191        assert!(
192            !json.contains("\"tags\""),
193            "empty tags must not serialize: {json}"
194        );
195    }
196
197    #[test]
198    fn old_records_without_tag_fields_still_deserialize() {
199        // A pre-feature record (no tag/tags keys) reads back with the defaults.
200        let json = r#"{"version":1,"created_at":7,"file_count":1,"total_size":5}"#;
201        let meta: DeployMeta = serde_json::from_str(json).unwrap();
202        assert_eq!(meta.tag, None);
203        assert!(meta.tags.is_empty());
204    }
205}