@agentsdock/server 1.0.7-beta.5
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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,1354 @@
|
|
|
1
|
+
"""Managed in-process Team Hub boundary for one designated AgentsServer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import ipaddress
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from fastapi import Request
|
|
16
|
+
from starlette.responses import JSONResponse
|
|
17
|
+
|
|
18
|
+
from agentsdock_team_hub.secure_peer import SecurePeerError
|
|
19
|
+
from agentsdock_team_hub.service import (
|
|
20
|
+
ManagedTransportIdentity,
|
|
21
|
+
classify_managed_transport,
|
|
22
|
+
create_app,
|
|
23
|
+
)
|
|
24
|
+
from agentsdock_team_hub.store import HubError, HubStore
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
TEAM_HUB_MOUNT_PATH = "/api/team-hub"
|
|
28
|
+
TEAM_HUB_SERVER_SESSION_MOUNT_PATH = "/api/team-hub-server"
|
|
29
|
+
TEAM_HUB_CAPABILITY_VERSION = 1
|
|
30
|
+
TEAM_HUB_MODE_DISABLED = "disabled"
|
|
31
|
+
TEAM_HUB_MODE_HOST = "host"
|
|
32
|
+
TEAM_HUB_TRANSPORT_LOOPBACK = "loopback"
|
|
33
|
+
TEAM_HUB_TRANSPORT_TAILSCALE_SERVE = "tailscale_serve"
|
|
34
|
+
TEAM_HUB_TRANSPORT_DIRECT_IP = "direct_ip"
|
|
35
|
+
TEAM_HUB_TAILSCALE_SERVE_PORT = 8444
|
|
36
|
+
TEAM_HUB_TAILNET_LABEL_PATTERN = re.compile(
|
|
37
|
+
r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$"
|
|
38
|
+
)
|
|
39
|
+
TEAM_HUB_STARTUP_FAILURE_REASON_MAX_CHARS = 320
|
|
40
|
+
TEAM_HUB_STARTUP_SECRET_RE = re.compile(
|
|
41
|
+
r"(?i)\b(token|secret|password|proof|authorization|bearer)\b"
|
|
42
|
+
r"(?:\s*[:=]\s*|\s+)[^\s,;]+"
|
|
43
|
+
)
|
|
44
|
+
TEAM_HUB_ABSOLUTE_PATH_RE = re.compile(
|
|
45
|
+
r"(?<![A-Za-z0-9])(?:/[A-Za-z0-9_.~+@%=-][^\s,;]*|[A-Za-z]:[\\/][^\s,;]*)"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def concise_team_hub_startup_failure(exc: BaseException) -> str:
|
|
50
|
+
"""Return one bounded operator-safe reason without credentials or paths."""
|
|
51
|
+
|
|
52
|
+
message = " ".join(str(exc).split()).strip()
|
|
53
|
+
if not message:
|
|
54
|
+
message = type(exc).__name__
|
|
55
|
+
message = TEAM_HUB_STARTUP_SECRET_RE.sub(
|
|
56
|
+
lambda match: f"{match.group(1)}=[redacted]",
|
|
57
|
+
message,
|
|
58
|
+
)
|
|
59
|
+
message = TEAM_HUB_ABSOLUTE_PATH_RE.sub("[path]", message)
|
|
60
|
+
if len(message) > TEAM_HUB_STARTUP_FAILURE_REASON_MAX_CHARS:
|
|
61
|
+
message = (
|
|
62
|
+
message[: TEAM_HUB_STARTUP_FAILURE_REASON_MAX_CHARS - 3].rstrip()
|
|
63
|
+
+ "..."
|
|
64
|
+
)
|
|
65
|
+
return message
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _canonical_tailnet_hostname(value: str) -> bool:
|
|
69
|
+
labels = value.split(".")
|
|
70
|
+
return (
|
|
71
|
+
value == value.lower()
|
|
72
|
+
and not value.endswith(".")
|
|
73
|
+
and len(labels) >= 4
|
|
74
|
+
and labels[-2:] == ["ts", "net"]
|
|
75
|
+
and all(
|
|
76
|
+
1 <= len(label) <= 63
|
|
77
|
+
and not label.startswith("xn--")
|
|
78
|
+
and TEAM_HUB_TAILNET_LABEL_PATTERN.fullmatch(label) is not None
|
|
79
|
+
for label in labels
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def configured_team_hub_mode(value: str | None) -> tuple[str, str | None]:
|
|
85
|
+
normalized = str(value or "disabled").strip().lower()
|
|
86
|
+
if normalized in {"", TEAM_HUB_MODE_DISABLED}:
|
|
87
|
+
return TEAM_HUB_MODE_DISABLED, None
|
|
88
|
+
if normalized == TEAM_HUB_MODE_HOST:
|
|
89
|
+
return TEAM_HUB_MODE_HOST, None
|
|
90
|
+
return TEAM_HUB_MODE_DISABLED, "AGENTSDOCK_TEAM_HUB_MODE must be disabled or host"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def configured_team_hub_hosts(
|
|
94
|
+
bind_address: str | None,
|
|
95
|
+
configured: str | None,
|
|
96
|
+
) -> set[str]:
|
|
97
|
+
hosts = {"127.0.0.1", "localhost", "[::1]", "::1"}
|
|
98
|
+
bind = str(bind_address or "").strip().lower()
|
|
99
|
+
if bind and bind not in {"0.0.0.0", "::", "[::]", "localhost"}:
|
|
100
|
+
hosts.add(bind)
|
|
101
|
+
for value in str(configured or "").split(","):
|
|
102
|
+
host = value.strip().lower()
|
|
103
|
+
if host:
|
|
104
|
+
hosts.add(host)
|
|
105
|
+
return hosts
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def configured_team_hub_endpoint(
|
|
109
|
+
mode: str,
|
|
110
|
+
configured_url: str | None,
|
|
111
|
+
configured_transport: str | None = None,
|
|
112
|
+
server_port: int = 7850,
|
|
113
|
+
) -> tuple[str | None, str | None, str | None, str | None]:
|
|
114
|
+
"""Return transport, public Hub URL, public hostname, and config error."""
|
|
115
|
+
|
|
116
|
+
if mode != TEAM_HUB_MODE_HOST:
|
|
117
|
+
return None, None, None, None
|
|
118
|
+
value = str(configured_url or "").strip()
|
|
119
|
+
selected = str(configured_transport or "").strip().lower()
|
|
120
|
+
if not value:
|
|
121
|
+
if selected not in {"", TEAM_HUB_TRANSPORT_LOOPBACK}:
|
|
122
|
+
return (
|
|
123
|
+
None,
|
|
124
|
+
None,
|
|
125
|
+
None,
|
|
126
|
+
"AGENTSDOCK_TEAM_HUB_URL is required for the selected Team Hub transport",
|
|
127
|
+
)
|
|
128
|
+
return TEAM_HUB_TRANSPORT_LOOPBACK, None, None, None
|
|
129
|
+
try:
|
|
130
|
+
parsed = urlsplit(value)
|
|
131
|
+
port = parsed.port
|
|
132
|
+
except ValueError:
|
|
133
|
+
return None, None, None, "AGENTSDOCK_TEAM_HUB_URL is invalid"
|
|
134
|
+
hostname = parsed.hostname
|
|
135
|
+
if selected == TEAM_HUB_TRANSPORT_DIRECT_IP:
|
|
136
|
+
try:
|
|
137
|
+
address = ipaddress.ip_address(str(hostname or ""))
|
|
138
|
+
except ValueError:
|
|
139
|
+
address = None
|
|
140
|
+
canonical_host = str(address) if address is not None else ""
|
|
141
|
+
canonical = (
|
|
142
|
+
f"http://{canonical_host}:{server_port}{TEAM_HUB_MOUNT_PATH}"
|
|
143
|
+
if canonical_host
|
|
144
|
+
else ""
|
|
145
|
+
)
|
|
146
|
+
if (
|
|
147
|
+
parsed.scheme != "http"
|
|
148
|
+
or address is None
|
|
149
|
+
or address.version != 4
|
|
150
|
+
or address.is_loopback
|
|
151
|
+
or not 1 <= int(str(address).split(".", 1)[0]) < 224
|
|
152
|
+
or parsed.username is not None
|
|
153
|
+
or parsed.password is not None
|
|
154
|
+
or port != server_port
|
|
155
|
+
or parsed.path != TEAM_HUB_MOUNT_PATH
|
|
156
|
+
or parsed.query
|
|
157
|
+
or parsed.fragment
|
|
158
|
+
or value != canonical
|
|
159
|
+
):
|
|
160
|
+
return (
|
|
161
|
+
None,
|
|
162
|
+
None,
|
|
163
|
+
None,
|
|
164
|
+
"AGENTSDOCK_TEAM_HUB_URL must be the exact same-origin "
|
|
165
|
+
f"http://<literal-ip>:{server_port}{TEAM_HUB_MOUNT_PATH} URL "
|
|
166
|
+
"for direct_ip transport",
|
|
167
|
+
)
|
|
168
|
+
return TEAM_HUB_TRANSPORT_DIRECT_IP, canonical, canonical_host, None
|
|
169
|
+
|
|
170
|
+
if selected not in {"", TEAM_HUB_TRANSPORT_TAILSCALE_SERVE}:
|
|
171
|
+
return (
|
|
172
|
+
None,
|
|
173
|
+
None,
|
|
174
|
+
None,
|
|
175
|
+
"AGENTSDOCK_TEAM_HUB_TRANSPORT must be loopback, tailscale_serve, or direct_ip",
|
|
176
|
+
)
|
|
177
|
+
canonical = (
|
|
178
|
+
f"https://{hostname}:{TEAM_HUB_TAILSCALE_SERVE_PORT}{TEAM_HUB_MOUNT_PATH}"
|
|
179
|
+
if hostname is not None
|
|
180
|
+
else ""
|
|
181
|
+
)
|
|
182
|
+
if (
|
|
183
|
+
parsed.scheme != "https"
|
|
184
|
+
or hostname is None
|
|
185
|
+
or parsed.username is not None
|
|
186
|
+
or parsed.password is not None
|
|
187
|
+
or port != TEAM_HUB_TAILSCALE_SERVE_PORT
|
|
188
|
+
or parsed.path != TEAM_HUB_MOUNT_PATH
|
|
189
|
+
or parsed.query
|
|
190
|
+
or parsed.fragment
|
|
191
|
+
or not _canonical_tailnet_hostname(hostname)
|
|
192
|
+
or value != canonical
|
|
193
|
+
):
|
|
194
|
+
return (
|
|
195
|
+
None,
|
|
196
|
+
None,
|
|
197
|
+
None,
|
|
198
|
+
"AGENTSDOCK_TEAM_HUB_URL must be the canonical private "
|
|
199
|
+
"https://<host>.ts.net:8444/api/team-hub URL",
|
|
200
|
+
)
|
|
201
|
+
return TEAM_HUB_TRANSPORT_TAILSCALE_SERVE, canonical, hostname, None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def _join_task_despite_caller_cancellation(
|
|
205
|
+
task: asyncio.Task[Any],
|
|
206
|
+
) -> Any:
|
|
207
|
+
"""Join an owned side effect even when the caller keeps cancelling."""
|
|
208
|
+
|
|
209
|
+
while True:
|
|
210
|
+
try:
|
|
211
|
+
return await asyncio.shield(task)
|
|
212
|
+
except asyncio.CancelledError:
|
|
213
|
+
if task.done():
|
|
214
|
+
return task.result()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ManagedTeamHubHost:
|
|
218
|
+
"""An unavailable-first ASGI mount whose credential realm is Hub-only."""
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
*,
|
|
223
|
+
mode: str,
|
|
224
|
+
data_dir: Path,
|
|
225
|
+
server_identity: str,
|
|
226
|
+
server_instance_id: str = "server-instance-local-preview",
|
|
227
|
+
managed_host_display_name: str = "Team Hub host",
|
|
228
|
+
allowed_hosts: set[str],
|
|
229
|
+
transport: str = TEAM_HUB_TRANSPORT_LOOPBACK,
|
|
230
|
+
hub_url: str | None = None,
|
|
231
|
+
routes: dict[str, str | None] | None = None,
|
|
232
|
+
secure_peer_manager: Any | None = None,
|
|
233
|
+
reactivation_hub_id: str | None = None,
|
|
234
|
+
reactivation_operation_id: str | None = None,
|
|
235
|
+
reactivation_snapshot: Path | None = None,
|
|
236
|
+
update_hub_id: str | None = None,
|
|
237
|
+
update_operation_id: str | None = None,
|
|
238
|
+
update_snapshot: Path | None = None,
|
|
239
|
+
config_error: str | None = None,
|
|
240
|
+
logger: logging.Logger | None = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
self.mode = mode
|
|
243
|
+
self.data_dir = Path(data_dir)
|
|
244
|
+
self.server_identity = str(server_identity)
|
|
245
|
+
self.server_instance_id = str(server_instance_id)
|
|
246
|
+
self.managed_host_display_name = str(managed_host_display_name)
|
|
247
|
+
self.allowed_hosts = set(allowed_hosts)
|
|
248
|
+
self.transport = transport
|
|
249
|
+
self.hub_url = hub_url
|
|
250
|
+
configured_routes = dict(routes or {transport: hub_url})
|
|
251
|
+
route_error: str | None = None
|
|
252
|
+
if (
|
|
253
|
+
transport not in {
|
|
254
|
+
TEAM_HUB_TRANSPORT_LOOPBACK,
|
|
255
|
+
TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
|
|
256
|
+
TEAM_HUB_TRANSPORT_DIRECT_IP,
|
|
257
|
+
}
|
|
258
|
+
or configured_routes.get(transport) != hub_url
|
|
259
|
+
or any(
|
|
260
|
+
route not in {
|
|
261
|
+
TEAM_HUB_TRANSPORT_LOOPBACK,
|
|
262
|
+
TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
|
|
263
|
+
TEAM_HUB_TRANSPORT_DIRECT_IP,
|
|
264
|
+
}
|
|
265
|
+
for route in configured_routes
|
|
266
|
+
)
|
|
267
|
+
):
|
|
268
|
+
route_error = "Team Hub routes do not contain the exact primary transport"
|
|
269
|
+
self.routes = {
|
|
270
|
+
route: configured_routes[route]
|
|
271
|
+
for route in (
|
|
272
|
+
transport,
|
|
273
|
+
TEAM_HUB_TRANSPORT_LOOPBACK,
|
|
274
|
+
TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
|
|
275
|
+
TEAM_HUB_TRANSPORT_DIRECT_IP,
|
|
276
|
+
)
|
|
277
|
+
if route in configured_routes
|
|
278
|
+
}
|
|
279
|
+
self.secure_peer_manager = secure_peer_manager
|
|
280
|
+
self.reactivation_hub_id = reactivation_hub_id
|
|
281
|
+
self.reactivation_operation_id = reactivation_operation_id
|
|
282
|
+
self.reactivation_snapshot = reactivation_snapshot
|
|
283
|
+
self.update_hub_id = update_hub_id
|
|
284
|
+
self.update_operation_id = update_operation_id
|
|
285
|
+
self.update_snapshot = update_snapshot
|
|
286
|
+
self.config_error = config_error or route_error
|
|
287
|
+
self.logger = logger or logging.getLogger("agents-server.team-hub")
|
|
288
|
+
self._guard = threading.RLock()
|
|
289
|
+
self._admission = asyncio.Condition()
|
|
290
|
+
self._in_flight = 0
|
|
291
|
+
self._delegate: Any | None = None
|
|
292
|
+
self._store: HubStore | None = None
|
|
293
|
+
self._runtime_lease_fd: int | None = None
|
|
294
|
+
self._accepting = False
|
|
295
|
+
self._startup_failed = False
|
|
296
|
+
self._startup_failure_reason: str | None = None
|
|
297
|
+
self._fenced_finalize_in_progress = False
|
|
298
|
+
self._bootstrap_rate_guard = threading.Lock()
|
|
299
|
+
self._bootstrap_rate_buckets: dict[str, tuple[int, int]] = {}
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def designated_host(self) -> bool:
|
|
303
|
+
return self.mode == TEAM_HUB_MODE_HOST
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def store(self) -> HubStore | None:
|
|
307
|
+
with self._guard:
|
|
308
|
+
return self._store
|
|
309
|
+
|
|
310
|
+
def enable_live_host(
|
|
311
|
+
self,
|
|
312
|
+
*,
|
|
313
|
+
transport: str,
|
|
314
|
+
hub_url: str | None,
|
|
315
|
+
routes: dict[str, str | None],
|
|
316
|
+
allowed_hosts: set[str],
|
|
317
|
+
managed_host_display_name: str,
|
|
318
|
+
reactivation_hub_id: str | None = None,
|
|
319
|
+
reactivation_operation_id: str | None = None,
|
|
320
|
+
reactivation_snapshot: Path | None = None,
|
|
321
|
+
commit_configuration: Callable[[], Any] | None = None,
|
|
322
|
+
) -> dict[str, Any]:
|
|
323
|
+
"""Activate this process as its own managed Hub host without restart.
|
|
324
|
+
|
|
325
|
+
The caller owns durable configuration. This method changes only the
|
|
326
|
+
in-process boundary, and publishes the new mode atomically with a
|
|
327
|
+
successfully initialized delegate. A failed initialization restores
|
|
328
|
+
the exact disabled runtime projection so health never claims that an
|
|
329
|
+
unusable host was enabled.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
if transport not in {
|
|
333
|
+
TEAM_HUB_TRANSPORT_LOOPBACK,
|
|
334
|
+
TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
|
|
335
|
+
TEAM_HUB_TRANSPORT_DIRECT_IP,
|
|
336
|
+
}:
|
|
337
|
+
raise ValueError("Team Hub transport is invalid")
|
|
338
|
+
if routes.get(transport) != hub_url:
|
|
339
|
+
raise ValueError("Team Hub routes do not contain the primary transport")
|
|
340
|
+
reactivation_values = (
|
|
341
|
+
reactivation_hub_id,
|
|
342
|
+
reactivation_operation_id,
|
|
343
|
+
reactivation_snapshot,
|
|
344
|
+
)
|
|
345
|
+
if any(value is not None for value in reactivation_values) and not all(
|
|
346
|
+
value is not None for value in reactivation_values
|
|
347
|
+
):
|
|
348
|
+
raise ValueError("Team Hub reactivation authority is incomplete")
|
|
349
|
+
|
|
350
|
+
with self._guard:
|
|
351
|
+
if self.designated_host:
|
|
352
|
+
self.rename_live_host(managed_host_display_name)
|
|
353
|
+
capability = self.capability()
|
|
354
|
+
if capability.get("available") is not True:
|
|
355
|
+
raise RuntimeError("the designated Team Hub host is unavailable")
|
|
356
|
+
return capability
|
|
357
|
+
if (
|
|
358
|
+
self._delegate is not None
|
|
359
|
+
or self._store is not None
|
|
360
|
+
or self._runtime_lease_fd is not None
|
|
361
|
+
):
|
|
362
|
+
raise RuntimeError("the disabled Team Hub runtime is not clean")
|
|
363
|
+
|
|
364
|
+
previous = {
|
|
365
|
+
"mode": self.mode,
|
|
366
|
+
"transport": self.transport,
|
|
367
|
+
"hub_url": self.hub_url,
|
|
368
|
+
"routes": dict(self.routes),
|
|
369
|
+
"allowed_hosts": set(self.allowed_hosts),
|
|
370
|
+
"config_error": self.config_error,
|
|
371
|
+
"managed_host_display_name": self.managed_host_display_name,
|
|
372
|
+
"reactivation_hub_id": self.reactivation_hub_id,
|
|
373
|
+
"reactivation_operation_id": self.reactivation_operation_id,
|
|
374
|
+
"reactivation_snapshot": self.reactivation_snapshot,
|
|
375
|
+
}
|
|
376
|
+
self.mode = TEAM_HUB_MODE_HOST
|
|
377
|
+
self.transport = transport
|
|
378
|
+
self.hub_url = hub_url
|
|
379
|
+
self.routes = dict(routes)
|
|
380
|
+
self.allowed_hosts = set(allowed_hosts)
|
|
381
|
+
self.managed_host_display_name = str(managed_host_display_name)
|
|
382
|
+
self.reactivation_hub_id = reactivation_hub_id
|
|
383
|
+
self.reactivation_operation_id = reactivation_operation_id
|
|
384
|
+
self.reactivation_snapshot = reactivation_snapshot
|
|
385
|
+
self.config_error = None
|
|
386
|
+
self._startup_failed = False
|
|
387
|
+
self._startup_failure_reason = None
|
|
388
|
+
|
|
389
|
+
self.initialize(hold_admission=True, hold_peer_attachment=True)
|
|
390
|
+
available = self._store is not None
|
|
391
|
+
failure_reason = self._startup_failure_reason
|
|
392
|
+
if not available:
|
|
393
|
+
self.mode = str(previous["mode"])
|
|
394
|
+
self.transport = str(previous["transport"])
|
|
395
|
+
self.hub_url = previous["hub_url"]
|
|
396
|
+
self.routes = dict(previous["routes"])
|
|
397
|
+
self.allowed_hosts = set(previous["allowed_hosts"])
|
|
398
|
+
self.config_error = previous["config_error"]
|
|
399
|
+
self.managed_host_display_name = str(
|
|
400
|
+
previous["managed_host_display_name"]
|
|
401
|
+
)
|
|
402
|
+
self.reactivation_hub_id = previous["reactivation_hub_id"]
|
|
403
|
+
self.reactivation_operation_id = previous[
|
|
404
|
+
"reactivation_operation_id"
|
|
405
|
+
]
|
|
406
|
+
self.reactivation_snapshot = previous["reactivation_snapshot"]
|
|
407
|
+
self._delegate = None
|
|
408
|
+
self._store = None
|
|
409
|
+
self._accepting = False
|
|
410
|
+
self._startup_failed = False
|
|
411
|
+
self._startup_failure_reason = None
|
|
412
|
+
raise RuntimeError(
|
|
413
|
+
failure_reason or "Team Hub host activation failed"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
if commit_configuration is not None:
|
|
418
|
+
commit_configuration()
|
|
419
|
+
if reactivation_hub_id is not None:
|
|
420
|
+
assert reactivation_operation_id is not None
|
|
421
|
+
assert reactivation_snapshot is not None
|
|
422
|
+
if not self.clear_maintenance_sync(
|
|
423
|
+
"host-reactivation",
|
|
424
|
+
reactivation_operation_id,
|
|
425
|
+
reactivation_snapshot,
|
|
426
|
+
):
|
|
427
|
+
raise RuntimeError(
|
|
428
|
+
"Team Hub reactivation fence disappeared before commit"
|
|
429
|
+
)
|
|
430
|
+
self._finalize_committed_fenced_start()
|
|
431
|
+
elif self._store is not None:
|
|
432
|
+
self._attach_host_store(self._store)
|
|
433
|
+
except BaseException:
|
|
434
|
+
lease = self._runtime_lease_fd
|
|
435
|
+
self._runtime_lease_fd = None
|
|
436
|
+
self.mode = str(previous["mode"])
|
|
437
|
+
self.transport = str(previous["transport"])
|
|
438
|
+
self.hub_url = previous["hub_url"]
|
|
439
|
+
self.routes = dict(previous["routes"])
|
|
440
|
+
self.allowed_hosts = set(previous["allowed_hosts"])
|
|
441
|
+
self.config_error = previous["config_error"]
|
|
442
|
+
self.managed_host_display_name = str(
|
|
443
|
+
previous["managed_host_display_name"]
|
|
444
|
+
)
|
|
445
|
+
self.reactivation_hub_id = previous["reactivation_hub_id"]
|
|
446
|
+
self.reactivation_operation_id = previous[
|
|
447
|
+
"reactivation_operation_id"
|
|
448
|
+
]
|
|
449
|
+
self.reactivation_snapshot = previous["reactivation_snapshot"]
|
|
450
|
+
self._delegate = None
|
|
451
|
+
self._store = None
|
|
452
|
+
self._accepting = False
|
|
453
|
+
self._startup_failed = False
|
|
454
|
+
self._startup_failure_reason = None
|
|
455
|
+
HubStore.release_managed_runtime_lease(lease)
|
|
456
|
+
raise
|
|
457
|
+
self._accepting = True
|
|
458
|
+
return self.capability()
|
|
459
|
+
|
|
460
|
+
def rename_live_host(self, display_name: str) -> None:
|
|
461
|
+
"""Rename the active managed node without changing host identity."""
|
|
462
|
+
|
|
463
|
+
label = str(display_name)
|
|
464
|
+
with self._guard:
|
|
465
|
+
store = self._store
|
|
466
|
+
previous = self.managed_host_display_name
|
|
467
|
+
if store is None:
|
|
468
|
+
if self.designated_host:
|
|
469
|
+
raise RuntimeError("the designated Team Hub host is unavailable")
|
|
470
|
+
self.managed_host_display_name = label
|
|
471
|
+
return
|
|
472
|
+
try:
|
|
473
|
+
store.rename_managed_host(label)
|
|
474
|
+
except BaseException:
|
|
475
|
+
self.managed_host_display_name = previous
|
|
476
|
+
raise
|
|
477
|
+
self.managed_host_display_name = label
|
|
478
|
+
|
|
479
|
+
async def disable_live_host(self) -> dict[str, Any]:
|
|
480
|
+
"""Drain and demote this live host without stopping AgentsServer."""
|
|
481
|
+
|
|
482
|
+
with self._guard:
|
|
483
|
+
if not self.designated_host:
|
|
484
|
+
return self.capability()
|
|
485
|
+
store = self._store
|
|
486
|
+
if store is None:
|
|
487
|
+
with self._guard:
|
|
488
|
+
lease = self._runtime_lease_fd
|
|
489
|
+
self._runtime_lease_fd = None
|
|
490
|
+
self._delegate = None
|
|
491
|
+
self._accepting = False
|
|
492
|
+
self._startup_failed = False
|
|
493
|
+
self._startup_failure_reason = None
|
|
494
|
+
self.mode = TEAM_HUB_MODE_DISABLED
|
|
495
|
+
HubStore.release_managed_runtime_lease(lease)
|
|
496
|
+
return self.capability()
|
|
497
|
+
|
|
498
|
+
drain_task = asyncio.create_task(self._close_and_drain())
|
|
499
|
+
try:
|
|
500
|
+
await asyncio.shield(drain_task)
|
|
501
|
+
except asyncio.CancelledError as cancellation:
|
|
502
|
+
try:
|
|
503
|
+
await _join_task_despite_caller_cancellation(drain_task)
|
|
504
|
+
except BaseException:
|
|
505
|
+
await self.reopen_admission()
|
|
506
|
+
raise
|
|
507
|
+
# Draining alone is not the demotion commit point. If the caller
|
|
508
|
+
# goes away before peer detachment, restore the still-live Host's
|
|
509
|
+
# admission boundary instead of leaving it unavailable forever.
|
|
510
|
+
await self.reopen_admission()
|
|
511
|
+
raise cancellation
|
|
512
|
+
except BaseException:
|
|
513
|
+
await self.reopen_admission()
|
|
514
|
+
raise
|
|
515
|
+
|
|
516
|
+
if self.secure_peer_manager is not None:
|
|
517
|
+
detach_task = asyncio.create_task(
|
|
518
|
+
asyncio.to_thread(
|
|
519
|
+
self.secure_peer_manager.detach_host_hub,
|
|
520
|
+
hub_store=store,
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
try:
|
|
524
|
+
await asyncio.shield(detach_task)
|
|
525
|
+
except asyncio.CancelledError as cancellation:
|
|
526
|
+
try:
|
|
527
|
+
await _join_task_despite_caller_cancellation(detach_task)
|
|
528
|
+
except BaseException:
|
|
529
|
+
# A failed detach has not published the local demotion.
|
|
530
|
+
# Restore admission on the still-owned Host, failing
|
|
531
|
+
# closed if its peer surface cannot reopen.
|
|
532
|
+
await self.reopen_admission()
|
|
533
|
+
raise
|
|
534
|
+
# Detachment is the commit boundary. Finish the local state
|
|
535
|
+
# transition before propagating cancellation to the caller.
|
|
536
|
+
cancellation_to_raise: asyncio.CancelledError | None = cancellation
|
|
537
|
+
except BaseException:
|
|
538
|
+
await self.reopen_admission()
|
|
539
|
+
raise
|
|
540
|
+
else:
|
|
541
|
+
cancellation_to_raise = None
|
|
542
|
+
else:
|
|
543
|
+
cancellation_to_raise = None
|
|
544
|
+
|
|
545
|
+
with self._guard:
|
|
546
|
+
if self._store is not store:
|
|
547
|
+
raise RuntimeError("Team Hub host ownership changed during demotion")
|
|
548
|
+
lease = self._runtime_lease_fd
|
|
549
|
+
self._runtime_lease_fd = None
|
|
550
|
+
self._delegate = None
|
|
551
|
+
self._store = None
|
|
552
|
+
self._accepting = False
|
|
553
|
+
self._startup_failed = False
|
|
554
|
+
self._startup_failure_reason = None
|
|
555
|
+
self.mode = TEAM_HUB_MODE_DISABLED
|
|
556
|
+
HubStore.release_managed_runtime_lease(lease)
|
|
557
|
+
result = self.capability()
|
|
558
|
+
if cancellation_to_raise is not None:
|
|
559
|
+
raise cancellation_to_raise
|
|
560
|
+
return result
|
|
561
|
+
|
|
562
|
+
def _attach_host_store(self, store: HubStore) -> None:
|
|
563
|
+
if self.secure_peer_manager is None:
|
|
564
|
+
return
|
|
565
|
+
try:
|
|
566
|
+
self.secure_peer_manager.attach_host_hub(
|
|
567
|
+
hub_id=store.hub_id,
|
|
568
|
+
hub_data_dir=self.data_dir,
|
|
569
|
+
hub_store=store,
|
|
570
|
+
)
|
|
571
|
+
except Exception as exc:
|
|
572
|
+
self.logger.error(
|
|
573
|
+
"secure peer host attachment failed error_type=%s error_code=%s",
|
|
574
|
+
type(exc).__name__,
|
|
575
|
+
getattr(exc, "code", "secure_peer_host_recovery_failed"),
|
|
576
|
+
)
|
|
577
|
+
error_code = str(
|
|
578
|
+
getattr(exc, "code", "secure_peer_host_recovery_failed")
|
|
579
|
+
)
|
|
580
|
+
if (
|
|
581
|
+
isinstance(exc, SecurePeerError)
|
|
582
|
+
and error_code == "secure_peer_host_address_unavailable"
|
|
583
|
+
):
|
|
584
|
+
self.secure_peer_manager.mark_host_unavailable(
|
|
585
|
+
str(exc),
|
|
586
|
+
error_code=error_code,
|
|
587
|
+
action="Configure hosting with a current local IPv4 address, or restore the previous address.",
|
|
588
|
+
)
|
|
589
|
+
return
|
|
590
|
+
self.secure_peer_manager.mark_host_unavailable(
|
|
591
|
+
(
|
|
592
|
+
"An existing secure peer connection could not be "
|
|
593
|
+
"reconciled safely."
|
|
594
|
+
if error_code == "peer_identity_conflict"
|
|
595
|
+
else "The secure peer host could not finish recovery."
|
|
596
|
+
),
|
|
597
|
+
error_code=error_code,
|
|
598
|
+
action=(
|
|
599
|
+
"Review or remove the conflicting logical server "
|
|
600
|
+
"connection, then retry host recovery."
|
|
601
|
+
if error_code == "peer_identity_conflict"
|
|
602
|
+
else "Retry after the Team Hub database is available."
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
def initialize(
|
|
607
|
+
self,
|
|
608
|
+
*,
|
|
609
|
+
hold_admission: bool = False,
|
|
610
|
+
hold_peer_attachment: bool = False,
|
|
611
|
+
) -> None:
|
|
612
|
+
if not self.designated_host:
|
|
613
|
+
if self.config_error:
|
|
614
|
+
self.logger.error("Team Hub is disabled: %s", self.config_error)
|
|
615
|
+
return
|
|
616
|
+
if self.config_error:
|
|
617
|
+
failure_reason = concise_team_hub_startup_failure(
|
|
618
|
+
RuntimeError(self.config_error)
|
|
619
|
+
)
|
|
620
|
+
with self._guard:
|
|
621
|
+
self._startup_failed = True
|
|
622
|
+
self._startup_failure_reason = failure_reason
|
|
623
|
+
self._accepting = False
|
|
624
|
+
self.logger.error(
|
|
625
|
+
"Team Hub host configuration is invalid reason=%s",
|
|
626
|
+
failure_reason,
|
|
627
|
+
)
|
|
628
|
+
return
|
|
629
|
+
lease: int | None = None
|
|
630
|
+
try:
|
|
631
|
+
lease = HubStore.acquire_managed_runtime_lease(self.data_dir)
|
|
632
|
+
application = create_app(
|
|
633
|
+
self.data_dir,
|
|
634
|
+
allowed_hosts=self.allowed_hosts,
|
|
635
|
+
managed_host_identity=self.server_identity,
|
|
636
|
+
managed_server_instance_id=self.server_instance_id,
|
|
637
|
+
managed_host_display_name=self.managed_host_display_name,
|
|
638
|
+
managed_transport=self.transport,
|
|
639
|
+
managed_hub_url=(
|
|
640
|
+
self.hub_url
|
|
641
|
+
or f"http://127.0.0.1{TEAM_HUB_MOUNT_PATH}"
|
|
642
|
+
),
|
|
643
|
+
managed_routes={
|
|
644
|
+
route: (
|
|
645
|
+
url
|
|
646
|
+
or f"http://127.0.0.1{TEAM_HUB_MOUNT_PATH}"
|
|
647
|
+
)
|
|
648
|
+
for route, url in self.routes.items()
|
|
649
|
+
},
|
|
650
|
+
secure_peer_manager=self.secure_peer_manager,
|
|
651
|
+
managed_reactivation_hub_id=self.reactivation_hub_id,
|
|
652
|
+
managed_reactivation_operation_id=self.reactivation_operation_id,
|
|
653
|
+
managed_reactivation_snapshot=self.reactivation_snapshot,
|
|
654
|
+
managed_update_hub_id=self.update_hub_id,
|
|
655
|
+
managed_update_operation_id=self.update_operation_id,
|
|
656
|
+
managed_update_snapshot=self.update_snapshot,
|
|
657
|
+
)
|
|
658
|
+
except Exception as exc:
|
|
659
|
+
HubStore.release_managed_runtime_lease(lease)
|
|
660
|
+
failure_reason = concise_team_hub_startup_failure(exc)
|
|
661
|
+
with self._guard:
|
|
662
|
+
self._startup_failed = True
|
|
663
|
+
self._startup_failure_reason = failure_reason
|
|
664
|
+
self._delegate = None
|
|
665
|
+
self._store = None
|
|
666
|
+
self._accepting = False
|
|
667
|
+
self.logger.error(
|
|
668
|
+
"managed Team Hub activation failed error_type=%s reason=%s",
|
|
669
|
+
type(exc).__name__,
|
|
670
|
+
failure_reason,
|
|
671
|
+
)
|
|
672
|
+
return
|
|
673
|
+
with self._guard:
|
|
674
|
+
self._runtime_lease_fd = lease
|
|
675
|
+
self._delegate = application
|
|
676
|
+
self._store = application.state.store
|
|
677
|
+
self._accepting = not hold_admission
|
|
678
|
+
self._startup_failed = False
|
|
679
|
+
self._startup_failure_reason = None
|
|
680
|
+
if (
|
|
681
|
+
self.secure_peer_manager is not None
|
|
682
|
+
and not application.state.store.maintenance_fenced_start
|
|
683
|
+
and not hold_peer_attachment
|
|
684
|
+
):
|
|
685
|
+
self._attach_host_store(application.state.store)
|
|
686
|
+
|
|
687
|
+
def _finalize_committed_fenced_start(self) -> None:
|
|
688
|
+
"""Attach peer hosting after the installer consumes the exact fence.
|
|
689
|
+
|
|
690
|
+
The candidate remains manager-owned and running throughout commit.
|
|
691
|
+
Health polling drives this idempotent transition, so no service
|
|
692
|
+
bootout/restart window exists after rollback becomes impossible.
|
|
693
|
+
"""
|
|
694
|
+
|
|
695
|
+
with self._guard:
|
|
696
|
+
store = self._store
|
|
697
|
+
if (
|
|
698
|
+
store is None
|
|
699
|
+
or not store.maintenance_fenced_start
|
|
700
|
+
or self._fenced_finalize_in_progress
|
|
701
|
+
):
|
|
702
|
+
return
|
|
703
|
+
try:
|
|
704
|
+
fence = store.maintenance_fence()
|
|
705
|
+
except Exception as exc:
|
|
706
|
+
self.logger.error(
|
|
707
|
+
"Team Hub fenced finalization check failed error_type=%s",
|
|
708
|
+
type(exc).__name__,
|
|
709
|
+
)
|
|
710
|
+
return
|
|
711
|
+
if fence is not None:
|
|
712
|
+
return
|
|
713
|
+
self._fenced_finalize_in_progress = True
|
|
714
|
+
try:
|
|
715
|
+
if self.secure_peer_manager is not None:
|
|
716
|
+
self.secure_peer_manager.attach_host_hub(
|
|
717
|
+
hub_id=store.hub_id,
|
|
718
|
+
hub_data_dir=self.data_dir,
|
|
719
|
+
hub_store=store,
|
|
720
|
+
)
|
|
721
|
+
except Exception as exc:
|
|
722
|
+
self.logger.error(
|
|
723
|
+
"secure peer host post-commit attachment failed error_type=%s error_code=%s",
|
|
724
|
+
type(exc).__name__,
|
|
725
|
+
getattr(exc, "code", "secure_peer_host_recovery_failed"),
|
|
726
|
+
)
|
|
727
|
+
if self.secure_peer_manager is not None:
|
|
728
|
+
if (
|
|
729
|
+
isinstance(exc, SecurePeerError)
|
|
730
|
+
and exc.code == "secure_peer_host_address_unavailable"
|
|
731
|
+
):
|
|
732
|
+
self.secure_peer_manager.mark_host_unavailable(
|
|
733
|
+
str(exc),
|
|
734
|
+
error_code=exc.code,
|
|
735
|
+
action="Configure hosting with a current local IPv4 address, or restore the previous address.",
|
|
736
|
+
)
|
|
737
|
+
return
|
|
738
|
+
self.secure_peer_manager.mark_host_unavailable(
|
|
739
|
+
"The secure peer host could not finish recovery.",
|
|
740
|
+
error_code=str(
|
|
741
|
+
getattr(
|
|
742
|
+
exc,
|
|
743
|
+
"code",
|
|
744
|
+
"secure_peer_host_recovery_failed",
|
|
745
|
+
)
|
|
746
|
+
),
|
|
747
|
+
action="Retry after the Team Hub database is available.",
|
|
748
|
+
)
|
|
749
|
+
else:
|
|
750
|
+
with self._guard:
|
|
751
|
+
if self._store is store:
|
|
752
|
+
store.maintenance_fenced_start = False
|
|
753
|
+
store.reactivation_fenced_start = False
|
|
754
|
+
store.maintenance_fence_reason = None
|
|
755
|
+
store.maintenance_operation_id = None
|
|
756
|
+
store.maintenance_fence_snapshot = None
|
|
757
|
+
self.reactivation_hub_id = None
|
|
758
|
+
self.reactivation_operation_id = None
|
|
759
|
+
self.reactivation_snapshot = None
|
|
760
|
+
self.update_hub_id = None
|
|
761
|
+
self.update_operation_id = None
|
|
762
|
+
self.update_snapshot = None
|
|
763
|
+
finally:
|
|
764
|
+
with self._guard:
|
|
765
|
+
self._fenced_finalize_in_progress = False
|
|
766
|
+
|
|
767
|
+
def capability(self) -> dict[str, Any]:
|
|
768
|
+
self._finalize_committed_fenced_start()
|
|
769
|
+
with self._guard:
|
|
770
|
+
available = bool(self._accepting and self._store is not None)
|
|
771
|
+
hub_id = self._store.hub_id if available and self._store is not None else None
|
|
772
|
+
startup_failed = self._startup_failed
|
|
773
|
+
startup_failure_reason = self._startup_failure_reason
|
|
774
|
+
if not self.designated_host:
|
|
775
|
+
message = (
|
|
776
|
+
"Team Hub host mode is misconfigured on this AgentsServer."
|
|
777
|
+
if self.config_error
|
|
778
|
+
else "This AgentsServer is not the designated Team Hub host."
|
|
779
|
+
)
|
|
780
|
+
return {
|
|
781
|
+
"available": False,
|
|
782
|
+
"designated_host": False,
|
|
783
|
+
"version": TEAM_HUB_CAPABILITY_VERSION,
|
|
784
|
+
"base_path": None,
|
|
785
|
+
"transport": None,
|
|
786
|
+
"hub_url": None,
|
|
787
|
+
"routes": [],
|
|
788
|
+
"hub_id": None,
|
|
789
|
+
"host_server_identity": None,
|
|
790
|
+
"message": message,
|
|
791
|
+
"action": (
|
|
792
|
+
"Set AGENTSDOCK_TEAM_HUB_MODE to disabled or host."
|
|
793
|
+
if self.config_error
|
|
794
|
+
else "Configure AGENTSDOCK_TEAM_HUB_MODE=host on exactly one AgentsServer."
|
|
795
|
+
),
|
|
796
|
+
}
|
|
797
|
+
return {
|
|
798
|
+
"available": available,
|
|
799
|
+
"designated_host": True,
|
|
800
|
+
"version": TEAM_HUB_CAPABILITY_VERSION,
|
|
801
|
+
"base_path": TEAM_HUB_MOUNT_PATH,
|
|
802
|
+
"transport": self.transport,
|
|
803
|
+
"hub_url": self.hub_url,
|
|
804
|
+
"routes": [
|
|
805
|
+
{"transport": route, "hub_url": url}
|
|
806
|
+
for route, url in self.routes.items()
|
|
807
|
+
],
|
|
808
|
+
"hub_id": hub_id,
|
|
809
|
+
"host_server_identity": self.server_identity,
|
|
810
|
+
"startup_failure_reason": (
|
|
811
|
+
startup_failure_reason if startup_failed else None
|
|
812
|
+
),
|
|
813
|
+
"message": (
|
|
814
|
+
"This AgentsServer hosts Team Hub over private Tailscale Serve."
|
|
815
|
+
if available and self.transport == TEAM_HUB_TRANSPORT_TAILSCALE_SERVE
|
|
816
|
+
else (
|
|
817
|
+
"This AgentsServer hosts Team Hub over direct, unencrypted IP access."
|
|
818
|
+
)
|
|
819
|
+
if available and self.transport == TEAM_HUB_TRANSPORT_DIRECT_IP
|
|
820
|
+
else "This AgentsServer hosts the local Team Hub preview."
|
|
821
|
+
if available
|
|
822
|
+
else "The designated Team Hub is unavailable."
|
|
823
|
+
),
|
|
824
|
+
"action": (
|
|
825
|
+
None
|
|
826
|
+
if available
|
|
827
|
+
else "Restart the designated host after checking its local Team Hub logs."
|
|
828
|
+
if startup_failed
|
|
829
|
+
else "Wait for the designated Team Hub to finish starting."
|
|
830
|
+
),
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
def server_session_available(self) -> bool:
|
|
834
|
+
"""Return whether this exact managed host can expose its shared session."""
|
|
835
|
+
|
|
836
|
+
with self._guard:
|
|
837
|
+
store = self._store if self._accepting else None
|
|
838
|
+
if store is None or not self.server_identity:
|
|
839
|
+
return False
|
|
840
|
+
try:
|
|
841
|
+
if not bool(store.health().get("bootstrapped")):
|
|
842
|
+
return False
|
|
843
|
+
store.managed_server_claims()
|
|
844
|
+
return True
|
|
845
|
+
except Exception:
|
|
846
|
+
return False
|
|
847
|
+
|
|
848
|
+
def tailscale_serve_identity(
|
|
849
|
+
self,
|
|
850
|
+
request: Request,
|
|
851
|
+
) -> ManagedTransportIdentity | None:
|
|
852
|
+
route_url = self.routes.get(TEAM_HUB_TRANSPORT_TAILSCALE_SERVE)
|
|
853
|
+
if route_url is None:
|
|
854
|
+
return None
|
|
855
|
+
identity = classify_managed_transport(
|
|
856
|
+
request,
|
|
857
|
+
managed_transport=TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
|
|
858
|
+
managed_hub_url=route_url,
|
|
859
|
+
)
|
|
860
|
+
if identity is None or identity.kind != TEAM_HUB_TRANSPORT_TAILSCALE_SERVE:
|
|
861
|
+
return None
|
|
862
|
+
return identity
|
|
863
|
+
|
|
864
|
+
def direct_ip_identity(
|
|
865
|
+
self,
|
|
866
|
+
request: Request,
|
|
867
|
+
) -> ManagedTransportIdentity | None:
|
|
868
|
+
route_url = self.routes.get(TEAM_HUB_TRANSPORT_DIRECT_IP)
|
|
869
|
+
if route_url is None:
|
|
870
|
+
return None
|
|
871
|
+
identity = classify_managed_transport(
|
|
872
|
+
request,
|
|
873
|
+
managed_transport=TEAM_HUB_TRANSPORT_DIRECT_IP,
|
|
874
|
+
managed_hub_url=route_url,
|
|
875
|
+
)
|
|
876
|
+
if identity is None or identity.kind != TEAM_HUB_TRANSPORT_DIRECT_IP:
|
|
877
|
+
return None
|
|
878
|
+
return identity
|
|
879
|
+
|
|
880
|
+
async def issue_tailnet_bootstrap_proof(
|
|
881
|
+
self,
|
|
882
|
+
*,
|
|
883
|
+
request_id: str,
|
|
884
|
+
server_identity: str,
|
|
885
|
+
server_instance_id: str,
|
|
886
|
+
hub_url: str,
|
|
887
|
+
tailnet_login: str,
|
|
888
|
+
recipient_email: str,
|
|
889
|
+
display_name: str,
|
|
890
|
+
device_label: str,
|
|
891
|
+
transport: str = TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
|
|
892
|
+
) -> dict[str, Any]:
|
|
893
|
+
"""Admit one route-bound remote proof mutation into the Hub drain boundary."""
|
|
894
|
+
|
|
895
|
+
if transport not in {
|
|
896
|
+
TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
|
|
897
|
+
TEAM_HUB_TRANSPORT_DIRECT_IP,
|
|
898
|
+
} or transport not in self.routes:
|
|
899
|
+
raise HubError("bootstrap_unavailable", "Bootstrap is unavailable", 403)
|
|
900
|
+
|
|
901
|
+
async with self._admission:
|
|
902
|
+
with self._guard:
|
|
903
|
+
store = self._store if self._accepting else None
|
|
904
|
+
if store is not None:
|
|
905
|
+
self._in_flight += 1
|
|
906
|
+
if store is None:
|
|
907
|
+
raise HubError("hub_unavailable", "Team Hub is unavailable", 503)
|
|
908
|
+
|
|
909
|
+
window = int(time.monotonic() // 60)
|
|
910
|
+
normalized_login = str(tailnet_login).strip().lower()
|
|
911
|
+
with self._bootstrap_rate_guard:
|
|
912
|
+
allowed = True
|
|
913
|
+
for key, limit in ((normalized_login, 8), ("*", 200)):
|
|
914
|
+
prior_window, count = self._bootstrap_rate_buckets.get(
|
|
915
|
+
key, (window, 0)
|
|
916
|
+
)
|
|
917
|
+
if prior_window != window:
|
|
918
|
+
count = 0
|
|
919
|
+
count += 1
|
|
920
|
+
self._bootstrap_rate_buckets[key] = (window, count)
|
|
921
|
+
allowed = allowed and count <= limit
|
|
922
|
+
if len(self._bootstrap_rate_buckets) > 4096:
|
|
923
|
+
self._bootstrap_rate_buckets = {
|
|
924
|
+
key: value
|
|
925
|
+
for key, value in self._bootstrap_rate_buckets.items()
|
|
926
|
+
if value[0] >= window - 1
|
|
927
|
+
}
|
|
928
|
+
while len(self._bootstrap_rate_buckets) > 4096:
|
|
929
|
+
self._bootstrap_rate_buckets.pop(
|
|
930
|
+
next(iter(self._bootstrap_rate_buckets))
|
|
931
|
+
)
|
|
932
|
+
if not allowed:
|
|
933
|
+
async with self._admission:
|
|
934
|
+
self._in_flight = max(0, self._in_flight - 1)
|
|
935
|
+
if self._in_flight == 0:
|
|
936
|
+
self._admission.notify_all()
|
|
937
|
+
raise HubError("rate_limited", "Too many bootstrap proof requests", 429)
|
|
938
|
+
|
|
939
|
+
def issue() -> dict[str, Any]:
|
|
940
|
+
with HubStore.maintenance_control_lock(self.data_dir):
|
|
941
|
+
if store.maintenance_fence() is not None:
|
|
942
|
+
raise HubError(
|
|
943
|
+
"hub_maintenance",
|
|
944
|
+
"Team Hub is unavailable during server maintenance",
|
|
945
|
+
503,
|
|
946
|
+
)
|
|
947
|
+
return store.issue_tailnet_bootstrap_proof(
|
|
948
|
+
request_id=request_id,
|
|
949
|
+
server_identity=server_identity,
|
|
950
|
+
server_instance_id=server_instance_id,
|
|
951
|
+
hub_url=hub_url,
|
|
952
|
+
tailnet_login=tailnet_login,
|
|
953
|
+
recipient_email=recipient_email,
|
|
954
|
+
display_name=display_name,
|
|
955
|
+
device_label=device_label,
|
|
956
|
+
transport=transport,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
try:
|
|
960
|
+
return await asyncio.to_thread(issue)
|
|
961
|
+
finally:
|
|
962
|
+
async with self._admission:
|
|
963
|
+
self._in_flight = max(0, self._in_flight - 1)
|
|
964
|
+
if self._in_flight == 0:
|
|
965
|
+
self._admission.notify_all()
|
|
966
|
+
|
|
967
|
+
async def _close_and_drain(self) -> None:
|
|
968
|
+
if self.secure_peer_manager is not None:
|
|
969
|
+
# The TLS gateway bypasses ASGI but shares the authoritative Hub
|
|
970
|
+
# database. Close and drain it before a maintenance snapshot can
|
|
971
|
+
# begin so an acknowledged peer write cannot land post-snapshot.
|
|
972
|
+
close_task = asyncio.create_task(
|
|
973
|
+
asyncio.to_thread(
|
|
974
|
+
self.secure_peer_manager.close_host_admission
|
|
975
|
+
)
|
|
976
|
+
)
|
|
977
|
+
try:
|
|
978
|
+
await asyncio.shield(close_task)
|
|
979
|
+
except asyncio.CancelledError as cancellation:
|
|
980
|
+
# The native peer drain cannot be stopped once its worker has
|
|
981
|
+
# begun. Keep ownership until it settles so a later close
|
|
982
|
+
# cannot race the cancellation rollback's reopen.
|
|
983
|
+
try:
|
|
984
|
+
await _join_task_despite_caller_cancellation(close_task)
|
|
985
|
+
except BaseException as exc:
|
|
986
|
+
self.logger.error(
|
|
987
|
+
"secure peer maintenance drain failed during cancellation "
|
|
988
|
+
"error_type=%s",
|
|
989
|
+
type(exc).__name__,
|
|
990
|
+
)
|
|
991
|
+
raise cancellation
|
|
992
|
+
async with self._admission:
|
|
993
|
+
with self._guard:
|
|
994
|
+
self._accepting = False
|
|
995
|
+
while self._in_flight:
|
|
996
|
+
await self._admission.wait()
|
|
997
|
+
|
|
998
|
+
async def prepare_maintenance(
|
|
999
|
+
self,
|
|
1000
|
+
reason: str,
|
|
1001
|
+
*,
|
|
1002
|
+
drain_timeout_seconds: float = 10.0,
|
|
1003
|
+
persistent_fence: bool = True,
|
|
1004
|
+
operation_id: str | None = None,
|
|
1005
|
+
allow_unavailable_host: bool = False,
|
|
1006
|
+
_reopen_on_abort: bool = True,
|
|
1007
|
+
) -> Path | None:
|
|
1008
|
+
"""Close Hub admission, drain delegated requests, then snapshot."""
|
|
1009
|
+
|
|
1010
|
+
if not self.designated_host:
|
|
1011
|
+
return None
|
|
1012
|
+
|
|
1013
|
+
async def cleanup_aborted_maintenance(
|
|
1014
|
+
*,
|
|
1015
|
+
store: HubStore | None = None,
|
|
1016
|
+
snapshot: Path | None = None,
|
|
1017
|
+
) -> None:
|
|
1018
|
+
if not _reopen_on_abort:
|
|
1019
|
+
# Lifespan shutdown may outlive its bounded caller while a
|
|
1020
|
+
# native snapshot worker settles. Publish permanent local
|
|
1021
|
+
# closure before any further await so that straggler can never
|
|
1022
|
+
# resurrect admission after the peer runtime is shut down.
|
|
1023
|
+
with self._guard:
|
|
1024
|
+
self._accepting = False
|
|
1025
|
+
if persistent_fence and store is not None:
|
|
1026
|
+
if snapshot is not None:
|
|
1027
|
+
await asyncio.to_thread(
|
|
1028
|
+
store.clear_maintenance_fence,
|
|
1029
|
+
expected_reason=reason,
|
|
1030
|
+
expected_operation_id=str(operation_id),
|
|
1031
|
+
expected_snapshot=snapshot,
|
|
1032
|
+
)
|
|
1033
|
+
# A failed snapshot worker may have installed an incomplete,
|
|
1034
|
+
# changed, or otherwise ambiguous fence without returning its
|
|
1035
|
+
# snapshot. Probe before reopening; a valid remaining marker
|
|
1036
|
+
# or a validation error must leave both admission surfaces
|
|
1037
|
+
# closed for explicit recovery.
|
|
1038
|
+
remaining_fence = await asyncio.to_thread(
|
|
1039
|
+
store.maintenance_fence
|
|
1040
|
+
)
|
|
1041
|
+
if remaining_fence is not None:
|
|
1042
|
+
raise RuntimeError(
|
|
1043
|
+
"Team Hub maintenance fence remains after abort"
|
|
1044
|
+
)
|
|
1045
|
+
if _reopen_on_abort:
|
|
1046
|
+
# Local ASGI and secure-peer admission form one boundary. They
|
|
1047
|
+
# reopen only after the exact durable fence is cleared (or
|
|
1048
|
+
# after a pre-snapshot abort where no fence was installed).
|
|
1049
|
+
await self.reopen_admission()
|
|
1050
|
+
|
|
1051
|
+
async def settle_aborted_maintenance(
|
|
1052
|
+
*,
|
|
1053
|
+
store: HubStore | None = None,
|
|
1054
|
+
snapshot: Path | None = None,
|
|
1055
|
+
) -> asyncio.CancelledError | None:
|
|
1056
|
+
cleanup_task = asyncio.create_task(
|
|
1057
|
+
cleanup_aborted_maintenance(store=store, snapshot=snapshot)
|
|
1058
|
+
)
|
|
1059
|
+
try:
|
|
1060
|
+
await asyncio.shield(cleanup_task)
|
|
1061
|
+
return None
|
|
1062
|
+
except asyncio.CancelledError as cancellation:
|
|
1063
|
+
try:
|
|
1064
|
+
await _join_task_despite_caller_cancellation(cleanup_task)
|
|
1065
|
+
except BaseException as exc:
|
|
1066
|
+
self.logger.error(
|
|
1067
|
+
"Team Hub maintenance cancellation cleanup failed "
|
|
1068
|
+
"error_type=%s",
|
|
1069
|
+
type(exc).__name__,
|
|
1070
|
+
)
|
|
1071
|
+
return cancellation
|
|
1072
|
+
except BaseException as exc:
|
|
1073
|
+
self.logger.error(
|
|
1074
|
+
"Team Hub maintenance cancellation cleanup failed "
|
|
1075
|
+
"error_type=%s",
|
|
1076
|
+
type(exc).__name__,
|
|
1077
|
+
)
|
|
1078
|
+
return None
|
|
1079
|
+
|
|
1080
|
+
drain_task = asyncio.create_task(self._close_and_drain())
|
|
1081
|
+
try:
|
|
1082
|
+
await asyncio.wait_for(
|
|
1083
|
+
asyncio.shield(drain_task),
|
|
1084
|
+
timeout=max(0.1, float(drain_timeout_seconds)),
|
|
1085
|
+
)
|
|
1086
|
+
except asyncio.CancelledError as cancellation:
|
|
1087
|
+
drain_task.cancel()
|
|
1088
|
+
try:
|
|
1089
|
+
await _join_task_despite_caller_cancellation(drain_task)
|
|
1090
|
+
except BaseException:
|
|
1091
|
+
pass
|
|
1092
|
+
await settle_aborted_maintenance()
|
|
1093
|
+
raise cancellation
|
|
1094
|
+
except asyncio.TimeoutError as exc:
|
|
1095
|
+
drain_task.cancel()
|
|
1096
|
+
try:
|
|
1097
|
+
await _join_task_despite_caller_cancellation(drain_task)
|
|
1098
|
+
except BaseException:
|
|
1099
|
+
pass
|
|
1100
|
+
cleanup_cancellation = await settle_aborted_maintenance()
|
|
1101
|
+
if cleanup_cancellation is not None:
|
|
1102
|
+
raise cleanup_cancellation
|
|
1103
|
+
raise RuntimeError("Team Hub request drain timed out") from exc
|
|
1104
|
+
except BaseException:
|
|
1105
|
+
cleanup_cancellation = await settle_aborted_maintenance()
|
|
1106
|
+
if cleanup_cancellation is not None:
|
|
1107
|
+
raise cleanup_cancellation
|
|
1108
|
+
raise
|
|
1109
|
+
with self._guard:
|
|
1110
|
+
store = self._store
|
|
1111
|
+
startup_failed = self._startup_failed
|
|
1112
|
+
startup_failure_reason = self._startup_failure_reason
|
|
1113
|
+
if store is None:
|
|
1114
|
+
if allow_unavailable_host and startup_failed:
|
|
1115
|
+
# A failed host has no live HubStore whose logical state can
|
|
1116
|
+
# be snapshotted. Keep both local and peer admission closed;
|
|
1117
|
+
# the caller must bind the repair transaction to an exact
|
|
1118
|
+
# cold on-disk generation before replacing the release.
|
|
1119
|
+
self.logger.warning(
|
|
1120
|
+
"Team Hub maintenance proceeding without a live snapshot "
|
|
1121
|
+
"reason=%s startup_failure=%s",
|
|
1122
|
+
reason,
|
|
1123
|
+
startup_failure_reason or "unavailable",
|
|
1124
|
+
)
|
|
1125
|
+
return None
|
|
1126
|
+
cleanup_cancellation = await settle_aborted_maintenance()
|
|
1127
|
+
if cleanup_cancellation is not None:
|
|
1128
|
+
raise cleanup_cancellation
|
|
1129
|
+
raise RuntimeError("designated Team Hub is unavailable for maintenance")
|
|
1130
|
+
if persistent_fence and not operation_id:
|
|
1131
|
+
cleanup_cancellation = await settle_aborted_maintenance()
|
|
1132
|
+
if cleanup_cancellation is not None:
|
|
1133
|
+
raise cleanup_cancellation
|
|
1134
|
+
raise ValueError("persistent Team Hub maintenance requires an operation_id")
|
|
1135
|
+
snapshot_call = (
|
|
1136
|
+
asyncio.to_thread(
|
|
1137
|
+
store.maintenance_snapshot_and_fence,
|
|
1138
|
+
reason,
|
|
1139
|
+
operation_id=str(operation_id),
|
|
1140
|
+
)
|
|
1141
|
+
if persistent_fence
|
|
1142
|
+
else asyncio.to_thread(store.maintenance_snapshot, reason)
|
|
1143
|
+
)
|
|
1144
|
+
snapshot_task = asyncio.create_task(
|
|
1145
|
+
snapshot_call
|
|
1146
|
+
)
|
|
1147
|
+
try:
|
|
1148
|
+
return await asyncio.shield(snapshot_task)
|
|
1149
|
+
except asyncio.CancelledError as cancellation:
|
|
1150
|
+
snapshot: Path | None = None
|
|
1151
|
+
try:
|
|
1152
|
+
snapshot = await _join_task_despite_caller_cancellation(
|
|
1153
|
+
snapshot_task
|
|
1154
|
+
)
|
|
1155
|
+
except BaseException as exc:
|
|
1156
|
+
self.logger.error(
|
|
1157
|
+
"Team Hub maintenance snapshot failed during cancellation "
|
|
1158
|
+
"error_type=%s",
|
|
1159
|
+
type(exc).__name__,
|
|
1160
|
+
)
|
|
1161
|
+
await settle_aborted_maintenance(store=store, snapshot=snapshot)
|
|
1162
|
+
raise cancellation
|
|
1163
|
+
except BaseException:
|
|
1164
|
+
cleanup_cancellation = await settle_aborted_maintenance(store=store)
|
|
1165
|
+
if cleanup_cancellation is not None:
|
|
1166
|
+
raise cleanup_cancellation
|
|
1167
|
+
raise
|
|
1168
|
+
|
|
1169
|
+
async def clear_maintenance(
|
|
1170
|
+
self,
|
|
1171
|
+
reason: str,
|
|
1172
|
+
operation_id: str,
|
|
1173
|
+
snapshot: Path,
|
|
1174
|
+
) -> bool:
|
|
1175
|
+
with self._guard:
|
|
1176
|
+
store = self._store
|
|
1177
|
+
if store is None:
|
|
1178
|
+
if self.designated_host:
|
|
1179
|
+
raise RuntimeError("designated Team Hub is unavailable")
|
|
1180
|
+
return False
|
|
1181
|
+
clear_task = asyncio.create_task(
|
|
1182
|
+
asyncio.to_thread(
|
|
1183
|
+
store.clear_maintenance_fence,
|
|
1184
|
+
expected_reason=reason,
|
|
1185
|
+
expected_operation_id=operation_id,
|
|
1186
|
+
expected_snapshot=snapshot,
|
|
1187
|
+
)
|
|
1188
|
+
)
|
|
1189
|
+
try:
|
|
1190
|
+
return await asyncio.shield(clear_task)
|
|
1191
|
+
except asyncio.CancelledError as cancellation:
|
|
1192
|
+
# The filesystem replace/unlink sequence cannot be cancelled once
|
|
1193
|
+
# its worker has begun. Retain ownership through repeated caller
|
|
1194
|
+
# cancellation so a caller cannot reopen admission while the
|
|
1195
|
+
# durable fence still exists (or while its outcome is unknown).
|
|
1196
|
+
await _join_task_despite_caller_cancellation(clear_task)
|
|
1197
|
+
raise cancellation
|
|
1198
|
+
|
|
1199
|
+
def clear_maintenance_sync(
|
|
1200
|
+
self,
|
|
1201
|
+
reason: str,
|
|
1202
|
+
operation_id: str,
|
|
1203
|
+
snapshot: Path,
|
|
1204
|
+
) -> bool:
|
|
1205
|
+
with self._guard:
|
|
1206
|
+
store = self._store
|
|
1207
|
+
if store is None:
|
|
1208
|
+
if self.designated_host:
|
|
1209
|
+
raise RuntimeError("designated Team Hub is unavailable")
|
|
1210
|
+
return False
|
|
1211
|
+
return store.clear_maintenance_fence(
|
|
1212
|
+
expected_reason=reason,
|
|
1213
|
+
expected_operation_id=operation_id,
|
|
1214
|
+
expected_snapshot=snapshot,
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
def maintenance_fence_sync(self) -> dict[str, Any] | None:
|
|
1218
|
+
with self._guard:
|
|
1219
|
+
store = self._store
|
|
1220
|
+
if store is None:
|
|
1221
|
+
if self.designated_host:
|
|
1222
|
+
raise RuntimeError("designated Team Hub is unavailable")
|
|
1223
|
+
return None
|
|
1224
|
+
return store.maintenance_fence()
|
|
1225
|
+
|
|
1226
|
+
async def reopen_admission(self) -> None:
|
|
1227
|
+
with self._guard:
|
|
1228
|
+
ready = self._delegate is not None and self._store is not None
|
|
1229
|
+
# Do not expose the local ASGI surface while the secure-peer
|
|
1230
|
+
# surface is still closed or its reopen outcome is unknown.
|
|
1231
|
+
if self.secure_peer_manager is not None:
|
|
1232
|
+
self._accepting = False
|
|
1233
|
+
cancellation: asyncio.CancelledError | None = None
|
|
1234
|
+
if self.secure_peer_manager is not None and ready:
|
|
1235
|
+
reopen_task = asyncio.create_task(
|
|
1236
|
+
asyncio.to_thread(
|
|
1237
|
+
self.secure_peer_manager.reopen_host_admission
|
|
1238
|
+
)
|
|
1239
|
+
)
|
|
1240
|
+
try:
|
|
1241
|
+
await asyncio.shield(reopen_task)
|
|
1242
|
+
except asyncio.CancelledError as exc:
|
|
1243
|
+
try:
|
|
1244
|
+
await _join_task_despite_caller_cancellation(reopen_task)
|
|
1245
|
+
except BaseException:
|
|
1246
|
+
with self._guard:
|
|
1247
|
+
self._accepting = False
|
|
1248
|
+
raise
|
|
1249
|
+
cancellation = exc
|
|
1250
|
+
except BaseException:
|
|
1251
|
+
with self._guard:
|
|
1252
|
+
self._accepting = False
|
|
1253
|
+
raise
|
|
1254
|
+
|
|
1255
|
+
# No await is allowed between a successful peer reopen and publishing
|
|
1256
|
+
# local admission: cancellation must observe both surfaces in the same
|
|
1257
|
+
# state. Notify condition waiters in a separately owned task afterward.
|
|
1258
|
+
with self._guard:
|
|
1259
|
+
self._accepting = ready
|
|
1260
|
+
|
|
1261
|
+
async def notify_reopened() -> None:
|
|
1262
|
+
async with self._admission:
|
|
1263
|
+
self._admission.notify_all()
|
|
1264
|
+
|
|
1265
|
+
notification_task = asyncio.create_task(notify_reopened())
|
|
1266
|
+
try:
|
|
1267
|
+
await asyncio.shield(notification_task)
|
|
1268
|
+
except asyncio.CancelledError as exc:
|
|
1269
|
+
await _join_task_despite_caller_cancellation(notification_task)
|
|
1270
|
+
if cancellation is None:
|
|
1271
|
+
cancellation = exc
|
|
1272
|
+
if cancellation is not None:
|
|
1273
|
+
raise cancellation
|
|
1274
|
+
|
|
1275
|
+
def reopen_admission_sync(self) -> None:
|
|
1276
|
+
"""Reopen after a synchronous restart signal worker fails.
|
|
1277
|
+
|
|
1278
|
+
Request admission only holds the asyncio condition long enough to
|
|
1279
|
+
increment a counter; changing the guarded flag cannot strand a waiter.
|
|
1280
|
+
The next request observes the restored value without crossing event
|
|
1281
|
+
loops from Starlette's background thread.
|
|
1282
|
+
"""
|
|
1283
|
+
|
|
1284
|
+
with self._guard:
|
|
1285
|
+
ready = self._delegate is not None and self._store is not None
|
|
1286
|
+
if self.secure_peer_manager is not None:
|
|
1287
|
+
self._accepting = False
|
|
1288
|
+
if self.secure_peer_manager is not None and ready:
|
|
1289
|
+
try:
|
|
1290
|
+
self.secure_peer_manager.reopen_host_admission()
|
|
1291
|
+
except BaseException:
|
|
1292
|
+
with self._guard:
|
|
1293
|
+
self._accepting = False
|
|
1294
|
+
raise
|
|
1295
|
+
with self._guard:
|
|
1296
|
+
self._accepting = ready
|
|
1297
|
+
|
|
1298
|
+
async def shutdown(self) -> None:
|
|
1299
|
+
if not self.designated_host:
|
|
1300
|
+
return
|
|
1301
|
+
try:
|
|
1302
|
+
try:
|
|
1303
|
+
with self._guard:
|
|
1304
|
+
store = self._store
|
|
1305
|
+
already_fenced = bool(
|
|
1306
|
+
store is not None
|
|
1307
|
+
and await asyncio.to_thread(store.maintenance_fence) is not None
|
|
1308
|
+
)
|
|
1309
|
+
if already_fenced:
|
|
1310
|
+
await asyncio.wait_for(self._close_and_drain(), timeout=10.0)
|
|
1311
|
+
else:
|
|
1312
|
+
await self.prepare_maintenance(
|
|
1313
|
+
"server-shutdown",
|
|
1314
|
+
persistent_fence=False,
|
|
1315
|
+
_reopen_on_abort=False,
|
|
1316
|
+
)
|
|
1317
|
+
except Exception as exc:
|
|
1318
|
+
self.logger.error(
|
|
1319
|
+
"managed Team Hub shutdown snapshot failed error_type=%s",
|
|
1320
|
+
type(exc).__name__,
|
|
1321
|
+
)
|
|
1322
|
+
async with self._admission:
|
|
1323
|
+
self._accepting = False
|
|
1324
|
+
finally:
|
|
1325
|
+
with self._guard:
|
|
1326
|
+
lease = self._runtime_lease_fd
|
|
1327
|
+
self._runtime_lease_fd = None
|
|
1328
|
+
HubStore.release_managed_runtime_lease(lease)
|
|
1329
|
+
|
|
1330
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
1331
|
+
async with self._admission:
|
|
1332
|
+
with self._guard:
|
|
1333
|
+
delegate = self._delegate if self._accepting else None
|
|
1334
|
+
if delegate is not None:
|
|
1335
|
+
self._in_flight += 1
|
|
1336
|
+
if delegate is None:
|
|
1337
|
+
response = JSONResponse(
|
|
1338
|
+
{
|
|
1339
|
+
"error": {
|
|
1340
|
+
"code": "hub_unavailable",
|
|
1341
|
+
"message": "Team Hub is unavailable",
|
|
1342
|
+
}
|
|
1343
|
+
},
|
|
1344
|
+
status_code=503,
|
|
1345
|
+
)
|
|
1346
|
+
await response(scope, receive, send)
|
|
1347
|
+
return
|
|
1348
|
+
try:
|
|
1349
|
+
await delegate(scope, receive, send)
|
|
1350
|
+
finally:
|
|
1351
|
+
async with self._admission:
|
|
1352
|
+
self._in_flight = max(0, self._in_flight - 1)
|
|
1353
|
+
if self._in_flight == 0:
|
|
1354
|
+
self._admission.notify_all()
|