77
88use bestool_canopy:: { Outcome , RestoreVerification } ;
99use jiff:: Timestamp ;
10- use kube:: ResourceExt ;
10+ use k8s_openapi:: api:: core:: v1:: Secret ;
11+ use kube:: { Api , ResourceExt } ;
1112use serde:: Deserialize ;
13+ use serde_json:: { Value , json} ;
1214use tracing:: { info, warn} ;
1315use uuid:: Uuid ;
1416
1517use crate :: {
1618 context:: Context ,
17- controllers:: canopy:: labels,
19+ controllers:: { canopy:: labels, postgres } ,
1820 types:: { PostgresPhysicalReplica , PostgresPhysicalRestore } ,
1921} ;
2022
@@ -125,11 +127,32 @@ pub async fn report(
125127 s3_received_payload_bytes : Some ( stats. received_payload_bytes as i64 ) ,
126128 } ;
127129
128- match canopy. restore_verification ( & report) . await {
130+ // Serialize the typed report, then splice in `health_details` — the
131+ // typed struct doesn't carry it, so we send via the arbitrary-JSON
132+ // path. If serialization somehow fails, fall back to the typed call so
133+ // the outcome still reaches canopy.
134+ let mut body = match serde_json:: to_value ( & report) {
135+ Ok ( v) => v,
136+ Err ( err) => {
137+ warn ! (
138+ restore = %restore. name_any( ) ,
139+ error = %err,
140+ "canopy verification: failed to serialize report; sending without health_details"
141+ ) ;
142+ if let Err ( err) = canopy. restore_verification ( & report) . await {
143+ warn ! ( restore = %restore. name_any( ) , error = %err, "canopy verification report failed" ) ;
144+ }
145+ return ;
146+ }
147+ } ;
148+ body[ "health_details" ] = gather_health_details ( ctx, replica, restore) . await ;
149+
150+ match canopy. restore_verification_json ( & body) . await {
129151 Ok ( ( ) ) => info ! (
130152 replica = %replica. name_any( ) ,
131153 restore = %restore. name_any( ) ,
132154 ?outcome,
155+ health_details = %body[ "health_details" ] ,
133156 "canopy verification reported"
134157 ) ,
135158 Err ( err) => warn ! (
@@ -140,3 +163,140 @@ pub async fn report(
140163 ) ,
141164 }
142165}
166+
167+ /// Best-effort gather of the `health_details` map (snake_case keys):
168+ /// `{ sizes: {<db>: bytes}, fixes: {reindex, locale}, restore_duration_sec }`.
169+ ///
170+ /// `sizes` and `fixes` come from a single read-only connection to the
171+ /// restore's postgres (`sizes` from `pg_database_size`, `fixes` from the
172+ /// `_pgro.restore_info` flags the init recorded). Any connection or query
173+ /// failure — expected on the failure path, where postgres may never have
174+ /// come up — just omits that piece; the verification still sends.
175+ /// `restore_duration_sec` is the wall-clock from the restore CR's
176+ /// `createdAt` to now (≈ activation), independent of postgres.
177+ async fn gather_health_details (
178+ ctx : & Context ,
179+ replica : & PostgresPhysicalReplica ,
180+ restore : & PostgresPhysicalRestore ,
181+ ) -> Value {
182+ let duration_sec = restore
183+ . status
184+ . as_ref ( )
185+ . and_then ( |s| s. created_at . as_ref ( ) )
186+ . map ( |created| Timestamp :: now ( ) . duration_since ( created. 0 ) . as_secs ( ) . max ( 0 ) as u64 ) ;
187+
188+ let pg = match gather_from_postgres ( ctx, replica, restore) . await {
189+ Ok ( parts) => Some ( parts) ,
190+ // Expected on the failure path (postgres may never have come up) and
191+ // on ephemeral replicas racing teardown; the verification still sends.
192+ Err ( err) => {
193+ warn ! (
194+ replica = %replica. name_any( ) ,
195+ restore = %restore. name_any( ) ,
196+ error = %err,
197+ "canopy verification: could not gather sizes/fixes; reporting without them"
198+ ) ;
199+ None
200+ }
201+ } ;
202+
203+ build_health_details ( duration_sec, pg)
204+ }
205+
206+ /// Postgres-derived pieces of the health details: per-database sizes and
207+ /// the `fixes` map the restore init recorded. `fixes` is an arbitrary JSON
208+ /// object (`{locale, reindex, reset_wal, ...}`) forwarded verbatim, so new
209+ /// fix flags flow through without operator changes.
210+ struct PostgresHealth {
211+ sizes : Vec < ( String , u64 ) > ,
212+ fixes : Value ,
213+ }
214+
215+ /// Assemble the `health_details` JSON from already-gathered parts. Pure, so
216+ /// the snake_case wire shape is unit-testable without a database. Omits any
217+ /// piece that wasn't gathered.
218+ fn build_health_details ( duration_sec : Option < u64 > , pg : Option < PostgresHealth > ) -> Value {
219+ let mut details = serde_json:: Map :: new ( ) ;
220+ if let Some ( secs) = duration_sec {
221+ details. insert ( "restore_duration_sec" . into ( ) , json ! ( secs) ) ;
222+ }
223+ if let Some ( pg) = pg {
224+ let sizes_obj: serde_json:: Map < String , Value > = pg
225+ . sizes
226+ . into_iter ( )
227+ . map ( |( name, bytes) | ( name, json ! ( bytes) ) )
228+ . collect ( ) ;
229+ details. insert ( "sizes" . into ( ) , Value :: Object ( sizes_obj) ) ;
230+ details. insert ( "fixes" . into ( ) , pg. fixes ) ;
231+ }
232+ Value :: Object ( details)
233+ }
234+
235+ /// Connect to the restore's postgres (as the replica's reader user) and
236+ /// read the per-database sizes + fix flags. Returns
237+ /// `(sizes, (locale_fixed, reindex_done))`.
238+ async fn gather_from_postgres (
239+ ctx : & Context ,
240+ replica : & PostgresPhysicalReplica ,
241+ restore : & PostgresPhysicalRestore ,
242+ ) -> crate :: error:: Result < PostgresHealth > {
243+ let namespace = replica. namespace ( ) . unwrap_or_default ( ) ;
244+ let restore_name = restore. name_any ( ) ;
245+
246+ let secrets: Api < Secret > = Api :: namespaced ( ctx. client . clone ( ) , & namespace) ;
247+ let reader_secret = secrets. get ( & replica. creds_secret_name ( ) ) . await ?;
248+ let reader_user = postgres:: read_secret_field ( & reader_secret, "username" ) ?;
249+ let reader_password = postgres:: read_secret_field ( & reader_secret, "password" ) ?;
250+
251+ let conn = postgres:: connect_to_restore (
252+ & ctx. client ,
253+ & namespace,
254+ & restore_name,
255+ "postgres" ,
256+ & reader_user,
257+ & reader_password,
258+ ctx. use_port_forward ( ) ,
259+ )
260+ . await ?;
261+
262+ let sizes = postgres:: list_database_sizes ( & conn. client ) . await ?;
263+ let fixes = postgres:: read_restore_fixes ( & conn. client )
264+ . await
265+ . unwrap_or_else ( |_| json ! ( { } ) ) ;
266+ Ok ( PostgresHealth { sizes, fixes } )
267+ }
268+
269+ #[ cfg( test) ]
270+ mod tests {
271+ use super :: * ;
272+
273+ #[ test]
274+ fn health_details_shape_is_snake_case ( ) {
275+ let v = build_health_details (
276+ Some ( 700 ) ,
277+ Some ( PostgresHealth {
278+ sizes : vec ! [ ( "tamanu" . into( ) , 1_872_782 ) , ( "postgres" . into( ) , 12_829 ) ] ,
279+ fixes : json ! ( { "locale" : true , "reindex" : false , "reset_wal" : false } ) ,
280+ } ) ,
281+ ) ;
282+ assert_eq ! ( v[ "restore_duration_sec" ] , json!( 700 ) ) ;
283+ assert_eq ! ( v[ "sizes" ] [ "tamanu" ] , json!( 1_872_782u64 ) ) ;
284+ assert_eq ! ( v[ "sizes" ] [ "postgres" ] , json!( 12_829u64 ) ) ;
285+ // fixes is forwarded verbatim, so new flags pass through untouched.
286+ assert_eq ! ( v[ "fixes" ] [ "locale" ] , json!( true ) ) ;
287+ assert_eq ! ( v[ "fixes" ] [ "reindex" ] , json!( false ) ) ;
288+ assert_eq ! ( v[ "fixes" ] [ "reset_wal" ] , json!( false ) ) ;
289+ }
290+
291+ #[ test]
292+ fn health_details_omits_ungathered_parts ( ) {
293+ // Failure path: no postgres connection, no createdAt.
294+ let v = build_health_details ( None , None ) ;
295+ assert_eq ! ( v, json!( { } ) ) ;
296+ // Duration known but postgres unreachable: sizes/fixes omitted.
297+ let v = build_health_details ( Some ( 42 ) , None ) ;
298+ assert_eq ! ( v, json!( { "restore_duration_sec" : 42 } ) ) ;
299+ assert ! ( v. get( "sizes" ) . is_none( ) ) ;
300+ assert ! ( v. get( "fixes" ) . is_none( ) ) ;
301+ }
302+ }
0 commit comments