Automate post-import refresh and validation workflow

This commit is contained in:
Jason Thistlethwaite
2026-05-04 09:49:47 -04:00
parent fba494dada
commit faad70872b
13 changed files with 995 additions and 136 deletions
+18
View File
@@ -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()
+112
View File
@@ -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()
+23
View File
@@ -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()
+26
View File
@@ -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()
+21
View File
@@ -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()