diff --git a/assets/composition-readiness/README.md b/assets/composition-readiness/README.md index 640fdd7..544bc13 100644 --- a/assets/composition-readiness/README.md +++ b/assets/composition-readiness/README.md @@ -19,12 +19,16 @@ Example private configuration: Run `python3 adapter.py --config /private/adapter.json` inside the application frontend namespace. Keep the token and probe credentials private. A64-character lowercase hexadecimal random token is suitable. This adapter does not need issuer private keys, a Docker socket, root, writable application data or network-administration capabilities. Bind only to loopback; route authorized origin readiness through a separately validated local proxy. -Only GET at the exact canonical path, with one matching `X-APEX-Ready-Token` header, can execute the fixed command. Missing/wrong/duplicate credentials return403; other paths404; supported non-GET methods405. Ready returns204; probe failure, timeout or concurrency exhaustion returns503. These responses have empty bodies and `Cache-Control: no-store`. The adapter does not log requests, tokens or command output, cache success, or accept a client-supplied command. Each connection closes after its response. +Only GET at the exact canonical path, with one matching `X-APEX-Ready-Token` header, can request readiness. In the default mode it executes the fixed command. Missing/wrong/duplicate credentials return403; other paths404; supported non-GET methods405. Ready returns204; probe failure, timeout or concurrency exhaustion returns503. These responses have empty bodies and `Cache-Control: no-store`. The adapter does not log requests, tokens or command output, or accept a client-supplied command. Each connection closes after its response. Probe timeout defaults to1.5seconds and can be configured up to3seconds. With a3-second probe, allow4seconds at the readiness proxy and5seconds at the Worker. A timeout kills the probe's process group. Probe slots and request threads are bounded; slow socket operations time out after two seconds. Operators must account for proxy overhead and actual probe execution time. Probe programs must not detach into a new session or launch background jobs. Optional `single_flight: true` lets overlapping authenticated requests share one running fixed-command probe. It accepts only a JSON boolean and defaults to false. Waiters use that probe's remaining monotonic deadline; they never queue another probe for the same request. The existing connection limit (`slots * 2 + 4`) also bounds waiting requests. Timeout or shutdown returns unavailable and terminates the probe group. Completed results are not cached: each subsequent request starts a fresh probe, with no success TTL or failure cache. Coalescing reduces concurrent work but does not make a slow individual probe meet its deadline. +For checks containing expensive authentication, optional `refresh_interval: 2` and `max_age: 5` run one background probe every two seconds and serve its latest result immediately. This avoids repeating SQL, directory and session checks for each page or asset, including the Worker probe followed by the origin proxy's own check. Probes never overlap; a slow probe delays the next start. Startup, a failed or timed-out probe, shutdown, and an expired result all return503. Freshness uses monotonic time measured from the probe's **start**, not completion, so a stopped refresher cannot retain success indefinitely. With these settings readiness can lag a dependency or role change by at most five seconds. This does not confer write authority or replace fencing during manual promotion. + +Background mode is disabled by default (`refresh_interval: 0`). Its interval must be positive and at most five seconds; `max_age` must cover the interval plus probe timeout and cannot exceed ten seconds. A three-second timeout, two-second interval and five-second maximum age are suitable for the current SSO checks. Token validation, bounded connections, process-group termination and no-store HTTP responses still apply. Request traffic does not launch extra probes in this mode; `single_flight` is used only in the default request-driven mode. + For the controlled active/standby SSO pattern, the probe must reject a node not explicitly configured as active, verify completed migration and unchanged Authelia configuration, and check local Authelia health, authenticated SQL access/schema with read_only disabled, directory access, and an authenticated session write/read/delete over verified TLS. All dependencies belong to the selected local identity instance. These checks do not establish exclusive primary ownership: the operator must fence the previous writer before promotion, and the standby application and ingress stay disabled. No quorum, Sentinel, or session-controller authority is required by this pattern. The generic adapter supplies none of those application checks; do not deploy a placeholder-success probe or claim these tests establish HA. Run `python3 test_adapter.py` for loopback HTTP and subprocess behavior checks. No third-party packages are required. Production native image/probe/proxy integration and location-loss tests remain separate acceptance gates. diff --git a/assets/composition-readiness/adapter.py b/assets/composition-readiness/adapter.py index db0d684..5975492 100644 --- a/assets/composition-readiness/adapter.py +++ b/assets/composition-readiness/adapter.py @@ -18,7 +18,7 @@ class ReadinessServer(http.server.ThreadingHTTPServer): allow_reuse_address = True request_queue_size = 64 - def __init__(self, address, *, token, path, command, timeout=1.5, slots=4, single_flight=False): + def __init__(self, address, *, token, path, command, timeout=1.5, slots=4, single_flight=False, refresh_interval=0, max_age=5): if not isinstance(token, str) or not re.fullmatch(r'[!-~]{16,512}', token): raise ValueError('A private visible-ASCII token is required') if not isinstance(path, str) or not re.fullmatch(r'/[A-Za-z0-9._~/-]+', path) or any(part in ('', '.', '..') for part in path[1:].split('/')): @@ -29,6 +29,10 @@ class ReadinessServer(http.server.ThreadingHTTPServer): raise ValueError('Invalid probe bounds') if not isinstance(single_flight, bool): raise ValueError('single_flight must be a boolean') + if isinstance(refresh_interval, bool) or not isinstance(refresh_interval, (int, float)) or not 0 <= refresh_interval <= 5: + raise ValueError('Invalid refresh interval') + if isinstance(max_age, bool) or not isinstance(max_age, (int, float)) or not 0 < max_age <= 10 or (refresh_interval and max_age < refresh_interval + timeout): + raise ValueError('Invalid readiness freshness bound') self.token = token.encode('ascii') self.ready_path = path self.command = command @@ -40,7 +44,15 @@ class ReadinessServer(http.server.ThreadingHTTPServer): self.active = set() self.active_lock = threading.Lock() self.closing = False + self.refresh_interval = refresh_interval + self.max_age = max_age + self.snapshot = None + self.refresh_stop = threading.Event() + self.refresh_thread = None super().__init__(address, Handler) + if refresh_interval: + self.refresh_thread = threading.Thread(target=self.refresh, daemon=True) + self.refresh_thread.start() def get_request(self): connection, address = super().get_request() @@ -80,6 +92,10 @@ class ReadinessServer(http.server.ThreadingHTTPServer): process.wait() def probe(self): + if self.refresh_interval: + with self.active_lock: + return bool(not self.closing and self.snapshot and self.snapshot['ready'] + and 0 <= time.monotonic() - self.snapshot['started'] < self.max_age) if not self.single_flight: return self.run_probe() with self.active_lock: @@ -104,6 +120,18 @@ class ReadinessServer(http.server.ThreadingHTTPServer): flight['done'].set() return flight['ready'] + def refresh(self): + while not self.refresh_stop.is_set(): + started = time.monotonic() + try: + ready = self.run_probe(started + self.timeout) + except Exception: + ready = False + with self.active_lock: + self.snapshot = {'started': started, 'ready': ready and not self.closing and time.monotonic() < started + self.timeout} + if self.refresh_stop.wait(max(0, started + self.refresh_interval - time.monotonic())): + break + def run_probe(self, deadline=None): if not self.probe_slots.acquire(blocking=False): return False @@ -129,6 +157,7 @@ class ReadinessServer(http.server.ThreadingHTTPServer): self.probe_slots.release() def server_close(self): + self.refresh_stop.set() with self.active_lock: self.closing = True if self.flight is not None: @@ -137,6 +166,8 @@ class ReadinessServer(http.server.ThreadingHTTPServer): active = list(self.active) for process in active: self.stop_probe(process) + if self.refresh_thread is not None: + self.refresh_thread.join(self.timeout + 1) super().server_close() diff --git a/assets/composition-readiness/test_adapter.py b/assets/composition-readiness/test_adapter.py index 555bda4..9c36c4d 100644 --- a/assets/composition-readiness/test_adapter.py +++ b/assets/composition-readiness/test_adapter.py @@ -143,4 +143,40 @@ class AdapterTests(unittest.TestCase): def test_single_flight_requires_explicit_boolean(self): for value in [0,1,None,'true']: with self.subTest(value=value),self.assertRaises(ValueError):self.server(single_flight=value) + def wait_status(self,s,status): + deadline=time.monotonic()+2 + while time.monotonic()