Repository navigation
Expand file tree
/
Copy pathsetup-openprocessor.sh
More file actions
executable file
·3148 lines (2964 loc) · 138 KB
/
Copy pathsetup-openprocessor.sh
File metadata and controls
executable file
·3148 lines (2964 loc) · 138 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
# =============================================================================
# setup-openprocessor.sh - one-line OpenProcessor installer
# =============================================================================
# Usage:
# curl -fsSL https://raw.githubusercontent.com/${OP_GH_REPO}/main/setup-openprocessor.sh | bash
# curl -fsSL .../setup-openprocessor.sh | bash -s -- --tiers core,curation --unattended
#
# Installs a pinned OpenProcessor release into ./openprocessor/ with no git
# clone, no local image build and no host Python. Design:
# docs/design/openprocessor_internal/one_line_installer_plan.md
#
# The whole script is one function (__op_define) that only defines things,
# followed by a single line that calls it and then main. A truncated
# `curl | bash` download is a syntax error before anything runs (plan 3.3).
#
# When read from a pipe, this copy does one job: resolve the release,
# download that release's setup-openprocessor.sh plus SHA256SUMS, verify
# the checksum and exec the verified copy with the same arguments.
#
# Env vars (all optional; a flag wins over its env var):
# OP_GH_REPO GitHub org/repo to install from (default davidamacey/OpenProcessor)
# OP_GH_DEFAULT_REF default branch for --branch (default main)
# OP_IMAGE_NAMESPACE Docker Hub namespace (default davidamacey)
# OP_DOCS_URL docs-site URL, printed in the summary if set
# OP_ARTIFACT_BASE_URL release-asset base (https only; <base>/<ref>/<asset>)
# OP_RAW_BASE_URL raw-file base (https only; <base>/<ref>/<path>)
# OP_INSTALL_DIR / --dir install directory (default ./openprocessor, or . when run inside an install dir)
# OP_PROJECT / --project compose project name + container prefix
# OP_VERSION / --version pinned release tag (vX.Y.Z)
# OP_BRANCH / --branch testing install from a branch head
# OP_RELEASE_DIR / --release-dir read the release assets from a local
# directory (scripts/release/build_deploy_bundle.sh
# output) instead of GitHub; still verified
# OP_IMAGE_TAG / --image-tag run images by tag instead of images.lock
# digests (local-only test builds);
# OP_IMAGE_REPO picks the repo prefix
# OP_TIERS / --tiers comma list of tiers, or --all
# OP_GPU_PLAN / --gpu-plan triton=1,segmenter=0,vlm=2,trainer=0
# GPU_PROFILE / --profile minimal|standard|full
# OP_VLM_URL / --vlm-remote, OP_VLM_MODEL / --vlm-model,
# OP_VLM_KEY_FILE / --vlm-key-file remote OpenAI-compatible VLM
# OP_VLM_CATALOG_ID / --vlm-model-id explicit local VLM catalog entry
# OP_BIND_ADDRESS / --bind publish address (default 127.0.0.1)
# OP_PORT_BASE / --port-base shift the whole 46xx block to N..N+12
# OP_WITH_MONITORING / --with-monitoring, OP_SAMPLE_DATA / --sample-data
# OP_UNATTENDED / --unattended no prompts (auto when /dev/tty is unusable)
# OP_DRY_RUN / --dry-run print every mutating command, run none
# OP_FORCE_CPU / --cpu no GPU; see --control-plane-only
# OP_ALLOW_PUBLIC_BIND=1 unattended consent for a non-loopback --bind
# OP_ALLOW_EXTERNAL_VLM=1 unattended consent for a non-private --vlm-remote
# --yes consent to a destructive step (uninstall, rollback,
# upgrade) without a prompt; --unattended implies it.
# A missing terminal alone is never consent.
# --force-existing-dir install into a non-empty dir this installer did not
# create (its files are backed up first)
# OP_CONFIRM_PURGE=<project> unattended consent for --purge-volumes/--purge-data
# OP_CONFIRM_PURGE_SECRETS=<project> unattended consent to also delete secrets/
# HF_TOKEN_FILE / HF_TOKEN HuggingFace token for gated tiers (segmenter)
# OP_HEALTH_TIMEOUT cap (seconds) on every health wait
#
# Exit codes:
# 0 ok | 1 failure | 2 usage | 3 project or container-name collision
# 4 no usable GPU / GPU plan refused | 5 Docker unreachable
# 6 HuggingFace token missing or rejected for a gated tier
# 7 artifact, checksum or image-digest verification failed
# 8 health check or model setup failed | 9 consent not given
# =============================================================================
__op_define() {
OP_GH_REPO="${OP_GH_REPO:-davidamacey/OpenProcessor}"
OP_GH_DEFAULT_REF="${OP_GH_DEFAULT_REF:-main}"
OP_IMAGE_NAMESPACE="${OP_IMAGE_NAMESPACE:-davidamacey}"
OP_DOCS_URL="${OP_DOCS_URL:-}"
SCRIPT_VERSION="0.5.0"
EXIT_USAGE=2
EXIT_COLLISION=3
EXIT_GPU=4
EXIT_DOCKER=5
EXIT_TOKEN=6
EXIT_VERIFY=7
EXIT_HEALTH=8
EXIT_CONSENT=9
TIER_LIST=(core curation segmenter vlm trainer cropwright)
COMPOSE_MIN_OVERRIDE="2.24.4"
# -----------------------------------------------------------------------------
# Logging (self-contained: nothing is sourced before the release is verified)
# -----------------------------------------------------------------------------
log_info() { echo "[INFO] $*"; }
log_warn() { echo "[WARN] $*" >&2; }
log_error() { echo "[ERROR] $*" >&2; }
log_success() { echo "[OK] $*"; }
log_step() { echo ""; echo "=== $* ==="; }
die() {
log_error "$1"
exit "${2:-1}"
}
_truthy() {
case "${1,,}" in
1|true|yes|y|on) return 0 ;;
*) return 1 ;;
esac
}
# Same masking rules as scripts/lib/model_setup.sh (plan section 7).
_op_redact() {
sed -u -E \
-e 's/hf_[A-Za-z0-9]{10,}/hf_***REDACTED***/g' \
-e 's/(Bearer)[[:space:]]+[^[:space:]"]+/\1 ***REDACTED***/g' \
-e 's/(^|[^A-Za-z0-9_])([A-Z0-9_]*(_API_KEY|_TOKEN|_SECRET|_PASSWORD))=[^[:space:]]*/\1\2=***REDACTED***/g'
}
# -----------------------------------------------------------------------------
# 5.1 Compose invocation rule: every compose call goes through dc().
# -----------------------------------------------------------------------------
# _dc_is_readonly ARGS... -> 0 if the compose subcommand never changes state
_dc_is_readonly() {
while (( $# > 0 )); do
case "$1" in
--profile) shift 2 ;;
-*) shift ;;
config|ps|version|images|ls|logs|port|top) return 0 ;;
*) return 1 ;;
esac
done
return 1
}
# Shell-level COMPOSE_* variables would silently override the install's
# .env (project name drives container_name; profiles pick services), so
# dc() clears them (and the Cropwright overrides) and pins COMPOSE_PROJECT_NAME.
dc() {
local project="${OP_PROJECT:?OP_PROJECT not set}" dir="${OP_DIR:?OP_DIR not set}"
local -a cmd=(docker compose -p "$project" --env-file "${dir}/.env"
--project-directory "$dir" -f "${dir}/docker-compose.yml")
if [[ -f "${dir}/docker-compose.cpu.yml" ]]; then
cmd+=(-f "${dir}/docker-compose.cpu.yml")
fi
cmd+=("$@")
if [[ "${OP_DRY_RUN:-0}" == "1" ]] && ! _dc_is_readonly "$@"; then
echo "DRY: ${cmd[*]//"$dir"/"${OP_REAL_DIR:-$dir}"}"
return 0
fi
env -u COMPOSE_PROFILES -u COMPOSE_FILE -u COMPOSE_ENV_FILES -u CROPWRIGHT_BIND_ADDRESS \
-u CROPWRIGHT_IMAGE -u CROPWRIGHT_PORT COMPOSE_PROJECT_NAME="$project" "${cmd[@]}"
}
# docker_mut ARGS... -- a state-changing plain docker call (pull/run/rmi)
docker_mut() {
if [[ "${OP_DRY_RUN:-0}" == "1" ]]; then
local line="docker $*"
if [[ -n "${OP_DIR:-}" && -n "${OP_REAL_DIR:-}" ]]; then
line="${line//"$OP_DIR"/"$OP_REAL_DIR"}"
fi
echo "DRY: ${line}"
return 0
fi
docker "$@"
}
# -----------------------------------------------------------------------------
# Prompts: always /dev/tty, because under `curl | bash` stdin is the script.
# -----------------------------------------------------------------------------
tty_usable() {
{ : </dev/tty; } 2>/dev/null
}
# prompt_line VARNAME TEXT HINT
# Unattended or no tty: fails with HINT (the flag/env that answers it).
prompt_line() {
local __var="$1" text="$2" hint="$3" __reply=""
if [[ "$OP_UNATTENDED" == "1" ]] || ! tty_usable; then
die "a prompt needs an answer but there is no terminal (unattended): ${hint}" "$EXIT_CONSENT"
fi
printf '%s' "$text" >/dev/tty
IFS= read -r __reply </dev/tty || __reply=""
printf -v "$__var" '%s' "$__reply"
}
# prompt_secret VARNAME TEXT HINT -- no echo, never logged
prompt_secret() {
local __var="$1" text="$2" hint="$3" __reply=""
if [[ "$OP_UNATTENDED" == "1" ]] || ! tty_usable; then
die "a secret prompt needs an answer but there is no terminal: ${hint}" "$EXIT_TOKEN"
fi
printf '%s' "$text" >/dev/tty
IFS= read -rs __reply </dev/tty || __reply=""
printf '\n' >/dev/tty
printf -v "$__var" '%s' "$__reply"
}
confirm_yes() {
local reply=""
prompt_line reply "$1 [Y/n]: " "$2"
[[ -z "$reply" || "${reply,,}" == y || "${reply,,}" == yes ]]
}
# -----------------------------------------------------------------------------
# Validation
# -----------------------------------------------------------------------------
validate_project_name() {
[[ "$1" =~ ^[a-z0-9][a-z0-9_-]{0,62}$ ]]
}
validate_version() {
[[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]
}
validate_branch_name() {
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$ && "$1" != *..* ]]
}
validate_https_base() {
[[ "$1" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$ ]]
}
is_ipv4() {
local ip="$1" o
[[ "$ip" =~ ^(0|[1-9][0-9]{0,2})(\.(0|[1-9][0-9]{0,2})){3}$ ]] || return 1
local IFS=.
for o in $ip; do
(( 10#$o <= 255 )) || return 1
done
return 0
}
# normalize_bind ADDR -> prints a Docker-usable host IP, or fails
normalize_bind() {
local addr="$1"
case "$addr" in
localhost) echo "127.0.0.1"; return 0 ;;
::1|'[::1]'|::|'[::]')
log_error "IPv6 bind addresses are not supported; use 127.0.0.1 or an IPv4 address"
return 1 ;;
esac
if is_ipv4 "$addr"; then
echo "$addr"
return 0
fi
log_error "--bind must be an IPv4 address (got '${addr}')"
return 1
}
is_loopback_ipv4() {
is_ipv4 "$1" && [[ "$1" == 127.* ]]
}
# -----------------------------------------------------------------------------
# Downloads: https only, no protocol downgrade on redirect
# -----------------------------------------------------------------------------
_dl() {
local url="$1" out="$2"
# file:// only ever comes from --release-dir (never from a user URL).
if [[ "$url" == file://* ]]; then
[[ -n "${OP_RELEASE_DIR:-}" && "${url#file://}" == "${OP_RELEASE_DIR}"/* && -f "${url#file://}" ]] || return 1
cp -- "${url#file://}" "$out"
return
fi
curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL \
--retry 2 --connect-timeout 20 -o "$out" "$url"
}
_sha256() {
sha256sum "$1" | cut -d' ' -f1
}
# _sums_lookup SUMS_FILE PATH -> the recorded sha256 for PATH, or nothing
_sums_lookup() {
awk -v p="$2" '($2 == p || $2 == "*" p) { print $1; exit }' "$1"
}
# verify_against_sums SUMS FILE NAME -> 0 when FILE's sha256 is NAME's entry
verify_against_sums() {
local sums="$1" file="$2" name="$3" want got
want="$(_sums_lookup "$sums" "$name")"
if [[ ! "$want" =~ ^[0-9a-f]{64}$ ]]; then
log_error "SHA256SUMS has no entry for ${name}"
return 1
fi
got="$(_sha256 "$file")"
if [[ "$got" != "$want" ]]; then
log_error "checksum mismatch for ${name}"
return 1
fi
return 0
}
_asset_url() {
if [[ -n "${OP_RELEASE_DIR:-}" ]]; then
printf 'file://%s/%s' "$OP_RELEASE_DIR" "$2"
return
fi
printf '%s/%s/%s' "${OP_ARTIFACT_BASE_URL:-https://github.com/${OP_GH_REPO}/releases/download}" "$1" "$2"
}
_raw_url() {
if [[ -n "${OP_RELEASE_DIR:-}" ]]; then
printf 'file://%s/raw/%s' "$OP_RELEASE_DIR" "$2"
return
fi
printf '%s/%s/%s' "${OP_RAW_BASE_URL:-https://raw.githubusercontent.com/${OP_GH_REPO}}" "$1" "$2"
}
# -----------------------------------------------------------------------------
# 3.2 Version pinning
# -----------------------------------------------------------------------------
# resolve_branch_sha BRANCH -> the full 40-hex head SHA, or fails
resolve_branch_sha() {
local branch="$1" body sha
body="$(curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL --retry 2 \
"https://api.github.com/repos/${OP_GH_REPO}/commits/${branch}")" || return 1
sha="$(printf '%s\n' "$body" | sed -n -E 's/^[[:space:]]*"sha":[[:space:]]*"([0-9a-f]{40})".*/\1/p' | head -n1)"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || return 1
echo "$sha"
}
# resolve_install_ref -> sets RESOLVED_REF (git ref for downloads) and
# RESOLVED_MODE (release|branch). Never floats to a branch silently.
resolve_install_ref() {
if [[ -n "${OP_VERSION:-}" ]]; then
validate_version "$OP_VERSION" || die "--version must look like v1.2.3 (got '${OP_VERSION}')" "$EXIT_USAGE"
RESOLVED_REF="$OP_VERSION"
RESOLVED_MODE=release
return 0
fi
if [[ -n "${OP_BRANCH:-}" ]]; then
validate_branch_name "$OP_BRANCH" || die "invalid --branch '${OP_BRANCH}'" "$EXIT_USAGE"
local sha
sha="$(resolve_branch_sha "$OP_BRANCH")" || die "could not resolve the head commit of branch '${OP_BRANCH}'"
RESOLVED_REF="$sha"
RESOLVED_MODE=branch
return 0
fi
local attempt=1 body ref=""
while (( attempt <= 3 )); do
if body="$(curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL \
"https://api.github.com/repos/${OP_GH_REPO}/releases/latest" 2>/dev/null)"; then
ref="$(printf '%s\n' "$body" | sed -n -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/p' | head -n1)"
[[ -n "$ref" ]] && break
fi
sleep $((attempt * 2))
attempt=$((attempt + 1))
done
[[ -n "$ref" ]] || die "could not resolve the latest GitHub Release of ${OP_GH_REPO} (use --version)"
validate_version "$ref" || die "latest release tag '${ref}' is not a vX.Y.Z version" "$EXIT_VERIFY"
RESOLVED_REF="$ref"
RESOLVED_MODE=release
}
# -----------------------------------------------------------------------------
# 3.3 One-liner bootstrap
# -----------------------------------------------------------------------------
# needs_bootstrap SELF_PATH -> 0 when this copy was read from a pipe
needs_bootstrap() {
local self="$1"
[[ -z "$self" || "$self" == /dev/fd/* || "$self" == /proc/self/fd/* || "$self" == /dev/stdin ]]
}
bootstrap_reexec() {
resolve_install_ref
local tmp
tmp="$(mktemp -d)"
local script="${tmp}/setup-openprocessor.sh"
if [[ "$RESOLVED_MODE" == "branch" ]]; then
_dl "$(_raw_url "$RESOLVED_REF" setup-openprocessor.sh)" "$script" \
|| { rm -rf "$tmp"; die "download of setup-openprocessor.sh at ${RESOLVED_REF} failed"; }
log_warn "TESTING install from branch '${OP_BRANCH}' (${RESOLVED_REF:0:12}): not reproducible, no checksum"
else
if ! _dl "$(_asset_url "$RESOLVED_REF" setup-openprocessor.sh)" "$script" \
|| ! _dl "$(_asset_url "$RESOLVED_REF" SHA256SUMS)" "${tmp}/SHA256SUMS"; then
rm -rf "$tmp"
die "could not download the ${RESOLVED_REF} installer and its SHA256SUMS" "$EXIT_VERIFY"
fi
if ! verify_against_sums "${tmp}/SHA256SUMS" "$script" setup-openprocessor.sh; then
rm -rf "$tmp"
die "the downloaded ${RESOLVED_REF} installer failed checksum verification; nothing was run" "$EXIT_VERIFY"
fi
fi
log_info "running the verified ${RESOLVED_REF} installer"
# The child removes the temp dir on exit (exec replaces this process,
# so no trap here could ever fire).
OP_BOOTSTRAP_TMP="$tmp" OP_BOOTSTRAP_REF="$RESOLVED_REF" \
OP_BOOTSTRAP_MODE="$RESOLVED_MODE" exec bash "$script" "$@"
}
# bootstrap_child_setup -- the OP_BOOTSTRAP_* hand-off is honoured only by
# the verified copy the bootstrap itself exec'd (this script's own path is
# <OP_BOOTSTRAP_TMP>/setup-openprocessor.sh, a mktemp dir we own). From
# anywhere else the variables are ignored, so the environment can neither
# point the cleanup at another path nor inject a release ref.
bootstrap_child_setup() {
local tmp="${OP_BOOTSTRAP_TMP:-}" ref="${OP_BOOTSTRAP_REF:-}" mode="${OP_BOOTSTRAP_MODE:-}"
unset OP_BOOTSTRAP_TMP OP_BOOTSTRAP_REF OP_BOOTSTRAP_MODE
_OP_BOOT_TMP=""
_OP_BOOT_REF=""
_OP_BOOT_MODE=""
[[ -n "$tmp" ]] || return 0
if [[ "$tmp" =~ ^/[A-Za-z0-9._/-]*/tmp\.[A-Za-z0-9]{6,}$ && "$tmp" != *..* && -d "$tmp" && ! -L "$tmp" && -O "$tmp" \
&& "${_OP_SELF_PATH:-}" == "${tmp}/setup-openprocessor.sh" ]]; then
_OP_BOOT_TMP="$tmp"
if [[ "$mode" == release ]] && validate_version "$ref"; then
_OP_BOOT_REF="$ref"; _OP_BOOT_MODE=release
elif [[ "$mode" == branch && "$ref" =~ ^[0-9a-f]{40}$ ]]; then
_OP_BOOT_REF="$ref"; _OP_BOOT_MODE=branch
fi
fi
}
_op_cleanup() {
[[ -n "${_OP_BOOT_TMP:-}" ]] && rm -rf -- "$_OP_BOOT_TMP"
[[ -n "${_OP_SCRATCH:-}" ]] && rm -rf -- "$_OP_SCRATCH"
return 0
}
# -----------------------------------------------------------------------------
# 3.1 Release artifacts
# -----------------------------------------------------------------------------
# manifest_entries MANIFEST -> "path<TAB>flags" lines (comments dropped)
manifest_entries() {
awk 'NF && $1 !~ /^#/ { print $1 "\t" (NF > 1 ? $2 : "") }' "$1"
}
# _tar_is_safe TARBALL -> no absolute paths, no "..", only files and dirs
_tar_is_safe() {
local tb="$1" line name
while IFS= read -r line; do
case "${line:0:1}" in
-|d) ;;
*) log_error "release tarball contains a non-regular entry: ${line}"; return 1 ;;
esac
done < <(tar -tvzf "$tb")
while IFS= read -r name; do
if [[ "$name" == /* || "$name" == ".." || "$name" == ../* || "$name" == */../* || "$name" == */.. ]]; then
log_error "release tarball contains an unsafe path: ${name}"
return 1
fi
done < <(tar -tzf "$tb")
}
# fetch_release_artifacts REF MODE STAGING
# Release mode: SHA256SUMS first, then the deploy tarball (every file
# inside re-verified against SHA256SUMS, nothing extra allowed), falling
# back to raw files at the tag, each verified. Branch mode: raw files, no
# checksums (a loudly-labelled testing install).
fetch_release_artifacts() {
local ref="$1" mode="$2" staging="$3" sums="" tb path flags f rel
rm -rf "$staging"
mkdir -p "$staging"
if [[ "$mode" == "release" ]]; then
sums="${staging}.SHA256SUMS"
_dl "$(_asset_url "$ref" SHA256SUMS)" "$sums" \
|| die "could not download SHA256SUMS for ${ref}" "$EXIT_VERIFY"
# Running the installer straight out of a release dir: it must match
# that release's checksum list too (the piped bootstrap does the same).
if [[ -n "${OP_RELEASE_DIR:-}" && "${_OP_SELF_PATH:-}" == "${OP_RELEASE_DIR}/setup-openprocessor.sh" ]]; then
verify_against_sums "$sums" "$_OP_SELF_PATH" setup-openprocessor.sh \
|| die "this setup-openprocessor.sh does not match the ${ref} checksums; nothing was installed" "$EXIT_VERIFY"
fi
tb="${staging}.tar.gz"
if _dl "$(_asset_url "$ref" "openprocessor-deploy-${ref}.tar.gz")" "$tb"; then
verify_against_sums "$sums" "$tb" "openprocessor-deploy-${ref}.tar.gz" \
|| die "deploy tarball for ${ref} failed checksum verification; nothing was installed" "$EXIT_VERIFY"
_tar_is_safe "$tb" || die "deploy tarball for ${ref} is unsafe; nothing was installed" "$EXIT_VERIFY"
tar -xzf "$tb" -C "$staging" --no-same-owner --no-same-permissions
rm -f "$tb"
while IFS= read -r -d '' f; do
rel="${f#"$staging"/}"
verify_against_sums "$sums" "$f" "$rel" \
|| die "file ${rel} in the ${ref} tarball failed verification; nothing was installed" "$EXIT_VERIFY"
done < <(find "$staging" -type f -print0)
else
log_warn "deploy tarball not available for ${ref}; fetching verified raw files at the tag"
_dl "$(_raw_url "$ref" release-manifest.txt)" "${staging}/release-manifest.txt" \
|| die "could not download release-manifest.txt at ${ref}" "$EXIT_VERIFY"
verify_against_sums "$sums" "${staging}/release-manifest.txt" release-manifest.txt \
|| die "release-manifest.txt failed verification" "$EXIT_VERIFY"
while IFS=$'\t' read -r path flags; do
if [[ "$path" == *'**' ]]; then
local prefix="${path%%\*\*}"
while read -r _ rel; do
rel="${rel#\*}"
[[ "$rel" == "$prefix"* ]] || continue
mkdir -p "${staging}/$(dirname "$rel")"
_dl "$(_raw_url "$ref" "$rel")" "${staging}/${rel}" \
|| die "download failed: ${rel}" "$EXIT_VERIFY"
verify_against_sums "$sums" "${staging}/${rel}" "$rel" || die "${rel} failed verification" "$EXIT_VERIFY"
done < "$sums"
continue
fi
[[ "$path" == "release-manifest.txt" ]] && continue
mkdir -p "${staging}/$(dirname "$path")"
if ! _dl "$(_raw_url "$ref" "$path")" "${staging}/${path}"; then
[[ "$flags" == *optional* ]] && continue
die "download failed: ${path}" "$EXIT_VERIFY"
fi
verify_against_sums "$sums" "${staging}/${path}" "$path" || die "${path} failed verification" "$EXIT_VERIFY"
done < <(manifest_entries "${staging}/release-manifest.txt")
fi
rm -f "$sums"
else
_dl "$(_raw_url "$ref" release-manifest.txt)" "${staging}/release-manifest.txt" \
|| die "could not download release-manifest.txt at ${ref}"
while IFS=$'\t' read -r path flags; do
if [[ "$path" == *'**' ]]; then
log_warn "branch install: optional '${path}' is not fetched (no checksum list to enumerate it)"
continue
fi
[[ "$path" == "release-manifest.txt" ]] && continue
mkdir -p "${staging}/$(dirname "$path")"
if ! _dl "$(_raw_url "$ref" "$path")" "${staging}/${path}"; then
[[ "$flags" == *optional* ]] && continue
die "download failed: ${path}"
fi
done < <(manifest_entries "${staging}/release-manifest.txt")
fi
[[ -f "${staging}/release-manifest.txt" ]] || die "release has no release-manifest.txt" "$EXIT_VERIFY"
while IFS=$'\t' read -r path flags; do
[[ "$path" == *'**' || "$flags" == *optional* ]] && continue
[[ -f "${staging}/${path}" ]] || die "release is missing ${path}" "$EXIT_VERIFY"
done < <(manifest_entries "${staging}/release-manifest.txt")
}
# manifest_installed_files DIR -> every manifest file present under DIR
manifest_installed_files() {
local dir="$1" path flags f
[[ -f "${dir}/release-manifest.txt" ]] || return 0
echo "release-manifest.txt"
while IFS=$'\t' read -r path flags; do
if [[ "$path" == *'**' ]]; then
local prefix="${path%%\*\*}"
[[ -d "${dir}/${prefix}" ]] || continue
while IFS= read -r -d '' f; do
echo "${f#"$dir"/}"
done < <(find "${dir}/${prefix}" -type f -print0)
elif [[ -f "${dir}/${path}" && "$path" != "release-manifest.txt" ]]; then
echo "$path"
fi
done < <(manifest_entries "${dir}/release-manifest.txt")
}
# backup_install -> copies the current release files, .env and state into
# backups/<UTC timestamp>/ and prints that directory
backup_install() {
local ts dest rel
ts="$(date -u +%Y%m%dT%H%M%SZ)"
dest="${OP_DIR}/backups/${ts}"
mkdir -p "$dest"
chmod 700 "${OP_DIR}/backups" "$dest"
while IFS= read -r rel; do
mkdir -p "${dest}/$(dirname "$rel")" && cp -p "${OP_DIR}/${rel}" "${dest}/${rel}" || return 1
done < <(manifest_installed_files "$OP_DIR")
for rel in .env .install/state.json .install/managed.env .install/groups.tsv; do
if [[ -f "${OP_DIR}/${rel}" ]]; then
mkdir -p "${dest}/$(dirname "$rel")" && cp -p "${OP_DIR}/${rel}" "${dest}/${rel}" || return 1
fi
done
echo "$dest"
}
# install_staged STAGING -> copy verified files into OP_DIR (preserve-flagged
# files are never overwritten; .env is never part of a release)
install_staged() {
local staging="$1" path flags f rel
local -A flag_of=()
while IFS=$'\t' read -r path flags; do
flag_of["$path"]="$flags"
done < <(manifest_entries "${staging}/release-manifest.txt")
while IFS= read -r -d '' f; do
rel="${f#"$staging"/}"
[[ "$rel" == ".env" ]] && continue
if [[ "${flag_of[$rel]:-}" == *preserve* && -e "${OP_DIR}/${rel}" ]]; then
continue
fi
mkdir -p "${OP_DIR}/$(dirname "$rel")"
cp -f "$f" "${OP_DIR}/${rel}"
# Release files are not secret, and several are bind-mounted into
# containers running as other users (Prometheus, Grafana, Loki).
if [[ "${flag_of[$rel]:-}" == *exec* ]]; then
chmod 755 "${OP_DIR}/${rel}"
else
chmod 644 "${OP_DIR}/${rel}"
fi
done < <(find "$staging" -type f -print0)
rm -rf "$staging"
}
# -----------------------------------------------------------------------------
# 2. Install tiers
# -----------------------------------------------------------------------------
tier_implies() {
case "$1" in
curation) echo "core" ;;
segmenter|vlm|trainer|cropwright) echo "core curation" ;;
*) echo "" ;;
esac
}
# tiers_close_dependencies "t1,t2" -> closure in TIER_LIST order, space-separated
tiers_close_dependencies() {
local -A selected=()
local -a requested out=()
local t dep
IFS=',' read -ra requested <<< "$1"
for t in "${requested[@]}"; do
[[ -z "$t" ]] && continue
selected["$t"]=1
for dep in $(tier_implies "$t"); do
selected["$dep"]=1
done
done
for t in "${TIER_LIST[@]}"; do
[[ -n "${selected[$t]:-}" ]] && out+=("$t")
done
echo "${out[*]}"
}
tiers_validate() {
local -a requested
local t known
IFS=',' read -ra requested <<< "$1"
for t in "${requested[@]}"; do
[[ -z "$t" ]] && continue
for known in "${TIER_LIST[@]}"; do
[[ "$t" == "$known" ]] && continue 2
done
log_error "unknown tier: ${t} (known: ${TIER_LIST[*]})"
return 1
done
return 0
}
_has_tier() {
[[ " ${SELECTED_TIERS} " == *" $1 "* ]]
}
# -----------------------------------------------------------------------------
# 2.1 GPU detection and recommendation
# -----------------------------------------------------------------------------
# gpu_query -> raw nvidia-smi rows (index, name, total MiB, used MiB, cc)
gpu_query() {
command -v nvidia-smi >/dev/null 2>&1 || return 1
nvidia-smi --query-gpu=index,name,memory.total,memory.used,compute_cap \
--format=csv,noheader,nounits 2>/dev/null
}
# gpu_normalize -> "index total_mib used_mib" per row; parses from the right
# so a GPU name containing commas cannot shift the numeric columns.
gpu_normalize() {
awk -F',' 'NF >= 5 {
idx = $1; total = $(NF-2); used = $(NF-1)
gsub(/[^0-9]/, "", idx); gsub(/[^0-9]/, "", total); gsub(/[^0-9]/, "", used)
if (idx != "" && total != "" && used != "") print idx, total, used
}'
}
# gpu_labels -> "0=NVIDIA RTX A6000,1=..." (names with commas are squashed)
gpu_labels() {
awk -F',' 'NF >= 5 {
idx = $1; gsub(/[^0-9]/, "", idx)
name = $2; for (i = 3; i <= NF - 3; i++) name = name " " $i
gsub(/^[ \t]+|[ \t]+$/, "", name); gsub(/[^A-Za-z0-9 ._()-]/, "", name)
printf "%s%s=%s", (n++ ? "," : ""), idx, name
} END { print "" }'
}
# gpu_own_usage -> "index mib" per GPU: VRAM held by this project's own
# containers, so a re-run does not count its own services as "other
# processes" (the plan would otherwise refuse or shrink what is installed).
gpu_own_usage() {
command -v nvidia-smi >/dev/null 2>&1 || return 0
local cid pids="" apps idx
for cid in $(docker ps -q --filter "label=com.docker.compose.project=${OP_PROJECT}" 2>/dev/null); do
pids+=" $(docker top "$cid" -eo pid 2>/dev/null | awk 'NR > 1 { print $1 }' | tr '\n' ' ')"
done
[[ -n "${pids// /}" ]] || return 0
apps="$(nvidia-smi --query-compute-apps=pid,gpu_uuid,used_gpu_memory --format=csv,noheader,nounits 2>/dev/null || true)"
idx="$(nvidia-smi --query-gpu=index,uuid --format=csv,noheader 2>/dev/null || true)"
awk -F', *' -v pids="$pids" -v idxmap="$idx" '
BEGIN {
n = split(pids, a, " "); for (i = 1; i <= n; i++) mine[a[i]] = 1
m = split(idxmap, rows, "\n")
for (i = 1; i <= m; i++) { split(rows[i], f, ", *"); if (f[2] != "") byuuid[f[2]] = f[1] }
}
($1 in mine) && ($2 in byuuid) { sum[byuuid[$2]] += $3 }
END { for (g in sum) print g, sum[g] }' <<< "$apps"
}
# gpu_subtract_own GPUS OWN -- GPUS rows "index total used" minus OWN rows
# "index mib" (never below 0)
gpu_subtract_own() {
awk 'NR == FNR { own[$1] += $2; next }
{ u = $3 - ($1 in own ? own[$1] : 0); if (u < 0) u = 0; print $1, $2, u }' \
<(printf '%s\n' "$2") <(printf '%s\n' "$1")
}
# docker_runtime_has_nvidia -> 1 yes, 0 no, 2 could not determine
docker_runtime_has_nvidia() {
local out
if ! out="$(docker info --format '{{json .Runtimes}}' 2>/dev/null)" || [[ -z "$out" ]]; then
echo 2
return 0
fi
if [[ "$out" == *nvidia* ]]; then
echo 1
else
echo 0
fi
}
_triton_need_gb() {
case "$1" in
minimal) echo 8 ;;
full) echo 17 ;;
*) echo 12 ;;
esac
}
# recommend_plan GPUS TIERS [force=0|1] [vlm_id=ID] [gpu_plan=k=v,...]
# [profile=NAME] [remote=0|1]
# GPUS: "index total_mib used_mib" lines. TIERS: requested closure
# (space-separated) or "" to recommend. Pure: reads only the VLM catalog.
# Prints key=value lines; every key at most once, "warn=" may repeat.
# Returns 1 (with refuse=) when the plan is impossible.
recommend_plan() {
local gpus="$1" req_tiers="$2"
shift 2
local force=0 vlm_id="" gpu_plan="" profile_ovr="" remote=0 kv
for kv in "$@"; do
case "$kv" in
force=*) force="${kv#force=}" ;;
vlm_id=*) vlm_id="${kv#vlm_id=}" ;;
gpu_plan=*) gpu_plan="${kv#gpu_plan=}" ;;
profile=*) profile_ovr="${kv#profile=}" ;;
remote=*) remote="${kv#remote=}" ;;
esac
done
local -a ids=() tot=() free=() warns=()
local -A pos=()
local i t u n=0
while read -r i t u; do
[[ -z "${i:-}" ]] && continue
ids+=("$i"); tot+=($(( (t + 512) / 1024 ))); free+=($(( (t - u) / 1024 )))
pos["$i"]=$n
if (( t > 0 && u * 100 / t > 10 )); then
warns+=("GPU ${i}: $(( u * 100 / t ))% of its VRAM is already in use by other processes")
fi
n=$((n + 1))
done <<< "$gpus"
local tri="" vlm="" seg="" trn="" profile="" inst=1 single=0 tri_explicit=0
local -a usable=()
for (( i = 0; i < n; i++ )); do
if (( free[i] >= 8 )); then usable+=("$i"); fi
done
if (( n == 0 )); then
echo "gpu_count=0"
echo "refuse=no NVIDIA GPU detected"
return 1
fi
if (( ${#usable[@]} == 0 )); then
if [[ "$force" == 1 ]]; then
local best=0
for (( i = 1; i < n; i++ )); do (( free[i] > free[best] )) && best=$i; done
usable=("$best")
warns+=("no GPU has 8 GB free; --force installs core with GPU_PROFILE=minimal anyway")
else
echo "gpu_count=${n}"
echo "refuse=no GPU has at least 8 GB of free VRAM (the core tier's floor); use --force to try anyway"
for kv in "${warns[@]}"; do echo "warn=${kv}"; done
return 1
fi
fi
local k=${#usable[@]} a b c best
if (( k == 1 )); then
single=1
tri=${usable[0]}; vlm=$tri; seg=$tri; trn=$tri
local f=${free[tri]}
if (( f < 16 )); then
profile=minimal
else
profile=standard
(( f >= 24 )) && inst=2
fi
elif (( k == 2 )); then
a=${usable[0]}; b=${usable[1]}
if (( free[a] > free[b] )); then c=$a; a=$b; b=$c; fi
if (( free[a] >= 40 && free[b] >= 40 )); then
tri=$a; seg=$a; trn=$a; vlm=$b; inst=2
else
tri=$a; vlm=$b; seg=$b
(( free[b] >= 24 )) && inst=2
if (( free[a] - $(_triton_need_gb standard) - 2 >= 16 )); then
trn=$a
else
trn=$b
fi
fi
if (( free[tri] >= 24 )); then profile=full
elif (( free[tri] >= 12 )); then profile=standard
else profile=minimal; fi
else
# Triton: the smallest card that is >= 12 GB (fixed, latency-bound).
tri=""
for i in "${usable[@]}"; do
(( tot[i] >= 12 )) || continue
if [[ -z "$tri" ]] || (( tot[i] < tot[tri] )); then tri=$i; fi
done
if [[ -z "$tri" ]]; then
tri=${usable[0]}
for i in "${usable[@]}"; do (( tot[i] < tot[tri] )) && tri=$i; done
fi
# VLM: the largest remaining card (ties: more free VRAM wins).
vlm=""
for i in "${usable[@]}"; do
(( i == tri )) && continue
if [[ -z "$vlm" ]] || (( tot[i] > tot[vlm] )) || (( tot[i] == tot[vlm] && free[i] > free[vlm] )); then
vlm=$i
fi
done
# Segmenter + trainer + evaluator: the remaining card with most free.
seg=""
for i in "${usable[@]}"; do
(( i == tri || i == vlm )) && continue
if [[ -z "$seg" ]] || (( free[i] > free[seg] )); then seg=$i; fi
done
trn=$seg
(( free[seg] >= 24 )) && inst=2
if (( free[tri] >= 24 )); then profile=full
elif (( free[tri] >= 12 )); then profile=standard
else profile=minimal; fi
fi
# Explicit --gpu-plan wins over the automatic placement.
if [[ -n "$gpu_plan" ]]; then
local -a pairs
local key val
IFS=',' read -ra pairs <<< "$gpu_plan"
for kv in "${pairs[@]}"; do
key="${kv%%=*}"; val="${kv#*=}"
if [[ -z "${pos[$val]+x}" ]]; then
echo "gpu_count=${n}"
echo "refuse=--gpu-plan ${kv}: no GPU with index ${val}"
return 1
fi
case "$key" in
triton) tri=${pos[$val]}; tri_explicit=1 ;;
segmenter) seg=${pos[$val]} ;;
vlm) vlm=${pos[$val]} ;;
trainer|evaluator) trn=${pos[$val]} ;;
*)
echo "gpu_count=${n}"
echo "refuse=--gpu-plan: unknown key '${key}' (triton, segmenter, vlm, trainer)"
return 1 ;;
esac
done
single=0
if (( tri == vlm && vlm == seg )); then single=1; fi
fi
[[ -n "$profile_ovr" ]] && profile="$profile_ovr"
# The API container runs the TensorRT exports on Triton's own card (API_GPU_ID
# == TRITON_GPU_ID). Triton loads existing engines first, so on a card with
# little free VRAM the builder hits CUDA out-of-memory (#111). An explicit
# --gpu-plan onto such a card is refused unless --force; the automatic plan
# only warns (a fresh install has no engines loaded yet).
local export_floor_gb=16
if (( free[tri] < export_floor_gb )); then
local share_msg="Triton and the API (which runs the engine exports) share GPU ${ids[tri]} with only ${free[tri]} GB free; exports need about ${export_floor_gb} GB there and fail with out-of-GPU-memory once Triton has engines loaded"
if (( tri_explicit == 1 )) && [[ "$force" != 1 ]]; then
echo "gpu_count=${n}"
echo "refuse=--gpu-plan triton=${ids[tri]}: ${share_msg}; put Triton on a card with at least ${export_floor_gb} GB free (--gpu-plan triton=N) or pass --force to try anyway"
for kv in "${warns[@]}"; do echo "warn=${kv}"; done
return 1
fi
warns+=("${share_msg}; if an export step reports out of GPU memory, re-run with --gpu-plan triton=<card with >= ${export_floor_gb} GB free> (or --force to keep this placement)")
fi
local tn pe_need=2
tn=$(_triton_need_gb "$profile")
# _avail_on_vlm INST -> free VRAM left on the VLM card for the VLM
local vlm_avail
_plan_vlm_avail() {
local s=$1 x=${free[vlm]}
(( tri == vlm )) && x=$(( x - tn - pe_need ))
(( seg == vlm )) && x=$(( x - 2 * s ))
echo "$x"
}
# Which tiers the hardware allows (and why not).
local -A allowed=() why=()
local ftri=${free[tri]}
allowed[core]=1
if (( ftri - tn >= pe_need || tri != seg )); then allowed[curation]=1; else why[curation]="not enough VRAM for the PE encoder next to Triton"; fi
if (( single == 1 && free[seg] < 12 )); then
why[segmenter]="needs a card with at least 12 GB free (has ${free[seg]} GB)"
else
allowed[segmenter]=1
(( single == 1 && free[seg] < 16 )) && inst=1
fi
if (( single == 1 && free[trn] < 16 )); then
why[trainer]="needs at least 16 GB free for a training run"
else
allowed[trainer]=1
fi
allowed[cropwright]=1
local wants_vlm=0
if [[ -z "$req_tiers" ]]; then
[[ "$remote" != 1 ]] && wants_vlm=1
elif [[ " $req_tiers " == *" vlm "* ]]; then
wants_vlm=1
fi
local vlm_pick="" vlm_status="none" line
if (( wants_vlm == 1 )); then
vlm_avail=$(_plan_vlm_avail "$inst")
if [[ -n "$vlm_id" ]]; then
local need st
if ! need="$(vlm_catalog_field "$vlm_id" vram_gb)"; then
echo "gpu_count=${n}"
echo "refuse=--vlm-model-id '${vlm_id}' is not in the VLM catalog"
return 1
fi
st="$(vlm_catalog_field "$vlm_id" status)"
if (( need > vlm_avail )) && [[ "$force" != 1 ]]; then
why[vlm]="--vlm-model-id ${vlm_id} needs ${need} GB, only ${vlm_avail} GB is available on GPU ${ids[vlm]} (use --force to try anyway)"
else
(( need > vlm_avail )) && warns+=("VLM ${vlm_id} needs ${need} GB but only ${vlm_avail} GB is free; forced")
allowed[vlm]=1; vlm_pick="$vlm_id"; vlm_status="$st"
[[ "$st" != tested ]] && warns+=("VLM ${vlm_id} is unverified/experimental (not tested with the prompt contract; prefer a tested catalog entry); run 'openprocessor vlm probe' after install and check the pairing warnings")
fi
else
if ! line="$(pick_vlm "$vlm_avail")" && (( inst == 2 && seg == vlm )); then
inst=1
vlm_avail=$(_plan_vlm_avail "$inst")
line="$(pick_vlm "$vlm_avail")" || line=""
fi
if [[ -n "${line:-}" ]]; then
allowed[vlm]=1; vlm_pick="${line%%$'\t'*}"; vlm_status=tested
else
why[vlm]="no tested catalog entry fits the ${vlm_avail} GB left on GPU ${ids[vlm]} (tested floor $(vlm_catalog_floor_gb) GB); use --vlm-remote, or pick an unverified entry explicitly with --vlm-model-id"
fi
fi
else
vlm_avail=$(_plan_vlm_avail "$inst")
fi
local rec="core"
[[ -n "${allowed[curation]:-}" ]] && rec+=",curation"
if [[ -n "${allowed[segmenter]:-}" ]] && ! (( single == 1 && free[seg] < 16 )); then rec+=",segmenter"; fi
if [[ -n "${allowed[vlm]:-}" && "$remote" != 1 && -z "$req_tiers" ]]; then rec+=",vlm"; fi
local final="" tt
if [[ -n "$req_tiers" ]]; then
for tt in $req_tiers; do
if [[ -z "${allowed[$tt]:-}" ]]; then
if [[ "$force" == 1 && "$tt" != vlm ]]; then
warns+=("tier ${tt}: ${why[$tt]:-not supported on this hardware}; forced")
else
echo "gpu_count=${n}"
echo "refuse=tier ${tt}: ${why[$tt]:-not supported on this hardware}"
for kv in "${warns[@]}"; do echo "warn=${kv}"; done
return 1
fi
fi
final+="${final:+,}${tt}"
done
else
final="$rec"
[[ -n "${why[vlm]:-}" && "$remote" != 1 ]] && warns+=("local VLM not offered: ${why[vlm]}")
fi
if [[ ",$final," == *",vlm,"* ]] && (( single == 1 || tri == vlm )); then
warns+=("the VLM shares GPU ${ids[vlm]} with Triton; it gets only the VRAM left after Triton and the segmenter")
fi
if [[ ",$final," == *",trainer,"* ]] && (( trn == vlm || trn == seg && single == 1 )); then
warns+=("training shares its GPU with the VLM/segmenter: run 'openprocessor train-mode on' before a run and 'train-mode off' after")
fi
local util="" total_mib=$(( tot[vlm] * 1024 ))
if [[ -n "$vlm_pick" ]]; then
util="$(vlm_gpu_memory_utilization "$(vlm_catalog_field "$vlm_pick" vram_gb)" "${tot[vlm]}")"
fi
local allowed_ids
allowed_ids="$(IFS=,; echo "${ids[*]}")"
echo "gpu_count=${n}"