Skip to content

Commit 478acfe

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 478acfe

15 files changed

Lines changed: 516 additions & 477 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,24 @@ 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)
22-
* Redeploy and cleanup test stages with post-disaster recovery verification. (#49)
18+
* `--cleanup` CLI flag with idempotent deploy and cleanup actions. (#49)
19+
* Credential recovery for partial redeploy from existing healthy nodes. (#49)
20+
* PEM serialization support in `crypto.py` for credential recovery. (#49)
21+
* InnoDB Cluster auto-join for fresh MySQL nodes added to an existing cluster. (#49)
22+
* Disaster recovery test flow with node replacement and partial redeploy verification. (#49)
2323

2424
### Changed
2525

2626
* Completed [ARCHITECTURE.md](./docs/ARCHITECTURE.md) (#93)
2727
* Migrated CI from self-hosted to GitHub-hosted runners with Docker-in-Docker test infrastructure. (#94)
2828
* Test workflow always builds service images locally for reproducibility. (#97)
29+
* Moved `.pyc` bytecode files from `/opt` to `/opt/superset-cluster` for cleaner file organization.
30+
* Restructured test suite into 10-stage flow: sanity, deploy, functional, disaster, post-disaster,
31+
recovery, redeploy, post-redeploy functional, cleanup, cleanup verification. (#49)
32+
* InnoDB Cluster initcontainer scans all MySQL nodes to find existing cluster and adds missing
33+
members instead of only checking the primary node. (#49)
34+
* Made Terraform test infrastructure idempotent: removed `always_run` triggers, node recovery
35+
reruns `terraform apply` which only recreates missing containers. (#49)
2936

3037
### Fixed
3138

services/mysql-mgmt/docker_compose.yml

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ services:
1313
- |
1414
set -euo pipefail
1515
export MYSQL_SUPERSET_PASSWORD=$(</run/secrets/mysql_superset_password)
16-
if ! mysqlsh \
17-
--login-path="${PRIMARY_MYSQL_NODE}" \
18-
--execute="dba.getCluster();"; then
16+
CLUSTER_NODE=""
17+
for node in "${PRIMARY_MYSQL_NODE}" "${SECONDARY_FIRST_MYSQL_NODE}" "${SECONDARY_SECOND_MYSQL_NODE}"; do
18+
if mysqlsh --login-path="${node}" --execute="dba.getCluster();" 2>/dev/null; then
19+
CLUSTER_NODE="${node}"
20+
break
21+
fi
22+
done
23+
if [ -z "${CLUSTER_NODE}" ]; then
1924
mysqlsh \
2025
--login-path="${PRIMARY_MYSQL_NODE}" \
2126
--execute="dba.configureInstance('${PRIMARY_MYSQL_NODE}')"
@@ -46,18 +51,32 @@ services:
4651
--login-path="${PRIMARY_MYSQL_NODE}" \
4752
--sql \
4853
--file="/opt/superset_user.sql"
54+
CLUSTER_NODE="${PRIMARY_MYSQL_NODE}"
55+
else
56+
CLUSTER_STATUS=$(mysqlsh --login-path="${CLUSTER_NODE}" \
57+
--execute="print(dba.getCluster('superset').status())" 2>/dev/null)
58+
for node in "${PRIMARY_MYSQL_NODE}" "${SECONDARY_FIRST_MYSQL_NODE}" "${SECONDARY_SECOND_MYSQL_NODE}"; do
59+
if ! echo "${CLUSTER_STATUS}" | grep -q "\"address\": \"${node}:3306\""; then
60+
mysqlsh --login-path="${node}" \
61+
--execute="dba.configureInstance('${node}')"
62+
mysqlsh --login-path="${node}" --sql \
63+
--execute="RESET MASTER;"
64+
mysqlsh --login-path="${CLUSTER_NODE}" \
65+
--execute="dba.getCluster('superset').addInstance('${node}',{recoveryMethod:'incremental'});"
66+
fi
67+
done
4968
fi
5069
export NODE_IP_ADDRESS=$(mysqlsh --python --execute "exec(open('/opt/interfaces.py').read())")
5170
/opt/envsubst-Linux-x86_64 < "/opt/superset_user.sql.tpl" > "/opt/superset_user.sql"
5271
mysqlsh \
53-
--login-path="${PRIMARY_MYSQL_NODE}" \
72+
--login-path="${CLUSTER_NODE}" \
5473
--sql \
5574
--file="/opt/superset_user.sql"
5675
mysqlrouter \
5776
--user \
5877
"superset" \
5978
--bootstrap \
60-
"superset:$(cat /run/secrets/mysql_superset_password)@${PRIMARY_MYSQL_NODE}:3306" \
79+
"superset:$(cat /run/secrets/mysql_superset_password)@${CLUSTER_NODE}:3306" \
6180
--directory \
6281
"/opt/default/mysql_router" \
6382
--conf-use-sockets \
@@ -72,7 +91,7 @@ services:
7291
--client-ssl-key \
7392
"/opt/default/mysql_router_key.pem"
7493
mysqlsh \
75-
--login-path="${PRIMARY_MYSQL_NODE}" --sql \
94+
--login-path="${CLUSTER_NODE}" --sql \
7695
--execute="DROP USER 'superset'@'$(mysqlsh --python --execute "exec(open('/opt/interfaces.py').read())")';"
7796
ifmetric ${VIRTUAL_NETWORK_INTERFACE} 2
7897
/opt/envsubst-Linux-x86_64 < "/opt/keepalived.conf.tpl" > "/opt/default/keepalived.conf";

src/crypto.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,21 @@ def generate_certificate(
128128
backend=cryptography.hazmat.backends.default_backend()
129129
)
130130

131+
@staticmethod
132+
def serialization(pem: str
133+
) -> cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey | cryptography.x509.Certificate:
134+
pem_bytes = pem.encode('utf-8')
135+
if 'PRIVATE KEY' in pem:
136+
return cryptography.hazmat.primitives.serialization.load_pem_private_key(
137+
pem_bytes,
138+
password=None,
139+
backend=cryptography.hazmat.backends.default_backend()
140+
)
141+
return cryptography.x509.load_pem_x509_certificate(
142+
pem_bytes,
143+
backend=cryptography.hazmat.backends.default_backend()
144+
)
145+
131146
@staticmethod
132147
def deserialization(pki:
133148
cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey |

src/initialize.py

Lines changed: 123 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,67 @@ def __init__(self) -> None:
118118
] # type: ignore[assignment]
119119
self.cert_manager = crypto.OpenSSL()
120120

121+
def _recover_existing_credentials(self) -> bool:
122+
for node in self.mysql_nodes:
123+
try:
124+
_, stdout, _ = node.ssh_client.exec_command(
125+
"cat /opt/superset-cluster/mysql-server/mysql_root_password"
126+
)
127+
password = stdout.read().decode().strip()
128+
if not password:
129+
continue
130+
_, stdout, _ = node.ssh_client.exec_command(
131+
"cat /opt/superset-cluster/mysql-server/superset_cluster_ca_key.pem"
132+
)
133+
ca_key_pem = stdout.read().decode().strip()
134+
_, stdout, _ = node.ssh_client.exec_command(
135+
"cat /opt/superset-cluster/mysql-server/superset_cluster_ca_certificate.pem"
136+
)
137+
ca_bundle = stdout.read().decode().strip()
138+
if not (ca_key_pem and ca_bundle):
139+
continue
140+
certs = ca_bundle.split('-----END CERTIFICATE-----')
141+
ca_cert_pem = [c for c in certs if '-----BEGIN CERTIFICATE-----' in c][-1]
142+
ca_cert_pem = ca_cert_pem.strip() + '\n-----END CERTIFICATE-----\n'
143+
self.ca_key = self.cert_manager.serialization(ca_key_pem)
144+
self.ca_certificate = self.cert_manager.serialization(ca_cert_pem)
145+
self.mysql_root_password = password
146+
break
147+
except (OSError, IndexError, ValueError):
148+
continue
149+
else:
150+
return False
151+
for node in self.mgmt_nodes:
152+
try:
153+
_, stdout, _ = node.ssh_client.exec_command(
154+
"cat /opt/superset-cluster/mysql-mgmt/mysql_superset_password"
155+
)
156+
superset_pw = stdout.read().decode().strip()
157+
if not superset_pw:
158+
continue
159+
_, stdout, _ = node.ssh_client.exec_command(
160+
"docker exec $(docker ps --filter name=superset"
161+
" --format '{{.ID}}' | head -1)"
162+
" cat /run/secrets/superset_secret_key 2>/dev/null"
163+
)
164+
secret_key = stdout.read().decode().strip()
165+
if not secret_key:
166+
continue
167+
self.mysql_superset_password = superset_pw
168+
self.superset_secret_key = secret_key
169+
return True
170+
except OSError:
171+
continue
172+
return False
173+
121174
@decorators.Overlay.run_selected_methods_once
122175
def credentials(self) -> None:
123-
self.ca_key = self.cert_manager.generate_private_key()
124-
self.ca_certificate = self.cert_manager.generate_certificate('Superset-Cluster', self.ca_key)
125-
self.mysql_root_password = self.cert_manager.generate_mysql_root_password()
126-
self.mysql_superset_password = self.cert_manager.generate_mysql_superset_password()
127-
self.superset_secret_key = self.cert_manager.generate_superset_secret_key()
176+
if not self._recover_existing_credentials():
177+
self.ca_key = self.cert_manager.generate_private_key()
178+
self.ca_certificate = self.cert_manager.generate_certificate('Superset-Cluster', self.ca_key)
179+
self.mysql_root_password = self.cert_manager.generate_mysql_root_password()
180+
self.mysql_superset_password = self.cert_manager.generate_mysql_superset_password()
181+
self.superset_secret_key = self.cert_manager.generate_superset_secret_key()
128182
for node in list(itertools.chain(self.mysql_nodes, self.mgmt_nodes)):
129183
node.key = self.cert_manager.generate_private_key()
130184
node.csr = self.cert_manager.generate_csr(f'Superset-Cluster-{node.node}', node.key)
@@ -164,6 +218,13 @@ def get_mylogin_cnf(self, node: remote.RemoteConnection) -> bytes:
164218

165219
def start_mysql_servers(self) -> None:
166220
for node in self.mysql_nodes:
221+
_, stdout, _ = node.ssh_client.exec_command(
222+
"docker inspect --format='{{.State.Health.Status}}'"
223+
" mysql 2>/dev/null"
224+
)
225+
if stdout.read().decode().strip() == "healthy":
226+
continue
227+
node.ssh_client.exec_command("docker rm -f mysql 2>/dev/null")
167228
node.upload_directory(
168229
local_directory_path="./services/mysql-server",
169230
remote_directory_path="/opt/superset-cluster/mysql-server"
@@ -195,6 +256,16 @@ def start_mysql_servers(self) -> None:
195256
)
196257

197258
def start_mysql_mgmt(self, node: remote.RemoteConnection, state: str, priority: int) -> None:
259+
_, stdout, _ = node.ssh_client.exec_command(
260+
"docker inspect --format='{{.State.Health.Status}}'"
261+
" mysql-mgmt 2>/dev/null"
262+
)
263+
if stdout.read().decode().strip() == "healthy":
264+
return
265+
node.ssh_client.exec_command(
266+
"docker rm -f mysql-mgmt mysql-mgmt-initcontainer 2>/dev/null;"
267+
" docker volume rm mysql-mgmt_default_generated 2>/dev/null"
268+
)
198269
node.upload_directory(
199270
local_directory_path="./services/mysql-mgmt",
200271
remote_directory_path="/opt/superset-cluster/mysql-mgmt"
@@ -251,6 +322,25 @@ def start_mysql_mgmt(self, node: remote.RemoteConnection, state: str, priority:
251322

252323
def start_superset(self) -> None:
253324
for node in self.mgmt_nodes:
325+
_, redis_out, _ = node.ssh_client.exec_command(
326+
"docker inspect --format='{{.State.Health.Status}}'"
327+
" redis 2>/dev/null"
328+
)
329+
_, svc_out, _ = node.ssh_client.exec_command(
330+
"docker service ps superset"
331+
" --format='{{.CurrentState}}'"
332+
" --filter desired-state=running 2>/dev/null"
333+
)
334+
redis_healthy = redis_out.read().decode().strip() == "healthy"
335+
superset_running = "Running" in svc_out.read().decode()
336+
if redis_healthy and superset_running:
337+
continue
338+
node.ssh_client.exec_command(
339+
"docker service rm superset 2>/dev/null;"
340+
" docker rm -f redis 2>/dev/null;"
341+
" docker swarm leave --force 2>/dev/null;"
342+
" docker network rm superset-network docker_gwbridge 2>/dev/null"
343+
)
254344
node.upload_directory(
255345
local_directory_path="./services/superset",
256346
remote_directory_path='/opt/superset-cluster/superset'
@@ -283,27 +373,6 @@ def start_superset(self) -> None:
283373
mysql_superset_password=self.mysql_superset_password)
284374
)
285375

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-
307376
def teardown_node(self, node: remote.RemoteConnection, is_mgmt: bool = False) -> None:
308377
commands = [
309378
"if docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null"
@@ -316,7 +385,6 @@ def teardown_node(self, node: remote.RemoteConnection, is_mgmt: bool = False) ->
316385
" | xargs -r docker network rm",
317386
"if [ -d /opt/superset-cluster ]; then"
318387
" rm -rf /opt/superset-cluster; fi",
319-
"find /opt -maxdepth 1 -name '*.pyc' -delete",
320388
]
321389
if is_mgmt:
322390
commands.extend([
@@ -330,34 +398,41 @@ def teardown_node(self, node: remote.RemoteConnection, is_mgmt: bool = False) ->
330398
" ip route del {vip}; fi".format(
331399
vip=self.virtual_ip_address),
332400
])
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)
401+
_, stdout, stderr = node.ssh_client.exec_command(" && ".join(commands))
402+
exit_status = stdout.channel.recv_exit_status()
403+
if exit_status != 0:
404+
raise RuntimeError(
405+
f"Teardown failed on {node.node} (exit {exit_status}): "
406+
f"{stderr.read().decode().strip()}"
407+
)
341408

342409
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:
349410
try:
350-
self.teardown_cluster()
351-
self._prepare_nodes()
352-
self.start_cluster()
411+
self.start_mysql_servers()
412+
self.start_mysql_mgmt(node=self.mgmt_nodes[0], state="MASTER", priority=100)
413+
self.start_mysql_mgmt(node=self.mgmt_nodes[1], state="BACKUP", priority=90)
414+
self.start_superset()
353415
finally:
354-
self._close_connections()
416+
for node in list(itertools.chain(self.mysql_nodes, self.mgmt_nodes)):
417+
node.ssh_client.close()
418+
node.sftp_client.close()
355419

356420
def cleanup(self) -> None:
357421
try:
358-
self.teardown_cluster()
422+
for node in self.mgmt_nodes:
423+
try:
424+
self.teardown_node(node, is_mgmt=True)
425+
except (OSError, RuntimeError):
426+
pass
427+
for node in self.mysql_nodes:
428+
try:
429+
self.teardown_node(node, is_mgmt=False)
430+
except (OSError, RuntimeError):
431+
pass
359432
finally:
360-
self._close_connections()
433+
for node in list(itertools.chain(self.mysql_nodes, self.mgmt_nodes)):
434+
node.ssh_client.close()
435+
node.sftp_client.close()
361436

362437

363438
if __name__ == "__main__":
@@ -371,4 +446,4 @@ def cleanup(self) -> None:
371446
if action == "cleanup":
372447
controller.cleanup()
373448
else:
374-
controller.deploy()
449+
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()

0 commit comments

Comments
 (0)