468 lines
14 KiB
Python
Executable File
468 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Post-import automation for the LAN Redmine development clone.
|
|
|
|
Run this after the production database and Redmine tree have been copied onto
|
|
the dev/test host. It reapplies this repository's tracked plugin forks, runs
|
|
plugin migrations, sanitizes Helpdesk mail settings, restarts Passenger, and
|
|
performs validation. The default mode is a dry run.
|
|
"""
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import socket
|
|
from pathlib import Path
|
|
from typing import List, Optional, Tuple
|
|
|
|
|
|
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
|
|
DEFAULT_SSH_KEY = Path("/tmp/reddev")
|
|
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
|
|
DEFAULT_REMOTE_OVERRIDES = "/home/reddev/redmine-dev-overrides"
|
|
DEFAULT_FILES_ROOT = "/var/lib/redmine/default/files"
|
|
DEFAULT_MAILPIT_HOST = "192.168.1.105"
|
|
DEFAULT_SEMANTIC_LIMITS = "customer-service=5"
|
|
DEFAULT_LANSCRATCH_ROOT = Path("/opt/lanscratch/redmine-post-import")
|
|
DEFAULT_STATUS_DIR = DEFAULT_LANSCRATCH_ROOT / "status"
|
|
DEFAULT_LOCAL_REPO_ROOT = DEFAULT_LANSCRATCH_ROOT / "repo"
|
|
|
|
TRACKED_PLUGINS = (
|
|
"redmine_event_outbox",
|
|
"redmine_contacts",
|
|
"redmine_contacts_helpdesk",
|
|
)
|
|
|
|
|
|
class AutomationConfig:
|
|
def __init__(
|
|
self,
|
|
apply=False,
|
|
local=False,
|
|
repo_root=None,
|
|
status_dir=DEFAULT_STATUS_DIR,
|
|
ssh_host=DEFAULT_SSH_HOST,
|
|
ssh_key=DEFAULT_SSH_KEY,
|
|
remote_redmine=DEFAULT_REMOTE_REDMINE,
|
|
remote_overrides=DEFAULT_REMOTE_OVERRIDES,
|
|
files_root=DEFAULT_FILES_ROOT,
|
|
mailpit_host=DEFAULT_MAILPIT_HOST,
|
|
semantic_limits=DEFAULT_SEMANTIC_LIMITS,
|
|
remote_sudo=True,
|
|
skip_semantic_check=False,
|
|
skip_helpdesk_reset=False,
|
|
):
|
|
self.apply = apply
|
|
self.local = local
|
|
self.repo_root = repo_root if repo_root is not None else (DEFAULT_LOCAL_REPO_ROOT if local else Path("."))
|
|
self.status_dir = status_dir
|
|
self.ssh_host = ssh_host
|
|
self.ssh_key = ssh_key
|
|
self.remote_redmine = remote_redmine
|
|
self.remote_overrides = remote_overrides
|
|
self.files_root = files_root
|
|
self.mailpit_host = mailpit_host
|
|
self.semantic_limits = semantic_limits
|
|
self.remote_sudo = remote_sudo
|
|
self.skip_semantic_check = skip_semantic_check
|
|
self.skip_helpdesk_reset = skip_helpdesk_reset
|
|
|
|
|
|
class Step:
|
|
def __init__(self, name, commands):
|
|
self.name = name
|
|
self.commands = commands
|
|
|
|
|
|
class StepResult:
|
|
def __init__(self, step, command, returncode):
|
|
self.step = step
|
|
self.command = command
|
|
self.returncode = returncode
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
config = AutomationConfig(
|
|
apply=args.apply,
|
|
local=args.local,
|
|
repo_root=args.repo_root or (DEFAULT_LOCAL_REPO_ROOT if args.local else Path(".")),
|
|
status_dir=args.status_dir,
|
|
ssh_host=args.ssh_host,
|
|
ssh_key=args.ssh_key,
|
|
remote_redmine=args.remote_redmine,
|
|
remote_overrides=args.remote_overrides,
|
|
files_root=args.files_root,
|
|
mailpit_host=args.mailpit_host,
|
|
semantic_limits=args.semantic_limits,
|
|
remote_sudo=not args.no_remote_sudo,
|
|
skip_semantic_check=args.skip_semantic_check,
|
|
skip_helpdesk_reset=args.skip_helpdesk_reset,
|
|
)
|
|
steps = build_steps(config)
|
|
run_id = utc_stamp()
|
|
results = [] # type: List[StepResult]
|
|
|
|
print(f"mode={'apply' if config.apply else 'dry-run'}")
|
|
print(f"execution={'local' if config.local else 'remote'}")
|
|
if config.apply:
|
|
write_status(config, run_id, "running", results)
|
|
for step in steps:
|
|
print(f"\n== {step.name} ==")
|
|
for command in step.commands:
|
|
if config.apply:
|
|
print(f"running: {command}")
|
|
result = subprocess.run(command, shell=True, check=False)
|
|
results.append(StepResult(step.name, command, result.returncode))
|
|
write_status(config, run_id, "running", results)
|
|
if result.returncode != 0:
|
|
print(f"error: command failed with exit {result.returncode}", file=sys.stderr)
|
|
write_status(config, run_id, "failed", results, failed_step=step.name)
|
|
return result.returncode
|
|
else:
|
|
print(f"would run: {command}")
|
|
|
|
if not config.apply:
|
|
print("\nDry run only. Re-run with --apply after reviewing the command list.")
|
|
else:
|
|
write_status(config, run_id, "success", results)
|
|
return 0
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Run daily post-import steps for the LAN Redmine dev clone."
|
|
)
|
|
parser.add_argument("--apply", action="store_true", help="Run commands. Default is dry-run.")
|
|
parser.add_argument(
|
|
"--local",
|
|
action="store_true",
|
|
help="Run directly on the Redmine test host instead of orchestrating over SSH.",
|
|
)
|
|
parser.add_argument(
|
|
"--repo-root",
|
|
type=Path,
|
|
default=Path(os.environ["POST_IMPORT_REPO_ROOT"]) if "POST_IMPORT_REPO_ROOT" in os.environ else None,
|
|
help="Repository/payload root to copy plugins and run helper scripts from.",
|
|
)
|
|
parser.add_argument(
|
|
"--status-dir",
|
|
type=Path,
|
|
default=Path(os.getenv("POST_IMPORT_STATUS_DIR", str(DEFAULT_STATUS_DIR))),
|
|
)
|
|
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(
|
|
"--remote-redmine",
|
|
default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE),
|
|
)
|
|
parser.add_argument(
|
|
"--remote-overrides",
|
|
default=os.getenv("REDMINE_REMOTE_OVERRIDES", DEFAULT_REMOTE_OVERRIDES),
|
|
help=(
|
|
"Remote directory containing dev-only files to overlay after the production copy. "
|
|
"If it is absent on the remote host, the step is skipped."
|
|
),
|
|
)
|
|
parser.add_argument("--files-root", default=DEFAULT_FILES_ROOT)
|
|
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST)
|
|
parser.add_argument("--semantic-limits", default=DEFAULT_SEMANTIC_LIMITS)
|
|
parser.add_argument(
|
|
"--no-remote-sudo",
|
|
action="store_true",
|
|
help="Do not use sudo for remote write/permission steps.",
|
|
)
|
|
parser.add_argument("--skip-semantic-check", action="store_true")
|
|
parser.add_argument("--skip-helpdesk-reset", action="store_true")
|
|
return parser.parse_args()
|
|
|
|
|
|
def build_steps(config):
|
|
steps = [
|
|
Step(
|
|
"preflight",
|
|
preflight_commands(config),
|
|
),
|
|
Step(
|
|
"restore dev-local overrides",
|
|
(
|
|
restore_overrides_command(config),
|
|
),
|
|
),
|
|
Step(
|
|
"reapply tracked plugins",
|
|
(
|
|
copy_plugins_command(config),
|
|
),
|
|
),
|
|
Step(
|
|
"run plugin migrations",
|
|
(
|
|
redmine_command(
|
|
config,
|
|
"RAILS_ENV=production bundle exec rake redmine:plugins:migrate",
|
|
),
|
|
),
|
|
),
|
|
Step(
|
|
"fix dev writable paths",
|
|
(
|
|
fix_permissions_command(config),
|
|
),
|
|
),
|
|
]
|
|
|
|
if not config.skip_helpdesk_reset:
|
|
steps.append(
|
|
Step(
|
|
"reset Helpdesk mail settings",
|
|
(
|
|
helpdesk_reset_command(config),
|
|
),
|
|
)
|
|
)
|
|
|
|
steps.extend(
|
|
[
|
|
Step(
|
|
"restart Passenger",
|
|
(
|
|
redmine_command(config, "touch tmp/restart.txt"),
|
|
),
|
|
),
|
|
Step(
|
|
"validate test instance",
|
|
(
|
|
validate_command(config),
|
|
),
|
|
),
|
|
Step(
|
|
"check outbox worker",
|
|
outbox_commands(config),
|
|
),
|
|
]
|
|
)
|
|
|
|
if not config.skip_semantic_check:
|
|
steps.append(
|
|
Step(
|
|
"validate semantic index dry-run",
|
|
(
|
|
semantic_check_command(config),
|
|
),
|
|
)
|
|
)
|
|
|
|
return steps
|
|
|
|
|
|
def preflight_commands(config):
|
|
commands = tuple(local_test(str(config.repo_root / "plugins" / plugin)) for plugin in TRACKED_PLUGINS)
|
|
if config.local:
|
|
return commands + (f"test -d {q(config.remote_redmine)}",)
|
|
return commands + (ssh(config, f"test -d {q(config.remote_redmine)}"),)
|
|
|
|
|
|
def semantic_check_command(config):
|
|
refresh = config.repo_root / "semantic_index" / "refresh.sh"
|
|
python_bin = config.repo_root / ".venv" / "bin" / "python"
|
|
command = "SEMANTIC_INDEX_PROJECT_LIMITS={limits} {refresh}".format(
|
|
limits=q(config.semantic_limits),
|
|
refresh=q(str(refresh)),
|
|
)
|
|
if not config.local:
|
|
return command
|
|
return (
|
|
"if test -x {python}; then PYTHON={python} {command}; "
|
|
"else echo 'semantic index runtime missing; skipping dry-run'; fi"
|
|
).format(
|
|
python=q(str(python_bin)),
|
|
command=command,
|
|
)
|
|
|
|
|
|
def local_test(path):
|
|
return f"test -d {q(path)}"
|
|
|
|
|
|
def restore_overrides_command(config):
|
|
command = "if [ -d {overrides} ]; then {sudo}rsync -a {overrides}/ {redmine}/; else echo 'no dev overrides directory: {overrides}'; fi".format(
|
|
sudo=remote_sudo_prefix(config),
|
|
overrides=q(config.remote_overrides),
|
|
redmine=q(config.remote_redmine),
|
|
)
|
|
return command if config.local else ssh(config, command)
|
|
|
|
|
|
def copy_plugins_command(config):
|
|
sources = " ".join(q(str(config.repo_root / "plugins" / plugin)) for plugin in TRACKED_PLUGINS)
|
|
if config.local:
|
|
return f"rsync -a --delete {sources} {q(config.remote_redmine + '/plugins/')}"
|
|
return (
|
|
"rsync -a --delete "
|
|
f"{rsync_path_option(config)}"
|
|
f"-e {q(ssh_transport(config))} "
|
|
f"{sources} "
|
|
f"{q(config.ssh_host + ':' + config.remote_redmine + '/plugins/')}"
|
|
)
|
|
|
|
|
|
def redmine_command(config, command):
|
|
full = f"cd {q(config.remote_redmine)} && {command}"
|
|
return full if config.local else ssh(config, full)
|
|
|
|
|
|
def fix_permissions_command(config):
|
|
command = "{sudo}mkdir -p {files} {redmine}/tmp {redmine}/log && {sudo}chmod -R g+rwX {files} {redmine}/tmp {redmine}/log && {sudo}find {files} -type d -exec chmod g+s {{}} +".format(
|
|
sudo=remote_sudo_prefix(config),
|
|
files=q(config.files_root),
|
|
redmine=q(config.remote_redmine),
|
|
)
|
|
return command if config.local else ssh(config, command)
|
|
|
|
|
|
def script(config, name):
|
|
path = config.repo_root / name
|
|
if str(config.repo_root) == ".":
|
|
return q(f"./{name}")
|
|
return q(str(path))
|
|
|
|
|
|
def helpdesk_reset_command(config):
|
|
base = f"{script(config, 'reset_helpdesk_mail_settings.py')} "
|
|
if config.local:
|
|
return (
|
|
base +
|
|
f"--local --remote-redmine {q(config.remote_redmine)} "
|
|
f"--mailpit-host {q(config.mailpit_host)}"
|
|
)
|
|
return (
|
|
base +
|
|
f"--ssh-host {q(config.ssh_host)} "
|
|
f"--ssh-key {q(str(config.ssh_key))} "
|
|
f"--remote-redmine {q(config.remote_redmine)} "
|
|
f"--mailpit-host {q(config.mailpit_host)}"
|
|
)
|
|
|
|
|
|
def validate_command(config):
|
|
base = f"{script(config, 'validate_test_instance.py')} "
|
|
if config.local:
|
|
return (
|
|
base +
|
|
f"--local --remote-redmine {q(config.remote_redmine)} "
|
|
f"--mailpit-host {q(config.mailpit_host)} "
|
|
f"--files-root {q(config.files_root)}"
|
|
)
|
|
return (
|
|
base +
|
|
f"--ssh-host {q(config.ssh_host)} "
|
|
f"--ssh-key {q(str(config.ssh_key))} "
|
|
f"--remote-redmine {q(config.remote_redmine)} "
|
|
f"--mailpit-host {q(config.mailpit_host)} "
|
|
f"--files-root {q(config.files_root)}"
|
|
)
|
|
|
|
|
|
def outbox_commands(config):
|
|
base = script(config, "redmine_outbox_worker.py")
|
|
local = " --local" if config.local else ""
|
|
return (
|
|
f"{base}{local} --status",
|
|
f"{base}{local} --dry-run --batch-size 10",
|
|
)
|
|
|
|
|
|
def ssh(config, remote_command):
|
|
return (
|
|
"ssh "
|
|
f"-i {q(str(config.ssh_key))} "
|
|
"-o IdentitiesOnly=yes "
|
|
f"{q(config.ssh_host)} "
|
|
f"{q(remote_command)}"
|
|
)
|
|
|
|
|
|
def ssh_transport(config):
|
|
return f"ssh -i {str(config.ssh_key)} -o IdentitiesOnly=yes"
|
|
|
|
|
|
def remote_sudo_prefix(config):
|
|
return "sudo " if config.remote_sudo else ""
|
|
|
|
|
|
def rsync_path_option(config):
|
|
return "--rsync-path 'sudo rsync' " if config.remote_sudo else ""
|
|
|
|
|
|
def write_status(
|
|
config: AutomationConfig,
|
|
run_id: str,
|
|
status: str,
|
|
results,
|
|
failed_step=None,
|
|
):
|
|
now = utc_iso()
|
|
document = {
|
|
"run_id": run_id,
|
|
"started_at": run_id_to_iso(run_id),
|
|
"updated_at": now,
|
|
"finished_at": now if status in {"success", "failed"} else None,
|
|
"mode": "apply" if config.apply else "dry-run",
|
|
"execution": "local" if config.local else "remote",
|
|
"host": socket.gethostname(),
|
|
"remote_redmine": config.remote_redmine,
|
|
"repo_root": str(config.repo_root),
|
|
"status": status,
|
|
"failed_step": failed_step,
|
|
"steps": [
|
|
{"step": item.step, "command": item.command, "returncode": item.returncode}
|
|
for item in results
|
|
],
|
|
}
|
|
config.status_dir.mkdir(parents=True, exist_ok=True)
|
|
runs_dir = config.status_dir / "runs"
|
|
runs_dir.mkdir(parents=True, exist_ok=True)
|
|
write_json(runs_dir / f"{run_id}.json", document)
|
|
write_json(config.status_dir / "latest.json", document)
|
|
if status == "success":
|
|
write_json(config.status_dir / "latest-success.json", document)
|
|
return document
|
|
|
|
|
|
def write_json(path, document):
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
tmp.replace(path)
|
|
|
|
|
|
def utc_stamp():
|
|
return dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
|
|
|
|
def utc_iso():
|
|
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def run_id_to_iso(run_id):
|
|
try:
|
|
parsed = dt.datetime.strptime(run_id, "%Y%m%dT%H%M%SZ").replace(tzinfo=dt.timezone.utc)
|
|
return parsed.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
except ValueError:
|
|
return run_id
|
|
|
|
|
|
def q(value):
|
|
return shlex.quote(value)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|