Skip to main content

bestool_canopy/
backup.rs

1//! Wire types for Canopy's backup-credentials endpoints.
2//!
3//! These mirror the canopy public-server contract (`CapabilitiesArgs`,
4//! `CredentialsArgs`, `ReportArgs`, `CredentialProcessOutput`, `BackupTarget`).
5//! The device fetches short-lived S3 creds and the repo target from Canopy on
6//! each run, then serves the creds to kopia's minio-go provider in the
7//! [`ContainerCreds`] shape (note `Token`, not `SessionToken`).
8
9use jiff::Timestamp;
10use serde::{Deserialize, Serialize};
11
12use crate::Redacted;
13
14/// Why a credential was issued / a run executed.
15///
16/// A real capability gate on the issued S3 creds: `backup` grants
17/// write-without-delete, `restore` grants read-only.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum Purpose {
21	#[default]
22	Backup,
23	Restore,
24}
25
26/// Outcome of a reported backup/restore run.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum Outcome {
30	Success,
31	Failure,
32}
33
34/// Body for `POST /backup-capabilities`: the backup types this server can run.
35#[derive(Debug, Clone, Serialize)]
36pub struct CapabilitiesRequest<'a> {
37	pub types: &'a [String],
38}
39
40/// Body for `POST /backup-credentials`.
41#[derive(Debug, Clone, Serialize)]
42pub struct BackupCredentialsRequest<'a> {
43	pub r#type: &'a str,
44	pub purpose: Purpose,
45}
46
47/// `credential_process`-shaped creds returned by `POST /backup-credentials`.
48///
49/// Field names are fixed by the AWS SDK (PascalCase). The driver translates
50/// these into the [`ContainerCreds`] shape kopia's minio-go provider polls for.
51#[derive(Debug, Clone, Deserialize)]
52#[serde(rename_all = "PascalCase")]
53pub struct BackupCredentials {
54	pub version: u32,
55	pub access_key_id: String,
56	pub secret_access_key: Redacted<String>,
57	pub session_token: Redacted<String>,
58	pub expiration: Timestamp,
59}
60
61/// Creds in the ECS container-credentials shape kopia's minio-go provider polls
62/// for: note **`Token`** (not `SessionToken`), and `Expiration` as RFC3339 `Z`.
63#[derive(Debug, Clone, Serialize)]
64#[serde(rename_all = "PascalCase")]
65pub struct ContainerCreds {
66	pub access_key_id: String,
67	pub secret_access_key: Redacted<String>,
68	pub token: Redacted<String>,
69	pub expiration: Timestamp,
70}
71
72impl From<&BackupCredentials> for ContainerCreds {
73	fn from(c: &BackupCredentials) -> Self {
74		Self {
75			access_key_id: c.access_key_id.clone(),
76			secret_access_key: c.secret_access_key.clone(),
77			token: c.session_token.clone(),
78			expiration: c.expiration,
79		}
80	}
81}
82
83/// The S3 repo target returned by `GET /backup-target`.
84#[derive(Debug, Clone, Deserialize)]
85pub struct BackupTarget {
86	/// Always `"s3"`.
87	pub storage: String,
88	pub bucket: String,
89	/// Normally empty (the repo lives at the bucket root).
90	#[serde(default)]
91	pub prefix: String,
92	pub region: String,
93	pub repo_password: Redacted<String>,
94}
95
96/// Result of `GET /backup-target`: a live target, or the benign dormant state
97/// (the device is not yet authorised for backups — `412`/`409`).
98#[derive(Debug, Clone)]
99pub enum TargetOutcome {
100	Ready(BackupTarget),
101	Dormant,
102}
103
104/// Body for `POST /backup-report`.
105#[derive(Debug, Clone, Serialize)]
106pub struct BackupReport<'a> {
107	/// The run-uuid bestool minted at run start (becomes `backup_runs.id`).
108	pub run_id: &'a str,
109	pub r#type: &'a str,
110	pub purpose: Purpose,
111	pub outcome: Outcome,
112	#[serde(skip_serializing_if = "Option::is_none")]
113	pub error: Option<&'a str>,
114	#[serde(skip_serializing_if = "Option::is_none")]
115	pub bytes_uploaded: Option<i64>,
116	#[serde(skip_serializing_if = "Option::is_none")]
117	pub snapshot_id: Option<&'a str>,
118	/// S3 traffic the run accounted for, measured by the re-signing proxy. `*_raw`
119	/// is the full HTTP message (headers + on-the-wire body incl. SigV4 chunk
120	/// framing); `*_payload` is the decoded object data. A rough per-deployment
121	/// network/S3 measure; distinct from `bytes_uploaded` (kopia's own figure).
122	#[serde(skip_serializing_if = "Option::is_none")]
123	pub s3_sent_raw_bytes: Option<i64>,
124	#[serde(skip_serializing_if = "Option::is_none")]
125	pub s3_sent_payload_bytes: Option<i64>,
126	#[serde(skip_serializing_if = "Option::is_none")]
127	pub s3_received_raw_bytes: Option<i64>,
128	#[serde(skip_serializing_if = "Option::is_none")]
129	pub s3_received_payload_bytes: Option<i64>,
130}
131
132#[cfg(test)]
133mod tests {
134	use serde_json::json;
135
136	use super::*;
137
138	#[test]
139	fn purpose_serialises_lowercase() {
140		assert_eq!(
141			serde_json::to_value(Purpose::Backup).unwrap(),
142			json!("backup")
143		);
144		assert_eq!(
145			serde_json::to_value(Purpose::Restore).unwrap(),
146			json!("restore")
147		);
148	}
149
150	#[test]
151	fn purpose_defaults_to_backup() {
152		assert_eq!(Purpose::default(), Purpose::Backup);
153	}
154
155	#[test]
156	fn outcome_serialises_lowercase() {
157		assert_eq!(
158			serde_json::to_value(Outcome::Success).unwrap(),
159			json!("success")
160		);
161		assert_eq!(
162			serde_json::to_value(Outcome::Failure).unwrap(),
163			json!("failure")
164		);
165	}
166
167	#[test]
168	fn credentials_request_carries_type_and_purpose() {
169		let req = BackupCredentialsRequest {
170			r#type: "tamanu-postgres",
171			purpose: Purpose::Backup,
172		};
173		assert_eq!(
174			serde_json::to_value(&req).unwrap(),
175			json!({"type": "tamanu-postgres", "purpose": "backup"})
176		);
177	}
178
179	#[test]
180	fn capabilities_request_lists_types() {
181		let types = vec!["tamanu-postgres".to_owned(), "files".to_owned()];
182		let req = CapabilitiesRequest { types: &types };
183		assert_eq!(
184			serde_json::to_value(&req).unwrap(),
185			json!({"types": ["tamanu-postgres", "files"]})
186		);
187	}
188
189	#[test]
190	fn backup_credentials_deserialise_from_credential_process_shape() {
191		let body = json!({
192			"Version": 1,
193			"AccessKeyId": "AKIA",
194			"SecretAccessKey": "secret",
195			"SessionToken": "token",
196			"Expiration": "2026-05-21T13:00:00Z",
197		});
198		let creds: BackupCredentials = serde_json::from_value(body).unwrap();
199		assert_eq!(creds.version, 1);
200		assert_eq!(creds.access_key_id, "AKIA");
201		assert_eq!(&*creds.secret_access_key, "secret");
202		assert_eq!(&*creds.session_token, "token");
203		assert_eq!(creds.expiration.to_string(), "2026-05-21T13:00:00Z");
204	}
205
206	#[test]
207	fn container_creds_translate_session_token_to_token() {
208		let body = json!({
209			"Version": 1,
210			"AccessKeyId": "AKIA",
211			"SecretAccessKey": "secret",
212			"SessionToken": "session-token",
213			"Expiration": "2026-05-21T13:00:00Z",
214		});
215		let creds: BackupCredentials = serde_json::from_value(body).unwrap();
216		let container = ContainerCreds::from(&creds);
217		let out = serde_json::to_value(&container).unwrap();
218		assert_eq!(
219			out,
220			json!({
221				"AccessKeyId": "AKIA",
222				"SecretAccessKey": "secret",
223				"Token": "session-token",
224				"Expiration": "2026-05-21T13:00:00Z",
225			})
226		);
227		// No SessionToken key leaks through.
228		assert!(out.get("SessionToken").is_none());
229	}
230
231	#[test]
232	fn backup_target_deserialises() {
233		let body = json!({
234			"storage": "s3",
235			"bucket": "my-bucket",
236			"prefix": "",
237			"region": "ap-southeast-2",
238			"repo_password": "hunter2",
239		});
240		let target: BackupTarget = serde_json::from_value(body).unwrap();
241		assert_eq!(target.storage, "s3");
242		assert_eq!(target.bucket, "my-bucket");
243		assert_eq!(target.prefix, "");
244		assert_eq!(target.region, "ap-southeast-2");
245		assert_eq!(&*target.repo_password, "hunter2");
246	}
247
248	#[test]
249	fn backup_report_omits_optional_fields() {
250		let report = BackupReport {
251			run_id: "11111111-1111-1111-1111-111111111111",
252			r#type: "tamanu-postgres",
253			purpose: Purpose::Backup,
254			outcome: Outcome::Success,
255			error: None,
256			bytes_uploaded: None,
257			snapshot_id: None,
258			s3_sent_raw_bytes: None,
259			s3_sent_payload_bytes: None,
260			s3_received_raw_bytes: None,
261			s3_received_payload_bytes: None,
262		};
263		assert_eq!(
264			serde_json::to_value(&report).unwrap(),
265			json!({
266				"run_id": "11111111-1111-1111-1111-111111111111",
267				"type": "tamanu-postgres",
268				"purpose": "backup",
269				"outcome": "success",
270			})
271		);
272	}
273
274	#[test]
275	fn backup_report_includes_failure_fields() {
276		let report = BackupReport {
277			run_id: "run",
278			r#type: "tamanu-postgres",
279			purpose: Purpose::Backup,
280			outcome: Outcome::Failure,
281			error: Some("kopia exploded"),
282			bytes_uploaded: Some(42),
283			snapshot_id: Some("snap"),
284			s3_sent_raw_bytes: Some(2048),
285			s3_sent_payload_bytes: Some(1024),
286			s3_received_raw_bytes: Some(64),
287			s3_received_payload_bytes: Some(0),
288		};
289		assert_eq!(
290			serde_json::to_value(&report).unwrap(),
291			json!({
292				"run_id": "run",
293				"type": "tamanu-postgres",
294				"purpose": "backup",
295				"outcome": "failure",
296				"error": "kopia exploded",
297				"bytes_uploaded": 42,
298				"snapshot_id": "snap",
299				"s3_sent_raw_bytes": 2048,
300				"s3_sent_payload_bytes": 1024,
301				"s3_received_raw_bytes": 64,
302				"s3_received_payload_bytes": 0,
303			})
304		);
305	}
306
307	#[test]
308	fn redacted_debug_does_not_leak() {
309		let creds = ContainerCreds {
310			access_key_id: "AKIA".to_owned(),
311			secret_access_key: Redacted("aws-sk-value-123".to_owned()),
312			token: Redacted("aws-token-value-456".to_owned()),
313			expiration: "2026-05-21T13:00:00Z".parse().unwrap(),
314		};
315		let debug = format!("{creds:?}");
316		assert!(!debug.contains("aws-sk-value-123"));
317		assert!(!debug.contains("aws-token-value-456"));
318		assert!(debug.contains("<redacted>"));
319	}
320}