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 "Commands:"
|
||||||
echo " (default) Deploy a new VPN server"
|
echo " (default) Deploy a new VPN server"
|
||||||
echo " update-users Add or remove users on an existing 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 ""
|
||||||
echo "Configuration:"
|
echo "Configuration:"
|
||||||
echo " Edit config.cfg to set users, DNS, and VPN options before deploying."
|
echo " Edit config.cfg to set users, DNS, and VPN options before deploying."
|
||||||
@@ -186,6 +188,24 @@ case "$1" in
|
|||||||
;;
|
;;
|
||||||
update-users)
|
update-users)
|
||||||
uv run ansible-playbook users.yml "${@:2}" -t 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 "${@}" ;;
|
uv run ansible-playbook main.yml "${@}" ;;
|
||||||
esac
|
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)
|
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 parameters of the wished server depends on the configuration
|
||||||
ip_payload = public_ip_payload(compute_api=compute_api, public_ip=module.params["public_ip"])
|
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 = scaleway_argument_spec()
|
||||||
argument_spec.update(
|
argument_spec.update(
|
||||||
dict(
|
dict(
|
||||||
image=dict(required=True),
|
image=dict(),
|
||||||
name=dict(),
|
name=dict(),
|
||||||
region=dict(required=True, choices=SCALEWAY_LOCATION.keys()),
|
region=dict(required=True, choices=SCALEWAY_LOCATION.keys()),
|
||||||
commercial_type=dict(required=True),
|
commercial_type=dict(),
|
||||||
enable_ipv6=dict(default=False, type="bool"),
|
enable_ipv6=dict(default=False, type="bool"),
|
||||||
boot_type=dict(choices=["bootscript", "local"]),
|
boot_type=dict(choices=["bootscript", "local"]),
|
||||||
public_ip=dict(default="absent"),
|
public_ip=dict(default="absent"),
|
||||||
state=dict(choices=state_strategy.keys(), default="present"),
|
state=dict(choices=state_strategy.keys(), default="present"),
|
||||||
tags=dict(type="list", default=[]),
|
tags=dict(type="list", default=[]),
|
||||||
organization=dict(required=True),
|
organization=dict(),
|
||||||
wait=dict(type="bool", default=False),
|
wait=dict(type="bool", default=False),
|
||||||
wait_timeout=dict(type="int", default=300),
|
wait_timeout=dict(type="int", default=300),
|
||||||
wait_sleep_time=dict(type="int", default=3),
|
wait_sleep_time=dict(type="int", default=3),
|
||||||
@@ -666,6 +667,12 @@ def main():
|
|||||||
)
|
)
|
||||||
module = AnsibleModule(
|
module = AnsibleModule(
|
||||||
argument_spec=argument_spec,
|
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,
|
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'] }}{%-
|
elif _algo_region.user_input -%}{{ vultr_regions[_algo_region.user_input | int - 1]['id'] }}{%-
|
||||||
else -%}{{ vultr_regions[default_region | int - 1]['id'] }}{%-
|
else -%}{{ vultr_regions[default_region | int - 1]['id'] }}{%-
|
||||||
endif -%}
|
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 %}
|
{% endif %}
|
||||||
algo_provider: {{ algo_provider }}
|
algo_provider: {{ algo_provider }}
|
||||||
algo_server_name: {{ algo_server_name }}
|
algo_server_name: {{ algo_server_name }}
|
||||||
|
algo_region: {{ algo_region | default('') }}
|
||||||
algo_ondemand_cellular: {{ algo_ondemand_cellular }}
|
algo_ondemand_cellular: {{ algo_ondemand_cellular }}
|
||||||
algo_ondemand_wifi: {{ algo_ondemand_wifi }}
|
algo_ondemand_wifi: {{ algo_ondemand_wifi }}
|
||||||
algo_ondemand_wifi_exclude: {{ algo_ondemand_wifi_exclude }}
|
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