mirror of
https://github.com/trailofbits/algo.git
synced 2026-08-22 15:42:28 +02:00
* feat: add destroy subcommand to tear down deployed servers Add `./algo destroy <server-ip>` to programmatically remove cloud resources and clean up local configs. Reads provider and server name from configs/<ip>/.config.yml, gathers credentials via existing prompts.yml, confirms with user, then dispatches to provider-specific destroy tasks. Supports all 11 cloud providers: - DigitalOcean, EC2, Lightsail (CloudFormation), Azure (resource group), GCE (instance + subsidiary resources), Hetzner, Vultr, Scaleway, OpenStack, CloudStack, Linode - Local provider: config cleanup only Also stores algo_region in .config.yml during deployment so destroy can auto-detect region. Fixes Scaleway module to allow state=absent without image/commercial_type/organization params. Adds Vultr to region-required providers and stores algo_region in Vultr prompts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add list-servers script and tests Add scripts/list_servers.py to scan configs/ for deployed server metadata and output JSON. Referenced by `./algo list-servers`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
203 lines
6.6 KiB
Python
203 lines
6.6 KiB
Python
"""Tests for the destroy playbook and provider destroy task files."""
|
|
|
|
import os
|
|
import subprocess
|
|
|
|
import yaml # type: ignore[import-untyped]
|
|
|
|
PROVIDERS = [
|
|
"digitalocean",
|
|
"ec2",
|
|
"lightsail",
|
|
"azure",
|
|
"gce",
|
|
"hetzner",
|
|
"vultr",
|
|
"scaleway",
|
|
"openstack",
|
|
"cloudstack",
|
|
"linode",
|
|
]
|
|
|
|
REGION_REQUIRED_PROVIDERS = ["ec2", "lightsail", "gce", "scaleway", "vultr"]
|
|
|
|
|
|
def test_destroy_playbook_exists():
|
|
"""destroy.yml must exist at repo root."""
|
|
assert os.path.exists("destroy.yml"), "destroy.yml not found"
|
|
|
|
|
|
def test_destroy_playbook_valid_yaml():
|
|
"""destroy.yml must be valid YAML."""
|
|
with open("destroy.yml") as f:
|
|
data = yaml.safe_load(f)
|
|
assert isinstance(data, list), "destroy.yml should be a YAML list"
|
|
assert len(data) == 1, "destroy.yml should have one play"
|
|
play = data[0]
|
|
assert play["hosts"] == "localhost"
|
|
assert play["gather_facts"] is False
|
|
|
|
|
|
def test_destroy_playbook_syntax():
|
|
"""destroy.yml must pass ansible-playbook --syntax-check."""
|
|
result = subprocess.run(
|
|
["ansible-playbook", "destroy.yml", "--syntax-check"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0, f"Syntax check failed:\n{result.stderr}"
|
|
|
|
|
|
def test_destroy_playbook_has_rescue():
|
|
"""destroy.yml must include a rescue block for error handling."""
|
|
with open("destroy.yml") as f:
|
|
content = f.read()
|
|
assert "rescue:" in content
|
|
assert "rescue.yml" in content
|
|
|
|
|
|
def test_destroy_playbook_has_confirmation():
|
|
"""destroy.yml must have a confirmation step."""
|
|
with open("destroy.yml") as f:
|
|
content = f.read()
|
|
assert "confirm" in content.lower()
|
|
|
|
|
|
def test_all_provider_destroy_files_exist():
|
|
"""Every cloud provider must have a destroy.yml task file."""
|
|
for provider in PROVIDERS:
|
|
path = f"roles/cloud-{provider}/tasks/destroy.yml"
|
|
assert os.path.exists(path), f"Missing destroy task file: {path}"
|
|
|
|
|
|
def test_all_provider_destroy_files_valid_yaml():
|
|
"""Every provider destroy.yml must be valid YAML."""
|
|
for provider in PROVIDERS:
|
|
path = f"roles/cloud-{provider}/tasks/destroy.yml"
|
|
with open(path) as f:
|
|
data = yaml.safe_load(f)
|
|
assert isinstance(data, list), f"{path} should be a YAML list"
|
|
|
|
|
|
def test_provider_destroy_uses_absent_state():
|
|
"""Each provider destroy file must use state: absent (or expunged)."""
|
|
for provider in PROVIDERS:
|
|
path = f"roles/cloud-{provider}/tasks/destroy.yml"
|
|
with open(path) as f:
|
|
content = f.read()
|
|
assert "absent" in content or "expunged" in content, f"{path} missing state: absent/expunged"
|
|
|
|
|
|
def test_ec2_destroy_uses_cloudformation():
|
|
"""EC2 destroy should delete the CloudFormation stack."""
|
|
with open("roles/cloud-ec2/tasks/destroy.yml") as f:
|
|
content = f.read()
|
|
assert "cloudformation" in content
|
|
assert "stack_name" in content
|
|
|
|
|
|
def test_lightsail_destroy_uses_cloudformation():
|
|
"""Lightsail destroy should delete the CloudFormation stack."""
|
|
with open("roles/cloud-lightsail/tasks/destroy.yml") as f:
|
|
content = f.read()
|
|
assert "cloudformation" in content
|
|
assert "stack_name" in content
|
|
|
|
|
|
def test_gce_destroy_cleans_subsidiary_resources():
|
|
"""GCE destroy should clean up firewall, static IP, and network."""
|
|
with open("roles/cloud-gce/tasks/destroy.yml") as f:
|
|
content = f.read()
|
|
assert "gcp_compute_firewall" in content
|
|
assert "gcp_compute_address" in content
|
|
assert "gcp_compute_network" in content
|
|
|
|
|
|
def test_vultr_destroy_cleans_firewall_group():
|
|
"""Vultr destroy should remove the firewall group."""
|
|
with open("roles/cloud-vultr/tasks/destroy.yml") as f:
|
|
content = f.read()
|
|
assert "firewall_group" in content
|
|
|
|
|
|
def test_openstack_destroy_cleans_security_group():
|
|
"""OpenStack destroy should remove the security group."""
|
|
with open("roles/cloud-openstack/tasks/destroy.yml") as f:
|
|
content = f.read()
|
|
assert "security_group" in content
|
|
|
|
|
|
def test_cloudstack_destroy_cleans_security_group():
|
|
"""CloudStack destroy should remove the security group."""
|
|
with open("roles/cloud-cloudstack/tasks/destroy.yml") as f:
|
|
content = f.read()
|
|
assert "security_group" in content
|
|
|
|
|
|
def test_subsidiary_cleanup_is_best_effort():
|
|
"""Subsidiary resource cleanup should use failed_when: false."""
|
|
files_with_subsidiary = {
|
|
"gce": ["gcp_compute_address", "gcp_compute_firewall", "gcp_compute_network"],
|
|
"vultr": ["firewall_group"],
|
|
"openstack": ["security_group"],
|
|
"cloudstack": ["security_group"],
|
|
}
|
|
for provider, _resources in files_with_subsidiary.items():
|
|
path = f"roles/cloud-{provider}/tasks/destroy.yml"
|
|
with open(path) as f:
|
|
content = f.read()
|
|
assert "failed_when: false" in content, f"{path} should use failed_when: false for subsidiary cleanup"
|
|
|
|
|
|
def test_linode_uses_label_not_name():
|
|
"""Linode module uses 'label' parameter, not 'name'."""
|
|
with open("roles/cloud-linode/tasks/destroy.yml") as f:
|
|
content = f.read()
|
|
assert "label:" in content, "Linode destroy should use 'label' parameter"
|
|
|
|
|
|
def test_azure_deletes_resource_group():
|
|
"""Azure destroy should delete the entire resource group."""
|
|
with open("roles/cloud-azure/tasks/destroy.yml") as f:
|
|
content = f.read()
|
|
assert "azure_rm_resourcegroup" in content
|
|
assert "force_delete_nonempty" in content
|
|
|
|
|
|
def test_algo_script_has_destroy_command():
|
|
"""The algo shell script must include the destroy subcommand."""
|
|
with open("algo") as f:
|
|
content = f.read()
|
|
assert "destroy)" in content
|
|
assert "destroy.yml" in content
|
|
|
|
|
|
def test_algo_script_destroy_requires_ip():
|
|
"""The destroy command should validate that an IP is provided."""
|
|
with open("algo") as f:
|
|
content = f.read()
|
|
assert "server_ip=$2" in content
|
|
|
|
|
|
def test_server_yml_stores_algo_region():
|
|
"""server.yml should store algo_region in .config.yml."""
|
|
with open("server.yml") as f:
|
|
content = f.read()
|
|
assert "algo_region" in content
|
|
|
|
|
|
def test_destroy_playbook_validates_region_for_required_providers():
|
|
"""destroy.yml must check region for ec2/lightsail/gce/scaleway."""
|
|
with open("destroy.yml") as f:
|
|
content = f.read()
|
|
for provider in REGION_REQUIRED_PROVIDERS:
|
|
assert provider in content, f"destroy.yml should reference {provider} in region validation"
|
|
|
|
|
|
def test_destroy_playbook_loads_server_config():
|
|
"""destroy.yml must load .config.yml from the configs directory."""
|
|
with open("destroy.yml") as f:
|
|
content = f.read()
|
|
assert ".config.yml" in content
|
|
assert "include_vars" in content
|