@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
package/package.json
CHANGED
|
@@ -19,6 +19,9 @@ keywords: [ops, host-manage, 主机, 持久化, 审批]
|
|
|
19
19
|
|
|
20
20
|
指定 local 或 SSH 的稳定 host_id;读取最近盘点与有限问题证据。初次 SSH 用用户已验证的 known_hosts,identity=discover 只允许只读探测;把返回身份明确登记后才允许计划和执行。
|
|
21
21
|
|
|
22
|
+
Linux SSH 目标若没有 Node:必须先 `ops.mjs bootstrap-node`(控制端校验已审核 tar 的 SHA256,scp 到目标,远端 POSIX 展开固定 Volta/Node),禁止把 POSIX 盘点只写在对话里。引导成功后必须 `ops.mjs enroll`(或 `register` 再 `probe --host`),`hosts/{host_id}/inventory/snapshot-*.json` 与 `status.json.hosts` 是完成标准的一部分。引导是 ops.mjs 第一阶段通道,需要明文 ack 与 SHA256,不走 plan/approve;已有 Node 的目标不重复安装、不改 `.bashrc`/`.profile`。缺 Node 只能阻塞 apply,不能阻塞登记。本轮仅 Linux SSH;Windows/macOS 仍要求目标已有 Node。
|
|
23
|
+
|
|
24
|
+
|
|
22
25
|
区分系统版本、用户默认、项目 pin、服务环境。environment-spec 支持明确版本的 uv、Volta、SDKMAN 管理配方;管理器缺失先用经过审核的安装器。保留旧默认与旧目录,不能为统一外观先删除旧环境。工作流不擅自改写用户 shell profile;激活新管理器入口是另一个明确的准备动作。
|
|
23
26
|
|
|
24
27
|
Docker 缺失使用经审核且版本固定的 Linux Engine 安装配方,完成服务、Compose、data-root、账号权限验证之后才进入 D。既有 data-root 不匹配时单独备份、停机、迁移、验证,不把 /var/lib/docker 直接 mv 当作安装步骤。镜像源按可信清单、样本哈希和目标网络测试,切换配置仍需批准。
|
|
@@ -24,7 +24,7 @@ keywords: [ops, 主机, SSH, 部署, Docker, 持久化, 公共服务, 运维]
|
|
|
24
24
|
## Work 激活
|
|
25
25
|
|
|
26
26
|
I-initialize:控制端初始化。
|
|
27
|
-
H-host-manage:本地或远程主机的环境、诊断、清理和维护。
|
|
27
|
+
H-host-manage:本地或远程主机的环境、诊断、清理和维护。Linux SSH 目标无 Node 时先走 USAGE 中的 `bootstrap-node` 再 `enroll`,盘点必须写入 `hosts/`。
|
|
28
28
|
D-project-deploy:业务 APP 与公共服务部署、复用、更新、停止及显式迁移。
|
|
29
29
|
|
|
30
30
|
三个入口都使用 plan → approve → apply → verify → dual-documents;不得自动激活额外旧 Work。
|
|
@@ -89,7 +89,7 @@ init/register/credential-put 是用户显式请求的部署机本地记录操作
|
|
|
89
89
|
|
|
90
90
|
## 阅读与操作入口
|
|
91
91
|
|
|
92
|
-
详细命令和可运行演练:`<Path>{roots.workflows}/ops/common/USAGE.md</Path>`。
|
|
92
|
+
详细命令和可运行演练:`<Path>{roots.workflows}/ops/common/USAGE.md</Path>`。Linux SSH 缺 Node 的固定 Volta 引导见 USAGE §1。
|
|
93
93
|
数据与账户:`<Path>{roots.workflows}/ops/common/rules/persistence-and-secrets.md</Path>`。
|
|
94
94
|
共享服务:`<Path>{roots.workflows}/ops/common/rules/shared-services.md</Path>`。
|
|
95
95
|
恢复:`<Path>{roots.workflows}/ops/common/rules/recovery.md</Path>`。
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|---|---|---|
|
|
7
7
|
| 控制端 | Node 标准库 CLI(ops.mjs);状态、锁、明文、计划、执行 | 无第三方 npm 包;控制端可本地执行完整闭环 |
|
|
8
8
|
| Linux local | 原生 oneshot;systemd 配置/权限/健康门;Docker Engine Compose | 本轮隔离 local oneshot 演练;systemd/Docker 需要真实有权限主机验收 |
|
|
9
|
-
| SSH | OpenSSH 密钥或 agent;固定 known_hosts;目标身份;可 sudo -n |
|
|
9
|
+
| SSH | OpenSSH 密钥或 agent;固定 known_hosts;目标身份;可 sudo -n;Linux 目标无 Node 时可走固定 Volta 引导 | 目标无 Node 时 Linux SSH 可走固定 Volta/Node 引导(pin 见 common/toolchains/volta-linux.json);Windows 仍要求已有 Node。本轮没有远程主机/ssh 客户端实机验证,由模拟 SSH/scp 覆盖;不实现交互式 SSH 密码登录 |
|
|
10
10
|
| Windows 原生 | PowerShell 引导、路径/DACL、同账户登录态 Scheduled Task | 不是无人登录的 Windows Service;真正后台服务需专用 adapter;本轮非 Windows 实机 |
|
|
11
11
|
| WSL | 单独登记为 Linux 执行目标 | 不把 Windows 主机路径/Docker Desktop VM 当作 WSL 根 |
|
|
12
12
|
| macOS | 身份探测、POSIX 路径、oneshot | 无 launchd 适配;不是承诺全部平台常驻部署 |
|
|
13
13
|
| Compose | JSON-as-YAML、显式 context/project、raw env_file、digest image、bind 与 VOLUME检查 | 要求 Compose >=2.30;strict Engine data-root;Docker Desktop 不自动迁移 |
|
|
14
|
-
| uv/Volta/SDKMAN |
|
|
14
|
+
| uv/Volta/SDKMAN | 明确版本准备与原默认保留配方;命令/默认验证;Volta 本体 + 固定 Node 镜像可由 Linux SSH `bootstrap-node` 安装 | 管理器安装包必须可信固定;旧 profile 不自动改写;项目真实兼容性另验 |
|
|
15
15
|
| 共享服务 | MySQL/Redis/MinIO 分配、existing 验证、owner/binding/依赖排序 | 无本轮数据库实机;未知产品须真实版本适配;没有万能数据库备份/恢复 |
|
|
16
16
|
| 升级/回滚/迁移 | 新 spec、固定版本、显式主机操作、跨机排序、逐步回执 | 没有隐式停机零损失承诺;没有自动生成任意项目迁移脚本 |
|
|
17
17
|
| 清理 | 只读诊断、cache/log 隔离、精确回执受控 purge | 不普遍删除 Windows/Linux 任意系统垃圾;不 drop_caches 或 prune volume |
|
|
@@ -10,9 +10,28 @@ node /path/to/ops/common/tools/ops.mjs --state /path/to/.speculo/ops init --cont
|
|
|
10
10
|
node /path/to/ops/common/tools/ops.mjs probe --output /safe/path/local-inventory.json
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
没有 Node 时运行 common/tools/bootstrap.sh probe 或 bootstrap.ps1 -Probe,只做检测。经过批准的本地安装器还需精确 SHA256 与确认字符串。完成后重新 init。
|
|
13
|
+
没有 Node 时运行 common/tools/bootstrap.sh probe 或 bootstrap.ps1 -Probe,只做检测。经过批准的本地安装器还需精确 SHA256 与确认字符串。完成后重新 init。SSH 目标缺 Node 不要对本机 bootstrap.sh 假装能装远端,改走下面的 `bootstrap-node`。
|
|
14
|
+
|
|
15
|
+
初次远端将 host.json 中 identity 写为 discover(仅 probe 支持),connection 包含 hostname、username、known_hosts,可选 port、identity_file、sudo、shell。known_hosts 必须已经通过可信方式核对,不能自动信任 ssh-keyscan 输出。目标还没有 Node 时不要填一个 PATH 上的 `node` 然后把盘点留在对话里:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
node /path/to/ops/common/tools/ops.mjs bootstrap-node --connection-file /safe/path/host-discovery.json --probe
|
|
19
|
+
# 若 tools.node=missing,准备已审核的 volta/node tar(SHA256 见 common/toolchains/volta-linux.json),再:
|
|
20
|
+
node /path/to/ops/common/tools/ops.mjs --state /path/to/.speculo/ops bootstrap-node --apply \
|
|
21
|
+
--connection-file /safe/path/host-discovery.json --host-id node-a --account ops --host-root /srv/ops \
|
|
22
|
+
--volta-archive /safe/volta-2.0.2-linux.tar.gz --volta-sha256 PINNED \
|
|
23
|
+
--node-archive /safe/node-v24.21.0-linux-x64.tar.gz --node-sha256 PINNED \
|
|
24
|
+
--ack I-APPROVE-THIS-BOOTSTRAP
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
引导把 `VOLTA_HOME` 放在 `{host.root}/_host/toolchains/{account}/volta`,`connection.node` 指向其中的 `bin/ops-node`(导出 VOLTA_HOME 后 exec 真实 Node),不改 `.bashrc`/`.profile`,不跑官方 `install.sh`。已有 Node 则 `skipped-existing-node`,原默认不动。随后必须落盘:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
node /path/to/ops/common/tools/ops.mjs --state /path/to/.speculo/ops enroll --file /safe/path/register.json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`enroll` 接受 `identity=discover` 与引导返回的绝对 `connection.node`,写入 `status.json` 与 `hosts/{host_id}/inventory/snapshot-*.json`。单独 `probe --connection-file` 仍只打印 JSON,下一步必须 enroll(或 register + `probe --host`)。
|
|
14
34
|
|
|
15
|
-
初次远端将 host.json 中 identity 写为 discover(仅 probe 支持),connection 包含 hostname、username、known_hosts、node,可选 port、identity_file、sudo、shell。known_hosts 必须已经通过可信方式核对,不能自动信任 ssh-keyscan 输出。
|
|
16
35
|
|
|
17
36
|
```sh
|
|
18
37
|
node /path/to/ops/common/tools/ops.mjs probe --connection-file /safe/path/host-discovery.json --output /safe/path/host-observed.json
|
|
@@ -63,7 +82,7 @@ node /path/to/ops/common/tools/ops.mjs --state /path/to/.speculo/ops environment
|
|
|
63
82
|
node /path/to/ops/common/tools/ops.mjs --state /path/to/.speculo/ops mirror-probe --host node-a --file /safe/path/mirror-candidates.json --allow-network
|
|
64
83
|
```
|
|
65
84
|
|
|
66
|
-
环境配方不抓 latest
|
|
85
|
+
环境配方不抓 latest;管理器缺失先明确准备安装器。Linux SSH 上 Volta 本体与固定 Node 镜像用 `bootstrap-node`,不要指望 environment-spec 自己装 Volta。来源测速不修改配置,证书/哈希失败候选不使用,真实配置更换通过 H 的精确 write-file/write-control/命令计划。不能将 Python/npm/Docker/Maven 镜像混为一套规则。
|
|
67
86
|
|
|
68
87
|
H.host_actions 支持 mkdir、write-file(host.root 内相对路径;不得写生成器负责的 README/DEPLOYMENTS/knowledge 账本)、write-control(精确系统控制文件:内置 docker daemon.json 与 ops-*.service,以及经理由/回滚/验证声明的 nginx、wireguard、非 ops- 前缀单元)、install-toolchain(明确写集/原默认/验证)、command(明确写集/验证)、defaults、quarantine、purge-quarantine。系统软件包安装使用明确批准的可信安装脚本,不自动猜当前发行版安装命令;Docker 示例见 service-profiles/docker-engine.md。主机级入口用 resource_updates.hosts[].host_services 与 spec.public_ingress 入账,不要为 Nginx/WireGuard 伪造 APP 部署。
|
|
69
88
|
|
|
@@ -105,7 +124,7 @@ node /path/to/ops/common/tools/ops.mjs --state /path/to/new-ops-state import-leg
|
|
|
105
124
|
|
|
106
125
|
```sh
|
|
107
126
|
node /path/to/ops/common/tools/demo-local.mjs --output /absolute/empty/demo-root
|
|
108
|
-
node --test /path/to/ops/common/tests/test_ops.mjs
|
|
127
|
+
node --test /path/to/ops/common/tests/test_ops.mjs /path/to/ops/common/tests/test_ops_bootstrap.mjs
|
|
109
128
|
node /path/to/ops/common/tools/validate-ops.mjs --self-check
|
|
110
129
|
```
|
|
111
130
|
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/** Node-less Linux SSH bootstrap/enroll contracts. SSH/scp are simulated; no public network. */
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import * as core from "../tools/opslib/core.mjs";
|
|
11
|
+
import * as transport from "../tools/opslib/transport.mjs";
|
|
12
|
+
import * as recipes from "../tools/opslib/host_recipes.mjs";
|
|
13
|
+
import * as bootstrap from "../tools/opslib/bootstrap.mjs";
|
|
14
|
+
import { initialize, main, cliHooks } from "../tools/opslib/cli.mjs";
|
|
15
|
+
import * as agent from "../tools/opslib/agent.mjs";
|
|
16
|
+
import * as model from "../tools/opslib/model.mjs";
|
|
17
|
+
|
|
18
|
+
const toolsDir = join(dirname(fileURLToPath(import.meta.url)), "../tools");
|
|
19
|
+
const scriptPath = join(toolsDir, "bootstrap-volta.sh");
|
|
20
|
+
const ACK = "I-APPROVE-THIS-BOOTSTRAP";
|
|
21
|
+
|
|
22
|
+
function sha256(p) {
|
|
23
|
+
return createHash("sha256").update(readFileSync(p)).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function splitRemote(cmd) {
|
|
27
|
+
return String(cmd).match(/(?:'[^']*'|\S)+/g).map((s) => (s.startsWith("'") ? s.slice(1, -1).replace(/'"'"'/g, "'") : s));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeArchives(dir, nodeVersion = "24.21.0") {
|
|
31
|
+
const voltaSrc = join(dir, "volta-src");
|
|
32
|
+
mkdirSync(voltaSrc, { recursive: true });
|
|
33
|
+
writeFileSync(join(voltaSrc, "volta"), "#!/bin/sh\necho 2.0.2\n", { mode: 0o755 });
|
|
34
|
+
writeFileSync(join(voltaSrc, "volta-shim"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
35
|
+
writeFileSync(join(voltaSrc, "install.sh"), "#!/bin/sh\necho SHOULD-NOT-RUN >&2; echo PROFILE_EDIT >> \"$HOME/.bashrc\"; exit 1\n", { mode: 0o755 });
|
|
36
|
+
const voltaTar = join(dir, "volta-2.0.2-linux.tar.gz");
|
|
37
|
+
const t1 = spawnSync("tar", ["-czf", voltaTar, "-C", voltaSrc, "volta", "volta-shim", "install.sh"], { encoding: "utf8" });
|
|
38
|
+
assert.equal(t1.status, 0, t1.stderr);
|
|
39
|
+
|
|
40
|
+
const top = `node-v${nodeVersion}-linux-x64`;
|
|
41
|
+
const nodeRoot = join(dir, top);
|
|
42
|
+
mkdirSync(join(nodeRoot, "bin"), { recursive: true });
|
|
43
|
+
writeFileSync(join(nodeRoot, "bin", "node"), `#!/bin/sh
|
|
44
|
+
if [ "$1" = "--version" ]; then echo v${nodeVersion}; exit 0; fi
|
|
45
|
+
exec ${JSON.stringify(process.execPath)} "$@"
|
|
46
|
+
`, { mode: 0o755 });
|
|
47
|
+
const nodeTar = join(dir, `${top}.tar.gz`);
|
|
48
|
+
const t2 = spawnSync("tar", ["-czf", nodeTar, "-C", dir, top], { encoding: "utf8" });
|
|
49
|
+
assert.equal(t2.status, 0, t2.stderr);
|
|
50
|
+
return { voltaTar, nodeTar, voltaSha: sha256(voltaTar), nodeSha: sha256(nodeTar), nodeVersion };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pinFor(archives, arch = "linux-x64") {
|
|
54
|
+
const base = bootstrap.loadVoltaPin();
|
|
55
|
+
const slot = { ...base.archives[arch] };
|
|
56
|
+
slot.volta = { ...slot.volta, sha256: archives.voltaSha };
|
|
57
|
+
slot.node = { ...slot.node, sha256: archives.nodeSha };
|
|
58
|
+
return { ...base, archives: { ...base.archives, [arch]: slot } };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mockTransport({ probeJson = null } = {}) {
|
|
62
|
+
const prev = transport.transportHooks.spawnSync;
|
|
63
|
+
const log = [];
|
|
64
|
+
transport.transportHooks.spawnSync = (cmd, args, kw) => {
|
|
65
|
+
log.push({ cmd, args: [...args], encoding: kw.encoding, inputIsBuffer: Buffer.isBuffer(kw.input) });
|
|
66
|
+
const last = args[args.length - 1];
|
|
67
|
+
if (cmd === "scp") {
|
|
68
|
+
const local = args[args.length - 2];
|
|
69
|
+
const spec = args[args.length - 1];
|
|
70
|
+
const remotePath = spec.slice(spec.indexOf(":") + 1);
|
|
71
|
+
mkdirSync(dirname(remotePath), { recursive: true });
|
|
72
|
+
copyFileSync(local, remotePath);
|
|
73
|
+
return { status: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
|
|
74
|
+
}
|
|
75
|
+
if (cmd === "ssh") {
|
|
76
|
+
assert.ok(args.includes("BatchMode=yes"));
|
|
77
|
+
assert.ok(args.includes("StrictHostKeyChecking=yes"));
|
|
78
|
+
assert.ok(args.some((a) => String(a).startsWith("UserKnownHostsFile=")));
|
|
79
|
+
if (probeJson && String(last).includes("--probe")) {
|
|
80
|
+
return { status: 0, stdout: Buffer.from(JSON.stringify(probeJson) + "\n"), stderr: Buffer.alloc(0) };
|
|
81
|
+
}
|
|
82
|
+
if (String(last).includes("--input-type=module")) {
|
|
83
|
+
const parts = splitRemote(last);
|
|
84
|
+
return spawnSync(parts[0], parts.slice(1), {
|
|
85
|
+
input: kw.input, encoding: "buffer", timeout: kw.timeout, maxBuffer: kw.maxBuffer,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const parts = splitRemote(last);
|
|
89
|
+
return spawnSync(parts[0], parts.slice(1), {
|
|
90
|
+
input: kw.input, encoding: "buffer", timeout: kw.timeout, maxBuffer: kw.maxBuffer,
|
|
91
|
+
env: { ...process.env, HOME: kw.HOME || process.env.HOME },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return prev(cmd, args, kw);
|
|
95
|
+
};
|
|
96
|
+
return { log, restore() { transport.transportHooks.spawnSync = prev; } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function captureMain(argv) {
|
|
100
|
+
const out = [];
|
|
101
|
+
const err = [];
|
|
102
|
+
const wo = process.stdout.write;
|
|
103
|
+
const we = process.stderr.write;
|
|
104
|
+
process.stdout.write = (c) => { out.push(String(c)); return true; };
|
|
105
|
+
process.stderr.write = (c) => { err.push(String(c)); return true; };
|
|
106
|
+
try {
|
|
107
|
+
const code = main(argv);
|
|
108
|
+
return { code, stdout: out.join(""), stderr: err.join("") };
|
|
109
|
+
} finally {
|
|
110
|
+
process.stdout.write = wo;
|
|
111
|
+
process.stderr.write = we;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sshHost({ root, kh, node = "/usr/bin/node", identity = "discover" }) {
|
|
116
|
+
return {
|
|
117
|
+
host_id: "wsl-a",
|
|
118
|
+
display_name: "WSL fixture",
|
|
119
|
+
platform: "linux",
|
|
120
|
+
transport: "ssh",
|
|
121
|
+
root,
|
|
122
|
+
identity,
|
|
123
|
+
connection: {
|
|
124
|
+
hostname: "host.invalid",
|
|
125
|
+
username: "ops",
|
|
126
|
+
known_hosts: kh,
|
|
127
|
+
node,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function writeConn(dir, host) {
|
|
133
|
+
const p = join(dir, "conn.json");
|
|
134
|
+
writeFileSync(p, JSON.stringify(host, null, 2));
|
|
135
|
+
return p;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test("posix ssh uses the same host-key contract", () => {
|
|
139
|
+
const d = mkdtempSync(join(tmpdir(), "ops-posix-"));
|
|
140
|
+
const kh = join(d, "known_hosts");
|
|
141
|
+
writeFileSync(kh, "fixture pinned key\n");
|
|
142
|
+
const captured = {};
|
|
143
|
+
const prev = transport.transportHooks.spawnSync;
|
|
144
|
+
transport.transportHooks.spawnSync = (cmd, args, kw) => {
|
|
145
|
+
captured.argv = [cmd, ...args];
|
|
146
|
+
Object.assign(captured, kw);
|
|
147
|
+
return { status: 0, stdout: '{"status":"probed"}', stderr: "" };
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
transport.posixCall({ hostname: "host.invalid", username: "ops", known_hosts: kh }, "#!/bin/sh\necho ok\n", { args: ["--probe"] });
|
|
151
|
+
assert.equal(captured.argv[0], "ssh");
|
|
152
|
+
assert.ok(captured.argv.includes("BatchMode=yes"));
|
|
153
|
+
assert.ok(captured.argv.includes("StrictHostKeyChecking=yes"));
|
|
154
|
+
assert.ok(captured.argv.includes("UserKnownHostsFile=" + kh));
|
|
155
|
+
assert.ok(captured.argv.includes("/bin/sh -s -- --probe") || captured.argv.at(-1).includes("/bin/sh"));
|
|
156
|
+
assert.ok(!String(captured.argv.at(-1)).includes("--input-type=module"));
|
|
157
|
+
assert.ok(Buffer.isBuffer(captured.input));
|
|
158
|
+
} finally { transport.transportHooks.spawnSync = prev; rmSync(d, { recursive: true, force: true }); }
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("scp uses the same host-key contract", () => {
|
|
162
|
+
const d = mkdtempSync(join(tmpdir(), "ops-scp-"));
|
|
163
|
+
const kh = join(d, "known_hosts");
|
|
164
|
+
writeFileSync(kh, "fixture pinned key\n");
|
|
165
|
+
const local = join(d, "a.tar.gz");
|
|
166
|
+
writeFileSync(local, "x");
|
|
167
|
+
const captured = {};
|
|
168
|
+
const prev = transport.transportHooks.spawnSync;
|
|
169
|
+
transport.transportHooks.spawnSync = (cmd, args, kw) => {
|
|
170
|
+
captured.argv = [cmd, ...args];
|
|
171
|
+
Object.assign(captured, kw);
|
|
172
|
+
return { status: 0, stdout: "", stderr: "" };
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
transport.posixSend({ hostname: "host.invalid", username: "ops", known_hosts: kh }, local, "/srv/ops/_host/installers/a.tar.gz");
|
|
176
|
+
assert.equal(captured.argv[0], "scp");
|
|
177
|
+
assert.ok(captured.argv.includes("BatchMode=yes"));
|
|
178
|
+
assert.ok(captured.argv.includes("StrictHostKeyChecking=yes"));
|
|
179
|
+
assert.ok(captured.argv.includes("UserKnownHostsFile=" + kh));
|
|
180
|
+
assert.ok(captured.argv.includes("-P"));
|
|
181
|
+
assert.ok(!captured.argv.includes("-T"));
|
|
182
|
+
} finally { transport.transportHooks.spawnSync = prev; rmSync(d, { recursive: true, force: true }); }
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("bootstrap-volta apply installs wrapper and does not touch profile", () => {
|
|
186
|
+
const d = mkdtempSync(join(tmpdir(), "ops-vol-"));
|
|
187
|
+
const home = join(d, "home");
|
|
188
|
+
mkdirSync(home);
|
|
189
|
+
const bashrc = join(home, ".bashrc");
|
|
190
|
+
writeFileSync(bashrc, "keep-me\n");
|
|
191
|
+
const archives = makeArchives(d);
|
|
192
|
+
const voltaHome = join(d, "srv", "ops", "_host", "toolchains", "ops", "volta");
|
|
193
|
+
const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, archives.voltaSha, archives.nodeTar, archives.nodeSha, voltaHome, archives.nodeVersion, ACK], {
|
|
194
|
+
encoding: "utf8", env: { ...process.env, HOME: home },
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
assert.equal(p.status, 0, p.stderr + p.stdout);
|
|
198
|
+
const result = JSON.parse(p.stdout.trim().split("\n").at(-1));
|
|
199
|
+
assert.equal(result.profile_unchanged, true);
|
|
200
|
+
assert.ok(result.connection_node.endsWith("/volta/bin/ops-node"));
|
|
201
|
+
assert.equal(readFileSync(bashrc, "utf8"), "keep-me\n");
|
|
202
|
+
const ver = spawnSync(result.connection_node, ["--version"], { encoding: "utf8", env: { ...process.env, HOME: home } });
|
|
203
|
+
assert.equal(ver.stdout.trim(), "v24.21.0");
|
|
204
|
+
assert.ok(!existsSync(join(voltaHome, "install.sh")) || readFileSync(join(home, ".bashrc"), "utf8") === "keep-me\n");
|
|
205
|
+
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("bootstrap-volta apply without ack blocked", () => {
|
|
209
|
+
const d = mkdtempSync(join(tmpdir(), "ops-ack-"));
|
|
210
|
+
const archives = makeArchives(d);
|
|
211
|
+
const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, archives.voltaSha, archives.nodeTar, archives.nodeSha, join(d, "volta"), "24.21.0", "nope"], { encoding: "utf8" });
|
|
212
|
+
assert.notEqual(p.status, 0);
|
|
213
|
+
rmSync(d, { recursive: true, force: true });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("bootstrap-volta sha mismatch blocked", () => {
|
|
217
|
+
const d = mkdtempSync(join(tmpdir(), "ops-sha-"));
|
|
218
|
+
const archives = makeArchives(d);
|
|
219
|
+
const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, "0".repeat(64), archives.nodeTar, archives.nodeSha, join(d, "volta"), "24.21.0", ACK], { encoding: "utf8" });
|
|
220
|
+
assert.notEqual(p.status, 0);
|
|
221
|
+
assert.ok(!existsSync(join(d, "volta", "bin", "ops-node")));
|
|
222
|
+
rmSync(d, { recursive: true, force: true });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
function cliFixture() {
|
|
226
|
+
const tmp = mkdtempSync(join(tmpdir(), "ops-bootcli-"));
|
|
227
|
+
const state = join(tmp, "controller");
|
|
228
|
+
const target = join(tmp, "server");
|
|
229
|
+
mkdirSync(target, { recursive: true });
|
|
230
|
+
const kh = join(tmp, "known_hosts");
|
|
231
|
+
writeFileSync(kh, "fixture pinned key\n");
|
|
232
|
+
const prevProbe = cliHooks.probeLocal;
|
|
233
|
+
cliHooks.probeLocal = () => ({ identity: agent.fingerprint(), scope: "mock" });
|
|
234
|
+
initialize(state, "control-a");
|
|
235
|
+
cliHooks.probeLocal = prevProbe;
|
|
236
|
+
const archives = makeArchives(tmp);
|
|
237
|
+
const prevPin = bootstrap.bootstrapHooks.loadPin;
|
|
238
|
+
bootstrap.bootstrapHooks.loadPin = () => pinFor(archives);
|
|
239
|
+
return {
|
|
240
|
+
tmp, state, target, kh, archives,
|
|
241
|
+
cleanup() {
|
|
242
|
+
bootstrap.bootstrapHooks.loadPin = prevPin;
|
|
243
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const missingProbe = {
|
|
249
|
+
status: "probed", uname: "Linux x86_64", arch: "linux-x64",
|
|
250
|
+
tools: { node: "missing", volta: "missing", tar: "/bin/tar", sha256sum: "/usr/bin/sha256sum" },
|
|
251
|
+
node_path: null, node_version: null, node_usable: false, profile_has_volta: false,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
test("discover probe without node returns node-missing", () => {
|
|
255
|
+
const fx = cliFixture();
|
|
256
|
+
const mock = mockTransport({ probeJson: missingProbe });
|
|
257
|
+
try {
|
|
258
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
|
|
259
|
+
const conn = writeConn(fx.tmp, host);
|
|
260
|
+
const r = captureMain(["probe", "--connection-file", conn]);
|
|
261
|
+
assert.equal(r.code, 2);
|
|
262
|
+
const payload = JSON.parse(r.stderr);
|
|
263
|
+
assert.equal(payload.status, "blocked");
|
|
264
|
+
assert.equal(payload.error, "node-missing");
|
|
265
|
+
assert.ok(String(payload.next).includes("bootstrap-node"));
|
|
266
|
+
assert.ok(!existsSync(join(fx.state, "hosts", "wsl-a")));
|
|
267
|
+
} finally { mock.restore(); fx.cleanup(); }
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("bootstrap-node probe is read-only", () => {
|
|
271
|
+
const fx = cliFixture();
|
|
272
|
+
const mock = mockTransport({ probeJson: missingProbe });
|
|
273
|
+
try {
|
|
274
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
|
|
275
|
+
const conn = writeConn(fx.tmp, host);
|
|
276
|
+
const r = captureMain(["bootstrap-node", "--connection-file", conn, "--probe"]);
|
|
277
|
+
assert.equal(r.code, 0, r.stderr);
|
|
278
|
+
const payload = JSON.parse(r.stdout);
|
|
279
|
+
assert.equal(payload.status, "probed");
|
|
280
|
+
assert.equal(payload.posix.tools.node, "missing");
|
|
281
|
+
assert.ok(!mock.log.some((e) => e.cmd === "scp"));
|
|
282
|
+
assert.ok(!mock.log.some((e) => String(e.args.at(-1) || "").includes("--apply")));
|
|
283
|
+
} finally { mock.restore(); fx.cleanup(); }
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("bootstrap-node apply without ack blocked", () => {
|
|
287
|
+
const fx = cliFixture();
|
|
288
|
+
const mock = mockTransport({ probeJson: missingProbe });
|
|
289
|
+
try {
|
|
290
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
|
|
291
|
+
const conn = writeConn(fx.tmp, host);
|
|
292
|
+
const r = captureMain([
|
|
293
|
+
"bootstrap-node", "--connection-file", conn, "--apply",
|
|
294
|
+
"--account", "ops", "--host-root", fx.target,
|
|
295
|
+
"--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha,
|
|
296
|
+
"--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
|
|
297
|
+
]);
|
|
298
|
+
assert.equal(r.code, 2);
|
|
299
|
+
assert.ok(r.stderr.includes("I-APPROVE-THIS-BOOTSTRAP"));
|
|
300
|
+
} finally { mock.restore(); fx.cleanup(); }
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("bootstrap-node sha mismatch blocked", () => {
|
|
304
|
+
const fx = cliFixture();
|
|
305
|
+
const mock = mockTransport({ probeJson: missingProbe });
|
|
306
|
+
try {
|
|
307
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
|
|
308
|
+
const conn = writeConn(fx.tmp, host);
|
|
309
|
+
const r = captureMain([
|
|
310
|
+
"bootstrap-node", "--connection-file", conn, "--apply",
|
|
311
|
+
"--account", "ops", "--host-root", fx.target, "--ack", ACK,
|
|
312
|
+
"--volta-archive", fx.archives.voltaTar, "--volta-sha256", "0".repeat(64),
|
|
313
|
+
"--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
|
|
314
|
+
]);
|
|
315
|
+
assert.equal(r.code, 2);
|
|
316
|
+
assert.ok(!mock.log.some((e) => String(e.args.at(-1) || "").includes("--apply")));
|
|
317
|
+
} finally { mock.restore(); fx.cleanup(); }
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("latest and curl-pipe names rejected", () => {
|
|
321
|
+
const fx = cliFixture();
|
|
322
|
+
const mock = mockTransport({ probeJson: missingProbe });
|
|
323
|
+
try {
|
|
324
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
|
|
325
|
+
const conn = writeConn(fx.tmp, host);
|
|
326
|
+
const r = captureMain([
|
|
327
|
+
"bootstrap-node", "--connection-file", conn, "--apply",
|
|
328
|
+
"--account", "ops", "--host-root", fx.target, "--ack", ACK,
|
|
329
|
+
"--node-version", "latest",
|
|
330
|
+
"--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha,
|
|
331
|
+
"--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
|
|
332
|
+
]);
|
|
333
|
+
assert.equal(r.code, 2);
|
|
334
|
+
assert.match(r.stderr, /latest/i);
|
|
335
|
+
} finally { mock.restore(); fx.cleanup(); }
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("bootstrap-node skips when node exists", () => {
|
|
339
|
+
const fx = cliFixture();
|
|
340
|
+
const mock = mockTransport({
|
|
341
|
+
probeJson: {
|
|
342
|
+
...missingProbe,
|
|
343
|
+
tools: { ...missingProbe.tools, node: "/usr/bin/node" },
|
|
344
|
+
node_path: "/usr/bin/node",
|
|
345
|
+
node_usable: true,
|
|
346
|
+
node_version: "v22.22.3",
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, node: "/usr/bin/node" });
|
|
351
|
+
const conn = writeConn(fx.tmp, host);
|
|
352
|
+
const r = captureMain([
|
|
353
|
+
"bootstrap-node", "--connection-file", conn, "--apply",
|
|
354
|
+
"--account", "ops", "--host-root", fx.target, "--ack", ACK,
|
|
355
|
+
"--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha,
|
|
356
|
+
"--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
|
|
357
|
+
]);
|
|
358
|
+
assert.equal(r.code, 0, r.stderr);
|
|
359
|
+
const payload = JSON.parse(r.stdout);
|
|
360
|
+
assert.equal(payload.status, "skipped-existing-node");
|
|
361
|
+
assert.ok(!mock.log.some((e) => e.cmd === "scp"));
|
|
362
|
+
} finally { mock.restore(); fx.cleanup(); }
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("bootstrap-node apply then enroll writes inventory and status", () => {
|
|
366
|
+
const fx = cliFixture();
|
|
367
|
+
const home = join(fx.tmp, "home");
|
|
368
|
+
mkdirSync(home);
|
|
369
|
+
writeFileSync(join(home, ".bashrc"), "keep-me\n");
|
|
370
|
+
const prevHome = process.env.HOME;
|
|
371
|
+
process.env.HOME = home;
|
|
372
|
+
const mock = mockTransport({ probeJson: missingProbe });
|
|
373
|
+
try {
|
|
374
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
|
|
375
|
+
const conn = writeConn(fx.tmp, host);
|
|
376
|
+
const applied = captureMain([
|
|
377
|
+
"bootstrap-node", "--state", fx.state, "--host-id", "wsl-a",
|
|
378
|
+
"--connection-file", conn, "--apply",
|
|
379
|
+
"--account", "ops", "--host-root", fx.target, "--ack", ACK,
|
|
380
|
+
"--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha,
|
|
381
|
+
"--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
|
|
382
|
+
]);
|
|
383
|
+
assert.equal(applied.code, 0, applied.stderr);
|
|
384
|
+
const boot = JSON.parse(applied.stdout);
|
|
385
|
+
assert.equal(boot.status, "installed");
|
|
386
|
+
assert.ok(boot.connection_node.endsWith("/ops-node"));
|
|
387
|
+
assert.equal(readFileSync(join(home, ".bashrc"), "utf8"), "keep-me\n");
|
|
388
|
+
assert.ok(existsSync(join(fx.state, "hosts", "wsl-a", "bootstrap")));
|
|
389
|
+
const statusBefore = model.load(fx.state);
|
|
390
|
+
assert.ok(!statusBefore.hosts["wsl-a"]);
|
|
391
|
+
|
|
392
|
+
host.connection.node = boot.connection_node;
|
|
393
|
+
host.identity = "discover";
|
|
394
|
+
const enrollFile = join(fx.tmp, "register.json");
|
|
395
|
+
writeFileSync(enrollFile, JSON.stringify({ hosts: [host], projects: [] }));
|
|
396
|
+
mock.restore();
|
|
397
|
+
const enrollMock = mockTransport({
|
|
398
|
+
probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: boot.connection_node }, node_path: boot.connection_node, node_usable: true },
|
|
399
|
+
});
|
|
400
|
+
try {
|
|
401
|
+
const enrolled = captureMain(["--state", fx.state, "enroll", "--file", enrollFile]);
|
|
402
|
+
assert.equal(enrolled.code, 0, enrolled.stderr + enrolled.stdout);
|
|
403
|
+
const result = JSON.parse(enrolled.stdout);
|
|
404
|
+
assert.equal(result.status, "enrolled");
|
|
405
|
+
assert.match(result.identity, /^[a-f0-9]{64}$/);
|
|
406
|
+
const snaps = readdirSync(join(fx.state, "hosts", "wsl-a", "inventory"));
|
|
407
|
+
assert.ok(snaps.some((n) => n.startsWith("snapshot-")));
|
|
408
|
+
const status = model.load(fx.state);
|
|
409
|
+
assert.ok(status.hosts["wsl-a"]);
|
|
410
|
+
assert.equal(status.hosts["wsl-a"].connection.node, boot.connection_node);
|
|
411
|
+
} finally { enrollMock.restore(); }
|
|
412
|
+
} finally {
|
|
413
|
+
process.env.HOME = prevHome;
|
|
414
|
+
mock.restore();
|
|
415
|
+
fx.cleanup();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("enroll without node blocked", () => {
|
|
420
|
+
const fx = cliFixture();
|
|
421
|
+
const mock = mockTransport({ probeJson: missingProbe });
|
|
422
|
+
try {
|
|
423
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, node: "/no/such/node" });
|
|
424
|
+
const enrollFile = join(fx.tmp, "register.json");
|
|
425
|
+
writeFileSync(enrollFile, JSON.stringify({ hosts: [host], projects: [] }));
|
|
426
|
+
const r = captureMain(["--state", fx.state, "enroll", "--file", enrollFile]);
|
|
427
|
+
assert.equal(r.code, 2);
|
|
428
|
+
const payload = JSON.parse(r.stderr);
|
|
429
|
+
assert.equal(payload.error, "node-missing");
|
|
430
|
+
const status = model.load(fx.state);
|
|
431
|
+
assert.deepEqual(Object.keys(status.hosts), []);
|
|
432
|
+
} finally { mock.restore(); fx.cleanup(); }
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("probe --connection-file still does not imply register", () => {
|
|
436
|
+
const fx = cliFixture();
|
|
437
|
+
const mock = mockTransport({
|
|
438
|
+
probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: process.execPath }, node_path: process.execPath, node_usable: true },
|
|
439
|
+
});
|
|
440
|
+
try {
|
|
441
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, node: process.execPath, identity: "discover" });
|
|
442
|
+
const conn = writeConn(fx.tmp, host);
|
|
443
|
+
const r = captureMain(["probe", "--connection-file", conn]);
|
|
444
|
+
assert.equal(r.code, 0, r.stderr);
|
|
445
|
+
const payload = JSON.parse(r.stdout);
|
|
446
|
+
assert.ok(payload.next.includes("enroll"));
|
|
447
|
+
const status = model.load(fx.state);
|
|
448
|
+
assert.deepEqual(Object.keys(status.hosts), []);
|
|
449
|
+
} finally { mock.restore(); fx.cleanup(); }
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("environmentSpec uses managed volta path", () => {
|
|
453
|
+
const tmp = mkdtempSync(join(tmpdir(), "ops-env-"));
|
|
454
|
+
const state = join(tmp, "controller");
|
|
455
|
+
const target = join(tmp, "server");
|
|
456
|
+
const prevProbe = cliHooks.probeLocal;
|
|
457
|
+
cliHooks.probeLocal = () => ({ identity: agent.fingerprint(), scope: "mock" });
|
|
458
|
+
initialize(state, "control-a");
|
|
459
|
+
cliHooks.probeLocal = prevProbe;
|
|
460
|
+
const host = {
|
|
461
|
+
host_id: "node-a", display_name: "Node A", platform: process.platform === "win32" ? "windows" : "linux",
|
|
462
|
+
transport: "local", root: target, identity: agent.fingerprint(), connection: {},
|
|
463
|
+
};
|
|
464
|
+
model.register(state, { hosts: [host], projects: [] });
|
|
465
|
+
const prev = recipes.hostRecipesHooks.call;
|
|
466
|
+
const voltaBin = core.targetJoin(host, "_host/toolchains/ops/volta/bin/volta");
|
|
467
|
+
recipes.hostRecipesHooks.call = (_h, req) => {
|
|
468
|
+
if (req.action === "snapshot") return { paths: { [req.paths[0]]: { kind: "file" } } };
|
|
469
|
+
return {
|
|
470
|
+
identity: host.identity,
|
|
471
|
+
tools: {
|
|
472
|
+
node: { status: "observed", version: "v24.21.0", path: process.execPath },
|
|
473
|
+
volta: { status: "missing", path: null },
|
|
474
|
+
},
|
|
475
|
+
defaults: { VOLTA_HOME: core.targetJoin(host, "_host/toolchains/ops/volta") },
|
|
476
|
+
};
|
|
477
|
+
};
|
|
478
|
+
try {
|
|
479
|
+
const spec = recipes.environmentSpec(state, "node-a", { account: "ops", node_version: "24.21.0" });
|
|
480
|
+
const install = spec.host_actions.find((a) => a.kind === "install-toolchain");
|
|
481
|
+
assert.equal(install.argv[0], voltaBin);
|
|
482
|
+
} finally {
|
|
483
|
+
recipes.hostRecipesHooks.call = prev;
|
|
484
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("environmentSpec still throws when volta truly absent", () => {
|
|
489
|
+
const tmp = mkdtempSync(join(tmpdir(), "ops-envmiss-"));
|
|
490
|
+
const state = join(tmp, "controller");
|
|
491
|
+
const target = join(tmp, "server");
|
|
492
|
+
const prevProbe = cliHooks.probeLocal;
|
|
493
|
+
cliHooks.probeLocal = () => ({ identity: agent.fingerprint(), scope: "mock" });
|
|
494
|
+
initialize(state, "control-a");
|
|
495
|
+
cliHooks.probeLocal = prevProbe;
|
|
496
|
+
const host = {
|
|
497
|
+
host_id: "node-a", display_name: "Node A", platform: process.platform === "win32" ? "windows" : "linux",
|
|
498
|
+
transport: "local", root: target, identity: agent.fingerprint(), connection: {},
|
|
499
|
+
};
|
|
500
|
+
model.register(state, { hosts: [host], projects: [] });
|
|
501
|
+
const prev = recipes.hostRecipesHooks.call;
|
|
502
|
+
recipes.hostRecipesHooks.call = (_h, req) => {
|
|
503
|
+
if (req.action === "snapshot") return { paths: { [req.paths[0]]: { kind: "absent" } } };
|
|
504
|
+
return {
|
|
505
|
+
identity: host.identity,
|
|
506
|
+
tools: {
|
|
507
|
+
node: { status: "observed", version: "v24.21.0", path: process.execPath },
|
|
508
|
+
volta: { status: "missing", path: null },
|
|
509
|
+
},
|
|
510
|
+
defaults: {},
|
|
511
|
+
};
|
|
512
|
+
};
|
|
513
|
+
try {
|
|
514
|
+
assert.throws(
|
|
515
|
+
() => recipes.environmentSpec(state, "node-a", { account: "ops", node_version: "24.21.0" }),
|
|
516
|
+
/bootstrap-node/,
|
|
517
|
+
);
|
|
518
|
+
} finally {
|
|
519
|
+
recipes.hostRecipesHooks.call = prev;
|
|
520
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
521
|
+
}
|
|
522
|
+
});
|