Extend repair scripts with env-file support and unit tests

- Added detect_env_file() to both sys-ctl-rpr-docker-soft and sys-ctl-rpr-docker-hard
  * prefer .env, fallback to .env/env
  * append --env-file parameter automatically
- Refactored soft script to use compose_cmd() for consistent command building
- Adjusted error recovery path in soft script to also respect env-file
- Extended unit tests for soft script to cover env-file priority and restart commands
- Added new unit tests for hard script verifying env-file priority, cwd handling,
  and --only filter logic

Ref: https://chatgpt.com/share/68ad7b30-7510-800f-8172-56f03a2f40f5
This commit is contained in:
Kevin Veen-Birkenbach 2025-08-26 11:15:59 +02:00
parent e417bc19bd
commit 9756a0f75f
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
6 changed files with 269 additions and 11 deletions

View File

@ -3,15 +3,41 @@ import sys
import subprocess import subprocess
import argparse import argparse
def detect_env_file(dir_path: str) -> str | None:
"""
Return the path to a Compose env file if present (.env preferred, fallback to env).
"""
candidates = [os.path.join(dir_path, ".env"), os.path.join(dir_path, ".env", "env")]
for candidate in candidates:
if os.path.isfile(candidate):
return candidate
return None
def hard_restart_docker_services(dir_path): def hard_restart_docker_services(dir_path):
""" """
Perform a hard restart of docker-compose services in the given directory Perform a hard restart of docker-compose services in the given directory
using docker-compose down and docker-compose up -d. using docker-compose down and docker-compose up -d, adding --env-file if present.
""" """
try: try:
print(f"Performing hard restart for docker-compose services in: {dir_path}") print(f"Performing hard restart for docker-compose services in: {dir_path}")
subprocess.run(["docker-compose", "down"], cwd=dir_path, check=True)
subprocess.run(["docker-compose", "up", "-d"], cwd=dir_path, check=True) env_file = detect_env_file(dir_path)
base = ["docker-compose"]
down_cmd = base.copy()
up_cmd = base.copy()
if env_file:
down_cmd += ["--env-file", env_file]
up_cmd += ["--env-file", env_file]
down_cmd += ["down"]
up_cmd += ["up", "-d"]
subprocess.run(down_cmd, cwd=dir_path, check=True)
subprocess.run(up_cmd, cwd=dir_path, check=True)
print(f"Hard restart completed successfully in: {dir_path}") print(f"Hard restart completed successfully in: {dir_path}")
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
print(f"Error during hard restart in {dir_path}: {e}") print(f"Error during hard restart in {dir_path}: {e}")

View File

