@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Strict host-key SSH and subprocess local transport. No passwords in argv. */
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { canonical, digest, noSymlinks, OpsError, redact, UnknownResult } from "./core.mjs";
|
|
7
7
|
import { fingerprint } from "./agent.mjs";
|
|
@@ -30,6 +30,102 @@ export function hostTransportDigest(host) {
|
|
|
30
30
|
return digest({ host, known_hosts_digest: kh });
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export function validateSshEndpoint(c) {
|
|
34
|
+
if (!c || typeof c !== "object") throw new OpsError("ssh endpoint missing");
|
|
35
|
+
for (const k of ["hostname", "username", "known_hosts"]) {
|
|
36
|
+
if (!c[k]) throw new OpsError("ssh connection missing " + k);
|
|
37
|
+
}
|
|
38
|
+
if (!/^[A-Za-z0-9_.:-]+$/.test(c.hostname) || c.hostname.startsWith("-")) throw new OpsError("unsafe SSH hostname");
|
|
39
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(c.username) || c.username.startsWith("-")) throw new OpsError("unsafe SSH username");
|
|
40
|
+
if (![undefined, "posix", "powershell"].includes(c.shell)) throw new OpsError("unsupported remote shell");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function sshArgv(connection, { scp = false } = {}) {
|
|
44
|
+
validateSshEndpoint(connection);
|
|
45
|
+
const kh = resolve(connection.known_hosts);
|
|
46
|
+
noSymlinks(kh, { allowMissing: false });
|
|
47
|
+
const argv = [
|
|
48
|
+
scp ? "scp" : "ssh",
|
|
49
|
+
...(scp ? [] : ["-T"]),
|
|
50
|
+
"-o", "BatchMode=yes",
|
|
51
|
+
"-o", "StrictHostKeyChecking=yes",
|
|
52
|
+
"-o", `UserKnownHostsFile=${kh}`,
|
|
53
|
+
"-o", "ConnectTimeout=15",
|
|
54
|
+
"-o", "ServerAliveInterval=15",
|
|
55
|
+
"-o", "ServerAliveCountMax=3",
|
|
56
|
+
scp ? "-P" : "-p", String(connection.port ?? 22),
|
|
57
|
+
];
|
|
58
|
+
if (connection.identity_file) argv.push("-i", connection.identity_file, "-o", "IdentitiesOnly=yes");
|
|
59
|
+
return argv;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function spawnTransport(argv, { input, timeout, encoding = "buffer" } = {}) {
|
|
63
|
+
return transportHooks.spawnSync(argv[0], argv.slice(1), {
|
|
64
|
+
input,
|
|
65
|
+
encoding,
|
|
66
|
+
timeout: timeout * 1000,
|
|
67
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
68
|
+
windowsHide: true,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buffers(p) {
|
|
73
|
+
const stdout = Buffer.isBuffer(p.stdout) ? p.stdout.toString("utf8") : (p.stdout || "");
|
|
74
|
+
const stderr = Buffer.isBuffer(p.stderr) ? p.stderr.toString("utf8") : (p.stderr || "");
|
|
75
|
+
return { stdout, stderr };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function posixCall(endpoint, script, { timeout = 180, args = [], sudo = false } = {}) {
|
|
79
|
+
validateSshEndpoint(endpoint);
|
|
80
|
+
if ((endpoint.shell ?? "posix") === "powershell") {
|
|
81
|
+
throw new OpsError("POSIX bootstrap is Linux-only; PowerShell targets still require an existing Node");
|
|
82
|
+
}
|
|
83
|
+
const useSudo = sudo || endpoint.sudo;
|
|
84
|
+
let remote = ["/bin/sh", "-s", "--", ...args.map(String)];
|
|
85
|
+
if (useSudo) remote = ["sudo", "-n", "--", ...remote];
|
|
86
|
+
const argv = sshArgv(endpoint);
|
|
87
|
+
argv.push("--", endpoint.username + "@" + endpoint.hostname, shlexJoin(remote));
|
|
88
|
+
let p;
|
|
89
|
+
try {
|
|
90
|
+
p = spawnTransport(argv, { input: Buffer.from(String(script), "utf8"), timeout });
|
|
91
|
+
} catch (e) {
|
|
92
|
+
throw new OpsError("target posix unavailable: " + e);
|
|
93
|
+
}
|
|
94
|
+
if (p.error && (p.error.code === "ETIMEDOUT" || p.signal === "SIGTERM")) {
|
|
95
|
+
throw new OpsError("target posix unavailable: " + p.error);
|
|
96
|
+
}
|
|
97
|
+
const { stdout, stderr } = buffers(p);
|
|
98
|
+
if (p.status !== 0) {
|
|
99
|
+
throw new OpsError("target posix failed: " + redact(stderr, []).slice(-2000));
|
|
100
|
+
}
|
|
101
|
+
return { status: p.status, stdout, stderr };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function posixSend(endpoint, localFile, remotePath, { timeout = 180 } = {}) {
|
|
105
|
+
validateSshEndpoint(endpoint);
|
|
106
|
+
if ((endpoint.shell ?? "posix") === "powershell") {
|
|
107
|
+
throw new OpsError("POSIX bootstrap is Linux-only; PowerShell targets still require an existing Node");
|
|
108
|
+
}
|
|
109
|
+
noSymlinks(localFile, { allowMissing: false });
|
|
110
|
+
if (typeof remotePath !== "string" || !isAbsolute(remotePath) || remotePath.split("/").includes("..") || /[\s\n\r]/.test(remotePath)) {
|
|
111
|
+
throw new OpsError("remote scp path must be absolute without spaces or ..");
|
|
112
|
+
}
|
|
113
|
+
const argv = sshArgv(endpoint, { scp: true });
|
|
114
|
+
argv.push("--", localFile, `${endpoint.username}@${endpoint.hostname}:${remotePath}`);
|
|
115
|
+
let p;
|
|
116
|
+
try {
|
|
117
|
+
p = spawnTransport(argv, { input: Buffer.alloc(0), timeout });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
throw new OpsError("target scp unavailable: " + e);
|
|
120
|
+
}
|
|
121
|
+
if (p.error && (p.error.code === "ETIMEDOUT" || p.signal === "SIGTERM")) {
|
|
122
|
+
throw new OpsError("target scp unavailable: " + p.error);
|
|
123
|
+
}
|
|
124
|
+
const { stderr } = buffers(p);
|
|
125
|
+
if (p.status !== 0) throw new OpsError("target scp failed: " + redact(stderr, []).slice(-2000));
|
|
126
|
+
return { status: 0, remotePath };
|
|
127
|
+
}
|
|
128
|
+
|
|
33
129
|
function injectRequest(source, request) {
|
|
34
130
|
const b64 = Buffer.from(canonical(request)).toString("base64");
|
|
35
131
|
const assign = `globalThis.OPS_REQUEST = JSON.parse(Buffer.from(${JSON.stringify(b64)}, "base64").toString("utf8"));`;
|
|
@@ -49,13 +145,7 @@ export function call(host, request, { timeout = 1800 } = {}) {
|
|
|
49
145
|
const argv = [process.execPath, "--input-type=module"];
|
|
50
146
|
let p;
|
|
51
147
|
try {
|
|
52
|
-
p =
|
|
53
|
-
input: content,
|
|
54
|
-
encoding: "utf8",
|
|
55
|
-
timeout: timeout * 1000,
|
|
56
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
57
|
-
windowsHide: true,
|
|
58
|
-
});
|
|
148
|
+
p = spawnTransport(argv, { input: content, timeout, encoding: "utf8" });
|
|
59
149
|
} catch (e) {
|
|
60
150
|
if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport interrupted; inspect receipts before retry");
|
|
61
151
|
throw new OpsError("target read unavailable: " + e);
|
|
@@ -63,11 +153,7 @@ export function call(host, request, { timeout = 1800 } = {}) {
|
|
|
63
153
|
return decode(p, request);
|
|
64
154
|
}
|
|
65
155
|
const c = host.connection;
|
|
66
|
-
const
|
|
67
|
-
noSymlinks(kh, { allowMissing: false });
|
|
68
|
-
const argv = ["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${kh}`,
|
|
69
|
-
"-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", "-p", String(c.port ?? 22)];
|
|
70
|
-
if (c.identity_file) argv.push("-i", c.identity_file, "-o", "IdentitiesOnly=yes");
|
|
156
|
+
const argv = sshArgv(c);
|
|
71
157
|
let remote = [c.node, "--input-type=module"];
|
|
72
158
|
if (c.sudo) remote = ["sudo", "-n", "--", ...remote];
|
|
73
159
|
let remoteCommand;
|
|
@@ -82,13 +168,7 @@ export function call(host, request, { timeout = 1800 } = {}) {
|
|
|
82
168
|
argv.push("--", c.username + "@" + c.hostname, remoteCommand);
|
|
83
169
|
let p;
|
|
84
170
|
try {
|
|
85
|
-
p =
|
|
86
|
-
input: Buffer.from(content, "utf8"),
|
|
87
|
-
encoding: "buffer",
|
|
88
|
-
timeout: timeout * 1000,
|
|
89
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
90
|
-
windowsHide: true,
|
|
91
|
-
});
|
|
171
|
+
p = spawnTransport(argv, { input: Buffer.from(content, "utf8"), timeout });
|
|
92
172
|
} catch (e) {
|
|
93
173
|
if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport interrupted; inspect receipts before retry");
|
|
94
174
|
throw new OpsError("target read unavailable: " + e);
|
|
@@ -101,8 +181,7 @@ function decode(p, request) {
|
|
|
101
181
|
if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport interrupted; inspect receipts before retry");
|
|
102
182
|
throw new OpsError("target read unavailable: " + p.error);
|
|
103
183
|
}
|
|
104
|
-
const
|
|
105
|
-
const stderr = Buffer.isBuffer(p.stderr) ? p.stderr.toString("utf8") : (p.stderr || "");
|
|
184
|
+
const { stdout, stderr } = buffers(p);
|
|
106
185
|
if (p.status !== 0) {
|
|
107
186
|
const text = redact(stderr, request.secrets || []).slice(-2000);
|
|
108
187
|
if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport failed: " + text);
|
|
@@ -27,6 +27,7 @@ try {
|
|
|
27
27
|
for (const name of readdirSync(join(root, "common/schemas")).filter((x) => x.endsWith(".json"))) {
|
|
28
28
|
JSON.parse(readFileSync(join(root, "common/schemas", name), "utf8"));
|
|
29
29
|
}
|
|
30
|
+
JSON.parse(readFileSync(join(root, "common/toolchains/volta-linux.json"), "utf8"));
|
|
30
31
|
validateStatus(JSON.parse(readFileSync(join(root, "_state/status.json"), "utf8")));
|
|
31
32
|
console.log("resource schema valid");
|
|
32
33
|
console.log(JSON.stringify({
|