@astrofoundry/pi-astro 0.34.1 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/agents/arcane.md +3 -1
- package/agents/inference.md +4 -5
- package/agents/release-reviewer.md +2 -2
- package/extensions/astro-discord/daily-report.test.ts +153 -77
- package/extensions/astro-discord/daily-report.ts +322 -53
- package/extensions/astro-discord/index.ts +180 -51
- package/extensions/astro-discord/reports.ts +0 -2
- package/extensions/astro-discord/tools-report.test.ts +78 -13
- package/extensions/astro-discord/tools-report.ts +123 -40
- package/extensions/specialist-gate/approvals.test.ts +4 -1
- package/extensions/specialist-gate/index.ts +1 -1
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +16 -3
- package/skills/edge/SKILL.md +1 -1
- package/skills/inference/SKILL.md +3 -27
- package/skills/proxmox/SKILL.md +2 -2
- package/skills/security/SKILL.md +2 -2
- package/specialists/AGENTS.md +8 -7
- package/specialists/README.md +24 -18
- package/specialists/arcane/run.ts +223 -10
- package/specialists/edge/run.ts +3 -3
- package/specialists/entry/remote/spc-arcane-install.sh +2 -2
- package/specialists/entry/remote/spc-arcane-op.py +3 -0
- package/specialists/entry/specialist-install.sh +266 -8
- package/specialists/entry/specialist-install.test.ts +50 -1
- package/specialists/inference/run.ts +12 -94
- package/specialists/lib/arcane-host.test.ts +24 -1
- package/specialists/lib/arcane-host.ts +6 -2
- package/specialists/lib/deployment.ts +1 -1
- package/specialists/lib/stack-update.test.ts +101 -0
- package/specialists/lib/stack-update.ts +115 -0
- package/specialists/provision/provision.sh +2 -2
- package/specialists/wrappers.test.ts +7 -16
- package/specialists/entry/remote/spc-inference-hermes-entry.sh +0 -27
- package/specialists/entry/remote/spc-inference-pulsar-entry.sh +0 -14
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
2
|
# /usr/local/sbin/specialist-install: installs the specialist user's Node, wrappers,
|
|
3
|
-
# arcane-cli, and
|
|
3
|
+
# arcane-cli, trendarr, Python, gcloud, bw, and jq from downloads it checks itself.
|
|
4
|
+
# Installed root-owned 0755.
|
|
4
5
|
# Runs as root through
|
|
5
6
|
# cortex ALL=(root) NOPASSWD: /usr/local/sbin/specialist-install
|
|
6
7
|
# It runs only system binaries and the Node it installed, never anything the cortex
|
|
7
8
|
# user can write (all of Homebrew), and it touches the specialist's home only as that
|
|
8
9
|
# user, so links the specialist plants there cannot redirect a root write.
|
|
9
10
|
#
|
|
10
|
-
# specialist-install node|wrappers|arcane-cli|trendarr <version>
|
|
11
|
-
# specialist-install
|
|
11
|
+
# specialist-install node|wrappers|arcane-cli|trendarr|gcloud|bw|jq <version>
|
|
12
|
+
# specialist-install python <x.y.z+YYYYMMDD>
|
|
13
|
+
# specialist-install rollback node|wrappers|arcane-cli|trendarr|python|gcloud|bw|jq
|
|
12
14
|
# specialist-install status
|
|
13
15
|
set -eu
|
|
14
16
|
PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
|
@@ -18,11 +20,18 @@ cd /
|
|
|
18
20
|
|
|
19
21
|
SPECIALIST=specialist
|
|
20
22
|
NODE_BASE=/usr/local/libexec/specialist-node
|
|
23
|
+
PYTHON_BASE=/usr/local/libexec/specialist-python
|
|
24
|
+
GCLOUD_BASE=/usr/local/libexec/specialist-gcloud
|
|
25
|
+
BW_BASE=/usr/local/libexec/specialist-bw
|
|
26
|
+
JQ_BASE=/usr/local/libexec/specialist-jq
|
|
27
|
+
# Components of the official darwin-arm gcloud archive that gcloud itself needs (bq,
|
|
28
|
+
# gsutil, and the empty macOS bundled-python component are left out).
|
|
29
|
+
GCLOUD_COMPONENTS="core core-nix gcloud-deps gcloud-deps-darwin-arm gcloud-crc32c-darwin-arm"
|
|
21
30
|
# Same list as the allowlist in specialist-cli; a new specialist needs both reinstalled.
|
|
22
31
|
SERVICES="arcane identity network dns edge security backup proxmox inference media"
|
|
23
32
|
|
|
24
33
|
usage() {
|
|
25
|
-
echo "usage: specialist-install node|wrappers|arcane-cli|trendarr <version> | rollback node|wrappers|arcane-cli|trendarr | status" >&2
|
|
34
|
+
echo "usage: specialist-install node|wrappers|arcane-cli|trendarr|gcloud|bw|jq <version> | python <x.y.z+YYYYMMDD> | rollback node|wrappers|arcane-cli|trendarr|python|gcloud|bw|jq | status" >&2
|
|
26
35
|
exit 2
|
|
27
36
|
}
|
|
28
37
|
|
|
@@ -50,17 +59,34 @@ version_lt() {
|
|
|
50
59
|
[ "$3" -lt "$6" ]
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
# A python-build-standalone release: CPython x.y.z and the build date that names the release.
|
|
63
|
+
valid_python_version() {
|
|
64
|
+
case "$1" in '' | *[!0-9.+]*) return 1 ;; esac
|
|
65
|
+
printf '%s\n' "$1" | /usr/bin/grep -Eq '^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\+[0-9]{8}$'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# True when python release $1 is older than $2: CPython version first, then build date.
|
|
69
|
+
python_lt() {
|
|
70
|
+
version_lt "${1%+*}" "${2%+*}" && return 0
|
|
71
|
+
version_lt "${2%+*}" "${1%+*}" && return 1
|
|
72
|
+
[ "${1#*+}" -lt "${2#*+}" ]
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
component_ok() {
|
|
54
|
-
case "$1" in node | wrappers | arcane-cli | trendarr) return 0 ;; esac
|
|
76
|
+
case "$1" in node | wrappers | arcane-cli | trendarr | python | gcloud | bw | jq) return 0 ;; esac
|
|
55
77
|
return 1
|
|
56
78
|
}
|
|
57
79
|
|
|
58
80
|
command=${1:-}
|
|
59
81
|
case "$command" in
|
|
60
|
-
node | wrappers | arcane-cli | trendarr)
|
|
82
|
+
node | wrappers | arcane-cli | trendarr | gcloud | bw | jq)
|
|
61
83
|
[ $# -eq 2 ] || usage
|
|
62
84
|
valid_version "$2" || usage
|
|
63
85
|
;;
|
|
86
|
+
python)
|
|
87
|
+
[ $# -eq 2 ] || usage
|
|
88
|
+
valid_python_version "$2" || usage
|
|
89
|
+
;;
|
|
64
90
|
rollback)
|
|
65
91
|
[ $# -eq 2 ] || usage
|
|
66
92
|
component_ok "$2" || usage
|
|
@@ -230,6 +256,217 @@ rollback_node() {
|
|
|
230
256
|
echo "node $previous is current; previous $current"
|
|
231
257
|
}
|
|
232
258
|
|
|
259
|
+
# Root-owned tools for the specialist: each version in its own folder under
|
|
260
|
+
# /usr/local/libexec/specialist-<tool>, behind the links current and previous.
|
|
261
|
+
check_tool_base() {
|
|
262
|
+
check_root_dir /
|
|
263
|
+
check_root_dir /usr
|
|
264
|
+
check_root_dir /usr/local
|
|
265
|
+
ensure_root_dir /usr/local/libexec
|
|
266
|
+
ensure_root_dir "$1"
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
# $1 base, $2 link name, $3 version check, $4 executable inside a version folder.
|
|
270
|
+
tool_link() {
|
|
271
|
+
target=$(/usr/bin/readlink "$1/$2" 2>/dev/null) || return 0
|
|
272
|
+
"$3" "$target" && [ -x "$1/$target/$4" ] && printf '%s' "$target"
|
|
273
|
+
return 0
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
switch_tool_link() {
|
|
277
|
+
/bin/rm -f "$1/.$2.tmp"
|
|
278
|
+
(umask 022 && /bin/ln -s "$3" "$1/.$2.tmp")
|
|
279
|
+
/bin/mv -h "$1/.$2.tmp" "$1/$2"
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
new_tool_folder() {
|
|
283
|
+
/bin/rm -rf "$1.partial"
|
|
284
|
+
/bin/mkdir -m 0755 "$1.partial"
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
seal_tool_folder() {
|
|
288
|
+
/usr/sbin/chown -R root:wheel "$1.partial"
|
|
289
|
+
/bin/chmod -R u=rwX,go=rX "$1.partial"
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# Moves the checked $base/$version.partial into place, makes it current, keeps the old current as previous.
|
|
293
|
+
activate_tool() {
|
|
294
|
+
/bin/rm -rf "$1/$2"
|
|
295
|
+
/bin/mv "$1/$2.partial" "$1/$2"
|
|
296
|
+
[ -z "$3" ] || switch_tool_link "$1" previous "$3"
|
|
297
|
+
switch_tool_link "$1" current "$2"
|
|
298
|
+
for d in "$1"/*; do
|
|
299
|
+
n=${d##*/}
|
|
300
|
+
case "$n" in current | previous | "$2" | "$3") continue ;; esac
|
|
301
|
+
/bin/rm -rf "$d"
|
|
302
|
+
done
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
# $1 base, $2 label, $3 version check, $4 executable, $5 smoke function taking the executable's path.
|
|
306
|
+
rollback_tool() {
|
|
307
|
+
check_tool_base "$1"
|
|
308
|
+
current=$(tool_link "$1" current "$3" "$4")
|
|
309
|
+
previous=$(tool_link "$1" previous "$3" "$4")
|
|
310
|
+
[ -n "$current" ] && [ -n "$previous" ] || fail "no previous $2 to roll back to"
|
|
311
|
+
"$5" "$1/$previous/$4" "$previous" || fail "$2 $previous fails its check"
|
|
312
|
+
switch_tool_link "$1" current "$previous"
|
|
313
|
+
switch_tool_link "$1" previous "$current"
|
|
314
|
+
echo "$2 $previous is current; previous $current"
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
# Python from python-build-standalone: gcloud needs Python 3.10 to 3.15, and its macOS
|
|
318
|
+
# installer offers only Homebrew's (owned by cortex) or an unchecked python.org package.
|
|
319
|
+
SMOKE_PYTHON='"$1" -c "import ssl, sqlite3, sys; print(\"%d.%d.%d\" % sys.version_info[:3])"'
|
|
320
|
+
SMOKE_GCLOUD='cfg=$(/usr/bin/mktemp -d); trap "/bin/rm -rf \"\$cfg\"" EXIT
|
|
321
|
+
CLOUDSDK_PYTHON=$2 CLOUDSDK_CONFIG=$cfg CLOUDSDK_CORE_DISABLE_PROMPTS=1 "$1" version 2>/dev/null | /usr/bin/head -n 1'
|
|
322
|
+
SMOKE_BW='app=$(/usr/bin/mktemp -d); trap "/bin/rm -rf \"\$app\"" EXIT
|
|
323
|
+
BITWARDENCLI_APPDATA_DIR=$app "$1" --version 2>/dev/null'
|
|
324
|
+
SMOKE_JQ='"$1" --version'
|
|
325
|
+
|
|
326
|
+
smoke_python() {
|
|
327
|
+
[ "$(as_specialist "$SMOKE_PYTHON" "$1")" = "${2%+*}" ]
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
current_python() {
|
|
331
|
+
python=$(tool_link "$PYTHON_BASE" current valid_python_version bin/python3)
|
|
332
|
+
[ -n "$python" ] || fail "no specialist Python yet: run specialist-install python <x.y.z+YYYYMMDD> first"
|
|
333
|
+
printf '%s' "$PYTHON_BASE/$python/bin/python3"
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
# gcloud $1 (bin/gcloud) answers with version $2, run by Python $3.
|
|
337
|
+
gcloud_answers() {
|
|
338
|
+
[ "$(as_specialist "$SMOKE_GCLOUD" "$1" "$3")" = "Google Cloud SDK $2" ]
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
smoke_gcloud() {
|
|
342
|
+
gcloud_answers "$1" "$2" "$(current_python)"
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
smoke_bw() {
|
|
346
|
+
[ "$(as_specialist "$SMOKE_BW" "$1")" = "$2" ]
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
smoke_jq() {
|
|
350
|
+
[ "$(as_specialist "$SMOKE_JQ" "$1")" = "jq-$2" ]
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
install_python() {
|
|
354
|
+
version=$1
|
|
355
|
+
check_tool_base "$PYTHON_BASE"
|
|
356
|
+
current=$(tool_link "$PYTHON_BASE" current valid_python_version bin/python3)
|
|
357
|
+
if [ -n "$current" ] && python_lt "$version" "$current"; then fail "python $version is older than the installed $current; use rollback"; fi
|
|
358
|
+
[ "$current" != "$version" ] || { echo "python $version is already current"; return 0; }
|
|
359
|
+
tag=${version#*+}
|
|
360
|
+
name="cpython-$version-aarch64-apple-darwin-install_only_stripped.tar.gz"
|
|
361
|
+
base="https://github.com/astral-sh/python-build-standalone/releases/download/$tag"
|
|
362
|
+
fetch "$base/$name" "$work/$name"
|
|
363
|
+
fetch "$base/SHA256SUMS" "$work/SHA256SUMS"
|
|
364
|
+
check_sha256 "$work" "$name" "$work/SHA256SUMS"
|
|
365
|
+
dest="$PYTHON_BASE/$version"
|
|
366
|
+
new_tool_folder "$dest"
|
|
367
|
+
/usr/bin/tar -xzf "$work/$name" -C "$dest.partial" --strip-components 1
|
|
368
|
+
seal_tool_folder "$dest"
|
|
369
|
+
smoke_python "$dest.partial/bin/python3" "$version" || fail "python $version does not run"
|
|
370
|
+
gcloud=$(tool_link "$GCLOUD_BASE" current valid_version bin/gcloud)
|
|
371
|
+
if [ -n "$gcloud" ]; then
|
|
372
|
+
gcloud_answers "$GCLOUD_BASE/$gcloud/bin/gcloud" "$gcloud" "$dest.partial/bin/python3" || fail "gcloud $gcloud fails with python $version; current stays ${current:-unset}"
|
|
373
|
+
fi
|
|
374
|
+
activate_tool "$PYTHON_BASE" "$version" "$current"
|
|
375
|
+
echo "python $version is current${current:+; previous $current}"
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
# gcloud from the components of Google's release snapshot, each archive checked against
|
|
379
|
+
# the sha256 the snapshot lists; the snapshot and the archives come from dl.google.com.
|
|
380
|
+
install_gcloud() {
|
|
381
|
+
version=$1
|
|
382
|
+
python=$(current_python)
|
|
383
|
+
check_tool_base "$GCLOUD_BASE"
|
|
384
|
+
current=$(tool_link "$GCLOUD_BASE" current valid_version bin/gcloud)
|
|
385
|
+
if [ -n "$current" ] && version_lt "$version" "$current"; then fail "gcloud $version is older than the installed $current; use rollback"; fi
|
|
386
|
+
[ "$current" != "$version" ] || { echo "gcloud $version is already current"; return 0; }
|
|
387
|
+
channel=https://dl.google.com/dl/cloudsdk/channels/rapid
|
|
388
|
+
snapshot="$work/components.json"
|
|
389
|
+
fetch "$channel/components-v$version.json" "$snapshot"
|
|
390
|
+
[ "$(json_field "$snapshot" version)" = "$version" ] || fail "gcloud $version has no release snapshot"
|
|
391
|
+
dest="$GCLOUD_BASE/$version"
|
|
392
|
+
new_tool_folder "$dest"
|
|
393
|
+
for want in $GCLOUD_COMPONENTS; do
|
|
394
|
+
i=0
|
|
395
|
+
found=""
|
|
396
|
+
while [ "$i" -lt 1000 ]; do
|
|
397
|
+
id=$(json_field "$snapshot" "components.$i.id") || break
|
|
398
|
+
if [ "$id" = "$want" ]; then found=$i; break; fi
|
|
399
|
+
i=$((i + 1))
|
|
400
|
+
done
|
|
401
|
+
[ -n "$found" ] || fail "gcloud $version has no component $want"
|
|
402
|
+
source=$(json_field "$snapshot" "components.$found.data.source") || fail "component $want has no archive"
|
|
403
|
+
sum=$(json_field "$snapshot" "components.$found.data.checksum") || fail "component $want has no checksum"
|
|
404
|
+
printf '%s\n' "$source" | /usr/bin/grep -Eq '^components/google-cloud-sdk-[a-z0-9-]+-[0-9]{14}\.tar\.gz$' || fail "unexpected archive for component $want"
|
|
405
|
+
printf '%s\n' "$sum" | /usr/bin/grep -Eq '^[0-9a-f]{64}$' || fail "unexpected checksum for component $want"
|
|
406
|
+
fetch "$channel/$source" "$work/$want.tgz"
|
|
407
|
+
[ "$(/usr/bin/shasum -a 256 "$work/$want.tgz" | /usr/bin/awk '{ print $1 }')" = "$sum" ] || fail "checksum mismatch for component $want"
|
|
408
|
+
/usr/bin/tar -xzf "$work/$want.tgz" -C "$dest.partial"
|
|
409
|
+
done
|
|
410
|
+
seal_tool_folder "$dest"
|
|
411
|
+
gcloud_answers "$dest.partial/bin/gcloud" "$version" "$python" || fail "gcloud $version does not run"
|
|
412
|
+
activate_tool "$GCLOUD_BASE" "$version" "$current"
|
|
413
|
+
echo "gcloud $version is current${current:+; previous $current}"
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
# Bitwarden CLI: the native macOS arm64 build, checked against the sha256 digest GitHub publishes for the release asset.
|
|
417
|
+
install_bw() {
|
|
418
|
+
version=$1
|
|
419
|
+
check_tool_base "$BW_BASE"
|
|
420
|
+
current=$(tool_link "$BW_BASE" current valid_version bw)
|
|
421
|
+
if [ -n "$current" ] && version_lt "$version" "$current"; then fail "bw $version is older than the installed $current; use rollback"; fi
|
|
422
|
+
[ "$current" != "$version" ] || { echo "bw $version is already current"; return 0; }
|
|
423
|
+
name="bw-macos-arm64-$version.zip"
|
|
424
|
+
release="$work/bw-release.json"
|
|
425
|
+
fetch "https://api.github.com/repos/bitwarden/clients/releases/tags/cli-v$version" "$release"
|
|
426
|
+
i=0
|
|
427
|
+
found=""
|
|
428
|
+
while [ "$i" -lt 100 ]; do
|
|
429
|
+
asset=$(json_field "$release" "assets.$i.name") || break
|
|
430
|
+
if [ "$asset" = "$name" ]; then found=$i; break; fi
|
|
431
|
+
i=$((i + 1))
|
|
432
|
+
done
|
|
433
|
+
[ -n "$found" ] || fail "bw $version has no $name"
|
|
434
|
+
digest=$(json_field "$release" "assets.$found.digest") || fail "$name has no published digest"
|
|
435
|
+
printf '%s\n' "$digest" | /usr/bin/grep -Eq '^sha256:[0-9a-f]{64}$' || fail "unexpected digest for $name"
|
|
436
|
+
url="https://github.com/bitwarden/clients/releases/download/cli-v$version/$name"
|
|
437
|
+
[ "$(json_field "$release" "assets.$found.browser_download_url")" = "$url" ] || fail "unexpected download location for $name"
|
|
438
|
+
fetch "$url" "$work/$name"
|
|
439
|
+
[ "sha256:$(/usr/bin/shasum -a 256 "$work/$name" | /usr/bin/awk '{ print $1 }')" = "$digest" ] || fail "checksum mismatch for $name"
|
|
440
|
+
dest="$BW_BASE/$version"
|
|
441
|
+
new_tool_folder "$dest"
|
|
442
|
+
/usr/bin/unzip -q "$work/$name" bw -d "$dest.partial" || fail "$name has no bw"
|
|
443
|
+
/bin/chmod 0755 "$dest.partial/bw"
|
|
444
|
+
seal_tool_folder "$dest"
|
|
445
|
+
smoke_bw "$dest.partial/bw" "$version" || fail "bw $version does not run"
|
|
446
|
+
activate_tool "$BW_BASE" "$version" "$current"
|
|
447
|
+
echo "bw $version is current${current:+; previous $current}"
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
# jq: the macOS arm64 binary, checked against the release's sha256sum.txt.
|
|
451
|
+
install_jq() {
|
|
452
|
+
version=$1
|
|
453
|
+
check_tool_base "$JQ_BASE"
|
|
454
|
+
current=$(tool_link "$JQ_BASE" current valid_version jq)
|
|
455
|
+
if [ -n "$current" ] && version_lt "$version" "$current"; then fail "jq $version is older than the installed $current; use rollback"; fi
|
|
456
|
+
[ "$current" != "$version" ] || { echo "jq $version is already current"; return 0; }
|
|
457
|
+
base="https://github.com/jqlang/jq/releases/download/jq-$version"
|
|
458
|
+
fetch "$base/jq-macos-arm64" "$work/jq-macos-arm64"
|
|
459
|
+
fetch "$base/sha256sum.txt" "$work/sha256sum.txt"
|
|
460
|
+
check_sha256 "$work" jq-macos-arm64 "$work/sha256sum.txt"
|
|
461
|
+
dest="$JQ_BASE/$version"
|
|
462
|
+
new_tool_folder "$dest"
|
|
463
|
+
/usr/bin/install -m 0755 "$work/jq-macos-arm64" "$dest.partial/jq"
|
|
464
|
+
seal_tool_folder "$dest"
|
|
465
|
+
smoke_jq "$dest.partial/jq" "$version" || fail "jq $version does not run"
|
|
466
|
+
activate_tool "$JQ_BASE" "$version" "$current"
|
|
467
|
+
echo "jq $version is current${current:+; previous $current}"
|
|
468
|
+
}
|
|
469
|
+
|
|
233
470
|
WRAPPER_VERSION='v=$(cat "$HOME/.specialists/bin/VERSION" 2>/dev/null) || exit 0; printf "%s" "$v"'
|
|
234
471
|
|
|
235
472
|
# Moves the new wrapper files in and the old ones aside, two renames per item, then
|
|
@@ -433,13 +670,26 @@ json_version() {
|
|
|
433
670
|
if valid_version "$1"; then printf '"%s"' "$1"; else printf 'null'; fi
|
|
434
671
|
}
|
|
435
672
|
|
|
673
|
+
json_python() {
|
|
674
|
+
if valid_python_version "$1"; then printf '"%s"' "$1"; else printf 'null'; fi
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
# {"current":..,"previous":..} of one root-owned tool: $1 base, $2 version check, $3 executable, $4 JSON writer.
|
|
678
|
+
json_tool() {
|
|
679
|
+
printf '{"current":%s,"previous":%s}' "$("$4" "$(tool_link "$1" current "$2" "$3")")" "$("$4" "$(tool_link "$1" previous "$2" "$3")")"
|
|
680
|
+
}
|
|
681
|
+
|
|
436
682
|
status() {
|
|
437
683
|
wrappers=$(as_specialist "$WRAPPER_VERSION" || true)
|
|
438
684
|
arcane=$(as_specialist "$ARCANE_VERSION" || true)
|
|
439
685
|
trendarr=$(as_specialist "$TRENDARR_VERSION" || true)
|
|
440
|
-
printf '{"node":{"current":%s,"previous":%s},"wrappers":%s,"arcaneCli":%s,"trendarr":%s}\n' \
|
|
686
|
+
printf '{"node":{"current":%s,"previous":%s},"wrappers":%s,"arcaneCli":%s,"trendarr":%s,"python":%s,"gcloud":%s,"bw":%s,"jq":%s}\n' \
|
|
441
687
|
"$(json_version "$(node_link current)")" "$(json_version "$(node_link previous)")" \
|
|
442
|
-
"$(json_version "$wrappers")" "$(json_version "$arcane")" "$(json_version "$trendarr")"
|
|
688
|
+
"$(json_version "$wrappers")" "$(json_version "$arcane")" "$(json_version "$trendarr")" \
|
|
689
|
+
"$(json_tool "$PYTHON_BASE" valid_python_version bin/python3 json_python)" \
|
|
690
|
+
"$(json_tool "$GCLOUD_BASE" valid_version bin/gcloud json_version)" \
|
|
691
|
+
"$(json_tool "$BW_BASE" valid_version bw json_version)" \
|
|
692
|
+
"$(json_tool "$JQ_BASE" valid_version jq json_version)"
|
|
443
693
|
}
|
|
444
694
|
|
|
445
695
|
case "$command" in
|
|
@@ -447,12 +697,20 @@ case "$command" in
|
|
|
447
697
|
wrappers) install_wrappers "$2" ;;
|
|
448
698
|
arcane-cli) install_arcane_cli "$2" ;;
|
|
449
699
|
trendarr) install_trendarr "$2" ;;
|
|
700
|
+
python) install_python "$2" ;;
|
|
701
|
+
gcloud) install_gcloud "$2" ;;
|
|
702
|
+
bw) install_bw "$2" ;;
|
|
703
|
+
jq) install_jq "$2" ;;
|
|
450
704
|
rollback)
|
|
451
705
|
case "$2" in
|
|
452
706
|
node) rollback_node ;;
|
|
453
707
|
wrappers) rollback_wrappers ;;
|
|
454
708
|
arcane-cli) rollback_arcane_cli ;;
|
|
455
709
|
trendarr) rollback_trendarr ;;
|
|
710
|
+
python) rollback_tool "$PYTHON_BASE" python valid_python_version bin/python3 smoke_python ;;
|
|
711
|
+
gcloud) rollback_tool "$GCLOUD_BASE" gcloud valid_version bin/gcloud smoke_gcloud ;;
|
|
712
|
+
bw) rollback_tool "$BW_BASE" bw valid_version bw smoke_bw ;;
|
|
713
|
+
jq) rollback_tool "$JQ_BASE" jq valid_version jq smoke_jq ;;
|
|
456
714
|
esac
|
|
457
715
|
;;
|
|
458
716
|
status) status ;;
|
|
@@ -40,13 +40,33 @@ describe("specialist-install", () => {
|
|
|
40
40
|
[["rollback", "pi"]],
|
|
41
41
|
[["rollback", "node", "extra"]],
|
|
42
42
|
[["status", "now"]],
|
|
43
|
+
[["python", "3.14.8"]],
|
|
44
|
+
[["python", "3.14.8+2026101"]],
|
|
45
|
+
[["python", "3.14.8+20261013+1"]],
|
|
46
|
+
[["python", "3.14.8-20261013"]],
|
|
47
|
+
[["gcloud", "589.0"]],
|
|
48
|
+
[["bw", "cli-v2026.10.0"]],
|
|
49
|
+
[["jq", "jq-1.8.2"]],
|
|
50
|
+
[["rollback", "brew"]],
|
|
43
51
|
])("refuses %j with a usage error before anything else", (args) => {
|
|
44
52
|
const result = install(...args);
|
|
45
53
|
expect(result.status).toBe(2);
|
|
46
54
|
expect(result.stderr).toMatch(/^usage: specialist-install/);
|
|
47
55
|
});
|
|
48
56
|
|
|
49
|
-
it.skipIf(isRoot).each([
|
|
57
|
+
it.skipIf(isRoot).each([
|
|
58
|
+
[["node", "24.22.0"]],
|
|
59
|
+
[["wrappers", "0.31.0"]],
|
|
60
|
+
[["arcane-cli", "2.16.1"]],
|
|
61
|
+
[["trendarr", "0.6.0"]],
|
|
62
|
+
[["python", "3.14.8+20261013"]],
|
|
63
|
+
[["gcloud", "589.0.0"]],
|
|
64
|
+
[["bw", "2026.10.0"]],
|
|
65
|
+
[["jq", "1.8.2"]],
|
|
66
|
+
[["rollback", "trendarr"]],
|
|
67
|
+
[["rollback", "gcloud"]],
|
|
68
|
+
[["status"]],
|
|
69
|
+
])(
|
|
50
70
|
"accepts %j and then requires root",
|
|
51
71
|
(args) => {
|
|
52
72
|
const result = install(...args);
|
|
@@ -74,6 +94,35 @@ describe("specialist-install", () => {
|
|
|
74
94
|
expect(shellFunction("version_lt", `version_lt ${a} ${b}`).status).toBe(older ? 0 : 1);
|
|
75
95
|
});
|
|
76
96
|
|
|
97
|
+
it.each(["3.14.8+20261013", "3.10.0+20250101", "3.15.0+20270401"])("valid_python_version accepts %s", (v) => {
|
|
98
|
+
expect(shellFunction("valid_python_version", `valid_python_version '${v}'`).status).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it.each(["", "3.14.8", "3.14+20261013", "3.14.8+2026101", "3.14.8+20261013a", "+20261013", "3.14.8+ 20261013"])("valid_python_version rejects %j", (v) => {
|
|
102
|
+
expect(shellFunction("valid_python_version", `valid_python_version '${v}'`).status).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each([
|
|
106
|
+
["3.14.8+20261009", "3.14.8+20261013", true],
|
|
107
|
+
["3.14.8+20261013", "3.14.8+20261013", false],
|
|
108
|
+
["3.14.9+20261001", "3.14.8+20261013", false],
|
|
109
|
+
["3.14.7+20261101", "3.14.8+20261013", true],
|
|
110
|
+
])("python_lt %s < %s is %s", (a, b, older) => {
|
|
111
|
+
const script = readFileSync(SCRIPT, "utf-8");
|
|
112
|
+
const lt = /^version_lt\(\) \{\n[\s\S]*?^\}/m.exec(script)?.[0] ?? "";
|
|
113
|
+
expect(shellFunction("python_lt", `${lt}\npython_lt '${a}' '${b}'`).status).toBe(older ? 0 : 1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("installs gcloud from fixed components and checked downloads only", () => {
|
|
117
|
+
const text = readFileSync(SCRIPT, "utf-8");
|
|
118
|
+
expect(/^GCLOUD_COMPONENTS="([^"]+)"$/m.exec(text)?.[1].split(" ")).toEqual(["core", "core-nix", "gcloud-deps", "gcloud-deps-darwin-arm", "gcloud-crc32c-darwin-arm"]);
|
|
119
|
+
expect(text).toContain("channel=https://dl.google.com/dl/cloudsdk/channels/rapid\n");
|
|
120
|
+
expect(text).toContain('fetch "$channel/components-v$version.json"');
|
|
121
|
+
expect(text).toContain("https://github.com/astral-sh/python-build-standalone/releases/download/$tag");
|
|
122
|
+
expect(text).toContain("https://api.github.com/repos/bitwarden/clients/releases/tags/cli-v$version");
|
|
123
|
+
expect(text).toContain("https://github.com/jqlang/jq/releases/download/jq-$version");
|
|
124
|
+
});
|
|
125
|
+
|
|
77
126
|
it("installs the same services specialist-cli accepts", () => {
|
|
78
127
|
const services = /^SERVICES="([^"]+)"$/m.exec(readFileSync(SCRIPT, "utf-8"))?.[1].split(" ");
|
|
79
128
|
const allowed = /^ {2}([a-z|]+)\) ;;$/m.exec(readFileSync(ENTRY, "utf-8"))?.[1].split("|");
|
|
@@ -13,30 +13,16 @@ const SERVICE = "inference";
|
|
|
13
13
|
interface InferenceConfig extends Record<string, string> {
|
|
14
14
|
nexusHost: string;
|
|
15
15
|
nexusUser: string;
|
|
16
|
-
hermesHost: string;
|
|
17
|
-
hermesUser: string;
|
|
18
|
-
pulsarHost: string;
|
|
19
|
-
pulsarUser: string;
|
|
20
16
|
europaHealthUrl: string;
|
|
21
|
-
/** GitHub repository whose releases carry the Hermes upgrade notes, owner/name. */
|
|
22
|
-
hermesRepo: string;
|
|
23
17
|
}
|
|
24
18
|
|
|
25
19
|
const SHAPE = {
|
|
26
20
|
nexusHost: "string",
|
|
27
21
|
nexusUser: "string",
|
|
28
|
-
hermesHost: "string",
|
|
29
|
-
hermesUser: "string",
|
|
30
|
-
pulsarHost: "string",
|
|
31
|
-
pulsarUser: "string",
|
|
32
22
|
europaHealthUrl: "string",
|
|
33
|
-
hermesRepo: "string",
|
|
34
23
|
} as const;
|
|
35
24
|
|
|
36
|
-
type Target = "nexus" | "hermes" | "pulsar";
|
|
37
|
-
|
|
38
25
|
interface RemoteSpec {
|
|
39
|
-
target: Target;
|
|
40
26
|
args: number;
|
|
41
27
|
help: string;
|
|
42
28
|
timeoutMs: number;
|
|
@@ -45,40 +31,27 @@ interface RemoteSpec {
|
|
|
45
31
|
|
|
46
32
|
const MINUTE = 60_000;
|
|
47
33
|
|
|
48
|
-
/** Fixed remote commands; the forced
|
|
34
|
+
/** Fixed remote commands; the forced command on Nexus (user nexus) accepts exactly these names. */
|
|
49
35
|
export const INFERENCE_COMMANDS: Readonly<Record<string, RemoteSpec>> = {
|
|
50
|
-
"nexus-status": {
|
|
51
|
-
"nexus-restart": {
|
|
52
|
-
"nexus-start": {
|
|
53
|
-
"nexus-stop": {
|
|
54
|
-
"nexus-log": {
|
|
55
|
-
"nexus-disk": {
|
|
56
|
-
"hermes-status": { target: "hermes", args: 0, help: "both gateway user services", timeoutMs: MINUTE },
|
|
57
|
-
"hermes-journal": { target: "hermes", args: 1, help: "hermes-journal <since> user journal of both gateways", timeoutMs: MINUTE },
|
|
58
|
-
"hermes-errors": { target: "hermes", args: 0, help: "hermes logs errors", timeoutMs: MINUTE },
|
|
59
|
-
"hermes-restart": { target: "hermes", args: 1, help: "hermes-restart <gateway|api>", timeoutMs: 2 * MINUTE },
|
|
60
|
-
"hermes-api-health": { target: "hermes", args: 0, help: "/health and /v1/models codes on localhost:8650 (expect 200 and 401)", timeoutMs: MINUTE },
|
|
61
|
-
"hermes-version": { target: "hermes", args: 0, help: "installed Hermes version", timeoutMs: MINUTE },
|
|
62
|
-
"hermes-guest-status": { target: "pulsar", args: 0, help: "pct status 101", timeoutMs: MINUTE },
|
|
63
|
-
"hermes-snapshots": { target: "pulsar", args: 0, help: "pct listsnapshot 101", timeoutMs: MINUTE },
|
|
64
|
-
"hermes-upgrade": { target: "pulsar", args: 0, help: "hermes-upgrade --confirm snapshot LXC 101, hermes update, restart, health check", timeoutMs: 20 * MINUTE, confirm: true },
|
|
36
|
+
"nexus-status": { args: 0, help: "LaunchAgent states and /health codes of the embed and rerank servers", timeoutMs: MINUTE },
|
|
37
|
+
"nexus-restart": { args: 1, help: "nexus-restart <embed|rerank> launchctl kickstart -k", timeoutMs: 2 * MINUTE },
|
|
38
|
+
"nexus-start": { args: 1, help: "nexus-start <embed|rerank> launchctl bootstrap from the tracked plist", timeoutMs: 2 * MINUTE },
|
|
39
|
+
"nexus-stop": { args: 1, help: "nexus-stop <embed|rerank> --confirm launchctl bootout", timeoutMs: 2 * MINUTE, confirm: true },
|
|
40
|
+
"nexus-log": { args: 2, help: "nexus-log <embed|rerank> <lines>", timeoutMs: MINUTE },
|
|
41
|
+
"nexus-disk": { args: 0, help: "free space on the Nexus disk", timeoutMs: MINUTE },
|
|
65
42
|
};
|
|
66
43
|
|
|
67
44
|
const NEXUS_SERVICES = ["embed", "rerank"];
|
|
68
|
-
const HERMES_SERVICES = ["gateway", "api"];
|
|
69
|
-
const SINCE = /^[A-Za-z0-9 :\-+]{1,40}$/;
|
|
70
45
|
|
|
71
|
-
const HELP = `inference specialist (llama.cpp on Nexus,
|
|
46
|
+
const HELP = `inference specialist (llama.cpp on Nexus, Europa health)
|
|
72
47
|
|
|
73
48
|
<command> [args] fixed operations through forced-command SSH keys
|
|
74
49
|
${Object.entries(INFERENCE_COMMANDS)
|
|
75
50
|
.map(([name, c]) => ` ${name.padEnd(22)} ${c.help}`)
|
|
76
51
|
.join("\n")}
|
|
77
52
|
europa-health GET the Europa embedding /health over HTTP (Europa is usually off by design)
|
|
78
|
-
hermes-releases [n] latest n upstream Hermes releases, notes cut at 6000 chars (default 5)
|
|
79
|
-
hermes-release <tag> one release with its full notes; read every release between the installed version and the target before hermes-upgrade
|
|
80
53
|
|
|
81
|
-
Europa control
|
|
54
|
+
Europa control is an operator task.`;
|
|
82
55
|
|
|
83
56
|
export function buildInferenceRemote(args: string[]): { remote: string; spec: RemoteSpec } {
|
|
84
57
|
const [name, ...given] = args;
|
|
@@ -91,63 +64,14 @@ export function buildInferenceRemote(args: string[]): { remote: string; spec: Re
|
|
|
91
64
|
rest = rest.filter((_, i) => i !== index);
|
|
92
65
|
}
|
|
93
66
|
if (rest.length !== spec.args) throw new UsageError(`${name} takes ${spec.args} argument(s)`);
|
|
94
|
-
if (
|
|
67
|
+
if (spec.args >= 1 && !NEXUS_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${NEXUS_SERVICES.join(", ")}`);
|
|
95
68
|
if (name === "nexus-log") {
|
|
96
69
|
const n = Number(rest[1]);
|
|
97
70
|
if (!Number.isInteger(n) || n < 1 || n > 2000) throw new UsageError("lines must be an integer from 1 to 2000");
|
|
98
71
|
}
|
|
99
|
-
if (name === "hermes-restart" && !HERMES_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${HERMES_SERVICES.join(", ")}`);
|
|
100
|
-
if (name === "hermes-journal" && !SINCE.test(rest[0])) throw new UsageError("since: letters, digits, spaces, colon, plus, minus only");
|
|
101
72
|
return { remote: [name, ...rest].join(" "), spec };
|
|
102
73
|
}
|
|
103
74
|
|
|
104
|
-
interface GithubRelease {
|
|
105
|
-
tag_name?: string;
|
|
106
|
-
name?: string;
|
|
107
|
-
published_at?: string;
|
|
108
|
-
html_url?: string;
|
|
109
|
-
prerelease?: boolean;
|
|
110
|
-
body?: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Trims GitHub release objects to what an upgrade decision needs. */
|
|
114
|
-
export function compactReleases(releases: GithubRelease[]): { tag: string; name: string; published: string; prerelease: boolean; url: string; notes: string }[] {
|
|
115
|
-
return releases.map((r) => ({
|
|
116
|
-
tag: r.tag_name ?? "?",
|
|
117
|
-
name: r.name ?? "",
|
|
118
|
-
published: r.published_at ?? "",
|
|
119
|
-
prerelease: r.prerelease === true,
|
|
120
|
-
url: r.html_url ?? "",
|
|
121
|
-
notes: (r.body ?? "").slice(0, 6000),
|
|
122
|
-
}));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const RELEASE_TAG = /^[A-Za-z0-9._-]{1,60}$/;
|
|
126
|
-
|
|
127
|
-
async function github(config: InferenceConfig, path: string): Promise<unknown> {
|
|
128
|
-
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.hermesRepo)) throw new ServiceError("hermesRepo must be owner/name");
|
|
129
|
-
const response = await fetch(`https://api.github.com/repos/${config.hermesRepo}${path}`, {
|
|
130
|
-
headers: { Accept: "application/vnd.github+json", "User-Agent": "pi-astro-inference-specialist" },
|
|
131
|
-
signal: AbortSignal.timeout(30_000),
|
|
132
|
-
});
|
|
133
|
-
if (!response.ok) throw new ServiceError(`GitHub answered ${response.status} for ${config.hermesRepo}${path}`);
|
|
134
|
-
return response.json();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function hermesReleases(config: InferenceConfig, countArg: string | undefined): Promise<number> {
|
|
138
|
-
const n = countArg === undefined ? 5 : Number(countArg);
|
|
139
|
-
if (!Number.isInteger(n) || n < 1 || n > 30) throw new UsageError("hermes-releases [n]: n must be 1..30");
|
|
140
|
-
printJson({ repository: config.hermesRepo, releases: compactReleases((await github(config, `/releases?per_page=${n}`)) as GithubRelease[]) });
|
|
141
|
-
return 0;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function hermesRelease(config: InferenceConfig, tag: string | undefined): Promise<number> {
|
|
145
|
-
if (tag === undefined || !RELEASE_TAG.test(tag)) throw new UsageError("hermes-release <tag>, e.g. v2026.9.14");
|
|
146
|
-
const release = (await github(config, `/releases/tags/${encodeURIComponent(tag)}`)) as GithubRelease;
|
|
147
|
-
printJson({ repository: config.hermesRepo, ...compactReleases([release])[0], notes: release.body ?? "" });
|
|
148
|
-
return 0;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
75
|
async function europaHealth(config: InferenceConfig): Promise<number> {
|
|
152
76
|
const result = await run("/usr/bin/curl", ["--silent", "--output", "/dev/null", "--max-time", "5", "--write-out", "%{http_code}", config.europaHealthUrl], { timeoutMs: 15_000 });
|
|
153
77
|
const httpCode = result.stdout.trim();
|
|
@@ -163,15 +87,9 @@ export async function command(args: string[]): Promise<number> {
|
|
|
163
87
|
}
|
|
164
88
|
const config = readConfig<InferenceConfig>(SERVICE, SHAPE);
|
|
165
89
|
if (args[0] === "europa-health") return europaHealth(config);
|
|
166
|
-
if (args[0] === "hermes-releases") return hermesReleases(config, args[1]);
|
|
167
|
-
if (args[0] === "hermes-release") return hermesRelease(config, args[1]);
|
|
168
90
|
const { remote, spec } = buildInferenceRemote(args);
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
hermes: { host: config.hermesHost, user: config.hermesUser, keyFile: secretPath(SERVICE, "SSH_KEY_HERMES") },
|
|
172
|
-
pulsar: { host: config.pulsarHost, user: config.pulsarUser, keyFile: secretPath(SERVICE, "SSH_KEY_PULSAR") },
|
|
173
|
-
};
|
|
174
|
-
const result = await sshFixed({ ...targets[spec.target], knownHostsFile: join(specialistsHome(), "config", "known_hosts") }, remote, spec.timeoutMs);
|
|
91
|
+
const nexus = { host: config.nexusHost, user: config.nexusUser, keyFile: secretPath(SERVICE, "SSH_KEY_NEXUS") };
|
|
92
|
+
const result = await sshFixed({ ...nexus, knownHostsFile: join(specialistsHome(), "config", "known_hosts") }, remote, spec.timeoutMs);
|
|
175
93
|
if (result.stdout.length > 0) printRaw(result.stdout);
|
|
176
94
|
if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `${args[0]} exited ${result.code}`);
|
|
177
95
|
return 0;
|
|
@@ -75,6 +75,29 @@ describe("Arcane host commands", () => {
|
|
|
75
75
|
["arcane-backup.timer", null],
|
|
76
76
|
]);
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
it("compares the probes in their subfolder and ignores names outside host/", () => {
|
|
80
|
+
dir = mkdtempSync(join(tmpdir(), "audit-"));
|
|
81
|
+
mkdirSync(join(dir, "host", "spc-arcane-probes"), { recursive: true });
|
|
82
|
+
writeFileSync(join(dir, "host", "spc-arcane-probes", "host_probe.py"), "print(1)\n");
|
|
83
|
+
writeFileSync(join(dir, "outside.txt"), "x\n");
|
|
84
|
+
const probe = createHash("sha256").update("print(1)\n").digest("hex");
|
|
85
|
+
const outside = createHash("sha256").update("x\n").digest("hex");
|
|
86
|
+
expect(
|
|
87
|
+
compareAudit(
|
|
88
|
+
[
|
|
89
|
+
{ name: "spc-arcane-probes/host_probe.py", state: "present", sha256: probe },
|
|
90
|
+
{ name: "../outside.txt", state: "present", sha256: outside },
|
|
91
|
+
{ name: "/etc/passwd", state: "present", sha256: outside },
|
|
92
|
+
],
|
|
93
|
+
dir,
|
|
94
|
+
).map((r) => [r.name, r.match]),
|
|
95
|
+
).toEqual([
|
|
96
|
+
["spc-arcane-probes/host_probe.py", true],
|
|
97
|
+
["../outside.txt", null],
|
|
98
|
+
["/etc/passwd", null],
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
78
101
|
});
|
|
79
102
|
});
|
|
80
103
|
|
|
@@ -115,7 +138,7 @@ describe("deployment checks", () => {
|
|
|
115
138
|
const expect_ = { project: "grimoire", commit: "abc1234", services: { "grimoire-api-1": D1 }, jobs: { "grimoire-migrate": D2 }, noHealth: [] };
|
|
116
139
|
const sync = syncFacts({ lastSyncCommit: "abc1234ffff", lastSyncStatus: "success", lastSyncAt: "t1" });
|
|
117
140
|
|
|
118
|
-
it("parses the
|
|
141
|
+
it("parses the host command arguments", () => {
|
|
119
142
|
expect(parseDeploymentArgs(["wait", "grimoire", "--commit", "abc1234", "--container", `grimoire-api-1=${D1}`, "--timeout", "120"])).toMatchObject({
|
|
120
143
|
wait: true,
|
|
121
144
|
timeoutS: 120,
|
|
@@ -134,15 +134,19 @@ export interface AuditFile {
|
|
|
134
134
|
gid?: number;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/** A tracked name under `host/`, such as `arcane-backup.sh` or `spc-arcane-probes/host_probe.py`; no `..`, no absolute path. */
|
|
138
|
+
const AUDIT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
|
139
|
+
|
|
137
140
|
/**
|
|
138
141
|
* Compares the host's file hashes with `host/<name>` in the specialist's
|
|
139
|
-
* checkout of the arcane repository; `match` is null when either side is missing
|
|
142
|
+
* checkout of the arcane repository; `match` is null when either side is missing
|
|
143
|
+
* or the host reports a name outside `host/`.
|
|
140
144
|
*/
|
|
141
145
|
export function compareAudit(files: readonly AuditFile[], checkout: string): { name: string; state: string; match: boolean | null; mode?: number; uid?: number; gid?: number }[] {
|
|
142
146
|
return files.map((f) => {
|
|
143
147
|
const source = join(checkout, "host", f.name);
|
|
144
148
|
let expected: string | null = null;
|
|
145
|
-
if (existsSync(source) && lstatSync(source).isFile()) expected = createHash("sha256").update(readFileSync(source)).digest("hex");
|
|
149
|
+
if (AUDIT_NAME.test(f.name) && existsSync(source) && lstatSync(source).isFile()) expected = createHash("sha256").update(readFileSync(source)).digest("hex");
|
|
146
150
|
return { name: f.name, state: f.state, match: f.state !== "present" || expected === null ? null : f.sha256 === expected, mode: f.mode, uid: f.uid, gid: f.gid };
|
|
147
151
|
});
|
|
148
152
|
}
|
|
@@ -119,7 +119,7 @@ export function syncFacts(row: Record<string, unknown>): SyncFacts {
|
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/** The checks
|
|
122
|
+
/** The deployment checks (sync commit and state, hook, each container's project, image, state, health) as one pure comparison. */
|
|
123
123
|
export function deploymentChecks(expect: DeploymentExpectation, sync: SyncFacts, containers: readonly (ContainerFacts | null)[], syncAfter: SyncFacts): { status: "ok" | "failed" | "unknown"; checks: DeploymentCheck[] } {
|
|
124
124
|
const checks: DeploymentCheck[] = [];
|
|
125
125
|
const commitOk = sync.commit !== null && (sync.commit.startsWith(expect.commit) || expect.commit.startsWith(sync.commit));
|