Skip to content

Commit 6b1d5da

Browse files
szachovyCopilot
andcommitted
Add --cleanup CLI flag with deploy/cleanup actions
Replace blanket '|| true' error suppression in teardown with proper conditional logic: if/then guards for swarm leave, directory removal, and VIP cleanup; xargs -r for container, volume, and network removal. Exit codes are checked via _run_ssh_command() and real failures raise RuntimeError. Deploy action (default) always tears down existing state first, making it idempotent and safe for both fresh installs and redeployments. Add redeploy and cleanup test stages to the test suite: - redeploy.yml: restarts stopped nodes from disaster tests, runs a fresh deploy, and verifies the cluster is fully functional again. - cleanup.yml: runs --cleanup and verifies no containers, volumes, deployment directories, .pyc files, or VIP addresses remain. Closes #49 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0ab8ce7 commit 6b1d5da

6 files changed

Lines changed: 343 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
* CI workflow to build and push service images to GitHub Container Registry on master merge. (#97)
1717
* Pull-first with local build fallback for container images during deployment. (#97)
1818

19+
### Added
20+
21+
* `--cleanup` CLI flag and `deploy`/`cleanup` actions with proper teardown. (#49)
22+
* Redeploy and cleanup test stages with post-disaster recovery verification. (#49)
23+
1924
### Changed
2025

2126
* Completed [ARCHITECTURE.md](./docs/ARCHITECTURE.md) (#93)

src/initialize.py

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,10 @@ def credentials(self) -> None:
131131
node.certificate = self.cert_manager.generate_certificate(f'Superset-Cluster-{node.node}',
132132
node.csr,
133133
self.ca_key)
134-
node.create_directory('/opt/superset-cluster')
134+
try:
135+
node.create_directory('/opt/superset-cluster')
136+
except IOError:
137+
pass
135138
for node in self.mgmt_nodes:
136139
node.superset_key = self.cert_manager.generate_private_key()
137140
node.superset_csr = self.cert_manager.generate_csr(self.virtual_ip_address, node.superset_key)
@@ -280,20 +283,92 @@ def start_superset(self) -> None:
280283
mysql_superset_password=self.mysql_superset_password)
281284
)
282285

286+
def _close_connections(self) -> None:
287+
for node in list(itertools.chain(self.mysql_nodes, self.mgmt_nodes)):
288+
node.ssh_client.close()
289+
node.sftp_client.close()
290+
291+
def _run_ssh_command(self, node: remote.RemoteConnection, command: str) -> None:
292+
_, stdout, stderr = node.ssh_client.exec_command(command)
293+
exit_status = stdout.channel.recv_exit_status()
294+
if exit_status != 0:
295+
raise RuntimeError(
296+
f"Command failed on {node.node} (exit {exit_status}): "
297+
f"{command}\n{stderr.read().decode().strip()}"
298+
)
299+
300+
def _prepare_nodes(self) -> None:
301+
for node in itertools.chain(self.mysql_nodes, self.mgmt_nodes):
302+
try:
303+
node.sftp_client.mkdir('/opt/superset-cluster')
304+
except IOError:
305+
pass
306+
307+
def teardown_node(self, node: remote.RemoteConnection, is_mgmt: bool = False) -> None:
308+
commands = [
309+
"if docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null"
310+
" | grep -qvx inactive; then docker swarm leave --force; fi",
311+
"docker ps -aq | xargs -r docker rm -f",
312+
"docker volume ls -q | xargs -r docker volume rm",
313+
"docker network ls --filter name=superset-network -q"
314+
" | xargs -r docker network rm",
315+
"docker network ls --filter name=docker_gwbridge -q"
316+
" | xargs -r docker network rm",
317+
"if [ -d /opt/superset-cluster ]; then"
318+
" rm -rf /opt/superset-cluster; fi",
319+
"find /opt -maxdepth 1 -name '*.pyc' -delete",
320+
]
321+
if is_mgmt:
322+
commands.extend([
323+
"if ip addr show dev {iface} 2>/dev/null"
324+
" | grep -q '{vip}/'; then"
325+
" ip addr del {vip}/{mask} dev {iface}; fi".format(
326+
iface=self.virtual_network_interface,
327+
vip=self.virtual_ip_address,
328+
mask=self.virtual_network_mask),
329+
"if ip route show | grep -q '{vip} '; then"
330+
" ip route del {vip}; fi".format(
331+
vip=self.virtual_ip_address),
332+
])
333+
for cmd in commands:
334+
self._run_ssh_command(node, cmd)
335+
336+
def teardown_cluster(self) -> None:
337+
for node in self.mgmt_nodes:
338+
self.teardown_node(node, is_mgmt=True)
339+
for node in self.mysql_nodes:
340+
self.teardown_node(node, is_mgmt=False)
341+
283342
def start_cluster(self) -> None:
343+
self.start_mysql_servers()
344+
self.start_mysql_mgmt(node=self.mgmt_nodes[0], state="MASTER", priority=100)
345+
self.start_mysql_mgmt(node=self.mgmt_nodes[1], state="BACKUP", priority=90)
346+
self.start_superset()
347+
348+
def deploy(self) -> None:
349+
try:
350+
self.teardown_cluster()
351+
self._prepare_nodes()
352+
self.start_cluster()
353+
finally:
354+
self._close_connections()
355+
356+
def cleanup(self) -> None:
284357
try:
285-
self.start_mysql_servers()
286-
self.start_mysql_mgmt(node=self.mgmt_nodes[0], state="MASTER", priority=100)
287-
self.start_mysql_mgmt(node=self.mgmt_nodes[1], state="BACKUP", priority=90)
288-
self.start_superset()
358+
self.teardown_cluster()
289359
finally:
290-
for node in list(itertools.chain(self.mysql_nodes, self.mgmt_nodes)):
291-
node.ssh_client.close()
292-
node.sftp_client.close()
360+
self._close_connections()
293361

294362

295363
if __name__ == "__main__":
296-
if len(sys.argv) != 6:
364+
if len(sys.argv) < 6:
297365
print("Invalid form of arguments provided")
298366
sys.exit(1)
299-
Controller().start_cluster()
367+
368+
action = sys.argv[6] if len(sys.argv) > 6 else "deploy"
369+
controller = Controller()
370+
371+
if action == "cleanup":
372+
controller.cleanup()
373+
else:
374+
controller.deploy()

superset-cluster

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ display_help() {
2020
echo " --virtual-network-mask <mask> Network mask for the virtual network gateway."
2121
echo " Example: --virtual-network-mask 24"
2222
echo
23+
echo " --cleanup Remove all cluster resources from the specified nodes."
24+
echo
2325
echo " -h, --help Show this help message and exit."
2426
echo
2527
echo "Example:"
@@ -33,6 +35,7 @@ display_help() {
3335

3436

3537
parse_arguments() {
38+
action="deploy"
3639
while [[ "$#" -gt 0 ]]; do
3740
case $1 in
3841
--mgmt-nodes)
@@ -59,6 +62,9 @@ parse_arguments() {
5962
display_help
6063
exit 0
6164
;;
65+
--cleanup)
66+
action="cleanup"
67+
;;
6268
*)
6369
echo "Unknown option: $1"
6470
display_help
@@ -77,14 +83,16 @@ if python --version &>/dev/null; then
7783
'${virtual_network_interface}' \
7884
'${virtual_network_mask}' \
7985
'${mgmt_nodes[*]}' \
80-
'${mysql_nodes[*]}'"
86+
'${mysql_nodes[*]}' \
87+
'${action}'"
8188
elif python3 --version &>/dev/null; then
8289
eval "python3 ./src/initialize.py \
8390
'${virtual_ip_address}' \
8491
'${virtual_network_interface}' \
8592
'${virtual_network_mask}' \
8693
'${mgmt_nodes[*]}' \
87-
'${mysql_nodes[*]}'"
94+
'${mysql_nodes[*]}' \
95+
'${action}'"
8896
else
8997
echo "Neither python nor python3 is set as the main executable in this environment, check python location with 'which python'."
9098
exit 1

tests/testsuite/deploy.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,19 @@
2222
tasks:
2323
- name: "Run functional tasks from testing role"
2424
ansible.builtin.include_role: {name: "testing", tasks_from: "functional"}
25+
26+
- name: "Redeploy testing"
27+
connection: "local"
28+
hosts: "testing"
29+
any_errors_fatal: true
30+
tasks:
31+
- name: "Run redeploy tasks from testing role"
32+
ansible.builtin.include_role: {name: "testing", tasks_from: "redeploy"}
33+
34+
- name: "Cleanup testing"
35+
connection: "local"
36+
hosts: "testing"
37+
any_errors_fatal: true
38+
tasks:
39+
- name: "Run cleanup tasks from testing role"
40+
ansible.builtin.include_role: {name: "testing", tasks_from: "cleanup"}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
- name: "Run cleanup"
3+
ansible.builtin.shell: |
4+
set -euxo pipefail
5+
{
6+
./superset-cluster \
7+
--cleanup \
8+
--mgmt-nodes {{ node_prefix }}-0,{{ node_prefix }}-1 \
9+
--mysql-nodes {{ node_prefix }}-2,{{ node_prefix }}-3,{{ node_prefix }}-4 \
10+
--virtual-ip-address {{ virtual_ip_address }} \
11+
--virtual-network-interface {{ virtual_network_interface }} \
12+
--virtual-network-mask {{ virtual_network_mask }}
13+
} >> /tmp/superset_cluster_testsuite.log 2>&1
14+
args:
15+
executable: "/bin/bash"
16+
chdir: "../../"
17+
register: "cleanup_command"
18+
changed_when: "cleanup_command.rc != 0"
19+
20+
- name: "Verify no running containers on any node"
21+
community.docker.docker_container_exec:
22+
container: "{{ item }}"
23+
command: "/bin/bash -c 'test $(docker ps -q | wc -l) -eq 0'"
24+
loop:
25+
- "{{ node_prefix }}-0"
26+
- "{{ node_prefix }}-1"
27+
- "{{ node_prefix }}-2"
28+
- "{{ node_prefix }}-3"
29+
- "{{ node_prefix }}-4"
30+
register: "no_containers"
31+
32+
- name: "Verify no docker volumes on any node"
33+
community.docker.docker_container_exec:
34+
container: "{{ item }}"
35+
command: "/bin/bash -c 'test $(docker volume ls -q | wc -l) -eq 0'"
36+
loop:
37+
- "{{ node_prefix }}-0"
38+
- "{{ node_prefix }}-1"
39+
- "{{ node_prefix }}-2"
40+
- "{{ node_prefix }}-3"
41+
- "{{ node_prefix }}-4"
42+
register: "no_volumes"
43+
44+
- name: "Verify deployment directory removed from all nodes"
45+
community.docker.docker_container_exec:
46+
container: "{{ item }}"
47+
command: "/bin/bash -c 'test ! -d /opt/superset-cluster'"
48+
loop:
49+
- "{{ node_prefix }}-0"
50+
- "{{ node_prefix }}-1"
51+
- "{{ node_prefix }}-2"
52+
- "{{ node_prefix }}-3"
53+
- "{{ node_prefix }}-4"
54+
register: "no_deploy_dir"
55+
56+
- name: "Verify no .pyc files in /opt on any node"
57+
community.docker.docker_container_exec:
58+
container: "{{ item }}"
59+
command: >-
60+
/bin/bash -c
61+
'test $(find /opt -maxdepth 1 -name "*.pyc" | wc -l) -eq 0'
62+
loop:
63+
- "{{ node_prefix }}-0"
64+
- "{{ node_prefix }}-1"
65+
- "{{ node_prefix }}-2"
66+
- "{{ node_prefix }}-3"
67+
- "{{ node_prefix }}-4"
68+
register: "no_pyc_files"
69+
70+
- name: "Verify VIP removed from management nodes"
71+
community.docker.docker_container_exec:
72+
container: "{{ item }}"
73+
command: >-
74+
/bin/bash -c
75+
'! ip addr show dev {{ virtual_network_interface }}
76+
| grep -q "{{ virtual_ip_address }}/"'
77+
loop:
78+
- "{{ node_prefix }}-0"
79+
- "{{ node_prefix }}-1"
80+
register: "no_vip"

0 commit comments

Comments
 (0)