arbiter-server 0.9.1.dev1__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.
- arbiter_server/__init__.py +1 -0
- arbiter_server/__main__.py +5 -0
- arbiter_server/app.py +44 -0
- arbiter_server/artifacts.py +344 -0
- arbiter_server/cli_errors.py +31 -0
- arbiter_server/config.py +231 -0
- arbiter_server/deploy/docker/arbiter-docker +4477 -0
- arbiter_server/deploy/docker/compose.yaml +101 -0
- arbiter_server/file_protection/__init__.py +20 -0
- arbiter_server/file_protection/posix.py +70 -0
- arbiter_server/file_protection/windows.py +379 -0
- arbiter_server/main.py +2843 -0
- arbiter_server/plugins/__init__.py +36 -0
- arbiter_server/py.typed +1 -0
- arbiter_server/services.py +706 -0
- arbiter_server/storage.py +60 -0
- arbiter_server/version.py +135 -0
- arbiter_server-0.9.1.dev1.dist-info/METADATA +26 -0
- arbiter_server-0.9.1.dev1.dist-info/RECORD +22 -0
- arbiter_server-0.9.1.dev1.dist-info/WHEEL +5 -0
- arbiter_server-0.9.1.dev1.dist-info/entry_points.txt +2 -0
- arbiter_server-0.9.1.dev1.dist-info/top_level.txt +1 -0
arbiter_server/main.py
ADDED
|
@@ -0,0 +1,2843 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, distribution, entry_points
|
|
15
|
+
from importlib.resources import files
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from tempfile import TemporaryDirectory, mkstemp
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
from urllib.request import url2pathname
|
|
21
|
+
|
|
22
|
+
from hydra import compose, initialize_config_dir
|
|
23
|
+
from omegaconf import DictConfig, OmegaConf
|
|
24
|
+
|
|
25
|
+
from .app import ArbiterApp
|
|
26
|
+
from .artifacts import (
|
|
27
|
+
ArtifactConsumed,
|
|
28
|
+
ArtifactExpired,
|
|
29
|
+
ArtifactNotFound,
|
|
30
|
+
ArtifactStore,
|
|
31
|
+
)
|
|
32
|
+
from .cli_errors import print_cli_error
|
|
33
|
+
from .config import (
|
|
34
|
+
AppConfig,
|
|
35
|
+
DeploymentScope,
|
|
36
|
+
StorageConfig,
|
|
37
|
+
configured_service_names,
|
|
38
|
+
register_configs,
|
|
39
|
+
service_accounts_for,
|
|
40
|
+
service_policies_for,
|
|
41
|
+
)
|
|
42
|
+
from .file_protection import ensure_runtime_config_permissions
|
|
43
|
+
from .plugins import discover_service_plugins
|
|
44
|
+
from .services import (
|
|
45
|
+
SERVER_API_VERSION,
|
|
46
|
+
SERVER_VERSION,
|
|
47
|
+
OperationCatalog,
|
|
48
|
+
RuntimeRegistry,
|
|
49
|
+
SERVICE_PLUGIN_ENTRY_POINT_GROUP,
|
|
50
|
+
ServicePlugin,
|
|
51
|
+
ServicePluginContext,
|
|
52
|
+
ServicePluginFactory,
|
|
53
|
+
ServiceRuntimeContext,
|
|
54
|
+
service_plugin_runtime_info,
|
|
55
|
+
validate_service_plugin_compatibility,
|
|
56
|
+
validate_service_plugins,
|
|
57
|
+
)
|
|
58
|
+
from .storage import PluginStorage, default_plugin_data_root
|
|
59
|
+
from .version import arbiter_server_version, source_info
|
|
60
|
+
|
|
61
|
+
if TYPE_CHECKING:
|
|
62
|
+
from mcp.server.fastmcp import FastMCP
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
LOGGER = logging.getLogger(__name__)
|
|
66
|
+
TransportMode = Literal["stdio", "sse", "streamable-http"]
|
|
67
|
+
HydraConfig = AppConfig | DictConfig
|
|
68
|
+
BootstrapObjectKind = Literal["account", "policy"]
|
|
69
|
+
CLI_COMMANDS = {"serve", "config", "plugins", "bootstrap", "env", "deploy", "version"}
|
|
70
|
+
BOOTSTRAP_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
71
|
+
ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
72
|
+
ENV_FILE_CONFIG_KEY = "arbiter.env_file"
|
|
73
|
+
ENV_REFERENCE_PATTERN = re.compile(r"\$\{oc\.env:(?P<name>[^,}\s]+)(?:,[^}]*)?\}")
|
|
74
|
+
DEPLOY_PINNED_REQUIREMENT_PATTERN = re.compile(
|
|
75
|
+
r"^[A-Za-z0-9][A-Za-z0-9_.-]*"
|
|
76
|
+
r"(?:\[[A-Za-z0-9_.-]+(?:,[A-Za-z0-9_.-]+)*\])?"
|
|
77
|
+
r"==[^<>=!~\s#]+$"
|
|
78
|
+
)
|
|
79
|
+
DEPLOY_PINNED_REQUIREMENT_PARTS_PATTERN = re.compile(
|
|
80
|
+
r"^(?P<name>[A-Za-z0-9][A-Za-z0-9_.-]*)"
|
|
81
|
+
r"(?:\[[A-Za-z0-9_.-]+(?:,[A-Za-z0-9_.-]+)*\])?"
|
|
82
|
+
r"==(?P<version>[^<>=!~\s#]+)$"
|
|
83
|
+
)
|
|
84
|
+
DEFAULT_ENV_FILE_NAME = ".env"
|
|
85
|
+
DEFAULT_CONFIG_DIR = "~/.arbiter"
|
|
86
|
+
DEFAULT_SERVER_CONFIG_NAME = "arbiter-server"
|
|
87
|
+
CONFIG_FILE_MODE = 0o640
|
|
88
|
+
ENV_FILE_MODE = 0o600
|
|
89
|
+
DEFAULT_DOCKER_DEPLOY_DIR = "./arbiter-docker"
|
|
90
|
+
ARTIFACT_ROUTE_PREFIX = "/_arbiter/artifacts"
|
|
91
|
+
DEPLOY_MANIFEST_FILE_NAME = ".arbiter-deploy.json"
|
|
92
|
+
ARBITER_SERVER_PACKAGE = "arbiter-server"
|
|
93
|
+
ARBITER_ALL_META_PACKAGE = "arbiter-suite"
|
|
94
|
+
DOCKER_META_PACKAGE_GROUPS = {
|
|
95
|
+
ARBITER_ALL_META_PACKAGE: (
|
|
96
|
+
ARBITER_SERVER_PACKAGE,
|
|
97
|
+
"arbiter-smtp",
|
|
98
|
+
"arbiter-imap",
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
DOCKER_LOCAL_SOURCE_CONTAINER_ROOT = "/source/arbiter"
|
|
102
|
+
DOCKER_WHEELS_CONTAINER_ROOT = "/wheels"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _default_container_user() -> str:
|
|
106
|
+
getuid = getattr(os, "getuid", None)
|
|
107
|
+
getgid = getattr(os, "getgid", None)
|
|
108
|
+
if getuid is None or getgid is None:
|
|
109
|
+
return "10001:10001"
|
|
110
|
+
return f"{getuid()}:{getgid()}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
DOCKER_COMPOSE_ENV_DEFAULTS = [
|
|
114
|
+
("ARBITER_IMAGE", "python:3.11-slim"),
|
|
115
|
+
("ARBITER_CONTAINER_NAME", "arbiter-staging"),
|
|
116
|
+
("ARBITER_CONTAINER_USER", _default_container_user()),
|
|
117
|
+
("ARBITER_RESTART", "unless-stopped"),
|
|
118
|
+
("ARBITER_APP_ENV_FILE", "./conf/.env"),
|
|
119
|
+
("ARBITER_CONFIG_DIR", "./conf"),
|
|
120
|
+
("ARBITER_CONFIG_NAME", "arbiter-server"),
|
|
121
|
+
("ARBITER_REQUIREMENTS_FILE", "./requirements.txt"),
|
|
122
|
+
("ARBITER_WHEELS_DIR", "./wheels"),
|
|
123
|
+
("ARBITER_PLUGIN_DATA_DIR", "./data/plugins"),
|
|
124
|
+
("ARBITER_HOST_BIND", "127.0.0.1"),
|
|
125
|
+
("ARBITER_HOST_PORT", "18025"),
|
|
126
|
+
("ARBITER_CONTAINER_PORT", "8025"),
|
|
127
|
+
("ARBITER_PUBLIC_SCHEME", "http"),
|
|
128
|
+
("ARBITER_PUBLIC_BASE_URL", ""),
|
|
129
|
+
("ARBITER_DOCKER_NETWORK_NAME", "arbiter-staging"),
|
|
130
|
+
("ARBITER_DOCKER_BRIDGE_NAME", "arbiter-stg0"),
|
|
131
|
+
("ARBITER_DOCKER_SUBNET", "172.31.251.0/24"),
|
|
132
|
+
]
|
|
133
|
+
GROUP_SELECTION_PATTERN = re.compile(
|
|
134
|
+
r"^\s*-\s*(?P<item>[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)?)\s*(?:#.*)?$"
|
|
135
|
+
)
|
|
136
|
+
MISC_ENV_BLOCK = "miscellaneous"
|
|
137
|
+
MAIN_CONFIG_TEMPLATE = """defaults:
|
|
138
|
+
# Arbiter composes this config at startup from the defaults below.
|
|
139
|
+
# Inspect the composed config with:
|
|
140
|
+
# arbiter-server --config-dir <dir> --config-name arbiter-server config show
|
|
141
|
+
# Override composed values with Hydra overrides, for example:
|
|
142
|
+
# arbiter-server --config-dir <dir> serve arbiter.server.bind.port=8025
|
|
143
|
+
# Optionally load a config-dir-relative dotenv file before composition:
|
|
144
|
+
# arbiter:
|
|
145
|
+
# env_file: local.env
|
|
146
|
+
- arbiter_app_config_schema
|
|
147
|
+
- arbiter: server
|
|
148
|
+
- _self_
|
|
149
|
+
"""
|
|
150
|
+
SERVER_CONFIG_TEMPLATE = """# @package arbiter
|
|
151
|
+
server:
|
|
152
|
+
name: arbiter
|
|
153
|
+
transport: streamable-http
|
|
154
|
+
bind:
|
|
155
|
+
host: 127.0.0.1
|
|
156
|
+
port: 8000
|
|
157
|
+
path: /mcp
|
|
158
|
+
stateless_http: true
|
|
159
|
+
json_response: true
|
|
160
|
+
deployment_scope: unknown
|
|
161
|
+
discovery:
|
|
162
|
+
max_account_preview_limit: 25
|
|
163
|
+
max_operation_preview_limit: 25
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class EnvReference:
|
|
169
|
+
name: str
|
|
170
|
+
block: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class DockerDeployArgs:
|
|
175
|
+
action: str
|
|
176
|
+
directory: Path
|
|
177
|
+
requirements: tuple[str, ...]
|
|
178
|
+
force: bool
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class DockerDeployRequirements:
|
|
183
|
+
requirements: tuple[str, ...]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _service_plugin_map(
|
|
187
|
+
service_plugins: Sequence[ServicePlugin],
|
|
188
|
+
) -> dict[str, ServicePlugin]:
|
|
189
|
+
validate_service_plugins(service_plugins)
|
|
190
|
+
return {service_plugin.name: service_plugin for service_plugin in service_plugins}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _configured_service_plugins(
|
|
194
|
+
cfg: HydraConfig,
|
|
195
|
+
service_plugins: Sequence[ServicePlugin],
|
|
196
|
+
) -> list[ServicePlugin]:
|
|
197
|
+
available_plugins = _service_plugin_map(service_plugins)
|
|
198
|
+
active_service_plugins: list[ServicePlugin] = []
|
|
199
|
+
for service_name in configured_service_names(cfg.arbiter.account):
|
|
200
|
+
service_plugin = available_plugins.get(service_name)
|
|
201
|
+
if service_plugin is None:
|
|
202
|
+
raise RuntimeError(
|
|
203
|
+
f"configured service plugin is not installed: {service_name}"
|
|
204
|
+
)
|
|
205
|
+
active_service_plugins.append(service_plugin)
|
|
206
|
+
return active_service_plugins
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def build_app(
|
|
210
|
+
cfg: HydraConfig,
|
|
211
|
+
service_plugins: Sequence[ServicePlugin] | None = None,
|
|
212
|
+
runtime_dependencies: dict[str, object] | None = None,
|
|
213
|
+
) -> ArbiterApp:
|
|
214
|
+
available_plugins = (
|
|
215
|
+
discover_service_plugins() if service_plugins is None else service_plugins
|
|
216
|
+
)
|
|
217
|
+
active_service_plugins = _configured_service_plugins(cfg, available_plugins)
|
|
218
|
+
shared_runtime_dependencies = runtime_dependencies or {}
|
|
219
|
+
plugin_data_root = _plugin_data_root(_storage_config(cfg))
|
|
220
|
+
runtimes: dict[str, object] = {}
|
|
221
|
+
for service_plugin in active_service_plugins:
|
|
222
|
+
accounts = service_accounts_for(cfg, service_plugin.name)
|
|
223
|
+
if accounts is None:
|
|
224
|
+
raise RuntimeError(
|
|
225
|
+
f"service config is not configured: {service_plugin.name}"
|
|
226
|
+
)
|
|
227
|
+
policies = service_policies_for(cfg, service_plugin.name)
|
|
228
|
+
plugin_dependencies = {
|
|
229
|
+
**shared_runtime_dependencies,
|
|
230
|
+
"plugin_storage": PluginStorage(
|
|
231
|
+
plugin_name=service_plugin.name,
|
|
232
|
+
root=plugin_data_root,
|
|
233
|
+
),
|
|
234
|
+
}
|
|
235
|
+
artifact_store = shared_runtime_dependencies.get("artifact_store")
|
|
236
|
+
if isinstance(artifact_store, ArtifactStore):
|
|
237
|
+
plugin_dependencies["artifact_store"] = artifact_store.for_plugin(
|
|
238
|
+
service_plugin.name
|
|
239
|
+
)
|
|
240
|
+
runtime_context = ServiceRuntimeContext(
|
|
241
|
+
dependencies=plugin_dependencies,
|
|
242
|
+
)
|
|
243
|
+
runtimes[service_plugin.name] = service_plugin.build_runtime(
|
|
244
|
+
accounts=accounts,
|
|
245
|
+
policies=policies,
|
|
246
|
+
context=runtime_context,
|
|
247
|
+
)
|
|
248
|
+
_configure_sent_message_appender(cfg, runtimes)
|
|
249
|
+
return ArbiterApp(RuntimeRegistry(runtimes))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True)
|
|
253
|
+
class _SentCopyDestination:
|
|
254
|
+
account: str
|
|
255
|
+
folder: str
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class _IMAPSentMessageAppender:
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
*,
|
|
262
|
+
imap_accounts: Mapping[str, object],
|
|
263
|
+
imap_runtime: object,
|
|
264
|
+
) -> None:
|
|
265
|
+
self._imap_accounts = imap_accounts
|
|
266
|
+
self._imap_runtime = imap_runtime
|
|
267
|
+
|
|
268
|
+
def resolve_destination(
|
|
269
|
+
self,
|
|
270
|
+
*,
|
|
271
|
+
account: str,
|
|
272
|
+
folder: str | None,
|
|
273
|
+
) -> _SentCopyDestination:
|
|
274
|
+
imap_config = self._imap_accounts.get(account)
|
|
275
|
+
if imap_config is None:
|
|
276
|
+
raise ValueError(f"matching IMAP account is not configured: {account}")
|
|
277
|
+
|
|
278
|
+
folders = _config_mapping_value(imap_config, "folders")
|
|
279
|
+
if folder is not None:
|
|
280
|
+
if folder not in folders:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
f"sent copy folder is not configured for IMAP account "
|
|
283
|
+
f"{account}: {folder}"
|
|
284
|
+
)
|
|
285
|
+
return _SentCopyDestination(account=account, folder=folder)
|
|
286
|
+
|
|
287
|
+
sent_folders = [
|
|
288
|
+
folder_name
|
|
289
|
+
for folder_name, folder_config in sorted(folders.items())
|
|
290
|
+
if _folder_kind_value(folder_config) == "sent"
|
|
291
|
+
]
|
|
292
|
+
if len(sent_folders) == 1:
|
|
293
|
+
return _SentCopyDestination(account=account, folder=sent_folders[0])
|
|
294
|
+
if not sent_folders:
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"IMAP account has no folder configured with kind=sent: {account}"
|
|
297
|
+
)
|
|
298
|
+
raise ValueError(
|
|
299
|
+
f"IMAP account has multiple folders configured with kind=sent: {account}"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def append_sent_message(
|
|
303
|
+
self,
|
|
304
|
+
*,
|
|
305
|
+
account: str,
|
|
306
|
+
folder: str,
|
|
307
|
+
message_bytes: bytes,
|
|
308
|
+
) -> None:
|
|
309
|
+
append_sent_message = getattr(self._imap_runtime, "append_sent_message", None)
|
|
310
|
+
if not callable(append_sent_message):
|
|
311
|
+
raise RuntimeError("IMAP runtime does not support sent-copy append")
|
|
312
|
+
append_sent_message(
|
|
313
|
+
account=account,
|
|
314
|
+
folder=folder,
|
|
315
|
+
message_bytes=message_bytes,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _configure_sent_message_appender(
|
|
320
|
+
cfg: HydraConfig,
|
|
321
|
+
runtimes: Mapping[str, object],
|
|
322
|
+
) -> None:
|
|
323
|
+
smtp_runtime = runtimes.get("smtp")
|
|
324
|
+
configure = getattr(smtp_runtime, "configure_sent_message_appender", None)
|
|
325
|
+
if not callable(configure):
|
|
326
|
+
return
|
|
327
|
+
imap_runtime = runtimes.get("imap")
|
|
328
|
+
imap_accounts = service_accounts_for(cfg, "imap")
|
|
329
|
+
if imap_runtime is None or imap_accounts is None:
|
|
330
|
+
return
|
|
331
|
+
configure(
|
|
332
|
+
_IMAPSentMessageAppender(
|
|
333
|
+
imap_accounts=imap_accounts,
|
|
334
|
+
imap_runtime=imap_runtime,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _config_mapping_value(config: object, key: str) -> Mapping[str, object]:
|
|
340
|
+
value = config.get(key, {}) if isinstance(config, Mapping) else getattr(config, key)
|
|
341
|
+
if isinstance(value, Mapping):
|
|
342
|
+
return value
|
|
343
|
+
raise TypeError(f"config value must be a mapping: {key}")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _folder_kind_value(folder_config: object) -> str | None:
|
|
347
|
+
kind = (
|
|
348
|
+
folder_config.get("kind")
|
|
349
|
+
if isinstance(folder_config, Mapping)
|
|
350
|
+
else getattr(folder_config, "kind", None)
|
|
351
|
+
)
|
|
352
|
+
if kind is None:
|
|
353
|
+
return None
|
|
354
|
+
value = getattr(kind, "value", kind)
|
|
355
|
+
return str(value)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _storage_config(cfg: HydraConfig) -> StorageConfig | Any | None:
|
|
359
|
+
if OmegaConf.is_config(cfg):
|
|
360
|
+
return OmegaConf.select(cast(Any, cfg), "arbiter.storage")
|
|
361
|
+
return getattr(cfg.arbiter, "storage", None)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _plugin_data_root(storage_config: StorageConfig | Any | None) -> Path:
|
|
365
|
+
if storage_config is None:
|
|
366
|
+
return default_plugin_data_root()
|
|
367
|
+
if OmegaConf.is_config(storage_config):
|
|
368
|
+
plugin_data_dir = OmegaConf.select(cast(Any, storage_config), "plugin_data_dir")
|
|
369
|
+
else:
|
|
370
|
+
plugin_data_dir = getattr(storage_config, "plugin_data_dir", None)
|
|
371
|
+
if plugin_data_dir is not None:
|
|
372
|
+
return Path(str(plugin_data_dir)).expanduser().resolve()
|
|
373
|
+
return default_plugin_data_root()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _csv_or_none(values: list[str]) -> str:
|
|
377
|
+
return ",".join(values) if values else "none"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _service_accounts_summary(cfg: HydraConfig) -> str:
|
|
381
|
+
summaries: list[str] = []
|
|
382
|
+
for service_name in configured_service_names(cfg.arbiter.account):
|
|
383
|
+
accounts = cfg.arbiter.account.get(service_name, {})
|
|
384
|
+
account_names = sorted(str(account_name) for account_name in accounts)
|
|
385
|
+
summaries.append(f"{service_name}:{_csv_or_none(account_names)}")
|
|
386
|
+
return ";".join(summaries) if summaries else "none"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _server_mcp_url(cfg: HydraConfig) -> str:
|
|
390
|
+
if cfg.arbiter.server.transport == "stdio":
|
|
391
|
+
return "stdio"
|
|
392
|
+
return f"{_server_base_url(cfg)}{_server_public_path(cfg)}"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _server_public_path(cfg: HydraConfig) -> str:
|
|
396
|
+
public_path = cfg.arbiter.server.public.path
|
|
397
|
+
if "${" in public_path:
|
|
398
|
+
public_path = cfg.arbiter.server.bind.path
|
|
399
|
+
return public_path
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _server_base_url(cfg: HydraConfig) -> str:
|
|
403
|
+
if cfg.arbiter.server.transport == "stdio":
|
|
404
|
+
raise ValueError("stdio transport does not expose HTTP artifact URLs")
|
|
405
|
+
public_base_url = cfg.arbiter.server.public.base_url.strip()
|
|
406
|
+
if "${" in public_base_url:
|
|
407
|
+
public_port = str(cfg.arbiter.server.public.port)
|
|
408
|
+
if "${" in public_port:
|
|
409
|
+
public_port = str(cfg.arbiter.server.bind.port)
|
|
410
|
+
public_base_url = (
|
|
411
|
+
f"{cfg.arbiter.server.public.scheme}://"
|
|
412
|
+
f"{cfg.arbiter.server.public.host}:"
|
|
413
|
+
f"{public_port}"
|
|
414
|
+
)
|
|
415
|
+
if not public_base_url:
|
|
416
|
+
raise ValueError("arbiter.server.public.base_url must be non-empty")
|
|
417
|
+
return public_base_url.rstrip("/")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _artifact_base_url(cfg: HydraConfig) -> str:
|
|
421
|
+
return f"{_server_base_url(cfg)}{ARTIFACT_ROUTE_PREFIX}"
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _deployment_scope_value(deployment_scope: DeploymentScope | str) -> str:
|
|
425
|
+
if isinstance(deployment_scope, DeploymentScope):
|
|
426
|
+
return deployment_scope.value
|
|
427
|
+
return str(deployment_scope)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def log_startup_summary(cfg: HydraConfig) -> None:
|
|
431
|
+
active_services = configured_service_names(cfg.arbiter.account)
|
|
432
|
+
|
|
433
|
+
LOGGER.info(
|
|
434
|
+
"Arbiter starting version=%s deployment_scope=%s transport=%s bind=%s:%s%s "
|
|
435
|
+
"mcp_url=%s services=%s service_accounts=%s",
|
|
436
|
+
arbiter_server_version(),
|
|
437
|
+
_deployment_scope_value(cfg.arbiter.deployment_scope),
|
|
438
|
+
cfg.arbiter.server.transport,
|
|
439
|
+
cfg.arbiter.server.bind.host,
|
|
440
|
+
cfg.arbiter.server.bind.port,
|
|
441
|
+
cfg.arbiter.server.bind.path,
|
|
442
|
+
_server_mcp_url(cfg),
|
|
443
|
+
_csv_or_none(active_services),
|
|
444
|
+
_service_accounts_summary(cfg),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _installed_plugin_summary(
|
|
449
|
+
service_plugins: Sequence[ServicePlugin] | None = None,
|
|
450
|
+
) -> str:
|
|
451
|
+
names = service_plugin_names(service_plugins)
|
|
452
|
+
return ", ".join(names) if names else "none"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def ensure_runnable_config(
|
|
456
|
+
cfg: HydraConfig,
|
|
457
|
+
service_plugins: Sequence[ServicePlugin] | None = None,
|
|
458
|
+
) -> None:
|
|
459
|
+
if not configured_service_names(cfg.arbiter.account):
|
|
460
|
+
raise ValueError(
|
|
461
|
+
"config must define at least one service account before Arbiter can run\n"
|
|
462
|
+
f"currently installed arbiter plugins: "
|
|
463
|
+
f"{_installed_plugin_summary(service_plugins)}\n"
|
|
464
|
+
"use `arbiter-server --config-dir DIR bootstrap plugin PLUGIN "
|
|
465
|
+
"account NAME` to create an account config"
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def config_check_summary(
|
|
470
|
+
cfg: HydraConfig,
|
|
471
|
+
service_plugins: Sequence[ServicePlugin] | None = None,
|
|
472
|
+
) -> str:
|
|
473
|
+
ensure_runnable_config(cfg, service_plugins=service_plugins)
|
|
474
|
+
build_app(cfg, service_plugins=service_plugins)
|
|
475
|
+
return (
|
|
476
|
+
"config ok: "
|
|
477
|
+
f"services={_csv_or_none(configured_service_names(cfg.arbiter.account))} "
|
|
478
|
+
f"service_accounts={_service_accounts_summary(cfg)}"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def service_plugin_names(
|
|
483
|
+
service_plugins: Sequence[ServicePlugin] | None = None,
|
|
484
|
+
) -> list[str]:
|
|
485
|
+
plugins = discover_service_plugins() if service_plugins is None else service_plugins
|
|
486
|
+
validate_service_plugins(plugins)
|
|
487
|
+
return sorted(service_plugin.name for service_plugin in plugins)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def service_plugin_infos(
|
|
491
|
+
service_plugins: Sequence[ServicePlugin] | None = None,
|
|
492
|
+
) -> list[dict[str, str]]:
|
|
493
|
+
plugins = discover_service_plugins() if service_plugins is None else service_plugins
|
|
494
|
+
validate_service_plugins(plugins)
|
|
495
|
+
return [
|
|
496
|
+
{
|
|
497
|
+
"name": info.name,
|
|
498
|
+
"version": info.version,
|
|
499
|
+
"server_api_version": info.server_api_version,
|
|
500
|
+
}
|
|
501
|
+
for info in sorted(
|
|
502
|
+
(service_plugin_runtime_info(service_plugin) for service_plugin in plugins),
|
|
503
|
+
key=lambda plugin_info: plugin_info.name,
|
|
504
|
+
)
|
|
505
|
+
]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def runtime_version_info(
|
|
509
|
+
service_plugins: Sequence[ServicePlugin] | None = None,
|
|
510
|
+
*,
|
|
511
|
+
deployment_scope: DeploymentScope | str = DeploymentScope.unknown,
|
|
512
|
+
) -> dict[str, object]:
|
|
513
|
+
source = source_info()
|
|
514
|
+
if isinstance(deployment_scope, DeploymentScope):
|
|
515
|
+
deployment_scope_value = deployment_scope.value
|
|
516
|
+
else:
|
|
517
|
+
deployment_scope_value = deployment_scope
|
|
518
|
+
return {
|
|
519
|
+
"server": {
|
|
520
|
+
"version": SERVER_VERSION,
|
|
521
|
+
"api_version": SERVER_API_VERSION,
|
|
522
|
+
},
|
|
523
|
+
"deployment_scope": deployment_scope_value,
|
|
524
|
+
"source": {
|
|
525
|
+
"commit": source.commit,
|
|
526
|
+
"dirty": source.dirty,
|
|
527
|
+
},
|
|
528
|
+
"plugins": service_plugin_infos(service_plugins),
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _print_runtime_version_info(
|
|
533
|
+
service_plugins: Sequence[ServicePlugin] | None = None,
|
|
534
|
+
*,
|
|
535
|
+
as_json: bool,
|
|
536
|
+
) -> None:
|
|
537
|
+
version_info = runtime_version_info(service_plugins)
|
|
538
|
+
if as_json:
|
|
539
|
+
print(json.dumps(version_info))
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
server_info = cast(dict[str, str], version_info["server"])
|
|
543
|
+
print(f"server {server_info['version']} (api {server_info['api_version']})")
|
|
544
|
+
print(f"deployment scope {version_info['deployment_scope']}")
|
|
545
|
+
source = cast(dict[str, object], version_info["source"])
|
|
546
|
+
if source["commit"] is not None:
|
|
547
|
+
dirty = " dirty" if source["dirty"] else ""
|
|
548
|
+
print(f"source {source['commit']}{dirty}")
|
|
549
|
+
print("plugins:")
|
|
550
|
+
plugins = cast(list[dict[str, str]], version_info["plugins"])
|
|
551
|
+
if not plugins:
|
|
552
|
+
print(" none")
|
|
553
|
+
return
|
|
554
|
+
for plugin in plugins:
|
|
555
|
+
print(
|
|
556
|
+
f" {plugin['name']} {plugin['version']} "
|
|
557
|
+
f"(server api {plugin['server_api_version']})"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _register_server_tools(
|
|
562
|
+
server: "FastMCP",
|
|
563
|
+
catalog: OperationCatalog,
|
|
564
|
+
service_plugins: Sequence[ServicePlugin],
|
|
565
|
+
deployment_scope: DeploymentScope | str,
|
|
566
|
+
) -> None:
|
|
567
|
+
@server.tool(
|
|
568
|
+
description=(
|
|
569
|
+
"Return Arbiter server and loaded service plugin version " "information."
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
def version_info() -> dict[str, object]:
|
|
573
|
+
return runtime_version_info(
|
|
574
|
+
service_plugins,
|
|
575
|
+
deployment_scope=deployment_scope,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
@server.tool(
|
|
579
|
+
description=(
|
|
580
|
+
"Discover Arbiter server identity, installed plugins, accounts, "
|
|
581
|
+
"account policy summaries, read-only account test results, and "
|
|
582
|
+
"operation schemas."
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
def info(
|
|
586
|
+
kind: str = "overview",
|
|
587
|
+
plugin: str | None = None,
|
|
588
|
+
account: str | None = None,
|
|
589
|
+
operation: str | None = None,
|
|
590
|
+
) -> dict[str, object]:
|
|
591
|
+
return catalog.info(
|
|
592
|
+
kind=kind,
|
|
593
|
+
plugin=plugin,
|
|
594
|
+
account=account,
|
|
595
|
+
operation=operation,
|
|
596
|
+
version_info=runtime_version_info(
|
|
597
|
+
service_plugins,
|
|
598
|
+
deployment_scope=deployment_scope,
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
@server.tool(
|
|
603
|
+
description=(
|
|
604
|
+
"Return the available Arbiter capability names. Use "
|
|
605
|
+
"describe_caps or describe_cap to drill down before "
|
|
606
|
+
"choosing an operation."
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
def list_caps() -> dict[str, object]:
|
|
610
|
+
return catalog.list_capabilities()
|
|
611
|
+
|
|
612
|
+
@server.tool(
|
|
613
|
+
description=(
|
|
614
|
+
"Return bounded summaries of all Arbiter capabilities, including "
|
|
615
|
+
"account and operation previews."
|
|
616
|
+
)
|
|
617
|
+
)
|
|
618
|
+
def describe_caps(
|
|
619
|
+
operation_preview_limit: int = 8,
|
|
620
|
+
account_preview_limit: int = 8,
|
|
621
|
+
) -> dict[str, object]:
|
|
622
|
+
return catalog.describe_capabilities(
|
|
623
|
+
operation_preview_limit=operation_preview_limit,
|
|
624
|
+
account_preview_limit=account_preview_limit,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
@server.tool(
|
|
628
|
+
description=(
|
|
629
|
+
"Return focused account and operation context for one Arbiter "
|
|
630
|
+
"capability."
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
def describe_cap(capability: str) -> dict[str, object]:
|
|
634
|
+
return catalog.describe_capability(capability)
|
|
635
|
+
|
|
636
|
+
@server.tool(
|
|
637
|
+
description=(
|
|
638
|
+
"Return the description and input schema for one Arbiter "
|
|
639
|
+
"operation. Operation ids use CAPABILITY:OPERATION syntax."
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
def describe_op(id: str) -> dict[str, object]:
|
|
643
|
+
return catalog.describe_operation(id)
|
|
644
|
+
|
|
645
|
+
@server.tool(
|
|
646
|
+
description=(
|
|
647
|
+
"Run one Arbiter operation by id. Operation ids use "
|
|
648
|
+
"CAPABILITY:OPERATION syntax."
|
|
649
|
+
)
|
|
650
|
+
)
|
|
651
|
+
def run_op(
|
|
652
|
+
id: str,
|
|
653
|
+
arguments: dict[str, Any] | None = None,
|
|
654
|
+
) -> object:
|
|
655
|
+
return catalog.invoke_operation(id, arguments)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _create_fastmcp_server(cfg: HydraConfig) -> "FastMCP":
|
|
659
|
+
from mcp.server.fastmcp import FastMCP
|
|
660
|
+
|
|
661
|
+
server = FastMCP(
|
|
662
|
+
cfg.arbiter.server.name,
|
|
663
|
+
stateless_http=cfg.arbiter.server.stateless_http,
|
|
664
|
+
json_response=cfg.arbiter.server.json_response,
|
|
665
|
+
)
|
|
666
|
+
server.settings.host = cfg.arbiter.server.bind.host
|
|
667
|
+
server.settings.port = int(cfg.arbiter.server.bind.port)
|
|
668
|
+
server.settings.streamable_http_path = cfg.arbiter.server.bind.path
|
|
669
|
+
mcp_server = getattr(server, "_mcp_server", None)
|
|
670
|
+
if mcp_server is not None:
|
|
671
|
+
mcp_server.version = arbiter_server_version()
|
|
672
|
+
return server
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def build_server(
|
|
676
|
+
cfg: HydraConfig,
|
|
677
|
+
service_plugins: Sequence[ServicePlugin] | None = None,
|
|
678
|
+
) -> "FastMCP":
|
|
679
|
+
available_service_plugins = (
|
|
680
|
+
discover_service_plugins() if service_plugins is None else service_plugins
|
|
681
|
+
)
|
|
682
|
+
active_service_plugins = _configured_service_plugins(
|
|
683
|
+
cfg,
|
|
684
|
+
available_service_plugins,
|
|
685
|
+
)
|
|
686
|
+
artifact_store: ArtifactStore | None = None
|
|
687
|
+
runtime_dependencies: dict[str, object] = {}
|
|
688
|
+
if cfg.arbiter.server.transport != "stdio":
|
|
689
|
+
artifact_store = ArtifactStore(
|
|
690
|
+
root=_plugin_data_root(_storage_config(cfg)),
|
|
691
|
+
base_url=_artifact_base_url(cfg),
|
|
692
|
+
)
|
|
693
|
+
runtime_dependencies["artifact_store"] = artifact_store
|
|
694
|
+
app = build_app(
|
|
695
|
+
cfg,
|
|
696
|
+
service_plugins=active_service_plugins,
|
|
697
|
+
runtime_dependencies=runtime_dependencies,
|
|
698
|
+
)
|
|
699
|
+
server = _create_fastmcp_server(cfg)
|
|
700
|
+
if artifact_store is not None:
|
|
701
|
+
_register_artifact_route(server, artifact_store)
|
|
702
|
+
catalog = OperationCatalog(
|
|
703
|
+
active_service_plugins,
|
|
704
|
+
ServicePluginContext(runtimes=app.runtime_registry),
|
|
705
|
+
max_account_preview_limit=cfg.arbiter.discovery.max_account_preview_limit,
|
|
706
|
+
max_operation_preview_limit=cfg.arbiter.discovery.max_operation_preview_limit,
|
|
707
|
+
)
|
|
708
|
+
_register_server_tools(
|
|
709
|
+
server,
|
|
710
|
+
catalog,
|
|
711
|
+
active_service_plugins,
|
|
712
|
+
cfg.arbiter.deployment_scope,
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
return server
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _register_artifact_route(server: "FastMCP", artifact_store: ArtifactStore) -> None:
|
|
719
|
+
from starlette.requests import Request
|
|
720
|
+
from starlette.responses import FileResponse, PlainTextResponse, Response
|
|
721
|
+
|
|
722
|
+
@server.custom_route(
|
|
723
|
+
f"{ARTIFACT_ROUTE_PREFIX}/{{artifact_id}}",
|
|
724
|
+
methods=["GET", "HEAD"],
|
|
725
|
+
include_in_schema=False,
|
|
726
|
+
)
|
|
727
|
+
async def get_artifact(request: Request) -> Response:
|
|
728
|
+
artifact_id = request.path_params["artifact_id"]
|
|
729
|
+
nonce = request.query_params.get("nonce", "")
|
|
730
|
+
if not nonce:
|
|
731
|
+
return PlainTextResponse("not found", status_code=404)
|
|
732
|
+
try:
|
|
733
|
+
if request.method == "HEAD":
|
|
734
|
+
artifact = artifact_store.inspect(artifact_id, nonce)
|
|
735
|
+
else:
|
|
736
|
+
artifact = artifact_store.open_once(artifact_id, nonce)
|
|
737
|
+
except ArtifactConsumed:
|
|
738
|
+
return PlainTextResponse("gone", status_code=410)
|
|
739
|
+
except (ArtifactExpired, ArtifactNotFound):
|
|
740
|
+
return PlainTextResponse("not found", status_code=404)
|
|
741
|
+
if request.method == "HEAD":
|
|
742
|
+
response = Response(status_code=200, media_type=artifact.content_type)
|
|
743
|
+
response.headers["Content-Length"] = str(artifact.size)
|
|
744
|
+
response.headers["Cache-Control"] = "no-store"
|
|
745
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
746
|
+
response.headers["X-Arbiter-Artifact-SHA256"] = artifact.sha256
|
|
747
|
+
return response
|
|
748
|
+
response = FileResponse(
|
|
749
|
+
artifact.path,
|
|
750
|
+
media_type=artifact.content_type,
|
|
751
|
+
filename=artifact.filename,
|
|
752
|
+
content_disposition_type="attachment",
|
|
753
|
+
)
|
|
754
|
+
response.headers.setdefault("Content-Disposition", "attachment")
|
|
755
|
+
response.headers["Cache-Control"] = "no-store"
|
|
756
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
757
|
+
response.headers["X-Arbiter-Artifact-SHA256"] = artifact.sha256
|
|
758
|
+
return response
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
async def _serve_uvicorn_app(server: "FastMCP", starlette_app: object) -> None:
|
|
762
|
+
import uvicorn
|
|
763
|
+
|
|
764
|
+
config = uvicorn.Config(
|
|
765
|
+
cast(Any, starlette_app),
|
|
766
|
+
host=server.settings.host,
|
|
767
|
+
port=server.settings.port,
|
|
768
|
+
log_level=server.settings.log_level.lower(),
|
|
769
|
+
log_config=None,
|
|
770
|
+
)
|
|
771
|
+
uvicorn_server = uvicorn.Server(config)
|
|
772
|
+
await uvicorn_server.serve()
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _run_server(server: "FastMCP", transport: TransportMode) -> None:
|
|
776
|
+
if transport == "stdio":
|
|
777
|
+
server.run(transport=transport)
|
|
778
|
+
return
|
|
779
|
+
|
|
780
|
+
import anyio
|
|
781
|
+
|
|
782
|
+
if transport == "streamable-http":
|
|
783
|
+
anyio.run(_serve_uvicorn_app, server, server.streamable_http_app())
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
anyio.run(_serve_uvicorn_app, server, server.sse_app(None))
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _strip_arg_separator(args: Sequence[str]) -> list[str]:
|
|
790
|
+
if args and args[0] == "--":
|
|
791
|
+
return list(args[1:])
|
|
792
|
+
return list(args)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _strip_env_comment(value: str) -> str:
|
|
796
|
+
in_single_quotes = False
|
|
797
|
+
in_double_quotes = False
|
|
798
|
+
escaped = False
|
|
799
|
+
for index, char in enumerate(value):
|
|
800
|
+
if escaped:
|
|
801
|
+
escaped = False
|
|
802
|
+
continue
|
|
803
|
+
if char == "\\" and in_double_quotes:
|
|
804
|
+
escaped = True
|
|
805
|
+
continue
|
|
806
|
+
if char == "'" and not in_double_quotes:
|
|
807
|
+
in_single_quotes = not in_single_quotes
|
|
808
|
+
continue
|
|
809
|
+
if char == '"' and not in_single_quotes:
|
|
810
|
+
in_double_quotes = not in_double_quotes
|
|
811
|
+
continue
|
|
812
|
+
if (
|
|
813
|
+
char == "#"
|
|
814
|
+
and not in_single_quotes
|
|
815
|
+
and not in_double_quotes
|
|
816
|
+
and (index == 0 or value[index - 1].isspace())
|
|
817
|
+
):
|
|
818
|
+
return value[:index].rstrip()
|
|
819
|
+
return value
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _decode_double_quoted_env_value(value: str) -> str:
|
|
823
|
+
replacements = {
|
|
824
|
+
"\\n": "\n",
|
|
825
|
+
"\\r": "\r",
|
|
826
|
+
"\\t": "\t",
|
|
827
|
+
'\\"': '"',
|
|
828
|
+
"\\\\": "\\",
|
|
829
|
+
}
|
|
830
|
+
for escaped, replacement in replacements.items():
|
|
831
|
+
value = value.replace(escaped, replacement)
|
|
832
|
+
return value
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _parse_env_value(value: str) -> str:
|
|
836
|
+
stripped = _strip_env_comment(value.strip()).strip()
|
|
837
|
+
if len(stripped) >= 2 and stripped[0] == stripped[-1] == "'":
|
|
838
|
+
return stripped[1:-1]
|
|
839
|
+
if len(stripped) >= 2 and stripped[0] == stripped[-1] == '"':
|
|
840
|
+
return _decode_double_quoted_env_value(stripped[1:-1])
|
|
841
|
+
return stripped
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _read_env_file_values(
|
|
845
|
+
env_file: Path, *, missing_ok: bool = False
|
|
846
|
+
) -> dict[str, str]:
|
|
847
|
+
env_file_path = env_file.expanduser()
|
|
848
|
+
if not env_file_path.exists():
|
|
849
|
+
if missing_ok:
|
|
850
|
+
return {}
|
|
851
|
+
raise ValueError(f"env file not found: {env_file_path}")
|
|
852
|
+
values: dict[str, str] = {}
|
|
853
|
+
for line_number, raw_line in enumerate(
|
|
854
|
+
env_file_path.read_text(encoding="utf-8").splitlines(),
|
|
855
|
+
start=1,
|
|
856
|
+
):
|
|
857
|
+
line = raw_line.strip()
|
|
858
|
+
if not line or line.startswith("#"):
|
|
859
|
+
continue
|
|
860
|
+
if line.startswith("export "):
|
|
861
|
+
line = line.removeprefix("export ").lstrip()
|
|
862
|
+
if "=" not in line:
|
|
863
|
+
raise ValueError(
|
|
864
|
+
f"invalid env file line {line_number} in {env_file_path}: "
|
|
865
|
+
"expected KEY=VALUE"
|
|
866
|
+
)
|
|
867
|
+
key, raw_value = line.split("=", 1)
|
|
868
|
+
key = key.strip()
|
|
869
|
+
if not ENV_NAME_PATTERN.fullmatch(key):
|
|
870
|
+
raise ValueError(
|
|
871
|
+
f"invalid env variable name on line {line_number} in "
|
|
872
|
+
f"{env_file_path}: {key}"
|
|
873
|
+
)
|
|
874
|
+
values[key] = _strip_env_comment(raw_value.strip()).strip()
|
|
875
|
+
return values
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _write_text_with_mode(path: Path, content: str, mode: int) -> None:
|
|
879
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
880
|
+
file_descriptor, temporary_name = mkstemp(
|
|
881
|
+
prefix=f".{path.name}.",
|
|
882
|
+
dir=path.parent,
|
|
883
|
+
)
|
|
884
|
+
temporary_path = Path(temporary_name)
|
|
885
|
+
try:
|
|
886
|
+
os.fchmod(file_descriptor, mode)
|
|
887
|
+
with os.fdopen(file_descriptor, "w", encoding="utf-8") as handle:
|
|
888
|
+
file_descriptor = -1
|
|
889
|
+
handle.write(content)
|
|
890
|
+
os.replace(temporary_path, path)
|
|
891
|
+
path.chmod(mode)
|
|
892
|
+
except BaseException:
|
|
893
|
+
if file_descriptor != -1:
|
|
894
|
+
os.close(file_descriptor)
|
|
895
|
+
try:
|
|
896
|
+
temporary_path.unlink()
|
|
897
|
+
except FileNotFoundError:
|
|
898
|
+
pass
|
|
899
|
+
raise
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def load_env_file(env_file: str | Path) -> None:
|
|
903
|
+
env_file_path = Path(env_file).expanduser()
|
|
904
|
+
for key, raw_value in _read_env_file_values(env_file_path).items():
|
|
905
|
+
os.environ.setdefault(key, _parse_env_value(raw_value))
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _configured_env_file(
|
|
909
|
+
*,
|
|
910
|
+
config_dir: Path,
|
|
911
|
+
config_name: str,
|
|
912
|
+
) -> Path | None:
|
|
913
|
+
config_file = config_dir / f"{config_name}.yaml"
|
|
914
|
+
if not config_file.exists():
|
|
915
|
+
return None
|
|
916
|
+
env_file = OmegaConf.select(OmegaConf.load(config_file), ENV_FILE_CONFIG_KEY)
|
|
917
|
+
if env_file in (None, ""):
|
|
918
|
+
return None
|
|
919
|
+
if not isinstance(env_file, str):
|
|
920
|
+
raise ValueError(f"{ENV_FILE_CONFIG_KEY} must be a string path")
|
|
921
|
+
env_file_path = Path(env_file).expanduser()
|
|
922
|
+
if env_file_path.is_absolute():
|
|
923
|
+
return env_file_path
|
|
924
|
+
return config_dir / env_file_path
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _configure_default_env_file(
|
|
928
|
+
*,
|
|
929
|
+
config_dir: Path,
|
|
930
|
+
config_name: str,
|
|
931
|
+
) -> Path:
|
|
932
|
+
config_file = config_dir / f"{config_name}.yaml"
|
|
933
|
+
if not config_file.exists():
|
|
934
|
+
raise ValueError(f"main config not found: {config_file}")
|
|
935
|
+
lines = config_file.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
936
|
+
env_line = f" env_file: {DEFAULT_ENV_FILE_NAME}\n"
|
|
937
|
+
for index, line in enumerate(lines):
|
|
938
|
+
if line.strip() == "arbiter:":
|
|
939
|
+
lines[index + 1 : index + 1] = [env_line]
|
|
940
|
+
_write_text_with_mode(config_file, "".join(lines), CONFIG_FILE_MODE)
|
|
941
|
+
return config_dir / DEFAULT_ENV_FILE_NAME
|
|
942
|
+
if lines and not lines[-1].endswith("\n"):
|
|
943
|
+
lines[-1] = f"{lines[-1]}\n"
|
|
944
|
+
if lines and lines[-1].strip():
|
|
945
|
+
lines.append("\n")
|
|
946
|
+
lines.extend(["arbiter:\n", env_line])
|
|
947
|
+
_write_text_with_mode(config_file, "".join(lines), CONFIG_FILE_MODE)
|
|
948
|
+
return config_dir / DEFAULT_ENV_FILE_NAME
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def compose_config(
|
|
952
|
+
*,
|
|
953
|
+
config_dir: str | Path,
|
|
954
|
+
config_name: str,
|
|
955
|
+
overrides: Sequence[str] = (),
|
|
956
|
+
enforce_runtime_permissions: bool = False,
|
|
957
|
+
) -> DictConfig:
|
|
958
|
+
config_dir_path = Path(config_dir).expanduser().resolve()
|
|
959
|
+
env_file = _configured_env_file(
|
|
960
|
+
config_dir=config_dir_path,
|
|
961
|
+
config_name=config_name,
|
|
962
|
+
)
|
|
963
|
+
if enforce_runtime_permissions:
|
|
964
|
+
ensure_runtime_config_permissions(
|
|
965
|
+
config_dir=config_dir_path,
|
|
966
|
+
env_file=env_file,
|
|
967
|
+
)
|
|
968
|
+
if env_file is not None:
|
|
969
|
+
load_env_file(env_file)
|
|
970
|
+
register_configs()
|
|
971
|
+
with initialize_config_dir(
|
|
972
|
+
version_base=None,
|
|
973
|
+
config_dir=str(config_dir_path),
|
|
974
|
+
job_name="arbiter-server",
|
|
975
|
+
):
|
|
976
|
+
return compose(
|
|
977
|
+
config_name=config_name,
|
|
978
|
+
overrides=list(_strip_arg_separator(overrides)),
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _env_block_for_path(path: Sequence[str]) -> str:
|
|
983
|
+
if (
|
|
984
|
+
len(path) >= 3
|
|
985
|
+
and path[0] == "arbiter"
|
|
986
|
+
and path[1]
|
|
987
|
+
in {
|
|
988
|
+
"account",
|
|
989
|
+
"policy",
|
|
990
|
+
}
|
|
991
|
+
):
|
|
992
|
+
return f"arbiter-{path[2]}"
|
|
993
|
+
return MISC_ENV_BLOCK
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _collect_env_references_from_value(
|
|
997
|
+
value: object,
|
|
998
|
+
*,
|
|
999
|
+
path: Sequence[str],
|
|
1000
|
+
references: dict[str, EnvReference],
|
|
1001
|
+
) -> None:
|
|
1002
|
+
if isinstance(value, Mapping):
|
|
1003
|
+
for key, nested_value in value.items():
|
|
1004
|
+
_collect_env_references_from_value(
|
|
1005
|
+
nested_value,
|
|
1006
|
+
path=[*path, str(key)],
|
|
1007
|
+
references=references,
|
|
1008
|
+
)
|
|
1009
|
+
return
|
|
1010
|
+
if isinstance(value, list):
|
|
1011
|
+
for index, nested_value in enumerate(value):
|
|
1012
|
+
_collect_env_references_from_value(
|
|
1013
|
+
nested_value,
|
|
1014
|
+
path=[*path, str(index)],
|
|
1015
|
+
references=references,
|
|
1016
|
+
)
|
|
1017
|
+
return
|
|
1018
|
+
if not isinstance(value, str):
|
|
1019
|
+
return
|
|
1020
|
+
for match in ENV_REFERENCE_PATTERN.finditer(value):
|
|
1021
|
+
name = match.group("name")
|
|
1022
|
+
if not ENV_NAME_PATTERN.fullmatch(name):
|
|
1023
|
+
raise ValueError(f"invalid env variable reference: {name}")
|
|
1024
|
+
block = _env_block_for_path(path)
|
|
1025
|
+
existing = references.get(name)
|
|
1026
|
+
if existing is None or existing.block == MISC_ENV_BLOCK:
|
|
1027
|
+
references[name] = EnvReference(name=name, block=block)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def collect_env_references(cfg: DictConfig) -> dict[str, EnvReference]:
|
|
1031
|
+
container = OmegaConf.to_container(cfg, resolve=False)
|
|
1032
|
+
references: dict[str, EnvReference] = {}
|
|
1033
|
+
_collect_env_references_from_value(
|
|
1034
|
+
container,
|
|
1035
|
+
path=[],
|
|
1036
|
+
references=references,
|
|
1037
|
+
)
|
|
1038
|
+
return references
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _compose_config_for_env_command(
|
|
1042
|
+
*,
|
|
1043
|
+
config_dir: str,
|
|
1044
|
+
config_name: str,
|
|
1045
|
+
overrides: Sequence[str],
|
|
1046
|
+
) -> tuple[Path, Path | None, DictConfig, dict[str, EnvReference]]:
|
|
1047
|
+
config_dir_path = Path(config_dir).expanduser().resolve()
|
|
1048
|
+
env_file = _configured_env_file(
|
|
1049
|
+
config_dir=config_dir_path,
|
|
1050
|
+
config_name=config_name,
|
|
1051
|
+
)
|
|
1052
|
+
register_configs()
|
|
1053
|
+
with initialize_config_dir(
|
|
1054
|
+
version_base=None,
|
|
1055
|
+
config_dir=str(config_dir_path),
|
|
1056
|
+
job_name="arbiter-server-env",
|
|
1057
|
+
):
|
|
1058
|
+
cfg = compose(
|
|
1059
|
+
config_name=config_name,
|
|
1060
|
+
overrides=list(_strip_arg_separator(overrides)),
|
|
1061
|
+
)
|
|
1062
|
+
return config_dir_path, env_file, cfg, collect_env_references(cfg)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _run_env_check(
|
|
1066
|
+
*,
|
|
1067
|
+
config_dir: str,
|
|
1068
|
+
config_name: str,
|
|
1069
|
+
overrides: Sequence[str],
|
|
1070
|
+
) -> int:
|
|
1071
|
+
try:
|
|
1072
|
+
_config_dir_path, env_file, _cfg, references = _compose_config_for_env_command(
|
|
1073
|
+
config_dir=config_dir,
|
|
1074
|
+
config_name=config_name,
|
|
1075
|
+
overrides=overrides,
|
|
1076
|
+
)
|
|
1077
|
+
env_file_values: dict[str, str] = {}
|
|
1078
|
+
if env_file is not None:
|
|
1079
|
+
env_file_values = _read_env_file_values(env_file)
|
|
1080
|
+
satisfied = set(env_file_values) | set(os.environ)
|
|
1081
|
+
missing = [
|
|
1082
|
+
reference
|
|
1083
|
+
for reference in references.values()
|
|
1084
|
+
if reference.name not in satisfied
|
|
1085
|
+
]
|
|
1086
|
+
except ValueError as exc:
|
|
1087
|
+
print_cli_error(str(exc), area="env")
|
|
1088
|
+
return 1
|
|
1089
|
+
if missing:
|
|
1090
|
+
print_cli_error(
|
|
1091
|
+
"missing required environment variables:",
|
|
1092
|
+
area="env",
|
|
1093
|
+
details=[
|
|
1094
|
+
f"{reference.name} ({reference.block})"
|
|
1095
|
+
for reference in sorted(
|
|
1096
|
+
missing, key=lambda item: (item.block, item.name)
|
|
1097
|
+
)
|
|
1098
|
+
],
|
|
1099
|
+
)
|
|
1100
|
+
return 1
|
|
1101
|
+
print(f"env ok: {len(references)} variables satisfied")
|
|
1102
|
+
return 0
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _format_env_file_blocks(block_values: Mapping[str, Mapping[str, str]]) -> str:
|
|
1106
|
+
lines: list[str] = []
|
|
1107
|
+
block_names = sorted(
|
|
1108
|
+
block_name for block_name, values in block_values.items() if values
|
|
1109
|
+
)
|
|
1110
|
+
if MISC_ENV_BLOCK in block_names:
|
|
1111
|
+
block_names = [
|
|
1112
|
+
block_name for block_name in block_names if block_name != MISC_ENV_BLOCK
|
|
1113
|
+
]
|
|
1114
|
+
block_names.append(MISC_ENV_BLOCK)
|
|
1115
|
+
for block_index, block_name in enumerate(block_names):
|
|
1116
|
+
if block_index:
|
|
1117
|
+
lines.append("")
|
|
1118
|
+
lines.append(f"# {block_name}")
|
|
1119
|
+
for name, value in block_values[block_name].items():
|
|
1120
|
+
lines.append(f"{name}={value}")
|
|
1121
|
+
return "\n".join(lines) + ("\n" if lines else "")
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _run_env_bootstrap(
|
|
1125
|
+
*,
|
|
1126
|
+
config_dir: str,
|
|
1127
|
+
config_name: str,
|
|
1128
|
+
overrides: Sequence[str],
|
|
1129
|
+
) -> int:
|
|
1130
|
+
try:
|
|
1131
|
+
_config_dir_path, env_file, _cfg, references = _compose_config_for_env_command(
|
|
1132
|
+
config_dir=config_dir,
|
|
1133
|
+
config_name=config_name,
|
|
1134
|
+
overrides=overrides,
|
|
1135
|
+
)
|
|
1136
|
+
if env_file is None:
|
|
1137
|
+
env_file = _configure_default_env_file(
|
|
1138
|
+
config_dir=Path(config_dir).expanduser().resolve(),
|
|
1139
|
+
config_name=config_name,
|
|
1140
|
+
)
|
|
1141
|
+
existing_values = _read_env_file_values(env_file, missing_ok=True)
|
|
1142
|
+
except ValueError as exc:
|
|
1143
|
+
print_cli_error(str(exc), area="env")
|
|
1144
|
+
return 1
|
|
1145
|
+
|
|
1146
|
+
block_values: dict[str, dict[str, str]] = {}
|
|
1147
|
+
for name, value in existing_values.items():
|
|
1148
|
+
reference = references.get(name)
|
|
1149
|
+
block = reference.block if reference is not None else MISC_ENV_BLOCK
|
|
1150
|
+
block_values.setdefault(block, {})[name] = value
|
|
1151
|
+
|
|
1152
|
+
satisfied = set(existing_values) | set(os.environ)
|
|
1153
|
+
for reference in references.values():
|
|
1154
|
+
if reference.name not in satisfied:
|
|
1155
|
+
block_values.setdefault(reference.block, {})[reference.name] = ""
|
|
1156
|
+
|
|
1157
|
+
content = _format_env_file_blocks(block_values)
|
|
1158
|
+
if env_file.exists() and env_file.read_text(encoding="utf-8") == content:
|
|
1159
|
+
env_file.chmod(ENV_FILE_MODE)
|
|
1160
|
+
print(f"env file already up to date: {env_file}")
|
|
1161
|
+
return 0
|
|
1162
|
+
env_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1163
|
+
_write_text_with_mode(env_file, content, ENV_FILE_MODE)
|
|
1164
|
+
print(f"wrote {env_file}")
|
|
1165
|
+
return 0
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _deploy_template_text(name: str) -> str:
|
|
1169
|
+
return (
|
|
1170
|
+
files("arbiter_server")
|
|
1171
|
+
.joinpath("deploy")
|
|
1172
|
+
.joinpath("docker")
|
|
1173
|
+
.joinpath(name)
|
|
1174
|
+
.read_text(encoding="utf-8")
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _entry_point_distribution_name(entry_point: Any) -> str | None:
|
|
1179
|
+
distribution = getattr(entry_point, "dist", None)
|
|
1180
|
+
metadata = getattr(distribution, "metadata", None)
|
|
1181
|
+
if metadata is not None:
|
|
1182
|
+
name = metadata.get("Name")
|
|
1183
|
+
if isinstance(name, str) and name:
|
|
1184
|
+
return name
|
|
1185
|
+
name = getattr(distribution, "name", None)
|
|
1186
|
+
if isinstance(name, str) and name:
|
|
1187
|
+
return name
|
|
1188
|
+
return None
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _normalized_distribution_name(name: str) -> str:
|
|
1192
|
+
return name.lower().replace("_", "-")
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _distribution_direct_url_source_root(installed_distribution: Any) -> Path | None:
|
|
1196
|
+
for distribution_file in installed_distribution.files or ():
|
|
1197
|
+
parts = distribution_file.parts
|
|
1198
|
+
if (
|
|
1199
|
+
len(parts) < 2
|
|
1200
|
+
or parts[-1] != "direct_url.json"
|
|
1201
|
+
or not parts[-2].endswith(".dist-info")
|
|
1202
|
+
):
|
|
1203
|
+
continue
|
|
1204
|
+
direct_url_path = Path(installed_distribution.locate_file(distribution_file))
|
|
1205
|
+
try:
|
|
1206
|
+
direct_url = json.loads(direct_url_path.read_text(encoding="utf-8"))
|
|
1207
|
+
except (OSError, json.JSONDecodeError):
|
|
1208
|
+
return None
|
|
1209
|
+
dir_info = direct_url.get("dir_info")
|
|
1210
|
+
if not isinstance(dir_info, dict):
|
|
1211
|
+
return None
|
|
1212
|
+
if not dir_info.get("editable"):
|
|
1213
|
+
return None
|
|
1214
|
+
url = direct_url.get("url")
|
|
1215
|
+
if not isinstance(url, str):
|
|
1216
|
+
return None
|
|
1217
|
+
parsed_url = urlparse(url)
|
|
1218
|
+
if parsed_url.scheme != "file":
|
|
1219
|
+
return None
|
|
1220
|
+
source_root = Path(url2pathname(parsed_url.path))
|
|
1221
|
+
if source_root.is_dir() and (source_root / "pyproject.toml").is_file():
|
|
1222
|
+
return source_root
|
|
1223
|
+
return None
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _build_local_source_wheel(source_root: Path, wheel_dir: Path) -> Path | None:
|
|
1227
|
+
if not _ensure_writable_wheel_dir(wheel_dir):
|
|
1228
|
+
return None
|
|
1229
|
+
with TemporaryDirectory(prefix="arbiter-wheel-") as temporary_wheel_dir_raw:
|
|
1230
|
+
temporary_wheel_dir = Path(temporary_wheel_dir_raw)
|
|
1231
|
+
result = subprocess.run(
|
|
1232
|
+
[
|
|
1233
|
+
sys.executable,
|
|
1234
|
+
"-m",
|
|
1235
|
+
"pip",
|
|
1236
|
+
"wheel",
|
|
1237
|
+
"--no-deps",
|
|
1238
|
+
"--no-build-isolation",
|
|
1239
|
+
"--wheel-dir",
|
|
1240
|
+
str(temporary_wheel_dir),
|
|
1241
|
+
str(source_root),
|
|
1242
|
+
],
|
|
1243
|
+
check=False,
|
|
1244
|
+
text=True,
|
|
1245
|
+
capture_output=True,
|
|
1246
|
+
)
|
|
1247
|
+
if result.returncode != 0:
|
|
1248
|
+
details = [f"source: {source_root}"]
|
|
1249
|
+
if result.stderr:
|
|
1250
|
+
details.extend(result.stderr.strip().splitlines()[-5:])
|
|
1251
|
+
print_cli_error(
|
|
1252
|
+
"cannot build local docker wheel", area="deploy", details=details
|
|
1253
|
+
)
|
|
1254
|
+
return None
|
|
1255
|
+
built_wheels = sorted(temporary_wheel_dir.glob("*.whl"))
|
|
1256
|
+
if len(built_wheels) != 1:
|
|
1257
|
+
print_cli_error(
|
|
1258
|
+
"cannot identify built local docker wheel",
|
|
1259
|
+
area="deploy",
|
|
1260
|
+
details=[
|
|
1261
|
+
f"source: {source_root}",
|
|
1262
|
+
f"wheel count: {len(built_wheels)}",
|
|
1263
|
+
],
|
|
1264
|
+
)
|
|
1265
|
+
return None
|
|
1266
|
+
wheel = built_wheels[0]
|
|
1267
|
+
destination = wheel_dir / wheel.name
|
|
1268
|
+
try:
|
|
1269
|
+
if destination.exists():
|
|
1270
|
+
destination.unlink()
|
|
1271
|
+
shutil.copy2(wheel, destination)
|
|
1272
|
+
except OSError as exc:
|
|
1273
|
+
print_cli_error(
|
|
1274
|
+
"cannot write local docker wheel",
|
|
1275
|
+
area="deploy",
|
|
1276
|
+
details=[
|
|
1277
|
+
f"source: {source_root}",
|
|
1278
|
+
f"wheel: {destination}",
|
|
1279
|
+
f"error: {exc}",
|
|
1280
|
+
],
|
|
1281
|
+
)
|
|
1282
|
+
return None
|
|
1283
|
+
return destination
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _ensure_writable_wheel_dir(wheel_dir: Path) -> bool:
|
|
1287
|
+
try:
|
|
1288
|
+
wheel_dir.mkdir(parents=True, exist_ok=True)
|
|
1289
|
+
write_check = wheel_dir / ".arbiter-write-check"
|
|
1290
|
+
write_check.write_text("", encoding="utf-8")
|
|
1291
|
+
write_check.unlink()
|
|
1292
|
+
except OSError as exc:
|
|
1293
|
+
print_cli_error(
|
|
1294
|
+
"deployment wheelhouse is not writable",
|
|
1295
|
+
area="deploy",
|
|
1296
|
+
details=[
|
|
1297
|
+
f"wheel dir: {wheel_dir}",
|
|
1298
|
+
f"error: {exc}",
|
|
1299
|
+
"remove or chown the wheelhouse directory, then retry",
|
|
1300
|
+
],
|
|
1301
|
+
)
|
|
1302
|
+
return False
|
|
1303
|
+
return True
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _ensure_writable_plugin_data_dir(plugin_data_dir: Path) -> bool:
|
|
1307
|
+
try:
|
|
1308
|
+
plugin_data_dir.mkdir(parents=True, exist_ok=True)
|
|
1309
|
+
if os.name != "nt":
|
|
1310
|
+
plugin_data_dir.chmod(0o700)
|
|
1311
|
+
write_check = plugin_data_dir / ".arbiter-write-check"
|
|
1312
|
+
write_check.write_text("", encoding="utf-8")
|
|
1313
|
+
write_check.unlink()
|
|
1314
|
+
except OSError as exc:
|
|
1315
|
+
print_cli_error(
|
|
1316
|
+
"deployment plugin data directory is not writable",
|
|
1317
|
+
area="deploy",
|
|
1318
|
+
details=[
|
|
1319
|
+
f"plugin data dir: {plugin_data_dir}",
|
|
1320
|
+
f"error: {exc}",
|
|
1321
|
+
"remove or chown the plugin data directory, then retry",
|
|
1322
|
+
],
|
|
1323
|
+
)
|
|
1324
|
+
return False
|
|
1325
|
+
return True
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _docker_requirement_for_installed_distribution(
|
|
1329
|
+
*,
|
|
1330
|
+
distribution_name: str,
|
|
1331
|
+
version: str,
|
|
1332
|
+
installed_distribution: Any | None,
|
|
1333
|
+
wheel_dir: Path | None,
|
|
1334
|
+
) -> str | None:
|
|
1335
|
+
if installed_distribution is not None and wheel_dir is not None:
|
|
1336
|
+
source_root = _distribution_direct_url_source_root(installed_distribution)
|
|
1337
|
+
if source_root is not None:
|
|
1338
|
+
wheel = _build_local_source_wheel(source_root, wheel_dir)
|
|
1339
|
+
if wheel is None:
|
|
1340
|
+
return None
|
|
1341
|
+
if version == "unknown":
|
|
1342
|
+
return None
|
|
1343
|
+
return f"{distribution_name}=={version}"
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def _installed_python_deploy_requirements(
|
|
1347
|
+
*, wheel_dir: Path | None = None
|
|
1348
|
+
) -> DockerDeployRequirements | None:
|
|
1349
|
+
server_version = arbiter_server_version()
|
|
1350
|
+
try:
|
|
1351
|
+
server_distribution = distribution(ARBITER_SERVER_PACKAGE)
|
|
1352
|
+
except PackageNotFoundError:
|
|
1353
|
+
server_distribution = None
|
|
1354
|
+
server_requirement = _docker_requirement_for_installed_distribution(
|
|
1355
|
+
distribution_name=ARBITER_SERVER_PACKAGE,
|
|
1356
|
+
version=server_version,
|
|
1357
|
+
installed_distribution=server_distribution,
|
|
1358
|
+
wheel_dir=wheel_dir,
|
|
1359
|
+
)
|
|
1360
|
+
if server_requirement is None:
|
|
1361
|
+
return None
|
|
1362
|
+
|
|
1363
|
+
plugin_pins: dict[str, tuple[str, str]] = {}
|
|
1364
|
+
for entry_point in entry_points().select(group=SERVICE_PLUGIN_ENTRY_POINT_GROUP):
|
|
1365
|
+
try:
|
|
1366
|
+
plugin_factory = cast(ServicePluginFactory, entry_point.load())
|
|
1367
|
+
except ModuleNotFoundError as exc:
|
|
1368
|
+
LOGGER.warning(
|
|
1369
|
+
"Skipping unavailable service plugin entry point %s=%s: %s",
|
|
1370
|
+
entry_point.name,
|
|
1371
|
+
entry_point.value,
|
|
1372
|
+
exc,
|
|
1373
|
+
)
|
|
1374
|
+
continue
|
|
1375
|
+
service_plugin = plugin_factory()
|
|
1376
|
+
validate_service_plugin_compatibility(service_plugin)
|
|
1377
|
+
plugin_info = service_plugin_runtime_info(service_plugin)
|
|
1378
|
+
if plugin_info.version == "unknown":
|
|
1379
|
+
return None
|
|
1380
|
+
distribution_name = _entry_point_distribution_name(entry_point)
|
|
1381
|
+
if distribution_name is None:
|
|
1382
|
+
return None
|
|
1383
|
+
requirement = _docker_requirement_for_installed_distribution(
|
|
1384
|
+
distribution_name=distribution_name,
|
|
1385
|
+
version=plugin_info.version,
|
|
1386
|
+
installed_distribution=getattr(entry_point, "dist", None),
|
|
1387
|
+
wheel_dir=wheel_dir,
|
|
1388
|
+
)
|
|
1389
|
+
if requirement is None:
|
|
1390
|
+
return None
|
|
1391
|
+
plugin_pins[_normalized_distribution_name(distribution_name)] = (
|
|
1392
|
+
distribution_name,
|
|
1393
|
+
requirement,
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
return DockerDeployRequirements(
|
|
1397
|
+
requirements=(
|
|
1398
|
+
server_requirement,
|
|
1399
|
+
*(
|
|
1400
|
+
requirement
|
|
1401
|
+
for _normalized_name, (_name, requirement) in sorted(
|
|
1402
|
+
plugin_pins.items()
|
|
1403
|
+
)
|
|
1404
|
+
),
|
|
1405
|
+
)
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def _default_deploy_requirements(
|
|
1410
|
+
*, wheel_dir: Path | None
|
|
1411
|
+
) -> DockerDeployRequirements | None:
|
|
1412
|
+
return _installed_python_deploy_requirements(wheel_dir=wheel_dir)
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _format_deploy_requirements(requirements: Sequence[str]) -> str:
|
|
1416
|
+
return "\n".join(requirements) + "\n"
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _deploy_requirement_error(requirement: str) -> str | None:
|
|
1420
|
+
if not requirement:
|
|
1421
|
+
return "docker.requirement must not be empty"
|
|
1422
|
+
if requirement.startswith("/"):
|
|
1423
|
+
return None
|
|
1424
|
+
if DEPLOY_PINNED_REQUIREMENT_PATTERN.fullmatch(requirement):
|
|
1425
|
+
return None
|
|
1426
|
+
return (
|
|
1427
|
+
"docker.requirement must be an exact package pin "
|
|
1428
|
+
"(name==version) or an absolute container path"
|
|
1429
|
+
)
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def _pinned_requirement_parts(requirement: str) -> tuple[str, str] | None:
|
|
1433
|
+
match = DEPLOY_PINNED_REQUIREMENT_PARTS_PATTERN.fullmatch(requirement)
|
|
1434
|
+
if match is None:
|
|
1435
|
+
return None
|
|
1436
|
+
return match.group("name"), match.group("version")
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _deploy_requirements_semantic_error(requirements: Sequence[str]) -> str | None:
|
|
1440
|
+
pins: dict[str, str] = {}
|
|
1441
|
+
for requirement in requirements:
|
|
1442
|
+
parts = _pinned_requirement_parts(requirement)
|
|
1443
|
+
if parts is None:
|
|
1444
|
+
continue
|
|
1445
|
+
name, version = parts
|
|
1446
|
+
existing_version = pins.get(name)
|
|
1447
|
+
if existing_version is not None and existing_version != version:
|
|
1448
|
+
return (
|
|
1449
|
+
f"conflicting docker.requirement pins for {name}: "
|
|
1450
|
+
f"{existing_version}, {version}"
|
|
1451
|
+
)
|
|
1452
|
+
pins[name] = version
|
|
1453
|
+
return None
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def _expand_meta_deploy_requirements(requirements: Sequence[str]) -> tuple[str, ...]:
|
|
1457
|
+
pins = {
|
|
1458
|
+
name: version
|
|
1459
|
+
for requirement in requirements
|
|
1460
|
+
if (parts := _pinned_requirement_parts(requirement)) is not None
|
|
1461
|
+
for name, version in (parts,)
|
|
1462
|
+
}
|
|
1463
|
+
expanded_meta_packages = {
|
|
1464
|
+
meta_package
|
|
1465
|
+
for meta_package, package_names in DOCKER_META_PACKAGE_GROUPS.items()
|
|
1466
|
+
if meta_package in pins and any(name in pins for name in package_names)
|
|
1467
|
+
}
|
|
1468
|
+
expanded_package_names = {
|
|
1469
|
+
package_name
|
|
1470
|
+
for meta_package in expanded_meta_packages
|
|
1471
|
+
for package_name in DOCKER_META_PACKAGE_GROUPS[meta_package]
|
|
1472
|
+
}
|
|
1473
|
+
if not expanded_meta_packages:
|
|
1474
|
+
return tuple(requirements)
|
|
1475
|
+
|
|
1476
|
+
expanded_requirements: list[str] = []
|
|
1477
|
+
for requirement in requirements:
|
|
1478
|
+
parts = _pinned_requirement_parts(requirement)
|
|
1479
|
+
if parts is None:
|
|
1480
|
+
expanded_requirements.append(requirement)
|
|
1481
|
+
continue
|
|
1482
|
+
name, version = parts
|
|
1483
|
+
if name in expanded_meta_packages:
|
|
1484
|
+
for package_name in DOCKER_META_PACKAGE_GROUPS[name]:
|
|
1485
|
+
expanded_requirements.append(
|
|
1486
|
+
f"{package_name}=={pins.get(package_name, version)}"
|
|
1487
|
+
)
|
|
1488
|
+
continue
|
|
1489
|
+
if name in expanded_package_names:
|
|
1490
|
+
continue
|
|
1491
|
+
expanded_requirements.append(requirement)
|
|
1492
|
+
return tuple(expanded_requirements)
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _parse_docker_deploy_args(args: Sequence[str]) -> DockerDeployArgs | None:
|
|
1496
|
+
action: str | None = None
|
|
1497
|
+
directory = Path(DEFAULT_DOCKER_DEPLOY_DIR)
|
|
1498
|
+
requirements: list[str] = []
|
|
1499
|
+
force = False
|
|
1500
|
+
|
|
1501
|
+
for arg in _strip_arg_separator(args):
|
|
1502
|
+
if arg == "--force":
|
|
1503
|
+
force = True
|
|
1504
|
+
continue
|
|
1505
|
+
if arg in {"init", "update"}:
|
|
1506
|
+
if action is not None:
|
|
1507
|
+
print_cli_error(
|
|
1508
|
+
f"multiple deploy actions provided: {action}, {arg}",
|
|
1509
|
+
area="deploy",
|
|
1510
|
+
)
|
|
1511
|
+
return None
|
|
1512
|
+
action = arg
|
|
1513
|
+
continue
|
|
1514
|
+
if "=" not in arg:
|
|
1515
|
+
print_cli_error(
|
|
1516
|
+
f"unknown docker deploy argument: {arg}",
|
|
1517
|
+
area="deploy",
|
|
1518
|
+
details=[
|
|
1519
|
+
"expected init, update, --force, docker.dir=PATH, or "
|
|
1520
|
+
"docker.requirement=REQUIREMENT"
|
|
1521
|
+
],
|
|
1522
|
+
)
|
|
1523
|
+
return None
|
|
1524
|
+
key, value = arg.split("=", 1)
|
|
1525
|
+
if key == "docker.dir":
|
|
1526
|
+
directory = Path(value)
|
|
1527
|
+
continue
|
|
1528
|
+
if key == "docker.requirement":
|
|
1529
|
+
requirements.append(value)
|
|
1530
|
+
continue
|
|
1531
|
+
print_cli_error(f"unknown docker deploy override: {key}", area="deploy")
|
|
1532
|
+
return None
|
|
1533
|
+
|
|
1534
|
+
if action is None:
|
|
1535
|
+
print_cli_error(
|
|
1536
|
+
"docker deploy requires an action: init or update",
|
|
1537
|
+
area="deploy",
|
|
1538
|
+
)
|
|
1539
|
+
return None
|
|
1540
|
+
if force and action != "update":
|
|
1541
|
+
print_cli_error(
|
|
1542
|
+
"--force is only supported with docker deploy update",
|
|
1543
|
+
area="deploy",
|
|
1544
|
+
)
|
|
1545
|
+
return None
|
|
1546
|
+
for requirement in requirements:
|
|
1547
|
+
error = _deploy_requirement_error(requirement)
|
|
1548
|
+
if error is not None:
|
|
1549
|
+
print_cli_error(error, area="deploy", details=[f"value: {requirement}"])
|
|
1550
|
+
return None
|
|
1551
|
+
semantic_error = _deploy_requirements_semantic_error(requirements)
|
|
1552
|
+
if semantic_error is not None:
|
|
1553
|
+
print_cli_error(semantic_error, area="deploy")
|
|
1554
|
+
return None
|
|
1555
|
+
return DockerDeployArgs(
|
|
1556
|
+
action=action,
|
|
1557
|
+
directory=directory.expanduser(),
|
|
1558
|
+
requirements=tuple(requirements),
|
|
1559
|
+
force=force,
|
|
1560
|
+
)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _resolve_docker_deploy_requirements(
|
|
1564
|
+
requirements: Sequence[str],
|
|
1565
|
+
*,
|
|
1566
|
+
wheel_dir: Path | None,
|
|
1567
|
+
) -> DockerDeployRequirements | None:
|
|
1568
|
+
if requirements:
|
|
1569
|
+
return DockerDeployRequirements(
|
|
1570
|
+
requirements=_expand_meta_deploy_requirements(requirements)
|
|
1571
|
+
)
|
|
1572
|
+
default_requirements = _default_deploy_requirements(wheel_dir=wheel_dir)
|
|
1573
|
+
if default_requirements is None:
|
|
1574
|
+
print_cli_error(
|
|
1575
|
+
"cannot infer default docker requirements",
|
|
1576
|
+
area="deploy",
|
|
1577
|
+
details=[
|
|
1578
|
+
"install Arbiter packages in the current Python environment so "
|
|
1579
|
+
"the generator can pin them",
|
|
1580
|
+
"or pass docker.requirement=arbiter-suite==VERSION for the "
|
|
1581
|
+
"all-in-one meta package",
|
|
1582
|
+
"or pass one or more docker.requirement=PACKAGE==VERSION "
|
|
1583
|
+
"entries for another meta package or explicit packages",
|
|
1584
|
+
"for local checkout testing, pass absolute container source paths",
|
|
1585
|
+
],
|
|
1586
|
+
)
|
|
1587
|
+
return None
|
|
1588
|
+
return default_requirements
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def _format_docker_compose_env_file(existing_values: Mapping[str, str]) -> str:
|
|
1592
|
+
lines = [
|
|
1593
|
+
"# Docker Compose settings for the Arbiter deployment.",
|
|
1594
|
+
"# These values control the container wrapper, not Arbiter runtime config.",
|
|
1595
|
+
"",
|
|
1596
|
+
]
|
|
1597
|
+
default_names = {name for name, _default in DOCKER_COMPOSE_ENV_DEFAULTS}
|
|
1598
|
+
for name, default in DOCKER_COMPOSE_ENV_DEFAULTS:
|
|
1599
|
+
lines.append(f"{name}={existing_values.get(name, default)}")
|
|
1600
|
+
extra_names = sorted(name for name in existing_values if name not in default_names)
|
|
1601
|
+
if extra_names:
|
|
1602
|
+
lines.extend(["", "# Extra local Compose values."])
|
|
1603
|
+
for name in extra_names:
|
|
1604
|
+
lines.append(f"{name}={existing_values[name]}")
|
|
1605
|
+
return "\n".join(lines) + "\n"
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def _write_deploy_file(path: Path, content: str, *, executable: bool = False) -> None:
|
|
1609
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1610
|
+
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
|
1611
|
+
handle.write(content)
|
|
1612
|
+
if executable:
|
|
1613
|
+
path.chmod(0o755)
|
|
1614
|
+
print(f"wrote {path}")
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def _sha256_bytes(content: bytes) -> str:
|
|
1618
|
+
return hashlib.sha256(content).hexdigest()
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def _sha256_file(path: Path) -> str:
|
|
1622
|
+
return _sha256_bytes(path.read_bytes())
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _deploy_managed_paths(deploy_dir: Path) -> dict[str, Path]:
|
|
1626
|
+
return {
|
|
1627
|
+
"compose": deploy_dir / "compose.yaml",
|
|
1628
|
+
"compose_override": deploy_dir / "compose.override.yaml",
|
|
1629
|
+
"docker_env": deploy_dir / "docker.env",
|
|
1630
|
+
"requirements": deploy_dir / "requirements.txt",
|
|
1631
|
+
"helper": deploy_dir / "arbiter-docker",
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
def _deploy_manifest_path(deploy_dir: Path) -> Path:
|
|
1636
|
+
return deploy_dir / DEPLOY_MANIFEST_FILE_NAME
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
def _load_deploy_manifest(deploy_dir: Path) -> dict[str, str]:
|
|
1640
|
+
manifest_path = _deploy_manifest_path(deploy_dir)
|
|
1641
|
+
if not manifest_path.exists():
|
|
1642
|
+
return {}
|
|
1643
|
+
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
1644
|
+
if not isinstance(data, dict):
|
|
1645
|
+
return {}
|
|
1646
|
+
raw_files = data.get("files", {})
|
|
1647
|
+
if not isinstance(raw_files, dict):
|
|
1648
|
+
return {}
|
|
1649
|
+
file_hashes: dict[str, str] = {}
|
|
1650
|
+
for relative_path, raw_entry in raw_files.items():
|
|
1651
|
+
if not isinstance(relative_path, str) or not isinstance(raw_entry, dict):
|
|
1652
|
+
continue
|
|
1653
|
+
sha256 = raw_entry.get("sha256")
|
|
1654
|
+
if isinstance(sha256, str):
|
|
1655
|
+
file_hashes[relative_path] = sha256
|
|
1656
|
+
return file_hashes
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def _write_deploy_manifest(
|
|
1660
|
+
deploy_dir: Path,
|
|
1661
|
+
*,
|
|
1662
|
+
file_hashes: Mapping[str, str],
|
|
1663
|
+
) -> None:
|
|
1664
|
+
manifest_path = _deploy_manifest_path(deploy_dir)
|
|
1665
|
+
manifest = {
|
|
1666
|
+
"schema_version": 1,
|
|
1667
|
+
"generator": "arbiter-server deploy docker",
|
|
1668
|
+
"arbiter_server_version": arbiter_server_version(),
|
|
1669
|
+
"files": {
|
|
1670
|
+
relative_path: {
|
|
1671
|
+
"kind": "template",
|
|
1672
|
+
"sha256": file_hashes[relative_path],
|
|
1673
|
+
}
|
|
1674
|
+
for relative_path in sorted(file_hashes)
|
|
1675
|
+
},
|
|
1676
|
+
}
|
|
1677
|
+
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
1678
|
+
print(f"wrote {manifest_path}")
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
def _write_manifest_owned_deploy_file(
|
|
1682
|
+
*,
|
|
1683
|
+
path: Path,
|
|
1684
|
+
relative_path: str,
|
|
1685
|
+
content: str,
|
|
1686
|
+
executable: bool,
|
|
1687
|
+
manifest_hashes: dict[str, str],
|
|
1688
|
+
) -> None:
|
|
1689
|
+
_write_deploy_file(path, content, executable=executable)
|
|
1690
|
+
manifest_hashes[relative_path] = _sha256_file(path)
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
def _deploy_requirement_names(requirements: Sequence[str]) -> set[str] | None:
|
|
1694
|
+
names: set[str] = set()
|
|
1695
|
+
for requirement in requirements:
|
|
1696
|
+
parts = _pinned_requirement_parts(requirement)
|
|
1697
|
+
if parts is None:
|
|
1698
|
+
return None
|
|
1699
|
+
name, _version = parts
|
|
1700
|
+
names.add(_normalized_distribution_name(name))
|
|
1701
|
+
return names
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def _read_deploy_requirements(path: Path) -> tuple[str, ...]:
|
|
1705
|
+
requirements: list[str] = []
|
|
1706
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
1707
|
+
requirement = line.strip()
|
|
1708
|
+
if not requirement or requirement.startswith("#"):
|
|
1709
|
+
continue
|
|
1710
|
+
requirements.append(requirement.split(" #", 1)[0].strip())
|
|
1711
|
+
return tuple(requirements)
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def _ensure_deploy_file_mode(path: Path, *, executable: bool) -> bool:
|
|
1715
|
+
if not executable:
|
|
1716
|
+
return False
|
|
1717
|
+
if os.name == "nt":
|
|
1718
|
+
return False
|
|
1719
|
+
current_mode = path.stat().st_mode
|
|
1720
|
+
if current_mode & 0o111:
|
|
1721
|
+
return False
|
|
1722
|
+
path.chmod(0o755)
|
|
1723
|
+
return True
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def _update_manifest_owned_deploy_file(
|
|
1727
|
+
*,
|
|
1728
|
+
path: Path,
|
|
1729
|
+
relative_path: str,
|
|
1730
|
+
content: str,
|
|
1731
|
+
executable: bool,
|
|
1732
|
+
manifest_hashes: dict[str, str],
|
|
1733
|
+
force: bool,
|
|
1734
|
+
) -> Literal["updated", "up_to_date", "skipped"]:
|
|
1735
|
+
if not path.exists():
|
|
1736
|
+
_write_manifest_owned_deploy_file(
|
|
1737
|
+
path=path,
|
|
1738
|
+
relative_path=relative_path,
|
|
1739
|
+
content=content,
|
|
1740
|
+
executable=executable,
|
|
1741
|
+
manifest_hashes=manifest_hashes,
|
|
1742
|
+
)
|
|
1743
|
+
return "updated"
|
|
1744
|
+
|
|
1745
|
+
current_hash = _sha256_file(path)
|
|
1746
|
+
desired_hash = _sha256_bytes(content.encode("utf-8"))
|
|
1747
|
+
if current_hash == desired_hash:
|
|
1748
|
+
manifest_hashes[relative_path] = current_hash
|
|
1749
|
+
if _ensure_deploy_file_mode(path, executable=executable):
|
|
1750
|
+
return "updated"
|
|
1751
|
+
return "up_to_date"
|
|
1752
|
+
|
|
1753
|
+
previous_hash = manifest_hashes.get(relative_path)
|
|
1754
|
+
if previous_hash is None:
|
|
1755
|
+
if force:
|
|
1756
|
+
print(f"force updating managed file without manifest ownership: {path}")
|
|
1757
|
+
_write_manifest_owned_deploy_file(
|
|
1758
|
+
path=path,
|
|
1759
|
+
relative_path=relative_path,
|
|
1760
|
+
content=content,
|
|
1761
|
+
executable=executable,
|
|
1762
|
+
manifest_hashes=manifest_hashes,
|
|
1763
|
+
)
|
|
1764
|
+
return "updated"
|
|
1765
|
+
print(f"skipped managed file without manifest ownership: {path}")
|
|
1766
|
+
return "skipped"
|
|
1767
|
+
if current_hash != previous_hash:
|
|
1768
|
+
if force:
|
|
1769
|
+
print(f"force updating managed file with local edits: {path}")
|
|
1770
|
+
_write_manifest_owned_deploy_file(
|
|
1771
|
+
path=path,
|
|
1772
|
+
relative_path=relative_path,
|
|
1773
|
+
content=content,
|
|
1774
|
+
executable=executable,
|
|
1775
|
+
manifest_hashes=manifest_hashes,
|
|
1776
|
+
)
|
|
1777
|
+
return "updated"
|
|
1778
|
+
print(f"skipped managed file with local edits: {path}")
|
|
1779
|
+
return "skipped"
|
|
1780
|
+
|
|
1781
|
+
_write_manifest_owned_deploy_file(
|
|
1782
|
+
path=path,
|
|
1783
|
+
relative_path=relative_path,
|
|
1784
|
+
content=content,
|
|
1785
|
+
executable=executable,
|
|
1786
|
+
manifest_hashes=manifest_hashes,
|
|
1787
|
+
)
|
|
1788
|
+
return "updated"
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
def _run_deploy_docker(argv: Sequence[str]) -> int:
|
|
1792
|
+
parsed = _parse_docker_deploy_args(argv)
|
|
1793
|
+
if parsed is None:
|
|
1794
|
+
return 2
|
|
1795
|
+
|
|
1796
|
+
deploy_dir = parsed.directory
|
|
1797
|
+
paths = _deploy_managed_paths(deploy_dir)
|
|
1798
|
+
compose_text = _deploy_template_text("compose.yaml")
|
|
1799
|
+
helper_text = _deploy_template_text("arbiter-docker")
|
|
1800
|
+
|
|
1801
|
+
if parsed.action == "init":
|
|
1802
|
+
manifest_path = _deploy_manifest_path(deploy_dir)
|
|
1803
|
+
init_paths = [
|
|
1804
|
+
paths["compose"],
|
|
1805
|
+
paths["docker_env"],
|
|
1806
|
+
paths["requirements"],
|
|
1807
|
+
paths["helper"],
|
|
1808
|
+
manifest_path,
|
|
1809
|
+
]
|
|
1810
|
+
existing = [path for path in init_paths if path.exists()]
|
|
1811
|
+
if existing:
|
|
1812
|
+
print_cli_error(
|
|
1813
|
+
f"refusing to overwrite existing deployment file: {existing[0]}",
|
|
1814
|
+
area="deploy",
|
|
1815
|
+
details=["use update to refresh generated files"],
|
|
1816
|
+
)
|
|
1817
|
+
return 1
|
|
1818
|
+
requirement_resolution = _resolve_docker_deploy_requirements(
|
|
1819
|
+
parsed.requirements,
|
|
1820
|
+
wheel_dir=deploy_dir / "wheels",
|
|
1821
|
+
)
|
|
1822
|
+
if requirement_resolution is None:
|
|
1823
|
+
return 2
|
|
1824
|
+
if not _ensure_writable_wheel_dir(deploy_dir / "wheels"):
|
|
1825
|
+
return 1
|
|
1826
|
+
if not _ensure_writable_plugin_data_dir(deploy_dir / "data" / "plugins"):
|
|
1827
|
+
return 1
|
|
1828
|
+
manifest_hashes: dict[str, str] = {}
|
|
1829
|
+
_write_manifest_owned_deploy_file(
|
|
1830
|
+
path=paths["compose"],
|
|
1831
|
+
relative_path="compose.yaml",
|
|
1832
|
+
content=compose_text,
|
|
1833
|
+
executable=False,
|
|
1834
|
+
manifest_hashes=manifest_hashes,
|
|
1835
|
+
)
|
|
1836
|
+
_write_deploy_file(
|
|
1837
|
+
paths["docker_env"],
|
|
1838
|
+
_format_docker_compose_env_file(existing_values={}),
|
|
1839
|
+
)
|
|
1840
|
+
_write_deploy_file(
|
|
1841
|
+
paths["requirements"],
|
|
1842
|
+
_format_deploy_requirements(requirement_resolution.requirements),
|
|
1843
|
+
)
|
|
1844
|
+
_write_manifest_owned_deploy_file(
|
|
1845
|
+
path=paths["helper"],
|
|
1846
|
+
relative_path="arbiter-docker",
|
|
1847
|
+
content=helper_text,
|
|
1848
|
+
executable=True,
|
|
1849
|
+
manifest_hashes=manifest_hashes,
|
|
1850
|
+
)
|
|
1851
|
+
_write_deploy_manifest(deploy_dir, file_hashes=manifest_hashes)
|
|
1852
|
+
(deploy_dir / "conf").mkdir(exist_ok=True)
|
|
1853
|
+
print("")
|
|
1854
|
+
print("Next steps:")
|
|
1855
|
+
print(f" bootstrap or copy an Arbiter config into {deploy_dir / 'conf'}")
|
|
1856
|
+
print(f" {paths['helper']} sync-env")
|
|
1857
|
+
print(f" {paths['helper']} edit-env")
|
|
1858
|
+
print(f" {paths['helper']} up")
|
|
1859
|
+
return 0
|
|
1860
|
+
|
|
1861
|
+
if parsed.action == "update":
|
|
1862
|
+
deploy_dir.mkdir(parents=True, exist_ok=True)
|
|
1863
|
+
if not _ensure_writable_wheel_dir(deploy_dir / "wheels"):
|
|
1864
|
+
return 1
|
|
1865
|
+
if not _ensure_writable_plugin_data_dir(deploy_dir / "data" / "plugins"):
|
|
1866
|
+
return 1
|
|
1867
|
+
manifest_hashes = _load_deploy_manifest(deploy_dir)
|
|
1868
|
+
original_manifest_hashes = dict(manifest_hashes)
|
|
1869
|
+
update_statuses = [
|
|
1870
|
+
_update_manifest_owned_deploy_file(
|
|
1871
|
+
path=paths["compose"],
|
|
1872
|
+
relative_path="compose.yaml",
|
|
1873
|
+
content=compose_text,
|
|
1874
|
+
executable=False,
|
|
1875
|
+
manifest_hashes=manifest_hashes,
|
|
1876
|
+
force=parsed.force,
|
|
1877
|
+
),
|
|
1878
|
+
_update_manifest_owned_deploy_file(
|
|
1879
|
+
path=paths["helper"],
|
|
1880
|
+
relative_path="arbiter-docker",
|
|
1881
|
+
content=helper_text,
|
|
1882
|
+
executable=True,
|
|
1883
|
+
manifest_hashes=manifest_hashes,
|
|
1884
|
+
force=parsed.force,
|
|
1885
|
+
),
|
|
1886
|
+
]
|
|
1887
|
+
try:
|
|
1888
|
+
existing_docker_env = _read_env_file_values(
|
|
1889
|
+
paths["docker_env"],
|
|
1890
|
+
missing_ok=True,
|
|
1891
|
+
)
|
|
1892
|
+
except ValueError as exc:
|
|
1893
|
+
print_cli_error(str(exc), area="deploy")
|
|
1894
|
+
return 1
|
|
1895
|
+
docker_env_content = _format_docker_compose_env_file(existing_docker_env)
|
|
1896
|
+
wrote_local_state = False
|
|
1897
|
+
update_requirement_resolution: DockerDeployRequirements | None = None
|
|
1898
|
+
refresh_existing_requirements = False
|
|
1899
|
+
if parsed.force and paths["requirements"].exists():
|
|
1900
|
+
update_requirement_resolution = _resolve_docker_deploy_requirements(
|
|
1901
|
+
parsed.requirements,
|
|
1902
|
+
wheel_dir=deploy_dir / "wheels",
|
|
1903
|
+
)
|
|
1904
|
+
if update_requirement_resolution is None:
|
|
1905
|
+
return 2
|
|
1906
|
+
if parsed.requirements:
|
|
1907
|
+
refresh_existing_requirements = True
|
|
1908
|
+
else:
|
|
1909
|
+
existing_names = _deploy_requirement_names(
|
|
1910
|
+
_read_deploy_requirements(paths["requirements"])
|
|
1911
|
+
)
|
|
1912
|
+
resolved_names = _deploy_requirement_names(
|
|
1913
|
+
update_requirement_resolution.requirements
|
|
1914
|
+
)
|
|
1915
|
+
refresh_existing_requirements = (
|
|
1916
|
+
existing_names is not None and existing_names == resolved_names
|
|
1917
|
+
)
|
|
1918
|
+
if not paths["requirements"].exists() or refresh_existing_requirements:
|
|
1919
|
+
if update_requirement_resolution is None:
|
|
1920
|
+
update_requirement_resolution = _resolve_docker_deploy_requirements(
|
|
1921
|
+
parsed.requirements,
|
|
1922
|
+
wheel_dir=deploy_dir / "wheels",
|
|
1923
|
+
)
|
|
1924
|
+
if update_requirement_resolution is None:
|
|
1925
|
+
return 2
|
|
1926
|
+
if paths["requirements"].exists() and refresh_existing_requirements:
|
|
1927
|
+
print(f"force updating requirements file: {paths['requirements']}")
|
|
1928
|
+
_write_deploy_file(
|
|
1929
|
+
paths["requirements"],
|
|
1930
|
+
_format_deploy_requirements(update_requirement_resolution.requirements),
|
|
1931
|
+
)
|
|
1932
|
+
wrote_local_state = True
|
|
1933
|
+
if (
|
|
1934
|
+
not paths["docker_env"].exists()
|
|
1935
|
+
or paths["docker_env"].read_text(encoding="utf-8") != docker_env_content
|
|
1936
|
+
):
|
|
1937
|
+
_write_deploy_file(paths["docker_env"], docker_env_content)
|
|
1938
|
+
wrote_local_state = True
|
|
1939
|
+
if manifest_hashes != original_manifest_hashes:
|
|
1940
|
+
_write_deploy_manifest(deploy_dir, file_hashes=manifest_hashes)
|
|
1941
|
+
elif all(status == "up_to_date" for status in update_statuses) and not (
|
|
1942
|
+
wrote_local_state
|
|
1943
|
+
):
|
|
1944
|
+
print(f"Files already up to date: {deploy_dir}")
|
|
1945
|
+
(deploy_dir / "conf").mkdir(exist_ok=True)
|
|
1946
|
+
return 0
|
|
1947
|
+
|
|
1948
|
+
raise AssertionError(f"unknown docker deploy action: {parsed.action}")
|
|
1949
|
+
|
|
1950
|
+
|
|
1951
|
+
def _run_serve(
|
|
1952
|
+
*,
|
|
1953
|
+
config_dir: str,
|
|
1954
|
+
config_name: str,
|
|
1955
|
+
overrides: Sequence[str],
|
|
1956
|
+
skip_runtime_permission_checks: bool = False,
|
|
1957
|
+
) -> int:
|
|
1958
|
+
try:
|
|
1959
|
+
cfg = compose_config(
|
|
1960
|
+
config_dir=config_dir,
|
|
1961
|
+
config_name=config_name,
|
|
1962
|
+
overrides=overrides,
|
|
1963
|
+
enforce_runtime_permissions=not skip_runtime_permission_checks,
|
|
1964
|
+
)
|
|
1965
|
+
ensure_runnable_config(cfg)
|
|
1966
|
+
log_startup_summary(cfg)
|
|
1967
|
+
server = build_server(cfg)
|
|
1968
|
+
_run_server(server, cast(TransportMode, cfg.arbiter.server.transport))
|
|
1969
|
+
except KeyboardInterrupt:
|
|
1970
|
+
print("Arbiter server stopped.", file=sys.stderr)
|
|
1971
|
+
return 130
|
|
1972
|
+
except ValueError as exc:
|
|
1973
|
+
print_cli_error(str(exc), area="config")
|
|
1974
|
+
return 1
|
|
1975
|
+
return 0
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def _run_config_check(
|
|
1979
|
+
*,
|
|
1980
|
+
config_dir: str,
|
|
1981
|
+
config_name: str,
|
|
1982
|
+
overrides: Sequence[str],
|
|
1983
|
+
) -> int:
|
|
1984
|
+
try:
|
|
1985
|
+
cfg = compose_config(
|
|
1986
|
+
config_dir=config_dir,
|
|
1987
|
+
config_name=config_name,
|
|
1988
|
+
overrides=overrides,
|
|
1989
|
+
)
|
|
1990
|
+
print(config_check_summary(cfg))
|
|
1991
|
+
except ValueError as exc:
|
|
1992
|
+
print_cli_error(str(exc), area="config")
|
|
1993
|
+
return 1
|
|
1994
|
+
return 0
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
def _run_config_show(
|
|
1998
|
+
*,
|
|
1999
|
+
config_dir: str,
|
|
2000
|
+
config_name: str,
|
|
2001
|
+
overrides: Sequence[str],
|
|
2002
|
+
resolve: bool,
|
|
2003
|
+
) -> int:
|
|
2004
|
+
try:
|
|
2005
|
+
cfg = compose_config(
|
|
2006
|
+
config_dir=config_dir,
|
|
2007
|
+
config_name=config_name,
|
|
2008
|
+
overrides=overrides,
|
|
2009
|
+
)
|
|
2010
|
+
print(OmegaConf.to_yaml(cfg, resolve=resolve), end="")
|
|
2011
|
+
except ValueError as exc:
|
|
2012
|
+
print_cli_error(str(exc), area="config")
|
|
2013
|
+
return 1
|
|
2014
|
+
return 0
|
|
2015
|
+
|
|
2016
|
+
|
|
2017
|
+
def _ensure_config_dir(config_dir: str | None) -> Path | None:
|
|
2018
|
+
return Path(DEFAULT_CONFIG_DIR if config_dir is None else config_dir).expanduser()
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
def _write_bootstrap_file(path: Path, content: str, *, force: bool) -> int:
|
|
2022
|
+
if path.exists() and not force:
|
|
2023
|
+
print_cli_error(
|
|
2024
|
+
f"refusing to overwrite existing file: {path}",
|
|
2025
|
+
area="bootstrap",
|
|
2026
|
+
)
|
|
2027
|
+
return 1
|
|
2028
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2029
|
+
_write_text_with_mode(path, content, CONFIG_FILE_MODE)
|
|
2030
|
+
print(f"wrote {path}")
|
|
2031
|
+
return 0
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
def _write_bootstrap_files(
|
|
2035
|
+
files: Sequence[tuple[Path, str]],
|
|
2036
|
+
*,
|
|
2037
|
+
force: bool,
|
|
2038
|
+
) -> int:
|
|
2039
|
+
for path, _content in files:
|
|
2040
|
+
if path.exists() and not force:
|
|
2041
|
+
print_cli_error(
|
|
2042
|
+
f"refusing to overwrite existing file: {path}",
|
|
2043
|
+
area="bootstrap",
|
|
2044
|
+
)
|
|
2045
|
+
return 1
|
|
2046
|
+
for path, content in files:
|
|
2047
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2048
|
+
_write_text_with_mode(path, content, CONFIG_FILE_MODE)
|
|
2049
|
+
print(f"wrote {path}")
|
|
2050
|
+
return 0
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
def _run_bootstrap_arbiter(
|
|
2054
|
+
*,
|
|
2055
|
+
config_dir: str | None,
|
|
2056
|
+
config_name: str,
|
|
2057
|
+
force: bool,
|
|
2058
|
+
) -> int:
|
|
2059
|
+
config_dir_path = _ensure_config_dir(config_dir)
|
|
2060
|
+
if config_dir_path is None:
|
|
2061
|
+
return 2
|
|
2062
|
+
if not BOOTSTRAP_NAME_PATTERN.fullmatch(config_name):
|
|
2063
|
+
print_cli_error(
|
|
2064
|
+
"config name must contain only letters, numbers, underscores, and "
|
|
2065
|
+
"dashes.",
|
|
2066
|
+
area="bootstrap",
|
|
2067
|
+
)
|
|
2068
|
+
return 2
|
|
2069
|
+
return _write_bootstrap_files(
|
|
2070
|
+
[
|
|
2071
|
+
(config_dir_path / f"{config_name}.yaml", MAIN_CONFIG_TEMPLATE),
|
|
2072
|
+
(config_dir_path / "arbiter" / "server.yaml", SERVER_CONFIG_TEMPLATE),
|
|
2073
|
+
],
|
|
2074
|
+
force=force,
|
|
2075
|
+
)
|
|
2076
|
+
|
|
2077
|
+
|
|
2078
|
+
def _bootstrap_object_path(
|
|
2079
|
+
*,
|
|
2080
|
+
config_dir: Path,
|
|
2081
|
+
plugin: str,
|
|
2082
|
+
kind: BootstrapObjectKind,
|
|
2083
|
+
name: str,
|
|
2084
|
+
) -> Path:
|
|
2085
|
+
return config_dir / "arbiter" / kind / plugin / f"{name}.yaml"
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
def _validate_bootstrap_object_args(plugin: str, name: str) -> bool:
|
|
2089
|
+
for label, value in (("plugin", plugin), ("name", name)):
|
|
2090
|
+
if not BOOTSTRAP_NAME_PATTERN.fullmatch(value):
|
|
2091
|
+
print_cli_error(
|
|
2092
|
+
f"{label} must contain only letters, numbers, underscores, and "
|
|
2093
|
+
"dashes.",
|
|
2094
|
+
area="bootstrap",
|
|
2095
|
+
)
|
|
2096
|
+
return False
|
|
2097
|
+
return True
|
|
2098
|
+
|
|
2099
|
+
|
|
2100
|
+
def _load_plugin_example_yaml(
|
|
2101
|
+
plugin: str,
|
|
2102
|
+
kind: BootstrapObjectKind,
|
|
2103
|
+
name: str,
|
|
2104
|
+
) -> str | None:
|
|
2105
|
+
plugins = _service_plugin_map(discover_service_plugins())
|
|
2106
|
+
service_plugin = plugins.get(plugin)
|
|
2107
|
+
if service_plugin is None:
|
|
2108
|
+
print_cli_error(f"service plugin is not installed: {plugin}", area="bootstrap")
|
|
2109
|
+
return None
|
|
2110
|
+
|
|
2111
|
+
node = service_plugin.bootstrap_config(kind=kind, name=name)
|
|
2112
|
+
if node is None:
|
|
2113
|
+
print_cli_error(
|
|
2114
|
+
f"service plugin does not provide an {kind} bootstrap example: {plugin}",
|
|
2115
|
+
area="bootstrap",
|
|
2116
|
+
)
|
|
2117
|
+
return None
|
|
2118
|
+
if isinstance(node, str):
|
|
2119
|
+
return node
|
|
2120
|
+
return OmegaConf.to_yaml(node, resolve=False)
|
|
2121
|
+
|
|
2122
|
+
|
|
2123
|
+
def _bootstrap_account_policy_name(account_name: str) -> str:
|
|
2124
|
+
return f"{account_name}_policy"
|
|
2125
|
+
|
|
2126
|
+
|
|
2127
|
+
def _config_group_for_kind(kind: BootstrapObjectKind) -> str:
|
|
2128
|
+
return f"arbiter/{kind}"
|
|
2129
|
+
|
|
2130
|
+
|
|
2131
|
+
def _config_group_item(plugin: str, name: str) -> str:
|
|
2132
|
+
return f"{plugin}/{name}"
|
|
2133
|
+
|
|
2134
|
+
|
|
2135
|
+
def _config_file_path(config_dir: Path, config_name: str) -> Path:
|
|
2136
|
+
return config_dir / f"{config_name}.yaml"
|
|
2137
|
+
|
|
2138
|
+
|
|
2139
|
+
def _load_main_config_lines(config_file: Path) -> list[str] | None:
|
|
2140
|
+
if not config_file.exists():
|
|
2141
|
+
print_cli_error(
|
|
2142
|
+
f"main config not found: {config_file}; run bootstrap arbiter first",
|
|
2143
|
+
area="config",
|
|
2144
|
+
)
|
|
2145
|
+
return None
|
|
2146
|
+
lines = config_file.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
2147
|
+
if "defaults:\n" not in lines:
|
|
2148
|
+
print_cli_error(
|
|
2149
|
+
f"main config does not contain a defaults list: {config_file}",
|
|
2150
|
+
area="config",
|
|
2151
|
+
)
|
|
2152
|
+
return None
|
|
2153
|
+
return lines
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
def _find_defaults_group(lines: Sequence[str], group: str) -> tuple[int, int] | None:
|
|
2157
|
+
start_index = None
|
|
2158
|
+
for index, line in enumerate(lines):
|
|
2159
|
+
if line == f" - {group}: []\n" or line == f" - {group}:\n":
|
|
2160
|
+
start_index = index
|
|
2161
|
+
break
|
|
2162
|
+
if start_index is None:
|
|
2163
|
+
return None
|
|
2164
|
+
end_index = len(lines)
|
|
2165
|
+
for index in range(start_index + 1, len(lines)):
|
|
2166
|
+
if lines[index].startswith(" - "):
|
|
2167
|
+
end_index = index
|
|
2168
|
+
break
|
|
2169
|
+
return start_index, end_index
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
def _insert_defaults_group(lines: list[str], group: str, items: Sequence[str]) -> None:
|
|
2173
|
+
if " - _self_\n" not in lines:
|
|
2174
|
+
raise ValueError("main config defaults list must contain _self_")
|
|
2175
|
+
self_index = lines.index(" - _self_\n")
|
|
2176
|
+
lines[self_index:self_index] = [
|
|
2177
|
+
f" - {group}:\n",
|
|
2178
|
+
*[f" - {item}\n" for item in items],
|
|
2179
|
+
]
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
def _active_group_items(lines: Sequence[str], group: str) -> list[str]:
|
|
2183
|
+
group_span = _find_defaults_group(lines, group)
|
|
2184
|
+
if group_span is None:
|
|
2185
|
+
return []
|
|
2186
|
+
start_index, end_index = group_span
|
|
2187
|
+
if lines[start_index] == f" - {group}: []\n":
|
|
2188
|
+
return []
|
|
2189
|
+
items: list[str] = []
|
|
2190
|
+
for line in lines[start_index + 1 : end_index]:
|
|
2191
|
+
match = GROUP_SELECTION_PATTERN.match(line.strip())
|
|
2192
|
+
if match is not None:
|
|
2193
|
+
items.append(match.group("item"))
|
|
2194
|
+
return items
|
|
2195
|
+
|
|
2196
|
+
|
|
2197
|
+
def _set_group_items(lines: list[str], group: str, items: Sequence[str]) -> bool:
|
|
2198
|
+
group_span = _find_defaults_group(lines, group)
|
|
2199
|
+
unique_items = list(dict.fromkeys(items))
|
|
2200
|
+
if group_span is None:
|
|
2201
|
+
if not unique_items:
|
|
2202
|
+
return False
|
|
2203
|
+
_insert_defaults_group(lines, group, unique_items)
|
|
2204
|
+
return True
|
|
2205
|
+
start_index, end_index = group_span
|
|
2206
|
+
replacement = (
|
|
2207
|
+
[]
|
|
2208
|
+
if not unique_items
|
|
2209
|
+
else [f" - {group}:\n", *[f" - {item}\n" for item in unique_items]]
|
|
2210
|
+
)
|
|
2211
|
+
if lines[start_index:end_index] == replacement:
|
|
2212
|
+
return False
|
|
2213
|
+
lines[start_index:end_index] = replacement
|
|
2214
|
+
return True
|
|
2215
|
+
|
|
2216
|
+
|
|
2217
|
+
def _add_group_item(lines: list[str], group: str, item: str) -> bool:
|
|
2218
|
+
items = _active_group_items(lines, group)
|
|
2219
|
+
if item in items:
|
|
2220
|
+
return False
|
|
2221
|
+
items.append(item)
|
|
2222
|
+
return _set_group_items(lines, group, items)
|
|
2223
|
+
|
|
2224
|
+
|
|
2225
|
+
def _remove_group_item(lines: list[str], group: str, item: str) -> bool:
|
|
2226
|
+
items = _active_group_items(lines, group)
|
|
2227
|
+
if item not in items:
|
|
2228
|
+
return False
|
|
2229
|
+
return _set_group_items(
|
|
2230
|
+
lines, group, [existing for existing in items if existing != item]
|
|
2231
|
+
)
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
def _active_default_configs(
|
|
2235
|
+
lines: Sequence[str],
|
|
2236
|
+
*,
|
|
2237
|
+
plugin: str,
|
|
2238
|
+
kind: BootstrapObjectKind,
|
|
2239
|
+
) -> list[str]:
|
|
2240
|
+
prefix = f"{plugin}/"
|
|
2241
|
+
return [
|
|
2242
|
+
item.removeprefix(prefix)
|
|
2243
|
+
for item in _active_group_items(lines, _config_group_for_kind(kind))
|
|
2244
|
+
if item.startswith(prefix)
|
|
2245
|
+
]
|
|
2246
|
+
|
|
2247
|
+
|
|
2248
|
+
def _read_account_policy(
|
|
2249
|
+
*,
|
|
2250
|
+
config_dir: Path,
|
|
2251
|
+
plugin: str,
|
|
2252
|
+
account_name: str,
|
|
2253
|
+
) -> str | None:
|
|
2254
|
+
account_file = _bootstrap_object_path(
|
|
2255
|
+
config_dir=config_dir,
|
|
2256
|
+
plugin=plugin,
|
|
2257
|
+
kind="account",
|
|
2258
|
+
name=account_name,
|
|
2259
|
+
)
|
|
2260
|
+
if not account_file.exists():
|
|
2261
|
+
print_cli_error(f"account config not found: {account_file}", area="config")
|
|
2262
|
+
return None
|
|
2263
|
+
cfg = OmegaConf.load(account_file)
|
|
2264
|
+
policy = OmegaConf.select(cfg, "policy")
|
|
2265
|
+
if not isinstance(policy, str) or not policy:
|
|
2266
|
+
print_cli_error(
|
|
2267
|
+
f"account config must define a non-empty policy: {account_file}",
|
|
2268
|
+
area="config",
|
|
2269
|
+
)
|
|
2270
|
+
return None
|
|
2271
|
+
return policy
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
def _ensure_config_object_file(
|
|
2275
|
+
*,
|
|
2276
|
+
config_dir: Path,
|
|
2277
|
+
plugin: str,
|
|
2278
|
+
kind: BootstrapObjectKind,
|
|
2279
|
+
name: str,
|
|
2280
|
+
) -> bool:
|
|
2281
|
+
object_file = _bootstrap_object_path(
|
|
2282
|
+
config_dir=config_dir,
|
|
2283
|
+
plugin=plugin,
|
|
2284
|
+
kind=kind,
|
|
2285
|
+
name=name,
|
|
2286
|
+
)
|
|
2287
|
+
if not object_file.exists():
|
|
2288
|
+
print_cli_error(f"{kind} config not found: {object_file}", area="config")
|
|
2289
|
+
return False
|
|
2290
|
+
return True
|
|
2291
|
+
|
|
2292
|
+
|
|
2293
|
+
def _config_object_exists(
|
|
2294
|
+
*,
|
|
2295
|
+
config_dir: Path,
|
|
2296
|
+
plugin: str,
|
|
2297
|
+
kind: BootstrapObjectKind,
|
|
2298
|
+
name: str,
|
|
2299
|
+
) -> bool:
|
|
2300
|
+
return _bootstrap_object_path(
|
|
2301
|
+
config_dir=config_dir,
|
|
2302
|
+
plugin=plugin,
|
|
2303
|
+
kind=kind,
|
|
2304
|
+
name=name,
|
|
2305
|
+
).exists()
|
|
2306
|
+
|
|
2307
|
+
|
|
2308
|
+
def _resolve_policy_config_name(
|
|
2309
|
+
*,
|
|
2310
|
+
config_dir: Path,
|
|
2311
|
+
plugin: str,
|
|
2312
|
+
account_name: str,
|
|
2313
|
+
policy_name: str,
|
|
2314
|
+
) -> str | None:
|
|
2315
|
+
for candidate in (policy_name, account_name):
|
|
2316
|
+
if _config_object_exists(
|
|
2317
|
+
config_dir=config_dir,
|
|
2318
|
+
plugin=plugin,
|
|
2319
|
+
kind="policy",
|
|
2320
|
+
name=candidate,
|
|
2321
|
+
):
|
|
2322
|
+
return candidate
|
|
2323
|
+
print_cli_error(
|
|
2324
|
+
"policy config not found for account policy "
|
|
2325
|
+
f"{policy_name}: expected "
|
|
2326
|
+
f"{config_dir / 'arbiter' / 'policy' / plugin / f'{policy_name}.yaml'} "
|
|
2327
|
+
"or "
|
|
2328
|
+
f"{config_dir / 'arbiter' / 'policy' / plugin / f'{account_name}.yaml'}",
|
|
2329
|
+
area="config",
|
|
2330
|
+
)
|
|
2331
|
+
return None
|
|
2332
|
+
|
|
2333
|
+
|
|
2334
|
+
def _write_main_config_lines(config_file: Path, lines: Sequence[str]) -> None:
|
|
2335
|
+
_write_text_with_mode(config_file, "".join(lines), CONFIG_FILE_MODE)
|
|
2336
|
+
|
|
2337
|
+
|
|
2338
|
+
def _run_config_activate_account(
|
|
2339
|
+
*,
|
|
2340
|
+
config_dir: str,
|
|
2341
|
+
config_name: str,
|
|
2342
|
+
plugin: str,
|
|
2343
|
+
name: str,
|
|
2344
|
+
) -> int:
|
|
2345
|
+
if not _validate_bootstrap_object_args(plugin, name):
|
|
2346
|
+
return 2
|
|
2347
|
+
config_dir_path = Path(config_dir).expanduser()
|
|
2348
|
+
if not _ensure_config_object_file(
|
|
2349
|
+
config_dir=config_dir_path,
|
|
2350
|
+
plugin=plugin,
|
|
2351
|
+
kind="account",
|
|
2352
|
+
name=name,
|
|
2353
|
+
):
|
|
2354
|
+
return 1
|
|
2355
|
+
policy_name = _read_account_policy(
|
|
2356
|
+
config_dir=config_dir_path,
|
|
2357
|
+
plugin=plugin,
|
|
2358
|
+
account_name=name,
|
|
2359
|
+
)
|
|
2360
|
+
if policy_name is None:
|
|
2361
|
+
return 1
|
|
2362
|
+
policy_config_name = _resolve_policy_config_name(
|
|
2363
|
+
config_dir=config_dir_path,
|
|
2364
|
+
plugin=plugin,
|
|
2365
|
+
account_name=name,
|
|
2366
|
+
policy_name=policy_name,
|
|
2367
|
+
)
|
|
2368
|
+
if policy_config_name is None:
|
|
2369
|
+
return 1
|
|
2370
|
+
config_file = _config_file_path(config_dir_path, config_name)
|
|
2371
|
+
lines = _load_main_config_lines(config_file)
|
|
2372
|
+
if lines is None:
|
|
2373
|
+
return 1
|
|
2374
|
+
try:
|
|
2375
|
+
changed_account = _add_group_item(
|
|
2376
|
+
lines,
|
|
2377
|
+
_config_group_for_kind("account"),
|
|
2378
|
+
_config_group_item(plugin, name),
|
|
2379
|
+
)
|
|
2380
|
+
changed_policy = _add_group_item(
|
|
2381
|
+
lines,
|
|
2382
|
+
_config_group_for_kind("policy"),
|
|
2383
|
+
_config_group_item(plugin, policy_config_name),
|
|
2384
|
+
)
|
|
2385
|
+
except ValueError as exc:
|
|
2386
|
+
print_cli_error(str(exc), area="config")
|
|
2387
|
+
return 1
|
|
2388
|
+
if changed_account or changed_policy:
|
|
2389
|
+
_write_main_config_lines(config_file, lines)
|
|
2390
|
+
print(f"updated {config_file}")
|
|
2391
|
+
else:
|
|
2392
|
+
print(f"account already active: {plugin}/{name}")
|
|
2393
|
+
return 0
|
|
2394
|
+
|
|
2395
|
+
|
|
2396
|
+
def _run_config_deactivate_account(
|
|
2397
|
+
*,
|
|
2398
|
+
config_dir: str,
|
|
2399
|
+
config_name: str,
|
|
2400
|
+
plugin: str,
|
|
2401
|
+
name: str,
|
|
2402
|
+
) -> int:
|
|
2403
|
+
if not _validate_bootstrap_object_args(plugin, name):
|
|
2404
|
+
return 2
|
|
2405
|
+
config_dir_path = Path(config_dir).expanduser()
|
|
2406
|
+
policy_name = _read_account_policy(
|
|
2407
|
+
config_dir=config_dir_path,
|
|
2408
|
+
plugin=plugin,
|
|
2409
|
+
account_name=name,
|
|
2410
|
+
)
|
|
2411
|
+
if policy_name is None:
|
|
2412
|
+
return 1
|
|
2413
|
+
config_file = _config_file_path(config_dir_path, config_name)
|
|
2414
|
+
lines = _load_main_config_lines(config_file)
|
|
2415
|
+
if lines is None:
|
|
2416
|
+
return 1
|
|
2417
|
+
changed = _remove_group_item(
|
|
2418
|
+
lines,
|
|
2419
|
+
_config_group_for_kind("account"),
|
|
2420
|
+
_config_group_item(plugin, name),
|
|
2421
|
+
)
|
|
2422
|
+
remaining_account_names = _active_default_configs(
|
|
2423
|
+
lines,
|
|
2424
|
+
plugin=plugin,
|
|
2425
|
+
kind="account",
|
|
2426
|
+
)
|
|
2427
|
+
policy_still_used = False
|
|
2428
|
+
for remaining_account_name in remaining_account_names:
|
|
2429
|
+
remaining_policy = _read_account_policy(
|
|
2430
|
+
config_dir=config_dir_path,
|
|
2431
|
+
plugin=plugin,
|
|
2432
|
+
account_name=remaining_account_name,
|
|
2433
|
+
)
|
|
2434
|
+
if remaining_policy is None:
|
|
2435
|
+
return 1
|
|
2436
|
+
if remaining_policy == policy_name:
|
|
2437
|
+
policy_still_used = True
|
|
2438
|
+
break
|
|
2439
|
+
if not policy_still_used:
|
|
2440
|
+
policy_config_name = _resolve_policy_config_name(
|
|
2441
|
+
config_dir=config_dir_path,
|
|
2442
|
+
plugin=plugin,
|
|
2443
|
+
account_name=name,
|
|
2444
|
+
policy_name=policy_name,
|
|
2445
|
+
)
|
|
2446
|
+
if policy_config_name is None:
|
|
2447
|
+
return 1
|
|
2448
|
+
changed = (
|
|
2449
|
+
_remove_group_item(
|
|
2450
|
+
lines,
|
|
2451
|
+
_config_group_for_kind("policy"),
|
|
2452
|
+
_config_group_item(plugin, policy_config_name),
|
|
2453
|
+
)
|
|
2454
|
+
or changed
|
|
2455
|
+
)
|
|
2456
|
+
if changed:
|
|
2457
|
+
_write_main_config_lines(config_file, lines)
|
|
2458
|
+
print(f"updated {config_file}")
|
|
2459
|
+
else:
|
|
2460
|
+
print(f"account already inactive: {plugin}/{name}")
|
|
2461
|
+
return 0
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
def _run_config_account_activation(
|
|
2465
|
+
*,
|
|
2466
|
+
action: str,
|
|
2467
|
+
config_dir: str,
|
|
2468
|
+
config_name: str,
|
|
2469
|
+
plugin: str,
|
|
2470
|
+
name: str,
|
|
2471
|
+
) -> int:
|
|
2472
|
+
if action == "activate":
|
|
2473
|
+
return _run_config_activate_account(
|
|
2474
|
+
config_dir=config_dir,
|
|
2475
|
+
config_name=config_name,
|
|
2476
|
+
plugin=plugin,
|
|
2477
|
+
name=name,
|
|
2478
|
+
)
|
|
2479
|
+
if action == "deactivate":
|
|
2480
|
+
return _run_config_deactivate_account(
|
|
2481
|
+
config_dir=config_dir,
|
|
2482
|
+
config_name=config_name,
|
|
2483
|
+
plugin=plugin,
|
|
2484
|
+
name=name,
|
|
2485
|
+
)
|
|
2486
|
+
raise AssertionError(f"unknown activation action: {action}")
|
|
2487
|
+
|
|
2488
|
+
|
|
2489
|
+
def _print_bootstrap_activation_hint(
|
|
2490
|
+
*,
|
|
2491
|
+
config_dir: Path,
|
|
2492
|
+
config_name: str,
|
|
2493
|
+
plugin: str,
|
|
2494
|
+
kind: BootstrapObjectKind,
|
|
2495
|
+
name: str,
|
|
2496
|
+
) -> None:
|
|
2497
|
+
config_file = config_dir / f"{config_name}.yaml"
|
|
2498
|
+
print("")
|
|
2499
|
+
if kind == "account":
|
|
2500
|
+
print("Edit the generated account and policy files, then activate the account:")
|
|
2501
|
+
print(
|
|
2502
|
+
f" arbiter-server --config-dir {config_dir} "
|
|
2503
|
+
f"config activate account {plugin} {name}"
|
|
2504
|
+
)
|
|
2505
|
+
print("")
|
|
2506
|
+
print("Then inspect the composed config with:")
|
|
2507
|
+
print(f" arbiter-server --config-dir {config_dir} config show")
|
|
2508
|
+
return
|
|
2509
|
+
print(f"To activate the generated policy, add this to {config_file}:")
|
|
2510
|
+
print("defaults:")
|
|
2511
|
+
print(f" - {_config_group_for_kind('policy')}:")
|
|
2512
|
+
print(f" - {_config_group_item(plugin, name)}")
|
|
2513
|
+
print("")
|
|
2514
|
+
print("Then inspect the composed config with:")
|
|
2515
|
+
print(f" arbiter-server --config-dir {config_dir} config show")
|
|
2516
|
+
|
|
2517
|
+
|
|
2518
|
+
def _run_plugin_bootstrap(
|
|
2519
|
+
*,
|
|
2520
|
+
plugin: str,
|
|
2521
|
+
kind: BootstrapObjectKind,
|
|
2522
|
+
name: str,
|
|
2523
|
+
config_dir: str | None,
|
|
2524
|
+
config_name: str,
|
|
2525
|
+
force: bool,
|
|
2526
|
+
) -> int:
|
|
2527
|
+
config_dir_path = _ensure_config_dir(config_dir)
|
|
2528
|
+
if config_dir_path is None:
|
|
2529
|
+
return 2
|
|
2530
|
+
if not _validate_bootstrap_object_args(plugin, name):
|
|
2531
|
+
return 2
|
|
2532
|
+
content = _load_plugin_example_yaml(plugin, kind, name)
|
|
2533
|
+
if content is None:
|
|
2534
|
+
return 1
|
|
2535
|
+
files = [
|
|
2536
|
+
(
|
|
2537
|
+
_bootstrap_object_path(
|
|
2538
|
+
config_dir=config_dir_path,
|
|
2539
|
+
plugin=plugin,
|
|
2540
|
+
kind=kind,
|
|
2541
|
+
name=name,
|
|
2542
|
+
),
|
|
2543
|
+
content,
|
|
2544
|
+
)
|
|
2545
|
+
]
|
|
2546
|
+
if kind == "account":
|
|
2547
|
+
policy_name = _bootstrap_account_policy_name(name)
|
|
2548
|
+
policy_content = _load_plugin_example_yaml(plugin, "policy", policy_name)
|
|
2549
|
+
if policy_content is None:
|
|
2550
|
+
return 1
|
|
2551
|
+
files.append(
|
|
2552
|
+
(
|
|
2553
|
+
_bootstrap_object_path(
|
|
2554
|
+
config_dir=config_dir_path,
|
|
2555
|
+
plugin=plugin,
|
|
2556
|
+
kind="policy",
|
|
2557
|
+
name=policy_name,
|
|
2558
|
+
),
|
|
2559
|
+
policy_content,
|
|
2560
|
+
)
|
|
2561
|
+
)
|
|
2562
|
+
result = _write_bootstrap_files(
|
|
2563
|
+
files,
|
|
2564
|
+
force=force,
|
|
2565
|
+
)
|
|
2566
|
+
if result == 0:
|
|
2567
|
+
_print_bootstrap_activation_hint(
|
|
2568
|
+
config_dir=config_dir_path,
|
|
2569
|
+
config_name=config_name,
|
|
2570
|
+
plugin=plugin,
|
|
2571
|
+
kind=kind,
|
|
2572
|
+
name=name,
|
|
2573
|
+
)
|
|
2574
|
+
return result
|
|
2575
|
+
|
|
2576
|
+
|
|
2577
|
+
def _add_override_arguments(parser: argparse.ArgumentParser, *, help_text: str) -> None:
|
|
2578
|
+
parser.add_argument(
|
|
2579
|
+
"overrides",
|
|
2580
|
+
nargs=argparse.REMAINDER,
|
|
2581
|
+
help=help_text,
|
|
2582
|
+
)
|
|
2583
|
+
|
|
2584
|
+
|
|
2585
|
+
def _extract_global_config_args(args: Sequence[str]) -> list[str]:
|
|
2586
|
+
extracted: list[str] = []
|
|
2587
|
+
remaining: list[str] = []
|
|
2588
|
+
index = 0
|
|
2589
|
+
while index < len(args):
|
|
2590
|
+
arg = args[index]
|
|
2591
|
+
if arg == "--":
|
|
2592
|
+
remaining.extend(args[index:])
|
|
2593
|
+
break
|
|
2594
|
+
if arg == "--unsafe-skip-runtime-permission-checks":
|
|
2595
|
+
extracted.append(arg)
|
|
2596
|
+
index += 1
|
|
2597
|
+
continue
|
|
2598
|
+
if arg in {"--config-dir", "--config-name"}:
|
|
2599
|
+
extracted.append(arg)
|
|
2600
|
+
if index + 1 < len(args):
|
|
2601
|
+
extracted.append(args[index + 1])
|
|
2602
|
+
index += 2
|
|
2603
|
+
continue
|
|
2604
|
+
index += 1
|
|
2605
|
+
continue
|
|
2606
|
+
if arg.startswith("--config-dir=") or arg.startswith("--config-name="):
|
|
2607
|
+
extracted.append(arg)
|
|
2608
|
+
index += 1
|
|
2609
|
+
continue
|
|
2610
|
+
remaining.append(arg)
|
|
2611
|
+
index += 1
|
|
2612
|
+
return [*extracted, *remaining]
|
|
2613
|
+
|
|
2614
|
+
|
|
2615
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
2616
|
+
parser = argparse.ArgumentParser(
|
|
2617
|
+
prog="arbiter-server",
|
|
2618
|
+
description="Policy-controlled MCP gateway for agent-accessible services.",
|
|
2619
|
+
)
|
|
2620
|
+
parser.add_argument(
|
|
2621
|
+
"--config-dir",
|
|
2622
|
+
default=DEFAULT_CONFIG_DIR,
|
|
2623
|
+
help=f"filesystem directory containing the root Hydra config (default: {DEFAULT_CONFIG_DIR})",
|
|
2624
|
+
)
|
|
2625
|
+
parser.add_argument(
|
|
2626
|
+
"--config-name",
|
|
2627
|
+
default=DEFAULT_SERVER_CONFIG_NAME,
|
|
2628
|
+
help="root config file name without .yaml",
|
|
2629
|
+
)
|
|
2630
|
+
parser.add_argument(
|
|
2631
|
+
"--unsafe-skip-runtime-permission-checks",
|
|
2632
|
+
action="store_true",
|
|
2633
|
+
help=argparse.SUPPRESS,
|
|
2634
|
+
)
|
|
2635
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
2636
|
+
|
|
2637
|
+
serve = subcommands.add_parser("serve", help="run the Arbiter MCP server")
|
|
2638
|
+
_add_override_arguments(
|
|
2639
|
+
serve,
|
|
2640
|
+
help_text="Hydra-style config overrides applied before serving",
|
|
2641
|
+
)
|
|
2642
|
+
|
|
2643
|
+
config = subcommands.add_parser("config", help="inspect and validate config")
|
|
2644
|
+
config_subcommands = config.add_subparsers(dest="config_command", required=True)
|
|
2645
|
+
check = config_subcommands.add_parser(
|
|
2646
|
+
"check",
|
|
2647
|
+
help="validate config and service runtime construction without serving",
|
|
2648
|
+
)
|
|
2649
|
+
_add_override_arguments(
|
|
2650
|
+
check,
|
|
2651
|
+
help_text="Hydra-style config overrides applied before validation",
|
|
2652
|
+
)
|
|
2653
|
+
show = config_subcommands.add_parser(
|
|
2654
|
+
"show",
|
|
2655
|
+
help="print the composed Arbiter config",
|
|
2656
|
+
)
|
|
2657
|
+
show.add_argument(
|
|
2658
|
+
"--resolve",
|
|
2659
|
+
action="store_true",
|
|
2660
|
+
help="resolve OmegaConf interpolations before printing",
|
|
2661
|
+
)
|
|
2662
|
+
_add_override_arguments(
|
|
2663
|
+
show,
|
|
2664
|
+
help_text="Hydra-style config overrides applied before printing",
|
|
2665
|
+
)
|
|
2666
|
+
for activation_action in ("activate", "deactivate"):
|
|
2667
|
+
activation = config_subcommands.add_parser(
|
|
2668
|
+
activation_action,
|
|
2669
|
+
help=f"{activation_action} a config object in the main defaults list",
|
|
2670
|
+
)
|
|
2671
|
+
activation.add_argument("kind", choices=["account"])
|
|
2672
|
+
activation.add_argument("plugin")
|
|
2673
|
+
activation.add_argument("name")
|
|
2674
|
+
|
|
2675
|
+
bootstrap = subcommands.add_parser("bootstrap", help="create config templates")
|
|
2676
|
+
bootstrap_subcommands = bootstrap.add_subparsers(
|
|
2677
|
+
dest="bootstrap_command",
|
|
2678
|
+
required=True,
|
|
2679
|
+
)
|
|
2680
|
+
bootstrap_arbiter = bootstrap_subcommands.add_parser(
|
|
2681
|
+
"arbiter",
|
|
2682
|
+
help="create the main Arbiter config",
|
|
2683
|
+
)
|
|
2684
|
+
bootstrap_arbiter.add_argument(
|
|
2685
|
+
"--force",
|
|
2686
|
+
action="store_true",
|
|
2687
|
+
help="overwrite an existing config file",
|
|
2688
|
+
)
|
|
2689
|
+
bootstrap_plugin = bootstrap_subcommands.add_parser(
|
|
2690
|
+
"plugin",
|
|
2691
|
+
help="create a plugin-owned account or policy template",
|
|
2692
|
+
)
|
|
2693
|
+
bootstrap_plugin.add_argument("plugin")
|
|
2694
|
+
bootstrap_plugin.add_argument("kind", choices=["account", "policy"])
|
|
2695
|
+
bootstrap_plugin.add_argument("name")
|
|
2696
|
+
bootstrap_plugin.add_argument(
|
|
2697
|
+
"--force",
|
|
2698
|
+
action="store_true",
|
|
2699
|
+
help="overwrite an existing config object file",
|
|
2700
|
+
)
|
|
2701
|
+
|
|
2702
|
+
env = subcommands.add_parser("env", help="inspect and bootstrap env files")
|
|
2703
|
+
env_subcommands = env.add_subparsers(dest="env_command", required=True)
|
|
2704
|
+
env_check = env_subcommands.add_parser(
|
|
2705
|
+
"check",
|
|
2706
|
+
help="check that all config env references are satisfied",
|
|
2707
|
+
)
|
|
2708
|
+
_add_override_arguments(
|
|
2709
|
+
env_check,
|
|
2710
|
+
help_text="Hydra-style config overrides applied before checking env",
|
|
2711
|
+
)
|
|
2712
|
+
env_bootstrap = env_subcommands.add_parser(
|
|
2713
|
+
"bootstrap",
|
|
2714
|
+
help="rebuild the configured env file with missing variables",
|
|
2715
|
+
)
|
|
2716
|
+
_add_override_arguments(
|
|
2717
|
+
env_bootstrap,
|
|
2718
|
+
help_text="Hydra-style config overrides applied before bootstrapping env",
|
|
2719
|
+
)
|
|
2720
|
+
|
|
2721
|
+
version_command = subcommands.add_parser(
|
|
2722
|
+
"version",
|
|
2723
|
+
help="print Arbiter server and plugin versions",
|
|
2724
|
+
)
|
|
2725
|
+
version_command.add_argument(
|
|
2726
|
+
"--json",
|
|
2727
|
+
action="store_true",
|
|
2728
|
+
help="print version information as JSON",
|
|
2729
|
+
)
|
|
2730
|
+
|
|
2731
|
+
deploy = subcommands.add_parser("deploy", help="create deployment files")
|
|
2732
|
+
deploy_subcommands = deploy.add_subparsers(dest="deploy_target", required=True)
|
|
2733
|
+
deploy_docker = deploy_subcommands.add_parser(
|
|
2734
|
+
"docker",
|
|
2735
|
+
help="create or update a local Docker deployment directory",
|
|
2736
|
+
)
|
|
2737
|
+
deploy_docker.add_argument(
|
|
2738
|
+
"args",
|
|
2739
|
+
nargs=argparse.REMAINDER,
|
|
2740
|
+
help=(
|
|
2741
|
+
"init or update plus optional docker.dir=PATH and "
|
|
2742
|
+
"docker.requirement=REQUIREMENT"
|
|
2743
|
+
),
|
|
2744
|
+
)
|
|
2745
|
+
|
|
2746
|
+
plugins = subcommands.add_parser("plugins", help="inspect service plugins")
|
|
2747
|
+
plugin_subcommands = plugins.add_subparsers(dest="plugins_command", required=True)
|
|
2748
|
+
plugins_list = plugin_subcommands.add_parser(
|
|
2749
|
+
"list",
|
|
2750
|
+
help="list installed service plugins",
|
|
2751
|
+
)
|
|
2752
|
+
plugins_list.add_argument(
|
|
2753
|
+
"--json",
|
|
2754
|
+
action="store_true",
|
|
2755
|
+
help="print plugin names as JSON",
|
|
2756
|
+
)
|
|
2757
|
+
|
|
2758
|
+
return parser
|
|
2759
|
+
|
|
2760
|
+
|
|
2761
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
2762
|
+
args = _extract_global_config_args(list(sys.argv[1:] if argv is None else argv))
|
|
2763
|
+
parser = _build_parser()
|
|
2764
|
+
|
|
2765
|
+
if args == ["-h"] or args == ["--help"]:
|
|
2766
|
+
parser.print_help()
|
|
2767
|
+
return 0
|
|
2768
|
+
|
|
2769
|
+
namespace = parser.parse_args(args)
|
|
2770
|
+
if namespace.command == "serve":
|
|
2771
|
+
return _run_serve(
|
|
2772
|
+
config_dir=namespace.config_dir,
|
|
2773
|
+
config_name=namespace.config_name,
|
|
2774
|
+
overrides=namespace.overrides,
|
|
2775
|
+
skip_runtime_permission_checks=(
|
|
2776
|
+
namespace.unsafe_skip_runtime_permission_checks
|
|
2777
|
+
),
|
|
2778
|
+
)
|
|
2779
|
+
if namespace.command == "config" and namespace.config_command == "check":
|
|
2780
|
+
return _run_config_check(
|
|
2781
|
+
config_dir=namespace.config_dir,
|
|
2782
|
+
config_name=namespace.config_name,
|
|
2783
|
+
overrides=namespace.overrides,
|
|
2784
|
+
)
|
|
2785
|
+
if namespace.command == "config" and namespace.config_command == "show":
|
|
2786
|
+
return _run_config_show(
|
|
2787
|
+
config_dir=namespace.config_dir,
|
|
2788
|
+
config_name=namespace.config_name,
|
|
2789
|
+
overrides=namespace.overrides,
|
|
2790
|
+
resolve=namespace.resolve,
|
|
2791
|
+
)
|
|
2792
|
+
if namespace.command == "config" and namespace.config_command in {
|
|
2793
|
+
"activate",
|
|
2794
|
+
"deactivate",
|
|
2795
|
+
}:
|
|
2796
|
+
return _run_config_account_activation(
|
|
2797
|
+
action=namespace.config_command,
|
|
2798
|
+
config_dir=namespace.config_dir,
|
|
2799
|
+
config_name=namespace.config_name,
|
|
2800
|
+
plugin=namespace.plugin,
|
|
2801
|
+
name=namespace.name,
|
|
2802
|
+
)
|
|
2803
|
+
if namespace.command == "env" and namespace.env_command == "check":
|
|
2804
|
+
return _run_env_check(
|
|
2805
|
+
config_dir=namespace.config_dir,
|
|
2806
|
+
config_name=namespace.config_name,
|
|
2807
|
+
overrides=namespace.overrides,
|
|
2808
|
+
)
|
|
2809
|
+
if namespace.command == "env" and namespace.env_command == "bootstrap":
|
|
2810
|
+
return _run_env_bootstrap(
|
|
2811
|
+
config_dir=namespace.config_dir,
|
|
2812
|
+
config_name=namespace.config_name,
|
|
2813
|
+
overrides=namespace.overrides,
|
|
2814
|
+
)
|
|
2815
|
+
if namespace.command == "version":
|
|
2816
|
+
_print_runtime_version_info(as_json=namespace.json)
|
|
2817
|
+
return 0
|
|
2818
|
+
if namespace.command == "deploy" and namespace.deploy_target == "docker":
|
|
2819
|
+
return _run_deploy_docker(namespace.args)
|
|
2820
|
+
if namespace.command == "plugins" and namespace.plugins_command == "list":
|
|
2821
|
+
if namespace.json:
|
|
2822
|
+
_print_runtime_version_info(as_json=True)
|
|
2823
|
+
else:
|
|
2824
|
+
for name in service_plugin_names():
|
|
2825
|
+
print(name)
|
|
2826
|
+
return 0
|
|
2827
|
+
if namespace.command == "bootstrap" and namespace.bootstrap_command == "arbiter":
|
|
2828
|
+
return _run_bootstrap_arbiter(
|
|
2829
|
+
config_dir=namespace.config_dir,
|
|
2830
|
+
config_name=namespace.config_name,
|
|
2831
|
+
force=namespace.force,
|
|
2832
|
+
)
|
|
2833
|
+
if namespace.command == "bootstrap" and namespace.bootstrap_command == "plugin":
|
|
2834
|
+
return _run_plugin_bootstrap(
|
|
2835
|
+
plugin=namespace.plugin,
|
|
2836
|
+
kind=cast(BootstrapObjectKind, namespace.kind),
|
|
2837
|
+
name=namespace.name,
|
|
2838
|
+
config_dir=namespace.config_dir,
|
|
2839
|
+
config_name=namespace.config_name,
|
|
2840
|
+
force=namespace.force,
|
|
2841
|
+
)
|
|
2842
|
+
|
|
2843
|
+
parser.error("unknown command")
|