sandcastle-ng

Scripts for the deployment of Sandcastle (GNU Taler)
Log | Files | Refs | README

commit ed7f70629285f64901ab6d2080f5193ac6d10307
parent df59759d130aa0ddd47285fadac6f99697b2dbce
Author: Florian Dold <dold@taler.net>
Date:   Sat, 22 Aug 2026 18:04:58 +0200

sandcastle: add provisioning wait command

Diffstat:
MREADME.md | 8++++++--
Asandcastle-wait | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_sandcastle_wait.py | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 415 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md @@ -12,6 +12,7 @@ You need (on your host system): * podman * bash +* Python 3.10 or newer The sandcastle-ng container exposes TCP ports that serve the APIs / Web interfaces of the respective GNU Taler service via HTTP. @@ -134,10 +135,13 @@ The following variables are typically adjusted in an override file: Run ``./sandcastle-run`` to run the single container. The container will be named `taler-sandcastle`. -On the first run, wait until everything has been set up: +Wait until everything has been set up: - ./sandcastle-logs + ./sandcastle-wait +While provisioning is running, this follows the output of +``setup-sandcastle.service`` inside the container. It exits successfully once +provisioning has completed, or with a non-zero status if provisioning fails. Note that ``./sandcastle-run`` is just a wrapper around ``podman run``. If required, you can pass addtional arguments to ``./sandcastle-run``. diff --git a/sandcastle-wait b/sandcastle-wait @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 + +# This file is in the public domain. + +from __future__ import annotations + +import argparse +import signal +import subprocess +import sys +import time +from collections.abc import Iterator, Sequence +from contextlib import contextmanager + +CONTAINER = "taler-sandcastle" +UNIT = "setup-sandcastle.service" +STARTUP_ATTEMPTS = 20 +STARTUP_RETRY_SECONDS = 0.1 +POLL_SECONDS = 1 +JOURNAL_STOP_SECONDS = 2 + + +class TerminationRequested(Exception): + def __init__(self, signum: int): + super().__init__(signum) + self.signum = signum + + +def container_is_running() -> bool: + try: + result = subprocess.run( + ["podman", "inspect", "--format", "{{.State.Running}}", CONTAINER], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + except subprocess.CalledProcessError: + return False + return result.stdout.strip() == "true" + + +def unit_state(*, quiet: bool = False) -> str: + result = subprocess.run( + [ + "podman", + "exec", + CONTAINER, + "systemctl", + "show", + "--property=ActiveState", + "--value", + UNIT, + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL if quiet else None, + text=True, + ) + return result.stdout.strip() + + +def initial_unit_state() -> str | None: + # A detached podman run can report success just before exec is ready. Retry + # that narrow startup race without imposing a timeout on provisioning. + for attempt in range(STARTUP_ATTEMPTS): + try: + return unit_state(quiet=True) + except subprocess.CalledProcessError: + if attempt + 1 < STARTUP_ATTEMPTS: + time.sleep(STARTUP_RETRY_SECONDS) + + print(f"Cannot query {UNIT} in container {CONTAINER}.", file=sys.stderr) + return None + + +def stop_process(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + try: + process.terminate() + except ProcessLookupError: + process.wait() + return + try: + process.wait(timeout=JOURNAL_STOP_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +@contextmanager +def journal_output() -> Iterator[subprocess.Popen[bytes]]: + process = subprocess.Popen( + [ + "podman", + "exec", + CONTAINER, + "journalctl", + "--unit", + UNIT, + "--follow", + "--no-pager", + ] + ) + try: + yield process + finally: + stop_process(process) + + +def report_failure(message: str) -> None: + print(message, file=sys.stderr) + subprocess.run( + [ + "podman", + "exec", + CONTAINER, + "systemctl", + "status", + "--no-pager", + "--full", + UNIT, + ], + check=False, + stdout=sys.stderr, + stderr=sys.stderr, + ) + + +def terminal_status(state: str) -> int: + if state == "active": + print("Sandcastle provisioning succeeded.") + return 0 + if state == "failed": + report_failure("Sandcastle provisioning failed.") + return 1 + + report_failure(f"Sandcastle provisioning entered unexpected state {state!r}.") + return 1 + + +def monitor_provisioning() -> int: + if not container_is_running(): + print( + f"Container {CONTAINER} does not exist or is not running.", + file=sys.stderr, + ) + return 1 + + state = initial_unit_state() + if state is None: + return 1 + if state != "activating": + return terminal_status(state) + + print(f"Sandcastle provisioning is running; following {UNIT} output.", flush=True) + with journal_output() as journal: + while True: + time.sleep(POLL_SECONDS) + try: + state = unit_state() + except subprocess.CalledProcessError: + print( + f"Lost contact with {UNIT} in container {CONTAINER}.", + file=sys.stderr, + ) + return 1 + + if state != "activating": + break + + journal_status = journal.poll() + if journal_status is not None: + print( + f"Stopped receiving output from {UNIT} " + f"(podman exec exited with status {journal_status}).", + file=sys.stderr, + ) + return 1 + + return terminal_status(state) + + +def request_termination(signum: int, _frame: object) -> None: + raise TerminationRequested(signum) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Wait for sandcastle provisioning to finish." + ) + parser.parse_args(argv) + signal.signal(signal.SIGTERM, request_termination) + + try: + return monitor_provisioning() + except KeyboardInterrupt: + return 128 + signal.SIGINT + except TerminationRequested as request: + return 128 + request.signum + except FileNotFoundError as error: + print(f"Cannot execute {error.filename}: {error.strerror}.", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sandcastle_wait.py b/tests/test_sandcastle_wait.py @@ -0,0 +1,201 @@ +# This file is in the public domain. + +import os +import signal +import subprocess +import tempfile +import time +import unittest +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[1] +WAIT_SCRIPT = REPOSITORY / "sandcastle-wait" + +FAKE_PODMAN = r"""#!/usr/bin/env python3 +import os +import signal +import sys +import time +from pathlib import Path + + +arguments = sys.argv[1:] +with Path(os.environ["FAKE_PODMAN_LOG"]).open("a", encoding="utf-8") as log: + log.write(" ".join(arguments) + "\n") + +if arguments[0] == "inspect": + running = os.environ.get("FAKE_CONTAINER_RUNNING", "true") + if running == "missing": + raise SystemExit(125) + print(running) + raise SystemExit(0) + +if arguments[:2] != ["exec", "taler-sandcastle"]: + raise SystemExit(125) + +command = arguments[2:] +if command[:2] == ["systemctl", "show"]: + states = os.environ["FAKE_UNIT_STATES"].split(",") + state_index = Path(os.environ["FAKE_STATE_INDEX"]) + try: + index = int(state_index.read_text(encoding="utf-8")) + except FileNotFoundError: + index = 0 + state_index.write_text(str(index + 1), encoding="utf-8") + state = states[min(index, len(states) - 1)] + if state == "ERROR": + raise SystemExit(125) + print(state) + raise SystemExit(0) + +if command[:2] == ["systemctl", "status"]: + print("diagnostic unit status") + raise SystemExit(3) + +if command[0] == "journalctl": + stopped = Path(os.environ["FAKE_JOURNAL_STOPPED"]) + + def terminate(_signum, _frame): + stopped.write_text("stopped\n", encoding="utf-8") + raise SystemExit(0) + + signal.signal(signal.SIGTERM, terminate) + print("provisioning journal output", flush=True) + while True: + time.sleep(0.1) + +raise SystemExit(125) +""" + + +class SandcastleWaitTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.bin_dir = self.root / "bin" + self.bin_dir.mkdir() + podman = self.bin_dir / "podman" + podman.write_text(FAKE_PODMAN, encoding="utf-8") + podman.chmod(0o755) + + def tearDown(self): + self.temporary.cleanup() + + def environment(self, states="active", running="true"): + environment = os.environ.copy() + environment.update( + { + "PATH": f"{self.bin_dir}:{environment['PATH']}", + "FAKE_CONTAINER_RUNNING": running, + "FAKE_UNIT_STATES": states, + "FAKE_STATE_INDEX": str(self.root / "state-index"), + "FAKE_PODMAN_LOG": str(self.root / "podman-log"), + "FAKE_JOURNAL_STOPPED": str(self.root / "journal-stopped"), + } + ) + return environment + + def run_wait(self, states="active", running="true"): + return subprocess.run( + [str(WAIT_SCRIPT)], + env=self.environment(states, running), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=5, + ) + + def podman_log(self): + try: + return (self.root / "podman-log").read_text(encoding="utf-8") + except FileNotFoundError: + return "" + + def test_missing_or_stopped_container_fails_immediately(self): + for running in ("missing", "false"): + with self.subTest(running=running): + result = self.run_wait(running=running) + self.assertNotEqual(0, result.returncode) + self.assertIn("does not exist or is not running", result.stderr) + + def test_already_successful_unit_exits_without_following_logs(self): + result = self.run_wait() + + self.assertEqual(0, result.returncode) + self.assertIn("provisioning succeeded", result.stdout) + self.assertNotIn("journalctl", self.podman_log()) + + def test_transient_exec_race_is_retried(self): + result = self.run_wait(states="ERROR,active") + + self.assertEqual(0, result.returncode) + self.assertEqual(2, self.podman_log().count("systemctl show")) + + def test_already_failed_unit_prints_diagnostics(self): + result = self.run_wait(states="failed") + + self.assertNotEqual(0, result.returncode) + self.assertIn("provisioning failed", result.stderr) + self.assertIn("diagnostic unit status", result.stderr) + self.assertIn("systemctl status", self.podman_log()) + + def test_unexpected_unit_state_fails(self): + result = self.run_wait(states="inactive") + + self.assertNotEqual(0, result.returncode) + self.assertIn("unexpected state 'inactive'", result.stderr) + self.assertIn("diagnostic unit status", result.stderr) + + def test_running_unit_follows_logs_and_succeeds(self): + result = self.run_wait(states="activating,active") + + self.assertEqual(0, result.returncode) + self.assertIn("provisioning journal output", result.stdout) + self.assertIn("provisioning succeeded", result.stdout) + self.assertTrue((self.root / "journal-stopped").is_file()) + + def test_running_unit_follows_logs_and_reports_failure(self): + result = self.run_wait(states="activating,failed") + + self.assertNotEqual(0, result.returncode) + self.assertIn("provisioning journal output", result.stdout) + self.assertIn("provisioning failed", result.stderr) + self.assertIn("diagnostic unit status", result.stderr) + self.assertTrue((self.root / "journal-stopped").is_file()) + + def test_losing_contact_while_waiting_fails_and_stops_journal(self): + result = self.run_wait(states="activating,ERROR") + + self.assertNotEqual(0, result.returncode) + self.assertIn("Lost contact", result.stderr) + self.assertTrue((self.root / "journal-stopped").is_file()) + + def test_termination_signal_stops_journal(self): + process = subprocess.Popen( + [str(WAIT_SCRIPT)], + env=self.environment(states="activating"), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + deadline = time.monotonic() + 5 + while "journalctl" not in self.podman_log(): + self.assertIsNone(process.poll()) + if time.monotonic() >= deadline: + self.fail("journal follower did not start") + time.sleep(0.01) + + process.send_signal(signal.SIGTERM) + process.communicate(timeout=5) + finally: + if process.poll() is None: + process.terminate() + process.communicate(timeout=5) + + self.assertEqual(128 + signal.SIGTERM, process.returncode) + self.assertTrue((self.root / "journal-stopped").is_file()) + + +if __name__ == "__main__": + unittest.main()