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.
@@ -0,0 +1,4477 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ deploy_dir="${ARBITER_DOCKER_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)}"
5
+ systemd_unit_dir="${ARBITER_SYSTEMD_DIR:-/etc/systemd/system}"
6
+ compose_file="$deploy_dir/compose.yaml"
7
+ compose_env_file="$deploy_dir/docker.env"
8
+ requirements_file="$deploy_dir/requirements.txt"
9
+ compose_override_file="$deploy_dir/compose.override.yaml"
10
+
11
+ usage() {
12
+ cat <<'EOF'
13
+ Usage: arbiter-docker COMMAND
14
+
15
+ Commands:
16
+ prepare Shortcut for bundle prepare
17
+ bundle Manage the root requirements and prepared wheelhouse
18
+ sync-env Run arbiter-server env bootstrap for the configured config
19
+ edit-config Edit the main config file with $ARBITER_EDITOR, $EDITOR, or vi
20
+ edit-requirements
21
+ Edit Python requirements installed inside the container
22
+ edit-env Edit the configured env file with $ARBITER_EDITOR, $EDITOR, or vi
23
+ edit-docker Edit docker.env with $ARBITER_EDITOR, $EDITOR, or vi
24
+ up Start or update the Docker Compose service
25
+ restart Recreate the Docker Compose service
26
+ test Call version_info through the computed MCP URL
27
+ down Stop and remove the Docker Compose service
28
+ ps Show Docker Compose service status
29
+ logs Follow Docker Compose logs
30
+ info Show deployment paths and Docker Compose version
31
+ doctor Check generated files, Docker Compose, and optional agent access
32
+ install Promote this prepared deployment directory to a Linux host
33
+
34
+ Doctor options:
35
+ --preinstall Check this directory before sudo install; skip Docker daemon checks
36
+ --agent-user USER Check that USER cannot read/write deployment state
37
+ --agent-uid UID Check that UID cannot read/write deployment state
38
+
39
+ Install options:
40
+ --to DIR Install target directory, default /opt/arbiter
41
+ --user USER Dedicated service config owner, default arbiter
42
+ --group GROUP Dedicated service config group, default USER
43
+ --service NAME systemd service name, default arbiter
44
+ --no-start Install and enable the service without starting it
45
+ --replace-config Replace installed config from this staging dir
46
+ --replace-env With --replace-config, also replace the installed env
47
+ --dry-run Print the install plan without changing the host
48
+
49
+ Environment:
50
+ ARBITER_DOCKER_DIR Deployment directory, default script directory
51
+ ARBITER_CLIENT_COMMAND
52
+ Arbiter client command for test, default arbiter,
53
+ then ../skill/bin/arbiter for checkout deployments
54
+ ARBITER_EDITOR Editor for edit-config/edit-env
55
+ EOF
56
+ }
57
+
58
+ require_file() {
59
+ local path="$1"
60
+ [[ -f "$path" ]] || {
61
+ printf 'error: missing file: %s\n' "$path" >&2
62
+ exit 1
63
+ }
64
+ }
65
+
66
+ require_dir() {
67
+ local path="$1"
68
+ [[ -d "$path" ]] || {
69
+ printf 'error: missing directory: %s\n' "$path" >&2
70
+ exit 1
71
+ }
72
+ }
73
+
74
+ validate_requirements_file() {
75
+ local path="$1"
76
+
77
+ awk '
78
+ {
79
+ line = $0
80
+ sub(/^[[:space:]]+/, "", line)
81
+ sub(/[[:space:]]+$/, "", line)
82
+ if (line == "" || line ~ /^#/) {
83
+ next
84
+ }
85
+ sub(/[[:space:]]+#.*$/, "", line)
86
+ sub(/[[:space:]]+$/, "", line)
87
+ if (line ~ /^\//) {
88
+ next
89
+ }
90
+ if (line ~ /^[A-Za-z0-9][A-Za-z0-9_.-]*(\[[A-Za-z0-9_.-]+(,[A-Za-z0-9_.-]+)*\])?==[^<>=!~[:space:]#]+$/) {
91
+ name = line
92
+ sub(/==.*/, "", name)
93
+ sub(/\[.*/, "", name)
94
+ version = line
95
+ sub(/^[^=]*==/, "", version)
96
+ if ((name in pins) && pins[name] != version) {
97
+ printf "%s:%d: conflicting package pins for %s: %s and %s\n", FILENAME, FNR, name, pins[name], version
98
+ invalid = 1
99
+ }
100
+ pins[name] = version
101
+ if (name == "arbiter-suite") {
102
+ meta_all = 1
103
+ }
104
+ if (name == "arbiter-server" || name == "arbiter-smtp" || name == "arbiter-imap") {
105
+ all_component = 1
106
+ }
107
+ next
108
+ }
109
+ printf "%s:%d: requirement must be an exact package pin (name==version) or an absolute container path: %s\n", FILENAME, FNR, $0
110
+ invalid = 1
111
+ }
112
+ END {
113
+ if (meta_all && all_component) {
114
+ printf "%s: arbiter-suite meta package cannot be combined directly with arbiter-server, arbiter-smtp, or arbiter-imap pins; generate expanded real package pins with arbiter-server deploy docker docker.requirement=...\n", FILENAME
115
+ invalid = 1
116
+ }
117
+ exit invalid
118
+ }
119
+ ' "$path"
120
+ }
121
+
122
+ requirements_has_absolute_paths() {
123
+ local path="$1"
124
+
125
+ awk '
126
+ {
127
+ line = $0
128
+ sub(/^[[:space:]]+/, "", line)
129
+ sub(/[[:space:]]+$/, "", line)
130
+ if (line == "" || line ~ /^#/) {
131
+ next
132
+ }
133
+ sub(/[[:space:]]+#.*$/, "", line)
134
+ sub(/[[:space:]]+$/, "", line)
135
+ if (line ~ /^\//) {
136
+ found = 1
137
+ }
138
+ }
139
+ END {
140
+ exit found ? 0 : 1
141
+ }
142
+ ' "$path"
143
+ }
144
+
145
+ requirements_has_source_paths() {
146
+ local path="$1"
147
+
148
+ awk '
149
+ {
150
+ line = $0
151
+ sub(/^[[:space:]]+/, "", line)
152
+ sub(/[[:space:]]+$/, "", line)
153
+ if (line == "" || line ~ /^#/) {
154
+ next
155
+ }
156
+ sub(/[[:space:]]+#.*$/, "", line)
157
+ sub(/[[:space:]]+$/, "", line)
158
+ if (line ~ /^\/source\/arbiter(\/|$)/) {
159
+ found = 1
160
+ }
161
+ }
162
+ END {
163
+ exit found ? 0 : 1
164
+ }
165
+ ' "$path"
166
+ }
167
+
168
+ reject_source_requirements_for_wheelhouse_command() {
169
+ local command_name="$1"
170
+
171
+ if requirements_has_source_paths "$requirements_file"; then
172
+ printf 'error: bundle %s does not support local checkout requirements: %s\n' "$command_name" "$requirements_file" >&2
173
+ printf ' /source/arbiter/... requirements are installed by Docker Compose at container startup\n' >&2
174
+ printf ' run %s restart to recreate staging with the local source mount\n' "$deploy_dir/arbiter-docker" >&2
175
+ printf ' run %s install to promote local checkout requirements to a wheel-backed install\n' "$deploy_dir/arbiter-docker" >&2
176
+ return 1
177
+ fi
178
+ }
179
+
180
+ edit_requirements() {
181
+ local edited_requirements
182
+
183
+ require_file "$requirements_file"
184
+ edited_requirements="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-requirements.XXXXXX")"
185
+ cp "$requirements_file" "$edited_requirements"
186
+
187
+ if ! run_editor "$edited_requirements"; then
188
+ rm -f "$edited_requirements"
189
+ return 1
190
+ fi
191
+
192
+ if ! validate_requirements_file "$edited_requirements"; then
193
+ printf 'error: requirements unchanged: %s\n' "$requirements_file" >&2
194
+ rm -f "$edited_requirements"
195
+ return 1
196
+ fi
197
+
198
+ cp "$edited_requirements" "$requirements_file"
199
+ rm -f "$edited_requirements"
200
+ printf 'updated requirements file: %s\n' "$requirements_file"
201
+ }
202
+
203
+ bundle_list_roots() {
204
+ require_file "$requirements_file"
205
+ validate_requirements_file "$requirements_file"
206
+ awk '
207
+ {
208
+ line = $0
209
+ sub(/^[[:space:]]+/, "", line)
210
+ sub(/[[:space:]]+#.*$/, "", line)
211
+ sub(/[[:space:]]+$/, "", line)
212
+ if (line == "") {
213
+ next
214
+ }
215
+ printf "root\t%s\n", line
216
+ }
217
+ ' "$requirements_file"
218
+ }
219
+
220
+ bundle_list_all() {
221
+ local active_wheels_dir
222
+ local root_requirements
223
+ local resolved_requirements
224
+ local wheel
225
+ local filename
226
+ local base
227
+ local distribution
228
+ local version
229
+ local normalized_name
230
+ local requirement
231
+ local label
232
+
233
+ require_file "$requirements_file"
234
+ validate_requirements_file "$requirements_file"
235
+ active_wheels_dir="$(wheels_dir_path)"
236
+ require_dir "$active_wheels_dir"
237
+
238
+ root_requirements="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-roots.XXXXXX")"
239
+ resolved_requirements="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-resolved.XXXXXX")"
240
+
241
+ awk '
242
+ function normalize(name) {
243
+ name = tolower(name)
244
+ gsub(/[-_.]+/, "-", name)
245
+ return name
246
+ }
247
+ function wheel_requirement(path, base, parts) {
248
+ base = path
249
+ sub(/^.*\//, "", base)
250
+ sub(/\.whl$/, "", base)
251
+ split(base, parts, "-")
252
+ if (parts[1] != "" && parts[2] != "") {
253
+ print normalize(parts[1]) "==" parts[2]
254
+ }
255
+ }
256
+ {
257
+ line = $0
258
+ sub(/^[[:space:]]+/, "", line)
259
+ sub(/[[:space:]]+#.*$/, "", line)
260
+ sub(/[[:space:]]+$/, "", line)
261
+ if (line == "") {
262
+ next
263
+ }
264
+ if (line ~ /^\/wheels\/.*\.whl$/) {
265
+ wheel_requirement(line)
266
+ next
267
+ }
268
+ name = line
269
+ sub(/==.*/, "", name)
270
+ sub(/\[.*/, "", name)
271
+ version = line
272
+ sub(/^[^=]*==/, "", version)
273
+ print normalize(name) "==" version
274
+ }
275
+ ' "$requirements_file" | sort -u >"$root_requirements"
276
+
277
+ shopt -s nullglob
278
+ for wheel in "$active_wheels_dir"/*.whl; do
279
+ filename="${wheel##*/}"
280
+ base="${filename%.whl}"
281
+ distribution="${base%%-*}"
282
+ base="${base#*-}"
283
+ version="${base%%-*}"
284
+ if [[ -z "$distribution" || -z "$version" ]]; then
285
+ continue
286
+ fi
287
+ normalized_name="$(printf '%s\n' "$distribution" | tr '[:upper:]' '[:lower:]' | sed 's/[-_.][-_\.]*/-/g')"
288
+ requirement="$normalized_name==$version"
289
+ if grep -Fxq "$requirement" "$root_requirements"; then
290
+ label="root"
291
+ else
292
+ label="transitive"
293
+ fi
294
+ printf '%s\t%s\n' "$label" "$requirement"
295
+ done | sort -u >"$resolved_requirements"
296
+ shopt -u nullglob
297
+
298
+ if [[ ! -s "$resolved_requirements" ]]; then
299
+ printf 'error: wheelhouse is empty: %s\n' "$active_wheels_dir" >&2
300
+ printf ' run %s bundle prepare to build the dependency wheelhouse\n' "$deploy_dir/arbiter-docker" >&2
301
+ rm -f "$root_requirements" "$resolved_requirements"
302
+ return 1
303
+ fi
304
+
305
+ cat "$resolved_requirements"
306
+ rm -f "$root_requirements" "$resolved_requirements"
307
+ }
308
+
309
+ bundle_list_plugins() {
310
+ printf 'imap\n'
311
+ printf 'smtp\n'
312
+ }
313
+
314
+ bundle_plugin_package() {
315
+ case "$1" in
316
+ imap)
317
+ printf 'arbiter-imap\n'
318
+ ;;
319
+ smtp)
320
+ printf 'arbiter-smtp\n'
321
+ ;;
322
+ *)
323
+ return 1
324
+ ;;
325
+ esac
326
+ }
327
+
328
+ bundle_meta_plugins() {
329
+ case "$1" in
330
+ arbiter-suite)
331
+ bundle_list_plugins
332
+ ;;
333
+ *)
334
+ return 1
335
+ ;;
336
+ esac
337
+ }
338
+
339
+ bundle_item_plugins() {
340
+ if bundle_plugin_package "$1" >/dev/null; then
341
+ printf '%s\n' "$1"
342
+ return
343
+ fi
344
+ bundle_meta_plugins "$1"
345
+ }
346
+
347
+ bundle_supported_items_message() {
348
+ printf 'supported plugins: imap, smtp; supported meta packages: arbiter-suite\n'
349
+ }
350
+
351
+ normalize_distribution_name() {
352
+ printf '%s\n' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[-_.][-_\.]*/-/g'
353
+ }
354
+
355
+ bundle_requirement_version() {
356
+ local package="$1"
357
+
358
+ awk -v package="$package" '
359
+ function normalize(name) {
360
+ name = tolower(name)
361
+ gsub(/[-_.]+/, "-", name)
362
+ return name
363
+ }
364
+ BEGIN {
365
+ wanted = normalize(package)
366
+ }
367
+ {
368
+ line = $0
369
+ sub(/^[[:space:]]+/, "", line)
370
+ sub(/[[:space:]]+#.*$/, "", line)
371
+ sub(/[[:space:]]+$/, "", line)
372
+ if (line == "" || line ~ /^\//) {
373
+ next
374
+ }
375
+ name = line
376
+ sub(/==.*/, "", name)
377
+ sub(/\[.*/, "", name)
378
+ version = line
379
+ sub(/^[^=]*==/, "", version)
380
+ if (normalize(name) == wanted) {
381
+ print version
382
+ found = 1
383
+ exit
384
+ }
385
+ }
386
+ END {
387
+ exit found ? 0 : 1
388
+ }
389
+ ' "$requirements_file"
390
+ }
391
+
392
+ bundle_write_without_packages() {
393
+ local output_file="$1"
394
+ shift
395
+ local packages="$*"
396
+
397
+ awk -v packages="$packages" '
398
+ function normalize(name) {
399
+ name = tolower(name)
400
+ gsub(/[-_.]+/, "-", name)
401
+ return name
402
+ }
403
+ BEGIN {
404
+ split(packages, package_list, " ")
405
+ for (i in package_list) {
406
+ skip[normalize(package_list[i])] = 1
407
+ }
408
+ }
409
+ {
410
+ line = $0
411
+ sub(/^[[:space:]]+/, "", line)
412
+ sub(/[[:space:]]+#.*$/, "", line)
413
+ sub(/[[:space:]]+$/, "", line)
414
+ if (line != "" && line !~ /^\//) {
415
+ name = line
416
+ sub(/==.*/, "", name)
417
+ sub(/\[.*/, "", name)
418
+ if (normalize(name) in skip) {
419
+ next
420
+ }
421
+ }
422
+ print $0
423
+ }
424
+ ' "$requirements_file" >"$output_file"
425
+ }
426
+
427
+ bundle_update_requirements_file() {
428
+ local updated_file="$1"
429
+
430
+ if ! validate_requirements_file "$updated_file"; then
431
+ printf 'error: generated requirements are invalid; requirements unchanged: %s\n' "$requirements_file" >&2
432
+ rm -f "$updated_file"
433
+ return 1
434
+ fi
435
+ mv "$updated_file" "$requirements_file"
436
+ printf 'updated requirements file: %s\n' "$requirements_file"
437
+ }
438
+
439
+ bundle_add_plugin() {
440
+ local item="${1:-}"
441
+ local plugin
442
+ local package
443
+ local version
444
+ local tmp_file
445
+ local last_char
446
+ local plugins_text
447
+ local selected_count=0
448
+ local added_count=0
449
+ local -a plugins=()
450
+
451
+ if [[ -z "$item" || $# -ne 1 ]]; then
452
+ printf 'error: bundle add requires exactly one plugin or meta package name\n' >&2
453
+ { printf ' '; bundle_supported_items_message; } >&2
454
+ return 2
455
+ fi
456
+ if ! plugins_text="$(bundle_item_plugins "$item")"; then
457
+ printf 'error: unsupported bundle item: %s\n' "$item" >&2
458
+ { printf ' '; bundle_supported_items_message; } >&2
459
+ return 2
460
+ fi
461
+ while IFS= read -r plugin; do
462
+ [[ -n "$plugin" ]] || continue
463
+ plugins+=("$plugin")
464
+ done <<<"$plugins_text"
465
+
466
+ require_file "$requirements_file"
467
+ validate_requirements_file "$requirements_file"
468
+ if bundle_requirement_version arbiter-suite >/dev/null; then
469
+ printf 'bundle item already selected by arbiter-suite: %s\n' "$item"
470
+ return 0
471
+ fi
472
+ if ! version="$(bundle_requirement_version arbiter-server)"; then
473
+ printf 'error: cannot add bundle item without an arbiter-server package pin: %s\n' "$item" >&2
474
+ printf ' add arbiter-server==VERSION to requirements.txt first\n' >&2
475
+ return 1
476
+ fi
477
+
478
+ tmp_file="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-requirements.XXXXXX")"
479
+ cp "$requirements_file" "$tmp_file"
480
+ last_char=""
481
+ if [[ -s "$tmp_file" ]]; then
482
+ last_char="$(tail -c 1 "$tmp_file")"
483
+ fi
484
+ if [[ -n "$last_char" ]]; then
485
+ printf '\n' >>"$tmp_file"
486
+ fi
487
+ for plugin in "${plugins[@]}"; do
488
+ package="$(bundle_plugin_package "$plugin")"
489
+ if bundle_requirement_version "$package" >/dev/null; then
490
+ selected_count=$((selected_count + 1))
491
+ continue
492
+ fi
493
+ printf '%s==%s\n' "$package" "$version" >>"$tmp_file"
494
+ added_count=$((added_count + 1))
495
+ done
496
+ if [[ "$added_count" -eq 0 ]]; then
497
+ rm -f "$tmp_file"
498
+ if [[ "$selected_count" -eq "${#plugins[@]}" ]]; then
499
+ printf 'bundle item already selected: %s\n' "$item"
500
+ fi
501
+ return 0
502
+ fi
503
+ bundle_update_requirements_file "$tmp_file"
504
+ }
505
+
506
+ bundle_remove_plugin() {
507
+ local item="${1:-}"
508
+ local plugin
509
+ local package
510
+ local suite_version
511
+ local tmp_file
512
+ local other_requirements_file
513
+ local supported_plugin
514
+ local supported_package
515
+ local plugins_text
516
+ local remove_plugins=""
517
+ local selected_count=0
518
+ local -a plugins=()
519
+ local -a packages=()
520
+
521
+ if [[ -z "$item" || $# -ne 1 ]]; then
522
+ printf 'error: bundle remove requires exactly one plugin or meta package name\n' >&2
523
+ { printf ' '; bundle_supported_items_message; } >&2
524
+ return 2
525
+ fi
526
+ if ! plugins_text="$(bundle_item_plugins "$item")"; then
527
+ printf 'error: unsupported bundle item: %s\n' "$item" >&2
528
+ { printf ' '; bundle_supported_items_message; } >&2
529
+ return 2
530
+ fi
531
+ while IFS= read -r plugin; do
532
+ [[ -n "$plugin" ]] || continue
533
+ plugins+=("$plugin")
534
+ done <<<"$plugins_text"
535
+ for plugin in "${plugins[@]}"; do
536
+ remove_plugins+=" $plugin "
537
+ packages+=("$(bundle_plugin_package "$plugin")")
538
+ done
539
+
540
+ require_file "$requirements_file"
541
+ validate_requirements_file "$requirements_file"
542
+ tmp_file="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-requirements.XXXXXX")"
543
+
544
+ if suite_version="$(bundle_requirement_version arbiter-suite)"; then
545
+ other_requirements_file="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-requirements.XXXXXX")"
546
+ bundle_write_without_packages \
547
+ "$other_requirements_file" \
548
+ arbiter-suite \
549
+ arbiter-server \
550
+ arbiter-imap \
551
+ arbiter-smtp
552
+ {
553
+ printf 'arbiter-server==%s\n' "$suite_version"
554
+ while IFS= read -r supported_plugin; do
555
+ [[ "$remove_plugins" != *" $supported_plugin "* ]] || continue
556
+ supported_package="$(bundle_plugin_package "$supported_plugin")"
557
+ printf '%s==%s\n' "$supported_package" "$suite_version"
558
+ done < <(bundle_list_plugins)
559
+ awk '
560
+ {
561
+ line = $0
562
+ sub(/^[[:space:]]+/, "", line)
563
+ sub(/[[:space:]]+$/, "", line)
564
+ if (line != "") {
565
+ print $0
566
+ }
567
+ }
568
+ ' "$other_requirements_file"
569
+ } >"$tmp_file"
570
+ rm -f "$other_requirements_file"
571
+ bundle_update_requirements_file "$tmp_file"
572
+ return
573
+ fi
574
+
575
+ for package in "${packages[@]}"; do
576
+ if bundle_requirement_version "$package" >/dev/null; then
577
+ selected_count=$((selected_count + 1))
578
+ fi
579
+ done
580
+ if [[ "$selected_count" -eq 0 ]]; then
581
+ rm -f "$tmp_file"
582
+ printf 'bundle item not selected: %s\n' "$item"
583
+ return 0
584
+ fi
585
+
586
+ bundle_write_without_packages "$tmp_file" "${packages[@]}"
587
+ bundle_update_requirements_file "$tmp_file"
588
+ }
589
+
590
+ wheel_name_version() {
591
+ local wheel_path="$1"
592
+ local filename
593
+ local base
594
+ local distribution
595
+ local version
596
+
597
+ filename="${wheel_path##*/}"
598
+ base="${filename%.whl}"
599
+ distribution="${base%%-*}"
600
+ base="${base#*-}"
601
+ version="${base%%-*}"
602
+ if [[ -n "$distribution" && -n "$version" ]]; then
603
+ printf '%s\t%s\n' "$(normalize_distribution_name "$distribution")" "$version"
604
+ fi
605
+ }
606
+
607
+ bundle_write_root_versions() {
608
+ local output_file="$1"
609
+ local line
610
+ local name
611
+ local base_name
612
+ local version
613
+ local wheel_info
614
+
615
+ : >"$output_file"
616
+ require_file "$requirements_file"
617
+ validate_requirements_file "$requirements_file"
618
+ while IFS= read -r line; do
619
+ [[ -n "$line" ]] || continue
620
+ if [[ "$line" == /wheels/*.whl ]]; then
621
+ wheel_info="$(wheel_name_version "$line")"
622
+ if [[ -n "$wheel_info" ]]; then
623
+ printf 'root\t%s\n' "$wheel_info" >>"$output_file"
624
+ fi
625
+ continue
626
+ fi
627
+ name="${line%%==*}"
628
+ base_name="${name%%[*}"
629
+ version="${line#*==}"
630
+ printf 'root\t%s\t%s\n' "$(normalize_distribution_name "$base_name")" "$version" >>"$output_file"
631
+ done < <(
632
+ awk '
633
+ {
634
+ line = $0
635
+ sub(/^[[:space:]]+/, "", line)
636
+ sub(/[[:space:]]+#.*$/, "", line)
637
+ sub(/[[:space:]]+$/, "", line)
638
+ if (line != "") {
639
+ print line
640
+ }
641
+ }
642
+ ' "$requirements_file"
643
+ )
644
+ }
645
+
646
+ bundle_root_summary() {
647
+ local root_versions
648
+
649
+ root_versions="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-roots.XXXXXX")"
650
+ bundle_write_root_versions "$root_versions"
651
+ awk -F '\t' '$1 == "root" { print $2 "==" $3 }' "$root_versions" | sort | paste -sd ' ' -
652
+ rm -f "$root_versions"
653
+ }
654
+
655
+ print_bundle_prepare_start() {
656
+ printf 'preparing bundle: %s\n' "$(bundle_root_summary)"
657
+ }
658
+
659
+ bundle_write_wheelhouse_versions() {
660
+ local output_file="$1"
661
+ local active_wheels_dir
662
+ local root_versions
663
+ local wheel
664
+ local wheel_info
665
+ local normalized_name
666
+ local version
667
+ local label
668
+
669
+ : >"$output_file"
670
+ active_wheels_dir="$(wheels_dir_path)"
671
+ [[ -d "$active_wheels_dir" ]] || return 0
672
+ root_versions="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-roots.XXXXXX")"
673
+ bundle_write_root_versions "$root_versions"
674
+
675
+ shopt -s nullglob
676
+ for wheel in "$active_wheels_dir"/*.whl; do
677
+ wheel_info="$(wheel_name_version "$wheel")"
678
+ [[ -n "$wheel_info" ]] || continue
679
+ normalized_name="${wheel_info%%$'\t'*}"
680
+ version="${wheel_info#*$'\t'}"
681
+ if grep -Fxq "root $normalized_name $version" "$root_versions"; then
682
+ label="root"
683
+ else
684
+ label="transitive"
685
+ fi
686
+ printf '%s\t%s\t%s\n' "$label" "$normalized_name" "$version" >>"$output_file"
687
+ done
688
+ shopt -u nullglob
689
+ sort -u "$output_file" -o "$output_file"
690
+ rm -f "$root_versions"
691
+ }
692
+
693
+ bundle_write_upgrade_snapshot() {
694
+ local output_file="$1"
695
+ local root_versions
696
+ local wheel_versions
697
+
698
+ root_versions="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-upgrade-roots.XXXXXX")"
699
+ wheel_versions="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-upgrade-wheels.XXXXXX")"
700
+ bundle_write_root_versions "$root_versions"
701
+ bundle_write_wheelhouse_versions "$wheel_versions"
702
+ {
703
+ cat "$root_versions"
704
+ awk -F '\t' '$1 == "transitive" { print }' "$wheel_versions"
705
+ } | sort -u >"$output_file"
706
+ rm -f "$root_versions" "$wheel_versions"
707
+ }
708
+
709
+ bundle_print_upgrade_section() {
710
+ local title="$1"
711
+ local label="$2"
712
+ local before_file="$3"
713
+ local after_file="$4"
714
+
715
+ printf '%s:\n' "$title"
716
+ awk -F '\t' -v label="$label" '
717
+ FILENAME == ARGV[1] && $1 == label {
718
+ before[$2] = $3
719
+ names[$2] = 1
720
+ next
721
+ }
722
+ FILENAME == ARGV[2] && $1 == label {
723
+ after[$2] = $3
724
+ names[$2] = 1
725
+ next
726
+ }
727
+ END {
728
+ for (name in names) {
729
+ old = before[name]
730
+ new = after[name]
731
+ if (old == new) {
732
+ continue
733
+ }
734
+ if (old == "") {
735
+ old = "not present"
736
+ }
737
+ if (new == "") {
738
+ new = "removed"
739
+ }
740
+ printf " %s %s -> %s\n", name, old, new
741
+ changed = 1
742
+ }
743
+ if (!changed) {
744
+ print " no changes"
745
+ }
746
+ }
747
+ ' "$before_file" "$after_file" | sort
748
+ }
749
+
750
+ bundle_print_upgrade_summary() {
751
+ local before_file="$1"
752
+ local after_file="$2"
753
+
754
+ printf 'bundle upgrade complete: %s\n' "$deploy_dir"
755
+ bundle_print_upgrade_section root root "$before_file" "$after_file"
756
+ bundle_print_upgrade_section transitive transitive "$before_file" "$after_file"
757
+ }
758
+
759
+ release_line_upper_bound() {
760
+ local release_line="$1"
761
+ local prefix
762
+ local last
763
+
764
+ prefix="${release_line%.*}"
765
+ last="${release_line##*.}"
766
+ if [[ "$prefix" == "$release_line" ]]; then
767
+ printf '%s\n' "$((10#$last + 1))"
768
+ else
769
+ printf '%s.%s\n' "$prefix" "$((10#$last + 1))"
770
+ fi
771
+ }
772
+
773
+ repo_root_from_start() {
774
+ local start="$1"
775
+
776
+ [[ -n "$start" ]] || return 1
777
+ if [[ ! -d "$start" ]]; then
778
+ start="$(dirname -- "$start")"
779
+ fi
780
+ start="$(cd -- "$start" 2>/dev/null && pwd -P)" || return 1
781
+
782
+ while [[ "$start" != "/" ]]; do
783
+ if [[ -f "$start/server/pyproject.toml" && -f "$start/plugins/imap/pyproject.toml" && -f "$start/plugins/smtp/pyproject.toml" ]]; then
784
+ printf '%s\n' "$start"
785
+ return 0
786
+ fi
787
+ start="$(dirname -- "$start")"
788
+ done
789
+ return 1
790
+ }
791
+
792
+ find_repo_root() {
793
+ repo_root_from_start "$PWD" || repo_root_from_start "$deploy_dir"
794
+ }
795
+
796
+ repo_source_dir_for_package() {
797
+ case "$(normalize_distribution_name "$1")" in
798
+ arbiter-server)
799
+ printf 'server\n'
800
+ ;;
801
+ arbiter-imap)
802
+ printf 'plugins/imap\n'
803
+ ;;
804
+ arbiter-smtp)
805
+ printf 'plugins/smtp\n'
806
+ ;;
807
+ arbiter-suite)
808
+ printf 'meta/arbiter-suite\n'
809
+ ;;
810
+ esac
811
+ }
812
+
813
+ bundle_add_repo_wheel_sources_for_package() {
814
+ local source_list="$1"
815
+ local repo_root="$2"
816
+ local package_name="$3"
817
+ local normalized_name
818
+ local source_dir
819
+
820
+ normalized_name="$(normalize_distribution_name "$package_name")"
821
+ if [[ "$normalized_name" == "arbiter-suite" ]]; then
822
+ for package_name in arbiter-server arbiter-imap arbiter-smtp arbiter-suite; do
823
+ source_dir="$(repo_source_dir_for_package "$package_name")"
824
+ [[ -n "$source_dir" && -d "$repo_root/$source_dir" ]] || continue
825
+ printf '%s\n' "$repo_root/$source_dir" >>"$source_list"
826
+ done
827
+ return
828
+ fi
829
+
830
+ source_dir="$(repo_source_dir_for_package "$normalized_name")"
831
+ [[ -n "$source_dir" && -d "$repo_root/$source_dir" ]] || return
832
+ printf '%s\n' "$repo_root/$source_dir" >>"$source_list"
833
+ }
834
+
835
+ bundle_refresh_repo_wheels() {
836
+ local tmp_dir="$1"
837
+ local repo_root
838
+ local active_wheels_dir
839
+ local python_bin="${ARBITER_PYTHON:-}"
840
+ local build_dir
841
+ local output_file
842
+ local source_list
843
+ local line
844
+ local name
845
+ local wheel
846
+ local built_count=0
847
+
848
+ repo_root="$(find_repo_root)" || return 0
849
+ active_wheels_dir="$(wheels_dir_path)"
850
+ build_dir="$tmp_dir/repo-wheels"
851
+ output_file="$tmp_dir/repo-wheel-build.out"
852
+ source_list="$tmp_dir/repo-wheel-sources"
853
+
854
+ if [[ -z "$python_bin" ]]; then
855
+ if [[ -x "$repo_root/.venv/bin/python" ]]; then
856
+ python_bin="$repo_root/.venv/bin/python"
857
+ elif command -v python >/dev/null; then
858
+ python_bin="python"
859
+ elif command -v python3 >/dev/null; then
860
+ python_bin="python3"
861
+ else
862
+ printf 'error: cannot build local repo wheels: python command not found\n' >&2
863
+ printf ' set ARBITER_PYTHON or use bundle upgrade --pypi-only\n' >&2
864
+ return 1
865
+ fi
866
+ fi
867
+
868
+ mkdir -p "$active_wheels_dir" "$build_dir"
869
+ : >"$source_list"
870
+
871
+ while IFS= read -r line; do
872
+ [[ -n "$line" ]] || continue
873
+ [[ "$line" != /* ]] || continue
874
+ name="${line%%==*}"
875
+ name="${name%%[*}"
876
+ bundle_add_repo_wheel_sources_for_package "$source_list" "$repo_root" "$name"
877
+ done < <(
878
+ awk '
879
+ {
880
+ line = $0
881
+ sub(/^[[:space:]]+/, "", line)
882
+ sub(/[[:space:]]+#.*$/, "", line)
883
+ sub(/[[:space:]]+$/, "", line)
884
+ if (line != "") {
885
+ print line
886
+ }
887
+ }
888
+ ' "$requirements_file"
889
+ )
890
+
891
+ if [[ ! -s "$source_list" ]]; then
892
+ return 0
893
+ fi
894
+ sort -u "$source_list" -o "$source_list"
895
+
896
+ while IFS= read -r source_dir; do
897
+ if ! "$python_bin" -m pip --disable-pip-version-check wheel --no-deps --no-build-isolation --wheel-dir "$build_dir" "$source_dir" >"$output_file" 2>&1; then
898
+ cat "$output_file" >&2
899
+ printf 'error: failed to build local repo wheel: %s\n' "$source_dir" >&2
900
+ printf ' use --pypi-only to resolve only from the package index\n' >&2
901
+ return 1
902
+ fi
903
+ built_count=$((built_count + 1))
904
+ done <"$source_list"
905
+
906
+ if [[ "$built_count" -eq 0 ]]; then
907
+ return 0
908
+ fi
909
+
910
+ shopt -s nullglob
911
+ for wheel in "$build_dir"/*.whl; do
912
+ cp "$wheel" "$active_wheels_dir/${wheel##*/}"
913
+ done
914
+ shopt -u nullglob
915
+ }
916
+
917
+ bundle_upgrade_build_input() {
918
+ local target="$1"
919
+ local input_file="$2"
920
+ local roots_file="$3"
921
+ local mode="all"
922
+ local target_name=""
923
+ local target_base=""
924
+ local target_normalized=""
925
+ local release_upper=""
926
+ local found_target=0
927
+ local root_count=0
928
+ local line
929
+ local name
930
+ local base_name
931
+ local version
932
+ local normalized_name
933
+ local display_name
934
+ local spec
935
+ local has_wheel_root=0
936
+
937
+ require_file "$requirements_file"
938
+ validate_requirements_file "$requirements_file"
939
+
940
+ if [[ -n "$target" ]]; then
941
+ if [[ "$target" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
942
+ mode="release"
943
+ release_upper="$(release_line_upper_bound "$target")"
944
+ elif [[ "$target" == *"=="* ]]; then
945
+ mode="exact"
946
+ target_name="${target%%==*}"
947
+ target_base="${target_name%%[*}"
948
+ target_normalized="$(normalize_distribution_name "$target_base")"
949
+ else
950
+ mode="package"
951
+ target_normalized="$(normalize_distribution_name "$target")"
952
+ fi
953
+ fi
954
+
955
+ while IFS= read -r line; do
956
+ [[ -n "$line" ]] || continue
957
+ if [[ "$line" == /wheels/*.whl ]]; then
958
+ has_wheel_root=1
959
+ printf '%s\n' "$line" >>"$input_file"
960
+ root_count=$((root_count + 1))
961
+ continue
962
+ fi
963
+
964
+ name="${line%%==*}"
965
+ base_name="${name%%[*}"
966
+ version="${line#*==}"
967
+ normalized_name="$(normalize_distribution_name "$base_name")"
968
+ display_name="$name"
969
+ spec="$line"
970
+
971
+ case "$mode" in
972
+ all)
973
+ spec="$name>=$version"
974
+ ;;
975
+ release)
976
+ spec="$name>=$version,>=$target,<$release_upper"
977
+ ;;
978
+ package)
979
+ if [[ "$normalized_name" == "$target_normalized" ]]; then
980
+ spec="$name>=$version"
981
+ found_target=1
982
+ fi
983
+ ;;
984
+ exact)
985
+ if [[ "$normalized_name" == "$target_normalized" ]]; then
986
+ spec="$target"
987
+ display_name="$target_name"
988
+ found_target=1
989
+ fi
990
+ ;;
991
+ esac
992
+
993
+ printf '%s\n' "$spec" >>"$input_file"
994
+ printf '%s\t%s\n' "$normalized_name" "$display_name" >>"$roots_file"
995
+ root_count=$((root_count + 1))
996
+ done < <(
997
+ awk '
998
+ {
999
+ line = $0
1000
+ sub(/^[[:space:]]+/, "", line)
1001
+ sub(/[[:space:]]+#.*$/, "", line)
1002
+ sub(/[[:space:]]+$/, "", line)
1003
+ if (line != "") {
1004
+ print line
1005
+ }
1006
+ }
1007
+ ' "$requirements_file"
1008
+ )
1009
+
1010
+ if [[ "$root_count" -eq 0 ]]; then
1011
+ printf 'error: requirements file has no root requirements: %s\n' "$requirements_file" >&2
1012
+ return 1
1013
+ fi
1014
+ if [[ "$has_wheel_root" -eq 1 ]]; then
1015
+ if [[ "$mode" != all ]]; then
1016
+ printf 'error: targeted bundle upgrade requires package root requirements, but this bundle has wheel roots\n' >&2
1017
+ printf ' use package pins for index-managed upgrades, or run bundle upgrade with no arguments to refresh the wheelhouse\n' >&2
1018
+ return 1
1019
+ fi
1020
+ if [[ ! -s "$roots_file" ]]; then
1021
+ return 3
1022
+ fi
1023
+ printf 'error: cannot mix wheel roots and package roots for bundle upgrade\n' >&2
1024
+ printf ' use either package pins for index-managed upgrades or wheel paths for local artifact bundles\n' >&2
1025
+ return 1
1026
+ fi
1027
+ if [[ "$mode" == package || "$mode" == exact ]] && [[ "$found_target" -eq 0 ]]; then
1028
+ printf 'error: package is not a root requirement: %s\n' "$target" >&2
1029
+ printf ' use bundle add first, or run bundle list to see root requirements\n' >&2
1030
+ return 1
1031
+ fi
1032
+ }
1033
+
1034
+ bundle_pypi_prepare_build_input() {
1035
+ local input_file="$1"
1036
+ local roots_file="$2"
1037
+ local root_count=0
1038
+ local line
1039
+ local name
1040
+ local base_name
1041
+
1042
+ require_file "$requirements_file"
1043
+ validate_requirements_file "$requirements_file"
1044
+ if requirements_has_absolute_paths "$requirements_file"; then
1045
+ printf 'error: prepare --pypi-only requires package pins, but requirements.txt contains absolute paths\n' >&2
1046
+ printf ' replace /wheels/*.whl or source paths with package==version pins\n' >&2
1047
+ return 1
1048
+ fi
1049
+
1050
+ while IFS= read -r line; do
1051
+ [[ -n "$line" ]] || continue
1052
+ name="${line%%==*}"
1053
+ base_name="${name%%[*}"
1054
+ printf '%s\n' "$name" >>"$input_file"
1055
+ printf '%s\t%s\n' "$(normalize_distribution_name "$base_name")" "$name" >>"$roots_file"
1056
+ root_count=$((root_count + 1))
1057
+ done < <(
1058
+ awk '
1059
+ {
1060
+ line = $0
1061
+ sub(/^[[:space:]]+/, "", line)
1062
+ sub(/[[:space:]]+#.*$/, "", line)
1063
+ sub(/[[:space:]]+$/, "", line)
1064
+ if (line != "") {
1065
+ print line
1066
+ }
1067
+ }
1068
+ ' "$requirements_file"
1069
+ )
1070
+
1071
+ if [[ "$root_count" -eq 0 ]]; then
1072
+ printf 'error: requirements file has no root requirements: %s\n' "$requirements_file" >&2
1073
+ return 1
1074
+ fi
1075
+ }
1076
+
1077
+ bundle_upgrade_resolve() {
1078
+ local input_file="$1"
1079
+ local roots_file="$2"
1080
+ local output_file="$3"
1081
+ local pypi_only="$4"
1082
+ local allow_pre="${5:-0}"
1083
+ local action_label="${6:-bundle upgrade}"
1084
+ local tmp_dir
1085
+ local image
1086
+ local docker_user
1087
+ local active_wheels_dir
1088
+ local parse_script
1089
+ local -a resolve_command
1090
+ local -a parse_command
1091
+ local -a wheel_mount_args=()
1092
+ local -a find_links_args=()
1093
+ local -a pre_args=()
1094
+
1095
+ tmp_dir="$(dirname "$input_file")"
1096
+ image="$(compose_env_value ARBITER_IMAGE python:3.11-slim)"
1097
+ docker_user="$(id -u):$(id -g)"
1098
+ active_wheels_dir="$(wheels_dir_path)"
1099
+ if [[ "$pypi_only" -eq 0 && -d "$active_wheels_dir" ]]; then
1100
+ wheel_mount_args=(-v "$active_wheels_dir:/wheels:ro")
1101
+ find_links_args=(--find-links /wheels)
1102
+ fi
1103
+ if [[ "$allow_pre" -eq 1 ]]; then
1104
+ pre_args=(--pre)
1105
+ fi
1106
+ require_docker_access
1107
+
1108
+ resolve_command=(
1109
+ docker run --rm
1110
+ --user "$docker_user"
1111
+ -v "$tmp_dir:/work"
1112
+ )
1113
+ if [[ "${#wheel_mount_args[@]}" -gt 0 ]]; then
1114
+ resolve_command+=("${wheel_mount_args[@]}")
1115
+ fi
1116
+ resolve_command+=(
1117
+ "$image"
1118
+ python -m pip --disable-pip-version-check install
1119
+ --dry-run --ignore-installed
1120
+ )
1121
+ if [[ "${#pre_args[@]}" -gt 0 ]]; then
1122
+ resolve_command+=("${pre_args[@]}")
1123
+ fi
1124
+ if [[ "${#find_links_args[@]}" -gt 0 ]]; then
1125
+ resolve_command+=("${find_links_args[@]}")
1126
+ fi
1127
+ resolve_command+=(
1128
+ --report /work/report.json
1129
+ -r /work/requirements.in
1130
+ )
1131
+
1132
+ if ! "${resolve_command[@]}" >"$tmp_dir/pip-resolve.out" 2>&1; then
1133
+ cat "$tmp_dir/pip-resolve.out" >&2
1134
+ printf 'error: failed to resolve %s\n' "$action_label" >&2
1135
+ return 1
1136
+ fi
1137
+
1138
+ parse_script='import json
1139
+ import re
1140
+ import sys
1141
+
1142
+ def normalize(name):
1143
+ return re.sub(r"[-_.]+", "-", name).lower()
1144
+
1145
+ with open("/work/roots.tsv", encoding="utf-8") as handle:
1146
+ roots = [line.rstrip("\n").split("\t", 1) for line in handle if line.strip()]
1147
+ with open("/work/report.json", encoding="utf-8") as handle:
1148
+ report = json.load(handle)
1149
+
1150
+ versions = {}
1151
+ for entry in report.get("install", []):
1152
+ metadata = entry.get("metadata", {})
1153
+ name = metadata.get("name")
1154
+ version = metadata.get("version")
1155
+ if name and version:
1156
+ versions[normalize(name)] = version
1157
+
1158
+ missing = [display for normalized, display in roots if normalized not in versions]
1159
+ if missing:
1160
+ print("missing resolved root package versions: " + ", ".join(missing), file=sys.stderr)
1161
+ sys.exit(1)
1162
+
1163
+ with open("/work/requirements.out", "w", encoding="utf-8") as handle:
1164
+ for normalized, display in roots:
1165
+ handle.write(f"{display}=={versions[normalized]}\n")
1166
+ '
1167
+ parse_command=(
1168
+ docker run --rm
1169
+ --user "$docker_user"
1170
+ -v "$tmp_dir:/work"
1171
+ "$image"
1172
+ python -c "$parse_script"
1173
+ )
1174
+ "${parse_command[@]}"
1175
+ cp "$tmp_dir/requirements.out" "$output_file"
1176
+ }
1177
+
1178
+ prepare_pypi_only_requirements() {
1179
+ local target_requirements_file="$1"
1180
+ local tmp_dir
1181
+ local input_file
1182
+ local roots_file
1183
+ local output_file
1184
+
1185
+ tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/arbiter-pypi-prepare.XXXXXX")"
1186
+ input_file="$tmp_dir/requirements.in"
1187
+ roots_file="$tmp_dir/roots.tsv"
1188
+ output_file="$tmp_dir/requirements.resolved"
1189
+
1190
+ if ! bundle_pypi_prepare_build_input "$input_file" "$roots_file"; then
1191
+ rm -rf "$tmp_dir"
1192
+ return 1
1193
+ fi
1194
+ if ! bundle_upgrade_resolve "$input_file" "$roots_file" "$output_file" 1 1 "package-index preparation"; then
1195
+ rm -rf "$tmp_dir"
1196
+ return 1
1197
+ fi
1198
+ if ! validate_requirements_file "$output_file"; then
1199
+ printf 'error: resolved requirements are invalid; requirements unchanged: %s\n' "$requirements_file" >&2
1200
+ rm -rf "$tmp_dir"
1201
+ return 1
1202
+ fi
1203
+
1204
+ cp "$output_file" "$target_requirements_file"
1205
+ rm -rf "$tmp_dir"
1206
+ }
1207
+
1208
+ prepare_pypi_only() {
1209
+ local target_wheels_dir="$1"
1210
+ local docker_user="$2"
1211
+ local image="$3"
1212
+ local tmp_dir
1213
+ local staged_requirements_file
1214
+ local staged_wheels_dir
1215
+
1216
+ tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/arbiter-pypi-prepare-transaction.XXXXXX")"
1217
+ staged_requirements_file="$tmp_dir/requirements.txt"
1218
+ staged_wheels_dir="$tmp_dir/wheels"
1219
+ mkdir -p "$staged_wheels_dir"
1220
+
1221
+ if ! prepare_pypi_only_requirements "$staged_requirements_file"; then
1222
+ rm -rf "$tmp_dir"
1223
+ return 1
1224
+ fi
1225
+ if ! prepare_dependency_wheelhouse "$staged_requirements_file" "$staged_wheels_dir" "$docker_user" "$image" 1 1; then
1226
+ rm -rf "$tmp_dir"
1227
+ return 1
1228
+ fi
1229
+ if ! validate_dependency_wheelhouse "$staged_requirements_file" "$staged_wheels_dir" "$docker_user" "$image" 1; then
1230
+ rm -rf "$tmp_dir"
1231
+ return 1
1232
+ fi
1233
+
1234
+ replace_dependency_wheelhouse "$staged_wheels_dir" "$target_wheels_dir"
1235
+ if ! cmp -s "$requirements_file" "$staged_requirements_file"; then
1236
+ cp "$staged_requirements_file" "$requirements_file"
1237
+ printf 'updated requirements file from package index: %s\n' "$requirements_file"
1238
+ fi
1239
+ printf 'bundle prepare complete: %s\n' "$target_wheels_dir"
1240
+ rm -rf "$tmp_dir"
1241
+ }
1242
+
1243
+ bundle_upgrade() {
1244
+ local target=""
1245
+ local pypi_only=0
1246
+ local tmp_dir
1247
+ local input_file
1248
+ local roots_file
1249
+ local output_file
1250
+ local before_file
1251
+ local after_file
1252
+ local build_status
1253
+
1254
+ while (($#)); do
1255
+ case "$1" in
1256
+ --pypi-only)
1257
+ pypi_only=1
1258
+ ;;
1259
+ --)
1260
+ shift
1261
+ while (($#)); do
1262
+ if [[ -n "$target" ]]; then
1263
+ printf 'error: bundle upgrade accepts at most one package or release-line argument\n' >&2
1264
+ return 2
1265
+ fi
1266
+ target="$1"
1267
+ shift
1268
+ done
1269
+ break
1270
+ ;;
1271
+ -*)
1272
+ printf 'error: unknown bundle upgrade option: %s\n' "$1" >&2
1273
+ return 2
1274
+ ;;
1275
+ *)
1276
+ if [[ -n "$target" ]]; then
1277
+ printf 'error: bundle upgrade accepts at most one package or release-line argument\n' >&2
1278
+ return 2
1279
+ fi
1280
+ target="$1"
1281
+ ;;
1282
+ esac
1283
+ shift
1284
+ done
1285
+
1286
+ tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/arbiter-bundle-upgrade.XXXXXX")"
1287
+ input_file="$tmp_dir/requirements.in"
1288
+ roots_file="$tmp_dir/roots.tsv"
1289
+ output_file="$tmp_dir/requirements.resolved"
1290
+ before_file="$tmp_dir/before.tsv"
1291
+ after_file="$tmp_dir/after.tsv"
1292
+ bundle_write_upgrade_snapshot "$before_file"
1293
+
1294
+ if [[ "$pypi_only" -eq 0 ]]; then
1295
+ if ! bundle_refresh_repo_wheels "$tmp_dir"; then
1296
+ rm -rf "$tmp_dir"
1297
+ return 1
1298
+ fi
1299
+ fi
1300
+
1301
+ if bundle_upgrade_build_input "$target" "$input_file" "$roots_file"; then
1302
+ build_status=0
1303
+ else
1304
+ build_status="$?"
1305
+ if [[ "$build_status" -eq 3 ]]; then
1306
+ if ! prepare_quiet; then
1307
+ rm -rf "$tmp_dir"
1308
+ return 1
1309
+ fi
1310
+ bundle_write_upgrade_snapshot "$after_file"
1311
+ bundle_print_upgrade_summary "$before_file" "$after_file"
1312
+ rm -rf "$tmp_dir"
1313
+ return
1314
+ fi
1315
+ rm -rf "$tmp_dir"
1316
+ return 1
1317
+ fi
1318
+ if ! bundle_upgrade_resolve "$input_file" "$roots_file" "$output_file" "$pypi_only"; then
1319
+ rm -rf "$tmp_dir"
1320
+ return 1
1321
+ fi
1322
+ if ! validate_requirements_file "$output_file"; then
1323
+ printf 'error: resolved requirements are invalid; requirements unchanged: %s\n' "$requirements_file" >&2
1324
+ rm -rf "$tmp_dir"
1325
+ return 1
1326
+ fi
1327
+
1328
+ if cmp -s "$requirements_file" "$output_file"; then
1329
+ :
1330
+ else
1331
+ cp "$output_file" "$requirements_file"
1332
+ fi
1333
+ if ! prepare_quiet; then
1334
+ rm -rf "$tmp_dir"
1335
+ return 1
1336
+ fi
1337
+ bundle_write_upgrade_snapshot "$after_file"
1338
+ bundle_print_upgrade_summary "$before_file" "$after_file"
1339
+ rm -rf "$tmp_dir"
1340
+ }
1341
+
1342
+ env_file_value() {
1343
+ local env_path="$1"
1344
+ local key="$2"
1345
+ [[ -f "$env_path" ]] || return 1
1346
+
1347
+ awk -v key="$key" '
1348
+ $0 ~ "^[[:space:]]*#" { next }
1349
+ index($0, key "=") == 1 {
1350
+ print substr($0, length(key) + 2)
1351
+ found = 1
1352
+ exit
1353
+ }
1354
+ END {
1355
+ if (!found) {
1356
+ exit 1
1357
+ }
1358
+ }
1359
+ ' "$env_path"
1360
+ }
1361
+
1362
+ set_env_file_value() {
1363
+ local env_path="$1"
1364
+ local key="$2"
1365
+ local value="$3"
1366
+ local tmp_path
1367
+
1368
+ require_file "$env_path"
1369
+ tmp_path="$(mktemp "${TMPDIR:-/tmp}/arbiter-env.XXXXXX")"
1370
+ awk -v key="$key" -v value="$value" '
1371
+ index($0, key "=") == 1 {
1372
+ print key "=" value
1373
+ found = 1
1374
+ next
1375
+ }
1376
+ { print }
1377
+ END {
1378
+ if (!found) {
1379
+ print key "=" value
1380
+ }
1381
+ }
1382
+ ' "$env_path" >"$tmp_path"
1383
+ mv "$tmp_path" "$env_path"
1384
+ }
1385
+
1386
+ compose_env_value() {
1387
+ local key="$1"
1388
+ local default="$2"
1389
+
1390
+ env_file_value "$compose_env_file" "$key" || printf '%s\n' "$default"
1391
+ }
1392
+
1393
+ set_compose_env_value() {
1394
+ local key="$1"
1395
+ local value="$2"
1396
+
1397
+ set_env_file_value "$compose_env_file" "$key" "$value"
1398
+ }
1399
+
1400
+ container_name_value() {
1401
+ compose_env_value ARBITER_CONTAINER_NAME arbiter-staging
1402
+ }
1403
+
1404
+ compose_project_name() {
1405
+ local name="${COMPOSE_PROJECT_NAME:-}"
1406
+
1407
+ if [[ -z "$name" ]]; then
1408
+ name="$(basename -- "$deploy_dir")"
1409
+ fi
1410
+ printf '%s\n' "$name" | tr '[:upper:]' '[:lower:]'
1411
+ }
1412
+
1413
+ deploy_path_from_compose_value() {
1414
+ local value="$1"
1415
+
1416
+ if [[ "$value" = /* ]]; then
1417
+ printf '%s\n' "$value"
1418
+ else
1419
+ printf '%s/%s\n' "$deploy_dir" "${value#./}"
1420
+ fi
1421
+ }
1422
+
1423
+ config_dir_path() {
1424
+ deploy_path_from_compose_value "$(compose_env_value ARBITER_CONFIG_DIR ./conf)"
1425
+ }
1426
+
1427
+ wheels_dir_path() {
1428
+ deploy_path_from_compose_value "$(compose_env_value ARBITER_WHEELS_DIR ./wheels)"
1429
+ }
1430
+
1431
+ plugin_data_dir_path() {
1432
+ deploy_path_from_compose_value "$(compose_env_value ARBITER_PLUGIN_DATA_DIR ./data/plugins)"
1433
+ }
1434
+
1435
+ config_name_value() {
1436
+ compose_env_value ARBITER_CONFIG_NAME arbiter-server
1437
+ }
1438
+
1439
+ app_env_path() {
1440
+ deploy_path_from_compose_value "$(compose_env_value ARBITER_APP_ENV_FILE ./conf/.env)"
1441
+ }
1442
+
1443
+ server_host_url_value() {
1444
+ local host
1445
+
1446
+ host="$(compose_env_value ARBITER_HOST_BIND 127.0.0.1)"
1447
+ case "$host" in
1448
+ 0.0.0.0)
1449
+ printf '127.0.0.1\n'
1450
+ ;;
1451
+ *:*)
1452
+ printf '[%s]\n' "$host"
1453
+ ;;
1454
+ *)
1455
+ printf '%s\n' "$host"
1456
+ ;;
1457
+ esac
1458
+ }
1459
+
1460
+ mcp_url_value() {
1461
+ printf 'http://%s:%s/mcp\n' \
1462
+ "$(server_host_url_value)" \
1463
+ "$(compose_env_value ARBITER_HOST_PORT 18025)"
1464
+ }
1465
+
1466
+ print_mcp_url_value() {
1467
+ local url="$1"
1468
+
1469
+ printf ' '
1470
+ doctor_prefix 32 "✔"
1471
+ printf ' MCP URL: '
1472
+ doctor_prefix 94 "$url"
1473
+ printf '\n'
1474
+ }
1475
+
1476
+ print_mcp_url() {
1477
+ print_mcp_url_value "$(mcp_url_value)"
1478
+ }
1479
+
1480
+ print_staging_port_note() {
1481
+ local host_port
1482
+
1483
+ deployment_is_staged || return 0
1484
+ host_port="$(compose_env_value ARBITER_HOST_PORT 18025)"
1485
+ [[ "$host_port" != 8025 ]] || return 0
1486
+ printf ' '
1487
+ doctor_prefix 32 "✔"
1488
+ printf ' Staging MCP port: 8025 -> %s to prevent collision\n' "$host_port"
1489
+ }
1490
+
1491
+ resolve_arbiter_client_command() {
1492
+ local checkout_client
1493
+
1494
+ if [[ -n "${ARBITER_CLIENT_COMMAND:-}" ]]; then
1495
+ command -v "$ARBITER_CLIENT_COMMAND"
1496
+ return
1497
+ fi
1498
+ if command -v arbiter >/dev/null; then
1499
+ command -v arbiter
1500
+ return
1501
+ fi
1502
+ checkout_client="$deploy_dir/../skill/bin/arbiter"
1503
+ if [[ -x "$checkout_client" ]]; then
1504
+ printf '%s\n' "$checkout_client"
1505
+ return
1506
+ fi
1507
+ return 1
1508
+ }
1509
+
1510
+ test_server_url() {
1511
+ local url="$1"
1512
+ local client_command
1513
+ local output_file
1514
+ local timeout
1515
+ local start_time
1516
+
1517
+ if ! client_command="$(resolve_arbiter_client_command)"; then
1518
+ printf 'error: Arbiter client command not found\n' >&2
1519
+ printf ' activate the environment that provides arbiter, set ARBITER_CLIENT_COMMAND,\n' >&2
1520
+ printf ' or run from a checkout with ../skill/bin/arbiter available\n' >&2
1521
+ return 1
1522
+ fi
1523
+
1524
+ output_file="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-test.XXXXXX")"
1525
+ timeout="${ARBITER_TEST_TIMEOUT:-60}"
1526
+ start_time="$SECONDS"
1527
+ while true; do
1528
+ if "$client_command" mcp call version_info "arbiter.mcp_url=$url" >"$output_file" 2>&1; then
1529
+ rm -f "$output_file"
1530
+ printf ' '
1531
+ doctor_prefix 32 "✔"
1532
+ printf ' MCP test: '
1533
+ doctor_prefix 94 "$url"
1534
+ printf '\n'
1535
+ return 0
1536
+ fi
1537
+ if ((SECONDS - start_time >= timeout)); then
1538
+ break
1539
+ fi
1540
+ sleep 1
1541
+ done
1542
+ rm -f "$output_file"
1543
+ printf ' '
1544
+ doctor_prefix 31 "✘"
1545
+ printf ' MCP test: '
1546
+ doctor_prefix 94 "$url"
1547
+ printf '\n'
1548
+ return 1
1549
+ }
1550
+
1551
+ test_server() {
1552
+ test_server_url "$(mcp_url_value)"
1553
+ }
1554
+
1555
+ config_main_file() {
1556
+ printf '%s/%s.yaml\n' "$(config_dir_path)" "$(config_name_value)"
1557
+ }
1558
+
1559
+ sync_env() {
1560
+ local active_config_dir
1561
+ local active_config_name
1562
+
1563
+ active_config_dir="$(config_dir_path)"
1564
+ active_config_name="$(config_name_value)"
1565
+ require_dir "$active_config_dir"
1566
+ require_file "$(config_main_file)"
1567
+ arbiter-server --config-dir "$active_config_dir" --config-name "$active_config_name" env bootstrap
1568
+ }
1569
+
1570
+ run_editor() {
1571
+ local path="$1"
1572
+ local -a editor=()
1573
+
1574
+ if [[ -n "${ARBITER_EDITOR:-}" ]]; then
1575
+ read -r -a editor <<<"$ARBITER_EDITOR"
1576
+ elif [[ -n "${EDITOR:-}" ]]; then
1577
+ read -r -a editor <<<"$EDITOR"
1578
+ else
1579
+ editor=(vi)
1580
+ fi
1581
+
1582
+ "${editor[@]}" "$path"
1583
+ }
1584
+
1585
+ inspect_existing_container() {
1586
+ local container_name="$1"
1587
+ local output_file="$2"
1588
+
1589
+ docker inspect "$container_name" --format \
1590
+ 'name={{.Name}}
1591
+ project={{index .Config.Labels "com.docker.compose.project"}}
1592
+ service={{index .Config.Labels "com.docker.compose.service"}}
1593
+ config_files={{index .Config.Labels "com.docker.compose.project.config_files"}}
1594
+ working_dir={{index .Config.Labels "com.docker.compose.project.working_dir"}}
1595
+ oneoff={{index .Config.Labels "com.docker.compose.oneoff"}}
1596
+ image={{.Config.Image}}
1597
+ created={{.Created}}
1598
+ status={{.State.Status}}
1599
+ restart={{.HostConfig.RestartPolicy.Name}}' >"$output_file" 2>/dev/null
1600
+ }
1601
+
1602
+ container_info_value() {
1603
+ local info_file="$1"
1604
+ local key="$2"
1605
+
1606
+ awk -F= -v key="$key" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "$info_file"
1607
+ }
1608
+
1609
+ print_container_owner_details() {
1610
+ local info_file="$1"
1611
+ local prefix="$2"
1612
+ local project
1613
+ local service
1614
+ local config_files
1615
+ local working_dir
1616
+ local image
1617
+ local status
1618
+ local restart
1619
+
1620
+ project="$(container_info_value "$info_file" project)"
1621
+ service="$(container_info_value "$info_file" service)"
1622
+ config_files="$(container_info_value "$info_file" config_files)"
1623
+ working_dir="$(container_info_value "$info_file" working_dir)"
1624
+ image="$(container_info_value "$info_file" image)"
1625
+ status="$(container_info_value "$info_file" status)"
1626
+ restart="$(container_info_value "$info_file" restart)"
1627
+
1628
+ if [[ -n "$project" ]]; then
1629
+ printf '%sowner compose project: %s\n' "$prefix" "$project"
1630
+ [[ -n "$service" ]] && printf '%sowner compose service: %s\n' "$prefix" "$service"
1631
+ [[ -n "$working_dir" ]] && printf '%sowner deployment dir: %s\n' "$prefix" "$working_dir"
1632
+ [[ -n "$config_files" ]] && printf '%sowner compose file: %s\n' "$prefix" "$config_files"
1633
+ else
1634
+ printf '%sowner: not labeled as a Docker Compose container\n' "$prefix"
1635
+ fi
1636
+ [[ -n "$image" ]] && printf '%simage: %s\n' "$prefix" "$image"
1637
+ [[ -n "$status" ]] && printf '%sstatus: %s\n' "$prefix" "$status"
1638
+ [[ -n "$restart" ]] && printf '%srestart policy: %s\n' "$prefix" "$restart"
1639
+ }
1640
+
1641
+ check_container_name_owner() {
1642
+ local mode="$1"
1643
+ local container_name
1644
+ local expected_project
1645
+ local info_file
1646
+ local actual_project
1647
+ local actual_config_files
1648
+ local actual_working_dir
1649
+
1650
+ container_name="$(container_name_value)"
1651
+ expected_project="$(compose_project_name)"
1652
+ info_file="$(mktemp "${TMPDIR:-/tmp}/arbiter-container-owner.XXXXXX")"
1653
+
1654
+ if ! inspect_existing_container "$container_name" "$info_file"; then
1655
+ rm -f "$info_file"
1656
+ return 0
1657
+ fi
1658
+
1659
+ actual_project="$(container_info_value "$info_file" project)"
1660
+ actual_config_files="$(container_info_value "$info_file" config_files)"
1661
+ actual_working_dir="$(container_info_value "$info_file" working_dir)"
1662
+
1663
+ if [[ "$actual_project" == "$expected_project" && "$actual_working_dir" == "$deploy_dir" ]]; then
1664
+ rm -f "$info_file"
1665
+ return 0
1666
+ fi
1667
+ if [[ "$actual_project" == "$expected_project" && "$actual_config_files" == "$compose_file" ]]; then
1668
+ rm -f "$info_file"
1669
+ return 0
1670
+ fi
1671
+
1672
+ case "$mode" in
1673
+ error)
1674
+ printf 'error: container name is already owned by another deployment: %s\n' "$container_name" >&2
1675
+ printf ' this deployment dir: %s\n' "$deploy_dir" >&2
1676
+ printf ' this compose project: %s\n' "$expected_project" >&2
1677
+ print_container_owner_details "$info_file" ' ' >&2
1678
+ printf ' note: docker ps shows only running containers; use docker ps -a --filter name=^/%s$ to see stopped containers\n' "$container_name" >&2
1679
+ printf ' stop it with the owning deployment helper, or set ARBITER_CONTAINER_NAME in %s\n' "$compose_env_file" >&2
1680
+ ;;
1681
+ warn)
1682
+ printf 'container name in use by another deployment: %s\n' "$container_name"
1683
+ printf ' this deployment dir: %s\n' "$deploy_dir"
1684
+ printf ' this compose project: %s\n' "$expected_project"
1685
+ print_container_owner_details "$info_file" ' '
1686
+ ;;
1687
+ esac
1688
+
1689
+ rm -f "$info_file"
1690
+ return 1
1691
+ }
1692
+
1693
+ deployment_is_staged() {
1694
+ grep -q 'arbiter\.deployment_scope=staged' "$compose_file"
1695
+ }
1696
+
1697
+ cidr_range() {
1698
+ local cidr="$1"
1699
+ local ip
1700
+ local prefix
1701
+ local a
1702
+ local b
1703
+ local c
1704
+ local d
1705
+ local ip_int
1706
+ local host_count
1707
+ local mask
1708
+ local start
1709
+ local end
1710
+
1711
+ [[ "$cidr" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)/([0-9]+)$ ]] || return 1
1712
+ ip="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}.${BASH_REMATCH[4]}"
1713
+ prefix="${BASH_REMATCH[5]}"
1714
+ IFS=. read -r a b c d <<<"$ip"
1715
+ ((a >= 0 && a <= 255 && b >= 0 && b <= 255 && c >= 0 && c <= 255 && d >= 0 && d <= 255)) || return 1
1716
+ ((prefix >= 0 && prefix <= 32)) || return 1
1717
+
1718
+ ip_int=$(((a << 24) + (b << 16) + (c << 8) + d))
1719
+ host_count=$((1 << (32 - prefix)))
1720
+ mask=$((0xFFFFFFFF ^ (host_count - 1)))
1721
+ start=$((ip_int & mask))
1722
+ end=$((start + host_count - 1))
1723
+ printf '%s %s\n' "$start" "$end"
1724
+ }
1725
+
1726
+ cidr_overlaps() {
1727
+ local left="$1"
1728
+ local right="$2"
1729
+ local left_range
1730
+ local right_range
1731
+ local left_start
1732
+ local left_end
1733
+ local right_start
1734
+ local right_end
1735
+
1736
+ left_range="$(cidr_range "$left")" || return 1
1737
+ right_range="$(cidr_range "$right")" || return 1
1738
+ read -r left_start left_end <<<"$left_range"
1739
+ read -r right_start right_end <<<"$right_range"
1740
+ ((left_start <= right_end && right_start <= left_end))
1741
+ }
1742
+
1743
+ docker_network_subnet_lines() {
1744
+ local network_ids
1745
+
1746
+ network_ids="$(docker network ls -q)" || return 1
1747
+ [[ -n "$network_ids" ]] || return 0
1748
+ docker network inspect $network_ids --format '{{.Name}} {{range .IPAM.Config}}{{.Subnet}} {{end}}' 2>/dev/null
1749
+ }
1750
+
1751
+ docker_subnet_conflict() {
1752
+ local candidate_subnet="$1"
1753
+ local configured_network="$2"
1754
+ local network_line
1755
+ local network_name
1756
+ local network_subnets
1757
+ local network_subnet
1758
+
1759
+ while IFS= read -r network_line; do
1760
+ [[ -n "$network_line" ]] || continue
1761
+ network_name="${network_line%% *}"
1762
+ network_subnets="${network_line#* }"
1763
+ [[ "$network_subnets" != "$network_line" ]] || continue
1764
+ [[ "$network_name" != "$configured_network" ]] || continue
1765
+ for network_subnet in $network_subnets; do
1766
+ [[ "$network_subnet" == */* ]] || continue
1767
+ if cidr_overlaps "$candidate_subnet" "$network_subnet"; then
1768
+ return 0
1769
+ fi
1770
+ done
1771
+ done < <(docker_network_subnet_lines)
1772
+
1773
+ return 1
1774
+ }
1775
+
1776
+ staging_subnet_candidates() {
1777
+ local third_octet
1778
+
1779
+ printf '172.31.251.0/24\n'
1780
+ for third_octet in {200..239}; do
1781
+ printf '10.213.%s.0/24\n' "$third_octet"
1782
+ done
1783
+ for third_octet in {200..239}; do
1784
+ printf '192.168.%s.0/24\n' "$third_octet"
1785
+ done
1786
+ for third_octet in {240..249}; do
1787
+ printf '172.31.%s.0/24\n' "$third_octet"
1788
+ done
1789
+ }
1790
+
1791
+ ensure_staging_subnet_available() {
1792
+ local configured_network
1793
+ local configured_subnet
1794
+ local candidate_subnet
1795
+
1796
+ deployment_is_staged || return 0
1797
+ configured_network="$(compose_env_value ARBITER_DOCKER_NETWORK_NAME arbiter-staging)"
1798
+ configured_subnet="$(compose_env_value ARBITER_DOCKER_SUBNET 172.31.251.0/24)"
1799
+ [[ -n "$configured_subnet" ]] || return 0
1800
+
1801
+ if ! docker_subnet_conflict "$configured_subnet" "$configured_network"; then
1802
+ return 0
1803
+ fi
1804
+
1805
+ while IFS= read -r candidate_subnet; do
1806
+ [[ -n "$candidate_subnet" ]] || continue
1807
+ if ! docker_subnet_conflict "$candidate_subnet" "$configured_network"; then
1808
+ set_compose_env_value ARBITER_DOCKER_SUBNET "$candidate_subnet"
1809
+ printf 'updated staging Docker subnet: %s -> %s\n' "$configured_subnet" "$candidate_subnet"
1810
+ return 0
1811
+ fi
1812
+ done < <(staging_subnet_candidates)
1813
+
1814
+ printf 'error: could not find an unused staging Docker subnet\n' >&2
1815
+ return 1
1816
+ }
1817
+
1818
+ compose() {
1819
+ local active_config_dir
1820
+
1821
+ require_file "$compose_file"
1822
+ active_config_dir="$(config_dir_path)"
1823
+ require_dir "$active_config_dir"
1824
+ require_file "$(config_main_file)"
1825
+ require_file "$(app_env_path)"
1826
+ require_file "$compose_env_file"
1827
+ require_file "$requirements_file"
1828
+ validate_requirements_file "$requirements_file"
1829
+ if [[ "${1:-}" == up ]]; then
1830
+ ensure_plugin_data_dir_ready
1831
+ fi
1832
+ require_docker_access
1833
+ if [[ "${1:-}" == up ]]; then
1834
+ ensure_staging_subnet_available
1835
+ check_container_name_owner error
1836
+ fi
1837
+
1838
+ cd "$deploy_dir"
1839
+ if [[ -f "$compose_override_file" ]]; then
1840
+ docker compose --env-file "$compose_env_file" -f "$compose_file" -f "$compose_override_file" "$@"
1841
+ else
1842
+ docker compose --env-file "$compose_env_file" -f "$compose_file" "$@"
1843
+ fi
1844
+ }
1845
+
1846
+ docker_access_error_matches_permission() {
1847
+ grep -Eiq 'permission denied|permission.*docker|docker.*permission' "$1"
1848
+ }
1849
+
1850
+ print_docker_access_error() {
1851
+ local output_file="$1"
1852
+ local user_name
1853
+ local groups_line
1854
+
1855
+ user_name="$(id -un 2>/dev/null || printf '%s' "${USER:-unknown}")"
1856
+ groups_line="$(id -nG 2>/dev/null || true)"
1857
+
1858
+ printf 'error: Docker daemon is not accessible by user %s\n' "$user_name" >&2
1859
+ if [[ -n "$groups_line" ]]; then
1860
+ printf ' current groups: %s\n' "$groups_line" >&2
1861
+ fi
1862
+ printf ' run as a user with Docker access, for example:\n' >&2
1863
+ printf ' sudo usermod -aG docker %s\n' "$user_name" >&2
1864
+ printf ' then log out and back in so group membership is refreshed\n' >&2
1865
+ printf ' alternatively run this command with sudo or use the installed systemd service\n' >&2
1866
+ if [[ -s "$output_file" ]]; then
1867
+ printf ' docker said: %s\n' "$(tr '\n' ' ' <"$output_file" | sed 's/[[:space:]]*$//')" >&2
1868
+ fi
1869
+ }
1870
+
1871
+ require_docker_access() {
1872
+ local output_file
1873
+
1874
+ if ! command -v docker >/dev/null; then
1875
+ printf 'error: docker command not found\n' >&2
1876
+ return 1
1877
+ fi
1878
+
1879
+ output_file="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-access.XXXXXX")"
1880
+ if docker info >/dev/null 2>"$output_file"; then
1881
+ rm -f "$output_file"
1882
+ return 0
1883
+ fi
1884
+
1885
+ if docker_access_error_matches_permission "$output_file"; then
1886
+ print_docker_access_error "$output_file"
1887
+ else
1888
+ cat "$output_file" >&2
1889
+ printf 'error: Docker daemon is not available\n' >&2
1890
+ fi
1891
+ rm -f "$output_file"
1892
+ return 1
1893
+ }
1894
+
1895
+ doctor_check_docker_access() {
1896
+ local output_file
1897
+
1898
+ if ! command -v docker >/dev/null; then
1899
+ doctor_fail "docker command not found"
1900
+ return
1901
+ fi
1902
+
1903
+ output_file="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-access.XXXXXX")"
1904
+ if docker info >/dev/null 2>"$output_file"; then
1905
+ doctor_ok "Docker daemon is accessible"
1906
+ elif docker_access_error_matches_permission "$output_file"; then
1907
+ doctor_fail "Docker daemon is not accessible by this user"
1908
+ doctor_detail "run as a user with Docker access, or add this user to the docker group and log out/back in"
1909
+ else
1910
+ doctor_fail "Docker daemon is not available"
1911
+ if [[ -s "$output_file" ]]; then
1912
+ doctor_detail "$(tr '\n' ' ' <"$output_file" | sed 's/[[:space:]]*$//')"
1913
+ fi
1914
+ fi
1915
+ rm -f "$output_file"
1916
+ }
1917
+
1918
+ doctor_check_container_name_owner() {
1919
+ local container_name
1920
+
1921
+ if ! command -v docker >/dev/null || ! docker info >/dev/null 2>&1; then
1922
+ return
1923
+ fi
1924
+
1925
+ container_name="$(container_name_value)"
1926
+ if check_container_name_owner quiet; then
1927
+ doctor_ok "container name is not owned by another deployment: $container_name"
1928
+ else
1929
+ doctor_warn "container name is in use by another deployment: $container_name"
1930
+ check_container_name_owner warn || true
1931
+ fi
1932
+ }
1933
+
1934
+ manifest_compose_hash() {
1935
+ local manifest_file="$deploy_dir/.arbiter-deploy.json"
1936
+
1937
+ [[ -f "$manifest_file" ]] || return 1
1938
+ awk '
1939
+ /"compose.yaml"/ { in_compose = 1 }
1940
+ in_compose && /"sha256"/ {
1941
+ line = $0
1942
+ sub(/^.*"sha256"[[:space:]]*:[[:space:]]*"/, "", line)
1943
+ sub(/".*$/, "", line)
1944
+ print line
1945
+ found = 1
1946
+ exit
1947
+ }
1948
+ END { exit found ? 0 : 1 }
1949
+ ' "$manifest_file"
1950
+ }
1951
+
1952
+ file_sha256() {
1953
+ local path="$1"
1954
+
1955
+ if command -v sha256sum >/dev/null; then
1956
+ sha256sum "$path" | awk '{print $1}'
1957
+ else
1958
+ shasum -a 256 "$path" | awk '{print $1}'
1959
+ fi
1960
+ }
1961
+
1962
+ compose_file_matches_manifest() {
1963
+ local expected_hash
1964
+ local current_hash
1965
+
1966
+ require_file "$compose_file"
1967
+ expected_hash="$(manifest_compose_hash)" || return 1
1968
+ [[ -n "$expected_hash" ]] || return 1
1969
+ if ! command -v sha256sum >/dev/null && ! command -v shasum >/dev/null; then
1970
+ return 1
1971
+ fi
1972
+ current_hash="$(file_sha256 "$compose_file")"
1973
+ [[ "$current_hash" == "$expected_hash" ]]
1974
+ }
1975
+
1976
+ compose_manifest_mismatch_reason() {
1977
+ local manifest_file="$deploy_dir/.arbiter-deploy.json"
1978
+ local expected_hash
1979
+ local current_hash
1980
+
1981
+ if [[ ! -f "$manifest_file" ]]; then
1982
+ printf 'deployment manifest is missing'
1983
+ return
1984
+ fi
1985
+ expected_hash="$(manifest_compose_hash || true)"
1986
+ if [[ -z "$expected_hash" ]]; then
1987
+ printf 'compose.yaml is not recorded in the deployment manifest'
1988
+ return
1989
+ fi
1990
+ if ! command -v sha256sum >/dev/null && ! command -v shasum >/dev/null; then
1991
+ printf 'sha256sum or shasum is not available'
1992
+ return
1993
+ fi
1994
+ current_hash="$(file_sha256 "$compose_file")"
1995
+ if [[ "$current_hash" != "$expected_hash" ]]; then
1996
+ printf 'compose.yaml has local edits'
1997
+ return
1998
+ fi
1999
+ printf 'compose.yaml manifest ownership could not be verified'
2000
+ }
2001
+
2002
+ args_include() {
2003
+ local expected="$1"
2004
+ shift
2005
+ local arg
2006
+
2007
+ for arg in "$@"; do
2008
+ [[ "$arg" == "$expected" ]] && return 0
2009
+ done
2010
+ return 1
2011
+ }
2012
+
2013
+ args_array_include() {
2014
+ local expected="$1"
2015
+ shift
2016
+
2017
+ if [[ "$#" -eq 0 ]]; then
2018
+ return 1
2019
+ fi
2020
+ args_include "$expected" "$@"
2021
+ }
2022
+
2023
+ compose_down() {
2024
+ local -a args=("$@")
2025
+ local has_remove_orphans=0
2026
+ local reason
2027
+
2028
+ if [[ "${#args[@]}" -gt 0 ]] && args_array_include --remove-orphans "${args[@]}"; then
2029
+ has_remove_orphans=1
2030
+ fi
2031
+
2032
+ if compose_file_matches_manifest && [[ "$has_remove_orphans" -eq 0 ]]; then
2033
+ if [[ "${#args[@]}" -gt 0 ]]; then
2034
+ args=(--remove-orphans "${args[@]}")
2035
+ else
2036
+ args=(--remove-orphans)
2037
+ fi
2038
+ elif [[ "$has_remove_orphans" -eq 0 ]]; then
2039
+ reason="$(compose_manifest_mismatch_reason)"
2040
+ printf 'not removing orphan containers automatically: %s\n' "$reason" >&2
2041
+ printf 'pass --remove-orphans to down if you want to remove stale services\n' >&2
2042
+ fi
2043
+ if [[ "${#args[@]}" -gt 0 ]]; then
2044
+ compose down "${args[@]}"
2045
+ else
2046
+ compose down
2047
+ fi
2048
+ }
2049
+
2050
+ info() {
2051
+ printf 'deploy dir: %s\n' "$deploy_dir"
2052
+ printf 'compose file: %s\n' "$compose_file"
2053
+ if [[ -f "$compose_override_file" ]]; then
2054
+ printf 'compose override file: %s\n' "$compose_override_file"
2055
+ fi
2056
+ printf 'compose project: %s\n' "$(compose_project_name)"
2057
+ printf 'container name: %s\n' "$(container_name_value)"
2058
+ printf 'config dir: %s\n' "$(config_dir_path)"
2059
+ printf 'config name: %s\n' "$(config_name_value)"
2060
+ printf 'app env file: %s\n' "$(app_env_path)"
2061
+ printf 'docker env file: %s\n' "$compose_env_file"
2062
+ printf 'requirements file: %s\n' "$requirements_file"
2063
+ printf 'wheels dir: %s\n' "$(wheels_dir_path)"
2064
+ printf 'plugin data dir: %s\n' "$(plugin_data_dir_path)"
2065
+ print_mcp_url
2066
+ if command -v docker >/dev/null && docker info >/dev/null 2>&1; then
2067
+ check_container_name_owner warn || true
2068
+ fi
2069
+ docker compose version || true
2070
+ }
2071
+
2072
+ doctor_status=0
2073
+ doctor_agent_uid=""
2074
+ doctor_agent_user=""
2075
+ doctor_agent_groups=""
2076
+ doctor_agent_group_names=""
2077
+ doctor_preinstall=0
2078
+ doctor_quiet=0
2079
+
2080
+ color_enabled() {
2081
+ [[ "${ARBITER_COLOR:-}" == always ]] && return 0
2082
+ [[ "${ARBITER_COLOR:-}" == never ]] && return 1
2083
+ [[ -t 1 ]]
2084
+ }
2085
+
2086
+ doctor_prefix() {
2087
+ local color="$1"
2088
+ local label="$2"
2089
+
2090
+ if color_enabled; then
2091
+ printf '\033[%sm%s\033[0m' "$color" "$label"
2092
+ else
2093
+ printf '%s' "$label"
2094
+ fi
2095
+ }
2096
+
2097
+ doctor_line() {
2098
+ local color="$1"
2099
+ local label="$2"
2100
+ local message="$3"
2101
+
2102
+ doctor_prefix "$color" "$label"
2103
+ printf ': %s\n' "$message"
2104
+ }
2105
+
2106
+ doctor_ok() {
2107
+ [[ "${doctor_quiet:-0}" -eq 1 ]] && return
2108
+ doctor_line 32 ok "$1"
2109
+ }
2110
+
2111
+ doctor_warn() {
2112
+ doctor_line 33 warn "$1"
2113
+ }
2114
+
2115
+ doctor_detail() {
2116
+ printf ' %s\n' "$1"
2117
+ }
2118
+
2119
+ doctor_fail() {
2120
+ doctor_line 31 fail "$1"
2121
+ doctor_status=1
2122
+ }
2123
+
2124
+ doctor_check_file() {
2125
+ local path="$1"
2126
+ local description="$2"
2127
+
2128
+ if [[ -f "$path" ]]; then
2129
+ doctor_ok "$description exists: $path"
2130
+ else
2131
+ doctor_fail "$description is missing: $path"
2132
+ fi
2133
+ }
2134
+
2135
+ path_is_under_deploy_dir() {
2136
+ local path="$1"
2137
+ local real_deploy_dir
2138
+ local real_path
2139
+
2140
+ real_deploy_dir="$(normalize_existing_path "$deploy_dir")"
2141
+ real_path="$(normalize_existing_path "$path")"
2142
+ [[ "$real_path" == "$real_deploy_dir" || "$real_path" == "$real_deploy_dir/"* ]]
2143
+ }
2144
+
2145
+ normalize_existing_path() {
2146
+ local path="$1"
2147
+
2148
+ if command -v readlink >/dev/null && readlink -f "$path" >/dev/null 2>&1; then
2149
+ readlink -f "$path"
2150
+ elif command -v realpath >/dev/null; then
2151
+ realpath "$path"
2152
+ else
2153
+ python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$path"
2154
+ fi
2155
+ }
2156
+
2157
+ normalize_path_allow_missing() {
2158
+ local path="$1"
2159
+
2160
+ if command -v realpath >/dev/null && realpath -m / >/dev/null 2>&1; then
2161
+ realpath -m -- "$path"
2162
+ elif command -v readlink >/dev/null && readlink -m / >/dev/null 2>&1; then
2163
+ readlink -m -- "$path"
2164
+ else
2165
+ python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "$path"
2166
+ fi
2167
+ }
2168
+
2169
+ path_is_under_deploy_dir_allow_missing() {
2170
+ local path="$1"
2171
+ local real_deploy_dir
2172
+ local real_path
2173
+
2174
+ real_deploy_dir="$(normalize_path_allow_missing "$deploy_dir")"
2175
+ real_path="$(normalize_path_allow_missing "$path")"
2176
+ [[ "$real_path" == "$real_deploy_dir" || "$real_path" == "$real_deploy_dir/"* ]]
2177
+ }
2178
+
2179
+ doctor_check_path_under_deploy_dir() {
2180
+ local path="$1"
2181
+ local description="$2"
2182
+
2183
+ [[ -e "$path" ]] || return
2184
+ if path_is_under_deploy_dir "$path"; then
2185
+ doctor_ok "$description is inside deployment directory"
2186
+ else
2187
+ doctor_fail "$description is outside deployment directory: $path"
2188
+ fi
2189
+ }
2190
+
2191
+ doctor_check_install_deploy_path() {
2192
+ local value="$1"
2193
+ local description="$2"
2194
+ local env_name="$3"
2195
+ local path
2196
+ local real_deploy_dir
2197
+ local real_path
2198
+
2199
+ if [[ "$value" = /* ]]; then
2200
+ doctor_fail "$description uses an absolute host path for install: $env_name=$value"
2201
+ printf ' edit docker.env to use a path relative to the deployment directory\n'
2202
+ return
2203
+ fi
2204
+
2205
+ path="$(deploy_path_from_compose_value "$value")"
2206
+ real_deploy_dir="$(normalize_path_allow_missing "$deploy_dir")"
2207
+ real_path="$(normalize_path_allow_missing "$path")"
2208
+ if [[ "$real_path" != "$real_deploy_dir" && "$real_path" != "$real_deploy_dir/"* ]]; then
2209
+ doctor_fail "$description is outside deployment directory: $path"
2210
+ printf ' edit docker.env to use a path inside the deployment directory\n'
2211
+ return
2212
+ fi
2213
+ if [[ "$real_path" == "$real_deploy_dir" ]]; then
2214
+ doctor_fail "$description resolves to the deployment directory root: $env_name=$value"
2215
+ printf ' edit docker.env to use a dedicated path inside the deployment directory\n'
2216
+ return
2217
+ fi
2218
+ doctor_ok "$description is inside deployment directory"
2219
+ }
2220
+
2221
+ doctor_check_dir() {
2222
+ local path="$1"
2223
+ local description="$2"
2224
+
2225
+ if [[ -d "$path" ]]; then
2226
+ doctor_ok "$description exists: $path"
2227
+ else
2228
+ doctor_fail "$description is missing: $path"
2229
+ fi
2230
+ }
2231
+
2232
+ container_user_write_digit() {
2233
+ local path="$1"
2234
+ local container_user
2235
+ local container_uid
2236
+ local container_gid
2237
+ local owner_uid
2238
+ local owner_gid
2239
+ local mode
2240
+ local perms
2241
+
2242
+ [[ -d "$path" ]] || return 1
2243
+ container_user="$(env_file_value "$compose_env_file" ARBITER_CONTAINER_USER || true)"
2244
+ [[ "$container_user" =~ ^[0-9]+:[0-9]+$ ]] || return 1
2245
+ container_uid="${container_user%%:*}"
2246
+ container_gid="${container_user#*:}"
2247
+
2248
+ read -r owner_uid owner_gid mode < <(stat_owner_group_mode "$path")
2249
+ perms="${mode: -3}"
2250
+ if [[ "$container_uid" == "$owner_uid" ]]; then
2251
+ printf '%s\n' "${perms:0:1}"
2252
+ elif [[ "$container_gid" == "$owner_gid" ]]; then
2253
+ printf '%s\n' "${perms:1:1}"
2254
+ else
2255
+ printf '%s\n' "${perms:2:1}"
2256
+ fi
2257
+ }
2258
+
2259
+ container_user_can_write_dir() {
2260
+ local path="$1"
2261
+ local digit
2262
+
2263
+ digit="$(container_user_write_digit "$path")" || return 1
2264
+ (( (10#$digit & 2) != 0 && (10#$digit & 1) != 0 ))
2265
+ }
2266
+
2267
+ ensure_plugin_data_dir_ready() {
2268
+ local path
2269
+ local container_user
2270
+ local write_check
2271
+
2272
+ path="$(plugin_data_dir_path)"
2273
+ container_user="$(env_file_value "$compose_env_file" ARBITER_CONTAINER_USER || true)"
2274
+
2275
+ if ! mkdir -p "$path"; then
2276
+ printf 'error: could not create plugin data directory: %s\n' "$path" >&2
2277
+ return 1
2278
+ fi
2279
+ if ! chmod 700 "$path"; then
2280
+ printf 'error: could not secure plugin data directory: %s\n' "$path" >&2
2281
+ printf ' chown it to the deployment helper user or run this command as root\n' >&2
2282
+ return 1
2283
+ fi
2284
+
2285
+ write_check="$path/.arbiter-write-check.$$"
2286
+ if ! : >"$write_check" 2>/dev/null; then
2287
+ printf 'error: plugin data directory is not writable: %s\n' "$path" >&2
2288
+ printf ' chown or chmod it so the deployment helper can prepare plugin state\n' >&2
2289
+ return 1
2290
+ fi
2291
+ rm -f "$write_check"
2292
+
2293
+ if container_user_can_write_dir "$path"; then
2294
+ return 0
2295
+ fi
2296
+
2297
+ if [[ "$(id -u)" == "0" && "$container_user" =~ ^[0-9]+:[0-9]+$ ]]; then
2298
+ chown "$container_user" "$path"
2299
+ chmod 700 "$path"
2300
+ fi
2301
+
2302
+ if ! container_user_can_write_dir "$path"; then
2303
+ printf 'error: plugin data directory is not writable by container user: %s\n' "$container_user" >&2
2304
+ printf ' plugin data dir: %s\n' "$path" >&2
2305
+ printf ' chown or chmod it so the container user can create plugin state\n' >&2
2306
+ return 1
2307
+ fi
2308
+ }
2309
+
2310
+ doctor_check_plugin_data_permissions() {
2311
+ local path="$1"
2312
+ local mode
2313
+ local perms
2314
+
2315
+ [[ -d "$path" ]] || return
2316
+ mode="$(stat_mode "$path")"
2317
+ perms="${mode: -3}"
2318
+ if (( (10#${perms:1:1} & 7) != 0 || (10#${perms:2:1} & 7) != 0 )); then
2319
+ doctor_fail "plugin data directory is accessible outside its owner: $path"
2320
+ doctor_detail "run $deploy_dir/arbiter-docker up or chmod 700 $path"
2321
+ else
2322
+ doctor_ok "plugin data directory is only accessible by its owner"
2323
+ fi
2324
+ }
2325
+
2326
+ doctor_check_plugin_data_compose_wiring() {
2327
+ if grep -Eq '^[[:space:]]*-[[:space:]]*\$\{ARBITER_PLUGIN_DATA_DIR:-\./data/plugins\}:/data/plugins[[:space:]]*$' "$compose_file"; then
2328
+ doctor_ok "compose mounts plugin data directory at /data/plugins"
2329
+ else
2330
+ doctor_fail "compose file does not mount plugin data directory at /data/plugins"
2331
+ doctor_detail "run $deploy_dir/arbiter-docker update to refresh generated Docker files"
2332
+ fi
2333
+
2334
+ if grep -Fq '"arbiter.storage.plugin_data_dir=/data/plugins"' "$compose_file"; then
2335
+ doctor_ok "server config points plugin data storage at /data/plugins"
2336
+ else
2337
+ doctor_fail "compose file does not configure arbiter.storage.plugin_data_dir=/data/plugins"
2338
+ doctor_detail "run $deploy_dir/arbiter-docker update to refresh generated Docker files"
2339
+ fi
2340
+ }
2341
+
2342
+ doctor_check_container_user_can_write_dir() {
2343
+ local path="$1"
2344
+ local description="$2"
2345
+ local container_user
2346
+
2347
+ [[ -d "$path" ]] || return
2348
+ container_user="$(env_file_value "$compose_env_file" ARBITER_CONTAINER_USER || true)"
2349
+ [[ "$container_user" =~ ^[0-9]+:[0-9]+$ ]] || return
2350
+
2351
+ if container_user_can_write_dir "$path"; then
2352
+ doctor_ok "$description is writable by container user: $container_user"
2353
+ else
2354
+ doctor_fail "$description is not writable by container user: $container_user"
2355
+ doctor_detail "chown or chmod $path so $container_user can create plugin state"
2356
+ fi
2357
+ }
2358
+
2359
+ doctor_check_env_file() {
2360
+ local path="$1"
2361
+ local description="$2"
2362
+
2363
+ if [[ ! -f "$path" ]]; then
2364
+ return
2365
+ fi
2366
+
2367
+ if awk '
2368
+ /^[[:space:]]*($|#)/ { next }
2369
+ /^[A-Za-z_][A-Za-z0-9_]*=/ { next }
2370
+ {
2371
+ printf "%s:%d: invalid env assignment: %s\n", FILENAME, FNR, $0
2372
+ invalid = 1
2373
+ }
2374
+ END { exit invalid }
2375
+ ' "$path"; then
2376
+ doctor_ok "$description has valid KEY=VALUE lines"
2377
+ else
2378
+ doctor_fail "$description contains invalid env lines"
2379
+ fi
2380
+ }
2381
+
2382
+ doctor_file_other_perm_digit() {
2383
+ local path="$1"
2384
+ local mode
2385
+ local perms
2386
+
2387
+ mode="$(stat_mode "$path")"
2388
+ perms="${mode: -3}"
2389
+ printf '%s\n' "${perms:2:1}"
2390
+ }
2391
+
2392
+ doctor_file_group_perm_digit() {
2393
+ local path="$1"
2394
+ local mode
2395
+ local perms
2396
+
2397
+ mode="$(stat_mode "$path")"
2398
+ perms="${mode: -3}"
2399
+ printf '%s\n' "${perms:1:1}"
2400
+ }
2401
+
2402
+ stat_mode() {
2403
+ local path="$1"
2404
+
2405
+ if stat -c '%a' "$path" >/dev/null 2>&1; then
2406
+ stat -c '%a' "$path"
2407
+ else
2408
+ stat -f '%Lp' "$path"
2409
+ fi
2410
+ }
2411
+
2412
+ stat_owner_group_mode() {
2413
+ local path="$1"
2414
+
2415
+ if stat -c '%u %g %a' "$path" >/dev/null 2>&1; then
2416
+ stat -c '%u %g %a' "$path"
2417
+ else
2418
+ stat -f '%u %g %Lp' "$path"
2419
+ fi
2420
+ }
2421
+
2422
+ doctor_check_config_file_permissions() {
2423
+ local config_dir="$1"
2424
+ local app_env="$2"
2425
+ local app_env_real
2426
+ local file
2427
+ local file_real
2428
+ local other_digit
2429
+ local checked=0
2430
+ local failed=0
2431
+
2432
+ [[ -d "$config_dir" ]] || return
2433
+ app_env_real="$(normalize_path_allow_missing "$app_env")"
2434
+
2435
+ while IFS= read -r -d '' file; do
2436
+ file_real="$(normalize_path_allow_missing "$file")"
2437
+ [[ "$file_real" == "$app_env_real" ]] && continue
2438
+ checked=1
2439
+ other_digit="$(doctor_file_other_perm_digit "$file")"
2440
+ if (( (10#$other_digit & 6) != 0 )); then
2441
+ doctor_fail "config file is world-readable or world-writable: $file"
2442
+ failed=1
2443
+ fi
2444
+ done < <(find "$config_dir" -type f -print0)
2445
+
2446
+ [[ "$checked" -eq 1 ]] || return
2447
+ if [[ "$failed" -eq 0 ]]; then
2448
+ doctor_ok "config files are not world-readable or world-writable"
2449
+ fi
2450
+ }
2451
+
2452
+ doctor_check_app_env_permissions() {
2453
+ local path="$1"
2454
+ local group_digit
2455
+ local other_digit
2456
+
2457
+ [[ -f "$path" ]] || return
2458
+ group_digit="$(doctor_file_group_perm_digit "$path")"
2459
+ other_digit="$(doctor_file_other_perm_digit "$path")"
2460
+ if (( (10#$group_digit & 6) != 0 || (10#$other_digit & 6) != 0 )); then
2461
+ doctor_fail "app env file is readable or writable outside its owner: $path"
2462
+ else
2463
+ doctor_ok "app env file is only readable and writable by its owner"
2464
+ fi
2465
+ }
2466
+
2467
+ doctor_check_container_user_config() {
2468
+ local container_user
2469
+
2470
+ if ! grep -Eq '^[[:space:]]*user:[[:space:]]*\$\{ARBITER_CONTAINER_USER:-' "$compose_file"; then
2471
+ doctor_fail "compose service does not use ARBITER_CONTAINER_USER"
2472
+ return
2473
+ fi
2474
+ if ! container_user="$(env_file_value "$compose_env_file" ARBITER_CONTAINER_USER)"; then
2475
+ doctor_fail "docker env is missing ARBITER_CONTAINER_USER"
2476
+ return
2477
+ fi
2478
+ if [[ ! "$container_user" =~ ^[0-9]+:[0-9]+$ ]]; then
2479
+ doctor_fail "ARBITER_CONTAINER_USER must be numeric uid:gid, got: $container_user"
2480
+ return
2481
+ fi
2482
+ if [[ "$container_user" =~ ^0: || "$container_user" =~ :0$ ]]; then
2483
+ doctor_fail "ARBITER_CONTAINER_USER must not be root: $container_user"
2484
+ return
2485
+ fi
2486
+ doctor_ok "container user is non-root: $container_user"
2487
+ }
2488
+
2489
+ doctor_check_requirements_file() {
2490
+ local path="$1"
2491
+
2492
+ if [[ ! -f "$path" ]]; then
2493
+ return
2494
+ fi
2495
+
2496
+ if validate_requirements_file "$path"; then
2497
+ doctor_ok "requirements file entries are syntactically valid"
2498
+ else
2499
+ doctor_fail "requirements file contains unpinned package requirements"
2500
+ fi
2501
+ }
2502
+
2503
+ doctor_group_contains() {
2504
+ local gid="$1"
2505
+ local group
2506
+
2507
+ for group in $doctor_agent_groups; do
2508
+ [[ "$group" == "$gid" ]] && return 0
2509
+ done
2510
+ return 1
2511
+ }
2512
+
2513
+ doctor_agent_perm_digit() {
2514
+ local path="$1"
2515
+ local owner_uid
2516
+ local owner_gid
2517
+ local mode
2518
+ local perms
2519
+
2520
+ read -r owner_uid owner_gid mode < <(stat_owner_group_mode "$path")
2521
+ perms="${mode: -3}"
2522
+
2523
+ if [[ "$doctor_agent_uid" == "$owner_uid" ]]; then
2524
+ printf '%s\n' "${perms:0:1}"
2525
+ elif doctor_group_contains "$owner_gid"; then
2526
+ printf '%s\n' "${perms:1:1}"
2527
+ else
2528
+ printf '%s\n' "${perms:2:1}"
2529
+ fi
2530
+ }
2531
+
2532
+ doctor_agent_has_perm() {
2533
+ local path="$1"
2534
+ local bit="$2"
2535
+ local digit
2536
+
2537
+ [[ -e "$path" ]] || return 1
2538
+ digit="$(doctor_agent_perm_digit "$path")"
2539
+ (( (10#$digit & bit) != 0 ))
2540
+ }
2541
+
2542
+ doctor_check_agent_cannot_read() {
2543
+ local path="$1"
2544
+ local description="$2"
2545
+
2546
+ [[ -e "$path" ]] || return
2547
+ if doctor_agent_has_perm "$path" 4; then
2548
+ doctor_fail "$description is readable by agent identity: $path"
2549
+ else
2550
+ doctor_ok "$description is not readable by agent identity"
2551
+ fi
2552
+ }
2553
+
2554
+ doctor_check_agent_cannot_write_file() {
2555
+ local path="$1"
2556
+ local description="$2"
2557
+
2558
+ [[ -e "$path" ]] || return
2559
+ if doctor_agent_has_perm "$path" 2; then
2560
+ doctor_fail "$description is writable by agent identity: $path"
2561
+ else
2562
+ doctor_ok "$description is not writable by agent identity"
2563
+ fi
2564
+ }
2565
+
2566
+ doctor_check_agent_cannot_write_dir() {
2567
+ local path="$1"
2568
+ local description="$2"
2569
+
2570
+ [[ -d "$path" ]] || return
2571
+ if doctor_agent_has_perm "$path" 2 && doctor_agent_has_perm "$path" 1; then
2572
+ doctor_fail "$description is writable by agent identity: $path"
2573
+ else
2574
+ doctor_ok "$description is not writable by agent identity"
2575
+ fi
2576
+ }
2577
+
2578
+ doctor_dir_has_sticky_bit() {
2579
+ local path="$1"
2580
+ local mode
2581
+ local special
2582
+
2583
+ mode="$(stat_mode "$path")"
2584
+ special=$((10#$mode / 1000 % 10))
2585
+ (( (special & 1) != 0 ))
2586
+ }
2587
+
2588
+ doctor_check_agent_cannot_replace_deploy_dir() {
2589
+ local deploy_parent
2590
+
2591
+ deploy_parent="$(dirname "$deploy_dir")"
2592
+ [[ -d "$deploy_parent" ]] || return
2593
+
2594
+ if ! doctor_agent_has_perm "$deploy_parent" 2 || ! doctor_agent_has_perm "$deploy_parent" 1; then
2595
+ doctor_ok "deployment parent directory does not allow agent replacement"
2596
+ return
2597
+ fi
2598
+
2599
+ if doctor_dir_has_sticky_bit "$deploy_parent"; then
2600
+ doctor_warn "deployment parent directory is writable but sticky: $deploy_parent"
2601
+ return
2602
+ fi
2603
+
2604
+ doctor_fail "deployment parent directory allows agent replacement: $deploy_parent"
2605
+ }
2606
+
2607
+ doctor_check_docker_socket() {
2608
+ local socket="/var/run/docker.sock"
2609
+
2610
+ if [[ ! -e "$socket" ]]; then
2611
+ doctor_warn "Docker socket not found at $socket"
2612
+ return
2613
+ fi
2614
+
2615
+ if doctor_agent_has_perm "$socket" 2; then
2616
+ doctor_fail "Docker socket is writable by agent identity: $socket"
2617
+ else
2618
+ doctor_ok "Docker socket is not writable by agent identity"
2619
+ fi
2620
+ }
2621
+
2622
+ doctor_check_preinstall_ready() {
2623
+ local active_config_dir
2624
+ local active_app_env
2625
+ local active_requirements_file
2626
+ local active_wheels_dir
2627
+ local active_plugin_data_dir
2628
+ local wheel_requirement
2629
+ local wheel_path
2630
+
2631
+ active_config_dir="$(config_dir_path)"
2632
+ active_app_env="$(app_env_path)"
2633
+ active_requirements_file="$(deploy_path_from_compose_value "$(compose_env_value ARBITER_REQUIREMENTS_FILE ./requirements.txt)")"
2634
+ active_wheels_dir="$(wheels_dir_path)"
2635
+ active_plugin_data_dir="$(plugin_data_dir_path)"
2636
+
2637
+ doctor_check_install_deploy_path "$(compose_env_value ARBITER_CONFIG_DIR ./conf)" "config directory" "ARBITER_CONFIG_DIR"
2638
+ doctor_check_install_deploy_path "$(compose_env_value ARBITER_APP_ENV_FILE ./conf/.env)" "app env file" "ARBITER_APP_ENV_FILE"
2639
+ doctor_check_install_deploy_path "$(compose_env_value ARBITER_REQUIREMENTS_FILE ./requirements.txt)" "runtime requirements file" "ARBITER_REQUIREMENTS_FILE"
2640
+ doctor_check_install_deploy_path "$(compose_env_value ARBITER_WHEELS_DIR ./wheels)" "wheels directory" "ARBITER_WHEELS_DIR"
2641
+ doctor_check_install_deploy_path "$(compose_env_value ARBITER_PLUGIN_DATA_DIR ./data/plugins)" "plugin data directory" "ARBITER_PLUGIN_DATA_DIR"
2642
+ doctor_check_dir "$active_plugin_data_dir" "plugin data directory"
2643
+ doctor_check_plugin_data_permissions "$active_plugin_data_dir"
2644
+ doctor_check_plugin_data_compose_wiring
2645
+ doctor_check_container_user_can_write_dir "$active_plugin_data_dir" "plugin data directory"
2646
+ doctor_check_file "$active_requirements_file" "runtime requirements file"
2647
+
2648
+ if [[ -f "$active_requirements_file" ]] && grep -Eq '^[[:space:]]*/source/arbiter(/|$)' "$active_requirements_file"; then
2649
+ doctor_warn "preinstall found local checkout requirements: $active_requirements_file"
2650
+ printf ' install will build local wheels and write wheel-backed requirements only in the install target\n'
2651
+ printf ' run %s install to install wheel-backed artifacts without changing staging requirements\n' "$deploy_dir/arbiter-docker"
2652
+ fi
2653
+
2654
+ while IFS= read -r wheel_requirement; do
2655
+ [[ -n "$wheel_requirement" ]] || continue
2656
+ wheel_path="$active_wheels_dir/${wheel_requirement#/wheels/}"
2657
+ if [[ -f "$wheel_path" ]]; then
2658
+ doctor_ok "wheel requirement exists: $wheel_path"
2659
+ else
2660
+ doctor_fail "wheel requirement is missing from deployment wheelhouse: $wheel_path"
2661
+ printf ' run %s bundle prepare to rebuild the deployment wheelhouse\n' "$deploy_dir/arbiter-docker"
2662
+ fi
2663
+ done < <(
2664
+ awk '
2665
+ {
2666
+ line = $0
2667
+ sub(/^[[:space:]]+/, "", line)
2668
+ sub(/[[:space:]]+#.*$/, "", line)
2669
+ sub(/[[:space:]]+$/, "", line)
2670
+ if (line ~ /^\/wheels\/.*\.whl$/) {
2671
+ print line
2672
+ }
2673
+ }
2674
+ ' "$active_requirements_file"
2675
+ )
2676
+
2677
+ if [[ -f "$compose_override_file" ]] && grep -q '/source/arbiter' "$compose_override_file"; then
2678
+ doctor_warn "preinstall found local checkout compose override: $compose_override_file"
2679
+ printf ' install will omit the local checkout override only in the install target\n'
2680
+ printf ' run %s install to install without changing the staging local checkout override\n' "$deploy_dir/arbiter-docker"
2681
+ fi
2682
+
2683
+ if [[ "$doctor_status" -eq 0 ]]; then
2684
+ doctor_ok "preinstall checks passed"
2685
+ fi
2686
+ }
2687
+
2688
+ doctor_check_docker_network_subnet() {
2689
+ local configured_network
2690
+ local configured_subnet
2691
+ local network_line
2692
+ local network_name
2693
+ local network_subnets
2694
+ local network_subnet
2695
+
2696
+ configured_network="$(compose_env_value ARBITER_DOCKER_NETWORK_NAME arbiter-staging)"
2697
+ configured_subnet="$(compose_env_value ARBITER_DOCKER_SUBNET 172.31.251.0/24)"
2698
+ [[ -n "$configured_subnet" ]] || return
2699
+
2700
+ if ! docker network ls >/dev/null 2>&1; then
2701
+ return
2702
+ fi
2703
+
2704
+ while read -r network_line; do
2705
+ [[ -n "$network_line" ]] || continue
2706
+ network_name="${network_line%% *}"
2707
+ network_subnets="${network_line#* }"
2708
+ [[ "$network_subnets" != "$network_line" ]] || continue
2709
+ [[ "$network_name" != "$configured_network" ]] || continue
2710
+ for network_subnet in $network_subnets; do
2711
+ [[ "$network_subnet" == */* ]] || continue
2712
+ if cidr_overlaps "$configured_subnet" "$network_subnet"; then
2713
+ doctor_fail "Docker subnet $configured_subnet overlaps network $network_name ($network_subnet)"
2714
+ return
2715
+ fi
2716
+ done
2717
+ done < <(docker_network_subnet_lines)
2718
+
2719
+ doctor_ok "Docker subnet is not already used by another network"
2720
+ }
2721
+
2722
+ doctor_resolve_agent() {
2723
+ if [[ -n "$doctor_agent_user" ]]; then
2724
+ if ! doctor_agent_uid="$(id -u "$doctor_agent_user" 2>/dev/null)"; then
2725
+ doctor_fail "agent user does not exist: $doctor_agent_user"
2726
+ return 1
2727
+ fi
2728
+ doctor_agent_groups="$(id -G "$doctor_agent_user")"
2729
+ doctor_agent_group_names="$(id -nG "$doctor_agent_user")"
2730
+ return 0
2731
+ fi
2732
+
2733
+ if [[ -n "$doctor_agent_uid" ]]; then
2734
+ doctor_agent_user="$(getent passwd "$doctor_agent_uid" | cut -d: -f1 || true)"
2735
+ if [[ -z "$doctor_agent_user" ]]; then
2736
+ doctor_fail "agent uid does not resolve to a local user: $doctor_agent_uid"
2737
+ return 1
2738
+ fi
2739
+ doctor_agent_groups="$(id -G "$doctor_agent_user")"
2740
+ doctor_agent_group_names="$(id -nG "$doctor_agent_user")"
2741
+ return 0
2742
+ fi
2743
+
2744
+ return 1
2745
+ }
2746
+
2747
+ doctor_check_agent_access() {
2748
+ local active_config_dir
2749
+ local active_config_file
2750
+ local active_app_env
2751
+ local active_plugin_data_dir
2752
+
2753
+ if ! doctor_resolve_agent; then
2754
+ doctor_warn "skipping agent permission checks; pass --agent-user USER or --agent-uid UID"
2755
+ return
2756
+ fi
2757
+
2758
+ doctor_ok "checking agent identity: ${doctor_agent_user:-uid $doctor_agent_uid} (uid $doctor_agent_uid)"
2759
+
2760
+ if [[ " $doctor_agent_group_names " == *" docker "* ]]; then
2761
+ doctor_fail "agent identity is in the docker group"
2762
+ else
2763
+ doctor_ok "agent identity is not in the docker group"
2764
+ fi
2765
+
2766
+ active_config_dir="$(config_dir_path)"
2767
+ active_config_file="$(config_main_file)"
2768
+ active_app_env="$(app_env_path)"
2769
+ doctor_check_agent_cannot_replace_deploy_dir
2770
+ doctor_check_agent_cannot_write_dir "$deploy_dir" "deployment directory"
2771
+ doctor_check_agent_cannot_write_file "$compose_file" "compose file"
2772
+ doctor_check_agent_cannot_write_dir "$active_config_dir" "config directory"
2773
+ doctor_check_agent_cannot_write_file "$active_config_file" "main config file"
2774
+ doctor_check_agent_cannot_write_file "$compose_env_file" "docker env file"
2775
+ doctor_check_agent_cannot_write_file "$active_app_env" "app env file"
2776
+ doctor_check_agent_cannot_write_file "$requirements_file" "requirements file"
2777
+ doctor_check_agent_cannot_write_file "$deploy_dir/arbiter-docker" "helper script"
2778
+ doctor_check_agent_cannot_read "$active_app_env" "app env file"
2779
+ doctor_check_docker_socket
2780
+ doctor_warn "permission checks do not inspect ACLs, sudo rules, or other direct service paths"
2781
+ }
2782
+
2783
+ doctor() {
2784
+ local active_config_dir
2785
+ local active_config_file
2786
+ local active_app_env
2787
+ local active_plugin_data_dir
2788
+
2789
+ doctor_status=0
2790
+ doctor_agent_uid=""
2791
+ doctor_agent_user=""
2792
+ doctor_agent_groups=""
2793
+ doctor_agent_group_names=""
2794
+ doctor_preinstall=0
2795
+ doctor_quiet=0
2796
+
2797
+ while (($#)); do
2798
+ case "$1" in
2799
+ --preinstall)
2800
+ doctor_preinstall=1
2801
+ shift
2802
+ ;;
2803
+ --quiet)
2804
+ doctor_quiet=1
2805
+ shift
2806
+ ;;
2807
+ --agent-user)
2808
+ [[ $# -ge 2 ]] || {
2809
+ printf 'error: --agent-user requires a value\n' >&2
2810
+ exit 2
2811
+ }
2812
+ doctor_agent_user="$2"
2813
+ shift 2
2814
+ ;;
2815
+ --agent-uid)
2816
+ [[ $# -ge 2 ]] || {
2817
+ printf 'error: --agent-uid requires a value\n' >&2
2818
+ exit 2
2819
+ }
2820
+ doctor_agent_uid="$2"
2821
+ shift 2
2822
+ ;;
2823
+ -h | --help)
2824
+ usage
2825
+ exit 0
2826
+ ;;
2827
+ *)
2828
+ printf 'error: unknown doctor option: %s\n\n' "$1" >&2
2829
+ usage >&2
2830
+ exit 2
2831
+ ;;
2832
+ esac
2833
+ done
2834
+
2835
+ if [[ -n "$doctor_agent_user" && -n "$doctor_agent_uid" ]]; then
2836
+ printf 'error: use either --agent-user or --agent-uid, not both\n' >&2
2837
+ exit 2
2838
+ fi
2839
+
2840
+ active_config_dir="$(config_dir_path)"
2841
+ active_config_file="$(config_main_file)"
2842
+ active_app_env="$(app_env_path)"
2843
+ active_plugin_data_dir="$(plugin_data_dir_path)"
2844
+ doctor_check_dir "$deploy_dir" "deployment directory"
2845
+ doctor_check_file "$compose_file" "compose file"
2846
+ doctor_check_dir "$active_config_dir" "config directory"
2847
+ doctor_check_dir "$active_plugin_data_dir" "plugin data directory"
2848
+ doctor_check_file "$active_config_file" "main config file"
2849
+ doctor_check_file "$active_app_env" "app env file"
2850
+ doctor_check_file "$compose_env_file" "docker env file"
2851
+ doctor_check_file "$requirements_file" "requirements file"
2852
+ doctor_check_file "$deploy_dir/arbiter-docker" "helper script"
2853
+ doctor_check_env_file "$active_app_env" "app env file"
2854
+ doctor_check_env_file "$compose_env_file" "docker env file"
2855
+ doctor_check_plugin_data_permissions "$active_plugin_data_dir"
2856
+ doctor_check_config_file_permissions "$active_config_dir" "$active_app_env"
2857
+ doctor_check_app_env_permissions "$active_app_env"
2858
+ doctor_check_container_user_config
2859
+ doctor_check_plugin_data_compose_wiring
2860
+ doctor_check_container_user_can_write_dir "$active_plugin_data_dir" "plugin data directory"
2861
+ doctor_check_requirements_file "$requirements_file"
2862
+
2863
+ if [[ "$doctor_preinstall" -eq 1 ]]; then
2864
+ doctor_check_preinstall_ready
2865
+ return "$doctor_status"
2866
+ fi
2867
+
2868
+ doctor_check_docker_access
2869
+ doctor_check_container_name_owner
2870
+ if docker compose version >/dev/null 2>&1; then
2871
+ doctor_ok "$(docker compose version)"
2872
+ else
2873
+ doctor_fail "Docker Compose is not available"
2874
+ fi
2875
+ doctor_check_docker_network_subnet
2876
+
2877
+ doctor_check_agent_access
2878
+ return "$doctor_status"
2879
+ }
2880
+
2881
+ shell_quote() {
2882
+ printf '%q' "$1"
2883
+ }
2884
+
2885
+ print_command() {
2886
+ local arg
2887
+
2888
+ printf 'would run:'
2889
+ for arg in "$@"; do
2890
+ printf ' '
2891
+ shell_quote "$arg"
2892
+ done
2893
+ printf '\n'
2894
+ }
2895
+
2896
+ run_install_command() {
2897
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
2898
+ print_command "$@"
2899
+ else
2900
+ "$@"
2901
+ fi
2902
+ }
2903
+
2904
+ install_report_success() {
2905
+ local unit_file="$systemd_unit_dir/${install_service}.service"
2906
+
2907
+ if [[ "$install_dry_run" -ne 0 ]]; then
2908
+ return 0
2909
+ fi
2910
+
2911
+ printf 'success: installed Arbiter Docker deployment\n'
2912
+ printf 'installed to: %s\n' "$install_target_dir"
2913
+ printf 'systemd unit: %s\n' "$unit_file"
2914
+ print_mcp_url_value "$(install_mcp_url_value)"
2915
+ if [[ "$install_start" -eq 1 ]]; then
2916
+ printf 'service: %s.service enabled and restarted\n' "$install_service"
2917
+ else
2918
+ printf 'service: %s.service enabled but not started\n' "$install_service"
2919
+ fi
2920
+ }
2921
+
2922
+ install_require_root() {
2923
+ if [[ "$(id -u)" != 0 ]]; then
2924
+ printf 'error: install requires root; rerun with sudo\n' >&2
2925
+ exit 1
2926
+ fi
2927
+ }
2928
+
2929
+ install_relative_path() {
2930
+ local path="$1"
2931
+ local real_deploy_dir
2932
+ local real_path
2933
+
2934
+ real_deploy_dir="$(normalize_existing_path "$deploy_dir")"
2935
+ real_path="$(normalize_existing_path "$path")"
2936
+ printf '%s\n' "${real_path#"$real_deploy_dir"/}"
2937
+ }
2938
+
2939
+ install_target_path_from_compose_value() {
2940
+ local value="$1"
2941
+ local path
2942
+ local real_install_target_dir
2943
+ local real_path
2944
+
2945
+ if [[ "$value" = /* ]]; then
2946
+ printf 'error: install requires relative docker.env host paths, got: %s\n' "$value" >&2
2947
+ return 1
2948
+ fi
2949
+ path="$install_target_dir/${value#./}"
2950
+ real_install_target_dir="$(normalize_path_allow_missing "$install_target_dir")"
2951
+ real_path="$(normalize_path_allow_missing "$path")"
2952
+ if [[ "$real_path" != "$real_install_target_dir" && "$real_path" != "$real_install_target_dir/"* ]]; then
2953
+ printf 'error: install requires docker.env host paths inside --to, got: %s\n' "$value" >&2
2954
+ return 1
2955
+ fi
2956
+ if [[ "$real_path" == "$real_install_target_dir" ]]; then
2957
+ printf 'error: install requires docker.env host paths below --to, got deployment root: %s\n' "$value" >&2
2958
+ return 1
2959
+ fi
2960
+ printf '%s\n' "$real_path"
2961
+ }
2962
+
2963
+ install_target_requirements_file_path() {
2964
+ install_target_path_from_compose_value \
2965
+ "$(compose_env_value ARBITER_REQUIREMENTS_FILE ./requirements.txt)"
2966
+ }
2967
+
2968
+ install_target_wheels_dir_path() {
2969
+ install_target_path_from_compose_value \
2970
+ "$(compose_env_value ARBITER_WHEELS_DIR ./wheels)"
2971
+ }
2972
+
2973
+ install_local_wheel_python() {
2974
+ local repo_root="$1"
2975
+ local python_bin="${ARBITER_PYTHON:-}"
2976
+
2977
+ if [[ -n "$python_bin" ]]; then
2978
+ printf '%s\n' "$python_bin"
2979
+ return 0
2980
+ fi
2981
+ if [[ -x "$repo_root/.venv/bin/python" ]]; then
2982
+ printf '%s\n' "$repo_root/.venv/bin/python"
2983
+ return 0
2984
+ fi
2985
+ if command -v python >/dev/null; then
2986
+ printf 'python\n'
2987
+ return 0
2988
+ fi
2989
+ if command -v python3 >/dev/null; then
2990
+ printf 'python3\n'
2991
+ return 0
2992
+ fi
2993
+ return 1
2994
+ }
2995
+
2996
+ install_chown_to_invoking_user() {
2997
+ [[ -n "${SUDO_UID:-}" && -n "${SUDO_GID:-}" ]] || return 0
2998
+ [[ "$#" -gt 0 ]] || return 0
2999
+ chown "$SUDO_UID:$SUDO_GID" "$@"
3000
+ }
3001
+
3002
+ install_chown_wheelhouse_to_invoking_user() {
3003
+ local target_wheels_dir="$1"
3004
+ local wheel
3005
+ local -a paths=()
3006
+
3007
+ [[ -n "${SUDO_UID:-}" && -n "${SUDO_GID:-}" ]] || return 0
3008
+ [[ -d "$target_wheels_dir" ]] || return 0
3009
+ paths+=("$target_wheels_dir")
3010
+ shopt -s nullglob
3011
+ for wheel in "$target_wheels_dir"/*.whl; do
3012
+ paths+=("$wheel")
3013
+ done
3014
+ shopt -u nullglob
3015
+ install_chown_to_invoking_user "${paths[@]}"
3016
+ }
3017
+
3018
+ install_local_checkout_requirements_file=""
3019
+ install_local_checkout_has_source_override=0
3020
+
3021
+ install_build_source_wheel() {
3022
+ local python_bin="$1"
3023
+ local source_dir="$2"
3024
+ local target_wheels_dir="$3"
3025
+ local build_dir
3026
+ local output_file
3027
+ local built_wheels
3028
+ local wheel
3029
+
3030
+ build_dir="$(mktemp -d "${TMPDIR:-/tmp}/arbiter-install-wheel.XXXXXX")"
3031
+ output_file="$build_dir/pip-wheel.out"
3032
+ if ! "$python_bin" -m pip --disable-pip-version-check wheel --no-deps --no-build-isolation --wheel-dir "$build_dir" "$source_dir" >"$output_file" 2>&1; then
3033
+ cat "$output_file" >&2
3034
+ printf 'error: failed to build install wheel from local source: %s\n' "$source_dir" >&2
3035
+ rm -rf "$build_dir"
3036
+ return 1
3037
+ fi
3038
+
3039
+ built_wheels=("$build_dir"/*.whl)
3040
+ if [[ "${#built_wheels[@]}" -ne 1 || ! -f "${built_wheels[0]}" ]]; then
3041
+ printf 'error: expected one wheel from local source, got %s: %s\n' "${#built_wheels[@]}" "$source_dir" >&2
3042
+ rm -rf "$build_dir"
3043
+ return 1
3044
+ fi
3045
+
3046
+ wheel="${built_wheels[0]}"
3047
+ mkdir -p "$target_wheels_dir"
3048
+ cp "$wheel" "$target_wheels_dir/${wheel##*/}"
3049
+ install_chown_to_invoking_user "$target_wheels_dir/${wheel##*/}"
3050
+ printf '%s\n' "${wheel##*/}"
3051
+ rm -rf "$build_dir"
3052
+ }
3053
+
3054
+ install_prepare_local_checkout_wheels() {
3055
+ local active_requirements_file
3056
+ local active_wheels_dir
3057
+ local repo_root
3058
+ local python_bin
3059
+ local tmp_requirements=""
3060
+ local raw_line
3061
+ local line
3062
+ local source_suffix
3063
+ local source_dir
3064
+ local wheel_name
3065
+ local promoted=0
3066
+ local has_source_requirements=0
3067
+ local has_source_override=0
3068
+ local image
3069
+ local docker_user
3070
+
3071
+ install_local_checkout_requirements_file=""
3072
+ install_local_checkout_has_source_override=0
3073
+ active_requirements_file="$(deploy_path_from_compose_value "$(compose_env_value ARBITER_REQUIREMENTS_FILE ./requirements.txt)")"
3074
+ active_wheels_dir="$(wheels_dir_path)"
3075
+
3076
+ if [[ -f "$active_requirements_file" ]] && requirements_has_source_paths "$active_requirements_file"; then
3077
+ has_source_requirements=1
3078
+ fi
3079
+ if [[ -f "$compose_override_file" ]] && grep -q '/source/arbiter' "$compose_override_file"; then
3080
+ has_source_override=1
3081
+ install_local_checkout_has_source_override=1
3082
+ fi
3083
+ if [[ "$has_source_requirements" -eq 0 && "$has_source_override" -eq 0 ]]; then
3084
+ return 0
3085
+ fi
3086
+
3087
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3088
+ printf 'would prepare wheel-backed install artifacts from local checkout without changing staging\n'
3089
+ return 0
3090
+ fi
3091
+
3092
+ require_file "$active_requirements_file"
3093
+ validate_requirements_file "$active_requirements_file"
3094
+
3095
+ if [[ "$has_source_requirements" -eq 1 ]]; then
3096
+ if ! repo_root="$(find_repo_root)"; then
3097
+ printf 'error: cannot promote local checkout deployment: Arbiter repo root not found\n' >&2
3098
+ return 1
3099
+ fi
3100
+ if ! python_bin="$(install_local_wheel_python "$repo_root")"; then
3101
+ printf 'error: cannot promote local checkout deployment: python command not found\n' >&2
3102
+ printf ' set ARBITER_PYTHON to the Python interpreter for building local wheels\n' >&2
3103
+ return 1
3104
+ fi
3105
+
3106
+ tmp_requirements="$(mktemp "${TMPDIR:-/tmp}/arbiter-install-requirements.XXXXXX")"
3107
+ while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
3108
+ line="$raw_line"
3109
+ line="${line#"${line%%[![:space:]]*}"}"
3110
+ line="${line%"${line##*[![:space:]]}"}"
3111
+ line="${line%%[[:space:]]#*}"
3112
+ line="${line%"${line##*[![:space:]]}"}"
3113
+ if [[ -z "$line" || "$line" == \#* ]]; then
3114
+ printf '%s\n' "$raw_line" >>"$tmp_requirements"
3115
+ continue
3116
+ fi
3117
+ if [[ "$line" == /source/arbiter || "$line" == /source/arbiter/* ]]; then
3118
+ source_suffix="${line#/source/arbiter}"
3119
+ source_suffix="${source_suffix#/}"
3120
+ source_dir="$repo_root"
3121
+ if [[ -n "$source_suffix" ]]; then
3122
+ source_dir="$repo_root/$source_suffix"
3123
+ fi
3124
+ if [[ ! -d "$source_dir" || ! -f "$source_dir/pyproject.toml" ]]; then
3125
+ printf 'error: cannot map local checkout requirement to package source: %s\n' "$line" >&2
3126
+ printf ' expected pyproject.toml at %s\n' "$source_dir" >&2
3127
+ rm -f "$tmp_requirements"
3128
+ return 1
3129
+ fi
3130
+ wheel_name="$(install_build_source_wheel "$python_bin" "$source_dir" "$active_wheels_dir")" || {
3131
+ rm -f "$tmp_requirements"
3132
+ return 1
3133
+ }
3134
+ printf '/wheels/%s\n' "$wheel_name" >>"$tmp_requirements"
3135
+ promoted=1
3136
+ continue
3137
+ fi
3138
+ printf '%s\n' "$raw_line" >>"$tmp_requirements"
3139
+ done <"$active_requirements_file"
3140
+ fi
3141
+
3142
+ if [[ "$promoted" -eq 1 ]]; then
3143
+ if ! validate_requirements_file "$tmp_requirements"; then
3144
+ printf 'error: generated install requirements are invalid; staging requirements unchanged: %s\n' "$active_requirements_file" >&2
3145
+ rm -f "$tmp_requirements"
3146
+ return 1
3147
+ fi
3148
+ image="$(compose_env_value ARBITER_IMAGE python:3.11-slim)"
3149
+ docker_user="$(id -u):$(id -g)"
3150
+ if ! prepare_dependency_wheelhouse "$tmp_requirements" "$active_wheels_dir" "$docker_user" "$image" 1 0; then
3151
+ rm -f "$tmp_requirements"
3152
+ return 1
3153
+ fi
3154
+ if ! validate_dependency_wheelhouse "$tmp_requirements" "$active_wheels_dir" "$docker_user" "$image" 1; then
3155
+ rm -f "$tmp_requirements"
3156
+ return 1
3157
+ fi
3158
+ install_chown_wheelhouse_to_invoking_user "$active_wheels_dir"
3159
+ install_chown_to_invoking_user "$tmp_requirements"
3160
+ install_local_checkout_requirements_file="$tmp_requirements"
3161
+ printf 'prepared wheel-backed install requirements from local checkout: %s\n' "$install_local_checkout_requirements_file"
3162
+ else
3163
+ if [[ -n "$tmp_requirements" ]]; then
3164
+ rm -f "$tmp_requirements"
3165
+ fi
3166
+ fi
3167
+ }
3168
+
3169
+ install_apply_local_checkout_install_artifacts() {
3170
+ local target_requirements_file
3171
+ local target_compose_override_file
3172
+
3173
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3174
+ return 0
3175
+ fi
3176
+
3177
+ if [[ -n "$install_local_checkout_requirements_file" ]]; then
3178
+ target_requirements_file="$(install_target_requirements_file_path)"
3179
+ install_copy_file_protected "$install_local_checkout_requirements_file" "$target_requirements_file" 0640
3180
+ rm -f "$install_local_checkout_requirements_file"
3181
+ install_local_checkout_requirements_file=""
3182
+ printf 'installed wheel-backed requirements without changing staging: %s\n' "$target_requirements_file"
3183
+ fi
3184
+
3185
+ target_compose_override_file="$install_target_dir/compose.override.yaml"
3186
+ if [[ "$install_local_checkout_has_source_override" -eq 1 && -f "$target_compose_override_file" ]] && grep -q '/source/arbiter' "$target_compose_override_file"; then
3187
+ rm -f "$target_compose_override_file"
3188
+ printf 'omitted local checkout compose override from install target: %s\n' "$target_compose_override_file"
3189
+ fi
3190
+ }
3191
+
3192
+ install_target_app_env_path() {
3193
+ install_target_path_from_compose_value \
3194
+ "$(install_env_value ARBITER_APP_ENV_FILE ./conf/.env)"
3195
+ }
3196
+
3197
+ install_env_value() {
3198
+ local key="$1"
3199
+ local default="$2"
3200
+
3201
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3202
+ case "$key" in
3203
+ ARBITER_HOST_BIND)
3204
+ printf '127.0.0.1\n'
3205
+ return
3206
+ ;;
3207
+ ARBITER_HOST_PORT)
3208
+ printf '8025\n'
3209
+ return
3210
+ ;;
3211
+ esac
3212
+ fi
3213
+ env_file_value "$install_target_dir/docker.env" "$key" || printf '%s\n' "$default"
3214
+ }
3215
+
3216
+ install_server_host_url_value() {
3217
+ local host
3218
+
3219
+ host="$(install_env_value ARBITER_HOST_BIND 127.0.0.1)"
3220
+ case "$host" in
3221
+ 0.0.0.0)
3222
+ printf '127.0.0.1\n'
3223
+ ;;
3224
+ *:*)
3225
+ printf '[%s]\n' "$host"
3226
+ ;;
3227
+ *)
3228
+ printf '%s\n' "$host"
3229
+ ;;
3230
+ esac
3231
+ }
3232
+
3233
+ install_mcp_url_value() {
3234
+ printf 'http://%s:%s/mcp\n' \
3235
+ "$(install_server_host_url_value)" \
3236
+ "$(install_env_value ARBITER_HOST_PORT 8025)"
3237
+ }
3238
+
3239
+ install_ensure_identity() {
3240
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3241
+ printf 'would create system group if missing: %s\n' "$install_group"
3242
+ printf 'would create system user if missing: %s\n' "$install_user"
3243
+ return
3244
+ fi
3245
+
3246
+ if ! getent group "$install_group" >/dev/null; then
3247
+ groupadd --system "$install_group"
3248
+ fi
3249
+
3250
+ if ! id -u "$install_user" >/dev/null 2>&1; then
3251
+ useradd \
3252
+ --system \
3253
+ --gid "$install_group" \
3254
+ --home-dir "$install_target_dir" \
3255
+ --shell /usr/sbin/nologin \
3256
+ --comment "Arbiter service user" \
3257
+ "$install_user"
3258
+ fi
3259
+ }
3260
+
3261
+ install_reject_symlinks() {
3262
+ local path="$1"
3263
+ local symlink
3264
+
3265
+ symlink="$(find "$path" -type l -print -quit)"
3266
+ if [[ -n "$symlink" ]]; then
3267
+ printf 'error: install does not support symlinks in deployment tree: %s\n' "$symlink" >&2
3268
+ return 1
3269
+ fi
3270
+ }
3271
+
3272
+ install_copy_file_protected() {
3273
+ local source_file="$1"
3274
+ local target_file="$2"
3275
+ local mode="$3"
3276
+ local target_parent
3277
+ local tmp_file
3278
+
3279
+ target_parent="$(dirname -- "$target_file")"
3280
+ mkdir -p "$target_parent"
3281
+ chmod 0750 "$target_parent"
3282
+ chown "$install_user:$install_group" "$target_parent"
3283
+ tmp_file="$(mktemp "$target_parent/.arbiter-install.XXXXXX")"
3284
+ if ! cat "$source_file" >"$tmp_file"; then
3285
+ rm -f "$tmp_file"
3286
+ return 1
3287
+ fi
3288
+ chown "$install_user:$install_group" "$tmp_file"
3289
+ chmod "$mode" "$tmp_file"
3290
+ mv -f "$tmp_file" "$target_file"
3291
+ }
3292
+
3293
+ install_file_mode_for_relative_path() {
3294
+ local relative_path="$1"
3295
+ local app_env_relative_path="${2:-}"
3296
+
3297
+ if [[ -n "$app_env_relative_path" && "$relative_path" == "$app_env_relative_path" ]]; then
3298
+ printf '0600\n'
3299
+ return
3300
+ fi
3301
+ if [[ "$relative_path" == "arbiter-docker" ]]; then
3302
+ printf '0750\n'
3303
+ return
3304
+ fi
3305
+ printf '0640\n'
3306
+ }
3307
+
3308
+ install_copy_tree_protected() {
3309
+ local source_root="$1"
3310
+ local target_root="$2"
3311
+ local app_env_relative_path="${3:-}"
3312
+ local source_path
3313
+ local relative_path
3314
+ local target_path
3315
+ local mode
3316
+
3317
+ install_reject_symlinks "$source_root"
3318
+ mkdir -p "$target_root"
3319
+ chmod 0750 "$target_root"
3320
+ chown "$install_user:$install_group" "$target_root"
3321
+
3322
+ while IFS= read -r -d '' source_path; do
3323
+ [[ "$source_path" == "$source_root" ]] && continue
3324
+ relative_path="${source_path#"$source_root"/}"
3325
+ mkdir -p "$target_root/$relative_path"
3326
+ chmod 0750 "$target_root/$relative_path"
3327
+ chown "$install_user:$install_group" "$target_root/$relative_path"
3328
+ done < <(find "$source_root" -type d -print0)
3329
+
3330
+ while IFS= read -r -d '' source_path; do
3331
+ relative_path="${source_path#"$source_root"/}"
3332
+ target_path="$target_root/$relative_path"
3333
+ mode="$(install_file_mode_for_relative_path "$relative_path" "$app_env_relative_path")"
3334
+ install_copy_file_protected "$source_path" "$target_path" "$mode"
3335
+ done < <(find "$source_root" -type f -print0)
3336
+ }
3337
+
3338
+ install_backup_existing_path() {
3339
+ local source_path="$1"
3340
+ local backup_prefix="$2"
3341
+ local app_env_relative_path="${3:-}"
3342
+ local timestamp
3343
+ local backup_parent
3344
+ local backup_path
3345
+ local attempt=0
3346
+
3347
+ [[ -e "$source_path" ]] || return 0
3348
+
3349
+ timestamp="$(date -u '+%Y%m%dT%H%M%SZ')"
3350
+ backup_parent="$install_target_dir/backup"
3351
+ mkdir -p "$backup_parent"
3352
+ chmod 0750 "$backup_parent"
3353
+ chown "$install_user:$install_group" "$backup_parent"
3354
+
3355
+ backup_path="$backup_parent/${backup_prefix}-${timestamp}"
3356
+ while [[ -e "$backup_path" ]]; do
3357
+ attempt=$((attempt + 1))
3358
+ backup_path="$backup_parent/${backup_prefix}-${timestamp}-${attempt}"
3359
+ done
3360
+
3361
+ mv "$source_path" "$backup_path"
3362
+ chown -R "$install_user:$install_group" "$backup_path"
3363
+ find "$backup_path" -type d -exec chmod 0750 {} +
3364
+ find "$backup_path" -type f -exec chmod 0640 {} +
3365
+ if [[ -n "$app_env_relative_path" && -f "$backup_path/$app_env_relative_path" ]]; then
3366
+ chmod 0600 "$backup_path/$app_env_relative_path"
3367
+ elif [[ -f "$backup_path/.env" ]]; then
3368
+ chmod 0600 "$backup_path/.env"
3369
+ fi
3370
+ }
3371
+
3372
+ install_copy_deployment() {
3373
+ local source_dir
3374
+ local target_dir
3375
+ local target_docker_env_file
3376
+ local preserve_config_dir_value="./conf"
3377
+ local preserve_app_env_value="./conf/.env"
3378
+ local preserve_config_name_value="arbiter-server"
3379
+ local preserve_plugin_data_dir_value="./data/plugins"
3380
+ local preserve_plugin_data_dir_env=0
3381
+ local preserve_config_dir
3382
+ local preserve_app_env
3383
+ local preserve_plugin_data_dir
3384
+ local staging_config_dir
3385
+ local staging_app_env
3386
+ local preserve_tmp_dir=""
3387
+ local preserve_config=0
3388
+ local preserve_env=0
3389
+ local replace_config_dir=0
3390
+ local target_is_source=0
3391
+ local preserve_config_dir_real
3392
+ local preserve_app_env_real
3393
+ local preserve_app_env_config_rel=""
3394
+ local staging_app_env_rel=""
3395
+
3396
+ source_dir="$(normalize_existing_path "$deploy_dir")"
3397
+ target_dir="$install_target_dir"
3398
+ target_docker_env_file="$target_dir/docker.env"
3399
+
3400
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3401
+ printf 'would copy deployment: %s -> %s\n' "$source_dir" "$target_dir"
3402
+ if [[ "$install_replace_config" -eq 0 ]]; then
3403
+ printf 'would preserve installed config and env if present: %s\n' "$target_dir"
3404
+ elif [[ "$install_replace_env" -eq 0 ]]; then
3405
+ printf 'would replace installed config from staging and preserve installed env if present\n'
3406
+ else
3407
+ printf 'would replace installed config and env from staging\n'
3408
+ fi
3409
+ return
3410
+ fi
3411
+
3412
+ if [[ "$(normalize_path_allow_missing "$target_dir")" == "$source_dir" ]]; then
3413
+ target_is_source=1
3414
+ fi
3415
+
3416
+ if [[ "$target_is_source" -eq 0 ]]; then
3417
+ install_reject_symlinks "$source_dir"
3418
+ fi
3419
+
3420
+ if [[ -e "$target_dir" && -f "$target_docker_env_file" ]]; then
3421
+ preserve_plugin_data_dir_value="$(env_file_value "$target_docker_env_file" ARBITER_PLUGIN_DATA_DIR || printf './data/plugins\n')"
3422
+ preserve_plugin_data_dir="$(install_target_path_from_compose_value "$preserve_plugin_data_dir_value")"
3423
+ if [[ -e "$preserve_plugin_data_dir" || -L "$preserve_plugin_data_dir" ]]; then
3424
+ install_reject_symlinks "$preserve_plugin_data_dir"
3425
+ fi
3426
+ preserve_plugin_data_dir_env=1
3427
+ fi
3428
+
3429
+ if [[ -e "$target_dir" ]]; then
3430
+ if [[ -f "$target_docker_env_file" ]]; then
3431
+ preserve_config_dir_value="$(env_file_value "$target_docker_env_file" ARBITER_CONFIG_DIR || printf './conf\n')"
3432
+ preserve_app_env_value="$(env_file_value "$target_docker_env_file" ARBITER_APP_ENV_FILE || printf './conf/.env\n')"
3433
+ preserve_config_name_value="$(env_file_value "$target_docker_env_file" ARBITER_CONFIG_NAME || printf 'arbiter-server\n')"
3434
+ fi
3435
+ preserve_config_dir="$(install_target_path_from_compose_value "$preserve_config_dir_value")"
3436
+ preserve_app_env="$(install_target_path_from_compose_value "$preserve_app_env_value")"
3437
+ if [[ -e "$preserve_config_dir" || -L "$preserve_config_dir" ]]; then
3438
+ install_reject_symlinks "$preserve_config_dir"
3439
+ fi
3440
+ if [[ -e "$preserve_app_env" || -L "$preserve_app_env" ]]; then
3441
+ install_reject_symlinks "$preserve_app_env"
3442
+ fi
3443
+ if [[ "$install_replace_config" -eq 0 && ( -e "$preserve_config_dir" || -e "$preserve_app_env" ) ]]; then
3444
+ preserve_config=1
3445
+ preserve_tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/arbiter-install-config.XXXXXX")"
3446
+ if [[ -e "$preserve_app_env" ]]; then
3447
+ preserve_config_dir_real="$(normalize_path_allow_missing "$preserve_config_dir")"
3448
+ preserve_app_env_real="$(normalize_path_allow_missing "$preserve_app_env")"
3449
+ case "$preserve_app_env_real" in
3450
+ "$preserve_config_dir_real"/*)
3451
+ preserve_app_env_config_rel="${preserve_app_env_real#"$preserve_config_dir_real"/}"
3452
+ ;;
3453
+ *)
3454
+ cp -a "$preserve_app_env" "$preserve_tmp_dir/app-env"
3455
+ ;;
3456
+ esac
3457
+ fi
3458
+ if [[ -e "$preserve_config_dir" ]]; then
3459
+ cp -a "$preserve_config_dir" "$preserve_tmp_dir/config-dir"
3460
+ install_backup_existing_path \
3461
+ "$preserve_config_dir" \
3462
+ conf \
3463
+ "$preserve_app_env_config_rel"
3464
+ fi
3465
+ elif [[ "$install_replace_config" -eq 1 ]]; then
3466
+ if [[ "$install_replace_env" -eq 0 && -e "$preserve_app_env" ]]; then
3467
+ preserve_env=1
3468
+ preserve_tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/arbiter-install-config.XXXXXX")"
3469
+ cp -a "$preserve_app_env" "$preserve_tmp_dir/app-env"
3470
+ fi
3471
+ if [[ "$target_is_source" -eq 0 && -e "$preserve_config_dir" ]]; then
3472
+ replace_config_dir=1
3473
+ fi
3474
+ fi
3475
+ fi
3476
+
3477
+ if [[ "$replace_config_dir" -eq 1 ]]; then
3478
+ rm -rf "$preserve_config_dir"
3479
+ fi
3480
+
3481
+ mkdir -p "$target_dir"
3482
+ if [[ "$target_is_source" -eq 0 ]]; then
3483
+ staging_app_env_rel="$(install_relative_path "$(app_env_path)")"
3484
+ install_copy_tree_protected "$source_dir" "$target_dir" "$staging_app_env_rel"
3485
+ fi
3486
+
3487
+ if [[ "$preserve_config" -eq 1 ]]; then
3488
+ staging_config_dir="$(install_target_path_from_compose_value "$(compose_env_value ARBITER_CONFIG_DIR ./conf)")"
3489
+ staging_app_env="$(install_target_path_from_compose_value "$(compose_env_value ARBITER_APP_ENV_FILE ./conf/.env)")"
3490
+
3491
+ rm -rf "$staging_config_dir"
3492
+ case "$(normalize_path_allow_missing "$staging_app_env")" in
3493
+ "$(normalize_path_allow_missing "$staging_config_dir")"/*)
3494
+ ;;
3495
+ *)
3496
+ rm -f "$staging_app_env"
3497
+ ;;
3498
+ esac
3499
+
3500
+ if [[ -e "$preserve_tmp_dir/config-dir" ]]; then
3501
+ mkdir -p "$(dirname -- "$preserve_config_dir")"
3502
+ rm -rf "$preserve_config_dir"
3503
+ install_copy_tree_protected \
3504
+ "$preserve_tmp_dir/config-dir" \
3505
+ "$preserve_config_dir" \
3506
+ "$preserve_app_env_config_rel"
3507
+ fi
3508
+ if [[ -e "$preserve_tmp_dir/app-env" ]]; then
3509
+ mkdir -p "$(dirname -- "$preserve_app_env")"
3510
+ install_copy_file_protected "$preserve_tmp_dir/app-env" "$preserve_app_env" 0600
3511
+ fi
3512
+ set_env_file_value "$target_docker_env_file" ARBITER_CONFIG_DIR "$preserve_config_dir_value"
3513
+ set_env_file_value "$target_docker_env_file" ARBITER_APP_ENV_FILE "$preserve_app_env_value"
3514
+ set_env_file_value "$target_docker_env_file" ARBITER_CONFIG_NAME "$preserve_config_name_value"
3515
+ rm -rf "$preserve_tmp_dir"
3516
+ elif [[ "$preserve_env" -eq 1 ]]; then
3517
+ mkdir -p "$(dirname -- "$preserve_app_env")"
3518
+ install_copy_file_protected "$preserve_tmp_dir/app-env" "$preserve_app_env" 0600
3519
+ set_env_file_value "$target_docker_env_file" ARBITER_APP_ENV_FILE "$preserve_app_env_value"
3520
+ rm -rf "$preserve_tmp_dir"
3521
+ fi
3522
+ if [[ "$preserve_plugin_data_dir_env" -eq 1 ]]; then
3523
+ set_env_file_value "$target_docker_env_file" ARBITER_PLUGIN_DATA_DIR "$preserve_plugin_data_dir_value"
3524
+ fi
3525
+ }
3526
+
3527
+ install_mark_deployment_installed() {
3528
+ local target_compose_file="$install_target_dir/compose.yaml"
3529
+ local target_docker_env_file="$install_target_dir/docker.env"
3530
+ local tmp_path
3531
+ local install_uid
3532
+ local install_gid
3533
+
3534
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3535
+ printf 'would set deployment scope in compose.yaml: %s\n' "arbiter.deployment_scope=installed"
3536
+ printf 'would set installed Docker identity and container user in docker.env\n'
3537
+ return
3538
+ fi
3539
+
3540
+ require_file "$target_compose_file"
3541
+ tmp_path="$(mktemp "${TMPDIR:-/tmp}/arbiter-compose.XXXXXX")"
3542
+ sed \
3543
+ -e 's/arbiter\.deployment_scope=staged/arbiter.deployment_scope=installed/g' \
3544
+ -e 's/ARBITER_CONTAINER_NAME:-arbiter-staging/ARBITER_CONTAINER_NAME:-arbiter/g' \
3545
+ -e 's/ARBITER_HOST_PORT:-18025/ARBITER_HOST_PORT:-8025/g' \
3546
+ -e 's/ARBITER_DOCKER_NETWORK_NAME:-arbiter-staging/ARBITER_DOCKER_NETWORK_NAME:-arbiter/g' \
3547
+ -e 's/ARBITER_DOCKER_BRIDGE_NAME:-arbiter-stg0/ARBITER_DOCKER_BRIDGE_NAME:-arbiter0/g' \
3548
+ -e 's#ARBITER_DOCKER_SUBNET:-172\.31\.251\.0/24#ARBITER_DOCKER_SUBNET:-172.31.250.0/24#g' \
3549
+ "$target_compose_file" >"$tmp_path"
3550
+ mv "$tmp_path" "$target_compose_file"
3551
+
3552
+ require_file "$target_docker_env_file"
3553
+ tmp_path="$(mktemp "${TMPDIR:-/tmp}/arbiter-docker-env.XXXXXX")"
3554
+ sed \
3555
+ -e 's/^ARBITER_CONTAINER_NAME=.*/ARBITER_CONTAINER_NAME=arbiter/' \
3556
+ -e 's/^ARBITER_HOST_BIND=.*/ARBITER_HOST_BIND=127.0.0.1/' \
3557
+ -e 's/^ARBITER_HOST_PORT=.*/ARBITER_HOST_PORT=8025/' \
3558
+ -e 's/^ARBITER_DOCKER_NETWORK_NAME=.*/ARBITER_DOCKER_NETWORK_NAME=arbiter/' \
3559
+ -e 's/^ARBITER_DOCKER_BRIDGE_NAME=.*/ARBITER_DOCKER_BRIDGE_NAME=arbiter0/' \
3560
+ -e 's#^ARBITER_DOCKER_SUBNET=.*#ARBITER_DOCKER_SUBNET=172.31.250.0/24#' \
3561
+ "$target_docker_env_file" >"$tmp_path"
3562
+ mv "$tmp_path" "$target_docker_env_file"
3563
+
3564
+ install_uid="$(id -u "$install_user")"
3565
+ install_gid="$(getent group "$install_group" | cut -d: -f3)"
3566
+ if [[ -z "$install_gid" ]]; then
3567
+ install_gid="$(id -g "$install_user")"
3568
+ fi
3569
+ set_env_file_value "$target_docker_env_file" ARBITER_CONTAINER_USER "$install_uid:$install_gid"
3570
+ }
3571
+
3572
+ install_metadata_quote() {
3573
+ printf '%q' "$1"
3574
+ }
3575
+
3576
+ install_write_target_metadata() {
3577
+ local metadata_file="$install_target_dir/.arbiter-install.env"
3578
+ local installed_at
3579
+ local source_dir
3580
+
3581
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3582
+ printf 'would write install metadata: %s\n' "$metadata_file"
3583
+ return
3584
+ fi
3585
+
3586
+ installed_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
3587
+ source_dir="$(normalize_existing_path "$deploy_dir")"
3588
+ rm -f "$install_target_dir/.arbiter-installations"
3589
+ cat >"$metadata_file" <<EOF
3590
+ ARBITER_INSTALL_PATH=$(install_metadata_quote "$install_target_dir")
3591
+ ARBITER_INSTALL_SERVICE=$(install_metadata_quote "$install_service")
3592
+ ARBITER_INSTALL_SYSTEMD_UNIT=$(install_metadata_quote "$systemd_unit_dir/${install_service}.service")
3593
+ ARBITER_INSTALL_USER=$(install_metadata_quote "$install_user")
3594
+ ARBITER_INSTALL_GROUP=$(install_metadata_quote "$install_group")
3595
+ ARBITER_INSTALL_MCP_URL=$(install_metadata_quote "$(install_mcp_url_value)")
3596
+ ARBITER_INSTALLED_AT=$(install_metadata_quote "$installed_at")
3597
+ ARBITER_INSTALLED_FROM=$(install_metadata_quote "$source_dir")
3598
+ EOF
3599
+ }
3600
+
3601
+ install_apply_permissions() {
3602
+ local app_env_target
3603
+ local plugin_data_target
3604
+
3605
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3606
+ printf 'would chown/chmod deployment for %s:%s: %s\n' \
3607
+ "$install_user" "$install_group" "$install_target_dir"
3608
+ return
3609
+ fi
3610
+
3611
+ chown -R "$install_user:$install_group" "$install_target_dir"
3612
+ find "$install_target_dir" -type d -exec chmod 0750 {} +
3613
+ find "$install_target_dir" -type f -exec chmod 0640 {} +
3614
+ chmod 0750 "$install_target_dir/arbiter-docker"
3615
+ if [[ -d "$install_target_dir/backup" ]]; then
3616
+ find "$install_target_dir/backup" -type f -exec chmod 0600 {} +
3617
+ fi
3618
+ plugin_data_target="$(install_target_path_from_compose_value "$(compose_env_value ARBITER_PLUGIN_DATA_DIR ./data/plugins)")"
3619
+ if [[ -d "$plugin_data_target" ]]; then
3620
+ find "$plugin_data_target" -type d -exec chmod 0700 {} +
3621
+ find "$plugin_data_target" -type f -exec chmod 0600 {} +
3622
+ fi
3623
+
3624
+ app_env_target="$(install_target_app_env_path)"
3625
+ if [[ -f "$app_env_target" ]]; then
3626
+ chmod 0600 "$app_env_target"
3627
+ fi
3628
+ }
3629
+
3630
+ install_record_location() {
3631
+ local registry_file="$deploy_dir/.arbiter-installations"
3632
+
3633
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3634
+ printf 'would record install location: %s in %s\n' \
3635
+ "$install_target_dir" "$registry_file"
3636
+ return
3637
+ fi
3638
+
3639
+ touch "$registry_file"
3640
+ if ! grep -Fx -- "$install_target_dir" "$registry_file" >/dev/null; then
3641
+ printf '%s\n' "$install_target_dir" >>"$registry_file"
3642
+ fi
3643
+ chmod 0644 "$registry_file"
3644
+ if [[ -n "${SUDO_UID:-}" && -n "${SUDO_GID:-}" ]]; then
3645
+ chown "$SUDO_UID:$SUDO_GID" "$registry_file"
3646
+ fi
3647
+ }
3648
+
3649
+ install_test_server() {
3650
+ local url
3651
+ local client_command
3652
+
3653
+ url="$(install_mcp_url_value)"
3654
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3655
+ printf 'would test MCP URL: %s\n' "$url"
3656
+ return 0
3657
+ fi
3658
+
3659
+ if ! client_command="$(resolve_arbiter_client_command)"; then
3660
+ doctor_warn "skipping MCP test; Arbiter client command not found"
3661
+ return 0
3662
+ fi
3663
+ test_server_url "$url"
3664
+ }
3665
+
3666
+ copy_existing_wheelhouse() {
3667
+ local source_wheels_dir="$1"
3668
+ local target_wheels_dir="$2"
3669
+ local wheel
3670
+
3671
+ [[ -d "$source_wheels_dir" ]] || return 0
3672
+ mkdir -p "$target_wheels_dir"
3673
+ shopt -s nullglob
3674
+ for wheel in "$source_wheels_dir"/*.whl; do
3675
+ cp -p "$wheel" "$target_wheels_dir/"
3676
+ done
3677
+ shopt -u nullglob
3678
+ }
3679
+
3680
+ replace_dependency_wheelhouse() {
3681
+ local source_wheels_dir="$1"
3682
+ local target_wheels_dir="$2"
3683
+
3684
+ mkdir -p "$target_wheels_dir"
3685
+ find "$target_wheels_dir" -maxdepth 1 -type f -name '*.whl' -delete
3686
+ find "$source_wheels_dir" -maxdepth 1 -type f -name '*.whl' -exec cp -p {} "$target_wheels_dir/" \;
3687
+ }
3688
+
3689
+ prune_dependency_wheelhouse() {
3690
+ local target_requirements_file
3691
+ local target_wheels_dir
3692
+ local docker_user
3693
+ local image
3694
+ local quiet
3695
+ local tmp_dir
3696
+ local output_file=""
3697
+ local prune_script
3698
+ local -a report_command
3699
+ local -a prune_command
3700
+
3701
+ target_requirements_file="$1"
3702
+ target_wheels_dir="$2"
3703
+ docker_user="$3"
3704
+ image="$4"
3705
+ quiet="${5:-0}"
3706
+
3707
+ require_file "$target_requirements_file"
3708
+ require_dir "$target_wheels_dir"
3709
+ require_docker_access
3710
+
3711
+ tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/arbiter-wheelhouse-prune.XXXXXX")"
3712
+ report_command=(
3713
+ docker run --rm
3714
+ --user "$docker_user"
3715
+ -v "$target_requirements_file:/requirements.txt:ro"
3716
+ -v "$target_wheels_dir:/wheels:ro"
3717
+ -v "$tmp_dir:/work"
3718
+ "$image"
3719
+ python -m pip --disable-pip-version-check install
3720
+ --dry-run --ignore-installed
3721
+ --no-index --find-links /wheels
3722
+ --report /work/report.json
3723
+ -r /requirements.txt
3724
+ )
3725
+
3726
+ prune_script='import json
3727
+ import re
3728
+ from pathlib import Path
3729
+
3730
+ def normalize(name):
3731
+ return re.sub(r"[-_.]+", "-", name).lower()
3732
+
3733
+ with open("/work/report.json", encoding="utf-8") as handle:
3734
+ report = json.load(handle)
3735
+
3736
+ referenced = set()
3737
+ for entry in report.get("install", []):
3738
+ metadata = entry.get("metadata", {})
3739
+ name = metadata.get("name")
3740
+ version = metadata.get("version")
3741
+ if name and version:
3742
+ referenced.add((normalize(name), str(version)))
3743
+
3744
+ for wheel in Path("/wheels").glob("*.whl"):
3745
+ parts = wheel.name[:-4].split("-", 2)
3746
+ if len(parts) < 2 or (normalize(parts[0]), parts[1]) not in referenced:
3747
+ wheel.unlink()
3748
+ '
3749
+ prune_command=(
3750
+ docker run --rm
3751
+ --user "$docker_user"
3752
+ -v "$tmp_dir:/work:ro"
3753
+ -v "$target_wheels_dir:/wheels"
3754
+ "$image"
3755
+ python -c "$prune_script"
3756
+ )
3757
+
3758
+ if [[ "$quiet" -eq 1 ]]; then
3759
+ output_file="$(mktemp "${TMPDIR:-/tmp}/arbiter-wheelhouse-prune.XXXXXX")"
3760
+ fi
3761
+
3762
+ if [[ "$quiet" -eq 1 ]]; then
3763
+ if ! "${report_command[@]}" >"$output_file" 2>&1; then
3764
+ cat "$output_file" >&2
3765
+ rm -f "$output_file"
3766
+ rm -rf "$tmp_dir"
3767
+ printf 'error: failed to resolve prepared wheelhouse contents: %s\n' "$target_wheels_dir" >&2
3768
+ return 1
3769
+ fi
3770
+ if ! "${prune_command[@]}" >"$output_file" 2>&1; then
3771
+ cat "$output_file" >&2
3772
+ rm -f "$output_file"
3773
+ rm -rf "$tmp_dir"
3774
+ printf 'error: failed to prune dependency wheelhouse: %s\n' "$target_wheels_dir" >&2
3775
+ return 1
3776
+ fi
3777
+ else
3778
+ if ! "${report_command[@]}"; then
3779
+ rm -rf "$tmp_dir"
3780
+ printf 'error: failed to resolve prepared wheelhouse contents: %s\n' "$target_wheels_dir" >&2
3781
+ return 1
3782
+ fi
3783
+ if ! "${prune_command[@]}"; then
3784
+ rm -rf "$tmp_dir"
3785
+ printf 'error: failed to prune dependency wheelhouse: %s\n' "$target_wheels_dir" >&2
3786
+ return 1
3787
+ fi
3788
+ fi
3789
+
3790
+ if [[ -n "$output_file" ]]; then
3791
+ rm -f "$output_file"
3792
+ fi
3793
+ rm -rf "$tmp_dir"
3794
+ }
3795
+
3796
+ prepare_dependency_wheelhouse() {
3797
+ local target_requirements_file
3798
+ local target_wheels_dir
3799
+ local image
3800
+ local docker_user
3801
+ local quiet
3802
+ local pypi_only
3803
+ local tmp_wheels_dir
3804
+ local output_file=""
3805
+ local -a docker_command
3806
+ local -a wheel_input_mount_args=()
3807
+ local -a find_links_args=()
3808
+
3809
+ target_requirements_file="$1"
3810
+ target_wheels_dir="$2"
3811
+ docker_user="$3"
3812
+ image="$4"
3813
+ quiet="${5:-0}"
3814
+ pypi_only="${6:-0}"
3815
+
3816
+ if [[ "$pypi_only" -eq 0 ]]; then
3817
+ wheel_input_mount_args=(-v "$target_wheels_dir:/wheels:ro")
3818
+ find_links_args=(--find-links /wheels)
3819
+ fi
3820
+
3821
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3822
+ docker_command=(
3823
+ docker run --rm
3824
+ --user "$docker_user"
3825
+ -v "$target_requirements_file:/requirements.txt:ro"
3826
+ )
3827
+ if [[ "${#wheel_input_mount_args[@]}" -gt 0 ]]; then
3828
+ docker_command+=("${wheel_input_mount_args[@]}")
3829
+ fi
3830
+ docker_command+=(
3831
+ -v "$target_wheels_dir:/wheelhouse"
3832
+ "$image"
3833
+ python -m pip --disable-pip-version-check wheel
3834
+ --no-cache-dir
3835
+ )
3836
+ if [[ "${#find_links_args[@]}" -gt 0 ]]; then
3837
+ docker_command+=("${find_links_args[@]}")
3838
+ fi
3839
+ docker_command+=(
3840
+ --wheel-dir /wheelhouse
3841
+ -r /requirements.txt
3842
+ )
3843
+ printf 'would prepare dependency wheelhouse: %s\n' "$target_wheels_dir"
3844
+ print_command "${docker_command[@]}"
3845
+ return 0
3846
+ fi
3847
+
3848
+ require_file "$target_requirements_file"
3849
+ require_docker_access
3850
+
3851
+ mkdir -p "$target_wheels_dir"
3852
+ tmp_wheels_dir="$(mktemp -d "${TMPDIR:-/tmp}/arbiter-wheelhouse.XXXXXX")"
3853
+ if [[ "$pypi_only" -eq 0 ]]; then
3854
+ copy_existing_wheelhouse "$target_wheels_dir" "$tmp_wheels_dir"
3855
+ fi
3856
+ docker_command=(
3857
+ docker run --rm
3858
+ --user "$docker_user"
3859
+ -v "$target_requirements_file:/requirements.txt:ro"
3860
+ )
3861
+ if [[ "${#wheel_input_mount_args[@]}" -gt 0 ]]; then
3862
+ docker_command+=("${wheel_input_mount_args[@]}")
3863
+ fi
3864
+ docker_command+=(
3865
+ -v "$tmp_wheels_dir:/wheelhouse"
3866
+ "$image"
3867
+ python -m pip --disable-pip-version-check wheel
3868
+ --no-cache-dir
3869
+ )
3870
+ if [[ "${#find_links_args[@]}" -gt 0 ]]; then
3871
+ docker_command+=("${find_links_args[@]}")
3872
+ fi
3873
+ docker_command+=(
3874
+ --wheel-dir /wheelhouse
3875
+ -r /requirements.txt
3876
+ )
3877
+
3878
+ if [[ "$quiet" -eq 1 ]]; then
3879
+ output_file="$(mktemp "${TMPDIR:-/tmp}/arbiter-wheelhouse-build.XXXXXX")"
3880
+ else
3881
+ printf 'preparing dependency wheelhouse with %s: %s\n' "$image" "$target_wheels_dir"
3882
+ fi
3883
+
3884
+ if [[ "$quiet" -eq 1 ]]; then
3885
+ if ! "${docker_command[@]}" >"$output_file" 2>&1; then
3886
+ cat "$output_file" >&2
3887
+ rm -f "$output_file"
3888
+ printf 'error: failed to prepare dependency wheelhouse: %s\n' "$target_wheels_dir" >&2
3889
+ printf ' check network access during install prep, package pins, and Docker image compatibility\n' >&2
3890
+ rm -rf "$tmp_wheels_dir"
3891
+ return 1
3892
+ fi
3893
+ elif ! "${docker_command[@]}"; then
3894
+ printf 'error: failed to prepare dependency wheelhouse: %s\n' "$target_wheels_dir" >&2
3895
+ printf ' check network access during install prep, package pins, and Docker image compatibility\n' >&2
3896
+ rm -rf "$tmp_wheels_dir"
3897
+ return 1
3898
+ fi
3899
+ if ! prune_dependency_wheelhouse "$target_requirements_file" "$tmp_wheels_dir" "$docker_user" "$image" "$quiet"; then
3900
+ rm -rf "$tmp_wheels_dir"
3901
+ return 1
3902
+ fi
3903
+ replace_dependency_wheelhouse "$tmp_wheels_dir" "$target_wheels_dir"
3904
+ rm -rf "$tmp_wheels_dir"
3905
+ if [[ -n "$output_file" ]]; then
3906
+ rm -f "$output_file"
3907
+ else
3908
+ printf 'prepared dependency wheelhouse: %s\n' "$target_wheels_dir"
3909
+ fi
3910
+ }
3911
+
3912
+ validate_dependency_wheelhouse() {
3913
+ local target_requirements_file
3914
+ local target_wheels_dir
3915
+ local image
3916
+ local docker_user
3917
+ local quiet
3918
+ local output_file=""
3919
+ local -a docker_command
3920
+
3921
+ target_requirements_file="$1"
3922
+ target_wheels_dir="$2"
3923
+ docker_user="$3"
3924
+ image="$4"
3925
+ quiet="${5:-0}"
3926
+
3927
+ if [[ "${install_dry_run:-0}" -eq 1 ]]; then
3928
+ docker_command=(
3929
+ docker run --rm
3930
+ --user "$docker_user"
3931
+ -v "$target_requirements_file:/requirements.txt:ro"
3932
+ -v "$target_wheels_dir:/wheels:ro"
3933
+ "$image"
3934
+ python -m pip --disable-pip-version-check install --no-cache-dir
3935
+ --target /tmp/arbiter-wheelhouse-check
3936
+ --no-index --find-links /wheels
3937
+ -r /requirements.txt
3938
+ )
3939
+ printf 'would validate dependency wheelhouse: %s\n' "$target_wheels_dir"
3940
+ print_command "${docker_command[@]}"
3941
+ return 0
3942
+ fi
3943
+
3944
+ require_file "$target_requirements_file"
3945
+ require_dir "$target_wheels_dir"
3946
+ require_docker_access
3947
+
3948
+ docker_command=(
3949
+ docker run --rm
3950
+ --user "$docker_user"
3951
+ -v "$target_requirements_file:/requirements.txt:ro"
3952
+ -v "$target_wheels_dir:/wheels:ro"
3953
+ "$image"
3954
+ python -m pip --disable-pip-version-check install --no-cache-dir
3955
+ --target /tmp/arbiter-wheelhouse-check
3956
+ --no-index --find-links /wheels
3957
+ -r /requirements.txt
3958
+ )
3959
+
3960
+ if [[ "$quiet" -eq 1 ]]; then
3961
+ output_file="$(mktemp "${TMPDIR:-/tmp}/arbiter-wheelhouse-check.XXXXXX")"
3962
+ else
3963
+ printf 'validating dependency wheelhouse with %s: %s\n' "$image" "$target_wheels_dir"
3964
+ fi
3965
+
3966
+ if [[ "$quiet" -eq 1 ]]; then
3967
+ if ! "${docker_command[@]}" >"$output_file" 2>&1; then
3968
+ cat "$output_file" >&2
3969
+ rm -f "$output_file"
3970
+ printf 'error: dependency wheelhouse validation failed: %s\n' "$target_wheels_dir" >&2
3971
+ printf ' check requirements.txt pins, Docker image compatibility, and network access during install prep\n' >&2
3972
+ return 1
3973
+ fi
3974
+ elif ! "${docker_command[@]}"; then
3975
+ if [[ -n "$output_file" ]]; then
3976
+ cat "$output_file" >&2
3977
+ rm -f "$output_file"
3978
+ fi
3979
+ printf 'error: dependency wheelhouse validation failed: %s\n' "$target_wheels_dir" >&2
3980
+ printf ' check requirements.txt pins, Docker image compatibility, and network access during install prep\n' >&2
3981
+ return 1
3982
+ fi
3983
+ if [[ -n "$output_file" ]]; then
3984
+ rm -f "$output_file"
3985
+ else
3986
+ printf 'validated dependency wheelhouse: %s\n' "$target_wheels_dir"
3987
+ fi
3988
+ }
3989
+
3990
+ prepare() {
3991
+ local target_wheels_dir
3992
+ local image
3993
+ local docker_user
3994
+ local pypi_only=0
3995
+ local tmp_dir
3996
+
3997
+ while (($#)); do
3998
+ case "$1" in
3999
+ --pypi-only)
4000
+ pypi_only=1
4001
+ ;;
4002
+ *)
4003
+ printf 'error: unknown prepare option: %s\n' "$1" >&2
4004
+ return 2
4005
+ ;;
4006
+ esac
4007
+ shift
4008
+ done
4009
+
4010
+ require_file "$requirements_file"
4011
+ validate_requirements_file "$requirements_file"
4012
+ target_wheels_dir="$(wheels_dir_path)"
4013
+ image="$(compose_env_value ARBITER_IMAGE python:3.11-slim)"
4014
+ docker_user="$(id -u):$(id -g)"
4015
+ if [[ "$pypi_only" -eq 1 ]]; then
4016
+ print_bundle_prepare_start
4017
+ if ! prepare_pypi_only "$target_wheels_dir" "$docker_user" "$image"; then
4018
+ return 1
4019
+ fi
4020
+ return
4021
+ fi
4022
+ if ! reject_source_requirements_for_wheelhouse_command prepare; then
4023
+ return 1
4024
+ fi
4025
+ print_bundle_prepare_start
4026
+ tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/arbiter-bundle-prepare.XXXXXX")"
4027
+ if ! bundle_refresh_repo_wheels "$tmp_dir"; then
4028
+ rm -rf "$tmp_dir"
4029
+ return 1
4030
+ fi
4031
+ rm -rf "$tmp_dir"
4032
+ prepare_dependency_wheelhouse "$requirements_file" "$target_wheels_dir" "$docker_user" "$image" 1 "$pypi_only"
4033
+ validate_dependency_wheelhouse "$requirements_file" "$target_wheels_dir" "$docker_user" "$image" 1
4034
+ printf 'bundle prepare complete: %s\n' "$target_wheels_dir"
4035
+ }
4036
+
4037
+ prepare_quiet() {
4038
+ local target_wheels_dir
4039
+ local image
4040
+ local docker_user
4041
+
4042
+ require_file "$requirements_file"
4043
+ validate_requirements_file "$requirements_file"
4044
+ if ! reject_source_requirements_for_wheelhouse_command prepare; then
4045
+ return 1
4046
+ fi
4047
+ target_wheels_dir="$(wheels_dir_path)"
4048
+ image="$(compose_env_value ARBITER_IMAGE python:3.11-slim)"
4049
+ docker_user="$(id -u):$(id -g)"
4050
+ prepare_dependency_wheelhouse "$requirements_file" "$target_wheels_dir" "$docker_user" "$image" 1 0
4051
+ validate_dependency_wheelhouse "$requirements_file" "$target_wheels_dir" "$docker_user" "$image" 1
4052
+ }
4053
+
4054
+ check() {
4055
+ local target_wheels_dir
4056
+ local image
4057
+ local docker_user
4058
+
4059
+ if (($#)); then
4060
+ printf 'error: check does not accept options yet; run bundle check with no arguments\n' >&2
4061
+ return 2
4062
+ fi
4063
+ require_file "$requirements_file"
4064
+ validate_requirements_file "$requirements_file"
4065
+ if ! reject_source_requirements_for_wheelhouse_command check; then
4066
+ return 1
4067
+ fi
4068
+ target_wheels_dir="$(wheels_dir_path)"
4069
+ image="$(compose_env_value ARBITER_IMAGE python:3.11-slim)"
4070
+ docker_user="$(id -u):$(id -g)"
4071
+ validate_dependency_wheelhouse "$requirements_file" "$target_wheels_dir" "$docker_user" "$image" 1
4072
+ printf 'bundle check passed: %s\n' "$deploy_dir"
4073
+ }
4074
+
4075
+ bundle() {
4076
+ local subcommand="${1:-}"
4077
+ if (($#)); then
4078
+ shift
4079
+ fi
4080
+
4081
+ case "$subcommand" in
4082
+ prepare)
4083
+ prepare "$@"
4084
+ ;;
4085
+ check)
4086
+ check "$@"
4087
+ ;;
4088
+ list)
4089
+ case "${1:-}" in
4090
+ "")
4091
+ bundle_list_roots
4092
+ ;;
4093
+ all)
4094
+ bundle_list_all
4095
+ ;;
4096
+ *)
4097
+ printf 'error: unknown bundle list argument: %s\n' "$1" >&2
4098
+ return 2
4099
+ ;;
4100
+ esac
4101
+ ;;
4102
+ list-plugins)
4103
+ if (($#)); then
4104
+ printf 'error: list-plugins does not accept arguments\n' >&2
4105
+ return 2
4106
+ fi
4107
+ bundle_list_plugins
4108
+ ;;
4109
+ upgrade)
4110
+ bundle_upgrade "$@"
4111
+ ;;
4112
+ add)
4113
+ bundle_add_plugin "$@"
4114
+ ;;
4115
+ remove)
4116
+ bundle_remove_plugin "$@"
4117
+ ;;
4118
+ "" | -h | --help | help)
4119
+ cat <<'EOF'
4120
+ Usage: arbiter-docker bundle COMMAND
4121
+
4122
+ Commands:
4123
+ prepare Build and validate the dependency wheelhouse
4124
+ check Validate the prepared wheelhouse without downloading packages
4125
+ list Show root requirements
4126
+ list all Show root and transitive wheelhouse packages
4127
+ list-plugins Show service plugins supported by add/remove
4128
+ add ITEM Add a service plugin, or all plugins in a meta package
4129
+ remove ITEM Remove a service plugin, or all plugins in a meta package
4130
+ upgrade Upgrade package root requirements and rebuild the wheelhouse
4131
+
4132
+ Upgrade options:
4133
+ --pypi-only Resolve from the package index only; skip local repo wheels
4134
+
4135
+ Prepare options:
4136
+ --pypi-only Resolve selected packages from the index instead of the checked-out repo, rewrite requirements.txt after success, and build without the existing wheelhouse
4137
+ EOF
4138
+ ;;
4139
+ *)
4140
+ printf 'error: unknown bundle command: %s\n' "$subcommand" >&2
4141
+ return 2
4142
+ ;;
4143
+ esac
4144
+ }
4145
+
4146
+ install_validate_wheelhouse() {
4147
+ local target_requirements_file
4148
+ local target_wheels_dir
4149
+ local image
4150
+ local uid
4151
+ local gid
4152
+
4153
+ target_requirements_file="$(install_target_requirements_file_path)"
4154
+ target_wheels_dir="$(install_target_wheels_dir_path)"
4155
+ image="$(compose_env_value ARBITER_IMAGE python:3.11-slim)"
4156
+
4157
+ if [[ "$install_dry_run" -eq 1 ]]; then
4158
+ validate_dependency_wheelhouse "$target_requirements_file" "$target_wheels_dir" "$install_user:$install_group" "$image"
4159
+ return
4160
+ fi
4161
+
4162
+ uid="$(id -u "$install_user")"
4163
+ gid="$(id -g "$install_user")"
4164
+ if ! validate_dependency_wheelhouse "$target_requirements_file" "$target_wheels_dir" "$uid:$gid" "$image" 1; then
4165
+ printf ' install aborted before writing/restarting the systemd service\n' >&2
4166
+ printf ' run %s prepare, then rerun: sudo %s install\n' "$deploy_dir/arbiter-docker" "$deploy_dir/arbiter-docker" >&2
4167
+ return 1
4168
+ fi
4169
+ }
4170
+
4171
+ install_write_systemd_unit() {
4172
+ local unit_file
4173
+ local docker_bin
4174
+ local compose_files
4175
+ local docker_unit_lines
4176
+
4177
+ unit_file="$systemd_unit_dir/${install_service}.service"
4178
+
4179
+ if [[ "$install_dry_run" -eq 1 ]]; then
4180
+ printf 'would write systemd unit: %s\n' "$unit_file"
4181
+ return
4182
+ fi
4183
+
4184
+ docker_bin="$(command -v docker || true)"
4185
+ if [[ -z "$docker_bin" ]]; then
4186
+ printf 'error: docker command not found\n' >&2
4187
+ exit 1
4188
+ fi
4189
+ if ! command -v systemctl >/dev/null; then
4190
+ printf 'error: systemctl command not found\n' >&2
4191
+ exit 1
4192
+ fi
4193
+ compose_files="-f $install_target_dir/compose.yaml"
4194
+ if [[ -f "$install_target_dir/compose.override.yaml" ]]; then
4195
+ compose_files="$compose_files -f $install_target_dir/compose.override.yaml"
4196
+ fi
4197
+ mkdir -p "$systemd_unit_dir"
4198
+ docker_unit_lines=""
4199
+ if systemctl cat docker.service >/dev/null 2>&1; then
4200
+ docker_unit_lines="Requires=docker.service
4201
+ After=docker.service"
4202
+ else
4203
+ doctor_warn "docker.service not found; generated unit will rely on Docker socket availability"
4204
+ fi
4205
+
4206
+ cat >"$unit_file" <<EOF
4207
+ [Unit]
4208
+ Description=Arbiter Docker service
4209
+ $docker_unit_lines
4210
+
4211
+ [Service]
4212
+ Type=simple
4213
+ WorkingDirectory=$install_target_dir
4214
+ ExecStart=$docker_bin compose --env-file $install_target_dir/docker.env $compose_files up
4215
+ ExecStop=$docker_bin compose --env-file $install_target_dir/docker.env $compose_files down
4216
+ Restart=on-failure
4217
+
4218
+ [Install]
4219
+ WantedBy=multi-user.target
4220
+ EOF
4221
+ chmod 0644 "$unit_file"
4222
+ }
4223
+
4224
+ install_stop_service_for_recreate() {
4225
+ if [[ "$install_dry_run" -eq 1 ]]; then
4226
+ print_command systemctl stop "${install_service}.service"
4227
+ return
4228
+ fi
4229
+ systemctl stop "${install_service}.service" >/dev/null 2>&1 || true
4230
+ }
4231
+
4232
+ install_reset_failed_service_for_recreate() {
4233
+ if [[ "$install_dry_run" -eq 1 ]]; then
4234
+ print_command systemctl reset-failed "${install_service}.service"
4235
+ return
4236
+ fi
4237
+ systemctl reset-failed "${install_service}.service" >/dev/null 2>&1 || true
4238
+ }
4239
+
4240
+ install_compose_down_for_recreate() {
4241
+ local docker_bin
4242
+
4243
+ if [[ "$install_dry_run" -eq 1 ]]; then
4244
+ print_command docker compose \
4245
+ --env-file "$install_target_dir/docker.env" \
4246
+ -f "$install_target_dir/compose.yaml" \
4247
+ down --remove-orphans
4248
+ return
4249
+ fi
4250
+
4251
+ docker_bin="$(command -v docker || true)"
4252
+ if [[ -z "$docker_bin" ]]; then
4253
+ printf 'error: docker command not found\n' >&2
4254
+ exit 1
4255
+ fi
4256
+ if [[ -f "$install_target_dir/compose.override.yaml" ]]; then
4257
+ run_install_command "$docker_bin" compose \
4258
+ --env-file "$install_target_dir/docker.env" \
4259
+ -f "$install_target_dir/compose.yaml" \
4260
+ -f "$install_target_dir/compose.override.yaml" \
4261
+ down --remove-orphans
4262
+ else
4263
+ run_install_command "$docker_bin" compose \
4264
+ --env-file "$install_target_dir/docker.env" \
4265
+ -f "$install_target_dir/compose.yaml" \
4266
+ down --remove-orphans
4267
+ fi
4268
+ }
4269
+
4270
+ install_deployment() {
4271
+ install_target_dir="/opt/arbiter"
4272
+ install_user="arbiter"
4273
+ install_group=""
4274
+ install_service="arbiter"
4275
+ install_start=1
4276
+ install_dry_run=0
4277
+ install_replace_config=0
4278
+ install_replace_env=0
4279
+
4280
+ while (($#)); do
4281
+ case "$1" in
4282
+ --to)
4283
+ [[ $# -ge 2 ]] || {
4284
+ printf 'error: --to requires a value\n' >&2
4285
+ exit 2
4286
+ }
4287
+ install_target_dir="$2"
4288
+ shift 2
4289
+ ;;
4290
+ --user)
4291
+ [[ $# -ge 2 ]] || {
4292
+ printf 'error: --user requires a value\n' >&2
4293
+ exit 2
4294
+ }
4295
+ install_user="$2"
4296
+ shift 2
4297
+ ;;
4298
+ --group)
4299
+ [[ $# -ge 2 ]] || {
4300
+ printf 'error: --group requires a value\n' >&2
4301
+ exit 2
4302
+ }
4303
+ install_group="$2"
4304
+ shift 2
4305
+ ;;
4306
+ --service)
4307
+ [[ $# -ge 2 ]] || {
4308
+ printf 'error: --service requires a value\n' >&2
4309
+ exit 2
4310
+ }
4311
+ install_service="$2"
4312
+ shift 2
4313
+ ;;
4314
+ --no-start)
4315
+ install_start=0
4316
+ shift
4317
+ ;;
4318
+ --start)
4319
+ install_start=1
4320
+ shift
4321
+ ;;
4322
+ --replace-config)
4323
+ install_replace_config=1
4324
+ shift
4325
+ ;;
4326
+ --replace-env)
4327
+ install_replace_env=1
4328
+ shift
4329
+ ;;
4330
+ --dry-run)
4331
+ install_dry_run=1
4332
+ shift
4333
+ ;;
4334
+ -h | --help)
4335
+ usage
4336
+ exit 0
4337
+ ;;
4338
+ *)
4339
+ printf 'error: unknown install option: %s\n\n' "$1" >&2
4340
+ usage >&2
4341
+ exit 2
4342
+ ;;
4343
+ esac
4344
+ done
4345
+
4346
+ if [[ "$install_target_dir" != /* ]]; then
4347
+ printf 'error: --to must be an absolute path: %s\n' "$install_target_dir" >&2
4348
+ exit 2
4349
+ fi
4350
+ if [[ "$install_replace_env" -eq 1 && "$install_replace_config" -eq 0 ]]; then
4351
+ printf 'error: --replace-env requires --replace-config\n' >&2
4352
+ exit 2
4353
+ fi
4354
+ if [[ "$install_target_dir" =~ [[:space:]] ]]; then
4355
+ printf 'error: --to must not contain whitespace: %s\n' "$install_target_dir" >&2
4356
+ exit 2
4357
+ fi
4358
+ if [[ ! "$install_service" =~ ^[A-Za-z0-9_.@-]+$ ]]; then
4359
+ printf 'error: --service contains unsupported characters: %s\n' "$install_service" >&2
4360
+ exit 2
4361
+ fi
4362
+ if [[ ! "$install_user" =~ ^[A-Za-z_][A-Za-z0-9_.-]*[$]?$ ]]; then
4363
+ printf 'error: --user contains unsupported characters: %s\n' "$install_user" >&2
4364
+ exit 2
4365
+ fi
4366
+ if [[ -z "$install_group" ]]; then
4367
+ install_group="$install_user"
4368
+ fi
4369
+ if [[ ! "$install_group" =~ ^[A-Za-z_][A-Za-z0-9_.-]*[$]?$ ]]; then
4370
+ printf 'error: --group contains unsupported characters: %s\n' "$install_group" >&2
4371
+ exit 2
4372
+ fi
4373
+
4374
+ printf 'installing Arbiter to %s as %s:%s (service: %s.service)\n' \
4375
+ "$install_target_dir" "$install_user" "$install_group" "$install_service"
4376
+
4377
+ install_prepare_local_checkout_wheels
4378
+ doctor --preinstall --quiet
4379
+
4380
+ if [[ "$install_dry_run" -ne 1 ]]; then
4381
+ install_require_root
4382
+ fi
4383
+
4384
+ install_ensure_identity
4385
+ install_copy_deployment
4386
+ install_apply_local_checkout_install_artifacts
4387
+ install_mark_deployment_installed
4388
+ install_write_target_metadata
4389
+ install_apply_permissions
4390
+ install_validate_wheelhouse
4391
+ install_apply_permissions
4392
+ install_write_systemd_unit
4393
+ install_record_location
4394
+ run_install_command systemctl daemon-reload
4395
+ run_install_command systemctl enable "${install_service}.service"
4396
+ if [[ "$install_start" -eq 1 ]]; then
4397
+ install_stop_service_for_recreate
4398
+ install_reset_failed_service_for_recreate
4399
+ install_compose_down_for_recreate
4400
+ run_install_command systemctl restart "${install_service}.service"
4401
+ fi
4402
+ if [[ "$install_start" -eq 1 ]]; then
4403
+ install_test_server
4404
+ fi
4405
+ install_report_success
4406
+ }
4407
+
4408
+ command="${1:-}"
4409
+ if (($#)); then
4410
+ shift
4411
+ fi
4412
+
4413
+ case "$command" in
4414
+ prepare)
4415
+ prepare "$@"
4416
+ ;;
4417
+ bundle)
4418
+ bundle "$@"
4419
+ ;;
4420
+ sync-env)
4421
+ sync_env
4422
+ ;;
4423
+ edit-config)
4424
+ require_file "$(config_main_file)"
4425
+ run_editor "$(config_main_file)"
4426
+ ;;
4427
+ edit-requirements)
4428
+ edit_requirements
4429
+ ;;
4430
+ edit-env)
4431
+ require_file "$(app_env_path)"
4432
+ run_editor "$(app_env_path)"
4433
+ ;;
4434
+ edit-docker)
4435
+ require_file "$compose_env_file"
4436
+ run_editor "$compose_env_file"
4437
+ ;;
4438
+ up)
4439
+ compose up -d
4440
+ print_staging_port_note
4441
+ print_mcp_url
4442
+ ;;
4443
+ restart)
4444
+ compose up -d --force-recreate
4445
+ print_staging_port_note
4446
+ print_mcp_url
4447
+ ;;
4448
+ test)
4449
+ test_server
4450
+ ;;
4451
+ down)
4452
+ compose_down "$@"
4453
+ ;;
4454
+ ps)
4455
+ compose ps
4456
+ ;;
4457
+ logs)
4458
+ compose logs --timestamps -f
4459
+ ;;
4460
+ info)
4461
+ info
4462
+ ;;
4463
+ doctor)
4464
+ doctor "$@"
4465
+ ;;
4466
+ install)
4467
+ install_deployment "$@"
4468
+ ;;
4469
+ "" | -h | --help | help)
4470
+ usage
4471
+ ;;
4472
+ *)
4473
+ printf 'error: unknown command: %s\n\n' "$command" >&2
4474
+ usage >&2
4475
+ exit 2
4476
+ ;;
4477
+ esac