From 99f48be4cd4d8072f827605108821c82261e1bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Thu, 2 Jul 2026 18:41:32 +1200 Subject: [PATCH] feat(canopy): use typed schema::VerificationArgs for restore-verification bestool-canopy 0.4.5 (bestool#628) generates typed request structs from canopy's OpenAPI at build time. Replace the hand-built serde_json::Value verification body with the generated schema::VerificationArgs: the field set is now checked against canopy's spec at compile time. health_details stays a free-form serde_json::Value (untyped in the spec by design) carrying { sizes, fixes, restore_duration_sec }; it's sent as None when nothing was gathered rather than an empty object. observed_at and outcome are strings per the generated type (outcome mapped to canopy's lowercase wire form). Drops the wrapper's dead hand-written restore_verification method and generalises the sender to restore_verification_typed(impl Serialize), POSTing via the generic request escape hatch. Note: bestool-canopy's build.rs fetches canopy's live OpenAPI at build time and falls back to its committed snapshot when unreachable (e.g. CI), so this adds typify/ureq build-deps to the tree. --- Cargo.lock | 169 +++++++++++++++++++++++-- Cargo.toml | 2 +- src/canopy.rs | 28 ++-- src/controllers/canopy/verification.rs | 63 ++++----- 4 files changed, 202 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9d2315..6e7908a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -464,9 +464,9 @@ checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" [[package]] name = "bestool-canopy" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "322f836e08276d95b49e4b1188470f24af768e3e504b39a14661f9a882a42fcf" +checksum = "4a9a70b6aea63cd674f184bb45b07cb0b42cfc36449e99e2e15c7860267ad7fd" dependencies = [ "algae-cli", "base64 0.22.1", @@ -478,14 +478,19 @@ dependencies = [ "jiff", "machine-uid", "miette", + "prettyplease", "rcgen", "reqwest", + "schemars 0.8.22", "serde", "serde_json", + "syn 2.0.118", "sysinfo", "time", "tokio", "tracing", + "typify", + "ureq", "uuid", ] @@ -2430,7 +2435,7 @@ checksum = "d9c6922f6afe80418dd6019818af5d0d34584c371780ff09b9752370c25b4abb" dependencies = [ "base64 0.22.1", "jiff", - "schemars", + "schemars 1.2.1", "serde", "serde_json", ] @@ -2496,7 +2501,7 @@ dependencies = [ "jiff", "json-patch", "k8s-openapi", - "schemars", + "schemars 1.2.1", "serde", "serde-value", "serde_json", @@ -2609,9 +2614,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -3201,7 +3206,7 @@ dependencies = [ "rand 0.10.1", "reqwest", "rust_decimal", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "serde_yaml", @@ -3264,6 +3269,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -3590,6 +3605,16 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "regress" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158a764437582235e3501f683b93a0a6f8d825d04a789dbe5ed30b8799b8908a" +dependencies = [ + "hashbrown 0.16.1", + "memchr", +] + [[package]] name = "rend" version = "0.4.2" @@ -3797,6 +3822,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3903,6 +3929,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -3912,11 +3950,23 @@ dependencies = [ "dyn-clone", "jiff", "ref-cast", - "schemars_derive", + "schemars_derive 1.2.1", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + [[package]] name = "schemars_derive" version = "1.2.1" @@ -4001,9 +4051,13 @@ checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -4023,7 +4077,7 @@ checksum = "5897b4c3faadadd35fdb6689f015641f3bc481d5adaaac56231ea15aeb243db3" dependencies = [ "ahash 0.8.12", "annotate-snippets", - "base64 0.21.7", + "base64 0.22.1", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -4108,6 +4162,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4847,6 +4913,53 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "typify" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cdc2e612ea322c6e232d46a0b34607c8eb28978fd6060ecfb139f2a50db8d5f" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "691591f49550c0d371bc441d019c30ce241d4116aee7d68df7a9840d6c70bf8f" +dependencies = [ + "heck", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn 2.0.118", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d41aea893c49cf95661389207b8af0c6254ff48b2c3ae1e4f3704777dbdfcf03" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn 2.0.118", + "typify-impl", +] + [[package]] name = "ucd-trie" version = "0.1.7" @@ -4945,6 +5058,34 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4957,6 +5098,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index ef3727d..59be6ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ publish = false [dependencies] anyhow = "1.0.102" axum = "0.8.9" -bestool-canopy = "0.4.4" +bestool-canopy = "0.4.5" bestool-kopia = { version = "0.3.4", features = ["proxy"] } cronexpr = "1.5.0" futures = "0.3.31" diff --git a/src/canopy.rs b/src/canopy.rs index 42c6109..8c85d37 100644 --- a/src/canopy.rs +++ b/src/canopy.rs @@ -7,9 +7,7 @@ //! the integration seam tests inject a stub at, and as the place to hang //! pgro-specific logging / retry / cache concerns later. -use bestool_canopy::{ - CanopyClient, RestoreCredentials, RestoreVerification, WorklistEntry, client_builder, -}; +use bestool_canopy::{CanopyClient, RestoreCredentials, WorklistEntry, client_builder}; use reqwest::Url; use uuid::Uuid; @@ -140,20 +138,16 @@ impl Client { } /// Report a restore outcome (signal 3, restore-verification). - pub async fn restore_verification(&self, report: &RestoreVerification<'_>) -> Result<()> { - self.inner - .restore_verification(&self.base_url, report) - .await - .map_err(|err| Error::Canopy(format!("restore_verification: {err:?}"))) - } - - /// Report a restore outcome with an arbitrary JSON body — used to include - /// the `health_details` field, which the typed [`RestoreVerification`] - /// struct doesn't carry. `body` should be the serialized verification - /// plus any extra fields. Goes to the same `POST /restore-verification` - /// endpoint via the generic request escape hatch; a non-2xx response is - /// an error carrying the status + body. - pub async fn restore_verification_json(&self, body: &serde_json::Value) -> Result<()> { + /// + /// `body` is the typed [`bestool_canopy::schema::VerificationArgs`] + /// (generated from canopy's OpenAPI) — including the free-form + /// `health_details` the hand-written wire type doesn't carry. Sent to + /// `POST /restore-verification` via the generic request escape hatch; a + /// non-2xx response is an error carrying the status + body. + pub async fn restore_verification_typed( + &self, + body: &(impl serde::Serialize + ?Sized), + ) -> Result<()> { let resp = self .inner .request( diff --git a/src/controllers/canopy/verification.rs b/src/controllers/canopy/verification.rs index 614251f..71c67c0 100644 --- a/src/controllers/canopy/verification.rs +++ b/src/controllers/canopy/verification.rs @@ -5,7 +5,7 @@ //! loop. This module owns signal 3 — one function called at each terminal //! transition (switchover success, restore failure). -use bestool_canopy::{Outcome, RestoreVerification}; +use bestool_canopy::{Outcome, schema::VerificationArgs}; use jiff::Timestamp; use k8s_openapi::api::core::v1::Secret; use kube::{Api, ResourceExt}; @@ -105,54 +105,46 @@ pub async fn report( let postgres_version = restore .status .as_ref() - .and_then(|s| s.postgres_version.as_deref()); + .and_then(|s| s.postgres_version.clone()); let replica_healthy = matches!(outcome, Outcome::Success); - let report = RestoreVerification { + // Gather health details; send None rather than an empty object when + // nothing was gathered (e.g. failure path, postgres unreachable). + let health = gather_health_details(ctx, replica, restore).await; + let health_details = health + .as_object() + .is_some_and(|m| !m.is_empty()) + .then_some(health); + + // 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, - r#type: &backup_type, - intent: &intent, - snapshot_id: Some(restore.spec.snapshot.as_str()), - outcome, - error, + 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(), + 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, }; - // Serialize the typed report, then splice in `health_details` — the - // typed struct doesn't carry it, so we send via the arbitrary-JSON - // path. If serialization somehow fails, fall back to the typed call so - // the outcome still reaches canopy. - let mut body = match serde_json::to_value(&report) { - Ok(v) => v, - Err(err) => { - warn!( - restore = %restore.name_any(), - error = %err, - "canopy verification: failed to serialize report; sending without health_details" - ); - if let Err(err) = canopy.restore_verification(&report).await { - warn!(restore = %restore.name_any(), error = %err, "canopy verification report failed"); - } - return; - } - }; - body["health_details"] = gather_health_details(ctx, replica, restore).await; - - match canopy.restore_verification_json(&body).await { + match canopy.restore_verification_typed(&args).await { Ok(()) => info!( replica = %replica.name_any(), restore = %restore.name_any(), ?outcome, - health_details = %body["health_details"], + health_details = %args.health_details.as_ref().map(|v| v.to_string()).unwrap_or_default(), "canopy verification reported" ), Err(err) => warn!( @@ -164,6 +156,15 @@ pub async fn report( } } +/// Wire string canopy expects for the `outcome` field (matches the +/// lowercase serialization of `bestool_canopy::Outcome`). +fn outcome_wire(outcome: Outcome) -> &'static str { + match outcome { + Outcome::Success => "success", + Outcome::Failure => "failure", + } +} + /// Best-effort gather of the `health_details` map (snake_case keys): /// `{ sizes: {: bytes}, fixes: {reindex, locale}, restore_duration_sec }`. ///