Files
computer-playbook/tests/unit/python/roles/web-app-nextcloud/test_init_gate.py
T
kevinveenbirkenbachandClaude Opus 5.5 a0c36d6e38 fix(web-app-nextcloud): evaluate occ status's exit code in the init gate
Gate pass 23 failed tests/lint/filesystem/shell/test_no_or_true.py on init-gate.sh: occ_status swallowed every exit code with || true. It now returns 0 with the status on success and maps 1 (occ error), 124 (timeout) and 255 (php fatal while the tree is copied or upgraded) to an empty status, the not-ready state both slots already handle. Any other code, such as 127 from a missing su, timeout or php, ends the gate with that code and a message instead of waiting forever. Both slots now call occ_status directly so that exit reaches the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 02:12:57 +01:00

185 lines
6.4 KiB
Python

from __future__ import annotations
import fcntl
import os
import subprocess
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from utils.cache.files import PROJECT_ROOT, read_text
GATE = read_text(
str(PROJECT_ROOT / "roles/web-app-nextcloud/files/shell/utils/init-gate.sh")
)
APP_DIR_LINE = 'APP_DIR="/var/www/html"'
UPSTREAM_EXEC = "exec /entrypoint.sh"
HANDED_OVER = "upstream entrypoint reached"
INIT_LOCK = "nextcloud-init-sync.lock"
COPIED = ("version.php", "occ", "config/config.php")
STUB_SU = '#!/bin/sh\nprintf \'%s\\n\' "$GATE_STATUS"\nexit "$GATE_RC"\n'
INSTALLED = " - installed: true\n - maintenance: false\n - needsDbUpgrade: false"
NOT_INSTALLED = (
" - installed: false\n - maintenance: false\n - needsDbUpgrade: false"
)
def _write_executable(path: Path, body: str) -> None:
path.write_text(body, encoding="utf-8")
path.chmod(0o755)
def _run_gate(
*,
task_slot: str | None,
files: tuple[str, ...],
status: str,
occ_rc: int,
hold_init_lock: bool,
) -> subprocess.CompletedProcess:
"""Run the gate as slot 1 against a fake app dir.
Args:
task_slot: value of TASK_SLOT, or None to leave it unset as compose does.
files: paths relative to the app dir that exist before the gate runs.
status: what `occ status` prints through the stubbed `su`.
occ_rc: the exit code `occ status` returns through the stubbed `su`.
hold_init_lock: hold the upstream init lock while the gate runs, as an
entrypoint that is still copying or installing does.
"""
assert GATE.count(APP_DIR_LINE) == 1, "the gate no longer pins APP_DIR"
assert GATE.count(UPSTREAM_EXEC) == 1, "the gate no longer execs upstream"
with TemporaryDirectory() as directory:
root = Path(directory)
app_dir = root / "html"
(app_dir / "config").mkdir(parents=True)
for relative in files:
(app_dir / relative).write_text("", encoding="utf-8")
bin_dir = root / "bin"
bin_dir.mkdir()
_write_executable(bin_dir / "su", STUB_SU)
entrypoint = root / "entrypoint.sh"
_write_executable(entrypoint, f"#!/bin/sh\necho {HANDED_OVER}\n")
gate = root / "init-gate.sh"
gate.write_text(
GATE.replace(APP_DIR_LINE, f'APP_DIR="{app_dir}"').replace(
UPSTREAM_EXEC, f"exec {entrypoint}"
),
encoding="utf-8",
)
env = {
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
"NEXTCLOUD_INIT_GATE_USER": "www-data",
"GATE_STATUS": status,
"GATE_RC": str(occ_rc),
}
if task_slot is not None:
env["TASK_SLOT"] = task_slot
with (app_dir / INIT_LOCK).open("w", encoding="utf-8") as lock:
if hold_init_lock:
fcntl.flock(lock, fcntl.LOCK_EX)
return subprocess.run(
["sh", str(gate), "php-fpm"],
check=False,
capture_output=True,
env=env,
text=True,
timeout=30,
)
class TestInitGateSlotOne(unittest.TestCase):
def test_refuses_an_install_that_failed_after_the_copy(self):
for task_slot in (None, "1"):
with self.subTest(task_slot=task_slot):
result = _run_gate(
task_slot=task_slot,
files=COPIED,
status=NOT_INSTALLED,
occ_rc=0,
hold_init_lock=False,
)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertNotIn(HANDED_OVER, result.stdout)
self.assertIn("installed: false", result.stderr)
def test_refuses_a_copy_without_any_config(self):
result = _run_gate(
task_slot="1",
files=("version.php", "occ"),
status=NOT_INSTALLED,
occ_rc=0,
hold_init_lock=False,
)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertNotIn(HANDED_OVER, result.stdout)
def test_leaves_a_running_install_to_the_upstream_lock(self):
result = _run_gate(
task_slot="1",
files=COPIED,
status=NOT_INSTALLED,
occ_rc=0,
hold_init_lock=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(HANDED_OVER, result.stdout)
def test_hands_an_installed_volume_to_upstream(self):
result = _run_gate(
task_slot="1",
files=COPIED,
status=INSTALLED,
occ_rc=0,
hold_init_lock=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(HANDED_OVER, result.stdout)
def test_hands_a_fresh_volume_to_upstream_for_the_install(self):
result = _run_gate(
task_slot=None,
files=(),
status=NOT_INSTALLED,
occ_rc=0,
hold_init_lock=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(HANDED_OVER, result.stdout)
def test_hands_over_when_occ_status_answers_nothing(self):
result = _run_gate(
task_slot="1", files=COPIED, status="", occ_rc=0, hold_init_lock=False
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(HANDED_OVER, result.stdout)
def test_hands_over_when_occ_status_fails_like_an_unready_nextcloud(self):
for occ_rc in (1, 124, 255):
with self.subTest(occ_rc=occ_rc):
result = _run_gate(
task_slot="1",
files=COPIED,
status=NOT_INSTALLED,
occ_rc=occ_rc,
hold_init_lock=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(HANDED_OVER, result.stdout)
def test_stops_when_occ_status_fails_in_a_way_no_nextcloud_state_explains(self):
result = _run_gate(
task_slot="1",
files=COPIED,
status="",
occ_rc=127,
hold_init_lock=False,
)
self.assertEqual(result.returncode, 127, result.stdout)
self.assertNotIn(HANDED_OVER, result.stdout)
self.assertIn("occ status exited 127", result.stderr)
if __name__ == "__main__":
unittest.main()