keepup-admin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. keepup/THIRD-PARTY.md +44 -0
  2. keepup/__init__.py +41 -0
  3. keepup/api_versions.py +100 -0
  4. keepup/audit.py +499 -0
  5. keepup/auth/__init__.py +7 -0
  6. keepup/auth/config.py +128 -0
  7. keepup/auth/dependencies.py +485 -0
  8. keepup/auth/dto/__init__.py +0 -0
  9. keepup/auth/dto/token.py +31 -0
  10. keepup/auth/factory.py +28 -0
  11. keepup/auth/login_throttle.py +151 -0
  12. keepup/auth/oidc.py +245 -0
  13. keepup/auth/oidc_policy.py +111 -0
  14. keepup/auth/oidc_routes.py +288 -0
  15. keepup/auth/panel_session.py +220 -0
  16. keepup/auth/permissions.py +59 -0
  17. keepup/auth/providers/__init__.py +0 -0
  18. keepup/auth/providers/base.py +168 -0
  19. keepup/auth/providers/local.py +180 -0
  20. keepup/auth/routes.py +660 -0
  21. keepup/auth/seed_accounts.py +322 -0
  22. keepup/auth/session_lifetime.py +104 -0
  23. keepup/auth/signing_key.py +138 -0
  24. keepup/auth/websocket.py +86 -0
  25. keepup/cluster.py +634 -0
  26. keepup/db.py +663 -0
  27. keepup/events.py +764 -0
  28. keepup/factory.py +484 -0
  29. keepup/instance.py +46 -0
  30. keepup/integrations.py +260 -0
  31. keepup/locks.py +412 -0
  32. keepup/logging_setup.py +690 -0
  33. keepup/metrics.py +818 -0
  34. keepup/metrics_retention.py +376 -0
  35. keepup/modules.py +572 -0
  36. keepup/notification_bus.py +355 -0
  37. keepup/plugins/__init__.py +9 -0
  38. keepup/plugins/admin.py +246 -0
  39. keepup/plugins/base.py +234 -0
  40. keepup/plugins/enablement.py +184 -0
  41. keepup/plugins/registry.py +171 -0
  42. keepup/plugins/route_mask.py +338 -0
  43. keepup/plugins/routes.py +376 -0
  44. keepup/roles.py +17 -0
  45. keepup/scheduler.py +93 -0
  46. keepup/schema.py +295 -0
  47. keepup/sections.json +104 -0
  48. keepup/settings.py +184 -0
  49. keepup/static/css/aos.css +1 -0
  50. keepup/static/css/main_nebula.css +232 -0
  51. keepup/static/css/main_new.css +852 -0
  52. keepup/static/css/tailwind.css +1 -0
  53. keepup/static/index_nebula.html +293 -0
  54. keepup/static/index_new.html +286 -0
  55. keepup/static/js/aos.js +1 -0
  56. keepup/static/js/feather-icons.js +13 -0
  57. keepup/static/js/main_new.js +1861 -0
  58. keepup/static/js/tailwind.js +83 -0
  59. keepup/static/modules/css/background_tasks.css +233 -0
  60. keepup/static/modules/css/cluster.css +16 -0
  61. keepup/static/modules/css/event_manager.css +386 -0
  62. keepup/static/modules/css/integration_logs.css +33 -0
  63. keepup/static/modules/css/metrics.css +115 -0
  64. keepup/static/modules/css/modules.css +189 -0
  65. keepup/static/modules/css/themes.css +563 -0
  66. keepup/static/modules/css/users.css +278 -0
  67. keepup/static/modules/js/background_tasks.js +657 -0
  68. keepup/static/modules/js/chart.js +14 -0
  69. keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  70. keepup/static/modules/js/cluster.js +363 -0
  71. keepup/static/modules/js/event_manager.js +979 -0
  72. keepup/static/modules/js/integration_logs.js +767 -0
  73. keepup/static/modules/js/metrics.js +908 -0
  74. keepup/static/modules/js/modules.js +1086 -0
  75. keepup/static/modules/js/themes.js +653 -0
  76. keepup/static/modules/js/users.js +784 -0
  77. keepup/tables.py +302 -0
  78. keepup/themes.py +496 -0
  79. keepup/web.py +182 -0
  80. keepup_admin-0.1.0.dist-info/METADATA +117 -0
  81. keepup_admin-0.1.0.dist-info/RECORD +86 -0
  82. keepup_admin-0.1.0.dist-info/WHEEL +5 -0
  83. keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
  84. keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
  85. keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
  86. keepup_admin-0.1.0.dist-info/top_level.txt +1 -0
