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/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")