@ -43,6 +43,32 @@ def find_docker_compose_file(directory: str) -> str | None:
return None return None
def detect_env_file(project_path: str) -> str | None:
"""
Return the path to a Compose env file if present (.env preferred, fallback to env).
"""
candidates = [os.path.join(project_path, ".env"), os.path.join(project_path, ".env", "env")]
for candidate in candidates:
if os.path.isfile(candidate):
return candidate
return None
def compose_cmd(subcmd: str, project_path: str, project_name: str | None = None) -> str:
"""
Build a docker-compose command string with optional -p and --env-file if present.
Example: compose_cmd("restart", "/opt/docker/foo", "foo")
"""
parts: List[str] = [f'cd "{project_path}" && docker-compose']
if project_name:
parts += ['-p', f'"{project_name}"']
env_file = detect_env_file(project_path)
if env_file:
parts += ['--env-file', f'"{env_file}"']
parts += subcmd.split()
return " ".join(parts)
def normalize_services_arg(raw: List[str] | None, raw_str: str | None) -> List[str]: def normalize_services_arg(raw: List[str] | None, raw_str: str | None) -> List[str]:
""" """
Accept either: Accept either:
@ -57,6 +83,7 @@ def normalize_services_arg(raw: List[str] | None, raw_str: str | None) -> List[s
return [p for p in parts if p] return [p for p in parts if p]
return [] return []
def wait_while_manipulation_running( def wait_while_manipulation_running(
services: List[str], services: List[str],
waiting_time: int = 600, waiting_time: int = 600,
@ -91,6 +118,7 @@ def wait_while_manipulation_running(
print("No blocking service is running.") print("No blocking service is running.")
break break
def main(base_directory: str, manipulation_services: List[str], timeout: int | None) -> int: def main(base_directory: str, manipulation_services: List[str], timeout: int | None) -> int:
errors = 0 errors = 0
wait_while_manipulation_running(manipulation_services, waiting_time=600, timeout=timeout) wait_while_manipulation_running(manipulation_services, waiting_time=600, timeout=timeout)
@ -117,13 +145,15 @@ def main(base_directory: str, manipulation_services: List[str], timeout: int | N
print("Restarting unhealthy container in:", compose_file_path) print("Restarting unhealthy container in:", compose_file_path)
project_path = os.path.dirname(compose_file_path) project_path = os.path.dirname(compose_file_path)
try: try:
print_bash(f'cd "{project_path}" && docker-compose -p "{repo}" restart') # restart with optional --env-file and -p
print_bash(compose_cmd("restart", project_path, repo))
except Exception as e: except Exception as e:
if "port is already allocated" in str(e): if "port is already allocated" in str(e):
print("Detected port allocation problem. Executing recovery steps...") print("Detected port allocation problem. Executing recovery steps...")
print_bash(f'cd "{project_path}" && docker-compose down') # down (no -p needed), then engine restart, then up -d with -p
print_bash(compose_cmd("down", project_path))
print_bash("systemctl restart docker") print_bash("systemctl restart docker")
print_bash(f'cd "{project_path}" && docker-compose -p "{repo}" up -d') print_bash(compose_cmd("up -d", project_path, repo))
else: else:
print("Unhandled exception during restart:", e) print("Unhandled exception during restart:", e)
errors += 1 errors += 1

View File

@ -0,0 +1,140 @@
import unittest
import sys
from pathlib import Path
from importlib.util import spec_from_file_location, module_from_spec
def load_script_module():
"""
Import the script under test from roles/sys-ctl-rpr-docker-hard/files/script.py
"""
test_file = Path(__file__).resolve()
repo_root = test_file.parents[5] # .../tests/unit/roles/sys-ctl-rpr-docker-hard/files -> repo root
script_path = repo_root / "roles" / "sys-ctl-rpr-docker-hard" / "files" / "script.py"
if not script_path.exists():
raise FileNotFoundError(f"script.py not found at {script_path}")
spec = spec_from_file_location("rpr_hard_script", str(script_path))
mod = module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(mod) # type: ignore[attr-defined]
return mod
class TestRepairDockerHard(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.script = load_script_module()
def test_detect_env_file_priority(self):
s = self.script
base = "/proj"
old_isfile = s.os.path.isfile
try:
# only .env
s.os.path.isfile = lambda p: p == f"{base}/.env"
self.assertEqual(s.detect_env_file(base), f"{base}/.env")
# only .env/env
s.os.path.isfile = lambda p: p == f"{base}/.env/env"
self.assertEqual(s.detect_env_file(base), f"{base}/.env/env")
# both -> prefer .env
s.os.path.isfile = lambda p: p in (f"{base}/.env", f"{base}/.env/env")
self.assertEqual(s.detect_env_file(base), f"{base}/.env")
# none
s.os.path.isfile = lambda p: False
self.assertIsNone(s.detect_env_file(base))
finally:
s.os.path.isfile = old_isfile
def test_hard_restart_uses_envfile_and_cwd(self):
s = self.script
calls = []
def fake_run(cmd, cwd=None, check=None):
calls.append({"cmd": cmd, "cwd": cwd, "check": check})
class R: pass
return R()
old_run = s.subprocess.run
old_detect = s.detect_env_file
try:
s.subprocess.run = fake_run
s.detect_env_file = lambda d: f"{d}/.env/env" # erzwinge .env/env
s.hard_restart_docker_services("/X/APP")
# Wir erwarten zwei Aufrufe: docker-compose --env-file ... down / up -d
self.assertEqual(len(calls), 2)
self.assertEqual(calls[0]["cwd"], "/X/APP")
self.assertEqual(calls[1]["cwd"], "/X/APP")
# down
self.assertIn("docker-compose", calls[0]["cmd"])
self.assertIn("--env-file", calls[0]["cmd"])
self.assertIn("/X/APP/.env/env", calls[0]["cmd"])
self.assertIn("down", calls[0]["cmd"])
# up -d
self.assertIn("docker-compose", calls[1]["cmd"])
self.assertIn("--env-file", calls[1]["cmd"])
self.assertIn("/X/APP/.env/env", calls[1]["cmd"])
self.assertIn("up", calls[1]["cmd"])
self.assertIn("-d", calls[1]["cmd"])
finally:
s.subprocess.run = old_run
s.detect_env_file = old_detect
def test_main_scans_parent_and_filters_only(self):
s = self.script
seen = {"scandir": [], "called": []}
class FakeDirEntry:
def __init__(self, path, is_dir=True):
self.path = path
self._is_dir = is_dir
def is_dir(self):
return self._is_dir
def fake_scandir(parent):
seen["scandir"].append(parent)
return [
FakeDirEntry(f"{parent}/app1"),
FakeDirEntry(f"{parent}/app2"),
FakeDirEntry(f"{parent}/notdir", is_dir=False),
]
def fake_isdir(p):
return p == "/PARENT"
def fake_isfile(p):
# Nur app2 hat docker-compose.yml
return p in ("/PARENT/app2/docker-compose.yml",)
def fake_hard_restart(dir_path):
seen["called"].append(dir_path)
old_scandir = s.os.scandir
old_isdir = s.os.path.isdir
old_isfile = s.os.path.isfile
old_restart = s.hard_restart_docker_services
try:
s.os.scandir = fake_scandir
s.os.path.isdir = fake_isdir
s.os.path.isfile = fake_isfile
s.hard_restart_docker_services = fake_hard_restart
# Mit --only app2 -> nur app2 wird aufgerufen
sys_argv = sys.argv
sys.argv = ["x", "/PARENT", "--only", "app2"]
s.main()
self.assertEqual(seen["called"], ["/PARENT/app2"])
finally:
s.os.scandir = old_scandir
s.os.path.isdir = old_isdir
s.os.path.isfile = old_isfile
s.hard_restart_docker_services = old_restart
sys.argv = sys_argv
if __name__ == "__main__":
unittest.main()

View File

@ -42,6 +42,38 @@ class TestRepairDockerSoft(unittest.TestCase):
) )
self.assertEqual(s.normalize_services_arg([], ""), []) self.assertEqual(s.normalize_services_arg([], ""), [])
def test_detect_env_file_priority(self):
s = self.script
base = "/proj"
# Monkeypatch os.path.isfile
old_isfile = s.os.path.isfile
try:
def fake_isfile(path):
# Only .env exists
return path == f"{base}/.env"
s.os.path.isfile = fake_isfile
self.assertEqual(s.detect_env_file(base), f"{base}/.env")
# Only .env/env exists
def fake_isfile2(path):
return path == f"{base}/.env/env"
s.os.path.isfile = fake_isfile2
self.assertEqual(s.detect_env_file(base), f"{base}/.env/env")
# Both exist -> prefer .env
def fake_isfile3(path):
return path in (f"{base}/.env", f"{base}/.env/env")
s.os.path.isfile = fake_isfile3
self.assertEqual(s.detect_env_file(base), f"{base}/.env")
# Neither exists
def fake_isfile4(path):
return False
s.os.path.isfile = fake_isfile4
self.assertIsNone(s.detect_env_file(base))
finally:
s.os.path.isfile = old_isfile
def test_wait_while_manipulation_running_respects_timeout(self): def test_wait_while_manipulation_running_respects_timeout(self):
s = self.script s = self.script
calls = {"checks": 0, "sleeps": 0} calls = {"checks": 0, "sleeps": 0}
@ -77,7 +109,7 @@ class TestRepairDockerSoft(unittest.TestCase):
s.time.sleep = old_sleep s.time.sleep = old_sleep
s.time.time = old_time s.time.time = old_time
def test_main_restarts_and_counts_errors(self): def test_main_restarts_and_counts_errors_and_envfile_usage(self):
s = self.script s = self.script
cmd_log = [] cmd_log = []
@ -92,25 +124,55 @@ class TestRepairDockerSoft(unittest.TestCase):
return [] return []
def fake_find_docker_compose(path): def fake_find_docker_compose(path):
# Compose-Projekte: app1, db -> vorhanden; "other" -> nicht vorhanden
if path.endswith("/app1") or path.endswith("/db"): if path.endswith("/app1") or path.endswith("/db"):
return str(Path(path) / "docker-compose.yml") return str(Path(path) / "docker-compose.yml")
return None return None
# Steuere die detect_env_file-Antwort:
# - Für app1 existiert nur .env/env
# - Für db existiert .env
def fake_detect_env_file(project_path: str):
if project_path.endswith("/app1"):
return f"{project_path}/.env/env"
if project_path.endswith("/db"):
return f"{project_path}/.env"
return None
old_print_bash = s.print_bash old_print_bash = s.print_bash
old_find = s.find_docker_compose_file old_find = s.find_docker_compose_file
old_detect = s.detect_env_file
try: try:
s.print_bash = fake_print_bash s.print_bash = fake_print_bash
s.find_docker_compose_file = fake_find_docker_compose # <-- jetzt gleicher Name! s.find_docker_compose_file = fake_find_docker_compose
s.detect_env_file = fake_detect_env_file
errors = s.main("/BASE", manipulation_services=[], timeout=None) errors = s.main("/BASE", manipulation_services=[], timeout=None)
# one error expected for "other" (no compose file)
self.assertEqual(errors, 1) self.assertEqual(errors, 1)
restart_cmds = [c for c in cmd_log if "docker-compose -p" in c and " restart" in c] restart_cmds = [c for c in cmd_log if ' docker-compose' in c and " restart" in c]
self.assertTrue(any('cd "/BASE/app1"' in c and 'docker-compose -p "app1" restart' in c for c in restart_cmds)) # app1: --env-file "/BASE/app1/.env/env" + -p "app1"
self.assertTrue(any('cd "/BASE/db"' in c and 'docker-compose -p "db" restart' in c for c in restart_cmds)) self.assertTrue(any(
'cd "/BASE/app1"' in c and
'--env-file "/BASE/app1/.env/env"' in c and
'-p "app1"' in c and
' restart' in c
for c in restart_cmds
))
# db: --env-file "/BASE/db/.env" + -p "db"
self.assertTrue(any(
'cd "/BASE/db"' in c and
'--env-file "/BASE/db/.env"' in c and
'-p "db"' in c and
' restart' in c
for c in restart_cmds
))
finally: finally:
s.print_bash = old_print_bash s.print_bash = old_print_bash
s.find_docker_compose_file = old_find s.find_docker_compose_file = old_find
s.detect_env_file = old_detect
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()