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.
- keepup/THIRD-PARTY.md +44 -0
- keepup/__init__.py +41 -0
- keepup/api_versions.py +100 -0
- keepup/audit.py +499 -0
- keepup/auth/__init__.py +7 -0
- keepup/auth/config.py +128 -0
- keepup/auth/dependencies.py +485 -0
- keepup/auth/dto/__init__.py +0 -0
- keepup/auth/dto/token.py +31 -0
- keepup/auth/factory.py +28 -0
- keepup/auth/login_throttle.py +151 -0
- keepup/auth/oidc.py +245 -0
- keepup/auth/oidc_policy.py +111 -0
- keepup/auth/oidc_routes.py +288 -0
- keepup/auth/panel_session.py +220 -0
- keepup/auth/permissions.py +59 -0
- keepup/auth/providers/__init__.py +0 -0
- keepup/auth/providers/base.py +168 -0
- keepup/auth/providers/local.py +180 -0
- keepup/auth/routes.py +660 -0
- keepup/auth/seed_accounts.py +322 -0
- keepup/auth/session_lifetime.py +104 -0
- keepup/auth/signing_key.py +138 -0
- keepup/auth/websocket.py +86 -0
- keepup/cluster.py +634 -0
- keepup/db.py +663 -0
- keepup/events.py +764 -0
- keepup/factory.py +484 -0
- keepup/instance.py +46 -0
- keepup/integrations.py +260 -0
- keepup/locks.py +412 -0
- keepup/logging_setup.py +690 -0
- keepup/metrics.py +818 -0
- keepup/metrics_retention.py +376 -0
- keepup/modules.py +572 -0
- keepup/notification_bus.py +355 -0
- keepup/plugins/__init__.py +9 -0
- keepup/plugins/admin.py +246 -0
- keepup/plugins/base.py +234 -0
- keepup/plugins/enablement.py +184 -0
- keepup/plugins/registry.py +171 -0
- keepup/plugins/route_mask.py +338 -0
- keepup/plugins/routes.py +376 -0
- keepup/roles.py +17 -0
- keepup/scheduler.py +93 -0
- keepup/schema.py +295 -0
- keepup/sections.json +104 -0
- keepup/settings.py +184 -0
- keepup/static/css/aos.css +1 -0
- keepup/static/css/main_nebula.css +232 -0
- keepup/static/css/main_new.css +852 -0
- keepup/static/css/tailwind.css +1 -0
- keepup/static/index_nebula.html +293 -0
- keepup/static/index_new.html +286 -0
- keepup/static/js/aos.js +1 -0
- keepup/static/js/feather-icons.js +13 -0
- keepup/static/js/main_new.js +1861 -0
- keepup/static/js/tailwind.js +83 -0
- keepup/static/modules/css/background_tasks.css +233 -0
- keepup/static/modules/css/cluster.css +16 -0
- keepup/static/modules/css/event_manager.css +386 -0
- keepup/static/modules/css/integration_logs.css +33 -0
- keepup/static/modules/css/metrics.css +115 -0
- keepup/static/modules/css/modules.css +189 -0
- keepup/static/modules/css/themes.css +563 -0
- keepup/static/modules/css/users.css +278 -0
- keepup/static/modules/js/background_tasks.js +657 -0
- keepup/static/modules/js/chart.js +14 -0
- keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup/static/modules/js/cluster.js +363 -0
- keepup/static/modules/js/event_manager.js +979 -0
- keepup/static/modules/js/integration_logs.js +767 -0
- keepup/static/modules/js/metrics.js +908 -0
- keepup/static/modules/js/modules.js +1086 -0
- keepup/static/modules/js/themes.js +653 -0
- keepup/static/modules/js/users.js +784 -0
- keepup/tables.py +302 -0
- keepup/themes.py +496 -0
- keepup/web.py +182 -0
- keepup_admin-0.1.0.dist-info/METADATA +117 -0
- keepup_admin-0.1.0.dist-info/RECORD +86 -0
- keepup_admin-0.1.0.dist-info/WHEEL +5 -0
- keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
- keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
- keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
- 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)
|