1212
1313use std:: sync:: Arc ;
1414
15+ use fakecloud_persistence:: SnapshotHook ;
16+
1517use crate :: AutoScalingService ;
1618use crate :: SharedAutoScalingState ;
1719
20+ /// Snapshot hooks the detached CFN capacity reconcile fires after it mutates
21+ /// shared state, so the launched ASG instances (`autoscaling`) and their
22+ /// backing EC2 records (`ec2`) survive a restart. `None` fields make the
23+ /// corresponding persist a no-op (memory mode / unit tests).
24+ #[ derive( Clone , Default ) ]
25+ pub struct CfnReconcilePersistHooks {
26+ pub autoscaling : Option < SnapshotHook > ,
27+ pub ec2 : Option < SnapshotHook > ,
28+ }
29+
1830/// Reconcile a CFN-provisioned Auto Scaling Group to its desired capacity by
1931/// launching REAL EC2 instances. No-op if the group is gone (e.g. the stack was
2032/// deleted before reconciliation ran). Intended to be `tokio::spawn`ed by the
@@ -29,9 +41,21 @@ pub async fn cfn_reconcile_capacity(
2941 group_name : String ,
3042 account_id : String ,
3143 region : String ,
44+ persist : CfnReconcilePersistHooks ,
3245) {
33- let svc = AutoScalingService :: new ( asg_state) . with_ec2 ( ec2_state, ec2_runtime) ;
46+ // The EC2 hook is set on the service so `apply_capacity` persists the REAL
47+ // EC2 instances it launches; the autoscaling hook is fired below to persist
48+ // the group's `instances` list. Both are required because this reconcile
49+ // runs DETACHED after `CreateStack`/`UpdateStack` has already serialized the
50+ // group with `instances=[]`; without them the launched instances (and their
51+ // EC2 records) vanish on restart (bug-hunt restart-dataloss).
52+ let svc = AutoScalingService :: new ( asg_state)
53+ . with_ec2 ( ec2_state, ec2_runtime)
54+ . with_ec2_snapshot_hook ( persist. ec2 ) ;
3455 svc. reconcile_group ( & account_id, & group_name, & region) . await ;
56+ if let Some ( hook) = persist. autoscaling {
57+ hook ( ) . await ;
58+ }
3559}
3660
3761/// Terminate the REAL EC2 instances launched for a CFN-provisioned Auto Scaling
@@ -54,3 +78,146 @@ pub async fn cfn_terminate_instances(
5478 svc. cfn_terminate_instances ( & account_id, & region, & instance_ids)
5579 . await ;
5680}
81+
82+ #[ cfg( test) ]
83+ mod tests {
84+ use super :: * ;
85+ use crate :: state:: { AutoScalingAccounts , AutoScalingGroup , AutoScalingSnapshot } ;
86+ use fakecloud_persistence:: SnapshotStore ;
87+ use parking_lot:: { Mutex , RwLock } ;
88+
89+ /// Shared cell holding the bytes a capturing snapshot store last saved.
90+ type Captured = Arc < Mutex < Option < Vec < u8 > > > > ;
91+
92+ // A `SnapshotStore` that captures the last-saved bytes in memory so a test
93+ // can assert what the persist hook serialized (no tempfile needed).
94+ struct CapturingStore ( Captured ) ;
95+
96+ impl SnapshotStore for CapturingStore {
97+ fn load ( & self ) -> std:: io:: Result < Option < Vec < u8 > > > {
98+ Ok ( self . 0 . lock ( ) . clone ( ) )
99+ }
100+ fn save ( & self , bytes : & [ u8 ] ) -> std:: io:: Result < ( ) > {
101+ * self . 0 . lock ( ) = Some ( bytes. to_vec ( ) ) ;
102+ Ok ( ( ) )
103+ }
104+ }
105+
106+ fn capturing ( ) -> ( Captured , Arc < dyn SnapshotStore > ) {
107+ let cell: Captured = Arc :: new ( Mutex :: new ( None ) ) ;
108+ ( cell. clone ( ) , Arc :: new ( CapturingStore ( cell) ) )
109+ }
110+
111+ fn group_desired ( name : & str , desired : i64 ) -> AutoScalingGroup {
112+ AutoScalingGroup {
113+ name : name. to_string ( ) ,
114+ arn : format ! (
115+ "arn:aws:autoscaling:us-east-1:123456789012:autoScalingGroup:x:autoScalingGroupName/{name}"
116+ ) ,
117+ launch_configuration_name : None ,
118+ launch_template : None ,
119+ min_size : 0 ,
120+ max_size : desired,
121+ desired_capacity : desired,
122+ default_cooldown : 300 ,
123+ availability_zones : vec ! [ "us-east-1a" . to_string( ) ] ,
124+ vpc_zone_identifier : None ,
125+ health_check_type : "EC2" . to_string ( ) ,
126+ health_check_grace_period : 0 ,
127+ target_group_arns : Vec :: new ( ) ,
128+ load_balancer_names : Vec :: new ( ) ,
129+ new_instances_protected_from_scale_in : false ,
130+ created_time : chrono:: Utc :: now ( ) ,
131+ instances : Vec :: new ( ) ,
132+ tags : Vec :: new ( ) ,
133+ status : None ,
134+ service_linked_role_arn : String :: new ( ) ,
135+ }
136+ }
137+
138+ // bug-hunt restart-dataloss #1/#2: a CFN-provisioned ASG is inserted with
139+ // `instances=[]` and reconciled to capacity by a DETACHED task. That task
140+ // must persist BOTH the launched ASG instances (autoscaling snapshot) and
141+ // their backing EC2 records (EC2 snapshot); otherwise the group reloads
142+ // empty and the EC2 containers leak with no owning state on restart.
143+ #[ tokio:: test]
144+ async fn cfn_reconcile_persists_asg_and_ec2_instances ( ) {
145+ let account = "123456789012" ;
146+ let asg_state: SharedAutoScalingState = Arc :: new ( RwLock :: new ( AutoScalingAccounts :: new ( ) ) ) ;
147+ asg_state
148+ . write ( )
149+ . get_or_create ( account)
150+ . groups
151+ . insert ( "g" . to_string ( ) , group_desired ( "g" , 2 ) ) ;
152+
153+ let ec2_state: fakecloud_ec2:: SharedEc2State = Arc :: new ( RwLock :: new (
154+ fakecloud_core:: multi_account:: MultiAccountState :: new ( account, "us-east-1" , "" ) ,
155+ ) ) ;
156+
157+ // Persist hooks over capturing stores, built exactly as the server wires
158+ // the autoscaling + EC2 snapshot hooks.
159+ let ( asg_cell, asg_store) = capturing ( ) ;
160+ let asg_hook = AutoScalingService :: new ( asg_state. clone ( ) )
161+ . with_snapshot_store ( asg_store)
162+ . snapshot_hook ( ) ;
163+ assert ! ( asg_hook. is_some( ) , "autoscaling hook must be built" ) ;
164+
165+ let ( ec2_cell, ec2_store) = capturing ( ) ;
166+ let ec2_hook = fakecloud_ec2:: Ec2Service :: with_state ( ec2_state. clone ( ) )
167+ . with_snapshot_store ( ec2_store)
168+ . snapshot_hook ( ) ;
169+ assert ! ( ec2_hook. is_some( ) , "ec2 hook must be built" ) ;
170+
171+ // No EC2 runtime -> metadata-only instances (CI path), still real EC2
172+ // records in `ec2_state`.
173+ cfn_reconcile_capacity (
174+ asg_state. clone ( ) ,
175+ ec2_state. clone ( ) ,
176+ None ,
177+ "g" . to_string ( ) ,
178+ account. to_string ( ) ,
179+ "us-east-1" . to_string ( ) ,
180+ CfnReconcilePersistHooks {
181+ autoscaling : asg_hook,
182+ ec2 : ec2_hook,
183+ } ,
184+ )
185+ . await ;
186+
187+ // In-memory: the group reached desired capacity.
188+ assert_eq ! (
189+ asg_state. read( ) . accounts[ account] . groups[ "g" ]
190+ . instances
191+ . len( ) ,
192+ 2 ,
193+ "reconcile must launch 2 instances"
194+ ) ;
195+
196+ // #1: the autoscaling snapshot the detached reconcile fired contains the
197+ // launched instances (not the empty list CreateStack serialized).
198+ let asg_bytes = asg_cell
199+ . lock ( )
200+ . clone ( )
201+ . expect ( "autoscaling snapshot written" ) ;
202+ let asg_snap: AutoScalingSnapshot = serde_json:: from_slice ( & asg_bytes) . unwrap ( ) ;
203+ let persisted = asg_snap. accounts . expect ( "multi-account snapshot" ) ;
204+ assert_eq ! (
205+ persisted. accounts[ account] . groups[ "g" ] . instances. len( ) ,
206+ 2 ,
207+ "launched ASG instances must survive restart"
208+ ) ;
209+
210+ // #2: the EC2 snapshot the reconcile fired contains the backing records,
211+ // so EC2 boot-recovery has a row to re-drive (no container leak).
212+ let ec2_bytes = ec2_cell. lock ( ) . clone ( ) . expect ( "ec2 snapshot written" ) ;
213+ let ec2_snap: fakecloud_ec2:: Ec2Snapshot = serde_json:: from_slice ( & ec2_bytes) . unwrap ( ) ;
214+ let ec2_accounts = ec2_snap. accounts . expect ( "ec2 multi-account snapshot" ) ;
215+ let in_memory = ec2_state. read ( ) . default_ref ( ) . instances . len ( ) ;
216+ assert_eq ! ( in_memory, 2 , "reconcile must create 2 EC2 records" ) ;
217+ assert_eq ! (
218+ ec2_accounts. default_ref( ) . instances. len( ) ,
219+ in_memory,
220+ "ASG-launched EC2 records must be persisted (bug #2)"
221+ ) ;
222+ }
223+ }
0 commit comments