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