@namewta/speculo 1.0.12 → 1.0.13
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/package.json +1 -1
- package/template/workflows/ops/H-host-manage/H-host-manage.md +3 -0
- package/template/workflows/ops/INDEX.md +1 -1
- package/template/workflows/ops/README.md +1 -1
- package/template/workflows/ops/common/CAPABILITIES.md +2 -2
- package/template/workflows/ops/common/USAGE.md +23 -4
- package/template/workflows/ops/common/examples/register.example.json +1 -1
- package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +522 -0
- package/template/workflows/ops/common/toolchains/volta-linux.json +35 -0
- package/template/workflows/ops/common/tools/bootstrap-volta.sh +210 -0
- package/template/workflows/ops/common/tools/bootstrap.sh +1 -0
- package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +221 -0
- package/template/workflows/ops/common/tools/opslib/cli.mjs +27 -5
- package/template/workflows/ops/common/tools/opslib/core.mjs +12 -0
- package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +20 -4
- package/template/workflows/ops/common/tools/opslib/transport.mjs +101 -22
- package/template/workflows/ops/common/tools/validate-ops.mjs +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"manager": "volta",
|
|
4
|
+
"volta_version": "2.0.2",
|
|
5
|
+
"node_version": "24.21.0",
|
|
6
|
+
"profile_edit": false,
|
|
7
|
+
"use_official_install_sh": false,
|
|
8
|
+
"volta_home_rel": "_host/toolchains/{account}/volta",
|
|
9
|
+
"archives": {
|
|
10
|
+
"linux-x64": {
|
|
11
|
+
"volta": {
|
|
12
|
+
"filename": "volta-2.0.2-linux.tar.gz",
|
|
13
|
+
"url": "https://github.com/volta-cli/volta/releases/download/v2.0.2/volta-2.0.2-linux.tar.gz",
|
|
14
|
+
"sha256": "6cec054c911fb925b629a09455775af6e95dc0f5694a4c28b63979ab9ef18037"
|
|
15
|
+
},
|
|
16
|
+
"node": {
|
|
17
|
+
"filename": "node-v24.21.0-linux-x64.tar.gz",
|
|
18
|
+
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz",
|
|
19
|
+
"sha256": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"linux-arm64": {
|
|
23
|
+
"volta": {
|
|
24
|
+
"filename": "volta-2.0.2-linux-arm.tar.gz",
|
|
25
|
+
"url": "https://github.com/volta-cli/volta/releases/download/v2.0.2/volta-2.0.2-linux-arm.tar.gz",
|
|
26
|
+
"sha256": "1eb92f8b711753aa576352b2e580b2f3fdadeab8664929ca1da3d0de65448517"
|
|
27
|
+
},
|
|
28
|
+
"node": {
|
|
29
|
+
"filename": "node-v24.21.0-linux-arm64.tar.gz",
|
|
30
|
+
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz",
|
|
31
|
+
"sha256": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# First-stage Linux SSH Node bootstrap. POSIX only; never runs install.sh or edits profiles.
|
|
3
|
+
set -eu
|
|
4
|
+
|
|
5
|
+
ACK='I-APPROVE-THIS-BOOTSTRAP'
|
|
6
|
+
|
|
7
|
+
json_str() {
|
|
8
|
+
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
digest_file() {
|
|
12
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
13
|
+
sha256sum "$1" | cut -d' ' -f1
|
|
14
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
15
|
+
shasum -a 256 "$1" | cut -d' ' -f1
|
|
16
|
+
else
|
|
17
|
+
echo 'No SHA-256 implementation; bootstrap blocked' >&2
|
|
18
|
+
exit 2
|
|
19
|
+
fi
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
profile_digest() {
|
|
23
|
+
d=''
|
|
24
|
+
for f in "${HOME-}/.bashrc" "${HOME-}/.profile"; do
|
|
25
|
+
if [ -f "$f" ] && [ ! -L "$f" ]; then
|
|
26
|
+
d="${d}$(digest_file "$f")"
|
|
27
|
+
else
|
|
28
|
+
d="${d}absent"
|
|
29
|
+
fi
|
|
30
|
+
done
|
|
31
|
+
printf '%s' "$d"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
normalize_arch() {
|
|
35
|
+
m=$(uname -m)
|
|
36
|
+
case "$m" in
|
|
37
|
+
x86_64) printf 'linux-x64' ;;
|
|
38
|
+
aarch64|arm64) printf 'linux-arm64' ;;
|
|
39
|
+
*) printf 'unsupported:%s' "$m" ;;
|
|
40
|
+
esac
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
tool_path() {
|
|
44
|
+
if command -v "$1" >/dev/null 2>&1; then command -v "$1"; else printf 'missing'; fi
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
abspath_cmd() {
|
|
48
|
+
p=$1
|
|
49
|
+
if [ "$p" = "missing" ] || [ -z "$p" ]; then
|
|
50
|
+
printf 'missing'
|
|
51
|
+
return
|
|
52
|
+
fi
|
|
53
|
+
if command -v readlink >/dev/null 2>&1; then
|
|
54
|
+
readlink -f "$p" 2>/dev/null || printf '%s' "$p"
|
|
55
|
+
else
|
|
56
|
+
printf '%s' "$p"
|
|
57
|
+
fi
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
emit_probe() {
|
|
61
|
+
specified=${1-}
|
|
62
|
+
arch=$(normalize_arch)
|
|
63
|
+
nodep=$(tool_path node)
|
|
64
|
+
voltap=$(tool_path volta)
|
|
65
|
+
usable=false
|
|
66
|
+
node_path=null
|
|
67
|
+
node_version=null
|
|
68
|
+
if [ -n "$specified" ]; then
|
|
69
|
+
if [ -x "$specified" ] && [ ! -L "$specified" -o -x "$specified" ]; then
|
|
70
|
+
usable=true
|
|
71
|
+
nodep=$specified
|
|
72
|
+
else
|
|
73
|
+
nodep=missing
|
|
74
|
+
usable=false
|
|
75
|
+
fi
|
|
76
|
+
elif [ "$nodep" != "missing" ] && [ -x "$nodep" ]; then
|
|
77
|
+
usable=true
|
|
78
|
+
fi
|
|
79
|
+
if [ "$usable" = true ]; then
|
|
80
|
+
nodep=$(abspath_cmd "$nodep")
|
|
81
|
+
node_path="\"$(json_str "$nodep")\""
|
|
82
|
+
ver=$("$nodep" --version 2>/dev/null || true)
|
|
83
|
+
if [ -n "$ver" ]; then node_version="\"$(json_str "$ver")\""; fi
|
|
84
|
+
else
|
|
85
|
+
nodep=missing
|
|
86
|
+
fi
|
|
87
|
+
profile_has=false
|
|
88
|
+
for f in "${HOME-}/.bashrc" "${HOME-}/.profile"; do
|
|
89
|
+
if [ -f "$f" ] && grep -q volta "$f" 2>/dev/null; then profile_has=true; fi
|
|
90
|
+
done
|
|
91
|
+
volta_out=$voltap
|
|
92
|
+
[ "$volta_out" = "missing" ] || volta_out=$(abspath_cmd "$volta_out")
|
|
93
|
+
printf '{"status":"probed","uname":"%s","arch":"%s","tools":{"node":"%s","volta":"%s","tar":"%s","sha256sum":"%s"},"node_path":%s,"node_version":%s,"node_usable":%s,"profile_has_volta":%s}\n' \
|
|
94
|
+
"$(json_str "$(uname -sm)")" "$(json_str "$arch")" \
|
|
95
|
+
"$(json_str "$nodep")" "$(json_str "$volta_out")" \
|
|
96
|
+
"$(json_str "$(tool_path tar)")" "$(json_str "$(tool_path sha256sum)")" \
|
|
97
|
+
"$node_path" "$node_version" "$usable" "$profile_has"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
find_named() {
|
|
101
|
+
root=$1
|
|
102
|
+
name=$2
|
|
103
|
+
found=''
|
|
104
|
+
# Prefer a file literally named $name, skipping install.sh.
|
|
105
|
+
for cand in "$root/$name" "$root"/*/"$name"; do
|
|
106
|
+
if [ -f "$cand" ] && [ ! -L "$cand" ]; then
|
|
107
|
+
found=$cand
|
|
108
|
+
break
|
|
109
|
+
fi
|
|
110
|
+
done
|
|
111
|
+
printf '%s' "$found"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case "${1:---probe}" in
|
|
115
|
+
--probe)
|
|
116
|
+
emit_probe "${2-}"
|
|
117
|
+
;;
|
|
118
|
+
--apply)
|
|
119
|
+
[ "$#" -eq 8 ] || { echo 'usage: bootstrap-volta.sh --apply VOLTA_TAR VOLTA_SHA NODE_TAR NODE_SHA VOLTA_HOME NODE_VERSION I-APPROVE-THIS-BOOTSTRAP' >&2; exit 2; }
|
|
120
|
+
volta_tar=$2
|
|
121
|
+
volta_sha=$3
|
|
122
|
+
node_tar=$4
|
|
123
|
+
node_sha=$5
|
|
124
|
+
volta_home=$6
|
|
125
|
+
node_version=$7
|
|
126
|
+
[ "$8" = "$ACK" ] || { echo 'explicit bootstrap acknowledgement required' >&2; exit 2; }
|
|
127
|
+
case "$node_version" in
|
|
128
|
+
''|latest|Latest|LATEST|*latest*) echo 'refusing latest or empty node version' >&2; exit 2 ;;
|
|
129
|
+
esac
|
|
130
|
+
case "$volta_home" in
|
|
131
|
+
/*) ;;
|
|
132
|
+
*) echo 'VOLTA_HOME must be an absolute path' >&2; exit 2 ;;
|
|
133
|
+
esac
|
|
134
|
+
case "$volta_home" in
|
|
135
|
+
*..*|*[\ \ ]*) echo 'VOLTA_HOME must not contain spaces or ..' >&2; exit 2 ;;
|
|
136
|
+
esac
|
|
137
|
+
for f in "$volta_tar" "$node_tar"; do
|
|
138
|
+
[ -f "$f" ] && [ ! -L "$f" ] || { echo "Installer must be a reviewed regular file: $f" >&2; exit 2; }
|
|
139
|
+
done
|
|
140
|
+
actual=$(digest_file "$volta_tar")
|
|
141
|
+
[ "$actual" = "$volta_sha" ] || { echo 'Volta installer changed after review' >&2; exit 2; }
|
|
142
|
+
actual=$(digest_file "$node_tar")
|
|
143
|
+
[ "$actual" = "$node_sha" ] || { echo 'Node installer changed after review' >&2; exit 2; }
|
|
144
|
+
|
|
145
|
+
image="$volta_home/tools/image/node/$node_version"
|
|
146
|
+
node_bin="$image/bin/node"
|
|
147
|
+
wrapper="$volta_home/bin/ops-node"
|
|
148
|
+
if [ -x "$node_bin" ] && [ -x "$wrapper" ]; then
|
|
149
|
+
ver=$("$wrapper" --version 2>/dev/null || true)
|
|
150
|
+
if [ "$ver" = "v$node_version" ]; then
|
|
151
|
+
printf '{"status":"installed","volta_home":"%s","connection_node":"%s","node_path":"%s","volta_bin":"%s","node_version":"%s","volta_version":"2.0.2","profile_unchanged":true,"idempotent":true}\n' \
|
|
152
|
+
"$(json_str "$volta_home")" "$(json_str "$wrapper")" "$(json_str "$node_bin")" "$(json_str "$volta_home/bin/volta")" "$(json_str "$node_version")"
|
|
153
|
+
exit 0
|
|
154
|
+
fi
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
before=$(profile_digest)
|
|
158
|
+
mkdir -p "$volta_home/bin" "$image" "$volta_home/tmp"
|
|
159
|
+
stage=$(mktemp -d "$volta_home/tmp/extract.XXXXXX")
|
|
160
|
+
trap 'rm -rf "$stage"' EXIT
|
|
161
|
+
|
|
162
|
+
tar -xzf "$volta_tar" -C "$stage"
|
|
163
|
+
volta_src=$(find_named "$stage" volta)
|
|
164
|
+
[ -n "$volta_src" ] || { echo 'volta binary missing from archive; refusing to run install.sh' >&2; exit 2; }
|
|
165
|
+
cp "$volta_src" "$volta_home/bin/volta"
|
|
166
|
+
chmod 755 "$volta_home/bin/volta"
|
|
167
|
+
shim_src=$(find_named "$stage" volta-shim)
|
|
168
|
+
if [ -n "$shim_src" ]; then
|
|
169
|
+
cp "$shim_src" "$volta_home/bin/volta-shim"
|
|
170
|
+
chmod 755 "$volta_home/bin/volta-shim"
|
|
171
|
+
fi
|
|
172
|
+
mig_src=$(find_named "$stage" volta-migrate)
|
|
173
|
+
if [ -n "$mig_src" ]; then
|
|
174
|
+
cp "$mig_src" "$volta_home/bin/volta-migrate"
|
|
175
|
+
chmod 755 "$volta_home/bin/volta-migrate"
|
|
176
|
+
fi
|
|
177
|
+
# Never execute install.sh even if present.
|
|
178
|
+
if [ -f "$stage/install.sh" ] || [ -f "$stage"/*/install.sh ] 2>/dev/null; then
|
|
179
|
+
:
|
|
180
|
+
fi
|
|
181
|
+
|
|
182
|
+
tar -xzf "$node_tar" -C "$image" --strip-components=1
|
|
183
|
+
[ -x "$node_bin" ] || { echo 'Node binary missing after extract' >&2; exit 2; }
|
|
184
|
+
chmod 755 "$node_bin"
|
|
185
|
+
|
|
186
|
+
cat > "$wrapper" <<EOF
|
|
187
|
+
#!/bin/sh
|
|
188
|
+
export VOLTA_HOME=$(printf '%s' "$volta_home" | sed "s/'/'\\\\''/g; s/^/'/; s/\$/'/")
|
|
189
|
+
export PATH="\$VOLTA_HOME/bin:\$PATH"
|
|
190
|
+
exec $(printf '%s' "$node_bin" | sed "s/'/'\\\\''/g; s/^/'/; s/\$/'/") "\$@"
|
|
191
|
+
EOF
|
|
192
|
+
chmod 755 "$wrapper"
|
|
193
|
+
cp "$wrapper" "$volta_home/bin/node"
|
|
194
|
+
chmod 755 "$volta_home/bin/node"
|
|
195
|
+
|
|
196
|
+
ver=$("$wrapper" --version 2>/dev/null || true)
|
|
197
|
+
[ "$ver" = "v$node_version" ] || { echo "ops-node version mismatch: got ${ver:-empty} want v$node_version" >&2; exit 2; }
|
|
198
|
+
|
|
199
|
+
after=$(profile_digest)
|
|
200
|
+
[ "$before" = "$after" ] || { echo 'bootstrap refused: shell profile changed' >&2; exit 2; }
|
|
201
|
+
|
|
202
|
+
volta_ver=$("$volta_home/bin/volta" --version 2>/dev/null || printf '2.0.2')
|
|
203
|
+
printf '{"status":"installed","volta_home":"%s","connection_node":"%s","node_path":"%s","volta_bin":"%s","node_version":"%s","volta_version":"%s","profile_unchanged":true}\n' \
|
|
204
|
+
"$(json_str "$volta_home")" "$(json_str "$wrapper")" "$(json_str "$node_bin")" "$(json_str "$volta_home/bin/volta")" "$(json_str "$node_version")" "$(json_str "$volta_ver")"
|
|
205
|
+
;;
|
|
206
|
+
*)
|
|
207
|
+
echo 'usage: bootstrap-volta.sh --probe [NODE_PATH] | --apply VOLTA_TAR VOLTA_SHA NODE_TAR NODE_SHA VOLTA_HOME NODE_VERSION I-APPROVE-THIS-BOOTSTRAP' >&2
|
|
208
|
+
exit 2
|
|
209
|
+
;;
|
|
210
|
+
esac
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/** Node-less Linux SSH bootstrap: pinned Volta/Node via POSIX + scp, then enroll. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, basename, isAbsolute, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { digest, exact, identifier, newId, noSymlinks, NodeMissing, now, OpsError, privateDir, rootPath, targetJoin, writeJson } from "./core.mjs";
|
|
7
|
+
import { register, validateHost } from "./model.mjs";
|
|
8
|
+
import { call, posixCall, posixSend, validateSshEndpoint } from "./transport.mjs";
|
|
9
|
+
|
|
10
|
+
const ACK = "I-APPROVE-THIS-BOOTSTRAP";
|
|
11
|
+
const TOOLS = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const SCRIPT = readFileSync(join(TOOLS, "bootstrap-volta.sh"), "utf8");
|
|
13
|
+
const PIN_PATH = join(TOOLS, "..", "toolchains", "volta-linux.json");
|
|
14
|
+
|
|
15
|
+
export function loadVoltaPin() {
|
|
16
|
+
const pin = JSON.parse(readFileSync(PIN_PATH, "utf8"));
|
|
17
|
+
if (pin.schema_version !== 1 || pin.manager !== "volta") throw new OpsError("invalid volta-linux pin metadata");
|
|
18
|
+
return pin;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const bootstrapHooks = { loadPin: loadVoltaPin };
|
|
22
|
+
|
|
23
|
+
function rejectUnpinned(label, value) {
|
|
24
|
+
const s = String(value ?? "");
|
|
25
|
+
if (!s || /latest/i.test(s)) throw new OpsError(`${label} must be a concrete pinned name, not latest`);
|
|
26
|
+
return s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeFilename(name) {
|
|
30
|
+
rejectUnpinned("installer filename", name);
|
|
31
|
+
if (!/^[A-Za-z0-9._+-]+$/.test(name)) throw new OpsError("unsafe installer filename: " + name);
|
|
32
|
+
return name;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sha256File(path) {
|
|
36
|
+
noSymlinks(path, { allowMissing: false });
|
|
37
|
+
return digest(readFileSync(path));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJsonLine(stdout, label) {
|
|
41
|
+
const lines = String(stdout || "").trim().split(/\r?\n/).filter(Boolean);
|
|
42
|
+
const last = lines.at(-1);
|
|
43
|
+
try { return JSON.parse(last); }
|
|
44
|
+
catch { throw new OpsError(`${label} returned invalid JSON`); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function posixProbe(endpoint, nodePath) {
|
|
48
|
+
const args = ["--probe"];
|
|
49
|
+
if (nodePath) args.push(String(nodePath));
|
|
50
|
+
const r = posixCall(endpoint, SCRIPT, { args });
|
|
51
|
+
return parseJsonLine(r.stdout, "posix probe");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function linuxHost(root) {
|
|
55
|
+
return { platform: "linux", root: rootPath(root, "linux") };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function installerDir(hostRoot) {
|
|
59
|
+
return targetJoin(linuxHost(hostRoot), "_host/installers");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function managedVoltaHome(hostRoot, account) {
|
|
63
|
+
return targetJoin(linuxHost(hostRoot), "_host/toolchains/" + account + "/volta");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function downloadHttps(url, dest) {
|
|
67
|
+
let u;
|
|
68
|
+
try { u = new URL(url); } catch { throw new OpsError("pin URL is invalid"); }
|
|
69
|
+
if (u.protocol !== "https:" || u.username || u.password) throw new OpsError("pin URL must be credential-free https");
|
|
70
|
+
privateDir(dirname(dest));
|
|
71
|
+
const curl = spawnSync("curl", ["-fsSL", "--max-time", "120", "-o", dest, url], { encoding: "utf8" });
|
|
72
|
+
if (curl.status === 0 && existsSync(dest)) return dest;
|
|
73
|
+
const wget = spawnSync("wget", ["-q", "-O", dest, url], { encoding: "utf8" });
|
|
74
|
+
if (wget.status === 0 && existsSync(dest)) return dest;
|
|
75
|
+
throw new OpsError("controller download failed; pass reviewed local archives instead of --allow-network");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveArchive({ archive, sha256, pinEntry, allowNetwork, cacheDir, label }) {
|
|
79
|
+
const expected = pinEntry.sha256;
|
|
80
|
+
if (sha256 && sha256 !== expected) throw new OpsError(`${label} sha256 does not match the reviewed pin`);
|
|
81
|
+
let file = archive;
|
|
82
|
+
if (!file) {
|
|
83
|
+
if (!allowNetwork) throw new OpsError(`${label} archive is required unless --allow-network fetches the pinned URL`);
|
|
84
|
+
file = join(cacheDir, safeFilename(pinEntry.filename));
|
|
85
|
+
downloadHttps(pinEntry.url, file);
|
|
86
|
+
}
|
|
87
|
+
noSymlinks(file, { allowMissing: false });
|
|
88
|
+
if (safeFilename(basename(file)) && /latest/i.test(basename(file))) throw new OpsError(`${label} archive name must not contain latest`);
|
|
89
|
+
const actual = sha256File(file);
|
|
90
|
+
if (actual !== expected) throw new OpsError(`${label} installer changed after review`);
|
|
91
|
+
return { file, sha256: expected, filename: safeFilename(pinEntry.filename) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function bootstrapNode(args) {
|
|
95
|
+
const apply = Boolean(args.apply);
|
|
96
|
+
if (apply && args.probe) throw new OpsError("bootstrap-node --probe and --apply are mutually exclusive");
|
|
97
|
+
if (!args.connection_file) throw new OpsError("--connection-file is required");
|
|
98
|
+
const host = JSON.parse(readFileSync(args.connection_file, "utf8"));
|
|
99
|
+
const endpoint = host.connection || host;
|
|
100
|
+
validateSshEndpoint(endpoint);
|
|
101
|
+
if ((endpoint.shell ?? "posix") === "powershell") {
|
|
102
|
+
throw new OpsError("POSIX bootstrap is Linux-only; PowerShell targets still require an existing Node");
|
|
103
|
+
}
|
|
104
|
+
const pin = bootstrapHooks.loadPin();
|
|
105
|
+
const posix = posixProbe(endpoint, endpoint.node);
|
|
106
|
+
if (String(posix.arch || "").startsWith("unsupported")) throw new OpsError("unsupported target architecture: " + posix.arch);
|
|
107
|
+
const arch = posix.arch;
|
|
108
|
+
const pinArch = pin.archives[arch];
|
|
109
|
+
if (!pinArch) throw new OpsError("no pinned Volta/Node archives for " + arch);
|
|
110
|
+
|
|
111
|
+
if (!apply) {
|
|
112
|
+
return { status: "probed", posix, pin: { volta_version: pin.volta_version, node_version: pin.node_version, arch } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (posix.node_usable && posix.tools?.node && posix.tools.node !== "missing") {
|
|
116
|
+
return {
|
|
117
|
+
status: "skipped-existing-node",
|
|
118
|
+
connection_node: posix.node_path || posix.tools.node,
|
|
119
|
+
posix,
|
|
120
|
+
note: "existing Node left untouched; register it as connection.node",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (args.ack !== ACK) throw new OpsError("explicit acknowledgement I-APPROVE-THIS-BOOTSTRAP is required");
|
|
125
|
+
if (!args.account) throw new OpsError("--account is required");
|
|
126
|
+
if (!args.host_root) throw new OpsError("--host-root is required");
|
|
127
|
+
identifier(args.account, "toolchain account");
|
|
128
|
+
const hostRoot = rootPath(args.host_root, "linux");
|
|
129
|
+
const nodeVersion = rejectUnpinned("node version", args.node_version || pin.node_version);
|
|
130
|
+
if (nodeVersion !== pin.node_version) throw new OpsError("node version must match the reviewed pin " + pin.node_version);
|
|
131
|
+
|
|
132
|
+
const cacheDir = args.state ? join(args.state, "private", "installers") : join(hostRoot, "_host", "installers-cache");
|
|
133
|
+
const volta = resolveArchive({
|
|
134
|
+
archive: args.volta_archive, sha256: args.volta_sha256, pinEntry: pinArch.volta,
|
|
135
|
+
allowNetwork: Boolean(args.allow_network), cacheDir, label: "Volta",
|
|
136
|
+
});
|
|
137
|
+
const node = resolveArchive({
|
|
138
|
+
archive: args.node_archive, sha256: args.node_sha256, pinEntry: pinArch.node,
|
|
139
|
+
allowNetwork: Boolean(args.allow_network), cacheDir, label: "Node",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const installers = installerDir(hostRoot);
|
|
143
|
+
const voltaHome = managedVoltaHome(hostRoot, args.account);
|
|
144
|
+
posixCall(endpoint, "#!/bin/sh\nset -eu\nmkdir -p -- \"$1\" \"$2\"\n", { args: [installers, voltaHome] });
|
|
145
|
+
const remoteVolta = join(installers, volta.filename);
|
|
146
|
+
const remoteNode = join(installers, node.filename);
|
|
147
|
+
posixSend(endpoint, volta.file, remoteVolta);
|
|
148
|
+
posixSend(endpoint, node.file, remoteNode);
|
|
149
|
+
const applied = posixCall(endpoint, SCRIPT, {
|
|
150
|
+
args: ["--apply", remoteVolta, volta.sha256, remoteNode, node.sha256, voltaHome, nodeVersion, ACK],
|
|
151
|
+
timeout: 600,
|
|
152
|
+
});
|
|
153
|
+
const result = parseJsonLine(applied.stdout, "posix apply");
|
|
154
|
+
result.status = result.status || "installed";
|
|
155
|
+
result.posix_probe = posix;
|
|
156
|
+
result.arch = arch;
|
|
157
|
+
|
|
158
|
+
if (args.state && args.host_id) {
|
|
159
|
+
identifier(args.host_id);
|
|
160
|
+
const receipt = join(args.state, "hosts", args.host_id, "bootstrap", newId("receipt") + ".json");
|
|
161
|
+
writeJson(receipt, { ...result, host_id: args.host_id, at: now() });
|
|
162
|
+
result.receipt = receipt;
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function enroll(state, request) {
|
|
168
|
+
exact(request, new Set(["hosts", "projects"]), new Set(), "enroll");
|
|
169
|
+
const hosts = request.hosts ?? [];
|
|
170
|
+
if (!hosts.length) throw new OpsError("enroll requires hosts");
|
|
171
|
+
const enrolled = [];
|
|
172
|
+
for (const raw of hosts) {
|
|
173
|
+
const host = { ...raw, connection: { ...(raw.connection || {}) } };
|
|
174
|
+
if (host.transport !== "ssh") throw new OpsError("enroll currently supports ssh hosts; local hosts use register");
|
|
175
|
+
validateSshEndpoint(host.connection);
|
|
176
|
+
if ((host.connection.shell ?? "posix") === "powershell") {
|
|
177
|
+
throw new OpsError("POSIX bootstrap/enroll is Linux-only");
|
|
178
|
+
}
|
|
179
|
+
if (!host.connection.node) throw new NodeMissing({ tools: { node: "missing" }, node_usable: false });
|
|
180
|
+
const posix = posixProbe(host.connection, host.connection.node);
|
|
181
|
+
if (posix.tools?.node === "missing" || posix.node_usable === false) throw new NodeMissing(posix);
|
|
182
|
+
const discovery = host.identity === "discover" || host.identity == null;
|
|
183
|
+
if (discovery) host.identity = "0".repeat(64);
|
|
184
|
+
if (!isAbsolute(host.connection.node)) {
|
|
185
|
+
throw new OpsError("connection.node must be the absolute path returned by bootstrap-node");
|
|
186
|
+
}
|
|
187
|
+
validateHost(host);
|
|
188
|
+
const observed = call(host, { action: "probe", ...(discovery ? { identity: null } : {}) }, { timeout: 180 });
|
|
189
|
+
host.identity = observed.identity;
|
|
190
|
+
validateHost(host);
|
|
191
|
+
register(state, { hosts: [host], projects: request.projects });
|
|
192
|
+
const inventory = call(host, { action: "probe" }, { timeout: 180 });
|
|
193
|
+
const snap = join(state, "hosts", host.host_id, "inventory", newId("snapshot") + ".json");
|
|
194
|
+
writeJson(snap, inventory);
|
|
195
|
+
enrolled.push({
|
|
196
|
+
host_id: host.host_id,
|
|
197
|
+
identity: host.identity,
|
|
198
|
+
connection_node: host.connection.node,
|
|
199
|
+
inventory,
|
|
200
|
+
snapshot: snap,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
status: "enrolled",
|
|
205
|
+
hosts: enrolled,
|
|
206
|
+
host_id: enrolled[0].host_id,
|
|
207
|
+
identity: enrolled[0].identity,
|
|
208
|
+
inventory: enrolled[0].inventory,
|
|
209
|
+
connection_node: enrolled[0].connection_node,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function discoverOrMissing(host) {
|
|
214
|
+
if (host.transport !== "ssh") return null;
|
|
215
|
+
validateSshEndpoint(host.connection);
|
|
216
|
+
const posix = posixProbe(host.connection, host.connection?.node);
|
|
217
|
+
const missing = posix.tools?.node === "missing" || posix.node_usable === false;
|
|
218
|
+
if (missing) throw new NodeMissing(posix);
|
|
219
|
+
if (!host.connection.node && posix.node_path) host.connection.node = posix.node_path;
|
|
220
|
+
return posix;
|
|
221
|
+
}
|
|
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, readFileSync, statSync, lstatSync, unlinkSync,
|
|
|
3
3
|
import { dirname, isAbsolute, join, relative as pathRelative, resolve, sep } from "node:path";
|
|
4
4
|
import { hostname } from "node:os";
|
|
5
5
|
import {
|
|
6
|
-
atomicWrite, digest, emptyStatus, identifier, newId, now, noSymlinks, OpsError,
|
|
6
|
+
atomicWrite, digest, emptyStatus, identifier, newId, now, noSymlinks, OpsError, NodeMissing,
|
|
7
7
|
privateDir, readJson, VERSION, withLock, writeJson,
|
|
8
8
|
} from "./core.mjs";
|
|
9
9
|
import { load, ledgerLoad, putCredential, register, save, validate, validateHost, validateStatus } from "./model.mjs";
|
|
@@ -13,6 +13,7 @@ import { approval, apply, verifyJournal, requestBase } from "./execution.mjs";
|
|
|
13
13
|
import { STANDARD } from "./docs.mjs";
|
|
14
14
|
import { environmentSpec } from "./host_recipes.mjs";
|
|
15
15
|
import { fetchSource } from "./sources.mjs";
|
|
16
|
+
import { bootstrapNode, enroll, discoverOrMissing } from "./bootstrap.mjs";
|
|
16
17
|
|
|
17
18
|
export const cliHooks = { probeLocal: transportProbeLocal };
|
|
18
19
|
export function probeLocal() { return cliHooks.probeLocal(); }
|
|
@@ -218,6 +219,15 @@ function parseArgs(argv) {
|
|
|
218
219
|
else if (command === "status") { /* none */ }
|
|
219
220
|
else if (command === "recover-controller-lock") grab("--ack");
|
|
220
221
|
else if (command === "import-legacy") { grab("--source"); grab("--controller-id"); }
|
|
222
|
+
else if (command === "bootstrap-node") {
|
|
223
|
+
grab("--connection-file"); grab("--host-id"); grab("--account"); grab("--host-root");
|
|
224
|
+
grab("--volta-archive"); grab("--volta-sha256"); grab("--node-archive"); grab("--node-sha256");
|
|
225
|
+
grab("--node-version"); grab("--ack");
|
|
226
|
+
out.probe = flag("--probe");
|
|
227
|
+
out.apply = flag("--apply");
|
|
228
|
+
out.allow_network = flag("--allow-network");
|
|
229
|
+
}
|
|
230
|
+
else if (command === "enroll") grab("--file");
|
|
221
231
|
else throw new Error("unknown command: " + command);
|
|
222
232
|
if (argv.length) throw new Error("unrecognized arguments: " + argv.join(" "));
|
|
223
233
|
return out;
|
|
@@ -234,7 +244,7 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
234
244
|
process.stdout.write(VERSION + "\n");
|
|
235
245
|
return 0;
|
|
236
246
|
}
|
|
237
|
-
if (!args.state && !["analyze", "probe", "validate"].includes(args.command)) {
|
|
247
|
+
if (!args.state && !["analyze", "probe", "validate", "bootstrap-node"].includes(args.command)) {
|
|
238
248
|
process.stderr.write("--state is required; it is never guessed from cwd\n");
|
|
239
249
|
return 2;
|
|
240
250
|
}
|
|
@@ -254,10 +264,14 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
254
264
|
} else if (args.connection_file) {
|
|
255
265
|
const h = readJson(args.connection_file);
|
|
256
266
|
const discovery = h.identity === "discover";
|
|
267
|
+
if (h.transport === "ssh") discoverOrMissing(h);
|
|
257
268
|
if (discovery) h.identity = "0".repeat(64);
|
|
258
269
|
validateHost(h);
|
|
259
270
|
result = call(h, { action: "probe", ...(discovery ? { identity: null } : {}) }, { timeout: 180 });
|
|
260
|
-
if (discovery)
|
|
271
|
+
if (discovery) {
|
|
272
|
+
result.next = "ops.mjs --state STATE enroll --file register.json";
|
|
273
|
+
result.registration_note = "Read-only discovery over your pinned known_hosts. Next must persist via enroll (or register + probe --host); discover is never valid for register/apply.";
|
|
274
|
+
}
|
|
261
275
|
} else result = probeLocal();
|
|
262
276
|
if (args.output) writeJson(resolve(args.output), result);
|
|
263
277
|
} else if (cmd === "register") result = register(state, readJson(args.file));
|
|
@@ -302,10 +316,18 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
302
316
|
};
|
|
303
317
|
} else if (cmd === "recover-controller-lock") result = breakControllerLock(state, args.ack);
|
|
304
318
|
else if (cmd === "import-legacy") result = importLegacy(state, resolve(args.source), args.controller_id);
|
|
319
|
+
else if (cmd === "bootstrap-node") result = bootstrapNode({ ...args, state });
|
|
320
|
+
else if (cmd === "enroll") result = enroll(state, readJson(args.file));
|
|
305
321
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
306
|
-
return ["failed", "partial", "unknown", "docs_pending"].includes(result.status) ? 2 : 0;
|
|
322
|
+
return ["failed", "partial", "unknown", "docs_pending", "blocked"].includes(result.status) ? 2 : 0;
|
|
307
323
|
} catch (e) {
|
|
308
|
-
|
|
324
|
+
const payload = { status: "blocked", error: e.message || String(e) };
|
|
325
|
+
if (e instanceof NodeMissing) {
|
|
326
|
+
payload.error = "node-missing";
|
|
327
|
+
payload.next = e.next;
|
|
328
|
+
payload.posix = e.posix;
|
|
329
|
+
}
|
|
330
|
+
process.stderr.write(JSON.stringify(payload) + "\n");
|
|
309
331
|
return 2;
|
|
310
332
|
}
|
|
311
333
|
}
|
|
@@ -19,6 +19,16 @@ export class OpsError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export class NodeMissing extends OpsError {
|
|
23
|
+
constructor(posix, next = "ops.mjs bootstrap-node --apply") {
|
|
24
|
+
super("node-missing");
|
|
25
|
+
this.name = "NodeMissing";
|
|
26
|
+
this.code = "node-missing";
|
|
27
|
+
this.next = next;
|
|
28
|
+
this.posix = posix;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
export class UnknownResult extends OpsError {
|
|
23
33
|
constructor(message) {
|
|
24
34
|
super(message);
|
|
@@ -26,6 +36,8 @@ export class UnknownResult extends OpsError {
|
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
|
|
39
|
+
|
|
40
|
+
|
|
29
41
|
export function now() {
|
|
30
42
|
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
31
43
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/** Pinned environment-management recipes. No latest-version guessing or implicit profile edits. */
|
|
2
2
|
import { identifier, exact, newId, now, OpsError, targetJoin, within } from "./core.mjs";
|
|
3
3
|
import { load } from "./model.mjs";
|
|
4
|
-
import { call } from "./transport.mjs";
|
|
4
|
+
import { call as transportCall } from "./transport.mjs";
|
|
5
|
+
|
|
6
|
+
export const hostRecipesHooks = { call: transportCall };
|
|
5
7
|
|
|
6
8
|
function shlexQuote(s) {
|
|
7
9
|
if (s === "") return "''";
|
|
@@ -13,12 +15,18 @@ function reEscape(s) {
|
|
|
13
15
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
function managedVoltaBin(host, account, inventory) {
|
|
19
|
+
const home = (inventory.defaults?.VOLTA_HOME || "").trim()
|
|
20
|
+
|| targetJoin(host, "_host/toolchains/" + account + "/volta");
|
|
21
|
+
return host.platform === "windows" ? home + "\\bin\\volta" : home.replace(/\/+$/, "") + "/bin/volta";
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
export function environmentSpec(state, hid, request) {
|
|
17
25
|
exact(request, new Set(["account", "python_versions", "uv_path", "node_version", "npm_version", "volta_path", "java_version", "original_java_candidate", "sdkman_init"]), new Set(["account"]), "environment request");
|
|
18
26
|
identifier(request.account, "toolchain account");
|
|
19
27
|
const status = load(state);
|
|
20
28
|
const host = status.hosts[hid];
|
|
21
|
-
const inventory = call(host, { action: "probe", disk_roots: [host.root] }, { timeout: 180 });
|
|
29
|
+
const inventory = hostRecipesHooks.call(host, { action: "probe", disk_roots: [host.root] }, { timeout: 180 });
|
|
22
30
|
const base = targetJoin(host, "_host/toolchains/" + request.account);
|
|
23
31
|
const defaults = Object.fromEntries(Object.entries(inventory.tools).filter(([k, v]) => ["java", "python", "python3", "node", "npm"].includes(k) && v.status === "observed"));
|
|
24
32
|
const actions = [];
|
|
@@ -55,8 +63,16 @@ export function environmentSpec(state, hid, request) {
|
|
|
55
63
|
}
|
|
56
64
|
if (request.node_version) {
|
|
57
65
|
const v = pinned(request.node_version, "Node");
|
|
58
|
-
|
|
59
|
-
if (!volta)
|
|
66
|
+
let volta = request.volta_path || inventory.tools.volta?.path;
|
|
67
|
+
if (!volta) {
|
|
68
|
+
const candidate = managedVoltaBin(host, request.account, inventory);
|
|
69
|
+
const snap = hostRecipesHooks.call(host, { action: "snapshot", paths: [candidate] }, { timeout: 60 });
|
|
70
|
+
const st = snap.paths?.[candidate];
|
|
71
|
+
if (!st || st.kind === "absent") {
|
|
72
|
+
throw new OpsError("Volta missing: install a reviewed pinned release before the managed environment recipe; for a Node-less Linux SSH target run ops.mjs bootstrap-node first");
|
|
73
|
+
}
|
|
74
|
+
volta = candidate;
|
|
75
|
+
}
|
|
60
76
|
const home = base + (host.platform === "windows" ? "\\volta" : "/volta");
|
|
61
77
|
mkdir("_host/toolchains/" + request.account + "/volta");
|
|
62
78
|
let old = (inventory.tools.node?.version ?? "").trim();
|