diff --git a/roles/svc-ai-ollama/meta/services.yml b/roles/svc-ai-ollama/meta/services.yml index 6068a8177d..3f8233dc75 100644 --- a/roles/svc-ai-ollama/meta/services.yml +++ b/roles/svc-ai-ollama/meta/services.yml @@ -13,7 +13,7 @@ ollama: backup: no_stop_required: true image: ollama/ollama - version: 0.40.5 + version: 0.40.6 name: ollama gpu: true min_storage: 3473MB diff --git a/roles/web-app-openproject/files/shell/await_migrations.sh b/roles/web-app-openproject/files/shell/await_migrations.sh index 1057cc26f1..e09c8bc0f5 100755 --- a/roles/web-app-openproject/files/shell/await_migrations.sh +++ b/roles/web-app-openproject/files/shell/await_migrations.sh @@ -2,13 +2,11 @@ # Wait for the seeder's migrations, then exec the app entrypoint. # # Swarm ignores depends_on, so web and worker would start while the seeder is -# still migrating and die on ActiveRecord::PendingMigrationError. The gate is -# read-only -- it never migrates, so concurrent replicas cannot race. +# still migrating and die on ActiveRecord::PendingMigrationError. # -# The wait is bounded on purpose. `db:abort_if_pending_migrations` exits 1 both -# for pending migrations and for an unreachable database (measured against -# openproject/openproject:17), so an unbounded loop would spin forever whenever -# the database is down. Past the deadline the entrypoint starts regardless, +# The wait is bounded on purpose. The probe fails both for pending migrations +# and for an unreachable database, so an unbounded loop would spin forever +# whenever the database is down. Past the deadline the entrypoint starts regardless, # which is exactly today's behaviour: the worst case stays the crash loop that # already exists, never a service that refuses to come up. # @@ -26,9 +24,15 @@ shift 2 exit 2 } +schema_is_current() { + local status + status=$(bundle exec rails db:migrate:status 2>/dev/null) || return 1 + ! grep -q -E '^[[:space:]]*down[[:space:]]' <<<"$status" +} + deadline=$(($(date +%s) + deadline_seconds)) while [ "$(date +%s)" -lt "$deadline" ]; do - if bundle exec rails db:abort_if_pending_migrations >/dev/null 2>&1; then + if schema_is_current; then echo "await_migrations: schema is current, starting $1" exec "$@" fi diff --git a/roles/web-app-socialhome/meta/services.yml b/roles/web-app-socialhome/meta/services.yml index 598f361675..270b0eac07 100644 --- a/roles/web-app-socialhome/meta/services.yml +++ b/roles/web-app-socialhome/meta/services.yml @@ -14,7 +14,7 @@ socialhome: placement: manager name: socialhome image: ghcr.io/social-home-io/socialhome - version: "2026.10.16" + version: "2026.10.17" ports: local: http: 8089 diff --git a/tests/unit/python/roles/web-app-openproject/__init__.py b/tests/unit/python/roles/web-app-openproject/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/roles/web-app-openproject/files/__init__.py b/tests/unit/python/roles/web-app-openproject/files/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/roles/web-app-openproject/files/test_await_migrations.py b/tests/unit/python/roles/web-app-openproject/files/test_await_migrations.py new file mode 100644 index 0000000000..6fbf6d7f90 --- /dev/null +++ b/tests/unit/python/roles/web-app-openproject/files/test_await_migrations.py @@ -0,0 +1,80 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +from utils import PROJECT_ROOT + +SCRIPT = PROJECT_ROOT / "roles/web-app-openproject/files/shell/await_migrations.sh" + +BUNDLE_STUB = """#!/usr/bin/env bash +printf '%s\\n' "$*" >>"${STUB_DIR}/calls" +cat "${STUB_DIR}/status" +exit "${STUB_RC}" +""" + +HEADER = ( + "\ndatabase: openproject\n\n" + " Status Migration ID Migration Name\n" + "-" * 50 + "\n" +) +ALL_UP = HEADER + " up 1000016 Aggregated migrations\n" +ONE_DOWN = ALL_UP + " down 20261001000000 Add announcement flags\n" + + +class TestAwaitMigrations(unittest.TestCase): + def run_gate(self, status, rc=0): + """Run the gate against a stubbed `bundle` with a 2 s deadline. + + Args: + status: what the stubbed `rails db:migrate:status` prints. + rc: exit code of the stubbed `bundle`. + + Returns: + Tuple of the gate's stdout plus stderr and the recorded `bundle` calls. + """ + with tempfile.TemporaryDirectory() as tmp: + stub_dir = Path(tmp) + bundle = stub_dir / "bundle" + bundle.write_text(BUNDLE_STUB) + bundle.chmod(0o755) + (stub_dir / "status").write_text(status) + env = dict(os.environ) + env["PATH"] = f"{stub_dir}{os.pathsep}{env['PATH']}" + env["STUB_DIR"] = tmp + env["STUB_RC"] = str(rc) + done = subprocess.run( + ["bash", str(SCRIPT), "2", "1", "echo", "ENTRYPOINT"], + env=env, + capture_output=True, + text=True, + check=False, + ) + calls = ( + stub_dir / "calls" + ).read_text() # nocheck: cache-read tempdir fixture + return done.stdout + done.stderr, calls + + def test_a_current_schema_starts_the_entrypoint_at_once(self): + output, calls = self.run_gate(ALL_UP) + self.assertIn("schema is current", output) + self.assertIn("ENTRYPOINT", output) + self.assertEqual(calls.splitlines(), ["exec rails db:migrate:status"]) + + def test_a_pending_migration_keeps_the_gate_waiting(self): + output, calls = self.run_gate(ONE_DOWN) + self.assertIn("deadline of 2s reached", output) + self.assertGreaterEqual(len(calls.splitlines()), 1) + + def test_a_failing_probe_keeps_the_gate_waiting(self): + output, _ = self.run_gate("", rc=1) + self.assertIn("deadline of 2s reached", output) + + def test_the_probe_never_builds_a_migrator(self): + _, calls = self.run_gate(ONE_DOWN) + self.assertNotIn("abort_if_pending_migrations", calls) + self.assertEqual(set(calls.splitlines()), {"exec rails db:migrate:status"}) + + +if __name__ == "__main__": + unittest.main()