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:
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()