Skip to content

Commit 02a65f2

Browse files
committed
Release shutdown 1.1.1
1 parent 4ae999f commit 02a65f2

3 files changed

Lines changed: 177 additions & 36 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v1.1.0
1+
v1.1.1

aws/ec2-shutdown-lambda/v1/ec2-shutdown-lambda.yml

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,6 @@ Resources:
169169
instance: Dict[str, Any] = response['Reservations'][0]['Instances'][0]
170170
return instance
171171
172-
173-
174172
# Handle case where an invalid or non-existent instance ID is provided
175173
except ClientError as e:
176174
print(f"Invalid or non-existent EC2 Instance ID: {instance_id}. Error: {e}")
@@ -219,19 +217,35 @@ Resources:
219217
# If an instance is not found, an error string will be printed by get_instance().
220218
return
221219
222-
if not is_instance_running(instance):
223-
# If an instance is not running, an error string will be printed by is_instance_running() containing the current state.
224-
return
225-
220+
instance_last_start_time = instance.get('LaunchTime')
226221
tags: Dict[str, str] = {tag['Key']: tag['Value'] for tag in instance.get('Tags', [])}
227222
223+
if not is_instance_running(instance):
224+
# Delete the shutdown tag if it exists
225+
if tag_to_monitor in tags:
226+
ec2.delete_tags(Resources=[instance_id],Tags=[{'Key': tag_to_monitor}])
227+
print(f"Removed {tag_to_monitor} tag from EC2 instance {instance_id} (instance not running).")
228+
return
229+
228230
if tag_to_monitor in tags:
229231
shutdown_time: datetime = datetime.strptime(tags[tag_to_monitor], '%a, %d %b %Y %H:%M:%S GMT')
230232
231-
if datetime.now(UTC).replace(tzinfo=None) >= shutdown_time:
233+
if instance_last_start_time is None:
234+
print("LaunchTime of EC2 instance is None, skipping comparison with shutdown_time.")
235+
return
236+
237+
instance_last_start_time_naive = instance_last_start_time.replace(tzinfo=None)
238+
239+
# Stale tag: instance was restarted after the scheduled shutdown time, skip action
240+
if instance_last_start_time_naive >= shutdown_time:
241+
print(f"Shutdown tag is stale (instance started at {instance_last_start_time_naive}, shutdown was scheduled for {shutdown_time}). Skipping.")
242+
return
243+
244+
now = datetime.now(UTC).replace(tzinfo=None)
245+
if now >= shutdown_time:
232246
shutdown_instance(instance_id, tag_to_monitor)
233247
else:
234-
remaining_minutes: int = int((shutdown_time - datetime.now(UTC).replace(tzinfo=None)).total_seconds() // 60)
248+
remaining_minutes: int = int((shutdown_time - now).total_seconds() // 60)
235249
print(f"No action needed. EC2 instance {instance_id} will shut down at {shutdown_time}. Remaining minutes: {remaining_minutes}")
236250
else:
237251
if shutdown_behaviour == 'Never':

aws/ec2-shutdown-lambda/v1/tests/test-ec2-shutdown-lambda.py

Lines changed: 154 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
419546
if __name__ == "__main__":
420547
pytest.main([__file__, '-sv'])

0 commit comments

Comments
 (0)