keepup/cluster.py ADDED
@@ -0,0 +1,634 @@
1
+ """The replicas of one deployment, seen and steered from the panel.
2
+
3
+ The application runs as several replicas against one database, each with its
4
+ own set of plugins up. Until this module an administrator saw only the replica
5
+ that happened to answer, and a plugin switched from the panel took effect
6
+ "after a restart" that the panel could not perform.
7
+
8
+ Every replica now publishes itself in ``cluster_members`` and collects the
9
+ commands addressed to it from ``cluster_commands``. Delivery goes through the
10
+ table rather than over HTTP between replicas: replicas do not know each
11
+ other's addresses, and behind a load balancer a request cannot be aimed at
12
+ one of them anyway.
13
+
14
+ Three commands. ``stop`` keeps the process alive and still publishing, but
15
+ answers 503 to the API and refuses WebSockets, and pauses the scheduler so
16
+ that background jobs and leader elections move to the other replicas.
17
+ ``start`` undoes it. ``restart`` shuts the server down the normal way --
18
+ lifespan, plugin cleanup, buffers flushed -- and then execs the same command
19
+ line in the same process, so no supervisor is needed.
20
+
21
+ See doc/cluster_control.md.
22
+ """
23
+
24
+ import asyncio
25
+ import atexit
26
+ import json
27
+ import logging
28
+ import multiprocessing
29
+ import os
30
+ import signal
31
+ import socket
32
+ import sys
33
+ from dataclasses import dataclass
34
+ from datetime import datetime, timedelta
35
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
36
+
37
+ from fastapi import Depends, HTTPException
38
+ from fastapi.responses import JSONResponse
39
+
40
+ from keepup.auth.dependencies import get_current_admin
41
+ from keepup.db import DatabaseManagerV2
42
+ from keepup.instance import get_instance_id, get_instance_name
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # ================================ constants ================================
47
+
48
+ #: How often a replica publishes itself and looks for its commands.
49
+ HEARTBEAT_SECONDS = 10
50
+ #: A replica silent for this many heartbeats is shown as gone.
51
+ MISSED_HEARTBEATS = 3
52
+ #: A gone replica stays listed this long: one that vanished without a trace is
53
+ #: exactly what an administrator needs to see. Older rows are deleted.
54
+ MEMBER_RETENTION = timedelta(days=1)
55
+ #: A command nobody picked up, or a restart that never came back, is closed
56
+ #: after this long -- otherwise it would block every later command.
57
+ COMMAND_TTL = timedelta(minutes=5)
58
+ #: How many past commands the panel is shown.
59
+ RECENT_COMMANDS = 30
60
+
61
+ STATE_SERVING = "serving"
62
+ STATE_STOPPED = "stopped"
63
+ #: Never stored: how a member reads when its heartbeat is too old.
64
+ STATE_GONE = "gone"
65
+
66
+ ACTION_STOP = "stop"
67
+ ACTION_START = "start"
68
+ ACTION_RESTART = "restart"
69
+ ACTIONS = (ACTION_STOP, ACTION_START, ACTION_RESTART)
70
+
71
+ STATUS_PENDING = "pending"
72
+ STATUS_EXECUTING = "executing"
73
+ STATUS_DONE = "done"
74
+ STATUS_FAILED = "failed"
75
+ STATUS_EXPIRED = "expired"
76
+ OPEN_STATUSES = (STATUS_PENDING, STATUS_EXECUTING)
77
+
78
+ #: A stopped replica refuses only these: what serves the product.
79
+ GATED_PREFIXES = ("/api/", "/ws/")
80
+ #: ...except what the panel needs to sign in and to send ``start``. A stopped
81
+ #: last replica must still be startable from the same panel -- otherwise
82
+ #: stopping it with ``force`` would need the infrastructure after all.
83
+ ALLOWED_WHILE_STOPPED = (
84
+ "/api/auth/",
85
+ "/api/theme/",
86
+ "/api/modules",
87
+ "/api/agreements",
88
+ "/api/public/config",
89
+ "/api/version",
90
+ "/api/admin/cluster",
91
+ # Answers 503 "stopped" by itself, so that a load balancer takes the
92
+ # replica out; see keepup/web.py.
93
+ "/api/health",
94
+ )
95
+
96
+ STOPPED_DETAIL = "This replica is stopped by an administrator; another replica serves the request."
97
+ #: WebSocket close code "try again later": the client reconnects, and the
98
+ #: balancer sends it to a replica that serves.
99
+ WS_CLOSE_TRY_AGAIN = 1013
100
+
101
+ # ================================== rules ==================================
102
+
103
+ def utcnow() -> datetime:
104
+ return datetime.utcnow()
105
+
106
+
107
+ def _as_datetime(value: Any) -> Optional[datetime]:
108
+ """SQLite hands timestamps back as text, PostgreSQL as datetimes."""
109
+ if value is None or isinstance(value, datetime):
110
+ return value
111
+ try:
112
+ return datetime.fromisoformat(str(value))
113
+ except ValueError:
114
+ return None
115
+
116
+
117
+ def is_alive(last_seen: Any, now: datetime) -> bool:
118
+ last_seen = _as_datetime(last_seen)
119
+ if last_seen is None:
120
+ return False
121
+ return now - last_seen <= timedelta(seconds=HEARTBEAT_SECONDS * MISSED_HEARTBEATS)
122
+
123
+
124
+ def restart_blocker(parent_process: Callable[[], Any] = multiprocessing.parent_process,
125
+ orig_argv: Optional[List[str]] = None) -> Optional[str]:
126
+ """Why this process cannot restart itself, or None when it can.
127
+
128
+ Under ``reload`` or with several workers the server process is spawned by
129
+ a supervising parent: its command line is multiprocessing's own, and
130
+ re-running it would start an orphan worker rather than the server. The
131
+ parent owns the process, so it is the parent that has to be restarted.
132
+ """
133
+ if parent_process() is not None:
134
+ return ("The server process is run by a supervising parent (uvicorn reload "
135
+ "or several workers); restart that parent instead.")
136
+ argv = sys.orig_argv if orig_argv is None else orig_argv
137
+ if not argv:
138
+ return "The command line this process was started with is unknown."
139
+ return None
140
+
141
+
142
+ def allowed_while_stopped(path: str) -> bool:
143
+ """Whether a stopped replica still answers this path."""
144
+ if not path.startswith(GATED_PREFIXES):
145
+ return True
146
+ return path.startswith(ALLOWED_WHILE_STOPPED)
147
+
148
+
149
+ class CommandRefused(Exception):
150
+ """A command the administrator cannot give, with the HTTP status to answer."""
151
+
152
+ def __init__(self, status_code: int, reason: str):
153
+ super().__init__(reason)
154
+ self.status_code = status_code
155
+ self.reason = reason
156
+
157
+
158
+ def check_command(action: str, target: Optional[Dict[str, Any]],
159
+ members: List[Dict[str, Any]], open_command: Optional[Dict[str, Any]],
160
+ force: bool, now: datetime) -> None:
161
+ """Refuse a command that cannot or must not be carried out.
162
+
163
+ Pure: the registry comes in, a refusal goes out. Each branch is a case an
164
+ administrator can actually run into from the panel.
165
+ """
166
+ if action not in ACTIONS:
167
+ raise CommandRefused(400, f"Unknown action {action!r}; expected one of {', '.join(ACTIONS)}.")
168
+ if target is None:
169
+ raise CommandRefused(404, "No such replica in the cluster.")
170
+ if not is_alive(target.get("last_seen"), now):
171
+ raise CommandRefused(
172
+ 409, f"The replica has not answered since {target.get('last_seen')}; "
173
+ f"a command would never be picked up.")
174
+ if open_command is not None:
175
+ raise CommandRefused(
176
+ 409, f"The replica has an unfinished command ({open_command.get('action')}, "
177
+ f"{open_command.get('status')}); wait for it.")
178
+ if action == ACTION_RESTART and not target.get("can_restart"):
179
+ raise CommandRefused(
180
+ 409, target.get("restart_blocker") or "This replica cannot restart itself.")
181
+ if action == ACTION_STOP and target.get("state") == STATE_SERVING and not force:
182
+ others = [m for m in members
183
+ if m.get("instance_id") != target.get("instance_id")
184
+ and m.get("state") == STATE_SERVING
185
+ and is_alive(m.get("last_seen"), now)]
186
+ if not others:
187
+ raise CommandRefused(
188
+ 409, "This is the last replica serving the cluster: stopping it takes "
189
+ "the whole product down. Repeat with force to do it anyway.")
190
+
191
+ # ================================ repository ===============================
192
+
193
+ _MEMBER_COLUMNS = ("instance_id", "instance_name", "host", "pid", "build", "started_at",
194
+ "last_seen", "state", "can_restart", "restart_blocker",
195
+ "plugins_running", "plugins_pending")
196
+
197
+
198
+ def publish_member(member: Dict[str, Any]) -> None:
199
+ """Write this replica's row. Only the replica itself writes it, so an
200
+ update-then-insert has no one to race with."""
201
+ values = {column: member.get(column) for column in _MEMBER_COLUMNS}
202
+ for column in ("plugins_running", "plugins_pending"):
203
+ values[column] = json.dumps(values[column] or [])
204
+ assignments = ", ".join(f"{c} = :{c}" for c in _MEMBER_COLUMNS if c != "instance_id")
205
+ updated = DatabaseManagerV2.execute_commit(
206
+ f"UPDATE cluster_members SET {assignments} WHERE instance_id = :instance_id", values)
207
+ if not updated:
208
+ columns = ", ".join(_MEMBER_COLUMNS)
209
+ placeholders = ", ".join(f":{c}" for c in _MEMBER_COLUMNS)
210
+ DatabaseManagerV2.execute_commit(
211
+ f"INSERT INTO cluster_members ({columns}) VALUES ({placeholders})", values)
212
+
213
+
214
+ def _member_from_row(row: Dict[str, Any]) -> Dict[str, Any]:
215
+ member = dict(row)
216
+ for column in ("plugins_running", "plugins_pending"):
217
+ try:
218
+ member[column] = json.loads(member.get(column) or "[]")
219
+ except (TypeError, ValueError):
220
+ member[column] = []
221
+ member["can_restart"] = bool(member.get("can_restart"))
222
+ return member
223
+
224
+
225
+ def read_members() -> List[Dict[str, Any]]:
226
+ rows = DatabaseManagerV2.execute(
227
+ "SELECT * FROM cluster_members ORDER BY instance_name, instance_id", {})
228
+ return [_member_from_row(row) for row in rows]
229
+
230
+
231
+ def read_member(instance_id: str) -> Optional[Dict[str, Any]]:
232
+ row = DatabaseManagerV2.execute_one(
233
+ "SELECT * FROM cluster_members WHERE instance_id = :id", {"id": instance_id})
234
+ return _member_from_row(row) if row else None
235
+
236
+
237
+ def write_state(instance_id: str, state: str) -> None:
238
+ DatabaseManagerV2.execute_commit(
239
+ "UPDATE cluster_members SET state = :state WHERE instance_id = :id",
240
+ {"state": state, "id": instance_id})
241
+
242
+
243
+ def record_command(instance_id: str, action: str, forced: bool, requested_by: Optional[int],
244
+ requested_by_name: Optional[str], now: datetime) -> Optional[int]:
245
+ row = DatabaseManagerV2.execute_commit_returning(
246
+ "INSERT INTO cluster_commands (instance_id, action, forced, status, requested_by, "
247
+ "requested_by_name, requested_at) VALUES (:instance_id, :action, :forced, :status, "
248
+ ":requested_by, :requested_by_name, :now)",
249
+ {"instance_id": instance_id, "action": action, "forced": bool(forced),
250
+ "status": STATUS_PENDING, "requested_by": requested_by,
251
+ "requested_by_name": requested_by_name, "now": now})
252
+ return row.get("id") if row else None
253
+
254
+
255
+ def open_command_for(instance_id: str) -> Optional[Dict[str, Any]]:
256
+ return DatabaseManagerV2.execute_one(
257
+ "SELECT * FROM cluster_commands WHERE instance_id = :id "
258
+ "AND status IN (:pending, :executing) ORDER BY id LIMIT 1",
259
+ {"id": instance_id, "pending": STATUS_PENDING, "executing": STATUS_EXECUTING})
260
+
261
+
262
+ def pending_commands_for(instance_id: str) -> List[Dict[str, Any]]:
263
+ return DatabaseManagerV2.execute(
264
+ "SELECT * FROM cluster_commands WHERE instance_id = :id AND status = :pending ORDER BY id",
265
+ {"id": instance_id, "pending": STATUS_PENDING})
266
+
267
+
268
+ def claim_command(command_id: int, now: datetime) -> bool:
269
+ """Take a command for execution; False when somebody already has.
270
+
271
+ A conditional UPDATE rather than SELECT ... FOR UPDATE SKIP LOCKED, which
272
+ is PostgreSQL-only -- and SQLite is the development mode.
273
+ """
274
+ claimed = DatabaseManagerV2.execute_commit(
275
+ "UPDATE cluster_commands SET status = :executing, claimed_at = :now "
276
+ "WHERE id = :id AND status = :pending",
277
+ {"executing": STATUS_EXECUTING, "now": now, "id": command_id, "pending": STATUS_PENDING})
278
+ return claimed == 1
279
+
280
+
281
+ def finish_command(command_id: int, status: str, result: str, now: datetime) -> None:
282
+ DatabaseManagerV2.execute_commit(
283
+ "UPDATE cluster_commands SET status = :status, result = :result, finished_at = :now "
284
+ "WHERE id = :id",
285
+ {"status": status, "result": result, "now": now, "id": command_id})
286
+
287
+
288
+ def executing_restarts_for(instance_id: str) -> List[Dict[str, Any]]:
289
+ return DatabaseManagerV2.execute(
290
+ "SELECT * FROM cluster_commands WHERE instance_id = :id AND action = :restart "
291
+ "AND status = :executing ORDER BY id",
292
+ {"id": instance_id, "restart": ACTION_RESTART, "executing": STATUS_EXECUTING})
293
+
294
+
295
+ def expire_commands(now: datetime) -> int:
296
+ """Close commands that will never finish, so they stop blocking new ones."""
297
+ cutoff = now - COMMAND_TTL
298
+ expired = DatabaseManagerV2.execute_commit(
299
+ "UPDATE cluster_commands SET status = :expired, finished_at = :now, "
300
+ "result = :not_picked WHERE status = :pending AND requested_at < :cutoff",
301
+ {"expired": STATUS_EXPIRED, "now": now, "cutoff": cutoff, "pending": STATUS_PENDING,
302
+ "not_picked": "The replica did not pick the command up in time."})
303
+ expired += DatabaseManagerV2.execute_commit(
304
+ "UPDATE cluster_commands SET status = :expired, finished_at = :now, "
305
+ "result = :not_back WHERE status = :executing AND claimed_at < :cutoff",
306
+ {"expired": STATUS_EXPIRED, "now": now, "cutoff": cutoff, "executing": STATUS_EXECUTING,
307
+ "not_back": "The replica did not report the command finished in time."})
308
+ return expired
309
+
310
+
311
+ def prune_members(now: datetime) -> int:
312
+ return DatabaseManagerV2.execute_commit(
313
+ "DELETE FROM cluster_members WHERE last_seen < :cutoff",
314
+ {"cutoff": now - MEMBER_RETENTION})
315
+
316
+
317
+ def recent_commands(limit: int = RECENT_COMMANDS) -> List[Dict[str, Any]]:
318
+ return DatabaseManagerV2.execute(
319
+ "SELECT * FROM cluster_commands ORDER BY id DESC LIMIT :limit", {"limit": int(limit)})
320
+
321
+ # ================================ controller ===============================
322
+
323
+ @dataclass
324
+ class PluginPicture:
325
+ """What runs in this replica, and what its next start would change."""
326
+ running: List[str]
327
+ pending: List[str]
328
+
329
+
330
+ async def _no_plugins() -> PluginPicture:
331
+ return PluginPicture([], [])
332
+
333
+
334
+ def _default_scheduler():
335
+ from keepup.scheduler import get_scheduler
336
+ return get_scheduler()
337
+
338
+
339
+ class ReplicaController:
340
+ """This replica's side of the cluster: its state, heartbeat and commands.
341
+
342
+ The heartbeat runs as its own asyncio task and not as a scheduler job: a
343
+ stopped replica pauses the scheduler, and a heartbeat inside it would go
344
+ silent and never hear ``start``.
345
+ """
346
+
347
+ def __init__(self, plugins: Callable[[], Awaitable[PluginPicture]] = _no_plugins,
348
+ build: Optional[str] = None,
349
+ scheduler: Callable[[], Any] = _default_scheduler,
350
+ clock: Callable[[], datetime] = utcnow,
351
+ instance_id: Optional[str] = None,
352
+ blocker: Optional[str] = None,
353
+ kill: Callable[[int, int], None] = os.kill,
354
+ execv: Callable[[str, List[str]], None] = os.execv):
355
+ self.instance_id = instance_id or get_instance_id()
356
+ self.instance_name = get_instance_name()
357
+ self.state = STATE_SERVING
358
+ self.started_at = clock()
359
+ self.build = build
360
+ self.blocker = restart_blocker() if blocker is None else (blocker or None)
361
+ self._plugins = plugins
362
+ self._scheduler = scheduler
363
+ self._clock = clock
364
+ self._kill = kill
365
+ self._execv = execv
366
+ self._lock = asyncio.Lock()
367
+ self._task: Optional[asyncio.Task] = None
368
+ self.restart_requested = False
369
+
370
+ # ------------------------------------------------------------ lifecycle
371
+
372
+ def restore(self) -> None:
373
+ """Pick up where this replica was left: a stopped one stays stopped.
374
+
375
+ Otherwise a restart -- ours, the container's, the host's -- would
376
+ quietly put back into service a replica an administrator took out.
377
+ """
378
+ try:
379
+ previous = read_member(self.instance_id)
380
+ except Exception as error:
381
+ logger.warning(f"Cluster: could not read this replica's previous state: {error}")
382
+ return
383
+ if previous and previous.get("state") == STATE_STOPPED:
384
+ logger.warning("Cluster: this replica was stopped by an administrator and stays stopped")
385
+ self.stop()
386
+ self._close_restarts()
387
+
388
+ def _close_restarts(self) -> None:
389
+ """A restart command is finished by the process that comes back from it."""
390
+ now = self._clock()
391
+ for command in executing_restarts_for(self.instance_id):
392
+ finish_command(command["id"], STATUS_DONE,
393
+ f"Restarted; the replica is back since {self.started_at.isoformat()}Z.",
394
+ now)
395
+
396
+ async def run_forever(self) -> None:
397
+ while True:
398
+ try:
399
+ await self.tick()
400
+ except asyncio.CancelledError:
401
+ raise
402
+ except Exception as error:
403
+ # The database may be briefly away; the next heartbeat retries.
404
+ logger.warning(f"Cluster heartbeat failed: {error}")
405
+ await asyncio.sleep(HEARTBEAT_SECONDS)
406
+
407
+ def launch(self) -> None:
408
+ try:
409
+ self.restore()
410
+ except Exception as error:
411
+ logger.warning(f"Cluster: could not restore this replica's state: {error}")
412
+ self._task = asyncio.create_task(self.run_forever())
413
+
414
+ async def shutdown(self) -> None:
415
+ if self._task is not None:
416
+ self._task.cancel()
417
+ try:
418
+ await self._task
419
+ except asyncio.CancelledError:
420
+ pass
421
+ self._task = None
422
+
423
+ def nudge(self) -> None:
424
+ """Handle a command addressed to this very replica now, not at the next beat."""
425
+ asyncio.create_task(self._tick_quietly())
426
+
427
+ async def _tick_quietly(self) -> None:
428
+ try:
429
+ await self.tick()
430
+ except Exception as error:
431
+ logger.warning(f"Cluster: immediate command pass failed: {error}")
432
+
433
+ # ------------------------------------------------------------ one pass
434
+
435
+ async def tick(self) -> None:
436
+ async with self._lock:
437
+ picture = await self._plugins()
438
+ now = self._clock()
439
+ publish_member(self.as_member(picture, now))
440
+ expire_commands(now)
441
+ for command in pending_commands_for(self.instance_id):
442
+ if claim_command(command["id"], now):
443
+ self.execute(command)
444
+ prune_members(now)
445
+
446
+ def as_member(self, picture: PluginPicture, now: datetime) -> Dict[str, Any]:
447
+ return {
448
+ "instance_id": self.instance_id,
449
+ "instance_name": self.instance_name,
450
+ "host": socket.gethostname(),
451
+ "pid": os.getpid(),
452
+ "build": self.build,
453
+ "started_at": self.started_at,
454
+ "last_seen": now,
455
+ "state": self.state,
456
+ "can_restart": self.blocker is None,
457
+ "restart_blocker": self.blocker,
458
+ "plugins_running": picture.running,
459
+ "plugins_pending": picture.pending,
460
+ }
461
+
462
+ def execute(self, command: Dict[str, Any]) -> None:
463
+ action = command.get("action")
464
+ now = self._clock()
465
+ if action == ACTION_STOP:
466
+ if self.state == STATE_STOPPED:
467
+ finish_command(command["id"], STATUS_DONE, "Already stopped.", now)
468
+ return
469
+ self.stop()
470
+ finish_command(command["id"], STATUS_DONE, "Stopped: the API answers 503, the scheduler is paused.", now)
471
+ elif action == ACTION_START:
472
+ if self.state == STATE_SERVING:
473
+ finish_command(command["id"], STATUS_DONE, "Already serving.", now)
474
+ return
475
+ self.start()
476
+ finish_command(command["id"], STATUS_DONE, "Serving again.", now)
477
+ elif action == ACTION_RESTART:
478
+ if self.blocker:
479
+ finish_command(command["id"], STATUS_FAILED, self.blocker, now)
480
+ return
481
+ # Left executing: the process that comes back closes it.
482
+ self.request_restart()
483
+ else:
484
+ finish_command(command["id"], STATUS_FAILED, f"Unknown action {action!r}.", now)
485
+
486
+ # ------------------------------------------------------------ actions
487
+
488
+ def stop(self) -> None:
489
+ self.state = STATE_STOPPED
490
+ write_state(self.instance_id, STATE_STOPPED)
491
+ scheduler = self._scheduler()
492
+ if scheduler is not None and getattr(scheduler, "running", False):
493
+ scheduler.pause()
494
+ logger.warning("Cluster: this replica is stopped -- the API answers 503, the scheduler is paused")
495
+
496
+ def start(self) -> None:
497
+ self.state = STATE_SERVING
498
+ write_state(self.instance_id, STATE_SERVING)
499
+ scheduler = self._scheduler()
500
+ if scheduler is not None and getattr(scheduler, "running", False):
501
+ scheduler.resume()
502
+ logger.warning("Cluster: this replica serves again")
503
+
504
+ def request_restart(self) -> None:
505
+ """Shut down the normal way, then run the same command line again.
506
+
507
+ SIGTERM makes uvicorn go through the application's shutdown; the exec
508
+ happens once the interpreter is on its way out, from atexit, so that
509
+ nothing of the shutdown is skipped.
510
+ """
511
+ if self.restart_requested:
512
+ return
513
+ self.restart_requested = True
514
+ atexit.register(self.exec_again)
515
+ logger.warning("Cluster: restarting this replica on an administrator's command")
516
+ self._kill(os.getpid(), signal.SIGTERM)
517
+
518
+ def exec_again(self) -> None:
519
+ argv = list(sys.orig_argv)
520
+ try:
521
+ self._execv(sys.executable, argv)
522
+ except OSError as error:
523
+ # The process simply ends; a supervisor, if any, takes it from here.
524
+ logger.error(f"Cluster: could not exec {argv!r} again: {error}")
525
+
526
+
527
+ #: The controller of this process, once the application has started one.
528
+ _controller: Optional[ReplicaController] = None
529
+
530
+
531
+ def set_controller(controller: Optional[ReplicaController]) -> None:
532
+ global _controller
533
+ _controller = controller
534
+
535
+
536
+ def get_controller() -> Optional[ReplicaController]:
537
+ return _controller
538
+
539
+
540
+ def current_state() -> str:
541
+ return _controller.state if _controller is not None else STATE_SERVING
542
+
543
+ # ============================ gate and routes ==============================
544
+
545
+ class StoppedReplicaGate:
546
+ """Pure ASGI middleware: what a stopped replica refuses.
547
+
548
+ A gate rather than removing routes, which FastAPI cannot do on a running
549
+ application -- and ``start`` has to put everything back. Installed inside
550
+ the API version middleware, so it sees the registered path without /v1.
551
+ """
552
+
553
+ def __init__(self, app):
554
+ self.app = app
555
+
556
+ async def __call__(self, scope, receive, send):
557
+ if (scope.get("type") in ("http", "websocket")
558
+ and current_state() == STATE_STOPPED
559
+ and not allowed_while_stopped(scope.get("path", ""))):
560
+ if scope["type"] == "http":
561
+ response = JSONResponse({"detail": STOPPED_DETAIL}, status_code=503,
562
+ headers={"Retry-After": str(HEARTBEAT_SECONDS)})
563
+ await response(scope, receive, send)
564
+ else:
565
+ await send({"type": "websocket.close", "code": WS_CLOSE_TRY_AGAIN})
566
+ return
567
+ await self.app(scope, receive, send)
568
+
569
+
570
+ def _member_view(member: Dict[str, Any], now: datetime,
571
+ last_command: Optional[Dict[str, Any]]) -> Dict[str, Any]:
572
+ alive = is_alive(member.get("last_seen"), now)
573
+ view = dict(member)
574
+ view["alive"] = alive
575
+ view["display_state"] = member.get("state") if alive else STATE_GONE
576
+ view["last_command"] = last_command
577
+ return view
578
+
579
+
580
+ async def cluster_overview(now: Optional[datetime] = None) -> Dict[str, Any]:
581
+ """The registry as the panel shows it."""
582
+ now = now or utcnow()
583
+ members = read_members()
584
+ commands = recent_commands()
585
+ latest: Dict[str, Dict[str, Any]] = {}
586
+ for command in commands:
587
+ latest.setdefault(command["instance_id"], command)
588
+ controller = get_controller()
589
+ return {
590
+ "answered_by": controller.instance_id if controller else get_instance_id(),
591
+ "heartbeat_seconds": HEARTBEAT_SECONDS,
592
+ "members": [_member_view(m, now, latest.get(m["instance_id"])) for m in members],
593
+ "commands": commands,
594
+ }
595
+
596
+
597
+ async def give_command(instance_id: str, request: Dict[str, Any], admin: Dict[str, Any],
598
+ now: Optional[datetime] = None) -> Dict[str, Any]:
599
+ """Address a command to one replica; it collects the command itself."""
600
+ now = now or utcnow()
601
+ request = request or {}
602
+ action = request.get("action")
603
+ force = request.get("force") is True
604
+ members = read_members()
605
+ target = next((m for m in members if m["instance_id"] == instance_id), None)
606
+ try:
607
+ check_command(action, target, members,
608
+ open_command_for(instance_id) if target else None, force, now)
609
+ except CommandRefused as refusal:
610
+ raise HTTPException(status_code=refusal.status_code, detail=refusal.reason)
611
+
612
+ command_id = record_command(instance_id, action, force, admin.get("id"),
613
+ admin.get("username"), now)
614
+ logger.warning(f"Cluster: {admin.get('username')} ordered {action} of {instance_id}"
615
+ f"{' (forced)' if force else ''}")
616
+ controller = get_controller()
617
+ if controller is not None and controller.instance_id == instance_id:
618
+ controller.nudge()
619
+ return {"success": True, "command": {"id": command_id, "instance_id": instance_id,
620
+ "action": action, "forced": force,
621
+ "status": STATUS_PENDING}}
622
+
623
+
624
+ def register_cluster_routes(app) -> None:
625
+ @app.get("/api/admin/cluster")
626
+ async def cluster_overview_endpoint(admin: dict = Depends(get_current_admin)):
627
+ """The replicas of this deployment, their plugins and commands (administrators only)."""
628
+ return await cluster_overview()
629
+
630
+ @app.post("/api/admin/cluster/{instance_id}/commands")
631
+ async def give_command_endpoint(instance_id: str, request: Dict[str, Any],
632
+ admin: dict = Depends(get_current_admin)):
633
+ """Stop, start or restart one replica (administrators only)."""
634
+ return await give_command(instance_id, request, admin)