Automate post-import refresh and validation workflow

This commit is contained in:
Jason Thistlethwaite
2026-05-04 09:49:47 -04:00
parent fba494dada
commit faad70872b
13 changed files with 995 additions and 136 deletions
+78 -50
View File
@@ -6,15 +6,12 @@ post-import reset steps. It reports whether the test instance looks ready for
Helpdesk and redMCP testing without changing remote state.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import socket
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -26,20 +23,30 @@ DEFAULT_MAILPIT_HOST = "192.168.1.105"
DEFAULT_FILES_ROOT = "/var/lib/redmine/default/files"
@dataclass(frozen=True)
class CheckResult:
status: str
name: str
detail: str
def __init__(self, status, name, detail):
self.status = status
self.name = name
self.detail = detail
@dataclass(frozen=True)
class RemoteRedmine:
ssh_host: str
ssh_key: Path
remote_redmine: str
def __init__(self, ssh_host, ssh_key, remote_redmine, local=False):
self.ssh_host = ssh_host
self.ssh_key = ssh_key
self.remote_redmine = remote_redmine
self.local = local
def ssh(self, remote_command: str) -> subprocess.CompletedProcess[str]:
def ssh(self, remote_command):
if self.local:
return subprocess.run(
remote_command,
shell=True,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
return subprocess.run(
[
"ssh",
@@ -50,43 +57,49 @@ class RemoteRedmine:
self.ssh_host,
remote_command,
],
text=True,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def mysql_json_lines(self, sql: str) -> list[dict[str, Any]]:
def mysql_json_lines(self, sql):
result = self.mysql(sql)
rows: list[dict[str, Any]] = []
rows = []
for line in result.splitlines():
if not line.strip():
continue
rows.append(json.loads(bytes.fromhex(line.strip()).decode("utf-8")))
return rows
def mysql(self, sql: str) -> str:
def mysql(self, sql):
command = self._mysql_runner_command()
shell = True
if not self.local:
command = [
"ssh",
"-i",
str(self.ssh_key),
"-o",
"IdentitiesOnly=yes",
self.ssh_host,
self._mysql_runner_command(),
]
shell = False
result = subprocess.run(
[
"ssh",
"-i",
str(self.ssh_key),
"-o",
"IdentitiesOnly=yes",
self.ssh_host,
self._mysql_runner_command(),
],
command,
input=sql,
text=True,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
shell=shell,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "Remote MySQL command failed.")
return result.stdout
def _mysql_runner_command(self) -> str:
def _mysql_runner_command(self):
ruby = (
"require 'yaml'; "
"c = YAML.load_file('config/database.yml')['production']; "
@@ -99,18 +112,23 @@ class RemoteRedmine:
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
def main() -> int:
def main():
parser = argparse.ArgumentParser(description="Read-only checks for the Redmine LAN test instance.")
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
parser.add_argument("--local", action="store_true", help="Validate local Redmine paths/database instead of using SSH.")
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST)
parser.add_argument("--files-root", default=DEFAULT_FILES_ROOT)
parser.add_argument("--composer-bin", default=os.getenv("COMPOSER_BIN", "composer"))
parser.add_argument(
"--composer-bin",
default=os.getenv("COMPOSER_BIN"),
help="Optional Composer binary or composer.phar for redMCP validation.",
)
args = parser.parse_args()
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine)
checks: list[CheckResult] = []
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine, local=args.local)
checks = []
checks.extend(check_remote_basics(remote))
checks.extend(check_mailpit_connectivity(remote, args.mailpit_host))
@@ -127,8 +145,8 @@ def main() -> int:
return 1 if failures else 0
def check_remote_basics(remote: RemoteRedmine) -> list[CheckResult]:
results: list[CheckResult] = []
def check_remote_basics(remote):
results = []
result = remote.ssh("printf ok")
if result.returncode == 0 and result.stdout == "ok":
results.append(CheckResult("OK", "SSH", f"connected to {remote.ssh_host}"))
@@ -155,7 +173,7 @@ def check_remote_basics(remote: RemoteRedmine) -> list[CheckResult]:
return results
def check_mailpit_connectivity(remote: RemoteRedmine, host: str) -> list[CheckResult]:
def check_mailpit_connectivity(remote, host):
results = [
tcp_check("Mailpit HTTP from local", host, 8025),
tcp_check("Mailpit SMTP from local", host, 1025),
@@ -181,7 +199,7 @@ def check_mailpit_connectivity(remote: RemoteRedmine, host: str) -> list[CheckRe
return results
def check_files_permissions(remote: RemoteRedmine, files_root: str) -> list[CheckResult]:
def check_files_permissions(remote, files_root):
command = (
"ruby -e "
+ shell_quote(
@@ -208,8 +226,8 @@ def check_files_permissions(remote: RemoteRedmine, files_root: str) -> list[Chec
return [CheckResult("OK", "Attachment directory permissions", detail)]
def check_database_state(remote: RemoteRedmine, mailpit_host: str) -> list[CheckResult]:
results: list[CheckResult] = []
def check_database_state(remote, mailpit_host):
results = []
try:
projects = remote.mysql_json_lines(
"""
@@ -219,12 +237,7 @@ WHERE identifier IN ('fud-helpdesk', 'fud-nohelpdesk')
ORDER BY identifier;
"""
)
found = {project["identifier"] for project in projects}
missing = {"fud-helpdesk", "fud-nohelpdesk"} - found
if missing:
results.append(CheckResult("FAIL", "Controlled test projects", "missing " + ", ".join(sorted(missing))))
else:
results.append(CheckResult("OK", "Controlled test projects", ", ".join(sorted(found))))
results.append(controlled_project_check(projects))
settings_rows = remote.mysql_json_lines(helpdesk_settings_sql())
failures = helpdesk_setting_failures(settings_rows, mailpit_host)
@@ -237,7 +250,7 @@ ORDER BY identifier;
return results
def helpdesk_settings_sql() -> str:
def helpdesk_settings_sql():
return """
SELECT HEX(CAST(JSON_OBJECT(
'identifier', p.identifier,
@@ -264,7 +277,7 @@ ORDER BY p.identifier;
"""
def helpdesk_setting_failures(rows: list[dict[str, Any]], mailpit_host: str) -> list[str]:
def helpdesk_setting_failures(rows, mailpit_host):
expected = {
"protocol": "pop3",
"host": mailpit_host,
@@ -281,7 +294,7 @@ def helpdesk_setting_failures(rows: list[dict[str, Any]], mailpit_host: str) ->
"smtp_ssl": "0",
"smtp_tls": "0",
}
failures: list[str] = []
failures = []
for row in rows:
for key, value in expected.items():
if row.get(key) != value:
@@ -289,7 +302,22 @@ def helpdesk_setting_failures(rows: list[dict[str, Any]], mailpit_host: str) ->
return failures
def check_composer(composer_bin: str) -> CheckResult:
def controlled_project_check(projects):
found = {project["identifier"] for project in projects}
missing = {"fud-helpdesk", "fud-nohelpdesk"} - found
if missing:
return CheckResult(
"WARN",
"Controlled test projects",
"optional smoke-test project(s) missing after production clone: "
+ ", ".join(sorted(missing)),
)
return CheckResult("OK", "Controlled test projects", ", ".join(sorted(found)))
def check_composer(composer_bin):
if not composer_bin:
return CheckResult("WARN", "Composer validation", "skipped; pass --composer-bin to enable")
composer_path = Path(composer_bin)
composer_on_path = shutil.which(composer_bin)
if composer_on_path is None and not composer_path.exists():
@@ -302,7 +330,7 @@ def check_composer(composer_bin: str) -> CheckResult:
command = [php, composer_bin, "validate", "--working-dir=redMCP"]
result = subprocess.run(
command,
text=True,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
@@ -312,7 +340,7 @@ def check_composer(composer_bin: str) -> CheckResult:
return CheckResult("FAIL", "Composer validation", (result.stdout + result.stderr).strip())
def tcp_check(name: str, host: str, port: int) -> CheckResult:
def tcp_check(name, host, port):
try:
with socket.create_connection((host, port), timeout=5):
return CheckResult("OK", name, f"{host}:{port}")
@@ -320,7 +348,7 @@ def tcp_check(name: str, host: str, port: int) -> CheckResult:
return CheckResult("FAIL", name, f"{host}:{port} {exc.__class__.__name__}: {exc}")
def shell_quote(value: str) -> str:
def shell_quote(value):
return "'" + value.replace("'", "'\"'\"'") + "'"