diff --git a/Cargo.lock b/Cargo.lock index e9157cf..a2eda79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -464,13 +464,14 @@ checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" [[package]] name = "bestool-canopy" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de6ff6a223ff6d3b1d721b68cfdb3e77ebfa3ef3372364d0d1df0fac7017b79" +checksum = "32a31932e57eff5a5aa9cc2e01e78685b30acc083bf65b13decdeeed3cecde5f" dependencies = [ "algae-cli", "base64 0.22.1", "blake3", + "bon", "flate2", "futures", "getrandom 0.4.3", @@ -495,9 +496,9 @@ dependencies = [ [[package]] name = "bestool-kopia" -version = "0.3.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03d98bc1690b9adaa51b3e4416ce19c1bc8472a520a269a33224b64abfd4757" +checksum = "decbf63eebeda2982713bf322792c1a3d888ceb029579aae94087e6f48800875" dependencies = [ "bytes", "hex", @@ -582,6 +583,31 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.118", +] + [[package]] name = "borsh" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index 8cbc7d3..918e582 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,8 @@ publish = false [dependencies] anyhow = "1.0.102" axum = "0.8.9" -bestool-canopy = "0.6.1" -bestool-kopia = { version = "0.3.4", features = ["proxy"] } +bestool-canopy = "0.7.0" +bestool-kopia = { version = "0.4.0", features = ["proxy"] } cronexpr = "1.5.0" futures = "0.3.31" globset = "0.4.18" diff --git a/README.md b/README.md index a754599..234e073 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ Deleting this resource will drop the restored database and prompt the Replica to | Field | Type | Description | |-------|------|-------------| | `phase` | `Pending` \| `Restoring` \| `Ready` \| `Switching` \| `Active` \| `Failed` | Current phase of the restore. | +| `runId` | `string` | Canopy run-uuid minted when the restore Job is created, reused for the run's credential requests and verification report so canopy can correlate them. Canopy-backed restores only. | | `postgresVersion` | `string` | Detected PostgreSQL major version from the restored data. | | `createdAt` | `Time` | When the restore resource was created. | | `restoredAt` | `Time` | When the restore job completed. | diff --git a/crds.yaml b/crds.yaml index d38ed30..771fd3c 100644 --- a/crds.yaml +++ b/crds.yaml @@ -1124,6 +1124,15 @@ spec: format: date-time nullable: true type: string + runId: + description: |- + Canopy run-uuid for this restore run, minted when the restore Job is + created and reused for the run's credential requests and its + verification report so canopy can correlate them. Stored as a string + (parsed to `Uuid` when sent) to avoid pulling a schemars uuid feature + into the CRD schema. + nullable: true + type: string type: object required: - spec diff --git a/src/bin/canopy_proxy.rs b/src/bin/canopy_proxy.rs index 31299b1..f83eeee 100644 --- a/src/bin/canopy_proxy.rs +++ b/src/bin/canopy_proxy.rs @@ -34,6 +34,10 @@ struct Config { broker_url: String, group: String, backup_type: String, + /// Canopy run-uuid for this restore run (`PGRO_RUN_ID`), forwarded with + /// every creds request so canopy attributes the grant to the run. Optional + /// so a sidecar scheduled by an older operator (no env set) still works. + run_id: Option, region: String, port_file: PathBuf, /// URL the operator serves the stats-callback on; the sidecar POSTs @@ -49,6 +53,7 @@ impl Config { broker_url: env_required("PGRO_BROKER_URL")?, group: env_required("PGRO_GROUP")?, backup_type: env_required("PGRO_TYPE")?, + run_id: std::env::var("PGRO_RUN_ID").ok().filter(|s| !s.is_empty()), region: env_required("PGRO_REGION")?, port_file: env_or("PGRO_PROXY_PORT_FILE", "/var/run/pgro/proxy-port").into(), stats_callback_url: env_required("PGRO_STATS_CALLBACK_URL")?, @@ -71,11 +76,12 @@ struct BrokerCredentialProvider { url: String, group: String, backup_type: String, + run_id: Option, cache: Mutex>, } impl BrokerCredentialProvider { - fn new(broker_url: &str, group: &str, backup_type: &str) -> Self { + fn new(broker_url: &str, group: &str, backup_type: &str, run_id: Option<&str>) -> Self { Self { http: reqwest::Client::builder() .timeout(Duration::from_secs(10)) @@ -87,6 +93,7 @@ impl BrokerCredentialProvider { ), group: group.to_string(), backup_type: backup_type.to_string(), + run_id: run_id.map(str::to_string), cache: Mutex::new(None), } } @@ -96,6 +103,8 @@ impl BrokerCredentialProvider { struct Req<'a> { group: &'a str, r#type: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + run_id: Option<&'a str>, } let resp = self .http @@ -103,6 +112,7 @@ impl BrokerCredentialProvider { .json(&Req { group: &self.group, r#type: &self.backup_type, + run_id: self.run_id.as_deref(), }) .send() .await? @@ -207,6 +217,7 @@ async fn run(cfg: Config) -> Result<(), String> { &cfg.broker_url, &group, &backup_type, + cfg.run_id.as_deref(), )); let s3_cfg = S3ProxyConfig { upstream: format!("https://s3.{}.amazonaws.com", cfg.region), diff --git a/src/bin/operator.rs b/src/bin/operator.rs index 4f52999..64b7347 100644 --- a/src/bin/operator.rs +++ b/src/bin/operator.rs @@ -698,10 +698,15 @@ async fn register_capabilities(ctx: Arc) { /// State passed to the credential-broker Router. Holds the operator's canopy /// client and a per-(group, type) cache so concurrent Job sidecars don't /// multiply upstream canopy calls. +/// Broker creds cache key: `(group, type, run_id)`. Including the run_id keeps +/// within-run STS refreshes on a cache hit while giving each restore run its +/// own canopy-attributed credentials. +type CredsCacheKey = (String, String, Option); + #[derive(Clone)] struct BrokerState { ctx: Arc, - cache: Arc>>, + cache: Arc>>, } #[derive(Clone)] @@ -736,6 +741,11 @@ fn broker_router(state: BrokerState) -> Router { struct BrokerCredsRequest { group: uuid::Uuid, r#type: String, + /// Canopy run-uuid of the restore run this sidecar serves, forwarded to + /// canopy so its credential grant is attributed to the run. Optional to + /// tolerate an older sidecar (mid-rollout) that doesn't send one. + #[serde(default)] + run_id: Option, } /// Broker endpoint the proxy sidecar hits to refresh its STS creds. Forwards @@ -756,7 +766,7 @@ async fn post_restore_creds( ); }; - let key = (req.group.to_string(), req.r#type.clone()); + let key = (req.group.to_string(), req.r#type.clone(), req.run_id); { let cache = state.cache.lock().await; if let Some(cached) = cache.get(&key) @@ -766,7 +776,10 @@ async fn post_restore_creds( } } - match canopy.restore_credentials(&req.r#type, req.group).await { + match canopy + .restore_credentials(&req.r#type, req.group, req.run_id) + .await + { Ok(resp) => { let expires_at = resp .credentials diff --git a/src/canopy.rs b/src/canopy.rs index 857631b..3ecbaa7 100644 --- a/src/canopy.rs +++ b/src/canopy.rs @@ -109,9 +109,9 @@ impl Client { /// carries the intent name, the canopy semantics it opts into, and its /// typed parameter schema. pub async fn restore_capabilities(&self, intents: &[IntentDescriptor]) -> Result<()> { - let body = RestoreCapabilitiesArgs { - intents: intents.to_vec(), - }; + let body = RestoreCapabilitiesArgs::builder() + .intents(intents.to_vec()) + .build(); self.inner .restore_capabilities(&body) .await @@ -128,16 +128,21 @@ impl Client { } /// Fetch short-lived read-only STS creds plus the repo password for a - /// `(group, type)`. Authorized iff a declaration covers it. + /// `(group, type)`. Authorized iff a declaration covers it. `run_id` is the + /// canopy run-uuid when the fetch belongs to a restore run (the restore + /// job's sidecar); `None` for non-run fetches such as the reconcile-time + /// repo-password poll. pub async fn restore_credentials( &self, backup_type: &str, group: Uuid, + run_id: Option, ) -> Result { - let body = RestoreCredentialsArgs { - group, - type_: backup_type.to_string(), - }; + let body = RestoreCredentialsArgs::builder() + .group(group) + .type_(backup_type.to_string()) + .maybe_run_id(run_id) + .build(); self.inner.restore_credentials(&body).await.map_err(|err| { Error::Canopy(format!( "restore_credentials({backup_type}, {group}): {err}" diff --git a/src/controllers/canopy.rs b/src/controllers/canopy.rs index a226de5..9365508 100644 --- a/src/controllers/canopy.rs +++ b/src/controllers/canopy.rs @@ -259,8 +259,10 @@ async fn reconcile_entry(ctx: &Context, ns_name: &str, entry: &WorklistEntry) -> let Some(canopy) = ctx.canopy.as_ref() else { return Ok(()); }; + // This per-tick fetch exists only to read the repo password for the creds + // Secret; it is not a restore run, so it carries no run_id. let creds = canopy - .restore_credentials(&entry.type_, entry.group_id) + .restore_credentials(&entry.type_, entry.group_id, None) .await?; ensure_canopy_creds_secret(ctx, ns_name, entry, &creds.repo_password.0).await?; diff --git a/src/controllers/canopy/intent.rs b/src/controllers/canopy/intent.rs index fea7ffe..5b9115e 100644 --- a/src/controllers/canopy/intent.rs +++ b/src/controllers/canopy/intent.rs @@ -72,7 +72,10 @@ const DEFAULT_ANALYTICS_MINIMUM_TTL_SECS: i64 = 7200; const DEFAULT_ANALYTICS_SWITCHOVER_GRACE_SECS: i64 = 120; fn param(type_: ParamType, default: Option) -> ParamSpec { - ParamSpec { type_, default } + ParamSpec::builder() + .type_(type_) + .maybe_default(default) + .build() } /// The `analytics` intent's parameter schema (name → typed spec + default). @@ -112,23 +115,28 @@ fn analytics_param_schema() -> ParamSchema { /// operators, dispatches only these, and applies the semantics' behaviours. pub fn descriptors() -> Vec { vec![ - IntentDescriptor { - intent: "verify".to_string(), - description: Some( + IntentDescriptor::builder() + .intent("verify".to_string()) + .description( "Restore the snapshot to prove it is restorable, then discard it.".to_string(), - ), - semantics: vec![semantics::CHECK.to_string(), semantics::ONCE.to_string()], - params: None, - }, - IntentDescriptor { - intent: "analytics".to_string(), - description: Some( + ) + .semantics(vec![ + semantics::CHECK.to_string(), + semantics::ONCE.to_string(), + ]) + .build(), + IntentDescriptor::builder() + .intent("analytics".to_string()) + .description( "Keep a long-lived read-only query replica restored from the latest snapshot." .to_string(), - ), - semantics: vec![semantics::CHECK.to_string(), semantics::URL.to_string()], - params: Some(analytics_param_schema()), - }, + ) + .semantics(vec![ + semantics::CHECK.to_string(), + semantics::URL.to_string(), + ]) + .params(analytics_param_schema()) + .build(), ] } diff --git a/src/controllers/canopy/verification.rs b/src/controllers/canopy/verification.rs index 6120a32..b8bb578 100644 --- a/src/controllers/canopy/verification.rs +++ b/src/controllers/canopy/verification.rs @@ -107,6 +107,11 @@ pub async fn report( .as_ref() .and_then(|s| s.postgres_version.clone()); + // The canopy run-uuid minted when this run's Job was created (persisted on + // the restore status). Reported so canopy can correlate the report with the + // run's credential requests. + let run_id = run_id_from_status(restore); + let replica_healthy = matches!(outcome, RunOutcome::Success); // Gather health details; send None rather than an empty object when @@ -127,24 +132,25 @@ pub async fn report( // Typed request body generated from canopy's OpenAPI (bestool#628). // Constructing it here means the field set is checked against canopy's // spec at compile time; `health_details` stays free-form by design. - let args = VerificationArgs { - replica_id, - group, - server_id, - type_: backup_type.clone(), - intent: intent.clone(), - snapshot_id: Some(restore.spec.snapshot.clone()), - outcome: outcome_wire(outcome).to_string(), - error: error.map(str::to_string), - replica_healthy, - postgres_version, - observed_at: Timestamp::now().to_string(), - s3_sent_raw_bytes: Some(stats.sent_raw_bytes as i64), - s3_sent_payload_bytes: Some(stats.sent_payload_bytes as i64), - s3_received_raw_bytes: Some(stats.received_raw_bytes as i64), - s3_received_payload_bytes: Some(stats.received_payload_bytes as i64), - health_details, - }; + let args = VerificationArgs::builder() + .maybe_replica_id(replica_id) + .maybe_run_id(run_id) + .group(group) + .server_id(server_id) + .type_(backup_type.clone()) + .intent(intent.clone()) + .snapshot_id(restore.spec.snapshot.clone()) + .outcome(outcome_wire(outcome).to_string()) + .maybe_error(error.map(str::to_string)) + .replica_healthy(replica_healthy) + .maybe_postgres_version(postgres_version) + .observed_at(Timestamp::now().to_string()) + .s3_sent_raw_bytes(stats.sent_raw_bytes as i64) + .s3_sent_payload_bytes(stats.sent_payload_bytes as i64) + .s3_received_raw_bytes(stats.received_raw_bytes as i64) + .s3_received_payload_bytes(stats.received_payload_bytes as i64) + .maybe_health_details(health_details) + .build(); match canopy.restore_verification_typed(&args).await { Ok(()) => info!( @@ -163,6 +169,17 @@ pub async fn report( } } +/// The canopy run-uuid persisted on the restore status, parsed to a `Uuid`. +/// A malformed value is treated as absent rather than failing the report — +/// canopy still accepts a report without a run_id while the field is optional. +fn run_id_from_status(restore: &PostgresPhysicalRestore) -> Option { + restore + .status + .as_ref() + .and_then(|s| s.run_id.as_deref()) + .and_then(|s| Uuid::parse_str(s).ok()) +} + /// Wire string canopy expects for the `outcome` field (matches the /// lowercase serialization of `bestool_canopy::Outcome`). fn outcome_wire(outcome: RunOutcome) -> &'static str { @@ -321,4 +338,48 @@ mod tests { assert!(v.get("sizes").is_none()); assert!(v.get("fixes").is_none()); } + + fn restore_with_run_id(run_id: Option<&str>) -> PostgresPhysicalRestore { + use k8s_openapi::api::core::v1::LocalObjectReference; + use k8s_openapi::apimachinery::pkg::api::resource::Quantity; + + use crate::types::{PostgresPhysicalRestoreSpec, PostgresPhysicalRestoreStatus}; + + let mut restore = PostgresPhysicalRestore::new( + "r", + PostgresPhysicalRestoreSpec { + replica: LocalObjectReference { + name: "rep".to_string(), + }, + snapshot: "snap".to_string(), + snapshot_size: Quantity("1Gi".to_string()), + snapshot_time: None, + storage_size: Quantity("2Gi".to_string()), + }, + ); + restore.status = Some(PostgresPhysicalRestoreStatus { + run_id: run_id.map(str::to_string), + ..Default::default() + }); + restore + } + + #[test] + fn run_id_from_status_parses_valid_uuid() { + let id = "44444444-4444-4444-4444-444444444444"; + assert_eq!( + run_id_from_status(&restore_with_run_id(Some(id))), + Some(Uuid::parse_str(id).unwrap()) + ); + } + + #[test] + fn run_id_from_status_absent_or_malformed_is_none() { + assert_eq!(run_id_from_status(&restore_with_run_id(None)), None); + assert_eq!( + run_id_from_status(&restore_with_run_id(Some("not-a-uuid"))), + None, + "a malformed run_id is tolerated as absent, not a hard error" + ); + } } diff --git a/src/controllers/replica.rs b/src/controllers/replica.rs index 8261808..d634553 100644 --- a/src/controllers/replica.rs +++ b/src/controllers/replica.rs @@ -1071,6 +1071,8 @@ pub async fn reconcile(replica: Arc, ctx: Arc) image: &ctx.canopy_proxy_image, broker_base_url: &ctx.canopy_broker_base_url, stats_callback_url: &stats_callback_url, + // Snapshot listing is discovery, not a restore run — no run_id. + run_id: None, }) } else { None diff --git a/src/controllers/restore.rs b/src/controllers/restore.rs index 322bc42..754a3e2 100644 --- a/src/controllers/restore.rs +++ b/src/controllers/restore.rs @@ -462,6 +462,32 @@ async fn reconcile_restoring( let replicas: Api = Api::namespaced(client.clone(), namespace); let replica = replicas.get(replica_name).await?; + // Mint (or reuse) the canopy run-uuid for this restore run before + // creating the Job, so the sidecar's credential requests and the + // eventual verification report carry the same id and canopy can + // correlate them. Only canopy-backed restores are a "run"; the + // legacy kopia path has no canopy report. Persist it to status + // first so it survives a crash between here and Job creation. + let run_id = if replica.spec.canopy_source.is_some() { + let id = match restore.status.as_ref().and_then(|s| s.run_id.clone()) { + Some(existing) => existing, + None => { + let minted = uuid::Uuid::new_v4().to_string(); + update_restore_status( + client, + namespace, + name, + serde_json::json!({ "runId": minted }), + ) + .await?; + minted + } + }; + Some(id) + } else { + None + }; + let cache_pressure_url = ctx.cache_pressure_callback_url(namespace, name); let stats_callback_url = ctx.canopy_stats_callback_url(namespace, &job_name); let canopy_proxy = if replica.spec.canopy_source.is_some() { @@ -469,6 +495,7 @@ async fn reconcile_restoring( image: &ctx.canopy_proxy_image, broker_base_url: &ctx.canopy_broker_base_url, stats_callback_url: &stats_callback_url, + run_id: run_id.as_deref(), }) } else { None diff --git a/src/controllers/restore/builders.rs b/src/controllers/restore/builders.rs index 882dd0b..9c83cb5 100644 --- a/src/controllers/restore/builders.rs +++ b/src/controllers/restore/builders.rs @@ -39,6 +39,11 @@ pub struct CanopyProxyArgs<'a> { pub broker_base_url: &'a str, /// Callback URL the sidecar POSTs its final TrafficStats to on shutdown. pub stats_callback_url: &'a str, + /// Canopy run-uuid for this restore run, passed to the sidecar as + /// `PGRO_RUN_ID` so its credential requests are attributed to the run. + /// `None` for non-run credential consumers (e.g. the snapshot-list job, + /// which is discovery rather than a restore run). + pub run_id: Option<&'a str>, } /// Name of the credential-reset Job for a given restore. @@ -816,36 +821,46 @@ echo -n "$VERSION" > /dev/termination-log // Same image as the operator; run the `canopy-proxy` binary // instead of the default `operator` entrypoint. command: Some(vec!["canopy-proxy".to_string()]), - env: Some(vec![ - EnvVar { - name: "PGRO_BROKER_URL".to_string(), - value: Some(proxy.broker_base_url.to_string()), - ..Default::default() - }, - EnvVar { - name: "PGRO_GROUP".to_string(), - value: Some(group.clone()), - ..Default::default() - }, - EnvVar { - name: "PGRO_TYPE".to_string(), - value: Some(backup_type.clone()), - ..Default::default() - }, - // Region comes from the canopy-creds Secret the syncer - // materialises alongside this Job; the sidecar signs S3 - // requests for it before forwarding upstream. - crate::controllers::jobs::env_from_secret_name( - "PGRO_REGION", - source.secret_name(), - "region", - ), - EnvVar { - name: "PGRO_STATS_CALLBACK_URL".to_string(), - value: Some(proxy.stats_callback_url.to_string()), - ..Default::default() - }, - ]), + env: Some({ + let mut env = vec![ + EnvVar { + name: "PGRO_BROKER_URL".to_string(), + value: Some(proxy.broker_base_url.to_string()), + ..Default::default() + }, + EnvVar { + name: "PGRO_GROUP".to_string(), + value: Some(group.clone()), + ..Default::default() + }, + EnvVar { + name: "PGRO_TYPE".to_string(), + value: Some(backup_type.clone()), + ..Default::default() + }, + // Region comes from the canopy-creds Secret the syncer + // materialises alongside this Job; the sidecar signs S3 + // requests for it before forwarding upstream. + crate::controllers::jobs::env_from_secret_name( + "PGRO_REGION", + source.secret_name(), + "region", + ), + EnvVar { + name: "PGRO_STATS_CALLBACK_URL".to_string(), + value: Some(proxy.stats_callback_url.to_string()), + ..Default::default() + }, + ]; + if let Some(run_id) = proxy.run_id { + env.push(EnvVar { + name: "PGRO_RUN_ID".to_string(), + value: Some(run_id.to_string()), + ..Default::default() + }); + } + env + }), volume_mounts: Some(vec![VolumeMount { name: "proxy-shared".to_string(), mount_path: "/var/run/pgro".to_string(), diff --git a/src/controllers/restore/tests.rs b/src/controllers/restore/tests.rs index 0987568..74f813b 100644 --- a/src/controllers/restore/tests.rs +++ b/src/controllers/restore/tests.rs @@ -167,6 +167,7 @@ fn canopy_restore_job_proxy_is_native_sidecar() { image: "ghcr.io/beyondessential/postgres-restore-operator:latest", broker_base_url: "http://operator.pgro-system.svc:9091", stats_callback_url: "http://operator.pgro-system.svc:8080/api/v1/canopy-stats/ns/job", + run_id: Some("44444444-4444-4444-4444-444444444444"), }; let job = build_restore_job( &restore, @@ -205,6 +206,65 @@ fn canopy_restore_job_proxy_is_native_sidecar() { ); } +#[test] +fn canopy_restore_job_sidecar_carries_run_id() { + // The run_id passed in CanopyProxyArgs must reach the sidecar as + // PGRO_RUN_ID so its credential requests are attributed to the run; when + // no run_id is given (non-run credential consumers) the env is absent. + let (restore, mut replica) = test_restore_and_replica(); + replica.spec.kopia_secret_ref = None; + replica.spec.canopy_source = Some(CanopySource { + group: "11111111-1111-1111-1111-111111111111".to_string(), + r#type: "tamanu-postgres".to_string(), + }); + + let run_id_env = |run_id: Option<&str>| { + let proxy = super::builders::CanopyProxyArgs { + image: "ghcr.io/beyondessential/postgres-restore-operator:latest", + broker_base_url: "http://operator.pgro-system.svc:9091", + stats_callback_url: "http://operator.pgro-system.svc:8080/api/v1/canopy-stats/ns/job", + run_id, + }; + let job = build_restore_job( + &restore, + "test-restore-restore", + "default", + &replica, + "kopia:latest", + "http://operator/api/v1/cache-pressure/default/test-restore", + Some(&proxy), + ) + .unwrap(); + let sidecar = job + .spec + .unwrap() + .template + .spec + .unwrap() + .init_containers + .unwrap() + .into_iter() + .find(|c| c.name == "canopy-proxy") + .expect("canopy-proxy sidecar"); + sidecar + .env + .unwrap_or_default() + .into_iter() + .find(|e| e.name == "PGRO_RUN_ID") + .and_then(|e| e.value) + }; + + assert_eq!( + run_id_env(Some("44444444-4444-4444-4444-444444444444")).as_deref(), + Some("44444444-4444-4444-4444-444444444444"), + ); + assert_eq!( + run_id_env(None), + None, + "no run_id → no PGRO_RUN_ID env on the sidecar" + ); +} + #[test] fn restore_job_has_ttl_seconds_after_finished() { let (restore, replica) = test_restore_and_replica(); diff --git a/src/types/restore.rs b/src/types/restore.rs index 7405d08..cd2748e 100644 --- a/src/types/restore.rs +++ b/src/types/restore.rs @@ -50,6 +50,14 @@ pub struct PostgresPhysicalRestoreStatus { #[serde(default, skip_serializing_if = "Option::is_none")] pub phase: Option, + /// Canopy run-uuid for this restore run, minted when the restore Job is + /// created and reused for the run's credential requests and its + /// verification report so canopy can correlate them. Stored as a string + /// (parsed to `Uuid` when sent) to avoid pulling a schemars uuid feature + /// into the CRD schema. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub postgres_version: Option,