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:
Dan Guido
2026-02-08 13:24:14 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent bdc03d0a7f
commit 5a72268117
19 changed files with 675 additions and 4 deletions
+20
View File
@@ -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
View File
@@ -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
+11 -4
View File
@@ -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,
) )
+10
View File
@@ -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 }}"
+17
View File
@@ -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
+14
View File
@@ -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
+55
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
---
- name: Destroy Hetzner server
hetzner.hcloud.server:
name: "{{ algo_server_name }}"
state: absent
api_token: "{{ algo_hcloud_token }}"
+13
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
---
- name: Destroy Linode instance
linode.cloud.instance:
api_token: "{{ algo_linode_token }}"
label: "{{ algo_server_name }}"
state: absent
+11
View File
@@ -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
+9
View File
@@ -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 }}"
+15
View File
@@ -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
+5
View File
@@ -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 -%}
+34
View File
@@ -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()
+1
View File
@@ -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 }}
+202
View File
@@ -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
+95
View File
@@ -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) == []