aws-bootstrap-g4dn 0.6.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,13 @@
1
1
  """Tests for CLI entry point and help output."""
2
2
 
3
3
  from __future__ import annotations
4
+ import json
4
5
  from datetime import UTC, datetime
5
6
  from pathlib import Path
6
7
  from unittest.mock import MagicMock, patch
7
8
 
8
9
  import botocore.exceptions
10
+ import yaml
9
11
  from click.testing import CliRunner
10
12
 
11
13
  from aws_bootstrap.cli import main
@@ -1254,3 +1256,378 @@ def test_cleanup_with_yes(mock_find, mock_session, mock_stale, mock_cleanup):
1254
1256
  assert "Removed aws-gpu1" in result.output
1255
1257
  assert "Cleaned up 1" in result.output
1256
1258
  mock_cleanup.assert_called_once()
1259
+
1260
+
1261
+ # ---------------------------------------------------------------------------
1262
+ # cleanup --include-ebs
1263
+ # ---------------------------------------------------------------------------
1264
+
1265
+
1266
+ @patch("aws_bootstrap.cli.find_orphan_ebs_volumes", return_value=[])
1267
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[])
1268
+ @patch("aws_bootstrap.cli.boto3.Session")
1269
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1270
+ def test_cleanup_include_ebs_no_orphans(mock_find, mock_session, mock_stale, mock_orphan):
1271
+ runner = CliRunner()
1272
+ result = runner.invoke(main, ["cleanup", "--include-ebs"])
1273
+ assert result.exit_code == 0
1274
+ assert "No stale SSH config entries or orphan EBS volumes found." in result.output
1275
+ mock_orphan.assert_called_once()
1276
+
1277
+
1278
+ @patch("aws_bootstrap.cli.find_orphan_ebs_volumes")
1279
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[])
1280
+ @patch("aws_bootstrap.cli.boto3.Session")
1281
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1282
+ def test_cleanup_include_ebs_dry_run(mock_find, mock_session, mock_stale, mock_orphan):
1283
+ mock_orphan.return_value = [
1284
+ {"VolumeId": "vol-orphan1", "Size": 50, "State": "available", "InstanceId": "i-dead1234"},
1285
+ ]
1286
+ runner = CliRunner()
1287
+ result = runner.invoke(main, ["cleanup", "--include-ebs", "--dry-run"])
1288
+ assert result.exit_code == 0
1289
+ assert "Would delete vol-orphan1" in result.output
1290
+ assert "50 GB" in result.output
1291
+
1292
+
1293
+ @patch("aws_bootstrap.cli.delete_ebs_volume")
1294
+ @patch("aws_bootstrap.cli.find_orphan_ebs_volumes")
1295
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[])
1296
+ @patch("aws_bootstrap.cli.boto3.Session")
1297
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1298
+ def test_cleanup_include_ebs_delete_with_yes(mock_find, mock_session, mock_stale, mock_orphan, mock_delete):
1299
+ mock_orphan.return_value = [
1300
+ {"VolumeId": "vol-orphan1", "Size": 50, "State": "available", "InstanceId": "i-dead1234"},
1301
+ ]
1302
+ runner = CliRunner()
1303
+ result = runner.invoke(main, ["cleanup", "--include-ebs", "--yes"])
1304
+ assert result.exit_code == 0
1305
+ assert "Deleted vol-orphan1" in result.output
1306
+ mock_delete.assert_called_once_with(mock_session.return_value.client.return_value, "vol-orphan1")
1307
+
1308
+
1309
+ @patch("aws_bootstrap.cli.delete_ebs_volume")
1310
+ @patch("aws_bootstrap.cli.find_orphan_ebs_volumes")
1311
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[])
1312
+ @patch("aws_bootstrap.cli.boto3.Session")
1313
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1314
+ def test_cleanup_include_ebs_json(mock_find, mock_session, mock_stale, mock_orphan, mock_delete):
1315
+ mock_orphan.return_value = [
1316
+ {"VolumeId": "vol-orphan1", "Size": 50, "State": "available", "InstanceId": "i-dead1234"},
1317
+ ]
1318
+ runner = CliRunner()
1319
+ result = runner.invoke(main, ["-o", "json", "cleanup", "--include-ebs", "--yes"])
1320
+ assert result.exit_code == 0
1321
+ data = json.loads(result.output)
1322
+ assert "deleted_volumes" in data
1323
+ assert len(data["deleted_volumes"]) == 1
1324
+ assert data["deleted_volumes"][0]["volume_id"] == "vol-orphan1"
1325
+ assert data["deleted_volumes"][0]["deleted"] is True
1326
+
1327
+
1328
+ @patch("aws_bootstrap.cli.find_orphan_ebs_volumes")
1329
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[])
1330
+ @patch("aws_bootstrap.cli.boto3.Session")
1331
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1332
+ def test_cleanup_include_ebs_dry_run_json(mock_find, mock_session, mock_stale, mock_orphan):
1333
+ mock_orphan.return_value = [
1334
+ {"VolumeId": "vol-orphan1", "Size": 50, "State": "available", "InstanceId": "i-dead1234"},
1335
+ ]
1336
+ runner = CliRunner()
1337
+ result = runner.invoke(main, ["-o", "json", "cleanup", "--include-ebs", "--dry-run"])
1338
+ assert result.exit_code == 0
1339
+ data = json.loads(result.output)
1340
+ assert data["dry_run"] is True
1341
+ assert "orphan_volumes" in data
1342
+ assert data["orphan_volumes"][0]["volume_id"] == "vol-orphan1"
1343
+ assert data["orphan_volumes"][0]["size_gb"] == 50
1344
+
1345
+
1346
+ @patch("aws_bootstrap.cli.find_orphan_ebs_volumes", return_value=[])
1347
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[])
1348
+ @patch("aws_bootstrap.cli.boto3.Session")
1349
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1350
+ def test_cleanup_without_include_ebs_skips_volume_check(mock_find, mock_session, mock_stale, mock_orphan):
1351
+ """Without --include-ebs, orphan volume discovery should not be called."""
1352
+ runner = CliRunner()
1353
+ result = runner.invoke(main, ["cleanup"])
1354
+ assert result.exit_code == 0
1355
+ mock_orphan.assert_not_called()
1356
+
1357
+
1358
+ # ---------------------------------------------------------------------------
1359
+ # --output structured format tests
1360
+ # ---------------------------------------------------------------------------
1361
+
1362
+
1363
+ def test_help_shows_output_option():
1364
+ runner = CliRunner()
1365
+ result = runner.invoke(main, ["--help"])
1366
+ assert result.exit_code == 0
1367
+ assert "--output" in result.output
1368
+ assert "-o" in result.output
1369
+
1370
+
1371
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance", return_value=[])
1372
+ @patch("aws_bootstrap.cli.get_ssh_host_details", return_value=None)
1373
+ @patch("aws_bootstrap.cli.list_ssh_hosts", return_value={})
1374
+ @patch("aws_bootstrap.cli.boto3.Session")
1375
+ @patch("aws_bootstrap.cli.get_spot_price")
1376
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1377
+ def test_status_output_json(mock_find, mock_spot_price, mock_session, mock_ssh_hosts, mock_details, mock_ebs):
1378
+ mock_find.return_value = [
1379
+ {
1380
+ "InstanceId": "i-abc123",
1381
+ "Name": "aws-bootstrap-g4dn.xlarge",
1382
+ "State": "running",
1383
+ "InstanceType": "g4dn.xlarge",
1384
+ "PublicIp": "1.2.3.4",
1385
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1386
+ "Lifecycle": "spot",
1387
+ "AvailabilityZone": "us-west-2a",
1388
+ }
1389
+ ]
1390
+ mock_spot_price.return_value = 0.1578
1391
+ runner = CliRunner()
1392
+ result = runner.invoke(main, ["-o", "json", "status"])
1393
+ assert result.exit_code == 0
1394
+ data = json.loads(result.output)
1395
+ assert "instances" in data
1396
+ assert len(data["instances"]) == 1
1397
+ inst = data["instances"][0]
1398
+ assert inst["instance_id"] == "i-abc123"
1399
+ assert inst["state"] == "running"
1400
+ assert inst["instance_type"] == "g4dn.xlarge"
1401
+ assert inst["public_ip"] == "1.2.3.4"
1402
+ assert inst["lifecycle"] == "spot"
1403
+ assert inst["spot_price_per_hour"] == 0.1578
1404
+ assert "uptime_seconds" in inst
1405
+ assert "estimated_cost" in inst
1406
+ # No ANSI or progress text in structured output
1407
+ assert "\x1b[" not in result.output
1408
+ assert "Found" not in result.output
1409
+
1410
+
1411
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance", return_value=[])
1412
+ @patch("aws_bootstrap.cli.get_ssh_host_details", return_value=None)
1413
+ @patch("aws_bootstrap.cli.list_ssh_hosts", return_value={})
1414
+ @patch("aws_bootstrap.cli.boto3.Session")
1415
+ @patch("aws_bootstrap.cli.get_spot_price")
1416
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1417
+ def test_status_output_yaml(mock_find, mock_spot_price, mock_session, mock_ssh_hosts, mock_details, mock_ebs):
1418
+ mock_find.return_value = [
1419
+ {
1420
+ "InstanceId": "i-abc123",
1421
+ "Name": "aws-bootstrap-g4dn.xlarge",
1422
+ "State": "running",
1423
+ "InstanceType": "g4dn.xlarge",
1424
+ "PublicIp": "1.2.3.4",
1425
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1426
+ "Lifecycle": "spot",
1427
+ "AvailabilityZone": "us-west-2a",
1428
+ }
1429
+ ]
1430
+ mock_spot_price.return_value = 0.15
1431
+ runner = CliRunner()
1432
+ result = runner.invoke(main, ["-o", "yaml", "status"])
1433
+ assert result.exit_code == 0
1434
+ data = yaml.safe_load(result.output)
1435
+ assert "instances" in data
1436
+ assert data["instances"][0]["instance_id"] == "i-abc123"
1437
+
1438
+
1439
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance", return_value=[])
1440
+ @patch("aws_bootstrap.cli.get_ssh_host_details", return_value=None)
1441
+ @patch("aws_bootstrap.cli.list_ssh_hosts", return_value={})
1442
+ @patch("aws_bootstrap.cli.boto3.Session")
1443
+ @patch("aws_bootstrap.cli.get_spot_price")
1444
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1445
+ def test_status_output_table(mock_find, mock_spot_price, mock_session, mock_ssh_hosts, mock_details, mock_ebs):
1446
+ mock_find.return_value = [
1447
+ {
1448
+ "InstanceId": "i-abc123",
1449
+ "Name": "aws-bootstrap-g4dn.xlarge",
1450
+ "State": "running",
1451
+ "InstanceType": "g4dn.xlarge",
1452
+ "PublicIp": "1.2.3.4",
1453
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1454
+ "Lifecycle": "spot",
1455
+ "AvailabilityZone": "us-west-2a",
1456
+ }
1457
+ ]
1458
+ mock_spot_price.return_value = 0.15
1459
+ runner = CliRunner()
1460
+ result = runner.invoke(main, ["-o", "table", "status"])
1461
+ assert result.exit_code == 0
1462
+ assert "Instance ID" in result.output
1463
+ assert "i-abc123" in result.output
1464
+
1465
+
1466
+ @patch("aws_bootstrap.cli.boto3.Session")
1467
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1468
+ def test_status_no_instances_json(mock_find, mock_session):
1469
+ mock_find.return_value = []
1470
+ runner = CliRunner()
1471
+ result = runner.invoke(main, ["-o", "json", "status"])
1472
+ assert result.exit_code == 0
1473
+ data = json.loads(result.output)
1474
+ assert data == {"instances": []}
1475
+
1476
+
1477
+ @patch("aws_bootstrap.cli.boto3.Session")
1478
+ @patch("aws_bootstrap.cli.get_latest_ami")
1479
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
1480
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
1481
+ def test_launch_output_json_dry_run(mock_sg, mock_import, mock_ami, mock_session, tmp_path):
1482
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
1483
+
1484
+ key_path = tmp_path / "id_ed25519.pub"
1485
+ key_path.write_text("ssh-ed25519 AAAA test@host")
1486
+
1487
+ runner = CliRunner()
1488
+ result = runner.invoke(main, ["-o", "json", "launch", "--key-path", str(key_path), "--dry-run"])
1489
+ assert result.exit_code == 0
1490
+ data = json.loads(result.output)
1491
+ assert data["dry_run"] is True
1492
+ assert data["instance_type"] == "g4dn.xlarge"
1493
+ assert data["ami_id"] == "ami-123"
1494
+ assert data["pricing"] == "spot"
1495
+ assert data["region"] == "us-west-2"
1496
+
1497
+
1498
+ @patch("aws_bootstrap.cli.remove_ssh_host", return_value="aws-gpu1")
1499
+ @patch("aws_bootstrap.cli.boto3.Session")
1500
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1501
+ @patch("aws_bootstrap.cli.terminate_tagged_instances")
1502
+ def test_terminate_output_json(mock_terminate, mock_find, mock_session, mock_remove_ssh):
1503
+ mock_find.return_value = [
1504
+ {
1505
+ "InstanceId": "i-abc123",
1506
+ "Name": "test",
1507
+ "State": "running",
1508
+ "InstanceType": "g4dn.xlarge",
1509
+ "PublicIp": "1.2.3.4",
1510
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1511
+ }
1512
+ ]
1513
+ mock_terminate.return_value = [
1514
+ {
1515
+ "InstanceId": "i-abc123",
1516
+ "PreviousState": {"Name": "running"},
1517
+ "CurrentState": {"Name": "shutting-down"},
1518
+ }
1519
+ ]
1520
+ runner = CliRunner()
1521
+ result = runner.invoke(main, ["-o", "json", "terminate", "--yes"])
1522
+ assert result.exit_code == 0
1523
+ data = json.loads(result.output)
1524
+ assert "terminated" in data
1525
+ assert len(data["terminated"]) == 1
1526
+ assert data["terminated"][0]["instance_id"] == "i-abc123"
1527
+ assert data["terminated"][0]["previous_state"] == "running"
1528
+ assert data["terminated"][0]["current_state"] == "shutting-down"
1529
+ assert data["terminated"][0]["ssh_alias_removed"] == "aws-gpu1"
1530
+
1531
+
1532
+ @patch("aws_bootstrap.cli.cleanup_stale_ssh_hosts")
1533
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[("i-dead1234", "aws-gpu1")])
1534
+ @patch("aws_bootstrap.cli.boto3.Session")
1535
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1536
+ def test_cleanup_output_json(mock_find, mock_session, mock_stale, mock_cleanup):
1537
+ mock_cleanup.return_value = [CleanupResult(instance_id="i-dead1234", alias="aws-gpu1", removed=True)]
1538
+ runner = CliRunner()
1539
+ result = runner.invoke(main, ["-o", "json", "cleanup", "--yes"])
1540
+ assert result.exit_code == 0
1541
+ data = json.loads(result.output)
1542
+ assert "cleaned" in data
1543
+ assert len(data["cleaned"]) == 1
1544
+ assert data["cleaned"][0]["instance_id"] == "i-dead1234"
1545
+ assert data["cleaned"][0]["alias"] == "aws-gpu1"
1546
+ assert data["cleaned"][0]["removed"] is True
1547
+
1548
+
1549
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[("i-dead1234", "aws-gpu1")])
1550
+ @patch("aws_bootstrap.cli.boto3.Session")
1551
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1552
+ def test_cleanup_dry_run_json(mock_find, mock_session, mock_stale):
1553
+ runner = CliRunner()
1554
+ result = runner.invoke(main, ["-o", "json", "cleanup", "--dry-run"])
1555
+ assert result.exit_code == 0
1556
+ data = json.loads(result.output)
1557
+ assert data["dry_run"] is True
1558
+ assert "stale" in data
1559
+ assert data["stale"][0]["alias"] == "aws-gpu1"
1560
+
1561
+
1562
+ @patch("aws_bootstrap.cli.boto3.Session")
1563
+ @patch("aws_bootstrap.cli.list_instance_types")
1564
+ def test_list_instance_types_json(mock_list, mock_session):
1565
+ mock_list.return_value = [
1566
+ {
1567
+ "InstanceType": "g4dn.xlarge",
1568
+ "VCpuCount": 4,
1569
+ "MemoryMiB": 16384,
1570
+ "GpuSummary": "1x T4 (16384 MiB)",
1571
+ },
1572
+ ]
1573
+ runner = CliRunner()
1574
+ result = runner.invoke(main, ["-o", "json", "list", "instance-types"])
1575
+ assert result.exit_code == 0
1576
+ data = json.loads(result.output)
1577
+ assert isinstance(data, list)
1578
+ assert data[0]["instance_type"] == "g4dn.xlarge"
1579
+ assert data[0]["vcpus"] == 4
1580
+ assert data[0]["memory_mib"] == 16384
1581
+ assert data[0]["gpu"] == "1x T4 (16384 MiB)"
1582
+
1583
+
1584
+ @patch("aws_bootstrap.cli.boto3.Session")
1585
+ @patch("aws_bootstrap.cli.list_amis")
1586
+ def test_list_amis_json(mock_list, mock_session):
1587
+ mock_list.return_value = [
1588
+ {
1589
+ "ImageId": "ami-abc123",
1590
+ "Name": "Deep Learning AMI v42",
1591
+ "CreationDate": "2025-06-01T00:00:00Z",
1592
+ "Architecture": "x86_64",
1593
+ },
1594
+ ]
1595
+ runner = CliRunner()
1596
+ result = runner.invoke(main, ["-o", "json", "list", "amis"])
1597
+ assert result.exit_code == 0
1598
+ data = json.loads(result.output)
1599
+ assert isinstance(data, list)
1600
+ assert data[0]["image_id"] == "ami-abc123"
1601
+ assert data[0]["name"] == "Deep Learning AMI v42"
1602
+ assert data[0]["creation_date"] == "2025-06-01"
1603
+
1604
+
1605
+ @patch("aws_bootstrap.cli.boto3.Session")
1606
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1607
+ def test_terminate_json_requires_yes(mock_find, mock_session):
1608
+ """Structured output without --yes should error."""
1609
+ mock_find.return_value = [
1610
+ {
1611
+ "InstanceId": "i-abc123",
1612
+ "Name": "test",
1613
+ "State": "running",
1614
+ "InstanceType": "g4dn.xlarge",
1615
+ "PublicIp": "1.2.3.4",
1616
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1617
+ }
1618
+ ]
1619
+ runner = CliRunner()
1620
+ result = runner.invoke(main, ["-o", "json", "terminate"])
1621
+ assert result.exit_code != 0
1622
+ assert "--yes is required" in result.output
1623
+
1624
+
1625
+ @patch("aws_bootstrap.cli.boto3.Session")
1626
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1627
+ def test_terminate_no_instances_json(mock_find, mock_session):
1628
+ mock_find.return_value = []
1629
+ runner = CliRunner()
1630
+ result = runner.invoke(main, ["-o", "json", "terminate", "--yes"])
1631
+ assert result.exit_code == 0
1632
+ data = json.loads(result.output)
1633
+ assert data == {"terminated": []}
@@ -14,6 +14,7 @@ from aws_bootstrap.ec2 import (
14
14
  delete_ebs_volume,
15
15
  detach_ebs_volume,
16
16
  find_ebs_volumes_for_instance,
17
+ find_orphan_ebs_volumes,
17
18
  validate_ebs_volume,
18
19
  )
