Automate post-import refresh and validation workflow
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
/.cache/
|
/.cache/
|
||||||
|
/.venv/
|
||||||
/__pycache__/
|
/__pycache__/
|
||||||
/redmine-copy/
|
/redmine-copy/
|
||||||
/dist/*.tar.gz
|
/dist/*.tar.gz
|
||||||
@@ -7,4 +8,5 @@ redMCP/test.env
|
|||||||
redMCP/vendor/
|
redMCP/vendor/
|
||||||
redMCP/composer.phar
|
redMCP/composer.phar
|
||||||
.env
|
.env
|
||||||
|
semantic_index/.env
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -15,6 +15,92 @@ redMCP testing.
|
|||||||
- Mailpit ports: HTTP `8025`, SMTP `1025`, POP3 `1110`
|
- Mailpit ports: HTTP `8025`, SMTP `1025`, POP3 `1110`
|
||||||
- POP3 credentials: `test` / `testpass`
|
- POP3 credentials: `test` / `testpass`
|
||||||
- SMTP authentication: none
|
- SMTP authentication: none
|
||||||
|
- Shared scratch path: `/opt/lanscratch`
|
||||||
|
- Post-import payload path:
|
||||||
|
`/opt/lanscratch/redmine-post-import/repo`
|
||||||
|
- Post-import status path:
|
||||||
|
`/opt/lanscratch/redmine-post-import/status`
|
||||||
|
|
||||||
|
## Automated Daily Post-Import
|
||||||
|
|
||||||
|
Stage the post-import payload from this host into the shared LAN scratch folder:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./stage_post_import_payload.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The default staging mode is a dry run. Review the `rsync` command, then apply:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./stage_post_import_payload.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
After the fresh production database and `/usr/share/redmine` tree have been
|
||||||
|
copied onto the LAN test host, the test host should run the automation locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/lanscratch/redmine-post-import/repo
|
||||||
|
./post_import_refresh.py --local --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
For manual review on the test host, omit `--apply` first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/lanscratch/redmine-post-import/repo
|
||||||
|
./post_import_refresh.py --local
|
||||||
|
```
|
||||||
|
|
||||||
|
This host can check completion by reading:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/lanscratch/redmine-post-import/status/latest.json
|
||||||
|
/opt/lanscratch/redmine-post-import/status/latest-success.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The automation:
|
||||||
|
|
||||||
|
- verifies the tracked plugin source directories exist locally and that the
|
||||||
|
remote Redmine path exists;
|
||||||
|
- overlays remote dev-only files from `/home/reddev/redmine-dev-overrides` when
|
||||||
|
that directory exists;
|
||||||
|
- reapplies `redmine_event_outbox`, `redmine_contacts`, and
|
||||||
|
`redmine_contacts_helpdesk` from this repository into
|
||||||
|
`/usr/share/redmine/plugins/`;
|
||||||
|
- runs `RAILS_ENV=production bundle exec rake redmine:plugins:migrate`;
|
||||||
|
- fixes group-write permissions on attachment, `tmp`, and `log` paths;
|
||||||
|
- runs `reset_helpdesk_mail_settings.py` unless `--skip-helpdesk-reset` is
|
||||||
|
passed;
|
||||||
|
- restarts Passenger with `touch tmp/restart.txt`;
|
||||||
|
- runs `validate_test_instance.py`;
|
||||||
|
- checks outbox status and dry-runs a small outbox batch;
|
||||||
|
- runs a semantic-index dry-run smoke check only.
|
||||||
|
|
||||||
|
Each applied run writes status JSON under
|
||||||
|
`/opt/lanscratch/redmine-post-import/status/runs/`, updates `latest.json` after
|
||||||
|
each step, and updates `latest-success.json` only after every step exits
|
||||||
|
successfully. The JSON includes the run id, host, execution mode, Redmine path,
|
||||||
|
repo root, failed step when applicable, and per-command return codes.
|
||||||
|
|
||||||
|
Remote write and permission steps use `sudo` by default because a fresh
|
||||||
|
production file copy may leave `/usr/share/redmine` or attachment paths owned by
|
||||||
|
another user. This applies in both local and SSH modes. If the dev host already
|
||||||
|
gives the runner write access to those paths, pass `--no-remote-sudo`.
|
||||||
|
|
||||||
|
The older SSH orchestration path from this host remains available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./post_import_refresh.py
|
||||||
|
./post_import_refresh.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The automation deliberately does **not** run a semantic-index apply refresh,
|
||||||
|
does **not** use `--force-rebuild`, and does **not** enable the semantic-index
|
||||||
|
refresh timer. After a fresh database clone, treat semantic-index writes or a
|
||||||
|
Qdrant rebuild as a separate manual maintenance action with a snapshot or
|
||||||
|
isolated dev collection first.
|
||||||
|
|
||||||
|
Use the manual sections below for troubleshooting individual steps or for
|
||||||
|
running the sequence by hand.
|
||||||
|
|
||||||
## 1. Validate The Fresh Import
|
## 1. Validate The Fresh Import
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
class CreateEventOutboxEvents < ActiveRecord::Migration
|
class CreateEventOutboxEvents < ActiveRecord::Migration
|
||||||
def change
|
def change
|
||||||
|
return if table_exists?(:event_outbox_events)
|
||||||
|
|
||||||
create_table :event_outbox_events do |t|
|
create_table :event_outbox_events do |t|
|
||||||
t.string :event_type, :null => false
|
t.string :event_type, :null => false
|
||||||
t.string :source_type, :null => false
|
t.string :source_type, :null => false
|
||||||
|
|||||||
Executable
+467
@@ -0,0 +1,467 @@
|
|||||||
|
#!/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())
|
||||||
+57
-53
@@ -7,8 +7,6 @@ writes deterministic JSONL records, and marks rows processed only after the
|
|||||||
write succeeds.
|
write succeeds.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -17,7 +15,6 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
@@ -32,15 +29,16 @@ class OutboxWorkerError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RemoteRedmine:
|
class RemoteRedmine:
|
||||||
ssh_host: str
|
def __init__(self, ssh_host, ssh_key, remote_redmine, local=False):
|
||||||
ssh_key: Path
|
self.ssh_host = ssh_host
|
||||||
remote_redmine: str
|
self.ssh_key = ssh_key
|
||||||
|
self.remote_redmine = remote_redmine
|
||||||
|
self.local = local
|
||||||
|
|
||||||
def mysql_json_lines(self, sql: str) -> list[dict[str, Any]]:
|
def mysql_json_lines(self, sql):
|
||||||
stdout = self.mysql(sql)
|
stdout = self.mysql(sql)
|
||||||
rows: list[dict[str, Any]] = []
|
rows = []
|
||||||
for line in stdout.splitlines():
|
for line in stdout.splitlines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
@@ -52,24 +50,29 @@ class RemoteRedmine:
|
|||||||
raise OutboxWorkerError(f"Remote query returned non-hex row: {line[:200]}") from exc
|
raise OutboxWorkerError(f"Remote query returned non-hex row: {line[:200]}") from exc
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def mysql(self, sql: str) -> str:
|
def mysql(self, sql):
|
||||||
command = [
|
command = self._mysql_runner_command()
|
||||||
"ssh",
|
shell = True
|
||||||
"-i",
|
if not self.local:
|
||||||
str(self.ssh_key),
|
command = [
|
||||||
"-o",
|
"ssh",
|
||||||
"IdentitiesOnly=yes",
|
"-i",
|
||||||
self.ssh_host,
|
str(self.ssh_key),
|
||||||
self._mysql_runner_command(),
|
"-o",
|
||||||
]
|
"IdentitiesOnly=yes",
|
||||||
|
self.ssh_host,
|
||||||
|
self._mysql_runner_command(),
|
||||||
|
]
|
||||||
|
shell = False
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
command,
|
||||||
input=sql,
|
input=sql,
|
||||||
text=True,
|
universal_newlines=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
check=False,
|
check=False,
|
||||||
|
shell=shell,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise OutboxWorkerError(f"Could not run ssh: {exc}") from exc
|
raise OutboxWorkerError(f"Could not run ssh: {exc}") from exc
|
||||||
@@ -78,7 +81,7 @@ class RemoteRedmine:
|
|||||||
raise OutboxWorkerError(result.stderr.strip() or "Remote MySQL command failed.")
|
raise OutboxWorkerError(result.stderr.strip() or "Remote MySQL command failed.")
|
||||||
return result.stdout
|
return result.stdout
|
||||||
|
|
||||||
def _mysql_runner_command(self) -> str:
|
def _mysql_runner_command(self):
|
||||||
ruby = (
|
ruby = (
|
||||||
"require 'yaml'; "
|
"require 'yaml'; "
|
||||||
"c = YAML.load_file('config/database.yml')['production']; "
|
"c = YAML.load_file('config/database.yml')['production']; "
|
||||||
@@ -91,10 +94,11 @@ class RemoteRedmine:
|
|||||||
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
|
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Process Redmine event outbox rows into enriched JSONL documents.")
|
parser = argparse.ArgumentParser(description="Process Redmine event outbox rows into enriched JSONL documents.")
|
||||||
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
|
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("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
|
||||||
|
parser.add_argument("--local", action="store_true", help="Read the Redmine database locally instead of over SSH.")
|
||||||
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
|
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
|
||||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||||
parser.add_argument("--batch-size", type=int, default=20)
|
parser.add_argument("--batch-size", type=int, default=20)
|
||||||
@@ -111,7 +115,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--apply-purge", action="store_true", help="Actually delete rows selected by --purge-processed-days.")
|
parser.add_argument("--apply-purge", action="store_true", help="Actually delete rows selected by --purge-processed-days.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine)
|
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine, local=args.local)
|
||||||
worker_id = make_worker_id()
|
worker_id = make_worker_id()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -164,7 +168,7 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def pending_events(remote: RemoteRedmine, limit: int, max_attempts: int, stale_lock_minutes: int) -> list[dict[str, Any]]:
|
def pending_events(remote, limit, max_attempts, stale_lock_minutes):
|
||||||
return remote.mysql_json_lines(
|
return remote.mysql_json_lines(
|
||||||
f"""
|
f"""
|
||||||
SELECT HEX(CAST(JSON_OBJECT(
|
SELECT HEX(CAST(JSON_OBJECT(
|
||||||
@@ -190,7 +194,7 @@ LIMIT {sql_int(limit)};
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def outbox_status(remote: RemoteRedmine, max_attempts: int, stale_lock_minutes: int) -> dict[str, Any]:
|
def outbox_status(remote, max_attempts, stale_lock_minutes):
|
||||||
rows = remote.mysql_json_lines(
|
rows = remote.mysql_json_lines(
|
||||||
f"""
|
f"""
|
||||||
SELECT HEX(CAST(JSON_OBJECT(
|
SELECT HEX(CAST(JSON_OBJECT(
|
||||||
@@ -217,12 +221,12 @@ FROM event_outbox_events;
|
|||||||
|
|
||||||
|
|
||||||
def claim_events(
|
def claim_events(
|
||||||
remote: RemoteRedmine,
|
remote,
|
||||||
worker_id: str,
|
worker_id,
|
||||||
limit: int,
|
limit,
|
||||||
max_attempts: int,
|
max_attempts,
|
||||||
stale_lock_minutes: int,
|
stale_lock_minutes,
|
||||||
) -> list[dict[str, Any]]:
|
):
|
||||||
remote.mysql(
|
remote.mysql(
|
||||||
f"""
|
f"""
|
||||||
UPDATE event_outbox_events
|
UPDATE event_outbox_events
|
||||||
@@ -258,7 +262,7 @@ LIMIT {sql_int(limit)};
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def purge_processed(remote: RemoteRedmine, days: int, apply: bool) -> int:
|
def purge_processed(remote, days, apply):
|
||||||
if days < 0:
|
if days < 0:
|
||||||
raise OutboxWorkerError("--purge-processed-days must be zero or greater.")
|
raise OutboxWorkerError("--purge-processed-days must be zero or greater.")
|
||||||
count_sql = f"""
|
count_sql = f"""
|
||||||
@@ -282,9 +286,9 @@ WHERE processed_at IS NOT NULL
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
def enrich_event(remote: RemoteRedmine, event: dict[str, Any]) -> list[dict[str, Any]]:
|
def enrich_event(remote, event):
|
||||||
payload = parse_payload(event.get("payload"))
|
payload = parse_payload(event.get("payload"))
|
||||||
documents: list[dict[str, Any]] = [event_document(event, payload)]
|
documents = [event_document(event, payload)]
|
||||||
event_type = str(event.get("event_type") or "")
|
event_type = str(event.get("event_type") or "")
|
||||||
|
|
||||||
if event_type.startswith("helpdesk_ticket."):
|
if event_type.startswith("helpdesk_ticket."):
|
||||||
@@ -301,7 +305,7 @@ def enrich_event(remote: RemoteRedmine, event: dict[str, Any]) -> list[dict[str,
|
|||||||
return [with_event_context(document, event) for document in documents]
|
return [with_event_context(document, event) for document in documents]
|
||||||
|
|
||||||
|
|
||||||
def event_document(event: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
|
def event_document(event, payload):
|
||||||
return {
|
return {
|
||||||
"doc_type": "event",
|
"doc_type": "event",
|
||||||
"doc_id": f"event:{event.get('id')}",
|
"doc_id": f"event:{event.get('id')}",
|
||||||
@@ -318,35 +322,35 @@ def event_document(event: dict[str, Any], payload: dict[str, Any]) -> dict[str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def fetch_ticket_documents(remote: RemoteRedmine, ids: Iterable[Any]) -> list[dict[str, Any]]:
|
def fetch_ticket_documents(remote, ids):
|
||||||
id_list = sql_id_list(ids)
|
id_list = sql_id_list(ids)
|
||||||
if not id_list:
|
if not id_list:
|
||||||
return []
|
return []
|
||||||
return remote.mysql_json_lines(ticket_sql(f"ht.id IN ({id_list})"))
|
return remote.mysql_json_lines(ticket_sql(f"ht.id IN ({id_list})"))
|
||||||
|
|
||||||
|
|
||||||
def fetch_tickets_by_issue(remote: RemoteRedmine, issue_ids: Iterable[Any]) -> list[dict[str, Any]]:
|
def fetch_tickets_by_issue(remote, issue_ids):
|
||||||
id_list = sql_id_list(issue_ids)
|
id_list = sql_id_list(issue_ids)
|
||||||
if not id_list:
|
if not id_list:
|
||||||
return []
|
return []
|
||||||
return remote.mysql_json_lines(ticket_sql(f"ht.issue_id IN ({id_list})"))
|
return remote.mysql_json_lines(ticket_sql(f"ht.issue_id IN ({id_list})"))
|
||||||
|
|
||||||
|
|
||||||
def fetch_message_documents(remote: RemoteRedmine, ids: Iterable[Any]) -> list[dict[str, Any]]:
|
def fetch_message_documents(remote, ids):
|
||||||
id_list = sql_id_list(ids)
|
id_list = sql_id_list(ids)
|
||||||
if not id_list:
|
if not id_list:
|
||||||
return []
|
return []
|
||||||
return remote.mysql_json_lines(message_sql(f"jm.id IN ({id_list})"))
|
return remote.mysql_json_lines(message_sql(f"jm.id IN ({id_list})"))
|
||||||
|
|
||||||
|
|
||||||
def fetch_messages_by_journal(remote: RemoteRedmine, journal_ids: Iterable[Any]) -> list[dict[str, Any]]:
|
def fetch_messages_by_journal(remote, journal_ids):
|
||||||
id_list = sql_id_list(journal_ids)
|
id_list = sql_id_list(journal_ids)
|
||||||
if not id_list:
|
if not id_list:
|
||||||
return []
|
return []
|
||||||
return remote.mysql_json_lines(message_sql(f"jm.journal_id IN ({id_list})"))
|
return remote.mysql_json_lines(message_sql(f"jm.journal_id IN ({id_list})"))
|
||||||
|
|
||||||
|
|
||||||
def fetch_contact_documents(remote: RemoteRedmine, ids: Iterable[Any]) -> list[dict[str, Any]]:
|
def fetch_contact_documents(remote, ids):
|
||||||
id_list = sql_id_list(ids)
|
id_list = sql_id_list(ids)
|
||||||
if not id_list:
|
if not id_list:
|
||||||
return []
|
return []
|
||||||
@@ -375,7 +379,7 @@ ORDER BY c.id;
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def ticket_sql(where_clause: str) -> str:
|
def ticket_sql(where_clause):
|
||||||
return f"""
|
return f"""
|
||||||
SELECT HEX(CAST(JSON_OBJECT(
|
SELECT HEX(CAST(JSON_OBJECT(
|
||||||
'doc_type', 'ticket',
|
'doc_type', 'ticket',
|
||||||
@@ -423,7 +427,7 @@ ORDER BY ht.id;
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def message_sql(where_clause: str) -> str:
|
def message_sql(where_clause):
|
||||||
return f"""
|
return f"""
|
||||||
SELECT HEX(CAST(JSON_OBJECT(
|
SELECT HEX(CAST(JSON_OBJECT(
|
||||||
'doc_type', 'message',
|
'doc_type', 'message',
|
||||||
@@ -474,7 +478,7 @@ ORDER BY jm.id;
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def with_event_context(document: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
|
def with_event_context(document, event):
|
||||||
document["event_id"] = event.get("id")
|
document["event_id"] = event.get("id")
|
||||||
document["event_type"] = event.get("event_type")
|
document["event_type"] = event.get("event_type")
|
||||||
document["event_occurred_at"] = event.get("occurred_at")
|
document["event_occurred_at"] = event.get("occurred_at")
|
||||||
@@ -482,7 +486,7 @@ def with_event_context(document: dict[str, Any], event: dict[str, Any]) -> dict[
|
|||||||
return document
|
return document
|
||||||
|
|
||||||
|
|
||||||
def append_jsonl(path: Path, documents: Iterable[dict[str, Any]]) -> None:
|
def append_jsonl(path, documents):
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with path.open("a", encoding="utf-8") as handle:
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
for document in documents:
|
for document in documents:
|
||||||
@@ -490,7 +494,7 @@ def append_jsonl(path: Path, documents: Iterable[dict[str, Any]]) -> None:
|
|||||||
handle.write("\n")
|
handle.write("\n")
|
||||||
|
|
||||||
|
|
||||||
def mark_processed(remote: RemoteRedmine, event_id: Any, worker_id: str) -> None:
|
def mark_processed(remote, event_id, worker_id):
|
||||||
remote.mysql(
|
remote.mysql(
|
||||||
f"""
|
f"""
|
||||||
UPDATE event_outbox_events
|
UPDATE event_outbox_events
|
||||||
@@ -501,7 +505,7 @@ WHERE id = {sql_int(event_id)}
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def mark_failed(remote: RemoteRedmine, event_id: Any, worker_id: str, exc: Exception) -> None:
|
def mark_failed(remote, event_id, worker_id, exc):
|
||||||
message = f"{exc.__class__.__name__}: {exc}"
|
message = f"{exc.__class__.__name__}: {exc}"
|
||||||
remote.mysql(
|
remote.mysql(
|
||||||
f"""
|
f"""
|
||||||
@@ -516,7 +520,7 @@ WHERE id = {sql_int(event_id)}
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def release_claims(remote: RemoteRedmine, worker_id: str) -> None:
|
def release_claims(remote, worker_id):
|
||||||
remote.mysql(
|
remote.mysql(
|
||||||
f"""
|
f"""
|
||||||
UPDATE event_outbox_events
|
UPDATE event_outbox_events
|
||||||
@@ -527,7 +531,7 @@ WHERE processed_at IS NULL
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_payload(value: Any) -> dict[str, Any]:
|
def parse_payload(value):
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return value
|
return value
|
||||||
if not value:
|
if not value:
|
||||||
@@ -539,7 +543,7 @@ def parse_payload(value: Any) -> dict[str, Any]:
|
|||||||
return parsed if isinstance(parsed, dict) else {}
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def sql_id_list(values: Iterable[Any]) -> str:
|
def sql_id_list(values):
|
||||||
ids = []
|
ids = []
|
||||||
for value in values:
|
for value in values:
|
||||||
try:
|
try:
|
||||||
@@ -551,22 +555,22 @@ def sql_id_list(values: Iterable[Any]) -> str:
|
|||||||
return ",".join(sorted(set(ids), key=int))
|
return ",".join(sorted(set(ids), key=int))
|
||||||
|
|
||||||
|
|
||||||
def sql_int(value: Any) -> int:
|
def sql_int(value):
|
||||||
try:
|
try:
|
||||||
return max(0, int(value))
|
return max(0, int(value))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def sql_string(value: str) -> str:
|
def sql_string(value):
|
||||||
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||||
|
|
||||||
|
|
||||||
def shell_quote(value: str) -> str:
|
def shell_quote(value):
|
||||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
|
||||||
def make_worker_id() -> str:
|
def make_worker_id():
|
||||||
return f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:12]}"
|
return f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,17 @@ settings so test mail flows through Mailpit and imported real credentials cannot
|
|||||||
be used accidentally.
|
be used accidentally.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
|
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
|
||||||
DEFAULT_SSH_KEY = Path("/tmp/reddev")
|
DEFAULT_SSH_KEY = Path("/home/iadnah/reddev")
|
||||||
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
|
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
|
||||||
DEFAULT_MAILPIT_HOST = "192.168.1.105"
|
DEFAULT_MAILPIT_HOST = "192.168.1.105"
|
||||||
|
|
||||||
@@ -55,15 +52,16 @@ class ResetError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RemoteRedmine:
|
class RemoteRedmine:
|
||||||
ssh_host: str
|
def __init__(self, ssh_host, ssh_key, remote_redmine, local=False):
|
||||||
ssh_key: Path
|
self.ssh_host = ssh_host
|
||||||
remote_redmine: str
|
self.ssh_key = ssh_key
|
||||||
|
self.remote_redmine = remote_redmine
|
||||||
|
self.local = local
|
||||||
|
|
||||||
def mysql_json_lines(self, sql: str) -> list[dict[str, Any]]:
|
def mysql_json_lines(self, sql):
|
||||||
stdout = self.mysql(sql)
|
stdout = self.mysql(sql)
|
||||||
rows: list[dict[str, Any]] = []
|
rows = []
|
||||||
for line in stdout.splitlines():
|
for line in stdout.splitlines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
@@ -73,24 +71,29 @@ class RemoteRedmine:
|
|||||||
raise ResetError(f"Remote query returned an unexpected row: {line[:200]}") from exc
|
raise ResetError(f"Remote query returned an unexpected row: {line[:200]}") from exc
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def mysql(self, sql: str) -> str:
|
def mysql(self, sql):
|
||||||
command = [
|
command = self._mysql_runner_command()
|
||||||
"ssh",
|
shell = True
|
||||||
"-i",
|
if not self.local:
|
||||||
str(self.ssh_key),
|
command = [
|
||||||
"-o",
|
"ssh",
|
||||||
"IdentitiesOnly=yes",
|
"-i",
|
||||||
self.ssh_host,
|
str(self.ssh_key),
|
||||||
self._mysql_runner_command(),
|
"-o",
|
||||||
]
|
"IdentitiesOnly=yes",
|
||||||
|
self.ssh_host,
|
||||||
|
self._mysql_runner_command(),
|
||||||
|
]
|
||||||
|
shell = False
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
command,
|
||||||
input=sql,
|
input=sql,
|
||||||
text=True,
|
universal_newlines=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
check=False,
|
check=False,
|
||||||
|
shell=shell,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise ResetError(f"Could not run ssh: {exc}") from exc
|
raise ResetError(f"Could not run ssh: {exc}") from exc
|
||||||
@@ -99,7 +102,7 @@ class RemoteRedmine:
|
|||||||
raise ResetError(result.stderr.strip() or "Remote MySQL command failed.")
|
raise ResetError(result.stderr.strip() or "Remote MySQL command failed.")
|
||||||
return result.stdout
|
return result.stdout
|
||||||
|
|
||||||
def _mysql_runner_command(self) -> str:
|
def _mysql_runner_command(self):
|
||||||
ruby = (
|
ruby = (
|
||||||
"require 'yaml'; "
|
"require 'yaml'; "
|
||||||
"c = YAML.load_file('config/database.yml')['production']; "
|
"c = YAML.load_file('config/database.yml')['production']; "
|
||||||
@@ -112,12 +115,13 @@ class RemoteRedmine:
|
|||||||
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
|
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Reset Helpdesk mail settings for all active projects."
|
description="Reset Helpdesk mail settings for all active projects."
|
||||||
)
|
)
|
||||||
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
|
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("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
|
||||||
|
parser.add_argument("--local", action="store_true", help="Read the Redmine database locally instead of over SSH.")
|
||||||
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
|
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
|
||||||
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST, help="Host Redmine should use to reach Mailpit.")
|
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST, help="Host Redmine should use to reach Mailpit.")
|
||||||
parser.add_argument("--pop3-port", type=int, default=1110)
|
parser.add_argument("--pop3-port", type=int, default=1110)
|
||||||
@@ -139,7 +143,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--dry-run", action="store_true", help="Show affected projects and settings without writing.")
|
parser.add_argument("--dry-run", action="store_true", help="Show affected projects and settings without writing.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine)
|
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine, local=args.local)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
projects = find_active_projects(remote, args.project)
|
projects = find_active_projects(remote, args.project)
|
||||||
@@ -166,7 +170,7 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def find_active_projects(remote: RemoteRedmine, filters: list[str]) -> list[dict[str, Any]]:
|
def find_active_projects(remote, filters):
|
||||||
where = ["p.status = 1"]
|
where = ["p.status = 1"]
|
||||||
if filters:
|
if filters:
|
||||||
clauses = []
|
clauses = []
|
||||||
@@ -190,8 +194,8 @@ ORDER BY p.identifier;
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_values(args: argparse.Namespace, projects: list[dict[str, Any]]) -> list[tuple[int, str, str]]:
|
def build_values(args, projects):
|
||||||
rows: list[tuple[int, str, str]] = []
|
rows = []
|
||||||
for project in projects:
|
for project in projects:
|
||||||
project_id = int(project["id"])
|
project_id = int(project["id"])
|
||||||
answer_from = args.from_pattern.format(
|
answer_from = args.from_pattern.format(
|
||||||
@@ -227,7 +231,7 @@ def build_values(args: argparse.Namespace, projects: list[dict[str, Any]]) -> li
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def apply_values(remote: RemoteRedmine, rows: list[tuple[int, str, str]]) -> None:
|
def apply_values(remote, rows):
|
||||||
statements = ["START TRANSACTION;"]
|
statements = ["START TRANSACTION;"]
|
||||||
for project_id, name, value in rows:
|
for project_id, name, value in rows:
|
||||||
project_id_sql = sql_int(project_id)
|
project_id_sql = sql_int(project_id)
|
||||||
@@ -254,8 +258,8 @@ WHERE NOT EXISTS (
|
|||||||
remote.mysql("\n".join(statements))
|
remote.mysql("\n".join(statements))
|
||||||
|
|
||||||
|
|
||||||
def print_plan(rows: list[tuple[int, str, str]]) -> None:
|
def print_plan(rows):
|
||||||
current_project_id: int | None = None
|
current_project_id = None
|
||||||
for project_id, name, value in rows:
|
for project_id, name, value in rows:
|
||||||
if project_id != current_project_id:
|
if project_id != current_project_id:
|
||||||
current_project_id = project_id
|
current_project_id = project_id
|
||||||
@@ -264,18 +268,18 @@ def print_plan(rows: list[tuple[int, str, str]]) -> None:
|
|||||||
print(f" {name} = {display_value}")
|
print(f" {name} = {display_value}")
|
||||||
|
|
||||||
|
|
||||||
def sql_int(value: Any) -> int:
|
def sql_int(value):
|
||||||
try:
|
try:
|
||||||
return max(0, int(value))
|
return max(0, int(value))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def sql_string(value: Any) -> str:
|
def sql_string(value):
|
||||||
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||||
|
|
||||||
|
|
||||||
def shell_quote(value: str) -> str:
|
def shell_quote(value):
|
||||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Stage post-import automation files into the shared LAN scratch directory."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TARGET = Path("/opt/lanscratch/redmine-post-import/repo")
|
||||||
|
|
||||||
|
PAYLOAD_PATHS = (
|
||||||
|
"plugins",
|
||||||
|
"docs",
|
||||||
|
"semantic_index",
|
||||||
|
"deploy",
|
||||||
|
"dist",
|
||||||
|
"post_import_refresh.py",
|
||||||
|
"stage_post_import_payload.py",
|
||||||
|
"reset_helpdesk_mail_settings.py",
|
||||||
|
"validate_test_instance.py",
|
||||||
|
"redmine_outbox_worker.py",
|
||||||
|
"redMCP",
|
||||||
|
)
|
||||||
|
|
||||||
|
EXCLUDES = (
|
||||||
|
".env",
|
||||||
|
".venv",
|
||||||
|
".cache",
|
||||||
|
"__pycache__/",
|
||||||
|
"*.pyc",
|
||||||
|
"*.tar.gz",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Copy the post-import automation payload into /opt/lanscratch."
|
||||||
|
)
|
||||||
|
parser.add_argument("--target", type=Path, default=DEFAULT_TARGET)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Run rsync. Default is dry-run.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
repo_root = Path(__file__).resolve().parent
|
||||||
|
command = build_rsync_command(repo_root, args.target)
|
||||||
|
if not args.apply:
|
||||||
|
print("mode=dry-run")
|
||||||
|
print(f"would run: mkdir -p {shlex.quote(str(args.target))}")
|
||||||
|
print(f"would run: {command}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
args.target.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"running: {command}")
|
||||||
|
result = subprocess.run(command, shell=True, check=False)
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def build_rsync_command(repo_root: Path, target: Path) -> str:
|
||||||
|
exclude_args = " ".join(f"--exclude {shlex.quote(pattern)}" for pattern in EXCLUDES)
|
||||||
|
sources = " ".join(shlex.quote(str(repo_root / path)) for path in PAYLOAD_PATHS)
|
||||||
|
return f"rsync -a --delete {exclude_args} {sources} {shlex.quote(str(target))}/"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MIGRATION = ROOT / "plugins" / "redmine_event_outbox" / "db" / "migrate" / "001_create_event_outbox_events.rb"
|
||||||
|
|
||||||
|
|
||||||
|
class EventOutboxMigrationTest(unittest.TestCase):
|
||||||
|
def test_create_table_migration_is_idempotent_for_imported_dev_clone(self):
|
||||||
|
source = MIGRATION.read_text()
|
||||||
|
|
||||||
|
self.assertIn("table_exists?(:event_outbox_events)", source)
|
||||||
|
self.assertIn("return if table_exists?(:event_outbox_events)", source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from post_import_refresh import AutomationConfig, StepResult, build_steps, write_status
|
||||||
|
|
||||||
|
|
||||||
|
class PostImportRefreshPlanTest(unittest.TestCase):
|
||||||
|
def test_dry_run_is_the_default_and_never_enables_index_writes(self):
|
||||||
|
config = AutomationConfig()
|
||||||
|
steps = build_steps(config)
|
||||||
|
commands = "\n".join(command for step in steps for command in step.commands)
|
||||||
|
|
||||||
|
self.assertFalse(config.apply)
|
||||||
|
self.assertIn("validate semantic index dry-run", [step.name for step in steps])
|
||||||
|
self.assertIn("semantic_index/refresh.sh", commands)
|
||||||
|
self.assertNotIn("semantic_index/refresh.sh --apply", commands)
|
||||||
|
self.assertNotIn("--force-rebuild", commands)
|
||||||
|
self.assertNotIn("systemctl enable --now semantic-index-refresh.timer", commands)
|
||||||
|
|
||||||
|
def test_plugin_reapply_happens_before_migrations_and_helpdesk_reset(self):
|
||||||
|
names = [step.name for step in build_steps(AutomationConfig())]
|
||||||
|
|
||||||
|
self.assertLess(names.index("reapply tracked plugins"), names.index("run plugin migrations"))
|
||||||
|
self.assertLess(names.index("run plugin migrations"), names.index("reset Helpdesk mail settings"))
|
||||||
|
|
||||||
|
def test_expected_plugins_are_reapplied_to_remote_redmine_tree(self):
|
||||||
|
steps = build_steps(AutomationConfig())
|
||||||
|
plugin_step = next(step for step in steps if step.name == "reapply tracked plugins")
|
||||||
|
commands = "\n".join(plugin_step.commands)
|
||||||
|
|
||||||
|
self.assertIn("plugins/redmine_event_outbox", commands)
|
||||||
|
self.assertIn("plugins/redmine_contacts", commands)
|
||||||
|
self.assertIn("plugins/redmine_contacts_helpdesk", commands)
|
||||||
|
self.assertNotIn("plugins/redmine_event_outbox/", commands)
|
||||||
|
self.assertIn("reddev@192.168.50.170:/usr/share/redmine/plugins/", commands)
|
||||||
|
|
||||||
|
def test_apply_mode_runs_mutating_validation_sequence(self):
|
||||||
|
steps = build_steps(AutomationConfig(apply=True))
|
||||||
|
commands = "\n".join(command for step in steps for command in step.commands)
|
||||||
|
|
||||||
|
self.assertIn("bundle exec rake redmine:plugins:migrate", commands)
|
||||||
|
self.assertIn("./reset_helpdesk_mail_settings.py", commands)
|
||||||
|
self.assertIn("touch tmp/restart.txt", commands)
|
||||||
|
self.assertIn("./validate_test_instance.py", commands)
|
||||||
|
|
||||||
|
def test_remote_write_steps_use_sudo_by_default(self):
|
||||||
|
commands = "\n".join(command for step in build_steps(AutomationConfig()) for command in step.commands)
|
||||||
|
|
||||||
|
self.assertIn("--rsync-path 'sudo rsync'", commands)
|
||||||
|
self.assertIn("sudo mkdir -p", commands)
|
||||||
|
self.assertIn("sudo chmod -R g+rwX", commands)
|
||||||
|
|
||||||
|
def test_local_mode_emits_local_commands_without_ssh(self):
|
||||||
|
config = AutomationConfig(local=True)
|
||||||
|
commands = "\n".join(command for step in build_steps(config) for command in step.commands)
|
||||||
|
|
||||||
|
self.assertNotIn("ssh -i", commands)
|
||||||
|
self.assertNotIn("rsync-path", commands)
|
||||||
|
self.assertIn("reset_helpdesk_mail_settings.py --local", commands)
|
||||||
|
self.assertIn("validate_test_instance.py --local", commands)
|
||||||
|
self.assertNotIn("--composer-bin", commands)
|
||||||
|
self.assertIn("redmine_outbox_worker.py --local --status", commands)
|
||||||
|
self.assertIn("/opt/lanscratch/redmine-post-import/repo/plugins/redmine_event_outbox", commands)
|
||||||
|
self.assertIn("/usr/share/redmine/plugins/", commands)
|
||||||
|
self.assertIn("cd /usr/share/redmine && RAILS_ENV=production bundle exec rake redmine:plugins:migrate", commands)
|
||||||
|
|
||||||
|
def test_local_semantic_check_is_non_blocking_without_staged_venv(self):
|
||||||
|
config = AutomationConfig(local=True)
|
||||||
|
semantic_step = next(step for step in build_steps(config) if step.name == "validate semantic index dry-run")
|
||||||
|
command = semantic_step.commands[0]
|
||||||
|
|
||||||
|
self.assertIn("test -x /opt/lanscratch/redmine-post-import/repo/.venv/bin/python", command)
|
||||||
|
self.assertIn("semantic index runtime missing; skipping dry-run", command)
|
||||||
|
self.assertIn("else", command)
|
||||||
|
|
||||||
|
def test_status_paths_default_to_lanscratch(self):
|
||||||
|
config = AutomationConfig()
|
||||||
|
|
||||||
|
self.assertEqual(Path("/opt/lanscratch/redmine-post-import/status"), config.status_dir)
|
||||||
|
|
||||||
|
def test_write_status_updates_latest_and_success_only_on_success(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = AutomationConfig(status_dir=Path(tmp))
|
||||||
|
failed = write_status(
|
||||||
|
config,
|
||||||
|
run_id="20260428T010000Z",
|
||||||
|
status="failed",
|
||||||
|
results=[StepResult("preflight", "test -d missing", 1)],
|
||||||
|
failed_step="preflight",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue((Path(tmp) / "latest.json").exists())
|
||||||
|
self.assertTrue((Path(tmp) / "runs" / "20260428T010000Z.json").exists())
|
||||||
|
self.assertFalse((Path(tmp) / "latest-success.json").exists())
|
||||||
|
self.assertEqual("failed", failed["status"])
|
||||||
|
|
||||||
|
successful = write_status(
|
||||||
|
config,
|
||||||
|
run_id="20260428T010100Z",
|
||||||
|
status="success",
|
||||||
|
results=[StepResult("preflight", "test -d plugins", 0)],
|
||||||
|
)
|
||||||
|
|
||||||
|
latest_success = json.loads((Path(tmp) / "latest-success.json").read_text())
|
||||||
|
self.assertEqual(successful["run_id"], latest_success["run_id"])
|
||||||
|
self.assertEqual("success", latest_success["status"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
POST_IMPORT_SCRIPTS = [
|
||||||
|
ROOT / "post_import_refresh.py",
|
||||||
|
ROOT / "stage_post_import_payload.py",
|
||||||
|
ROOT / "reset_helpdesk_mail_settings.py",
|
||||||
|
ROOT / "validate_test_instance.py",
|
||||||
|
ROOT / "redmine_outbox_worker.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Python36CompatTest(unittest.TestCase):
|
||||||
|
def test_post_import_scripts_do_not_use_subprocess_text_keyword(self):
|
||||||
|
for path in POST_IMPORT_SCRIPTS:
|
||||||
|
with self.subTest(path=path.name):
|
||||||
|
self.assertNotIn("text=True", path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from stage_post_import_payload import build_rsync_command
|
||||||
|
|
||||||
|
|
||||||
|
class StagePostImportPayloadTest(unittest.TestCase):
|
||||||
|
def test_stage_command_targets_lanscratch_and_excludes_runtime_files(self):
|
||||||
|
command = build_rsync_command(
|
||||||
|
repo_root=Path("/repo"),
|
||||||
|
target=Path("/opt/lanscratch/redmine-post-import/repo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("/opt/lanscratch/redmine-post-import/repo/", command)
|
||||||
|
self.assertIn("/repo/plugins", command)
|
||||||
|
self.assertIn("/repo/post_import_refresh.py", command)
|
||||||
|
self.assertIn("/repo/stage_post_import_payload.py", command)
|
||||||
|
self.assertIn("--exclude .env", command)
|
||||||
|
self.assertIn("--exclude .venv", command)
|
||||||
|
self.assertIn("--exclude .cache", command)
|
||||||
|
self.assertIn("--exclude __pycache__/", command)
|
||||||
|
self.assertIn("--exclude '*.tar.gz'", command)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
import validate_test_instance
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateTestInstanceTest(unittest.TestCase):
|
||||||
|
def test_missing_controlled_projects_are_warn_for_daily_clone(self):
|
||||||
|
results = validate_test_instance.controlled_project_check([])
|
||||||
|
|
||||||
|
self.assertEqual("WARN", results.status)
|
||||||
|
self.assertIn("optional", results.detail)
|
||||||
|
|
||||||
|
def test_composer_validation_is_skipped_when_disabled(self):
|
||||||
|
result = validate_test_instance.check_composer(None)
|
||||||
|
|
||||||
|
self.assertEqual("WARN", result.status)
|
||||||
|
self.assertIn("skipped", result.detail)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+78
-50
@@ -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.
|
Helpdesk and redMCP testing without changing remote state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -26,20 +23,30 @@ DEFAULT_MAILPIT_HOST = "192.168.1.105"
|
|||||||
DEFAULT_FILES_ROOT = "/var/lib/redmine/default/files"
|
DEFAULT_FILES_ROOT = "/var/lib/redmine/default/files"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CheckResult:
|
class CheckResult:
|
||||||
status: str
|
def __init__(self, status, name, detail):
|
||||||
name: str
|
self.status = status
|
||||||
detail: str
|
self.name = name
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RemoteRedmine:
|
class RemoteRedmine:
|
||||||
ssh_host: str
|
def __init__(self, ssh_host, ssh_key, remote_redmine, local=False):
|
||||||
ssh_key: Path
|
self.ssh_host = ssh_host
|
||||||
remote_redmine: str
|
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(
|
return subprocess.run(
|
||||||
[
|
[
|
||||||
"ssh",
|
"ssh",
|
||||||
@@ -50,43 +57,49 @@ class RemoteRedmine:
|
|||||||
self.ssh_host,
|
self.ssh_host,
|
||||||
remote_command,
|
remote_command,
|
||||||
],
|
],
|
||||||
text=True,
|
universal_newlines=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def mysql_json_lines(self, sql: str) -> list[dict[str, Any]]:
|
def mysql_json_lines(self, sql):
|
||||||
result = self.mysql(sql)
|
result = self.mysql(sql)
|
||||||
rows: list[dict[str, Any]] = []
|
rows = []
|
||||||
for line in result.splitlines():
|
for line in result.splitlines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
rows.append(json.loads(bytes.fromhex(line.strip()).decode("utf-8")))
|
rows.append(json.loads(bytes.fromhex(line.strip()).decode("utf-8")))
|
||||||
return rows
|
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(
|
result = subprocess.run(
|
||||||
[
|
command,
|
||||||
"ssh",
|
|
||||||
"-i",
|
|
||||||
str(self.ssh_key),
|
|
||||||
"-o",
|
|
||||||
"IdentitiesOnly=yes",
|
|
||||||
self.ssh_host,
|
|
||||||
self._mysql_runner_command(),
|
|
||||||
],
|
|
||||||
input=sql,
|
input=sql,
|
||||||
text=True,
|
universal_newlines=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
check=False,
|
check=False,
|
||||||
|
shell=shell,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(result.stderr.strip() or "Remote MySQL command failed.")
|
raise RuntimeError(result.stderr.strip() or "Remote MySQL command failed.")
|
||||||
return result.stdout
|
return result.stdout
|
||||||
|
|
||||||
def _mysql_runner_command(self) -> str:
|
def _mysql_runner_command(self):
|
||||||
ruby = (
|
ruby = (
|
||||||
"require 'yaml'; "
|
"require 'yaml'; "
|
||||||
"c = YAML.load_file('config/database.yml')['production']; "
|
"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)}"
|
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 = 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-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("--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("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
|
||||||
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST)
|
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST)
|
||||||
parser.add_argument("--files-root", default=DEFAULT_FILES_ROOT)
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine)
|
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine, local=args.local)
|
||||||
checks: list[CheckResult] = []
|
checks = []
|
||||||
|
|
||||||
checks.extend(check_remote_basics(remote))
|
checks.extend(check_remote_basics(remote))
|
||||||
checks.extend(check_mailpit_connectivity(remote, args.mailpit_host))
|
checks.extend(check_mailpit_connectivity(remote, args.mailpit_host))
|
||||||
@@ -127,8 +145,8 @@ def main() -> int:
|
|||||||
return 1 if failures else 0
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
def check_remote_basics(remote: RemoteRedmine) -> list[CheckResult]:
|
def check_remote_basics(remote):
|
||||||
results: list[CheckResult] = []
|
results = []
|
||||||
result = remote.ssh("printf ok")
|
result = remote.ssh("printf ok")
|
||||||
if result.returncode == 0 and result.stdout == "ok":
|
if result.returncode == 0 and result.stdout == "ok":
|
||||||
results.append(CheckResult("OK", "SSH", f"connected to {remote.ssh_host}"))
|
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
|
return results
|
||||||
|
|
||||||
|
|
||||||
def check_mailpit_connectivity(remote: RemoteRedmine, host: str) -> list[CheckResult]:
|
def check_mailpit_connectivity(remote, host):
|
||||||
results = [
|
results = [
|
||||||
tcp_check("Mailpit HTTP from local", host, 8025),
|
tcp_check("Mailpit HTTP from local", host, 8025),
|
||||||
tcp_check("Mailpit SMTP from local", host, 1025),
|
tcp_check("Mailpit SMTP from local", host, 1025),
|
||||||
@@ -181,7 +199,7 @@ def check_mailpit_connectivity(remote: RemoteRedmine, host: str) -> list[CheckRe
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def check_files_permissions(remote: RemoteRedmine, files_root: str) -> list[CheckResult]:
|
def check_files_permissions(remote, files_root):
|
||||||
command = (
|
command = (
|
||||||
"ruby -e "
|
"ruby -e "
|
||||||
+ shell_quote(
|
+ shell_quote(
|
||||||
@@ -208,8 +226,8 @@ def check_files_permissions(remote: RemoteRedmine, files_root: str) -> list[Chec
|
|||||||
return [CheckResult("OK", "Attachment directory permissions", detail)]
|
return [CheckResult("OK", "Attachment directory permissions", detail)]
|
||||||
|
|
||||||
|
|
||||||
def check_database_state(remote: RemoteRedmine, mailpit_host: str) -> list[CheckResult]:
|
def check_database_state(remote, mailpit_host):
|
||||||
results: list[CheckResult] = []
|
results = []
|
||||||
try:
|
try:
|
||||||
projects = remote.mysql_json_lines(
|
projects = remote.mysql_json_lines(
|
||||||
"""
|
"""
|
||||||
@@ -219,12 +237,7 @@ WHERE identifier IN ('fud-helpdesk', 'fud-nohelpdesk')
|
|||||||
ORDER BY identifier;
|
ORDER BY identifier;
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
found = {project["identifier"] for project in projects}
|
results.append(controlled_project_check(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))))
|
|
||||||
|
|
||||||
settings_rows = remote.mysql_json_lines(helpdesk_settings_sql())
|
settings_rows = remote.mysql_json_lines(helpdesk_settings_sql())
|
||||||
failures = helpdesk_setting_failures(settings_rows, mailpit_host)
|
failures = helpdesk_setting_failures(settings_rows, mailpit_host)
|
||||||
@@ -237,7 +250,7 @@ ORDER BY identifier;
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def helpdesk_settings_sql() -> str:
|
def helpdesk_settings_sql():
|
||||||
return """
|
return """
|
||||||
SELECT HEX(CAST(JSON_OBJECT(
|
SELECT HEX(CAST(JSON_OBJECT(
|
||||||
'identifier', p.identifier,
|
'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 = {
|
expected = {
|
||||||
"protocol": "pop3",
|
"protocol": "pop3",
|
||||||
"host": mailpit_host,
|
"host": mailpit_host,
|
||||||
@@ -281,7 +294,7 @@ def helpdesk_setting_failures(rows: list[dict[str, Any]], mailpit_host: str) ->
|
|||||||
"smtp_ssl": "0",
|
"smtp_ssl": "0",
|
||||||
"smtp_tls": "0",
|
"smtp_tls": "0",
|
||||||
}
|
}
|
||||||
failures: list[str] = []
|
failures = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
for key, value in expected.items():
|
for key, value in expected.items():
|
||||||
if row.get(key) != value:
|
if row.get(key) != value:
|
||||||
@@ -289,7 +302,22 @@ def helpdesk_setting_failures(rows: list[dict[str, Any]], mailpit_host: str) ->
|
|||||||
return failures
|
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_path = Path(composer_bin)
|
||||||
composer_on_path = shutil.which(composer_bin)
|
composer_on_path = shutil.which(composer_bin)
|
||||||
if composer_on_path is None and not composer_path.exists():
|
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"]
|
command = [php, composer_bin, "validate", "--working-dir=redMCP"]
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
command,
|
||||||
text=True,
|
universal_newlines=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
check=False,
|
check=False,
|
||||||
@@ -312,7 +340,7 @@ def check_composer(composer_bin: str) -> CheckResult:
|
|||||||
return CheckResult("FAIL", "Composer validation", (result.stdout + result.stderr).strip())
|
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:
|
try:
|
||||||
with socket.create_connection((host, port), timeout=5):
|
with socket.create_connection((host, port), timeout=5):
|
||||||
return CheckResult("OK", name, f"{host}:{port}")
|
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}")
|
return CheckResult("FAIL", name, f"{host}:{port} {exc.__class__.__name__}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
def shell_quote(value: str) -> str:
|
def shell_quote(value):
|
||||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user