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.
- aws_bootstrap/cli.py +411 -101
- aws_bootstrap/ec2.py +45 -8
- aws_bootstrap/output.py +106 -0
- aws_bootstrap/ssh.py +21 -20
- aws_bootstrap/tests/test_cli.py +377 -0
- aws_bootstrap/tests/test_ebs.py +90 -0
- aws_bootstrap/tests/test_output.py +192 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/METADATA +34 -1
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/RECORD +13 -11
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/WHEEL +0 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/entry_points.txt +0 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/top_level.txt +0 -0
aws_bootstrap/tests/test_cli.py
CHANGED
|
@@ -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": []}
|
aws_bootstrap/tests/test_ebs.py
CHANGED
|
@@ -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
|