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}