Files
computer-playbook/roles/svc-opt-ssd-hdd/files/python/script.py
kevinveenbirkenbachandClaude Opus 5.5 8567cb6992 fix(svc-opt-ssd-hdd): recognise database images behind registry, namespace and custom builds
is_database() compared the whole repository part of the image reference
against postgres, mariadb, redis, memcached and mongo. Every reference
with a registry or namespace prefix, every derivative image and every
locally built `<engine>_custom` image therefore counted as application
data and went to the mass storage. On a local stack the central
databases report `postgres_custom:18` and `openldap_custom:13.7-slim`,
both classified False and moved to the HDD.

The check now takes the last path component of the reference, drops
digest, tag and a `_custom` suffix, and compares it against the engines
the svc-db-* providers and the app-local databases run: elasticsearch,
mariadb, memcached, mongo, mysql, openldap, opensearch, postgis,
postgres, qdrant, rabbitmq, redis, typesense, valkey.

Volumes the script already symlinked stay where they are; a database
volume misplaced on the HDD has to be moved back by hand.

Fixes #1163

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 21:02:06 +02:00

185 lines
5.4 KiB
Python

# nocheck: mirrored-unit-test - orchestrates docker stop/start around an rsync move
# between disks; its helpers read live container state through docker inspect and a unit
# test would pin the mock, not the migration
import argparse
import shutil
import subprocess
import time
from pathlib import Path
def run_command(command):
"""Run a shell command and return its output.
Args:
command: shell string; every call site passes either a static
command or one built from Ansible-templated config, which is
host-trusted and never user input.
"""
print(command)
output = (
subprocess.check_output(command, shell=True).decode("utf-8").strip() # noqa: S602
)
print(output)
return output
def stop_containers(containers):
"""Stop a list of containers."""
container_list = " ".join(containers)
print(f"Stopping containers {container_list}...")
run_command(f"container stop {container_list}")
def start_containers(containers):
"""Start a list of containers."""
container_list = " ".join(containers)
print(f"Starting containers {container_list}...")
run_command(f"container start {container_list}")
DATABASE_IMAGES = {
"elasticsearch",
"mariadb",
"memcached",
"mongo",
"mysql",
"openldap",
"opensearch",
"postgis",
"postgres",
"qdrant",
"rabbitmq",
"redis",
"typesense",
"valkey",
}
def is_database(image):
"""Tell whether an image reference names a database engine.
Args:
image: reference as the container's `.Config.Image` reports it, with
optional registry, namespace, tag and digest; a locally built
`<engine>_custom` image counts as its engine.
"""
name = image.rsplit("/", 1)[-1].split("@")[0].split(":")[0]
return name.removesuffix("_custom") in DATABASE_IMAGES
def is_symbolic_link(file_path):
return Path(file_path).is_symlink()
def get_volume_path(volume):
return run_command(
f"container volume inspect --format '{{{{ .Mountpoint }}}}' {volume}"
)
def get_image(container):
return run_command(
f"container inspect --type container --format='{{{{.Config.Image}}}}' {container}"
)
def has_healthcheck(container):
"""Check if a container has a HEALTHCHECK defined."""
result = run_command(
f"container inspect --type container --format='{{{{json .State.Health}}}}' {container}"
)
return result not in ("null", "")
def get_health_status(container):
"""Return the health status."""
return run_command(
f"container inspect --type container --format='{{{{.State.Health.Status}}}}' {container}"
)
def run_rsync(src, dest):
run_command(f"rsync -aP --remove-source-files {src} {dest}")
def delete_directory(path):
"""Deletes a directory and all its contents."""
try:
shutil.rmtree(path)
print(f"Directory {path} was successfully deleted.")
except OSError as e:
print(f"Error deleting directory {path}: {e}")
def pause_and_move(storage_path, volume, volume_path, containers):
stop_containers(containers)
storage_volume_path = str(
Path(storage_path) / "data" / "docker" / "volumes" / volume
)
Path(storage_volume_path).mkdir(parents=True, exist_ok=False)
run_rsync(f"{volume_path}/", f"{storage_volume_path}/")
delete_directory(volume_path)
Path(volume_path).symlink_to(storage_volume_path)
start_containers(containers)
def has_container_with_database(containers):
for container in containers:
image = get_image(container)
if is_database(image):
return True
return False
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Migrate Docker volumes to SSD or HDD based on container image."
)
parser.add_argument(
"--rapid-storage-path", type=str, required=True, help="Path to the SSD storage"
)
parser.add_argument(
"--mass-storage-path", type=str, required=True, help="Path to the HDD storage"
)
args = parser.parse_args()
rapid_storage_path = args.rapid_storage_path
mass_storage_path = args.mass_storage_path
volumes = run_command("container volume ls -q").splitlines()
for volume in volumes:
volume_path = get_volume_path(volume)
containers = run_command(
f"container ps -q --filter volume={volume}"
).splitlines()
if not containers:
print(
f"Skipped Volume {volume}. It does not belong to a running container."
)
continue
if is_symbolic_link(volume_path):
print(
f"Skipped Volume {volume}. The storage path {volume_path} is a symbolic link."
)
continue
for container in containers:
if has_healthcheck(container):
status = get_health_status(container)
while status != "healthy":
print(f"Wait for Container {container}, Status '{status}'...")
time.sleep(1)
status = get_health_status(container)
if has_container_with_database(containers):
print(f"Safing volume {volume} on SSD.")
pause_and_move(rapid_storage_path, volume, volume_path, containers)
else:
print(f"Safing volume {volume} on HDD.")
pause_and_move(mass_storage_path, volume, volume_path, containers)
print("Operation completed.")