@@ -28,15 +28,19 @@ use five_spot::health::{start_health_server, HealthState};
2828use five_spot:: labels:: LABEL_SCHEDULED_MACHINE ;
2929use five_spot:: metrics:: init_controller_info;
3030use five_spot:: reconcilers:: {
31- error_policy, machine_to_scheduled_machine, node_to_scheduled_machines ,
31+ error_policy, machine_to_scheduled_machine, node_to_scheduled_machines_via_machine ,
3232 reconcile_scheduled_machine, Context ,
3333} ;
34- use futures:: StreamExt ;
34+ use futures:: { StreamExt , TryStreamExt } ;
3535use k8s_openapi:: api:: core:: v1:: Node ;
3636use kube:: {
3737 api:: { ApiResource , GroupVersionKind , ListParams } ,
3838 core:: DynamicObject ,
39- runtime:: { watcher:: Config , Controller } ,
39+ runtime:: {
40+ reflector,
41+ watcher:: { self , Config } ,
42+ Controller , WatchStreamExt ,
43+ } ,
4044 Api , Client ,
4145} ;
4246use tracing:: { error, info, warn} ;
@@ -276,19 +280,51 @@ async fn main() -> Result<()> {
276280 info ! ( "Starting controller for ScheduledMachine resources" ) ;
277281
278282 // Secondary watches — event-driven reactivity without polling.
279- // 1. CAPI Machine (dynamic GVK) — filtered by the scheduled-machine label we
280- // already stamp on every Machine we create. Reverse-mapped via that label.
281- // 2. Kubernetes Node — name-matched against every SM's status.nodeRef.name
282- // using a snapshot of the controller's own primary-resource Store.
283+ //
284+ // 1. CAPI Machine (dynamic GVK), filtered by the scheduled-machine label
285+ // we stamp on every Machine we create. Reverse-mapped via that label
286+ // (`machine_to_scheduled_machine`). The `Controller::watches_with`
287+ // call below maintains its own internal reflector for this watch.
288+ //
289+ // 2. Kubernetes Node, mapped to owning ScheduledMachines via the
290+ // canonical CAPI Machine ownership chain
291+ // (`node_to_scheduled_machines_via_machine`). This requires its own
292+ // Machine reflector store, separate from #1's internal one — kube-rs
293+ // `watches_with` does not expose its internal store. The doubled
294+ // watch traffic is minor: same kind, same label selector. Routing
295+ // via the canonical Machine instead of the tenant-writable
296+ // `SM.status.nodeRef.name` closes the watch-amplification vector
297+ // documented in Phase 3 of the 2026-04-25 security audit roadmap.
283298 let machine_ar = ApiResource :: from_gvk_with_plural (
284299 & GroupVersionKind :: gvk ( CAPI_GROUP , CAPI_MACHINE_API_VERSION , "Machine" ) ,
285300 CAPI_RESOURCE_MACHINES ,
286301 ) ;
287302 let machines_api: Api < DynamicObject > = Api :: all_with ( client. clone ( ) , & machine_ar) ;
288303 let nodes_api: Api < Node > = Api :: all ( client. clone ( ) ) ;
289304
305+ // Standalone Machine reflector for the Node→SM mapper. We build its
306+ // store here, spawn the watcher in the background, and clone the
307+ // reader handle into the Node closure below. The store starts empty
308+ // and fills as Machine watch events arrive — Node events that fire
309+ // during the warmup window will see an empty store and enqueue
310+ // nothing, which is correct (no Machine yet ⇒ no SM owns this Node).
311+ let machine_writer: reflector:: store:: Writer < DynamicObject > =
312+ reflector:: store:: Writer :: new ( machine_ar. clone ( ) ) ;
313+ let machine_store = machine_writer. as_reader ( ) ;
314+ let machine_reflector_machines_api = machines_api. clone ( ) ;
315+ tokio:: spawn ( async move {
316+ let stream = watcher:: watcher (
317+ machine_reflector_machines_api,
318+ watcher:: Config :: default ( ) . labels ( LABEL_SCHEDULED_MACHINE ) ,
319+ )
320+ . reflect ( machine_writer)
321+ . applied_objects ( ) ;
322+ if let Err ( e) = stream. try_for_each ( |_| async { Ok ( ( ) ) } ) . await {
323+ error ! ( error = %e, "Machine reflector for Node→SM mapping terminated" ) ;
324+ }
325+ } ) ;
326+
290327 let controller = Controller :: new ( scheduled_machines, Config :: default ( ) ) ;
291- let sm_store = controller. store ( ) ;
292328
293329 controller
294330 . watches_with (
@@ -298,8 +334,14 @@ async fn main() -> Result<()> {
298334 |machine : DynamicObject | machine_to_scheduled_machine ( & machine) ,
299335 )
300336 . watches ( nodes_api, Config :: default ( ) , move |node : Node | {
301- let snapshot = sm_store. state ( ) ;
302- node_to_scheduled_machines ( & node, snapshot. iter ( ) . map ( std:: convert:: AsRef :: as_ref) )
337+ // Snapshot the standalone Machine reflector's store. Each
338+ // entry is an Arc<DynamicObject>; deref through AsRef to feed
339+ // the pure mapper.
340+ let machines_snapshot = machine_store. state ( ) ;
341+ node_to_scheduled_machines_via_machine (
342+ & node,
343+ machines_snapshot. iter ( ) . map ( std:: convert:: AsRef :: as_ref) ,
344+ )
303345 } )
304346 . shutdown_on_signal ( )
305347 . run ( reconcile_scheduled_machine, error_policy, context)
0 commit comments