mirror of
https://github.com/trailofbits/algo.git
synced 2026-08-17 21:25:50 +02:00
feat: add destroy subcommand to tear down deployed servers (#14965)
* 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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
bdc03d0a7f
commit
5a72268117
@@ -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 <server-ip> [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
|
||||
|
||||
+144
@@ -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 <server-ip>
|
||||
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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Destroy Hetzner server
|
||||
hetzner.hcloud.server:
|
||||
name: "{{ algo_server_name }}"
|
||||
state: absent
|
||||
api_token: "{{ algo_hcloud_token }}"
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Destroy Linode instance
|
||||
linode.cloud.instance:
|
||||
api_token: "{{ algo_linode_token }}"
|
||||
label: "{{ algo_server_name }}"
|
||||
state: absent
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -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 -%}
|
||||
|
||||
@@ -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()
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -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) == []
|
||||
Reference in New Issue
Block a user