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}
119
120#[cfg(test)]
121mod tests {
122	use serde_json::json;
123
124	use super::*;
125
126	#[test]
127	fn purpose_serialises_lowercase() {
128		assert_eq!(
129			serde_json::to_value(Purpose::Backup).unwrap(),
130			json!("backup")
131		);
132		assert_eq!(
133			serde_json::to_value(Purpose::Restore).unwrap(),
134			json!("restore")
135		);
136	}
137
138	#[test]
139	fn purpose_defaults_to_backup() {
140		assert_eq!(Purpose::default(), Purpose::Backup);
141	}
142
143	#[test]
144	fn outcome_serialises_lowercase() {
145		assert_eq!(
146			serde_json::to_value(Outcome::Success).unwrap(),
147			json!("success")
148		);
149		assert_eq!(
150			serde_json::to_value(Outcome::Failure).unwrap(),
151			json!("failure")
152		);
153	}
154
155	#[test]
156	fn credentials_request_carries_type_and_purpose() {
157		let req = BackupCredentialsRequest {
158			r#type: "tamanu-postgres",
159			purpose: Purpose::Backup,
160		};
161		assert_eq!(
162			serde_json::to_value(&req).unwrap(),
163			json!({"type": "tamanu-postgres", "purpose": "backup"})
164		);
165	}
166
167	#[test]
168	fn capabilities_request_lists_types() {
169		let types = vec!["tamanu-postgres".to_owned(), "files".to_owned()];
170		let req = CapabilitiesRequest { types: &types };
171		assert_eq!(
172			serde_json::to_value(&req).unwrap(),
173			json!({"types": ["tamanu-postgres", "files"]})
174		);
175	}
176
177	#[test]
178	fn backup_credentials_deserialise_from_credential_process_shape() {
179		let body = json!({
180			"Version": 1,
181			"AccessKeyId": "AKIA",
182			"SecretAccessKey": "secret",
183			"SessionToken": "token",
184			"Expiration": "2026-05-21T13:00:00Z",
185		});
186		let creds: BackupCredentials = serde_json::from_value(body).unwrap();
187		assert_eq!(creds.version, 1);
188		assert_eq!(creds.access_key_id, "AKIA");
189		assert_eq!(&*creds.secret_access_key, "secret");
190		assert_eq!(&*creds.session_token, "token");
191		assert_eq!(creds.expiration.to_string(), "2026-05-21T13:00:00Z");
192	}
193
194	#[test]
195	fn container_creds_translate_session_token_to_token() {
196		let body = json!({
197			"Version": 1,
198			"AccessKeyId": "AKIA",
199			"SecretAccessKey": "secret",
200			"SessionToken": "session-token",
201			"Expiration": "2026-05-21T13:00:00Z",
202		});
203		let creds: BackupCredentials = serde_json::from_value(body).unwrap();
204		let container = ContainerCreds::from(&creds);
205		let out = serde_json::to_value(&container).unwrap();
206		assert_eq!(
207			out,
208			json!({
209				"AccessKeyId": "AKIA",
210				"SecretAccessKey": "secret",
211				"Token": "session-token",
212				"Expiration": "2026-05-21T13:00:00Z",
213			})
214		);
215		// No SessionToken key leaks through.
216		assert!(out.get("SessionToken").is_none());
217	}
218
219	#[test]
220	fn backup_target_deserialises() {
221		let body = json!({
222			"storage": "s3",
223			"bucket": "my-bucket",
224			"prefix": "",
225			"region": "ap-southeast-2",
226			"repo_password": "hunter2",
227		});
228		let target: BackupTarget = serde_json::from_value(body).unwrap();
229		assert_eq!(target.storage, "s3");
230		assert_eq!(target.bucket, "my-bucket");
231		assert_eq!(target.prefix, "");
232		assert_eq!(target.region, "ap-southeast-2");
233		assert_eq!(&*target.repo_password, "hunter2");
234	}
235
236	#[test]
237	fn backup_report_omits_optional_fields() {
238		let report = BackupReport {
239			run_id: "11111111-1111-1111-1111-111111111111",
240			r#type: "tamanu-postgres",
241			purpose: Purpose::Backup,
242			outcome: Outcome::Success,
243			error: None,
244			bytes_uploaded: None,
245			snapshot_id: None,
246		};
247		assert_eq!(
248			serde_json::to_value(&report).unwrap(),
249			json!({
250				"run_id": "11111111-1111-1111-1111-111111111111",
251				"type": "tamanu-postgres",
252				"purpose": "backup",
253				"outcome": "success",
254			})
255		);
256	}
257
258	#[test]
259	fn backup_report_includes_failure_fields() {
260		let report = BackupReport {
261			run_id: "run",
262			r#type: "tamanu-postgres",
263			purpose: Purpose::Backup,
264			outcome: Outcome::Failure,
265			error: Some("kopia exploded"),
266			bytes_uploaded: Some(42),
267			snapshot_id: Some("snap"),
268		};
269		assert_eq!(
270			serde_json::to_value(&report).unwrap(),
271			json!({
272				"run_id": "run",
273				"type": "tamanu-postgres",
274				"purpose": "backup",
275				"outcome": "failure",
276				"error": "kopia exploded",
277				"bytes_uploaded": 42,
278				"snapshot_id": "snap",
279			})
280		);
281	}
282
283	#[test]
284	fn redacted_debug_does_not_leak() {
285		let creds = ContainerCreds {
286			access_key_id: "AKIA".to_owned(),
287			secret_access_key: Redacted("aws-sk-value-123".to_owned()),
288			token: Redacted("aws-token-value-456".to_owned()),
289			expiration: "2026-05-21T13:00:00Z".parse().unwrap(),
290		};
291		let debug = format!("{creds:?}");
292		assert!(!debug.contains("aws-sk-value-123"));
293		assert!(!debug.contains("aws-token-value-456"));
294		assert!(debug.contains("<redacted>"));
295	}
296}