Initial Redmine tooling and local plugin forks
This commit is contained in:
Executable
+285
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reset RedmineUP Helpdesk mail settings on the LAN test Redmine instance.
|
||||
|
||||
This is intended to be run after importing a production database into the test
|
||||
instance. It finds projects with the Helpdesk module enabled and rewrites only
|
||||
the incoming/outgoing mail settings so test mail flows through Mailpit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
|
||||
DEFAULT_SSH_KEY = Path("/tmp/reddev")
|
||||
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
|
||||
DEFAULT_MAILPIT_HOST = "192.168.1.105"
|
||||
|
||||
MAIL_SETTING_NAMES = [
|
||||
"helpdesk_protocol",
|
||||
"helpdesk_host",
|
||||
"helpdesk_port",
|
||||
"helpdesk_username",
|
||||
"helpdesk_password",
|
||||
"helpdesk_use_ssl",
|
||||
"helpdesk_imap_folder",
|
||||
"helpdesk_move_on_success",
|
||||
"helpdesk_move_on_failure",
|
||||
"helpdesk_apop",
|
||||
"helpdesk_delete_unprocessed",
|
||||
"helpdesk_smtp_use_default_settings",
|
||||
"helpdesk_smtp_server",
|
||||
"helpdesk_smtp_port",
|
||||
"helpdesk_smtp_domain",
|
||||
"helpdesk_smtp_authentication",
|
||||
"helpdesk_smtp_username",
|
||||
"helpdesk_smtp_password",
|
||||
"helpdesk_smtp_ssl",
|
||||
"helpdesk_smtp_tls",
|
||||
"helpdesk_answer_from",
|
||||
]
|
||||
|
||||
SECRET_NAMES = {"helpdesk_password", "helpdesk_smtp_password"}
|
||||
|
||||
|
||||
class ResetError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteRedmine:
|
||||
ssh_host: str
|
||||
ssh_key: Path
|
||||
remote_redmine: str
|
||||
|
||||
def mysql_json_lines(self, sql: str) -> list[dict[str, Any]]:
|
||||
stdout = self.mysql(sql)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for line in stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(bytes.fromhex(line.strip()).decode("utf-8")))
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
raise ResetError(f"Remote query returned an unexpected row: {line[:200]}") from exc
|
||||
return rows
|
||||
|
||||
def mysql(self, sql: str) -> str:
|
||||
command = [
|
||||
"ssh",
|
||||
"-i",
|
||||
str(self.ssh_key),
|
||||
"-o",
|
||||
"IdentitiesOnly=yes",
|
||||
self.ssh_host,
|
||||
self._mysql_runner_command(),
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
input=sql,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ResetError(f"Could not run ssh: {exc}") from exc
|
||||
|
||||
if result.returncode != 0:
|
||||
raise ResetError(result.stderr.strip() or "Remote MySQL command failed.")
|
||||
return result.stdout
|
||||
|
||||
def _mysql_runner_command(self) -> str:
|
||||
ruby = (
|
||||
"require 'yaml'; "
|
||||
"c = YAML.load_file('config/database.yml')['production']; "
|
||||
"ENV['MYSQL_PWD'] = c['password'].to_s; "
|
||||
"args = ['--batch', '--raw', '--quick', '--skip-column-names', "
|
||||
"'--default-character-set=utf8', '-h', c['host'].to_s, "
|
||||
"'-P', (c['port'] || 3306).to_s, '-u', c['username'].to_s, c['database'].to_s]; "
|
||||
"exec('mysql', *args)"
|
||||
)
|
||||
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Reset Helpdesk mail settings for projects with the contacts_helpdesk module enabled."
|
||||
)
|
||||
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("--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("--smtp-port", type=int, default=1025)
|
||||
parser.add_argument("--username", default="test")
|
||||
parser.add_argument("--password", default="testpass")
|
||||
parser.add_argument("--smtp-domain", default="example.test")
|
||||
parser.add_argument(
|
||||
"--from-pattern",
|
||||
default="helpdesk-{identifier}@example.test",
|
||||
help="Pattern for helpdesk_answer_from. Available fields: {id}, {identifier}, {name}.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Optional project id or identifier to limit changes. Can be passed more than once.",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Show affected projects and settings without writing.")
|
||||
args = parser.parse_args()
|
||||
|
||||
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine)
|
||||
|
||||
try:
|
||||
projects = find_helpdesk_projects(remote, args.project)
|
||||
if not projects:
|
||||
print("No active projects with contacts_helpdesk enabled matched the requested filters.")
|
||||
return 0
|
||||
|
||||
print(f"Matched {len(projects)} Helpdesk-enabled project(s):")
|
||||
for project in projects:
|
||||
print(f" - #{project['id']} {project['identifier']} ({project['name']})")
|
||||
|
||||
values = build_values(args, projects)
|
||||
if args.dry_run:
|
||||
print("\nDry run. Planned settings:")
|
||||
print_plan(values)
|
||||
return 0
|
||||
|
||||
apply_values(remote, values)
|
||||
print(f"\nUpdated {len(values)} setting row(s) across {len(projects)} project(s).")
|
||||
print("Password values were written but not displayed.")
|
||||
return 0
|
||||
except ResetError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def find_helpdesk_projects(remote: RemoteRedmine, filters: list[str]) -> list[dict[str, Any]]:
|
||||
where = ["p.status = 1"]
|
||||
if filters:
|
||||
clauses = []
|
||||
for value in filters:
|
||||
if str(value).isdigit():
|
||||
clauses.append(f"p.id = {sql_int(value)}")
|
||||
clauses.append(f"p.identifier = {sql_string(value)}")
|
||||
where.append("(" + " OR ".join(clauses) + ")")
|
||||
|
||||
return remote.mysql_json_lines(
|
||||
f"""
|
||||
SELECT HEX(CAST(JSON_OBJECT(
|
||||
'id', p.id,
|
||||
'identifier', p.identifier,
|
||||
'name', p.name
|
||||
) AS CHAR)) AS document
|
||||
FROM projects p
|
||||
JOIN enabled_modules em
|
||||
ON em.project_id = p.id
|
||||
AND em.name = 'contacts_helpdesk'
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY p.identifier;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def build_values(args: argparse.Namespace, projects: list[dict[str, Any]]) -> list[tuple[int, str, str]]:
|
||||
rows: list[tuple[int, str, str]] = []
|
||||
for project in projects:
|
||||
project_id = int(project["id"])
|
||||
answer_from = args.from_pattern.format(
|
||||
id=project_id,
|
||||
identifier=project["identifier"],
|
||||
name=project["name"],
|
||||
)
|
||||
settings = {
|
||||
"helpdesk_protocol": "pop3",
|
||||
"helpdesk_host": args.mailpit_host,
|
||||
"helpdesk_port": str(args.pop3_port),
|
||||
"helpdesk_username": args.username,
|
||||
"helpdesk_password": args.password,
|
||||
"helpdesk_use_ssl": "0",
|
||||
"helpdesk_imap_folder": "",
|
||||
"helpdesk_move_on_success": "",
|
||||
"helpdesk_move_on_failure": "",
|
||||
"helpdesk_apop": "0",
|
||||
"helpdesk_delete_unprocessed": "0",
|
||||
# RedmineUP's UI label is confusing: 1 means use the custom SMTP block.
|
||||
"helpdesk_smtp_use_default_settings": "1",
|
||||
"helpdesk_smtp_server": args.mailpit_host,
|
||||
"helpdesk_smtp_port": str(args.smtp_port),
|
||||
"helpdesk_smtp_domain": args.smtp_domain,
|
||||
"helpdesk_smtp_authentication": "",
|
||||
"helpdesk_smtp_username": "",
|
||||
"helpdesk_smtp_password": "",
|
||||
"helpdesk_smtp_ssl": "0",
|
||||
"helpdesk_smtp_tls": "0",
|
||||
"helpdesk_answer_from": answer_from,
|
||||
}
|
||||
rows.extend((project_id, name, settings[name]) for name in MAIL_SETTING_NAMES)
|
||||
return rows
|
||||
|
||||
|
||||
def apply_values(remote: RemoteRedmine, rows: list[tuple[int, str, str]]) -> None:
|
||||
statements = ["START TRANSACTION;"]
|
||||
for project_id, name, value in rows:
|
||||
project_id_sql = sql_int(project_id)
|
||||
name_sql = sql_string(name)
|
||||
value_sql = sql_string(value)
|
||||
statements.append(
|
||||
f"""
|
||||
UPDATE contacts_settings
|
||||
SET value = {value_sql}, updated_on = UTC_TIMESTAMP()
|
||||
WHERE project_id = {project_id_sql}
|
||||
AND name = {name_sql};
|
||||
INSERT INTO contacts_settings (project_id, name, value, updated_on)
|
||||
SELECT {project_id_sql}, {name_sql}, {value_sql}, UTC_TIMESTAMP()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM contacts_settings
|
||||
WHERE project_id = {project_id_sql}
|
||||
AND name = {name_sql}
|
||||
);
|
||||
"""
|
||||
)
|
||||
statements.append("COMMIT;")
|
||||
|
||||
remote.mysql("\n".join(statements))
|
||||
|
||||
|
||||
def print_plan(rows: list[tuple[int, str, str]]) -> None:
|
||||
current_project_id: int | None = None
|
||||
for project_id, name, value in rows:
|
||||
if project_id != current_project_id:
|
||||
current_project_id = project_id
|
||||
print(f"\nProject #{project_id}")
|
||||
display_value = "<redacted>" if name in SECRET_NAMES else value
|
||||
print(f" {name} = {display_value}")
|
||||
|
||||
|
||||
def sql_int(value: Any) -> int:
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def sql_string(value: Any) -> str:
|
||||
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||
|
||||
|
||||
def shell_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user