Skip to main content

bestool_canopy/
restore.rs

1//! Wire types for Canopy's managed-restore endpoints (the PGRO restore consumer).
2//!
3//! Canopy drives the consumer via a worklist. The consumer registers the restore
4//! intents it supports, fetches the worklist of concrete replicas to restore,
5//! pulls per-group read-only credentials and the repo password, then reports each
6//! restore's health back. These types mirror the frozen canopy public-server
7//! contract; field renaming via serde matches the JSON.
8
9use jiff::Timestamp;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use crate::{BackupCredentials, Outcome, Redacted};
14
15/// Body for `POST /restore-capabilities`: the restore intents this consumer supports.
16///
17/// Replaces the registered intent set wholesale; canopy dispatches only matching
18/// worklist entries.
19#[derive(Debug, Clone, Serialize)]
20pub struct RestoreCapabilitiesRequest<'a> {
21	pub intents: &'a [&'a str],
22}
23
24/// One entry of `GET /restore-worklist`: a concrete replica to restore.
25///
26/// Declarations are expanded per live server, capability-filtered, and
27/// server-specific-over-group-wide deduped, so there is one entry per replica.
28/// `intent` is an open set passed through to the consumer.
29#[derive(Debug, Clone, Deserialize)]
30pub struct WorklistEntry {
31	pub replica_id: Uuid,
32	pub group_id: Uuid,
33	pub server_id: Uuid,
34	pub r#type: String,
35	pub intent: String,
36	/// Operator label.
37	pub name: String,
38	/// Maximum acceptable age of the restored snapshot; `None` means always latest.
39	pub freshness_seconds: Option<i64>,
40	/// Kopia snapshot id to restore; `None` when the group has no successful backup yet.
41	pub snapshot_id: Option<String>,
42	/// RFC3339 timestamp of `snapshot_id`; `None` alongside it.
43	pub snapshot_at: Option<String>,
44	/// Always `"s3"`.
45	pub storage: String,
46	pub bucket: String,
47	pub prefix: String,
48	pub region: String,
49}
50
51/// Body for `POST /restore-credentials`.
52#[derive(Debug, Clone, Serialize)]
53pub struct RestoreCredentialsRequest<'a> {
54	pub group: Uuid,
55	pub r#type: &'a str,
56}
57
58/// Read-only S3 credentials plus the repo password, from `POST /restore-credentials`.
59///
60/// `credentials` is the `credential_process` shape the proxy re-signs onto kopia;
61/// `repo_password` opens the kopia repo. One per-group call fully opens the repo.
62#[derive(Debug, Clone, Deserialize)]
63pub struct RestoreCredentials {
64	pub credentials: BackupCredentials,
65	pub repo_password: Redacted<String>,
66}
67
68/// Body for `POST /restore-verification`: a restore's reported health.
69///
70/// Report `outcome = Success` with `replica_healthy = true` only when the
71/// deployment passed the readiness gate; anything else raises a group-level
72/// incident. Unsupported intents are handled by capability registration, never
73/// reported here.
74#[derive(Debug, Clone, Serialize)]
75pub struct RestoreVerification<'a> {
76	#[serde(skip_serializing_if = "Option::is_none")]
77	pub replica_id: Option<Uuid>,
78	pub group: Uuid,
79	pub server_id: Uuid,
80	pub r#type: &'a str,
81	pub intent: &'a str,
82	#[serde(skip_serializing_if = "Option::is_none")]
83	pub snapshot_id: Option<&'a str>,
84	pub outcome: Outcome,
85	#[serde(skip_serializing_if = "Option::is_none")]
86	pub error: Option<&'a str>,
87	pub replica_healthy: bool,
88	#[serde(skip_serializing_if = "Option::is_none")]
89	pub postgres_version: Option<&'a str>,
90	pub observed_at: Timestamp,
91	#[serde(skip_serializing_if = "Option::is_none")]
92	pub s3_sent_raw_bytes: Option<i64>,
93	#[serde(skip_serializing_if = "Option::is_none")]
94	pub s3_sent_payload_bytes: Option<i64>,
95	#[serde(skip_serializing_if = "Option::is_none")]
96	pub s3_received_raw_bytes: Option<i64>,
97	#[serde(skip_serializing_if = "Option::is_none")]
98	pub s3_received_payload_bytes: Option<i64>,
99}
100
101#[cfg(test)]
102mod tests {
103	use serde_json::json;
104
105	use super::*;
106
107	#[test]
108	fn capabilities_request_lists_intents() {
109		let intents = ["verify", "analytics", "disaster-recovery"];
110		let req = RestoreCapabilitiesRequest { intents: &intents };
111		assert_eq!(
112			serde_json::to_value(&req).unwrap(),
113			json!({"intents": ["verify", "analytics", "disaster-recovery"]})
114		);
115	}
116
117	#[test]
118	fn credentials_request_carries_group_and_type() {
119		let group = "11111111-1111-1111-1111-111111111111".parse().unwrap();
120		let req = RestoreCredentialsRequest {
121			group,
122			r#type: "tamanu-postgres",
123		};
124		assert_eq!(
125			serde_json::to_value(&req).unwrap(),
126			json!({
127				"group": "11111111-1111-1111-1111-111111111111",
128				"type": "tamanu-postgres",
129			})
130		);
131	}
132
133	#[test]
134	fn worklist_entry_deserialises() {
135		let body = json!({
136			"replica_id": "11111111-1111-1111-1111-111111111111",
137			"group_id": "22222222-2222-2222-2222-222222222222",
138			"server_id": "33333333-3333-3333-3333-333333333333",
139			"type": "tamanu-postgres",
140			"intent": "verify",
141			"name": "nightly verify",
142			"freshness_seconds": 86400,
143			"snapshot_id": "kopia-snap",
144			"snapshot_at": "2026-06-30T00:00:00Z",
145			"storage": "s3",
146			"bucket": "my-bucket",
147			"prefix": "",
148			"region": "ap-southeast-2",
149		});
150		let entry: WorklistEntry = serde_json::from_value(body).unwrap();
151		assert_eq!(entry.r#type, "tamanu-postgres");
152		assert_eq!(entry.intent, "verify");
153		assert_eq!(entry.name, "nightly verify");
154		assert_eq!(entry.freshness_seconds, Some(86400));
155		assert_eq!(entry.snapshot_id.as_deref(), Some("kopia-snap"));
156		assert_eq!(entry.bucket, "my-bucket");
157	}
158
159	#[test]
160	fn worklist_entry_allows_null_snapshot_and_freshness() {
161		let body = json!({
162			"replica_id": "11111111-1111-1111-1111-111111111111",
163			"group_id": "22222222-2222-2222-2222-222222222222",
164			"server_id": "33333333-3333-3333-3333-333333333333",
165			"type": "tamanu-postgres",
166			"intent": "verify",
167			"name": "latest",
168			"freshness_seconds": null,
169			"snapshot_id": null,
170			"snapshot_at": null,
171			"storage": "s3",
172			"bucket": "my-bucket",
173			"prefix": "",
174			"region": "ap-southeast-2",
175		});
176		let entry: WorklistEntry = serde_json::from_value(body).unwrap();
177		assert_eq!(entry.freshness_seconds, None);
178		assert_eq!(entry.snapshot_id, None);
179		assert_eq!(entry.snapshot_at, None);
180	}
181
182	#[test]
183	fn restore_credentials_deserialise_composite() {
184		let body = json!({
185			"credentials": {
186				"Version": 1,
187				"AccessKeyId": "AKIA",
188				"SecretAccessKey": "secret",
189				"SessionToken": "token",
190				"Expiration": "2026-05-21T13:00:00Z",
191			},
192			"repo_password": "hunter2",
193		});
194		let creds: RestoreCredentials = serde_json::from_value(body).unwrap();
195		assert_eq!(creds.credentials.access_key_id, "AKIA");
196		assert_eq!(&*creds.repo_password, "hunter2");
197	}
198
199	#[test]
200	fn restore_credentials_debug_does_not_leak_password() {
201		let body = json!({
202			"credentials": {
203				"Version": 1,
204				"AccessKeyId": "AKIA",
205				"SecretAccessKey": "secret",
206				"SessionToken": "token",
207				"Expiration": "2026-05-21T13:00:00Z",
208			},
209			"repo_password": "super-secret-repo-pw",
210		});
211		let creds: RestoreCredentials = serde_json::from_value(body).unwrap();
212		let debug = format!("{creds:?}");
213		assert!(!debug.contains("super-secret-repo-pw"));
214		assert!(debug.contains("<redacted>"));
215	}
216
217	#[test]
218	fn verification_omits_optional_fields() {
219		let report = RestoreVerification {
220			replica_id: None,
221			group: "22222222-2222-2222-2222-222222222222".parse().unwrap(),
222			server_id: "33333333-3333-3333-3333-333333333333".parse().unwrap(),
223			r#type: "tamanu-postgres",
224			intent: "verify",
225			snapshot_id: None,
226			outcome: Outcome::Failure,
227			error: None,
228			replica_healthy: false,
229			postgres_version: None,
230			observed_at: "2026-06-30T00:00:00Z".parse().unwrap(),
231			s3_sent_raw_bytes: None,
232			s3_sent_payload_bytes: None,
233			s3_received_raw_bytes: None,
234			s3_received_payload_bytes: None,
235		};
236		assert_eq!(
237			serde_json::to_value(&report).unwrap(),
238			json!({
239				"group": "22222222-2222-2222-2222-222222222222",
240				"server_id": "33333333-3333-3333-3333-333333333333",
241				"type": "tamanu-postgres",
242				"intent": "verify",
243				"outcome": "failure",
244				"replica_healthy": false,
245				"observed_at": "2026-06-30T00:00:00Z",
246			})
247		);
248	}
249
250	#[test]
251	fn verification_includes_full_payload() {
252		let report = RestoreVerification {
253			replica_id: Some("11111111-1111-1111-1111-111111111111".parse().unwrap()),
254			group: "22222222-2222-2222-2222-222222222222".parse().unwrap(),
255			server_id: "33333333-3333-3333-3333-333333333333".parse().unwrap(),
256			r#type: "tamanu-postgres",
257			intent: "verify",
258			snapshot_id: Some("kopia-snap"),
259			outcome: Outcome::Success,
260			error: None,
261			replica_healthy: true,
262			postgres_version: Some("15"),
263			observed_at: "2026-06-30T00:00:00Z".parse().unwrap(),
264			s3_sent_raw_bytes: Some(123),
265			s3_sent_payload_bytes: Some(120),
266			s3_received_raw_bytes: Some(456),
267			s3_received_payload_bytes: Some(450),
268		};
269		assert_eq!(
270			serde_json::to_value(&report).unwrap(),
271			json!({
272				"replica_id": "11111111-1111-1111-1111-111111111111",
273				"group": "22222222-2222-2222-2222-222222222222",
274				"server_id": "33333333-3333-3333-3333-333333333333",
275				"type": "tamanu-postgres",
276				"intent": "verify",
277				"snapshot_id": "kopia-snap",
278				"outcome": "success",
279				"replica_healthy": true,
280				"postgres_version": "15",
281				"observed_at": "2026-06-30T00:00:00Z",
282				"s3_sent_raw_bytes": 123,
283				"s3_sent_payload_bytes": 120,
284				"s3_received_raw_bytes": 456,
285				"s3_received_payload_bytes": 450,
286			})
287		);
288	}
289}