From 5a722681170c21276be494034412db206da1f0a6 Mon Sep 17 00:00:00 2001 From: Dan Guido Date: Sun, 8 Feb 2026 13:24:14 -0500 Subject: [PATCH] feat: add destroy subcommand to tear down deployed servers (#14965) * feat: add destroy subcommand to tear down deployed servers Add `./algo destroy ` to programmatically remove cloud resources and clean up local configs. Reads provider and server name from configs//.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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- algo | 20 ++ destroy.yml | 144 +++++++++++++++ library/scaleway_compute.py | 15 +- roles/cloud-azure/tasks/destroy.yml | 10 + roles/cloud-cloudstack/tasks/destroy.yml | 17 ++ roles/cloud-digitalocean/tasks/destroy.yml | 7 + roles/cloud-ec2/tasks/destroy.yml | 14 ++ roles/cloud-gce/tasks/destroy.yml | 55 ++++++ roles/cloud-hetzner/tasks/destroy.yml | 6 + roles/cloud-lightsail/tasks/destroy.yml | 13 ++ roles/cloud-linode/tasks/destroy.yml | 6 + roles/cloud-openstack/tasks/destroy.yml | 11 ++ roles/cloud-scaleway/tasks/destroy.yml | 9 + roles/cloud-vultr/tasks/destroy.yml | 15 ++ roles/cloud-vultr/tasks/prompts.yml | 5 + scripts/list_servers.py | 34 ++++ server.yml | 1 + tests/unit/test_destroy.py | 202 +++++++++++++++++++++ tests/unit/test_list_servers.py | 95 ++++++++++ 19 files changed, 675 insertions(+), 4 deletions(-) create mode 100644 destroy.yml create mode 100644 roles/cloud-azure/tasks/destroy.yml create mode 100644 roles/cloud-cloudstack/tasks/destroy.yml create mode 100644 roles/cloud-digitalocean/tasks/destroy.yml create mode 100644 roles/cloud-ec2/tasks/destroy.yml create mode 100644 roles/cloud-gce/tasks/destroy.yml create mode 100644 roles/cloud-hetzner/tasks/destroy.yml create mode 100644 roles/cloud-lightsail/tasks/destroy.yml create mode 100644 roles/cloud-linode/tasks/destroy.yml create mode 100644 roles/cloud-openstack/tasks/destroy.yml create mode 100644 roles/cloud-scaleway/tasks/destroy.yml create mode 100644 roles/cloud-vultr/tasks/destroy.yml create mode 100644 scripts/list_servers.py create mode 100644 tests/unit/test_destroy.py create mode 100644 tests/unit/test_list_servers.py diff --git a/algo b/algo index a69287a6..10576f18 100755 --- a/algo +++ b/algo @@ -168,6 +168,8 @@ case "$1" in echo "Commands:" echo " (default) Deploy a new VPN server" echo " update-users Add or remove users on an existing server" + echo " destroy Destroy a deployed server and clean up configs" + echo " list-servers List deployed servers (JSON output)" echo "" echo "Configuration:" echo " Edit config.cfg to set users, DNS, and VPN options before deploying." @@ -186,6 +188,24 @@ case "$1" in ;; update-users) uv run ansible-playbook users.yml "${@:2}" -t update-users ;; + destroy) + if [ -z "${2:-}" ] || [[ "$2" == -* ]]; then + echo "Usage: ./algo destroy [ANSIBLE_OPTIONS]" + echo "" + echo "Destroy a deployed Algo VPN server and remove local configs." + echo "" + echo "Arguments:" + echo " server-ip IP address of the server to destroy" + echo "" + echo "Examples:" + echo " ./algo destroy 188.166.66.185" + echo " ./algo destroy 52.1.2.3 -e \"region=us-east-1\"" + echo " ./algo destroy 188.166.66.185 -e \"confirm_destroy=true\"" + exit 1 + fi + uv run ansible-playbook destroy.yml -e "server_ip=$2" "${@:3}" ;; + list-servers) + uv run python3 scripts/list_servers.py "${@:2}" ;; *) uv run ansible-playbook main.yml "${@}" ;; esac diff --git a/destroy.yml b/destroy.yml new file mode 100644 index 00000000..2d961eea --- /dev/null +++ b/destroy.yml @@ -0,0 +1,144 @@ +--- +- name: Destroy an Algo VPN server + hosts: localhost + gather_facts: false + become: false + vars_files: + - config.cfg + + tasks: + - block: + - name: Validate server_ip is provided + assert: + that: server_ip is defined and server_ip | length > 0 + fail_msg: | + server_ip is required. Usage: + ./algo destroy + ansible-playbook destroy.yml -e "server_ip=YOUR_SERVER_IP" + + - name: Check that server config exists + stat: + path: "configs/{{ server_ip }}/.config.yml" + register: _server_config + + - name: Fail if server config not found + fail: + msg: | + No config found at configs/{{ server_ip }}/.config.yml + + This server may not have been deployed by Algo, or + its configs were already removed. + + Known servers: + ls configs/*/ + when: not _server_config.stat.exists + + - name: Load server configuration + include_vars: + file: "configs/{{ server_ip }}/.config.yml" + name: _server_cfg + + - name: Set provider and server name from config + set_fact: + algo_provider: "{{ _server_cfg.algo_provider }}" + algo_server_name: "{{ _server_cfg.algo_server_name }}" + + - name: Validate required config values + assert: + that: + - algo_provider is defined and algo_provider | length > 0 + - algo_server_name is defined and algo_server_name | length > 0 + fail_msg: | + Server config is missing algo_provider or algo_server_name. + Check configs/{{ server_ip }}/.config.yml + + - name: Install cloud provider dependencies + shell: "uv pip install '.[{{ _provider_extras[algo_provider] | default(algo_provider) }}]'" + vars: + _provider_extras: + ec2: aws + lightsail: aws + azure: azure + gce: gcp + hetzner: hetzner + linode: linode + openstack: openstack + cloudstack: cloudstack + when: algo_provider != "local" + changed_when: false + + - name: Set region from stored config + set_fact: + region: "{{ _server_cfg.algo_region }}" + when: + - region is not defined + - _server_cfg.algo_region is defined + - _server_cfg.algo_region | length > 0 + + - name: Validate region for providers that require it + fail: + msg: | + Region is required to destroy {{ algo_provider }} servers. + Pass it with: -e "region=YOUR_REGION" + + Example: + ./algo destroy {{ server_ip }} -e "region=us-east-1" + when: + - algo_provider in ['ec2', 'lightsail', 'gce', 'scaleway', 'vultr'] + - region is not defined + + - name: Set dummy region for providers that do not need it + set_fact: + region: "unused" + when: + - region is not defined + - algo_provider not in ['ec2', 'lightsail', 'gce', 'scaleway', 'vultr'] + + - name: Gather provider credentials + include_tasks: "roles/cloud-{{ algo_provider }}/tasks/prompts.yml" + when: algo_provider != "local" + + - name: Display destroy plan + debug: + msg: + - "Server IP: {{ server_ip }}" + - "Server name: {{ algo_server_name }}" + - "Provider: {{ algo_provider }}" + + - name: Confirm destruction + pause: + prompt: | + This will permanently destroy the server and remove local configs. + Type 'yes' to confirm + register: _confirm_destroy + when: confirm_destroy is not defined or not confirm_destroy | bool + + - name: Abort if not confirmed + fail: + msg: "Destroy aborted by user." + when: + - confirm_destroy is not defined or not confirm_destroy | bool + - _confirm_destroy.user_input | default('') | lower != 'yes' + + - name: Destroy cloud resources + include_tasks: "roles/cloud-{{ algo_provider }}/tasks/destroy.yml" + when: algo_provider != "local" + + - name: Remove local config directory + file: + path: "configs/{{ server_ip }}" + state: absent + + - name: Remove localhost symlink + file: + path: configs/localhost + state: absent + when: server_ip == "localhost" + + - name: Destroy complete + debug: + msg: + - "Server {{ algo_server_name }} ({{ server_ip }}) destroyed." + - "Local configs removed from configs/{{ server_ip }}/" + rescue: + - include_tasks: playbooks/rescue.yml diff --git a/library/scaleway_compute.py b/library/scaleway_compute.py index 623a62c2..9d6fbcab 100644 --- a/library/scaleway_compute.py +++ b/library/scaleway_compute.py @@ -634,7 +634,8 @@ def core(module): compute_api = Scaleway(module=module) - check_image_id(compute_api, wished_server["image"]) + if wished_server["state"] != "absent": + check_image_id(compute_api, wished_server["image"]) # IP parameters of the wished server depends on the configuration ip_payload = public_ip_payload(compute_api=compute_api, public_ip=module.params["public_ip"]) @@ -648,16 +649,16 @@ def main(): argument_spec = scaleway_argument_spec() argument_spec.update( dict( - image=dict(required=True), + image=dict(), name=dict(), region=dict(required=True, choices=SCALEWAY_LOCATION.keys()), - commercial_type=dict(required=True), + commercial_type=dict(), enable_ipv6=dict(default=False, type="bool"), boot_type=dict(choices=["bootscript", "local"]), public_ip=dict(default="absent"), state=dict(choices=state_strategy.keys(), default="present"), tags=dict(type="list", default=[]), - organization=dict(required=True), + organization=dict(), wait=dict(type="bool", default=False), wait_timeout=dict(type="int", default=300), wait_sleep_time=dict(type="int", default=3), @@ -666,6 +667,12 @@ def main(): ) module = AnsibleModule( argument_spec=argument_spec, + required_if=[ + ("state", "present", ["image", "commercial_type", "organization"]), + ("state", "running", ["image", "commercial_type", "organization"]), + ("state", "stopped", ["image", "commercial_type", "organization"]), + ("state", "restarted", ["image", "commercial_type", "organization"]), + ], supports_check_mode=True, ) diff --git a/roles/cloud-azure/tasks/destroy.yml b/roles/cloud-azure/tasks/destroy.yml new file mode 100644 index 00000000..3e702af5 --- /dev/null +++ b/roles/cloud-azure/tasks/destroy.yml @@ -0,0 +1,10 @@ +--- +- name: Destroy Azure resource group + azure.azcollection.azure_rm_resourcegroup: + name: "{{ algo_server_name }}" + state: absent + force_delete_nonempty: true + secret: "{{ secret }}" + tenant: "{{ tenant }}" + client_id: "{{ client_id }}" + subscription_id: "{{ subscription_id }}" diff --git a/roles/cloud-cloudstack/tasks/destroy.yml b/roles/cloud-cloudstack/tasks/destroy.yml new file mode 100644 index 00000000..48194431 --- /dev/null +++ b/roles/cloud-cloudstack/tasks/destroy.yml @@ -0,0 +1,17 @@ +--- +- environment: + CLOUDSTACK_KEY: "{{ algo_cs_key }}" + CLOUDSTACK_SECRET: "{{ algo_cs_token }}" + CLOUDSTACK_ENDPOINT: "{{ algo_cs_url }}" + no_log: true + block: + - name: Destroy CloudStack instance + cs_instance: + name: "{{ algo_server_name }}" + state: expunged + + - name: Remove security group + cs_securitygroup: + name: "{{ algo_server_name }}-security_group" + state: absent + failed_when: false diff --git a/roles/cloud-digitalocean/tasks/destroy.yml b/roles/cloud-digitalocean/tasks/destroy.yml new file mode 100644 index 00000000..22edb58a --- /dev/null +++ b/roles/cloud-digitalocean/tasks/destroy.yml @@ -0,0 +1,7 @@ +--- +- name: Destroy DigitalOcean droplet + digital_ocean_droplet: + state: absent + name: "{{ algo_server_name }}" + oauth_token: "{{ algo_do_token }}" + unique_name: true diff --git a/roles/cloud-ec2/tasks/destroy.yml b/roles/cloud-ec2/tasks/destroy.yml new file mode 100644 index 00000000..0729c16c --- /dev/null +++ b/roles/cloud-ec2/tasks/destroy.yml @@ -0,0 +1,14 @@ +--- +- name: Set stack name + set_fact: + stack_name: "{{ algo_server_name | replace('.', '-') }}" + +- name: Destroy CloudFormation stack + cloudformation: + aws_access_key: "{{ access_key }}" + aws_secret_key: "{{ secret_key }}" + aws_session_token: "{{ session_token if session_token else omit }}" + stack_name: "{{ stack_name }}" + state: absent + region: "{{ algo_region }}" + no_log: true diff --git a/roles/cloud-gce/tasks/destroy.yml b/roles/cloud-gce/tasks/destroy.yml new file mode 100644 index 00000000..fdfa7d6c --- /dev/null +++ b/roles/cloud-gce/tasks/destroy.yml @@ -0,0 +1,55 @@ +--- +- name: Get zones + gcp_compute_location_info: + auth_kind: serviceaccount + service_account_file: "{{ credentials_file_path }}" + project: "{{ project_id }}" + scope: zones + filters: + - name={{ algo_region }}-* + - status=UP + register: gcp_compute_zone_info + +- name: Set zone + set_fact: + algo_zone: >- + {{ (gcp_compute_zone_info.resources | + random(seed=algo_server_name + algo_region + project_id) + ).name }} + +- name: Destroy GCE instance + gcp_compute_instance: + auth_kind: serviceaccount + service_account_file: "{{ credentials_file_path }}" + project: "{{ project_id }}" + name: "{{ algo_server_name }}" + zone: "{{ algo_zone }}" + state: absent + +- name: Remove static IP + gcp_compute_address: + auth_kind: serviceaccount + service_account_file: "{{ credentials_file_path }}" + project: "{{ project_id }}" + name: "{{ algo_server_name }}" + region: "{{ algo_region }}" + state: absent + failed_when: false + +- name: Remove firewall rule + gcp_compute_firewall: + auth_kind: serviceaccount + service_account_file: "{{ credentials_file_path }}" + project: "{{ project_id }}" + name: algovpn + state: absent + failed_when: false + +- name: Remove network + gcp_compute_network: + auth_kind: serviceaccount + service_account_file: "{{ credentials_file_path }}" + project: "{{ project_id }}" + name: algovpn + state: absent + failed_when: false diff --git a/roles/cloud-hetzner/tasks/destroy.yml b/roles/cloud-hetzner/tasks/destroy.yml new file mode 100644 index 00000000..51a5b8fe --- /dev/null +++ b/roles/cloud-hetzner/tasks/destroy.yml @@ -0,0 +1,6 @@ +--- +- name: Destroy Hetzner server + hetzner.hcloud.server: + name: "{{ algo_server_name }}" + state: absent + api_token: "{{ algo_hcloud_token }}" diff --git a/roles/cloud-lightsail/tasks/destroy.yml b/roles/cloud-lightsail/tasks/destroy.yml new file mode 100644 index 00000000..fd2e5b07 --- /dev/null +++ b/roles/cloud-lightsail/tasks/destroy.yml @@ -0,0 +1,13 @@ +--- +- name: Set stack name + set_fact: + stack_name: "{{ algo_server_name | replace('.', '-') }}" + +- name: Destroy CloudFormation stack + cloudformation: + aws_access_key: "{{ access_key }}" + aws_secret_key: "{{ secret_key }}" + stack_name: "{{ stack_name }}" + state: absent + region: "{{ algo_region }}" + no_log: true diff --git a/roles/cloud-linode/tasks/destroy.yml b/roles/cloud-linode/tasks/destroy.yml new file mode 100644 index 00000000..bdcd5406 --- /dev/null +++ b/roles/cloud-linode/tasks/destroy.yml @@ -0,0 +1,6 @@ +--- +- name: Destroy Linode instance + linode.cloud.instance: + api_token: "{{ algo_linode_token }}" + label: "{{ algo_server_name }}" + state: absent diff --git a/roles/cloud-openstack/tasks/destroy.yml b/roles/cloud-openstack/tasks/destroy.yml new file mode 100644 index 00000000..51abbba6 --- /dev/null +++ b/roles/cloud-openstack/tasks/destroy.yml @@ -0,0 +1,11 @@ +--- +- name: Destroy OpenStack server + openstack.cloud.server: + state: absent + name: "{{ algo_server_name }}" + +- name: Remove security group + openstack.cloud.security_group: + state: absent + name: "{{ algo_server_name }}-security_group" + failed_when: false diff --git a/roles/cloud-scaleway/tasks/destroy.yml b/roles/cloud-scaleway/tasks/destroy.yml new file mode 100644 index 00000000..803e058d --- /dev/null +++ b/roles/cloud-scaleway/tasks/destroy.yml @@ -0,0 +1,9 @@ +--- +- environment: + SCW_TOKEN: "{{ algo_scaleway_token }}" + block: + - name: Destroy Scaleway server + scaleway_compute: + name: "{{ algo_server_name }}" + state: absent + region: "{{ algo_region }}" diff --git a/roles/cloud-vultr/tasks/destroy.yml b/roles/cloud-vultr/tasks/destroy.yml new file mode 100644 index 00000000..8da05d86 --- /dev/null +++ b/roles/cloud-vultr/tasks/destroy.yml @@ -0,0 +1,15 @@ +--- +- environment: + VULTR_API_KEY: "{{ lookup('ini', 'key', section='default', file=algo_vultr_config) }}" + block: + - name: Destroy Vultr instance + vultr.cloud.instance: + name: "{{ algo_server_name }}" + region: "{{ algo_vultr_region }}" + state: absent + + - name: Remove firewall group + vultr.cloud.firewall_group: + name: "{{ algo_server_name }}" + state: absent + failed_when: false diff --git a/roles/cloud-vultr/tasks/prompts.yml b/roles/cloud-vultr/tasks/prompts.yml index 6ada5223..ac202302 100644 --- a/roles/cloud-vultr/tasks/prompts.yml +++ b/roles/cloud-vultr/tasks/prompts.yml @@ -59,3 +59,8 @@ elif _algo_region.user_input -%}{{ vultr_regions[_algo_region.user_input | int - 1]['id'] }}{%- else -%}{{ vultr_regions[default_region | int - 1]['id'] }}{%- endif -%} + algo_region: >- + {%- if region is defined -%}{{ region }}{%- + elif _algo_region.user_input -%}{{ vultr_regions[_algo_region.user_input | int - 1]['id'] }}{%- + else -%}{{ vultr_regions[default_region | int - 1]['id'] }}{%- + endif -%} diff --git a/scripts/list_servers.py b/scripts/list_servers.py new file mode 100644 index 00000000..a949ba1a --- /dev/null +++ b/scripts/list_servers.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""List deployed Algo VPN servers as JSON.""" + +import json +import sys +from pathlib import Path + +import yaml + + +def list_servers(configs_dir: Path) -> list[dict]: + """Scan configs directory for deployed server metadata.""" + servers = [] + for config_file in sorted(configs_dir.glob("*/.config.yml")): + with open(config_file) as f: + config = yaml.safe_load(f) + if config: + servers.append(config) + return servers + + +def main() -> None: + configs_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("configs") + if not configs_dir.is_dir(): + json.dump([], sys.stdout) + print() + sys.exit(0) + servers = list_servers(configs_dir) + json.dump(servers, sys.stdout, indent=2, default=str) + print() + + +if __name__ == "__main__": + main() diff --git a/server.yml b/server.yml index e02c5930..b2cef480 100644 --- a/server.yml +++ b/server.yml @@ -154,6 +154,7 @@ {% endif %} algo_provider: {{ algo_provider }} algo_server_name: {{ algo_server_name }} + algo_region: {{ algo_region | default('') }} algo_ondemand_cellular: {{ algo_ondemand_cellular }} algo_ondemand_wifi: {{ algo_ondemand_wifi }} algo_ondemand_wifi_exclude: {{ algo_ondemand_wifi_exclude }} diff --git a/tests/unit/test_destroy.py b/tests/unit/test_destroy.py new file mode 100644 index 00000000..7823c5a5 --- /dev/null +++ b/tests/unit/test_destroy.py @@ -0,0 +1,202 @@ +"""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 diff --git a/tests/unit/test_list_servers.py b/tests/unit/test_list_servers.py new file mode 100644 index 00000000..03a967ee --- /dev/null +++ b/tests/unit/test_list_servers.py @@ -0,0 +1,95 @@ +"""Tests for scripts/list_servers.py.""" + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +# Load list_servers module from scripts/ (not a Python package) +_script = Path(__file__).resolve().parents[2] / "scripts" / "list_servers.py" +_spec = importlib.util.spec_from_file_location("list_servers", str(_script)) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +list_servers = _mod.list_servers + + +@pytest.fixture() +def configs_dir(tmp_path): + """Create a temporary configs directory with sample configs.""" + server1 = tmp_path / "10.0.0.1" + server1.mkdir() + (server1 / ".config.yml").write_text("server: 10.0.0.1\nalgo_provider: digitalocean\nalgo_server_name: algo\n") + + server2 = tmp_path / "10.0.0.2" + server2.mkdir() + (server2 / ".config.yml").write_text("server: 10.0.0.2\nalgo_provider: ec2\nalgo_server_name: prod\n") + return tmp_path + + +def test_empty_directory(tmp_path): + """Empty configs directory returns empty list.""" + assert list_servers(tmp_path) == [] + + +def test_missing_directory(tmp_path): + """Non-existent path returns empty list via glob.""" + assert list_servers(tmp_path / "nonexistent") == [] + + +def test_lists_servers(configs_dir): + """Parses .config.yml files and returns server metadata.""" + servers = list_servers(configs_dir) + assert len(servers) == 2 + names = {s["algo_server_name"] for s in servers} + assert names == {"algo", "prod"} + + +def test_sorted_output(configs_dir): + """Servers are returned in sorted directory order.""" + servers = list_servers(configs_dir) + ips = [s["server"] for s in servers] + assert ips == ["10.0.0.1", "10.0.0.2"] + + +def test_skips_empty_yaml(tmp_path): + """Empty YAML files (parsing to None) are skipped.""" + server = tmp_path / "10.0.0.5" + server.mkdir() + (server / ".config.yml").write_text("") + + assert list_servers(tmp_path) == [] + + +def test_cli_output(tmp_path): + """CLI outputs valid JSON to stdout.""" + server = tmp_path / "10.0.0.1" + server.mkdir() + (server / ".config.yml").write_text("server: 10.0.0.1\nalgo_server_name: test\n") + + result = subprocess.run( + [sys.executable, "scripts/list_servers.py", str(tmp_path)], + capture_output=True, + text=True, + check=True, + ) + data = json.loads(result.stdout) + assert len(data) == 1 + assert data[0]["server"] == "10.0.0.1" + + +def test_cli_missing_dir(): + """CLI outputs empty JSON array for missing directory.""" + result = subprocess.run( + [ + sys.executable, + "scripts/list_servers.py", + "/nonexistent/path", + ], + capture_output=True, + text=True, + check=True, + ) + assert json.loads(result.stdout) == []