Skip to content

Commit efd3cb3

Browse files
szachovyCopilot
andcommitted
Add --cleanup CLI flag with idempotent deploy
Make deploy idempotent by checking component health before acting: - start_mysql_servers() skips nodes where mysql is already healthy - start_mysql_mgmt() skips nodes where mysql-mgmt is already healthy - start_superset() skips nodes where superset service already exists - credentials() handles idempotent directory creation Cleanup via --cleanup tears down all containers, volumes, networks, routes, deployed files, and VIP addresses. Teardown commands use conditional logic (if/then guards, xargs -r) instead of || true, with all commands per node executed in a single SSH call and exit status checked. Test suite extended with redeploy and cleanup stages: - redeploy.yml: restarts disaster-stopped nodes, runs cleanup then fresh deploy, verifies cluster fully functional - cleanup.yml: runs --cleanup, verifies no containers, volumes, directories, .pyc files, or VIP addresses remain Closes #49 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6b1d5da commit efd3cb3

6 files changed

Lines changed: 128 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
* Dependabot updates for `github-actions` and `terraform` ecosystems. (#29)
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)
18-
19-
### Added
20-
21-
* `--cleanup` CLI flag and `deploy`/`cleanup` actions with proper teardown. (#49)
18+
* `--cleanup` CLI flag with idempotent deploy and cleanup actions. (#49)
2219
* Redeploy and cleanup test stages with post-disaster recovery verification. (#49)
2320

2421
### Changed
2522

2623
* Completed [ARCHITECTURE.md](./docs/ARCHITECTURE.md) (#93)
2724
* Migrated CI from self-hosted to GitHub-hosted runners with Docker-in-Docker test infrastructure. (#94)
2825
* Test workflow always builds service images locally for reproducibility. (#97)
26+
* Moved `.pyc` bytecode files from `/opt` to `/opt/superset-cluster` for cleaner file organization.
2927

3028
### Fixed
3129

src/initialize.py

Lines changed: 85 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,21 @@ def get_mylogin_cnf(self, node: remote.RemoteConnection) -> bytes:
164164

165165
def start_mysql_servers(self) -> None:
166166
for node in self.mysql_nodes:
167+
_, stdout, _ = node.ssh_client.exec_command(
168+
"docker container inspect mysql > /dev/null 2>&1 && echo exists"
169+
)
170+
if stdout.read().decode().strip() == "exists":
171+
_, stdout, _ = node.ssh_client.exec_command(
172+
"for i in $(seq 1 60); do "
173+
"docker inspect --format='{{.State.Health.Status}}'"
174+
" mysql 2>/dev/null"
175+
" | grep -qx healthy && exit 0; sleep 5; done; exit 1"
176+
)
177+
if stdout.channel.recv_exit_status() != 0:
178+
raise RuntimeError(
179+
f"MySQL on {node.node} exists but did not become healthy"
180+
)
181+
continue
167182
node.upload_directory(
168183
local_directory_path="./services/mysql-server",
169184
remote_directory_path="/opt/superset-cluster/mysql-server"
@@ -195,6 +210,21 @@ def start_mysql_servers(self) -> None:
195210
)
196211

197212
def start_mysql_mgmt(self, node: remote.RemoteConnection, state: str, priority: int) -> None:
213+
_, stdout, _ = node.ssh_client.exec_command(
214+
"docker container inspect mysql-mgmt > /dev/null 2>&1 && echo exists"
215+
)
216+
if stdout.read().decode().strip() == "exists":
217+
_, stdout, _ = node.ssh_client.exec_command(
218+
"for i in $(seq 1 40); do "
219+
"docker inspect --format='{{.State.Health.Status}}'"
220+
" mysql-mgmt 2>/dev/null"
221+
" | grep -qx healthy && exit 0; sleep 5; done; exit 1"
222+
)
223+
if stdout.channel.recv_exit_status() != 0:
224+
raise RuntimeError(
225+
f"mysql-mgmt on {node.node} exists but did not become healthy"
226+
)
227+
return
198228
node.upload_directory(
199229
local_directory_path="./services/mysql-mgmt",
200230
remote_directory_path="/opt/superset-cluster/mysql-mgmt"
@@ -251,6 +281,38 @@ def start_mysql_mgmt(self, node: remote.RemoteConnection, state: str, priority:
251281

252282
def start_superset(self) -> None:
253283
for node in self.mgmt_nodes:
284+
_, redis_out, _ = node.ssh_client.exec_command(
285+
"docker container inspect redis > /dev/null 2>&1 && echo exists"
286+
)
287+
_, svc_out, _ = node.ssh_client.exec_command(
288+
"docker service inspect superset > /dev/null 2>&1 && echo exists"
289+
)
290+
redis_exists = redis_out.read().decode().strip() == "exists"
291+
superset_exists = svc_out.read().decode().strip() == "exists"
292+
if redis_exists != superset_exists:
293+
raise RuntimeError(
294+
f"Partial state on {node.node}: "
295+
f"redis={'exists' if redis_exists else 'missing'}, "
296+
f"superset={'exists' if superset_exists else 'missing'}. "
297+
f"Run --cleanup first"
298+
)
299+
if redis_exists and superset_exists:
300+
_, stdout, _ = node.ssh_client.exec_command(
301+
"for i in $(seq 1 90); do "
302+
"docker inspect --format='{{.State.Health.Status}}'"
303+
" redis 2>/dev/null | grep -qx healthy"
304+
" && docker service ps superset"
305+
" --format='{{.CurrentState}}'"
306+
" --filter desired-state=running 2>/dev/null"
307+
" | head -1 | grep -q Running"
308+
" && exit 0; sleep 5; done; exit 1"
309+
)
310+
if stdout.channel.recv_exit_status() != 0:
311+
raise RuntimeError(
312+
f"Superset stack on {node.node} exists"
313+
f" but did not become healthy"
314+
)
315+
continue
254316
node.upload_directory(
255317
local_directory_path="./services/superset",
256318
remote_directory_path='/opt/superset-cluster/superset'
@@ -283,27 +345,6 @@ def start_superset(self) -> None:
283345
mysql_superset_password=self.mysql_superset_password)
284346
)
285347

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-
307348
def teardown_node(self, node: remote.RemoteConnection, is_mgmt: bool = False) -> None:
308349
commands = [
309350
"if docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null"
@@ -316,7 +357,7 @@ def teardown_node(self, node: remote.RemoteConnection, is_mgmt: bool = False) ->
316357
" | xargs -r docker network rm",
317358
"if [ -d /opt/superset-cluster ]; then"
318359
" rm -rf /opt/superset-cluster; fi",
319-
"find /opt -maxdepth 1 -name '*.pyc' -delete",
360+
"find /opt/superset-cluster -maxdepth 1 -name '*.pyc' -delete",
320361
]
321362
if is_mgmt:
322363
commands.extend([
@@ -330,34 +371,35 @@ def teardown_node(self, node: remote.RemoteConnection, is_mgmt: bool = False) ->
330371
" ip route del {vip}; fi".format(
331372
vip=self.virtual_ip_address),
332373
])
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)
374+
_, stdout, stderr = node.ssh_client.exec_command(" && ".join(commands))
375+
exit_status = stdout.channel.recv_exit_status()
376+
if exit_status != 0:
377+
raise RuntimeError(
378+
f"Teardown failed on {node.node} (exit {exit_status}): "
379+
f"{stderr.read().decode().strip()}"
380+
)
341381

342382
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:
349383
try:
350-
self.teardown_cluster()
351-
self._prepare_nodes()
352-
self.start_cluster()
384+
self.start_mysql_servers()
385+
self.start_mysql_mgmt(node=self.mgmt_nodes[0], state="MASTER", priority=100)
386+
self.start_mysql_mgmt(node=self.mgmt_nodes[1], state="BACKUP", priority=90)
387+
self.start_superset()
353388
finally:
354-
self._close_connections()
389+
for node in list(itertools.chain(self.mysql_nodes, self.mgmt_nodes)):
390+
node.ssh_client.close()
391+
node.sftp_client.close()
355392

356393
def cleanup(self) -> None:
357394
try:
358-
self.teardown_cluster()
395+
for node in self.mgmt_nodes:
396+
self.teardown_node(node, is_mgmt=True)
397+
for node in self.mysql_nodes:
398+
self.teardown_node(node, is_mgmt=False)
359399
finally:
360-
self._close_connections()
400+
for node in list(itertools.chain(self.mysql_nodes, self.mgmt_nodes)):
401+
node.ssh_client.close()
402+
node.sftp_client.close()
361403

362404

363405
if __name__ == "__main__":
@@ -371,4 +413,4 @@ def cleanup(self) -> None:
371413
if action == "cleanup":
372414
controller.cleanup()
373415
else:
374-
controller.deploy()
416+
controller.start_cluster()

src/remote.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ def run_python_container_command(self, command: str) -> dict:
128128
pyc_file = io.BytesIO()
129129
pyc_file.write(b'o\r\r\n\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00')
130130
marshal.dump(code_object, pyc_file)
131-
self.upload_file(content=pyc_file.getvalue(), remote_file_path=f'/opt/{nonce}.pyc')
132-
_, stdout, stderr = self.ssh_client.exec_command(f"python3 /opt/{nonce}.pyc")
131+
self.upload_file(content=pyc_file.getvalue(), remote_file_path=f'/opt/superset-cluster/{nonce}.pyc')
132+
_, stdout, stderr = self.ssh_client.exec_command(f"python3 /opt/superset-cluster/{nonce}.pyc")
133133
return {
134134
"output": stdout.read().decode(),
135135
"error": stderr.read().decode()

tests/testsuite/deploy.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,41 @@
2323
- name: "Run functional tasks from testing role"
2424
ansible.builtin.include_role: {name: "testing", tasks_from: "functional"}
2525

26+
- name: "Restore test infrastructure after disaster simulation"
27+
connection: "local"
28+
hosts: "testing"
29+
any_errors_fatal: true
30+
tasks:
31+
- name: "Restart stopped nodes"
32+
community.docker.docker_container:
33+
name: "{{ item }}"
34+
state: "started"
35+
loop:
36+
- "{{ node_prefix }}-0"
37+
- "{{ node_prefix }}-2"
38+
39+
- name: "Start services in restarted nodes"
40+
community.docker.docker_container_exec:
41+
container: "{{ item }}"
42+
command: "/bin/bash -c 'service ssh start && service docker start'"
43+
loop:
44+
- "{{ node_prefix }}-0"
45+
- "{{ node_prefix }}-2"
46+
changed_when: false
47+
48+
- name: "Wait for Docker daemon readiness in restarted nodes"
49+
community.docker.docker_container_exec:
50+
container: "{{ item }}"
51+
command: >-
52+
/bin/bash -c
53+
'for i in $(seq 1 30); do
54+
docker info >/dev/null 2>&1 && exit 0; sleep 2; done; exit 1'
55+
loop:
56+
- "{{ node_prefix }}-0"
57+
- "{{ node_prefix }}-2"
58+
register: "docker_readiness"
59+
changed_when: false
60+
2661
- name: "Redeploy testing"
2762
connection: "local"
2863
hosts: "testing"

tests/testsuite/roles/testing/tasks/cleanup.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@
5353
- "{{ node_prefix }}-4"
5454
register: "no_deploy_dir"
5555

56-
- name: "Verify no .pyc files in /opt on any node"
56+
- name: "Verify no .pyc files in /opt/superset-cluster on any node"
5757
community.docker.docker_container_exec:
5858
container: "{{ item }}"
5959
command: >-
6060
/bin/bash -c
61-
'test $(find /opt -maxdepth 1 -name "*.pyc" | wc -l) -eq 0'
61+
'test $(find /opt/superset-cluster -maxdepth 1 -name "*.pyc" 2>/dev/null | wc -l) -eq 0'
6262
loop:
6363
- "{{ node_prefix }}-0"
6464
- "{{ node_prefix }}-1"

tests/testsuite/roles/testing/tasks/redeploy.yml

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,5 @@
11
---
2-
- name: "Restart stopped nodes for redeploy testing"
3-
community.docker.docker_container:
4-
name: "{{ item }}"
5-
state: "started"
6-
loop:
7-
- "{{ node_prefix }}-0"
8-
- "{{ node_prefix }}-2"
9-
10-
- name: "Start services in restarted nodes"
11-
community.docker.docker_container_exec:
12-
container: "{{ item }}"
13-
command: "/bin/bash -c 'service ssh start && service docker start'"
14-
loop:
15-
- "{{ node_prefix }}-0"
16-
- "{{ node_prefix }}-2"
17-
changed_when: false
18-
19-
- name: "Wait for Docker daemon readiness in restarted nodes"
20-
community.docker.docker_container_exec:
21-
container: "{{ item }}"
22-
command: >-
23-
/bin/bash -c
24-
'for i in $(seq 1 30); do
25-
docker info >/dev/null 2>&1 && exit 0; sleep 2; done; exit 1'
26-
loop:
27-
- "{{ node_prefix }}-0"
28-
- "{{ node_prefix }}-2"
29-
register: "docker_readiness"
30-
changed_when: false
31-
32-
- name: "Run redeploy (deploy cleans existing state first)"
2+
- name: "Run redeploy"
333
ansible.builtin.shell: |
344
set -euxo pipefail
355
{
@@ -43,7 +13,7 @@
4313
args:
4414
executable: "/bin/bash"
4515
chdir: "../../"
46-
async: 2100
16+
async: 4200
4717
poll: 60
4818
register: "redeploy_command"
4919
changed_when: "redeploy_command.rc != 0"

0 commit comments

Comments
 (0)