@@ -33,7 +33,7 @@ def load_lambda_module(self, lambda_function_files):
3333 """Helper to load the Lambda module. Must be called within mock_aws context."""
3434 _ , temp_path , _ = lambda_function_files
3535 spec = importlib .util .spec_from_file_location ("lambda_module" , temp_path )
36- lambda_module = importlib .util .module_from_spec (spec )
36+ lambda_module = importlib .util .module_from_spec (spec )
3737 spec .loader .exec_module (lambda_module )
3838 return lambda_module
3939
@@ -52,7 +52,32 @@ def create_instance(self, ec2_client, image_id: str = 'ami-12345678', instance_t
5252 InstanceType = instance_type
5353 )
5454 return response ['Instances' ][0 ]['InstanceId' ]
55-
55+
56+ def _get_launch_time_naive (self , ec2_client , instance_id : str ) -> datetime :
57+ response = ec2_client .describe_instances (InstanceIds = [instance_id ])
58+ instance = response ['Reservations' ][0 ]['Instances' ][0 ]
59+ launch_time = instance .get ('LaunchTime' )
60+ if launch_time is None :
61+ raise AssertionError ("LaunchTime is None in test setup; cannot construct non-stale shutdown tag." )
62+ if launch_time .tzinfo is not None :
63+ return launch_time .replace (tzinfo = None )
64+ return launch_time
65+
66+ def _freeze_lambda_now (self , monkeypatch , lambda_module , frozen_now_naive : datetime ):
67+ """
68+ Freeze lambda_module.datetime.now(...) to return frozen_now_naive.
69+ The Lambda imports `datetime` directly (`from datetime import datetime, ...`),
70+ so we patch the module attribute `datetime`.
71+ """
72+ class FrozenDatetime (datetime ):
73+ @classmethod
74+ def now (cls , tz = None ):
75+ if tz is None :
76+ return frozen_now_naive
77+ return frozen_now_naive .replace (tzinfo = tz )
78+
79+ monkeypatch .setattr (lambda_module , "datetime" , FrozenDatetime )
80+
5681 @mock_aws
5782 def test_handler_with_non_existent_instance (self , capsys , lambda_function_files ):
5883 """Test handler behavior with non-existent instance ID"""
@@ -97,6 +122,40 @@ def test_handler_with_stopped_instance(self, capsys, lambda_function_files):
97122 assert "skipping any action" in captured .out
98123 assert "(current state:" in captured .out
99124
125+ @mock_aws
126+ def test_handler_removes_tag_from_stopped_instance (self , capsys , lambda_function_files ):
127+ """Test handler removes shutdown tag from a stopped instance that has one"""
128+ lambda_module = self .load_lambda_module (lambda_function_files )
129+
130+ # Create and stop an instance
131+ ec2 = boto3 .client ('ec2' , region_name = 'us-east-1' )
132+ instance_id = self .create_instance (ec2 )
133+
134+ # Add a shutdown tag before stopping
135+ future_time = datetime .now (UTC ).replace (tzinfo = None ) + timedelta (hours = 2 )
136+ tag_value = future_time .strftime ('%a, %d %b %Y %H:%M:%S GMT' )
137+ ec2 .create_tags (
138+ Resources = [instance_id ],
139+ Tags = [{'Key' : 'mw-autoshutdown' , 'Value' : tag_value }]
140+ )
141+
142+ ec2 .stop_instances (InstanceIds = [instance_id ])
143+
144+ # Set environment variables
145+ self .set_env_vars (instance_id , "After 2 hours" )
146+
147+ # Execute handler
148+ lambda_module .handler (event = None , context = None )
149+
150+ # Verify tag was removed
151+ captured = capsys .readouterr ()
152+ response = ec2 .describe_instances (InstanceIds = [instance_id ])
153+ instance = response ['Reservations' ][0 ]['Instances' ][0 ]
154+ tags = {tag ['Key' ]: tag ['Value' ] for tag in instance .get ('Tags' , [])}
155+
156+ assert os .environ ['TAG_TO_MONITOR' ] not in tags
157+ assert f"Removed { os .environ ['TAG_TO_MONITOR' ]} tag from EC2 instance { instance_id } " in captured .out
158+
100159 @mock_aws
101160 def test_handler_with_terminated_instance (self , capsys , lambda_function_files ):
102161 """Test handler behavior with terminated instance"""
@@ -193,6 +252,9 @@ def test_handler_respects_future_shutdown_tag(self, capsys, lambda_function_file
193252 # Create instance
194253 ec2 = boto3 .client ('ec2' , region_name = 'us-east-1' )
195254 instance_id = self .create_instance (ec2 )
255+
256+ # Set environment variables (ensures TAG_TO_MONITOR exists deterministically)
257+ self .set_env_vars (instance_id , "After 1 hour" )
196258
197259 # Add future shutdown tag
198260 future_time = datetime .now (UTC ).replace (tzinfo = None ) + timedelta (hours = 3 )
@@ -202,9 +264,6 @@ def test_handler_respects_future_shutdown_tag(self, capsys, lambda_function_file
202264 Tags = [{'Key' : os .environ ['TAG_TO_MONITOR' ], 'Value' : tag_value }]
203265 )
204266
205- # Set environment variables
206- self .set_env_vars (instance_id , "After 1 hour" )
207-
208267 # Execute handler
209268 lambda_module .handler (event = None , context = None )
210269
@@ -221,23 +280,30 @@ def test_handler_respects_future_shutdown_tag(self, capsys, lambda_function_file
221280 assert "Remaining minutes:" in captured .out
222281
223282 @mock_aws
224- def test_handler_stops_instance_when_time_reached (self , capsys , lambda_function_files ):
283+ def test_handler_stops_instance_when_time_reached (self , capsys , monkeypatch , lambda_function_files ):
225284 """Test handler stops instance when shutdown time is reached"""
226285 lambda_module = self .load_lambda_module (lambda_function_files )
227286
228287 # Create instance
229288 ec2 = boto3 .client ('ec2' , region_name = 'us-east-1' )
230289 instance_id = self .create_instance (ec2 )
231290
232- # Add past shutdown tag (should trigger shutdown)
233- past_time = "Thu, 01 Jan 1970 00:00:00 GMT"
291+ # Set environment variables FIRST (deterministic TAG_TO_MONITOR)
292+ self .set_env_vars (instance_id , "After 2 hours" )
293+
294+ # Construct a non-stale shutdown time: AFTER LaunchTime but BEFORE "now" (frozen)
295+ launch_time = self ._get_launch_time_naive (ec2 , instance_id )
296+ shutdown_time = launch_time + timedelta (seconds = 5 )
297+ tag_value = shutdown_time .strftime ('%a, %d %b %Y %H:%M:%S GMT' )
298+
234299 ec2 .create_tags (
235300 Resources = [instance_id ],
236- Tags = [{'Key' : os .environ ['TAG_TO_MONITOR' ], 'Value' : past_time }]
301+ Tags = [{'Key' : os .environ ['TAG_TO_MONITOR' ], 'Value' : tag_value }]
237302 )
238303
239- # Set environment variables
240- self .set_env_vars (instance_id , "After 2 hours" )
304+ # Freeze lambda "now" to after shutdown_time to trigger shutdown
305+ frozen_now = shutdown_time + timedelta (minutes = 1 )
306+ self ._freeze_lambda_now (monkeypatch , lambda_module , frozen_now )
241307
242308 # Execute handler
243309 lambda_module .handler (event = None , context = None )
@@ -257,7 +323,7 @@ def test_handler_stops_instance_when_time_reached(self, capsys, lambda_function_
257323 assert f"The tag { os .environ ['TAG_TO_MONITOR' ]} has been removed from EC2 instance { instance_id } " in captured .out
258324
259325 @mock_aws
260- def test_handler_with_custom_tag_name (self , capsys , lambda_function_files ):
326+ def test_handler_with_custom_tag_name (self , capsys , monkeypatch , lambda_function_files ):
261327 """Test handler with custom tag name (not the default mw-autoshutdown)"""
262328 lambda_module = self .load_lambda_module (lambda_function_files )
263329
@@ -274,20 +340,27 @@ def test_handler_with_custom_tag_name(self, capsys, lambda_function_files):
274340
275341 # Verify custom tag was added
276342 response = ec2 .describe_instances (InstanceIds = [instance_id ])
277- tags = {tag ['Key' ]: tag ['Value' ]
343+ tags = {tag ['Key' ]: tag ['Value' ]
278344 for tag in response ['Reservations' ][0 ]['Instances' ][0 ].get ('Tags' , [])}
279345
280346 assert custom_tag in tags
281347
282- # Now add past time to trigger shutdown
348+ # Now force a due shutdown tag that is NOT stale:
349+ # shutdown_time must be after LaunchTime, and we freeze now after it.
350+ launch_time = self ._get_launch_time_naive (ec2 , instance_id )
351+ shutdown_time = launch_time + timedelta (seconds = 5 )
283352 ec2 .create_tags (
284353 Resources = [instance_id ],
285- Tags = [{'Key' : custom_tag , 'Value' : 'Thu, 01 Jan 1970 00:00:00 GMT' }]
354+ Tags = [{'Key' : custom_tag , 'Value' : shutdown_time . strftime ( '%a, %d %b %Y %H:%M:%S GMT') }]
286355 )
287356
288357 # Clear output
289358 capsys .readouterr ()
290359
360+ # Freeze lambda "now" so shutdown triggers
361+ frozen_now = shutdown_time + timedelta (minutes = 1 )
362+ self ._freeze_lambda_now (monkeypatch , lambda_module , frozen_now )
363+
291364 # Execute handler again - should stop and remove custom tag
292365 lambda_module .handler (event = None , context = None )
293366
@@ -327,7 +400,7 @@ def test_handler_with_different_hour_values(self, capsys, config_string, expecte
327400
328401 # Verify tag was set with correct time
329402 response = ec2 .describe_instances (InstanceIds = [instance_id ])
330- tags = {tag ['Key' ]: tag ['Value' ]
403+ tags = {tag ['Key' ]: tag ['Value' ]
331404 for tag in response ['Reservations' ][0 ]['Instances' ][0 ].get ('Tags' , [])}
332405
333406 assert os .environ ['TAG_TO_MONITOR' ] in tags , f"Tag should be added for { config_string } "
@@ -347,27 +420,36 @@ def test_handler_with_different_hour_values(self, capsys, config_string, expecte
347420 assert "minutes" in captured .out
348421
349422 @mock_aws
350- def test_handler_preserves_other_tags (self , capsys , lambda_function_files ):
423+ def test_handler_preserves_other_tags (self , capsys , monkeypatch , lambda_function_files ):
351424 """Test handler preserves other tags when stopping instance"""
352425 lambda_module = self .load_lambda_module (lambda_function_files )
353426
354427 # Create instance with multiple tags
355428 ec2 = boto3 .client ('ec2' , region_name = 'us-east-1' )
356429 instance_id = self .create_instance (ec2 )
357430
358- # Add multiple tags including shutdown tag with past time
431+ # Set environment variables FIRST (deterministic TAG_TO_MONITOR)
432+ self .set_env_vars (instance_id , "After 2 hours" )
433+
434+ # Construct a due, non-stale shutdown time
435+ launch_time = self ._get_launch_time_naive (ec2 , instance_id )
436+ shutdown_time = launch_time + timedelta (seconds = 5 )
437+ tag_value = shutdown_time .strftime ('%a, %d %b %Y %H:%M:%S GMT' )
438+
439+ # Add multiple tags including shutdown tag
359440 ec2 .create_tags (
360441 Resources = [instance_id ],
361442 Tags = [
362- {'Key' : os .environ ['TAG_TO_MONITOR' ], 'Value' : 'Thu, 01 Jan 1970 00:00:00 GMT' },
443+ {'Key' : os .environ ['TAG_TO_MONITOR' ], 'Value' : tag_value },
363444 {'Key' : 'Name' , 'Value' : 'TestInstance' },
364445 {'Key' : 'Environment' , 'Value' : 'Testing' },
365446 {'Key' : 'Owner' , 'Value' : 'TestUser' }
366447 ]
367448 )
368449
369- # Set environment variables
370- self .set_env_vars (instance_id , "After 2 hours" )
450+ # Freeze lambda "now" to after shutdown_time
451+ frozen_now = shutdown_time + timedelta (minutes = 1 )
452+ self ._freeze_lambda_now (monkeypatch , lambda_module , frozen_now )
371453
372454 # Execute handler - should stop and remove only shutdown tag
373455 lambda_module .handler (event = None , context = None )
@@ -384,23 +466,28 @@ def test_handler_preserves_other_tags(self, capsys, lambda_function_files):
384466 assert tags .get ('Owner' ) == 'TestUser'
385467
386468 @mock_aws
387- def test_handler_respects_never_with_existing_tag (self , capsys , lambda_function_files ):
469+ def test_handler_respects_never_with_existing_tag (self , capsys , monkeypatch , lambda_function_files ):
388470 """Test that 'Never' shutdown behavior still checks existing tags"""
389471 lambda_module = self .load_lambda_module (lambda_function_files )
390472
391473 # Create instance
392474 ec2 = boto3 .client ('ec2' , region_name = 'us-east-1' )
393475 instance_id = self .create_instance (ec2 )
394476
395- # Add a past shutdown tag
396- past_time = "Thu, 01 Jan 1970 00:00:00 GMT"
477+ # Set environment variables FIRST (deterministic TAG_TO_MONITOR)
478+ self .set_env_vars (instance_id , "Never" )
479+
480+ # Add a due, non-stale shutdown tag
481+ launch_time = self ._get_launch_time_naive (ec2 , instance_id )
482+ shutdown_time = launch_time + timedelta (seconds = 5 )
397483 ec2 .create_tags (
398484 Resources = [instance_id ],
399- Tags = [{'Key' : os .environ ['TAG_TO_MONITOR' ], 'Value' : past_time }]
485+ Tags = [{'Key' : os .environ ['TAG_TO_MONITOR' ], 'Value' : shutdown_time . strftime ( '%a, %d %b %Y %H:%M:%S GMT' ) }]
400486 )
401487
402- # Set environment variables with Never - but existing tag should still be processed
403- self .set_env_vars (instance_id , "Never" )
488+ # Freeze lambda "now" so shutdown triggers
489+ frozen_now = shutdown_time + timedelta (minutes = 1 )
490+ self ._freeze_lambda_now (monkeypatch , lambda_module , frozen_now )
404491
405492 # Execute handler - should still stop because tag exists
406493 lambda_module .handler (event = None , context = None )
@@ -416,5 +503,45 @@ def test_handler_respects_never_with_existing_tag(self, capsys, lambda_function_
416503 assert os .environ ['TAG_TO_MONITOR' ] not in tags
417504 assert f"Stopping EC2 instance { instance_id } " in captured .out
418505
506+ @mock_aws
507+ def test_handler_ignores_stale_shutdown_tag (self , capsys , lambda_function_files ):
508+ """
509+ Test handler ignores a stale shutdown tag.
510+
511+ A stale tag is one where the shutdown_time is <= instance LaunchTime,
512+ which can happen if the instance was restarted after a previously scheduled shutdown time.
513+ In this case, the Lambda should take no action and must not stop the instance.
514+ """
515+ lambda_module = self .load_lambda_module (lambda_function_files )
516+
517+ # Create instance
518+ ec2 = boto3 .client ('ec2' , region_name = 'us-east-1' )
519+ instance_id = self .create_instance (ec2 )
520+
521+ # Set environment variables FIRST (deterministic TAG_TO_MONITOR)
522+ self .set_env_vars (instance_id , "After 2 hours" )
523+
524+ # Create a stale shutdown tag: shutdown_time BEFORE LaunchTime
525+ launch_time = self ._get_launch_time_naive (ec2 , instance_id )
526+ stale_shutdown_time = launch_time - timedelta (minutes = 1 )
527+ ec2 .create_tags (
528+ Resources = [instance_id ],
529+ Tags = [{
530+ 'Key' : os .environ ['TAG_TO_MONITOR' ],
531+ 'Value' : stale_shutdown_time .strftime ('%a, %d %b %Y %H:%M:%S GMT' )
532+ }]
533+ )
534+
535+ # Execute handler - should return early (no action)
536+ lambda_module .handler (event = None , context = None )
537+
538+ response = ec2 .describe_instances (InstanceIds = [instance_id ])
539+ instance = response ['Reservations' ][0 ]['Instances' ][0 ]
540+ tags = {tag ['Key' ]: tag ['Value' ] for tag in instance .get ('Tags' , [])}
541+
542+ # Instance should still be running and stale tag should not be deleted
543+ assert instance ['State' ]['Name' ] == 'running'
544+ assert os .environ ['TAG_TO_MONITOR' ] in tags
545+
419546if __name__ == "__main__" :
420547 pytest .main ([__file__ , '-sv' ])
0 commit comments