sandcastle-ng

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

sandcastle-wait (5389B)


      1 #!/usr/bin/env python3
      2 
      3 # This file is in the public domain.
      4 
      5 from __future__ import annotations
      6 
      7 import argparse
      8 import signal
      9 import subprocess
     10 import sys
     11 import time
     12 from collections.abc import Iterator, Sequence
     13 from contextlib import contextmanager
     14 
     15 CONTAINER = "taler-sandcastle"
     16 UNIT = "setup-sandcastle.service"
     17 STARTUP_ATTEMPTS = 20
     18 STARTUP_RETRY_SECONDS = 0.1
     19 POLL_SECONDS = 1
     20 JOURNAL_STOP_SECONDS = 2
     21 
     22 
     23 class TerminationRequested(Exception):
     24     def __init__(self, signum: int):
     25         super().__init__(signum)
     26         self.signum = signum
     27 
     28 
     29 def container_is_running() -> bool:
     30     try:
     31         result = subprocess.run(
     32             ["podman", "inspect", "--format", "{{.State.Running}}", CONTAINER],
     33             check=True,
     34             stdout=subprocess.PIPE,
     35             stderr=subprocess.DEVNULL,
     36             text=True,
     37         )
     38     except subprocess.CalledProcessError:
     39         return False
     40     return result.stdout.strip() == "true"
     41 
     42 
     43 def unit_state(*, quiet: bool = False) -> str:
     44     result = subprocess.run(
     45         [
     46             "podman",
     47             "exec",
     48             CONTAINER,
     49             "systemctl",
     50             "show",
     51             "--property=ActiveState",
     52             "--value",
     53             UNIT,
     54         ],
     55         check=True,
     56         stdout=subprocess.PIPE,
     57         stderr=subprocess.DEVNULL if quiet else None,
     58         text=True,
     59     )
     60     return result.stdout.strip()
     61 
     62 
     63 def initial_unit_state() -> str | None:
     64     # A detached podman run can report success just before exec is ready. Retry
     65     # that narrow startup race without imposing a timeout on provisioning.
     66     for attempt in range(STARTUP_ATTEMPTS):
     67         try:
     68             return unit_state(quiet=True)
     69         except subprocess.CalledProcessError:
     70             if attempt + 1 < STARTUP_ATTEMPTS:
     71                 time.sleep(STARTUP_RETRY_SECONDS)
     72 
     73     print(f"Cannot query {UNIT} in container {CONTAINER}.", file=sys.stderr)
     74     return None
     75 
     76 
     77 def stop_process(process: subprocess.Popen[bytes]) -> None:
     78     if process.poll() is not None:
     79         return
     80     try:
     81         process.terminate()
     82     except ProcessLookupError:
     83         process.wait()
     84         return
     85     try:
     86         process.wait(timeout=JOURNAL_STOP_SECONDS)
     87     except subprocess.TimeoutExpired:
     88         process.kill()
     89         process.wait()
     90 
     91 
     92 @contextmanager
     93 def journal_output() -> Iterator[subprocess.Popen[bytes]]:
     94     process = subprocess.Popen(
     95         [
     96             "podman",
     97             "exec",
     98             CONTAINER,
     99             "journalctl",
    100             "--unit",
    101             UNIT,
    102             "--follow",
    103             "--no-pager",
    104         ]
    105     )
    106     try:
    107         yield process
    108     finally:
    109         stop_process(process)
    110 
    111 
    112 def report_failure(message: str) -> None:
    113     print(message, file=sys.stderr)
    114     subprocess.run(
    115         [
    116             "podman",
    117             "exec",
    118             CONTAINER,
    119             "systemctl",
    120             "status",
    121             "--no-pager",
    122             "--full",
    123             UNIT,
    124         ],
    125         check=False,
    126         stdout=sys.stderr,
    127         stderr=sys.stderr,
    128     )
    129 
    130 
    131 def terminal_status(state: str) -> int:
    132     if state == "active":
    133         print("Sandcastle provisioning succeeded.")
    134         return 0
    135     if state == "failed":
    136         report_failure("Sandcastle provisioning failed.")
    137         return 1
    138 
    139     report_failure(f"Sandcastle provisioning entered unexpected state {state!r}.")
    140     return 1
    141 
    142 
    143 def monitor_provisioning() -> int:
    144     if not container_is_running():
    145         print(
    146             f"Container {CONTAINER} does not exist or is not running.",
    147             file=sys.stderr,
    148         )
    149         return 1
    150 
    151     state = initial_unit_state()
    152     if state is None:
    153         return 1
    154     if state != "activating":
    155         return terminal_status(state)
    156 
    157     print(f"Sandcastle provisioning is running; following {UNIT} output.", flush=True)
    158     with journal_output() as journal:
    159         while True:
    160             time.sleep(POLL_SECONDS)
    161             try:
    162                 state = unit_state()
    163             except subprocess.CalledProcessError:
    164                 print(
    165                     f"Lost contact with {UNIT} in container {CONTAINER}.",
    166                     file=sys.stderr,
    167                 )
    168                 return 1
    169 
    170             if state != "activating":
    171                 break
    172 
    173             journal_status = journal.poll()
    174             if journal_status is not None:
    175                 print(
    176                     f"Stopped receiving output from {UNIT} "
    177                     f"(podman exec exited with status {journal_status}).",
    178                     file=sys.stderr,
    179                 )
    180                 return 1
    181 
    182     return terminal_status(state)
    183 
    184 
    185 def request_termination(signum: int, _frame: object) -> None:
    186     raise TerminationRequested(signum)
    187 
    188 
    189 def main(argv: Sequence[str] | None = None) -> int:
    190     parser = argparse.ArgumentParser(
    191         description="Wait for sandcastle provisioning to finish."
    192     )
    193     parser.parse_args(argv)
    194     signal.signal(signal.SIGTERM, request_termination)
    195 
    196     try:
    197         return monitor_provisioning()
    198     except KeyboardInterrupt:
    199         return 128 + signal.SIGINT
    200     except TerminationRequested as request:
    201         return 128 + request.signum
    202     except FileNotFoundError as error:
    203         print(f"Cannot execute {error.filename}: {error.strerror}.", file=sys.stderr)
    204         return 1
    205 
    206 
    207 if __name__ == "__main__":
    208     raise SystemExit(main())