19
20
 
@@ -243,3 +244,92 @@ def test_find_ebs_volumes_client_error_returns_empty():
243
244
  )
244
245
  volumes = find_ebs_volumes_for_instance(ec2, "i-test", "aws-bootstrap-g4dn")
245
246
  assert volumes == []
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # find_orphan_ebs_volumes
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def test_find_orphan_ebs_volumes_returns_orphans():
255
+ """Volumes whose linked instance is not live should be returned."""
256
+ ec2 = MagicMock()
257
+ ec2.describe_volumes.return_value = {
258
+ "Volumes": [
259
+ {
260
+ "VolumeId": "vol-orphan1",
261
+ "Size": 50,
262
+ "State": "available",
263
+ "Tags": [
264
+ {"Key": "created-by", "Value": "aws-bootstrap-g4dn"},
265
+ {"Key": "aws-bootstrap-instance", "Value": "i-dead1234"},
266
+ ],
267
+ }
268
+ ]
269
+ }
270
+ orphans = find_orphan_ebs_volumes(ec2, "aws-bootstrap-g4dn", live_instance_ids=set())
271
+ assert len(orphans) == 1
272
+ assert orphans[0]["VolumeId"] == "vol-orphan1"
273
+ assert orphans[0]["InstanceId"] == "i-dead1234"
274
+ assert orphans[0]["Size"] == 50
275
+
276
+ # Verify the API was called with status=available filter
277
+ filters = ec2.describe_volumes.call_args[1]["Filters"]
278
+ filter_names = {f["Name"] for f in filters}
279
+ assert "status" in filter_names
280
+
281
+
282
+ def test_find_orphan_ebs_volumes_excludes_live_instances():
283
+ """Volumes linked to a live instance should NOT be returned."""
284
+ ec2 = MagicMock()
285
+ ec2.describe_volumes.return_value = {
286
+ "Volumes": [
287
+ {
288
+ "VolumeId": "vol-attached",
289
+ "Size": 96,
290
+ "State": "available",
291
+ "Tags": [
292
+ {"Key": "created-by", "Value": "aws-bootstrap-g4dn"},
293
+ {"Key": "aws-bootstrap-instance", "Value": "i-live123"},
294
+ ],
295
+ }
296
+ ]
297
+ }
298
+ orphans = find_orphan_ebs_volumes(ec2, "aws-bootstrap-g4dn", live_instance_ids={"i-live123"})
299
+ assert orphans == []
300
+
301
+
302
+ def test_find_orphan_ebs_volumes_empty():
303
+ """No volumes at all should return empty list."""
304
+ ec2 = MagicMock()
305
+ ec2.describe_volumes.return_value = {"Volumes": []}
306
+ orphans = find_orphan_ebs_volumes(ec2, "aws-bootstrap-g4dn", live_instance_ids=set())
307
+ assert orphans == []
308
+
309
+
310
+ def test_find_orphan_ebs_volumes_skips_no_instance_tag():
311
+ """Volumes without aws-bootstrap-instance tag should be skipped."""
312
+ ec2 = MagicMock()
313
+ ec2.describe_volumes.return_value = {
314
+ "Volumes": [
315
+ {
316
+ "VolumeId": "vol-notag",
317
+ "Size": 10,
318
+ "State": "available",
319
+ "Tags": [{"Key": "created-by", "Value": "aws-bootstrap-g4dn"}],
320
+ }
321
+ ]
322
+ }
323
+ orphans = find_orphan_ebs_volumes(ec2, "aws-bootstrap-g4dn", live_instance_ids=set())
324
+ assert orphans == []
325
+
326
+
327
+ def test_find_orphan_ebs_volumes_client_error():
328
+ """ClientError should return empty list."""
329
+ ec2 = MagicMock()
330
+ ec2.describe_volumes.side_effect = botocore.exceptions.ClientError(
331
+ {"Error": {"Code": "UnauthorizedOperation", "Message": "no access"}},
332
+ "DescribeVolumes",
333
+ )
334
+ orphans = find_orphan_ebs_volumes(ec2, "aws-bootstrap-g4dn", live_instance_ids=set())
335
+ assert orphans == []
@@ -0,0 +1,192 @@
1
+ """Tests for the output formatting module."""
2
+
3
+ from __future__ import annotations
4
+ import json
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import yaml
10
+ from click.testing import CliRunner
11
+
12
+ from aws_bootstrap.output import OutputFormat, echo, emit, is_text
13
+
14
+
15
+ def test_output_format_enum_values():
16
+ assert OutputFormat.TEXT.value == "text"
17
+ assert OutputFormat.JSON.value == "json"
18
+ assert OutputFormat.YAML.value == "yaml"
19
+ assert OutputFormat.TABLE.value == "table"
20
+
21
+
22
+ def test_serialize_datetime():
23
+ """datetime objects should serialize to ISO format strings."""
24
+ dt = datetime(2025, 6, 15, 12, 30, 0, tzinfo=UTC)
25
+
26
+ @click.command()
27
+ @click.pass_context
28
+ def cli(ctx):
29
+ ctx.ensure_object(dict)
30
+ ctx.obj["output_format"] = OutputFormat.JSON
31
+ emit({"timestamp": dt}, ctx=ctx)
32
+
33
+ runner = CliRunner()
34
+ result = runner.invoke(cli, [])
35
+ assert result.exit_code == 0
36
+ data = json.loads(result.output)
37
+ assert data["timestamp"] == "2025-06-15T12:30:00+00:00"
38
+
39
+
40
+ def test_serialize_path():
41
+ """Path objects should serialize to strings."""
42
+ p = Path("/home/user/.ssh/id_ed25519")
43
+
44
+ @click.command()
45
+ @click.pass_context
46
+ def cli(ctx):
47
+ ctx.ensure_object(dict)
48
+ ctx.obj["output_format"] = OutputFormat.JSON
49
+ emit({"path": p}, ctx=ctx)
50
+
51
+ runner = CliRunner()
52
+ result = runner.invoke(cli, [])
53
+ assert result.exit_code == 0
54
+ data = json.loads(result.output)
55
+ assert data["path"] == "/home/user/.ssh/id_ed25519"
56
+
57
+
58
+ def test_emit_json():
59
+ """emit() should produce valid JSON in JSON mode."""
60
+
61
+ @click.command()
62
+ @click.pass_context
63
+ def cli(ctx):
64
+ ctx.ensure_object(dict)
65
+ ctx.obj["output_format"] = OutputFormat.JSON
66
+ emit({"key": "value", "count": 42}, ctx=ctx)
67
+
68
+ runner = CliRunner()
69
+ result = runner.invoke(cli, [])
70
+ assert result.exit_code == 0
71
+ data = json.loads(result.output)
72
+ assert data == {"key": "value", "count": 42}
73
+
74
+
75
+ def test_emit_yaml():
76
+ """emit() should produce valid YAML in YAML mode."""
77
+
78
+ @click.command()
79
+ @click.pass_context
80
+ def cli(ctx):
81
+ ctx.ensure_object(dict)
82
+ ctx.obj["output_format"] = OutputFormat.YAML
83
+ emit({"key": "value", "count": 42}, ctx=ctx)
84
+
85
+ runner = CliRunner()
86
+ result = runner.invoke(cli, [])
87
+ assert result.exit_code == 0
88
+ data = yaml.safe_load(result.output)
89
+ assert data == {"key": "value", "count": 42}
90
+
91
+
92
+ def test_emit_table_list():
93
+ """emit() should render a list of dicts as a table with headers."""
94
+
95
+ @click.command()
96
+ @click.pass_context
97
+ def cli(ctx):
98
+ ctx.ensure_object(dict)
99
+ ctx.obj["output_format"] = OutputFormat.TABLE
100
+ emit(
101
+ [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}],
102
+ headers={"name": "Name", "age": "Age"},
103
+ ctx=ctx,
104
+ )
105
+
106
+ runner = CliRunner()
107
+ result = runner.invoke(cli, [])
108
+ assert result.exit_code == 0
109
+ assert "Name" in result.output
110
+ assert "Age" in result.output
111
+ assert "Alice" in result.output
112
+ assert "Bob" in result.output
113
+
114
+
115
+ def test_emit_table_dict():
116
+ """emit() should render a single dict as key-value pairs."""
117
+
118
+ @click.command()
119
+ @click.pass_context
120
+ def cli(ctx):
121
+ ctx.ensure_object(dict)
122
+ ctx.obj["output_format"] = OutputFormat.TABLE
123
+ emit({"instance_id": "i-abc123", "state": "running"}, ctx=ctx)
124
+
125
+ runner = CliRunner()
126
+ result = runner.invoke(cli, [])
127
+ assert result.exit_code == 0
128
+ assert "instance_id" in result.output
129
+ assert "i-abc123" in result.output
130
+ assert "running" in result.output
131
+
132
+
133
+ def test_echo_suppressed_in_json_mode():
134
+ """echo() should produce no output when format is JSON."""
135
+
136
+ @click.command()
137
+ @click.pass_context
138
+ def cli(ctx):
139
+ ctx.ensure_object(dict)
140
+ ctx.obj["output_format"] = OutputFormat.JSON
141
+ echo("This should not appear")
142
+
143
+ runner = CliRunner()
144
+ result = runner.invoke(cli, [])
145
+ assert result.exit_code == 0
146
+ assert result.output == ""
147
+
148
+
149
+ def test_echo_emits_in_text_mode():
150
+ """echo() should work normally in text mode."""
151
+
152
+ @click.command()
153
+ @click.pass_context
154
+ def cli(ctx):
155
+ ctx.ensure_object(dict)
156
+ ctx.obj["output_format"] = OutputFormat.TEXT
157
+ echo("Hello world")
158
+
159
+ runner = CliRunner()
160
+ result = runner.invoke(cli, [])
161
+ assert result.exit_code == 0
162
+ assert "Hello world" in result.output
163
+
164
+
165
+ def test_is_text_default():
166
+ """is_text() should return True when no context is set (default behavior)."""
167
+
168
+ @click.command()
169
+ @click.pass_context
170
+ def cli(ctx):
171
+ ctx.ensure_object(dict)
172
+ ctx.obj["output_format"] = OutputFormat.TEXT
173
+ assert is_text(ctx) is True
174
+
175
+ runner = CliRunner()
176
+ result = runner.invoke(cli, [])
177
+ assert result.exit_code == 0
178
+
179
+
180
+ def test_is_text_false_for_json():
181
+ """is_text() should return False when format is JSON."""
182
+
183
+ @click.command()
184
+ @click.pass_context
185
+ def cli(ctx):
186
+ ctx.ensure_object(dict)
187
+ ctx.obj["output_format"] = OutputFormat.JSON
188
+ assert is_text(ctx) is False
189
+
190
+ runner = CliRunner()
191
+ result = runner.invoke(cli, [])
192
+ assert result.exit_code == 0