keepup-cluster 0.5.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepup-cluster
3
+ Version: 0.5.0
4
+ Summary: The replicas of this deployment, their state and the commands addressed to them.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: keepup-admin>=0.4.0
8
+ Requires-Dist: keepup-db>=0.4.0
9
+ Requires-Dist: keepup-auth>=0.4.0
10
+ Requires-Dist: fastapi<1,>=0.128
11
+
12
+ # keepup-cluster
13
+
14
+ The replicas of this deployment, their state and the commands addressed to them.
15
+
16
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
17
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
18
+ this distribution carries one part of it. It is installed on its own
19
+ (`pip install keepup-cluster`) or together with the base (`pip install "keepup-admin[panel]"`),
20
+ and it declares itself to the kernel through its
21
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,10 @@
1
+ # keepup-cluster
2
+
3
+ The replicas of this deployment, their state and the commands addressed to them.
4
+
5
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
6
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
7
+ this distribution carries one part of it. It is installed on its own
8
+ (`pip install keepup-cluster`) or together with the base (`pip install "keepup-admin[panel]"`),
9
+ and it declares itself to the kernel through its
10
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,50 @@
1
+ """The replicas of this deployment: their state, their commands and their panel.
2
+
3
+ This distribution provides `cluster` and declares one plugin, `cluster`, in the
4
+ `keepup.plugins` group. It depends on `keepup-admin` for the constructor, on
5
+ `keepup-db` for the two tables the registry keeps, and on `keepup-auth` for the
6
+ panel guard of its routes -- the temporary edge the route bridge carries until
7
+ `keepup-http` mounts what a plugin declares (keepup-121).
8
+
9
+ What stays in the kernel: `keepup.instance` (which replica this process is) and
10
+ `keepup.notification_bus` (how replicas reach each other). They are facts about
11
+ the process, not capabilities, and the kernel's own pieces need them.
12
+ """
13
+
14
+ from keepup_cluster.cluster import (
15
+ ACTION_RESTART,
16
+ ACTION_START,
17
+ ACTION_STOP,
18
+ STATE_GONE,
19
+ STATE_SERVING,
20
+ STATE_STOPPED,
21
+ ReplicaController,
22
+ current_state,
23
+ get_controller,
24
+ set_controller,
25
+ )
26
+
27
+ __version__ = "0.5.0"
28
+
29
+ __all__ = [
30
+ "ACTION_RESTART",
31
+ "ACTION_START",
32
+ "ACTION_STOP",
33
+ "STATE_GONE",
34
+ "STATE_SERVING",
35
+ "STATE_STOPPED",
36
+ "ReplicaController",
37
+ "current_state",
38
+ "get_controller",
39
+ "set_controller",
40
+ ]
41
+
42
+ _PLUGIN_NAMES = {"ClusterPlugin", "SERVICE_CLUSTER"}
43
+
44
+
45
+ def __getattr__(name):
46
+ if name in _PLUGIN_NAMES:
47
+ from keepup_cluster import plugin
48
+
49
+ return getattr(plugin, name)
50
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,666 @@
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 import clock
41
+ from keepup_auth.dependencies import get_current_admin
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
+ """Now, in UTC, without a timezone -- see `keepup.clock`."""
105
+ return clock.utcnow()
106
+
107
+
108
+ def _as_datetime(value: Any) -> Optional[datetime]:
109
+ """SQLite hands timestamps back as text, PostgreSQL as datetimes."""
110
+ if value is None or isinstance(value, datetime):
111
+ return value
112
+ try:
113
+ return datetime.fromisoformat(str(value))
114
+ except ValueError:
115
+ return None
116
+
117
+
118
+ def is_alive(last_seen: Any, now: datetime) -> bool:
119
+ last_seen = _as_datetime(last_seen)
120
+ if last_seen is None:
121
+ return False
122
+ return now - last_seen <= timedelta(seconds=HEARTBEAT_SECONDS * MISSED_HEARTBEATS)
123
+
124
+
125
+ def restart_blocker(parent_process: Callable[[], Any] = multiprocessing.parent_process,
126
+ orig_argv: Optional[List[str]] = None) -> Optional[str]:
127
+ """Why this process cannot restart itself, or None when it can.
128
+
129
+ Under ``reload`` or with several workers the server process is spawned by
130
+ a supervising parent: its command line is multiprocessing's own, and
131
+ re-running it would start an orphan worker rather than the server. The
132
+ parent owns the process, so it is the parent that has to be restarted.
133
+ """
134
+ if parent_process() is not None:
135
+ return ("The server process is run by a supervising parent (uvicorn reload "
136
+ "or several workers); restart that parent instead.")
137
+ argv = sys.orig_argv if orig_argv is None else orig_argv
138
+ if not argv:
139
+ return "The command line this process was started with is unknown."
140
+ return None
141
+
142
+
143
+ def allowed_while_stopped(path: str) -> bool:
144
+ """Whether a stopped replica still answers this path."""
145
+ if not path.startswith(GATED_PREFIXES):
146
+ return True
147
+ return path.startswith(ALLOWED_WHILE_STOPPED)
148
+
149
+
150
+ class CommandRefused(Exception):
151
+ """A command the administrator cannot give, with the HTTP status to answer."""
152
+
153
+ def __init__(self, status_code: int, reason: str):
154
+ super().__init__(reason)
155
+ self.status_code = status_code
156
+ self.reason = reason
157
+
158
+
159
+ def check_command(action: str, target: Optional[Dict[str, Any]],
160
+ members: List[Dict[str, Any]], open_command: Optional[Dict[str, Any]],
161
+ force: bool, now: datetime) -> None:
162
+ """Refuse a command that cannot or must not be carried out.
163
+
164
+ Pure: the registry comes in, a refusal goes out. Each branch is a case an
165
+ administrator can actually run into from the panel.
166
+ """
167
+ if action not in ACTIONS:
168
+ raise CommandRefused(400, f"Unknown action {action!r}; expected one of {', '.join(ACTIONS)}.")
169
+ if target is None:
170
+ raise CommandRefused(404, "No such replica in the cluster.")
171
+ if not is_alive(target.get("last_seen"), now):
172
+ raise CommandRefused(
173
+ 409, f"The replica has not answered since {target.get('last_seen')}; "
174
+ f"a command would never be picked up.")
175
+ if open_command is not None:
176
+ raise CommandRefused(
177
+ 409, f"The replica has an unfinished command ({open_command.get('action')}, "
178
+ f"{open_command.get('status')}); wait for it.")
179
+ if action == ACTION_RESTART and not target.get("can_restart"):
180
+ raise CommandRefused(
181
+ 409, target.get("restart_blocker") or "This replica cannot restart itself.")
182
+ if action == ACTION_STOP and target.get("state") == STATE_SERVING and not force:
183
+ others = [m for m in members
184
+ if m.get("instance_id") != target.get("instance_id")
185
+ and m.get("state") == STATE_SERVING
186
+ and is_alive(m.get("last_seen"), now)]
187
+ if not others:
188
+ raise CommandRefused(
189
+ 409, "This is the last replica serving the cluster: stopping it takes "
190
+ "the whole product down. Repeat with force to do it anyway.")
191
+
192
+ # ================================ repository ===============================
193
+
194
+ _MEMBER_COLUMNS = ("instance_id", "instance_name", "host", "pid", "build", "started_at",
195
+ "last_seen", "state", "can_restart", "restart_blocker",
196
+ "plugins_running", "plugins_pending")
197
+
198
+
199
+ def publish_member(member: Dict[str, Any]) -> None:
200
+ """Write this replica's row. Only the replica itself writes it, so an
201
+ update-then-insert has no one to race with."""
202
+ from keepup_db import DatabaseManagerV2
203
+ values = {column: member.get(column) for column in _MEMBER_COLUMNS}
204
+ for column in ("plugins_running", "plugins_pending"):
205
+ values[column] = json.dumps(values[column] or [])
206
+ assignments = ", ".join(f"{c} = :{c}" for c in _MEMBER_COLUMNS if c != "instance_id")
207
+ updated = DatabaseManagerV2.execute_commit(
208
+ f"UPDATE cluster_members SET {assignments} WHERE instance_id = :instance_id", values)
209
+ if not updated:
210
+ columns = ", ".join(_MEMBER_COLUMNS)
211
+ placeholders = ", ".join(f":{c}" for c in _MEMBER_COLUMNS)
212
+ DatabaseManagerV2.execute_commit(
213
+ f"INSERT INTO cluster_members ({columns}) VALUES ({placeholders})", values)
214
+
215
+
216
+ def _member_from_row(row: Dict[str, Any]) -> Dict[str, Any]:
217
+ member = dict(row)
218
+ for column in ("plugins_running", "plugins_pending"):
219
+ try:
220
+ member[column] = json.loads(member.get(column) or "[]")
221
+ except (TypeError, ValueError):
222
+ member[column] = []
223
+ member["can_restart"] = bool(member.get("can_restart"))
224
+ return member
225
+
226
+
227
+ def read_members() -> List[Dict[str, Any]]:
228
+ from keepup_db import DatabaseManagerV2
229
+ rows = DatabaseManagerV2.execute(
230
+ "SELECT * FROM cluster_members ORDER BY instance_name, instance_id", {})
231
+ return [_member_from_row(row) for row in rows]
232
+
233
+
234
+ def read_member(instance_id: str) -> Optional[Dict[str, Any]]:
235
+ from keepup_db import DatabaseManagerV2
236
+ row = DatabaseManagerV2.execute_one(
237
+ "SELECT * FROM cluster_members WHERE instance_id = :id", {"id": instance_id})
238
+ return _member_from_row(row) if row else None
239
+
240
+
241
+ def write_state(instance_id: str, state: str) -> None:
242
+ from keepup_db import DatabaseManagerV2
243
+ DatabaseManagerV2.execute_commit(
244
+ "UPDATE cluster_members SET state = :state WHERE instance_id = :id",
245
+ {"state": state, "id": instance_id})
246
+
247
+
248
+ def record_command(instance_id: str, action: str, forced: bool, requested_by: Optional[int],
249
+ requested_by_name: Optional[str], now: datetime) -> Optional[int]:
250
+ from keepup_db import DatabaseManagerV2
251
+ row = DatabaseManagerV2.execute_commit_returning(
252
+ "INSERT INTO cluster_commands (instance_id, action, forced, status, requested_by, "
253
+ "requested_by_name, requested_at) VALUES (:instance_id, :action, :forced, :status, "
254
+ ":requested_by, :requested_by_name, :now)",
255
+ {"instance_id": instance_id, "action": action, "forced": bool(forced),
256
+ "status": STATUS_PENDING, "requested_by": requested_by,
257
+ "requested_by_name": requested_by_name, "now": now})
258
+ return row.get("id") if row else None
259
+
260
+
261
+ def open_command_for(instance_id: str) -> Optional[Dict[str, Any]]:
262
+ from keepup_db import DatabaseManagerV2
263
+ return DatabaseManagerV2.execute_one(
264
+ "SELECT * FROM cluster_commands WHERE instance_id = :id "
265
+ "AND status IN (:pending, :executing) ORDER BY id LIMIT 1",
266
+ {"id": instance_id, "pending": STATUS_PENDING, "executing": STATUS_EXECUTING})
267
+
268
+
269
+ def pending_commands_for(instance_id: str) -> List[Dict[str, Any]]:
270
+ from keepup_db import DatabaseManagerV2
271
+ return DatabaseManagerV2.execute(
272
+ "SELECT * FROM cluster_commands WHERE instance_id = :id AND status = :pending ORDER BY id",
273
+ {"id": instance_id, "pending": STATUS_PENDING})
274
+
275
+
276
+ def claim_command(command_id: int, now: datetime) -> bool:
277
+ """Take a command for execution; False when somebody already has.
278
+
279
+ A conditional UPDATE rather than SELECT ... FOR UPDATE SKIP LOCKED, which
280
+ is PostgreSQL-only -- and SQLite is the development mode.
281
+ """
282
+ from keepup_db import DatabaseManagerV2
283
+ claimed = DatabaseManagerV2.execute_commit(
284
+ "UPDATE cluster_commands SET status = :executing, claimed_at = :now "
285
+ "WHERE id = :id AND status = :pending",
286
+ {"executing": STATUS_EXECUTING, "now": now, "id": command_id, "pending": STATUS_PENDING})
287
+ return claimed == 1
288
+
289
+
290
+ def finish_command(command_id: int, status: str, result: str, now: datetime) -> None:
291
+ from keepup_db import DatabaseManagerV2
292
+ DatabaseManagerV2.execute_commit(
293
+ "UPDATE cluster_commands SET status = :status, result = :result, finished_at = :now "
294
+ "WHERE id = :id",
295
+ {"status": status, "result": result, "now": now, "id": command_id})
296
+
297
+
298
+ def executing_restarts_for(instance_id: str) -> List[Dict[str, Any]]:
299
+ from keepup_db import DatabaseManagerV2
300
+ return DatabaseManagerV2.execute(
301
+ "SELECT * FROM cluster_commands WHERE instance_id = :id AND action = :restart "
302
+ "AND status = :executing ORDER BY id",
303
+ {"id": instance_id, "restart": ACTION_RESTART, "executing": STATUS_EXECUTING})
304
+
305
+
306
+ def expire_commands(now: datetime) -> int:
307
+ """Close commands that will never finish, so they stop blocking new ones."""
308
+ from keepup_db import DatabaseManagerV2
309
+ cutoff = now - COMMAND_TTL
310
+ expired = DatabaseManagerV2.execute_commit(
311
+ "UPDATE cluster_commands SET status = :expired, finished_at = :now, "
312
+ "result = :not_picked WHERE status = :pending AND requested_at < :cutoff",
313
+ {"expired": STATUS_EXPIRED, "now": now, "cutoff": cutoff, "pending": STATUS_PENDING,
314
+ "not_picked": "The replica did not pick the command up in time."})
315
+ expired += DatabaseManagerV2.execute_commit(
316
+ "UPDATE cluster_commands SET status = :expired, finished_at = :now, "
317
+ "result = :not_back WHERE status = :executing AND claimed_at < :cutoff",
318
+ {"expired": STATUS_EXPIRED, "now": now, "cutoff": cutoff, "executing": STATUS_EXECUTING,
319
+ "not_back": "The replica did not report the command finished in time."})
320
+ return expired
321
+
322
+
323
+ def prune_members(now: datetime) -> int:
324
+ from keepup_db import DatabaseManagerV2
325
+ return DatabaseManagerV2.execute_commit(
326
+ "DELETE FROM cluster_members WHERE last_seen < :cutoff",
327
+ {"cutoff": now - MEMBER_RETENTION})
328
+
329
+
330
+ def recent_commands(limit: int = RECENT_COMMANDS) -> List[Dict[str, Any]]:
331
+ from keepup_db import DatabaseManagerV2
332
+ return DatabaseManagerV2.execute(
333
+ "SELECT * FROM cluster_commands ORDER BY id DESC LIMIT :limit", {"limit": int(limit)})
334
+
335
+ # ================================ controller ===============================
336
+
337
+ @dataclass
338
+ class PluginPicture:
339
+ """What runs in this replica, and what its next start would change."""
340
+ running: List[str]
341
+ pending: List[str]
342
+
343
+
344
+ async def _no_plugins() -> PluginPicture:
345
+ return PluginPicture([], [])
346
+
347
+
348
+ def _default_scheduler():
349
+ """The scheduler the tasks capability runs, or None when it is not installed.
350
+
351
+ The cluster only wants it (``wants scheduler>=1``): a deployment that runs no
352
+ background work still has replicas, and asking for a scheduler nobody
353
+ provides must not stop the start (keepup-109).
354
+ """
355
+ try:
356
+ from keepup_tasks.scheduler import get_scheduler
357
+ except ImportError:
358
+ return None
359
+ return get_scheduler()
360
+
361
+
362
+ class ReplicaController:
363
+ """This replica's side of the cluster: its state, heartbeat and commands.
364
+
365
+ The heartbeat runs as its own asyncio task and not as a scheduler job: a
366
+ stopped replica pauses the scheduler, and a heartbeat inside it would go
367
+ silent and never hear ``start``.
368
+ """
369
+
370
+ def __init__(self, plugins: Callable[[], Awaitable[PluginPicture]] = _no_plugins,
371
+ build: Optional[str] = None,
372
+ scheduler: Callable[[], Any] = _default_scheduler,
373
+ clock: Callable[[], datetime] = utcnow,
374
+ instance_id: Optional[str] = None,
375
+ blocker: Optional[str] = None,
376
+ kill: Callable[[int, int], None] = os.kill,
377
+ execv: Callable[[str, List[str]], None] = os.execv):
378
+ self.instance_id = instance_id or get_instance_id()
379
+ self.instance_name = get_instance_name()
380
+ self.state = STATE_SERVING
381
+ self.started_at = clock()
382
+ self.build = build
383
+ self.blocker = restart_blocker() if blocker is None else (blocker or None)
384
+ self._plugins = plugins
385
+ self._scheduler = scheduler
386
+ self._clock = clock
387
+ self._kill = kill
388
+ self._execv = execv
389
+ self._lock = asyncio.Lock()
390
+ self._task: Optional[asyncio.Task] = None
391
+ self.restart_requested = False
392
+
393
+ # ------------------------------------------------------------ lifecycle
394
+
395
+ def restore(self) -> None:
396
+ """Pick up where this replica was left: a stopped one stays stopped.
397
+
398
+ Otherwise a restart -- ours, the container's, the host's -- would
399
+ quietly put back into service a replica an administrator took out.
400
+ """
401
+ try:
402
+ previous = read_member(self.instance_id)
403
+ except Exception as error:
404
+ logger.warning(f"Cluster: could not read this replica's previous state: {error}")
405
+ return
406
+ if previous and previous.get("state") == STATE_STOPPED:
407
+ logger.warning("Cluster: this replica was stopped by an administrator and stays stopped")
408
+ self.stop()
409
+ self._close_restarts()
410
+
411
+ def _close_restarts(self) -> None:
412
+ """A restart command is finished by the process that comes back from it."""
413
+ now = self._clock()
414
+ for command in executing_restarts_for(self.instance_id):
415
+ finish_command(command["id"], STATUS_DONE,
416
+ f"Restarted; the replica is back since {self.started_at.isoformat()}Z.",
417
+ now)
418
+
419
+ async def run_forever(self) -> None:
420
+ while True:
421
+ try:
422
+ await self.tick()
423
+ except asyncio.CancelledError:
424
+ raise
425
+ except Exception as error:
426
+ # The database may be briefly away; the next heartbeat retries.
427
+ logger.warning(f"Cluster heartbeat failed: {error}")
428
+ await asyncio.sleep(HEARTBEAT_SECONDS)
429
+
430
+ def launch(self) -> None:
431
+ try:
432
+ self.restore()
433
+ except Exception as error:
434
+ logger.warning(f"Cluster: could not restore this replica's state: {error}")
435
+ self._task = asyncio.create_task(self.run_forever())
436
+
437
+ async def shutdown(self) -> None:
438
+ if self._task is not None:
439
+ self._task.cancel()
440
+ try:
441
+ await self._task
442
+ except asyncio.CancelledError:
443
+ pass
444
+ self._task = None
445
+
446
+ def nudge(self) -> None:
447
+ """Handle a command addressed to this very replica now, not at the next beat."""
448
+ asyncio.create_task(self._tick_quietly())
449
+
450
+ async def _tick_quietly(self) -> None:
451
+ try:
452
+ await self.tick()
453
+ except Exception as error:
454
+ logger.warning(f"Cluster: immediate command pass failed: {error}")
455
+
456
+ # ------------------------------------------------------------ one pass
457
+
458
+ async def tick(self) -> None:
459
+ async with self._lock:
460
+ picture = await self._plugins()
461
+ now = self._clock()
462
+ # The registry is the database, and this runs on every replica every
463
+ # few seconds: the queries go to a worker thread, while a claimed
464
+ # command is carried out here, on the loop, since it acts on the
465
+ # scheduler and the server (keepup-44).
466
+ pending = await asyncio.to_thread(
467
+ self._publish_and_read, self.as_member(picture, now), now)
468
+ for command in pending:
469
+ if await asyncio.to_thread(claim_command, command["id"], now):
470
+ self.execute(command)
471
+ await asyncio.to_thread(prune_members, now)
472
+
473
+ def _publish_and_read(self, member: Dict[str, Any], now: datetime) -> List[Dict[str, Any]]:
474
+ """This replica's row written, expired commands closed, its own read."""
475
+ publish_member(member)
476
+ expire_commands(now)
477
+ return pending_commands_for(self.instance_id)
478
+
479
+ def as_member(self, picture: PluginPicture, now: datetime) -> Dict[str, Any]:
480
+ return {
481
+ "instance_id": self.instance_id,
482
+ "instance_name": self.instance_name,
483
+ "host": socket.gethostname(),
484
+ "pid": os.getpid(),
485
+ "build": self.build,
486
+ "started_at": self.started_at,
487
+ "last_seen": now,
488
+ "state": self.state,
489
+ "can_restart": self.blocker is None,
490
+ "restart_blocker": self.blocker,
491
+ "plugins_running": picture.running,
492
+ "plugins_pending": picture.pending,
493
+ }
494
+
495
+ def execute(self, command: Dict[str, Any]) -> None:
496
+ action = command.get("action")
497
+ now = self._clock()
498
+ if action == ACTION_STOP:
499
+ if self.state == STATE_STOPPED:
500
+ finish_command(command["id"], STATUS_DONE, "Already stopped.", now)
501
+ return
502
+ self.stop()
503
+ finish_command(command["id"], STATUS_DONE, "Stopped: the API answers 503, the scheduler is paused.", now)
504
+ elif action == ACTION_START:
505
+ if self.state == STATE_SERVING:
506
+ finish_command(command["id"], STATUS_DONE, "Already serving.", now)
507
+ return
508
+ self.start()
509
+ finish_command(command["id"], STATUS_DONE, "Serving again.", now)
510
+ elif action == ACTION_RESTART:
511
+ if self.blocker:
512
+ finish_command(command["id"], STATUS_FAILED, self.blocker, now)
513
+ return
514
+ # Left executing: the process that comes back closes it.
515
+ self.request_restart()
516
+ else:
517
+ finish_command(command["id"], STATUS_FAILED, f"Unknown action {action!r}.", now)
518
+
519
+ # ------------------------------------------------------------ actions
520
+
521
+ def stop(self) -> None:
522
+ self.state = STATE_STOPPED
523
+ write_state(self.instance_id, STATE_STOPPED)
524
+ scheduler = self._scheduler()
525
+ if scheduler is not None and getattr(scheduler, "running", False):
526
+ scheduler.pause()
527
+ logger.warning("Cluster: this replica is stopped -- the API answers 503, the scheduler is paused")
528
+
529
+ def start(self) -> None:
530
+ self.state = STATE_SERVING
531
+ write_state(self.instance_id, STATE_SERVING)
532
+ scheduler = self._scheduler()
533
+ if scheduler is not None and getattr(scheduler, "running", False):
534
+ scheduler.resume()
535
+ logger.warning("Cluster: this replica serves again")
536
+
537
+ def request_restart(self) -> None:
538
+ """Shut down the normal way, then run the same command line again.
539
+
540
+ SIGTERM makes uvicorn go through the application's shutdown; the exec
541
+ happens once the interpreter is on its way out, from atexit, so that
542
+ nothing of the shutdown is skipped.
543
+ """
544
+ if self.restart_requested:
545
+ return
546
+ self.restart_requested = True
547
+ atexit.register(self.exec_again)
548
+ logger.warning("Cluster: restarting this replica on an administrator's command")
549
+ self._kill(os.getpid(), signal.SIGTERM)
550
+
551
+ def exec_again(self) -> None:
552
+ argv = list(sys.orig_argv)
553
+ try:
554
+ self._execv(sys.executable, argv)
555
+ except OSError as error:
556
+ # The process simply ends; a supervisor, if any, takes it from here.
557
+ logger.error(f"Cluster: could not exec {argv!r} again: {error}")
558
+
559
+
560
+ #: The controller of this process, once the application has started one.
561
+ _controller: Optional[ReplicaController] = None
562
+
563
+
564
+ def set_controller(controller: Optional[ReplicaController]) -> None:
565
+ global _controller
566
+ _controller = controller
567
+
568
+
569
+ def get_controller() -> Optional[ReplicaController]:
570
+ return _controller
571
+
572
+
573
+ def current_state() -> str:
574
+ return _controller.state if _controller is not None else STATE_SERVING
575
+
576
+ # ============================ gate and routes ==============================
577
+
578
+ class StoppedReplicaGate:
579
+ """Pure ASGI middleware: what a stopped replica refuses.
580
+
581
+ A gate rather than removing routes, which FastAPI cannot do on a running
582
+ application -- and ``start`` has to put everything back. Installed inside
583
+ the API version middleware, so it sees the registered path without /v1.
584
+ """
585
+
586
+ def __init__(self, app):
587
+ self.app = app
588
+
589
+ async def __call__(self, scope, receive, send):
590
+ if (scope.get("type") in ("http", "websocket")
591
+ and current_state() == STATE_STOPPED
592
+ and not allowed_while_stopped(scope.get("path", ""))):
593
+ if scope["type"] == "http":
594
+ response = JSONResponse({"detail": STOPPED_DETAIL}, status_code=503,
595
+ headers={"Retry-After": str(HEARTBEAT_SECONDS)})
596
+ await response(scope, receive, send)
597
+ else:
598
+ await send({"type": "websocket.close", "code": WS_CLOSE_TRY_AGAIN})
599
+ return
600
+ await self.app(scope, receive, send)
601
+
602
+
603
+ def _member_view(member: Dict[str, Any], now: datetime,
604
+ last_command: Optional[Dict[str, Any]]) -> Dict[str, Any]:
605
+ alive = is_alive(member.get("last_seen"), now)
606
+ view = dict(member)
607
+ view["alive"] = alive
608
+ view["display_state"] = member.get("state") if alive else STATE_GONE
609
+ view["last_command"] = last_command
610
+ return view
611
+
612
+
613
+ async def cluster_overview(now: Optional[datetime] = None) -> Dict[str, Any]:
614
+ """The registry as the panel shows it."""
615
+ now = now or utcnow()
616
+ members, commands = await asyncio.to_thread(lambda: (read_members(), recent_commands()))
617
+ latest: Dict[str, Dict[str, Any]] = {}
618
+ for command in commands:
619
+ latest.setdefault(command["instance_id"], command)
620
+ controller = get_controller()
621
+ return {
622
+ "answered_by": controller.instance_id if controller else get_instance_id(),
623
+ "heartbeat_seconds": HEARTBEAT_SECONDS,
624
+ "members": [_member_view(m, now, latest.get(m["instance_id"])) for m in members],
625
+ "commands": commands,
626
+ }
627
+
628
+
629
+ async def give_command(instance_id: str, request: Dict[str, Any], admin: Dict[str, Any],
630
+ now: Optional[datetime] = None) -> Dict[str, Any]:
631
+ """Address a command to one replica; it collects the command itself."""
632
+ now = now or utcnow()
633
+ request = request or {}
634
+ action = request.get("action")
635
+ force = request.get("force") is True
636
+ members = await asyncio.to_thread(read_members)
637
+ target = next((m for m in members if m["instance_id"] == instance_id), None)
638
+ open_command = await asyncio.to_thread(open_command_for, instance_id) if target else None
639
+ try:
640
+ check_command(action, target, members, open_command, force, now)
641
+ except CommandRefused as refusal:
642
+ raise HTTPException(status_code=refusal.status_code, detail=refusal.reason)
643
+
644
+ command_id = await asyncio.to_thread(record_command, instance_id, action, force,
645
+ admin.get("id"), admin.get("username"), now)
646
+ logger.warning(f"Cluster: {admin.get('username')} ordered {action} of {instance_id}"
647
+ f"{' (forced)' if force else ''}")
648
+ controller = get_controller()
649
+ if controller is not None and controller.instance_id == instance_id:
650
+ controller.nudge()
651
+ return {"success": True, "command": {"id": command_id, "instance_id": instance_id,
652
+ "action": action, "forced": force,
653
+ "status": STATUS_PENDING}}
654
+
655
+
656
+ def register_cluster_routes(app) -> None:
657
+ @app.get("/api/admin/cluster")
658
+ async def cluster_overview_endpoint(admin: dict = Depends(get_current_admin)):
659
+ """The replicas of this deployment, their plugins and commands (administrators only)."""
660
+ return await cluster_overview()
661
+
662
+ @app.post("/api/admin/cluster/{instance_id}/commands")
663
+ async def give_command_endpoint(instance_id: str, request: Dict[str, Any],
664
+ admin: dict = Depends(get_current_admin)):
665
+ """Stop, start or restart one replica (administrators only)."""
666
+ return await give_command(instance_id, request, admin)
@@ -0,0 +1,166 @@
1
+ """The cluster as a capability: the replicas, their state and their commands (keepup-117).
2
+
3
+ `cluster.py` lived in the kernel, so every deployment -- one replica or ten --
4
+ carried the registry, the table of commands and the panel that shows them, and
5
+ the kernel's lifespan built and launched the controller itself.
6
+
7
+ The capability owns all of that now:
8
+
9
+ * it starts this replica's controller when the application comes up and stops it
10
+ with the application;
11
+ * it declares the cluster's two tables, its panel section and the gate that
12
+ refuses the API of a replica an administrator stopped -- the gate as a
13
+ `middleware` contribution, because in a runtime without HTTP there is nowhere
14
+ to put it (the application still installs it until `keepup-http` mounts what a
15
+ plugin declares, keepup-121);
16
+ * it publishes the `cluster` service, which is how the panel and the plugin
17
+ report reach the registry.
18
+
19
+ `keepup.instance` and `keepup.notification_bus` stay kernel infrastructure: who
20
+ this process is, and how replicas reach each other, are facts about the process.
21
+ Delivery *between* replicas is a service -- `notify_transport` -- that the
22
+ database driver provides, and this capability *requires* it: a registry nobody
23
+ can hear is worse than a start that says which distribution is missing
24
+ (keepup-117). `keepup-postgres` carries it over LISTEN/NOTIFY and
25
+ `keepup-sqlite` inside the process -- one replica has nobody else to hear.
26
+ """
27
+
28
+ import logging
29
+ from typing import Any, Callable, Dict, List
30
+
31
+ from keepup.kernel.descriptor import KIND_OPTIONAL, PluginDescriptor
32
+ from keepup.plugins.base import BasePlugin
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ __all__ = ["ClusterPlugin", "SERVICE_CLUSTER"]
37
+
38
+ #: What the panel and the plugin report ask for to see the replicas.
39
+ SERVICE_CLUSTER = "cluster"
40
+
41
+ #: The panel's section: the replicas, their plugins and their state.
42
+ SECTION = {
43
+ "id": "cluster",
44
+ "name": "Cluster",
45
+ "description": ("Application replicas, their plugins and state; stop, start "
46
+ "and restart a replica from the panel"),
47
+ "js": "/keepup-static/modules/js/cluster.js",
48
+ "css": "/keepup-static/modules/css/cluster.css",
49
+ "initFunction": "initCluster",
50
+ "icon": "server",
51
+ "version": "1.0.0",
52
+ }
53
+
54
+
55
+ class ClusterPlugin(BasePlugin):
56
+ """This replica's side of the cluster."""
57
+
58
+ descriptor = PluginDescriptor(
59
+ id="cluster",
60
+ name="Cluster",
61
+ version="0.5.0",
62
+ distribution="keepup-cluster",
63
+ kind=KIND_OPTIONAL,
64
+ priority=85,
65
+ provides=(f"{SERVICE_CLUSTER}>=1",),
66
+ requires=("datasource>=1", "notify_transport>=1"),
67
+ wants=("scheduler>=1",),
68
+ contributions=("routes", "sections", "tables", "middleware"),
69
+ )
70
+
71
+ def __init__(self, config=None):
72
+ super().__init__("cluster", "Cluster", config)
73
+ self.controller = None
74
+
75
+ def register(self, services):
76
+ """Offer the registry to whoever asks for it."""
77
+ from keepup_cluster import cluster as registry
78
+
79
+ services.provide(SERVICE_CLUSTER, registry, version=1, plugin_id="cluster")
80
+
81
+ async def initialize(self):
82
+ """Build this replica's controller and launch its heartbeat."""
83
+ from keepup_cluster import cluster as registry
84
+
85
+ self.controller = registry.ReplicaController(plugins=self._plugin_picture(),
86
+ build=build_label())
87
+ registry.set_controller(self.controller)
88
+ self.controller.launch()
89
+ logger.info("Cluster: replica %s is %s", self.controller.instance_id,
90
+ self.controller.state)
91
+ return True
92
+
93
+ async def cleanup(self):
94
+ """Stop the heartbeat and forget the controller with the application."""
95
+ from keepup_cluster import cluster as registry
96
+
97
+ if self.controller is not None:
98
+ await self.controller.shutdown()
99
+ registry.set_controller(None)
100
+ self.controller = None
101
+
102
+ def _plugin_picture(self) -> Callable[[], Any]:
103
+ """A callable the controller asks for what runs, from the plugin report."""
104
+ from keepup.plugins.registry import get_plugins_status
105
+ from keepup_cluster import cluster as registry
106
+
107
+ manager = self.plugin_manager
108
+
109
+ async def picture():
110
+ if manager is None:
111
+ return registry.PluginPicture([], [])
112
+ try:
113
+ rows = (await get_plugins_status(manager)).get("plugins", [])
114
+ except Exception as error: # noqa: BLE001 - a report that cannot be read
115
+ logger.warning("Cluster: plugin report unavailable: %s", error)
116
+ rows = []
117
+ return registry.PluginPicture(
118
+ running=[row["id"] for row in rows if row.get("initialized")],
119
+ pending=[row["id"] for row in rows if row.get("pending_restart")])
120
+
121
+ return picture
122
+
123
+ def get_declared_tables(self) -> List[Any]:
124
+ """The registry's two tables, declared by the capability that owns them."""
125
+ from keepup_cluster.tables import CLUSTER_COMMANDS, CLUSTER_MEMBERS
126
+
127
+ return [CLUSTER_MEMBERS, CLUSTER_COMMANDS]
128
+
129
+ def get_panel_sections(self) -> List[Dict[str, Any]]:
130
+ """The section, declared as data; the file is the seed it is imported from."""
131
+ return [dict(SECTION)]
132
+
133
+ def get_middleware(self) -> List[Any]:
134
+ """The gate of a stopped replica, as the transport's contribution."""
135
+ from keepup.kernel.contributions import MiddlewareSpec
136
+ from keepup_cluster.cluster import StoppedReplicaGate
137
+
138
+ return [MiddlewareSpec(order=10, factory=StoppedReplicaGate,
139
+ name="Stopped replica gate")]
140
+
141
+ def get_api_routes(self) -> List[Dict[str, Any]]:
142
+ """No routes yet: the application registers them through the bridge."""
143
+ return []
144
+
145
+ def get_handlers(self) -> Dict[str, Any]:
146
+ from keepup_cluster import cluster as registry
147
+
148
+ return {"registry": registry, "controller": self.controller}
149
+
150
+
151
+ def build_label() -> Any:
152
+ """The build this replica runs, from the version file build_image.sh writes."""
153
+ import json
154
+ import os
155
+
156
+ from keepup_http import web as web
157
+
158
+ try:
159
+ with open(os.path.join(web.STATIC_DIR, web.VERSION_FILE), encoding="utf-8") as handle:
160
+ version = json.load(handle)
161
+ except (OSError, ValueError):
162
+ return None
163
+ label = str(version.get("version") or "")
164
+ if version.get("build_number"):
165
+ label += f" #{version['build_number']}"
166
+ return label or None
@@ -0,0 +1,46 @@
1
+ """The cluster's tables, declared where the capability lives.
2
+
3
+ Task keepup-117. `cluster_members` and `cluster_commands` were declared by the
4
+ kernel's `schema.py`, so every deployment created them whether or not it ran
5
+ more than one replica. `keepup_db.schema`, which re-exported them for one release,
6
+ is gone (keepup-158), which
7
+ is what a path written before the split still imports.
8
+ """
9
+
10
+ from sqlalchemy import Boolean, Column, DateTime, Index, Integer, Text
11
+
12
+ from keepup_db import tables
13
+
14
+ __all__ = ["CLUSTER_COMMANDS", "CLUSTER_MEMBERS"]
15
+
16
+ CLUSTER_MEMBERS = tables.table(
17
+ "cluster_members",
18
+ Column("instance_id", Text, primary_key=True, nullable=True),
19
+ Column("instance_name", Text),
20
+ Column("host", Text),
21
+ Column("pid", Integer),
22
+ Column("build", Text),
23
+ Column("started_at", DateTime),
24
+ Column("last_seen", DateTime),
25
+ Column("state", Text, nullable=False, server_default="serving"),
26
+ Column("can_restart", Boolean),
27
+ Column("restart_blocker", Text),
28
+ Column("plugins_running", Text),
29
+ Column("plugins_pending", Text),
30
+ )
31
+
32
+ CLUSTER_COMMANDS = tables.table(
33
+ "cluster_commands",
34
+ tables.auto_id(),
35
+ Column("instance_id", Text, nullable=False),
36
+ Column("action", Text, nullable=False),
37
+ Column("forced", Boolean),
38
+ Column("status", Text, nullable=False, server_default="pending"),
39
+ Column("requested_by", Integer),
40
+ Column("requested_by_name", Text),
41
+ Column("requested_at", DateTime),
42
+ Column("claimed_at", DateTime),
43
+ Column("finished_at", DateTime),
44
+ Column("result", Text),
45
+ Index("idx_cluster_commands_target", "instance_id", "status"),
46
+ )
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepup-cluster
3
+ Version: 0.5.0
4
+ Summary: The replicas of this deployment, their state and the commands addressed to them.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: keepup-admin>=0.4.0
8
+ Requires-Dist: keepup-db>=0.4.0
9
+ Requires-Dist: keepup-auth>=0.4.0
10
+ Requires-Dist: fastapi<1,>=0.128
11
+
12
+ # keepup-cluster
13
+
14
+ The replicas of this deployment, their state and the commands addressed to them.
15
+
16
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
17
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
18
+ this distribution carries one part of it. It is installed on its own
19
+ (`pip install keepup-cluster`) or together with the base (`pip install "keepup-admin[panel]"`),
20
+ and it declares itself to the kernel through its
21
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ keepup_cluster/__init__.py
4
+ keepup_cluster/cluster.py
5
+ keepup_cluster/plugin.py
6
+ keepup_cluster/tables.py
7
+ keepup_cluster.egg-info/PKG-INFO
8
+ keepup_cluster.egg-info/SOURCES.txt
9
+ keepup_cluster.egg-info/dependency_links.txt
10
+ keepup_cluster.egg-info/entry_points.txt
11
+ keepup_cluster.egg-info/requires.txt
12
+ keepup_cluster.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [keepup.plugins]
2
+ cluster = keepup_cluster:ClusterPlugin
@@ -0,0 +1,4 @@
1
+ keepup-admin>=0.4.0
2
+ keepup-db>=0.4.0
3
+ keepup-auth>=0.4.0
4
+ fastapi<1,>=0.128
@@ -0,0 +1 @@
1
+ keepup_cluster
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "keepup-cluster"
7
+ version = "0.5.0"
8
+ description = "The replicas of this deployment, their state and the commands addressed to them."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "keepup-admin>=0.4.0",
13
+ "keepup-db>=0.4.0",
14
+ "keepup-auth>=0.4.0",
15
+ "fastapi>=0.128,<1",
16
+ ]
17
+
18
+ [project.entry-points."keepup.plugins"]
19
+ cluster = "keepup_cluster:ClusterPlugin"
20
+
21
+ [tool.setuptools]
22
+ packages = ["keepup_cluster"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+