@namewta/speculo 1.0.20 → 1.0.21
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/D-project-deploy/D-project-deploy.md +1 -1
- package/template/workflows/ops/H-host-manage/H-host-manage.md +4 -0
- package/template/workflows/ops/H-host-manage/references/host-profiles.md +4 -0
- package/template/workflows/ops/I-initialize/I-initialize.md +1 -1
- package/template/workflows/ops/INDEX.md +2 -0
- package/template/workflows/ops/README.md +7 -3
- package/template/workflows/ops/S-server-connect/S-server-connect.md +5 -5
- package/template/workflows/ops/V-inventory-view/V-inventory-view.md +2 -0
- package/template/workflows/ops/common/CAPABILITIES.md +2 -0
- package/template/workflows/ops/common/USAGE.md +5 -5
- package/template/workflows/ops/common/WORKSPACE-USAGE.md +29 -2
- package/template/workflows/ops/common/examples/README.md +1 -1
- package/template/workflows/ops/common/examples/register.example.json +7 -3
- package/template/workflows/ops/common/examples/server-connection.example.json +11 -0
- package/template/workflows/ops/common/rules/deployment-layout.md +1 -1
- package/template/workflows/ops/common/rules/host-root-and-onboarding.md +47 -0
- package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
- package/template/workflows/ops/common/rules/research-and-decisions.md +8 -0
- package/template/workflows/ops/common/rules/server-onboarding.md +3 -3
- package/template/workflows/ops/common/rules/workspace-and-authorization.md +2 -2
- package/template/workflows/ops/common/schemas/fleet-view.schema.json +64 -0
- package/template/workflows/ops/common/schemas/host.schema.json +19 -0
- package/template/workflows/ops/common/schemas/plan.schema.json +57 -0
- package/template/workflows/ops/common/schemas/root-confirmation.schema.json +118 -0
- package/template/workflows/ops/common/schemas/server-connection.schema.json +67 -0
- package/template/workflows/ops/common/schemas/server-discovery.schema.json +171 -0
- package/template/workflows/ops/common/schemas/spec.schema.json +19 -0
- package/template/workflows/ops/common/schemas/status.schema.json +19 -0
- package/template/workflows/ops/common/templates/FLEET.html +1 -1
- package/template/workflows/ops/common/templates/HOST-README.md +3 -1
- package/template/workflows/ops/common/tests/test_ops.mjs +3 -3
- package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +30 -2
- package/template/workflows/ops/common/tests/test_ops_onboarding.mjs +225 -0
- package/template/workflows/ops/common/tools/demo-local.mjs +4 -0
- package/template/workflows/ops/common/tools/opslib/agent.mjs +26 -0
- package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +50 -24
- package/template/workflows/ops/common/tools/opslib/cli.mjs +1 -0
- package/template/workflows/ops/common/tools/opslib/core.mjs +1 -1
- package/template/workflows/ops/common/tools/opslib/docs.mjs +1 -0
- package/template/workflows/ops/common/tools/opslib/execution.mjs +4 -0
- package/template/workflows/ops/common/tools/opslib/fleet.mjs +26 -4
- package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +34 -0
- package/template/workflows/ops/common/tools/opslib/model.mjs +6 -0
- package/template/workflows/ops/common/tools/opslib/onboarding.mjs +215 -0
- package/template/workflows/ops/common/tools/opslib/planner.mjs +3 -0
- package/template/workflows/ops/common/tools/opslib/transport.mjs +7 -3
- package/template/workflows/ops/common/tools/opslib/workspace_cli.mjs +20 -1
- package/template/workflows/ops/common/tools/server-discover.sh +65 -0
- package/template/workflows/ops/common/tools/validate-ops.mjs +1 -1
- package/template/workflows/ops/manifest.json +12 -5
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/** S-owned account discovery and explicit root confirmation. No target writes. */
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, posix, win32 } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { digest, identifier, newId, now, noSymlinks, OpsError, rootPath, withLock, writeJson } from "./core.mjs";
|
|
6
|
+
import { load, validate } from "./model.mjs";
|
|
7
|
+
import { call, posixCall, validateSshEndpoint } from "./transport.mjs";
|
|
8
|
+
import { readBoundedJson, statePath } from "./workspace.mjs";
|
|
9
|
+
|
|
10
|
+
const SCRIPT = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../server-discover.sh"), "utf8").replace(/\r\n?/g, "\n");
|
|
11
|
+
export const onboardingHooks = { observe: observeServer };
|
|
12
|
+
const pathApi = (platform) => platform === "windows" ? win32 : posix;
|
|
13
|
+
const clean = (s) => typeof s === "string" && s.trim().length > 0 && s.length <= 4096 && !/[\x00-\x1f\x7f]/.test(s);
|
|
14
|
+
|
|
15
|
+
export function endpointDigest(host) {
|
|
16
|
+
const c = { ...(host.connection || {}) };
|
|
17
|
+
// Runtime selection may change after a pinned Node bootstrap, not SSH identity/trust.
|
|
18
|
+
delete c.node;
|
|
19
|
+
if (host.transport === "ssh") {
|
|
20
|
+
validateSshEndpoint(c);
|
|
21
|
+
noSymlinks(c.known_hosts, { allowMissing: false });
|
|
22
|
+
}
|
|
23
|
+
return digest({ platform: host.platform, transport: host.transport, connection: c,
|
|
24
|
+
known_hosts_digest: host.transport === "ssh" ? digest(readFileSync(c.known_hosts)) : null });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseLinuxDiscovery(stdout, withRoot = false) {
|
|
28
|
+
const values = {};
|
|
29
|
+
const keys = ["machine", "system", "username", "uid", "home", "node", "node_version",
|
|
30
|
+
...(withRoot ? ["root_exists", "root_writable", "root_nonempty", "root_owner"] : [])];
|
|
31
|
+
for (const line of String(stdout).trimEnd().split(/\r?\n/)) {
|
|
32
|
+
const match = /^([a-z_]+)=((?:[a-f0-9]{2})*)$/.exec(line);
|
|
33
|
+
if (!match || !keys.includes(match[1]) || Object.hasOwn(values, match[1])) throw new OpsError("invalid Linux discovery response");
|
|
34
|
+
values[match[1]] = Buffer.from(match[2], "hex").toString("utf8");
|
|
35
|
+
}
|
|
36
|
+
if (Object.keys(values).length !== keys.length || values.system !== "Linux" || !clean(values.machine)) throw new OpsError("incomplete Linux identity discovery");
|
|
37
|
+
const facts = { identity: digest({ machine: values.machine.trim(), platform: "Linux" }), platform: "linux",
|
|
38
|
+
username: values.username, uid: values.uid, home: values.home, node: values.node || null, node_version: values.node_version || null };
|
|
39
|
+
if (withRoot) {
|
|
40
|
+
for (const key of ["root_exists", "root_writable", "root_nonempty"]) if (!["true", "false"].includes(values[key])) throw new OpsError("invalid root observation");
|
|
41
|
+
let owner = null;
|
|
42
|
+
if (values.root_owner) { try { owner = JSON.parse(values.root_owner); } catch { throw new OpsError("invalid root owner marker"); } }
|
|
43
|
+
facts.root_check = { exists: values.root_exists === "true", writable: values.root_writable === "true", nonempty: values.root_nonempty === "true", owner };
|
|
44
|
+
}
|
|
45
|
+
return facts;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function observeServer(host, root = null) {
|
|
49
|
+
if (host.transport === "ssh" && host.platform === "linux") {
|
|
50
|
+
const result = posixCall(host.connection, SCRIPT, { sudo: false, args: root ? [root] : [], timeout: 30 });
|
|
51
|
+
return parseLinuxDiscovery(result.stdout, Boolean(root));
|
|
52
|
+
}
|
|
53
|
+
if (host.transport === "ssh" && !host.connection?.node) throw new OpsError("non-Linux SSH discovery requires an existing Node executable");
|
|
54
|
+
return call({ ...host, connection: host.transport === "ssh" ? { ...host.connection, sudo: false } : {} },
|
|
55
|
+
{ action: "onboarding", identity: null, root }, { timeout: 30 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateFacts(host, facts) {
|
|
59
|
+
if (!facts || facts.platform !== host.platform || !/^[a-f0-9]{64}$/.test(facts.identity || "")
|
|
60
|
+
|| !clean(facts.username) || !clean(facts.home) || !pathApi(host.platform).isAbsolute(facts.home)) throw new OpsError("reliable login account/home/identity unavailable");
|
|
61
|
+
if (host.platform !== "windows" && !/^\d+$/.test(facts.uid)) throw new OpsError("reliable login uid unavailable");
|
|
62
|
+
if (host.transport === "ssh") {
|
|
63
|
+
const accountMatches = host.platform === "windows" ? facts.username.toLowerCase() === host.connection.username.toLowerCase() : facts.username === host.connection.username;
|
|
64
|
+
if (!accountMatches) throw new OpsError("observed login account differs from SSH username");
|
|
65
|
+
}
|
|
66
|
+
return facts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function assertUniqueMachine(registry, host) {
|
|
70
|
+
const peers = Object.values(registry.hosts).filter((h) => h.identity === host.identity);
|
|
71
|
+
if (peers.length > 1) throw new OpsError("ambiguous legacy machine roots; preserve evidence and resolve the affected Host explicitly");
|
|
72
|
+
if (peers.length && peers[0].host_id !== host.host_id) throw new OpsError("machine already registered; reuse Host " + peers[0].host_id + " and its root " + peers[0].root);
|
|
73
|
+
return peers[0] || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function folder(state, id) { identifier(id, "server"); return join(state, "records", "servers", id, "onboarding"); }
|
|
77
|
+
function artifactPath(state, id, rid) { identifier(rid, "receipt"); return join(folder(state, id), rid + ".json"); }
|
|
78
|
+
function persist(state, id, rid, value, schema) {
|
|
79
|
+
validate(value, schema);
|
|
80
|
+
const path = artifactPath(state, id, rid);
|
|
81
|
+
writeJson(path, value, { exclusive: true });
|
|
82
|
+
if (digest(readBoundedJson(path)) !== digest(value)) throw new OpsError("onboarding receipt readback mismatch");
|
|
83
|
+
return { receipt_id: rid, digest: digest(value) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertSelectedPath(host, facts, root) {
|
|
87
|
+
const normalized = rootPath(root, host.platform);
|
|
88
|
+
if (!clean(root) || normalized !== root || (host.platform !== "windows" && root.includes("\\"))) throw new OpsError("selected root must be a normalized absolute path without control characters");
|
|
89
|
+
const p = pathApi(host.platform);
|
|
90
|
+
const home = p.normalize(facts.home);
|
|
91
|
+
if (host.platform === "windows" ? home.toLowerCase() === root.toLowerCase() : home === root) throw new OpsError("select a dedicated child, not the login home itself");
|
|
92
|
+
const system = host.platform === "windows" ? ["C:\\Windows", "C:\\Program Files", "C:\\Program Files (x86)"] : ["/etc", "/usr", "/bin", "/sbin", "/dev", "/proc", "/sys", "/boot"];
|
|
93
|
+
for (const base of system) {
|
|
94
|
+
const rel = p.relative(base, root);
|
|
95
|
+
if (rel === "" || (!rel.startsWith("..") && !p.isAbsolute(rel))) throw new OpsError("system directory cannot contain a managed root");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function assertRootObservation(host, facts, { legacy = false } = {}) {
|
|
100
|
+
const c = facts.root_check;
|
|
101
|
+
if (!c || typeof c.exists !== "boolean" || typeof c.nonempty !== "boolean" || c.writable !== true) throw new OpsError("selected root or nearest parent is not writable/searchable by the login account; provision permissions explicitly");
|
|
102
|
+
if (c.owner && (c.owner.host_id !== host.host_id || c.owner.identity !== facts.identity)) throw new OpsError("root ownership conflict");
|
|
103
|
+
if (!legacy && c.nonempty && !c.owner) throw new OpsError("nonempty unowned root; choose a dedicated empty directory or use a reviewed adoption plan");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function discoverServer(state, serverId, endpoint) {
|
|
107
|
+
statePath(state); identifier(serverId);
|
|
108
|
+
const host = { host_id: serverId, platform: endpoint.platform, transport: endpoint.transport, connection: endpoint.connection || {} };
|
|
109
|
+
validate({ platform: host.platform, transport: host.transport, connection: host.connection }, "server-connection");
|
|
110
|
+
const registry = load(state);
|
|
111
|
+
if (!registry.controller) throw new OpsError("initialize the controller before connecting a Host");
|
|
112
|
+
if (!["linux", "windows", "darwin"].includes(host.platform) || !["local", "ssh"].includes(host.transport)) throw new OpsError("explicit platform and transport required");
|
|
113
|
+
if (host.transport === "local" && Object.keys(host.connection).length) throw new OpsError("local connection must be empty");
|
|
114
|
+
const before = endpointDigest(host);
|
|
115
|
+
const facts = validateFacts(host, onboardingHooks.observe(host));
|
|
116
|
+
const previous = assertUniqueMachine(registry, { ...host, identity: facts.identity });
|
|
117
|
+
if (registry.hosts[serverId] && !previous) throw new OpsError("registered machine identity drift");
|
|
118
|
+
if (before !== endpointDigest(host)) throw new OpsError("SSH trust changed during discovery");
|
|
119
|
+
const id = newId("discovery");
|
|
120
|
+
const record = { schema_version: 1, artifact: "ops-server-discovery", discovery_id: id, host_id: serverId,
|
|
121
|
+
discovered_at: now(), endpoint: host, endpoint_digest: before, facts,
|
|
122
|
+
suggested_root: previous?.root ?? pathApi(host.platform).join(facts.home, "ops"), registered: Boolean(previous) };
|
|
123
|
+
const ref = persist(state, serverId, id, record, "server-discovery");
|
|
124
|
+
return { status: previous ? "discovered-existing-host" : "discovered-not-confirmed", discovery: ref, ...record,
|
|
125
|
+
next: previous ? "Reuse the registered root; connection changes require S verification and an exact approved plan." : "Ask the user to specify or accept the suggested root, then server-root-confirm." };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function confirmServerRoot(state, serverId, discoveryId, root, by, statement) {
|
|
129
|
+
statePath(state);
|
|
130
|
+
if (!clean(by) || !clean(statement)) throw new OpsError("actual user confirmation by/statement required");
|
|
131
|
+
const discovery = readBoundedJson(artifactPath(state, serverId, discoveryId));
|
|
132
|
+
validate(discovery, "server-discovery");
|
|
133
|
+
if (discovery.host_id !== serverId || discovery.discovery_id !== discoveryId) throw new OpsError("discovery identity mismatch");
|
|
134
|
+
const host = discovery.endpoint;
|
|
135
|
+
assertSelectedPath(host, discovery.facts, root);
|
|
136
|
+
if (endpointDigest(host) !== discovery.endpoint_digest) throw new OpsError("SSH endpoint/trust changed; rediscover");
|
|
137
|
+
const facts = validateFacts(host, onboardingHooks.observe(host, root));
|
|
138
|
+
for (const k of ["identity", "username", "uid", "home"]) if (facts[k] !== discovery.facts[k]) throw new OpsError("login account/home/identity drift; rediscover");
|
|
139
|
+
return withLock(join(state, ".locks", "catalog"), { operation: "server-root-confirm" }, () => {
|
|
140
|
+
const registry = load(state);
|
|
141
|
+
const previous = assertUniqueMachine(registry, { ...host, identity: facts.identity });
|
|
142
|
+
if (registry.hosts[serverId] && !previous) throw new OpsError("registered machine identity drift");
|
|
143
|
+
if (previous && previous.root !== root) throw new OpsError("registered root is immutable; explicit migration required");
|
|
144
|
+
assertRootObservation(host, facts, { legacy: Boolean(previous && !previous.root_confirmation) });
|
|
145
|
+
if (endpointDigest(host) !== discovery.endpoint_digest) throw new OpsError("SSH trust changed during root confirmation");
|
|
146
|
+
const id = newId("root");
|
|
147
|
+
const record = { schema_version: 1, artifact: "ops-root-confirmation", receipt_id: id, host_id: serverId,
|
|
148
|
+
platform: host.platform, transport: host.transport, identity: facts.identity, root,
|
|
149
|
+
account: { username: facts.username, uid: facts.uid, home: facts.home }, endpoint_digest: discovery.endpoint_digest,
|
|
150
|
+
discovery: { receipt_id: discoveryId, digest: digest(discovery) }, confirmed_at: now(), by, statement };
|
|
151
|
+
const ref = persist(state, serverId, id, record, "root-confirmation");
|
|
152
|
+
return { status: "root-confirmed", host_id: serverId, root, identity: facts.identity, root_confirmation: ref,
|
|
153
|
+
note: "Local confirmation evidence only; no directory created or software installed." };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function validateRootConfirmation(state, host, { fresh = false, freshLegacy = false, require = false, checkEndpoint = false } = {}) {
|
|
158
|
+
const registry = load(state);
|
|
159
|
+
const previous = registry.hosts[host.host_id];
|
|
160
|
+
const ref = host.root_confirmation;
|
|
161
|
+
if (!ref) {
|
|
162
|
+
if (require || !previous || previous.identity !== host.identity || previous.root !== host.root || previous.root_confirmation) throw new OpsError("new Host requires server-root-confirm evidence before registration or target writes");
|
|
163
|
+
if (freshLegacy) {
|
|
164
|
+
const facts = validateFacts(host, onboardingHooks.observe(host, host.root));
|
|
165
|
+
if (facts.identity !== host.identity) throw new OpsError("legacy target machine identity drift");
|
|
166
|
+
assertRootObservation(host, facts, { legacy: true });
|
|
167
|
+
}
|
|
168
|
+
return null; // Existing v3 root remains authoritative; never fabricate retrospective consent.
|
|
169
|
+
}
|
|
170
|
+
assertUniqueMachine(registry, host);
|
|
171
|
+
const record = readBoundedJson(artifactPath(state, host.host_id, ref.receipt_id));
|
|
172
|
+
validate(record, "root-confirmation");
|
|
173
|
+
if (digest(record) !== ref.digest || record.receipt_id !== ref.receipt_id
|
|
174
|
+
|| ["host_id", "identity", "platform", "transport", "root"].some((k) => host[k] !== record[k])) throw new OpsError("root confirmation digest/target mismatch");
|
|
175
|
+
const discovery = readBoundedJson(artifactPath(state, host.host_id, record.discovery.receipt_id));
|
|
176
|
+
validate(discovery, "server-discovery");
|
|
177
|
+
if (digest(discovery) !== record.discovery.digest) throw new OpsError("discovery evidence digest mismatch");
|
|
178
|
+
if (checkEndpoint && record.endpoint_digest !== endpointDigest(host)) throw new OpsError("confirmed SSH endpoint/trust drift; rediscover");
|
|
179
|
+
if (fresh) {
|
|
180
|
+
const facts = validateFacts(host, onboardingHooks.observe(host, host.root));
|
|
181
|
+
if (facts.identity !== host.identity) throw new OpsError("target machine identity drift");
|
|
182
|
+
// A reviewed connection-account update keeps the original root and confirmation.
|
|
183
|
+
if (endpointDigest(host) === record.endpoint_digest && ["username", "uid", "home"].some((k) => facts[k] !== record.account[k])) throw new OpsError("login account/home drift; rediscover and review the connection");
|
|
184
|
+
assertSelectedPath(host, facts, host.root);
|
|
185
|
+
assertRootObservation(host, facts);
|
|
186
|
+
}
|
|
187
|
+
return record;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function confirmedBootstrapHost(state, raw, serverId, root) {
|
|
191
|
+
if (!state || !serverId) throw new OpsError("bootstrap apply requires --state and --host-id with root confirmation evidence");
|
|
192
|
+
statePath(state);
|
|
193
|
+
const previous = load(state).hosts[serverId];
|
|
194
|
+
const ref = raw.root_confirmation ?? previous?.root_confirmation;
|
|
195
|
+
if (!ref) {
|
|
196
|
+
if (!previous || previous.root !== root || previous.platform !== "linux" || previous.transport !== "ssh") throw new OpsError("bootstrap requires server-root-confirm evidence");
|
|
197
|
+
const host = { ...previous, connection: raw.connection || raw };
|
|
198
|
+
if (endpointDigest(previous) !== endpointDigest(host)) throw new OpsError("legacy bootstrap connection changes require S verification first");
|
|
199
|
+
assertUniqueMachine(load(state), host);
|
|
200
|
+
validateRootConfirmation(state, host, { freshLegacy: true });
|
|
201
|
+
return host;
|
|
202
|
+
}
|
|
203
|
+
const receipt = readBoundedJson(artifactPath(state, serverId, ref.receipt_id));
|
|
204
|
+
const host = { host_id: serverId, platform: "linux", transport: "ssh", root,
|
|
205
|
+
identity: receipt.identity, connection: raw.connection || raw, root_confirmation: ref };
|
|
206
|
+
if (raw.identity && raw.identity !== "discover" && raw.identity !== receipt.identity) throw new OpsError("bootstrap machine identity mismatch");
|
|
207
|
+
validateRootConfirmation(state, host, { require: true, fresh: true, checkEndpoint: true });
|
|
208
|
+
return host;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function rootSummary(state, host) {
|
|
212
|
+
if (!host.root_confirmation) return { status: "legacy-registered", login_home: null, confirmed_at: null };
|
|
213
|
+
const r = validateRootConfirmation(state, host);
|
|
214
|
+
return { status: "confirmed", login_home: r.account.home, confirmed_at: r.confirmed_at };
|
|
215
|
+
}
|
|
@@ -12,6 +12,7 @@ import { allocationOperation } from "./services.mjs";
|
|
|
12
12
|
import { credentialRefs, planReport, remotePaths } from "./docs.mjs";
|
|
13
13
|
import { taskFiles } from "./native_windows.mjs";
|
|
14
14
|
import { engineDigest } from "./execution.mjs";
|
|
15
|
+
import { validateRootConfirmation } from "./onboarding.mjs";
|
|
15
16
|
import { assertSafeControlPath, defaultFileMode, reservedHostDocumentPaths } from "./control_files.mjs";
|
|
16
17
|
|
|
17
18
|
export const plannerHooks = { call: transportCall };
|
|
@@ -246,6 +247,7 @@ export function compilePlan(state, specPath) {
|
|
|
246
247
|
if (previous == null || fixed.some((k) => previous[k] !== item[k])) {
|
|
247
248
|
throw new OpsError("resource update cannot change identity/root/kind; create explicit migration resources");
|
|
248
249
|
}
|
|
250
|
+
if (group === "hosts" && digest(previous.root_confirmation ?? null) !== digest(item.root_confirmation ?? null)) throw new OpsError("root confirmation cannot be removed or replaced through resource updates");
|
|
249
251
|
after[group][item[idkey]] = structuredClone(item);
|
|
250
252
|
}
|
|
251
253
|
}
|
|
@@ -668,6 +670,7 @@ export function compilePlan(state, specPath) {
|
|
|
668
670
|
const snapshots = {};
|
|
669
671
|
const docPreconditions = {};
|
|
670
672
|
for (const [hid, host] of Object.entries(selected)) {
|
|
673
|
+
validateRootConfirmation(state, host, { fresh: true });
|
|
671
674
|
const paths = new Set(ops.filter((o) => o.host_id === hid && ["write", "quarantine", "purge-quarantine"].includes(o.kind)).map((o) => o.path));
|
|
672
675
|
for (const did of uniqueDocs) {
|
|
673
676
|
if (after.deployments[did].host_id === hid) for (const p of remotePaths(after, after.deployments[did])) paths.add(p);
|
|
@@ -75,12 +75,12 @@ function buffers(p) {
|
|
|
75
75
|
return { stdout, stderr };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
export function posixCall(endpoint, script, { timeout = 180, args = [], sudo
|
|
78
|
+
export function posixCall(endpoint, script, { timeout = 180, args = [], sudo } = {}) {
|
|
79
79
|
validateSshEndpoint(endpoint);
|
|
80
80
|
if ((endpoint.shell ?? "posix") === "powershell") {
|
|
81
81
|
throw new OpsError("POSIX bootstrap is Linux-only; PowerShell targets still require an existing Node");
|
|
82
82
|
}
|
|
83
|
-
const useSudo = sudo
|
|
83
|
+
const useSudo = sudo ?? endpoint.sudo;
|
|
84
84
|
let remote = ["/bin/sh", "-s", "--", ...args.map(String)];
|
|
85
85
|
if (useSudo) remote = ["sudo", "-n", "--", ...remote];
|
|
86
86
|
const argv = sshArgv(endpoint);
|
|
@@ -155,7 +155,11 @@ export function call(host, request, { timeout = 1800 } = {}) {
|
|
|
155
155
|
const c = host.connection;
|
|
156
156
|
const argv = sshArgv(c);
|
|
157
157
|
let remote = [c.node, "--input-type=module"];
|
|
158
|
-
|
|
158
|
+
// New confirmed roots and their owner/lock files belong to the login account.
|
|
159
|
+
// Running first lock as root would make later unprivileged discovery unreadable.
|
|
160
|
+
// Legacy Hosts retain their existing privilege behavior.
|
|
161
|
+
const useSudo = c.sudo && !(host.root_confirmation && ["lock", "unlock"].includes(request.action));
|
|
162
|
+
if (useSudo) remote = ["sudo", "-n", "--", ...remote];
|
|
159
163
|
let remoteCommand;
|
|
160
164
|
if ((c.shell ?? "posix") === "powershell") {
|
|
161
165
|
if (c.sudo) throw new OpsError("sudo not valid for PowerShell transport");
|
|
@@ -5,9 +5,16 @@ import { readBoundedJson, readRegistry, route, statePath } from "./workspace.mjs
|
|
|
5
5
|
import { checkServer } from "./server_checks.mjs";
|
|
6
6
|
import { generateFleet } from "./fleet.mjs";
|
|
7
7
|
import { authorizeTask, prepareTask, runTask } from "./tasks.mjs";
|
|
8
|
+
import { discoverServer, confirmServerRoot } from "./onboarding.mjs";
|
|
9
|
+
import { initializeHostSpec, connectionSpec } from "./host_recipes.mjs";
|
|
10
|
+
import { writeJson } from "./core.mjs";
|
|
8
11
|
|
|
9
|
-
export const COMMANDS = Object.freeze(["workspace-help", "route", "server-check", "fleet", "task-plan", "task-authorize", "task-run"]);
|
|
12
|
+
export const COMMANDS = Object.freeze(["workspace-help", "route", "server-discover", "server-root-confirm", "server-initialize-spec", "server-connection-spec", "server-check", "fleet", "task-plan", "task-authorize", "task-run"]);
|
|
10
13
|
const options = {
|
|
14
|
+
"server-discover": ["server", "connection-file"],
|
|
15
|
+
"server-root-confirm": ["server", "discovery", "root", "by", "statement"],
|
|
16
|
+
"server-initialize-spec": ["server", "output"],
|
|
17
|
+
"server-connection-spec": ["server", "connection-file", "output"],
|
|
11
18
|
route: ["scope", "server", "project"],
|
|
12
19
|
"server-check": ["server", "profile", "min-free-mib", "min-memory-mib"],
|
|
13
20
|
fleet: ["stale-hours"], "task-plan": ["file"],
|
|
@@ -59,6 +66,18 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
59
66
|
load(state);
|
|
60
67
|
let result;
|
|
61
68
|
switch (args.command) {
|
|
69
|
+
case "server-discover": result = discoverServer(state, required(args, "server"), readBoundedJson(required(args, "connection-file"))); break;
|
|
70
|
+
case "server-root-confirm": result = confirmServerRoot(state, required(args, "server"), required(args, "discovery"), required(args, "root"), required(args, "by"), required(args, "statement")); break;
|
|
71
|
+
case "server-initialize-spec":
|
|
72
|
+
case "server-connection-spec": {
|
|
73
|
+
const output = required(args, "output");
|
|
74
|
+
if (!isAbsolute(output)) throw new OpsError("--output must be absolute");
|
|
75
|
+
const spec = args.command === "server-initialize-spec" ? initializeHostSpec(state, required(args, "server"))
|
|
76
|
+
: connectionSpec(state, required(args, "server"), readBoundedJson(required(args, "connection-file")));
|
|
77
|
+
writeJson(output, spec, { exclusive: true });
|
|
78
|
+
result = { status: "generated-not-executed", spec_path: output, next: "plan -> exact approve -> apply; root selection is not installation authorization" };
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
62
81
|
case "route": result = route(readRegistry(state), { scope: required(args, "scope"), server_ids: list(args.server), project_ids: list(args.project) }); break;
|
|
63
82
|
case "server-check": result = await checkServer(state, required(args, "server"), { profile: args.profile ?? "base",
|
|
64
83
|
minFreeMiB: integer(args["min-free-mib"], 1024), minMemoryMiB: integer(args["min-memory-mib"], 256) }); break;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Read-only Linux account and root inspection. Never sudo, mkdir or install here.
|
|
3
|
+
set -eu
|
|
4
|
+
[ "$(uname -s)" = Linux ] || { echo 'Linux discovery adapter required' >&2; exit 2; }
|
|
5
|
+
emit() { printf '%s=' "$1"; printf '%s' "$2" | od -An -v -tx1 | tr -d ' \n'; printf '\n'; }
|
|
6
|
+
account=$(id -un)
|
|
7
|
+
uid=$(id -u)
|
|
8
|
+
if command -v getent >/dev/null 2>&1; then
|
|
9
|
+
record=$(getent passwd "$uid")
|
|
10
|
+
else
|
|
11
|
+
record=$(awk -F: -v uid="$uid" '$3 == uid { print }' /etc/passwd)
|
|
12
|
+
fi
|
|
13
|
+
[ "$(printf '%s\n' "$record" | wc -l | tr -d ' ')" = 1 ] || { echo 'Ambiguous account database entry' >&2; exit 2; }
|
|
14
|
+
[ "$(printf '%s' "$record" | cut -d: -f1)" = "$account" ] || { echo 'Account database mismatch' >&2; exit 2; }
|
|
15
|
+
home=$(printf '%s' "$record" | cut -d: -f6)
|
|
16
|
+
case "$home" in /*) ;; *) echo 'Account home is not absolute' >&2; exit 2;; esac
|
|
17
|
+
machine=''
|
|
18
|
+
for p in /etc/machine-id /var/lib/dbus/machine-id; do
|
|
19
|
+
if [ -r "$p" ]; then machine=$(cat "$p"); [ -z "$machine" ] || break; fi
|
|
20
|
+
done
|
|
21
|
+
[ -n "$machine" ] || { echo 'Stable machine identity unavailable' >&2; exit 2; }
|
|
22
|
+
node=$(command -v node 2>/dev/null || true)
|
|
23
|
+
version=''
|
|
24
|
+
if [ -n "$node" ]; then version=$("$node" --version 2>/dev/null || true); fi
|
|
25
|
+
emit machine "$machine"
|
|
26
|
+
emit system Linux
|
|
27
|
+
emit username "$account"
|
|
28
|
+
emit uid "$uid"
|
|
29
|
+
emit home "$home"
|
|
30
|
+
emit node "$node"
|
|
31
|
+
emit node_version "$version"
|
|
32
|
+
root=${1-}
|
|
33
|
+
if [ -n "$root" ]; then
|
|
34
|
+
cursor=$root
|
|
35
|
+
while :; do
|
|
36
|
+
[ ! -L "$cursor" ] || { echo 'Symlink root traversal rejected' >&2; exit 2; }
|
|
37
|
+
if [ -e "$cursor" ] && [ ! -d "$cursor" ]; then echo 'Root ancestor is not a directory' >&2; exit 2; fi
|
|
38
|
+
[ "$cursor" != / ] || break
|
|
39
|
+
cursor=$(dirname -- "$cursor")
|
|
40
|
+
done
|
|
41
|
+
cursor=$root
|
|
42
|
+
while [ ! -e "$cursor" ]; do cursor=$(dirname -- "$cursor"); done
|
|
43
|
+
writable=false
|
|
44
|
+
if [ -w "$cursor" ] && [ -x "$cursor" ]; then writable=true; fi
|
|
45
|
+
present=false
|
|
46
|
+
nonempty=false
|
|
47
|
+
owner=''
|
|
48
|
+
if [ -d "$root" ]; then
|
|
49
|
+
present=true
|
|
50
|
+
[ -r "$root" ] || { echo 'Selected root cannot be inspected by the login account' >&2; exit 2; }
|
|
51
|
+
entries=$(find "$root" -mindepth 1 -maxdepth 1 -print -quit) || { echo 'Selected root inspection failed' >&2; exit 2; }
|
|
52
|
+
if [ -n "$entries" ]; then nonempty=true; fi
|
|
53
|
+
for rel in .ops-host.json _host _runtime docs knowledge README.md DEPLOYMENTS.md; do
|
|
54
|
+
[ ! -L "$root/$rel" ] || { echo 'Managed path is a symlink' >&2; exit 2; }
|
|
55
|
+
done
|
|
56
|
+
if [ -e "$root/.ops-host.json" ]; then
|
|
57
|
+
[ -f "$root/.ops-host.json" ] && [ "$(wc -c < "$root/.ops-host.json")" -le 16384 ] || { echo 'Invalid root owner marker' >&2; exit 2; }
|
|
58
|
+
owner=$(cat "$root/.ops-host.json")
|
|
59
|
+
fi
|
|
60
|
+
fi
|
|
61
|
+
emit root_exists "$present"
|
|
62
|
+
emit root_writable "$writable"
|
|
63
|
+
emit root_nonempty "$nonempty"
|
|
64
|
+
emit root_owner "$owner"
|
|
65
|
+
fi
|
|
@@ -26,7 +26,7 @@ try {
|
|
|
26
26
|
const text = readFileSync(join(root, id, id + ".md"), "utf8");
|
|
27
27
|
if (!text.includes("type: workflow-entry") || !text.includes("读取范围") || !text.includes("id: ops/" + id.slice(2))) throw new Error("invalid work " + id);
|
|
28
28
|
}
|
|
29
|
-
for (const file of ["INDEX.md", "README.md", "manifest.json", "runtime-contract.json", "common/USAGE.md", "common/WORKSPACE-USAGE.md", "common/CAPABILITIES.md", "common/templates/FLEET.html", "common/schemas/fleet-view.schema.json", "common/schemas/task-request.schema.json", "common/schemas/server-check.schema.json", "common/tools/opslib/execution.mjs", "common/tools/opslib/tasks.mjs", "common/tools/opslib/fleet.mjs"]) {
|
|
29
|
+
for (const file of ["INDEX.md", "README.md", "manifest.json", "runtime-contract.json", "common/USAGE.md", "common/WORKSPACE-USAGE.md", "common/CAPABILITIES.md", "common/templates/FLEET.html", "common/schemas/fleet-view.schema.json", "common/schemas/task-request.schema.json", "common/schemas/server-check.schema.json", "common/tools/opslib/execution.mjs", "common/tools/opslib/tasks.mjs", "common/tools/opslib/fleet.mjs", "common/tools/opslib/onboarding.mjs", "common/tools/server-discover.sh", "common/schemas/server-discovery.schema.json", "common/schemas/root-confirmation.schema.json", "common/rules/host-root-and-onboarding.md"]) {
|
|
30
30
|
if (!existsSync(join(root, file))) throw new Error("missing " + file);
|
|
31
31
|
}
|
|
32
32
|
for (const name of readdirSync(join(root, "common/schemas")).filter((x) => x.endsWith(".json"))) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
3
|
"id": "ops",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.3.0",
|
|
5
5
|
"persistent_knowledge": [
|
|
6
6
|
"<Path>{roots.state}/ops/knowledge/</Path>",
|
|
7
7
|
"<Path>{roots.state}/ops/hosts/{host_id}/knowledge/</Path>",
|
|
@@ -30,19 +30,24 @@
|
|
|
30
30
|
"inputs": [
|
|
31
31
|
"server",
|
|
32
32
|
"verified-host-key",
|
|
33
|
-
"key-or-agent"
|
|
33
|
+
"key-or-agent",
|
|
34
|
+
"user-selected-persistence-root"
|
|
34
35
|
],
|
|
35
36
|
"outputs": [
|
|
36
37
|
"server-registration",
|
|
37
38
|
"inventory",
|
|
38
|
-
"server-checks"
|
|
39
|
+
"server-checks",
|
|
40
|
+
"login-account-discovery",
|
|
41
|
+
"root-confirmation"
|
|
39
42
|
],
|
|
40
43
|
"risk": "production-critical",
|
|
41
44
|
"context_budget": 20000
|
|
42
45
|
},
|
|
43
46
|
{
|
|
44
47
|
"id": "host-manage",
|
|
45
|
-
"after": [
|
|
48
|
+
"after": [
|
|
49
|
+
"server-connect"
|
|
50
|
+
],
|
|
46
51
|
"inputs": [
|
|
47
52
|
"server",
|
|
48
53
|
"spec",
|
|
@@ -59,7 +64,9 @@
|
|
|
59
64
|
},
|
|
60
65
|
{
|
|
61
66
|
"id": "project-deploy",
|
|
62
|
-
"after": [
|
|
67
|
+
"after": [
|
|
68
|
+
"host-manage"
|
|
69
|
+
],
|
|
63
70
|
"inputs": [
|
|
64
71
|
"project",
|
|
65
72
|
"explicit-servers",
|