@namewta/speculo 1.0.16 → 1.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -5
- package/dist/src/agent-files.d.ts +9 -0
- package/dist/src/agent-files.js +106 -0
- package/dist/src/agent-files.js.map +1 -0
- package/dist/src/cli.js +64 -14
- package/dist/src/cli.js.map +1 -1
- package/dist/src/doctor.d.ts +2 -0
- package/dist/src/doctor.js +120 -53
- package/dist/src/doctor.js.map +1 -1
- package/dist/src/external-files.d.ts +22 -0
- package/dist/src/external-files.js +110 -0
- package/dist/src/external-files.js.map +1 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +29 -182
- package/dist/src/index.js.map +1 -1
- package/dist/src/paths.d.ts +12 -0
- package/dist/src/paths.js +57 -0
- package/dist/src/paths.js.map +1 -0
- package/dist/src/transaction.d.ts +27 -0
- package/dist/src/transaction.js +187 -0
- package/dist/src/transaction.js.map +1 -0
- package/dist/src/utils.js +4 -2
- package/dist/src/utils.js.map +1 -1
- package/dist/src/workflows.js +3 -4
- package/dist/src/workflows.js.map +1 -1
- package/package.json +6 -4
- package/template/.speculo/README.md +9 -3
- package/template/.speculo/refresh-contract.json +6 -2
- package/template/AGENTS.md +1 -1
- package/template/commands/docs-sync.md +13 -11
- package/template/skills/archive-and-consolidate/SKILL.md +2 -4
- package/template/skills/docs-sync/SKILL.md +22 -11
- package/template/skills/docs-sync/references/agents-contract.md +6 -4
- package/template/skills/docs-sync/references/document-lifecycle-contract.md +2 -0
- package/template/skills/docs-sync/references/entry-procedure.md +2 -17
- package/template/skills/docs-sync/references/git-state-contract.md +1 -1
- package/template/skills/docs-sync/references/mode-contract.md +21 -0
- package/template/skills/docs-sync/references/workflow-scope-contract.md +5 -3
- package/template/skills/engineering-standards-builder/SKILL.md +3 -1
- package/template/skills/engineering-standards-builder/scripts/validate-builder.mjs +5 -1
- package/template/skills/git-history-squash/SKILL.md +3 -3
- package/template/skills/github-npm-ops/SKILL.md +2 -4
- package/template/skills/source-code-zip/SKILL.md +0 -1
- package/template/skills/speculo-retro/SKILL.md +2 -4
- package/template/skills/upstream-fork-sync/SKILL.md +1 -3
- package/template/skills/writing-great-skills/SKILL.md +3 -3
- package/template/workflows/learning/A-archive/A-archive.md +2 -0
- package/template/workflows/learning/A-assess-and-plan/A-assess-and-plan.md +2 -0
- package/template/workflows/learning/C-consolidate/C-consolidate.md +2 -0
- package/template/workflows/learning/G-goal/G-goal.md +6 -0
- package/template/workflows/learning/G-goal/references/external-goal-runner.md +4 -0
- package/template/workflows/learning/H-homework/H-homework.md +2 -0
- package/template/workflows/learning/L-lesson/L-lesson.md +2 -0
- package/template/workflows/learning/Q-question/Q-question.md +2 -0
- package/template/workflows/learning/R-review/R-review.md +2 -0
- package/template/workflows/learning/README.md +4 -53
- package/template/workflows/learning/common/rules/artifact-layouts.md +46 -0
- package/template/workflows/learning/common/rules/consolidation-contract.md +5 -0
- package/template/workflows/learning/common/rules/homework-contract.md +3 -0
- package/template/workflows/learning/common/rules/lesson-contract.md +7 -0
- package/template/workflows/learning/common/skills/socratic-questioning/SKILL.md +1 -0
- package/template/workflows/learning/common/skills/topic-synthesis/SKILL.md +1 -0
- package/template/workflows/ops/D-project-deploy/D-project-deploy.md +11 -9
- package/template/workflows/ops/H-host-manage/H-host-manage.md +11 -10
- package/template/workflows/ops/I-initialize/I-initialize.md +7 -7
- package/template/workflows/ops/INDEX.md +20 -16
- package/template/workflows/ops/README.md +34 -12
- package/template/workflows/ops/S-server-connect/S-server-connect.md +28 -0
- package/template/workflows/ops/V-inventory-view/V-inventory-view.md +30 -0
- package/template/workflows/ops/_state/status.json +2 -1
- package/template/workflows/ops/common/CAPABILITIES.md +22 -15
- package/template/workflows/ops/common/USAGE.md +4 -0
- package/template/workflows/ops/common/WORKSPACE-USAGE.md +78 -0
- package/template/workflows/ops/common/examples/task-host.example.json +64 -0
- package/template/workflows/ops/common/rules/activation-and-memory.md +5 -3
- package/template/workflows/ops/common/rules/inventory-and-records.md +55 -0
- package/template/workflows/ops/common/rules/persistence-and-secrets.md +4 -2
- package/template/workflows/ops/common/rules/research-and-decisions.md +18 -0
- package/template/workflows/ops/common/rules/server-onboarding.md +21 -0
- package/template/workflows/ops/common/rules/workspace-and-authorization.md +39 -0
- package/template/workflows/ops/common/schemas/fleet-view.schema.json +487 -0
- package/template/workflows/ops/common/schemas/plan.schema.json +4 -0
- package/template/workflows/ops/common/schemas/server-check.schema.json +95 -0
- package/template/workflows/ops/common/schemas/spec.schema.json +4 -0
- package/template/workflows/ops/common/schemas/status.schema.json +4 -0
- package/template/workflows/ops/common/schemas/task-request.schema.json +138 -0
- package/template/workflows/ops/common/templates/FLEET.html +61 -0
- package/template/workflows/ops/common/tests/test_ops.mjs +33 -2
- package/template/workflows/ops/common/tests/test_ops_workspace.mjs +246 -0
- package/template/workflows/ops/common/tools/ops.mjs +5 -3
- package/template/workflows/ops/common/tools/opslib/core.mjs +1 -1
- package/template/workflows/ops/common/tools/opslib/docs.mjs +12 -7
- package/template/workflows/ops/common/tools/opslib/fleet.mjs +155 -0
- package/template/workflows/ops/common/tools/opslib/planner.mjs +7 -1
- package/template/workflows/ops/common/tools/opslib/server_checks.mjs +149 -0
- package/template/workflows/ops/common/tools/opslib/tasks.mjs +313 -0
- package/template/workflows/ops/common/tools/opslib/workspace.mjs +107 -0
- package/template/workflows/ops/common/tools/opslib/workspace_cli.mjs +80 -0
- package/template/workflows/ops/common/tools/validate-ops.mjs +11 -8
- package/template/workflows/ops/manifest.json +43 -7
- package/template/workflows/specdev/common/rules/path-reference-contract.md +6 -0
- package/template/workflows/specdev/common/skills/code-review/SKILL.md +3 -1
- package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +1 -0
- package/template/workflows/specdev/common/skills/plan-quality-review/SKILL.md +3 -1
- package/template/workflows/specdev/common/skills/research/SKILL.md +3 -1
- package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +1 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Allowlisted offline inventory. Never reads credentials, env, outboxes or raw task specifications. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { atomicWrite, digest, identifier, noSymlinks, OpsError, redact, withLock, writeJson } from "./core.mjs";
|
|
7
|
+
import { catalogUnlocked, ids, readBoundedJson, readRegistry, statePath, targetDigest } from "./workspace.mjs";
|
|
8
|
+
import { latestCheck, readiness } from "./server_checks.mjs";
|
|
9
|
+
|
|
10
|
+
const TEMPLATE = join(dirname(fileURLToPath(import.meta.url)), "../../templates/FLEET.html");
|
|
11
|
+
const text = (v) => typeof v === "string" ? redact(v.replace(/[\u0000-\u001f\u007f]/g, " "), []).slice(0, 500) : "";
|
|
12
|
+
const time = (v) => typeof v === "string" && Number.isFinite(Date.parse(v)) ? new Date(v).toISOString() : null;
|
|
13
|
+
const sorted = (map) => Object.keys(map).sort().map((id) => map[id]);
|
|
14
|
+
const knownStatus = new Set(["planned", "running", "configured", "docs_pending", "completed", "failed", "unknown", "retired", "blocked", "partial", "prepared", "authorized", "interrupted", "incomplete", "needs-inspection", "executing", "planned-not-authorized"]);
|
|
15
|
+
const status = (v) => knownStatus.has(v) ? v : "unknown";
|
|
16
|
+
const md = (v) => text(String(v ?? "—")).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("|", "|").replaceAll("`", "`").replaceAll("[", "[").replaceAll("]", "]");
|
|
17
|
+
|
|
18
|
+
function taskSummaries(state) {
|
|
19
|
+
const root = join(state, "records", "tasks");
|
|
20
|
+
noSymlinks(root);
|
|
21
|
+
if (!existsSync(root)) return [];
|
|
22
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
23
|
+
if (entries.length > 10000) throw new OpsError("task index exceeds 10000 entries");
|
|
24
|
+
return entries.sort((a, b) => a.name.localeCompare(b.name)).map((ent) => {
|
|
25
|
+
identifier(ent.name, "task_id");
|
|
26
|
+
if (!ent.isDirectory() || ent.isSymbolicLink()) throw new OpsError("invalid task index entry");
|
|
27
|
+
const folder = join(root, ent.name), summary = join(folder, "summary.json");
|
|
28
|
+
// Incomplete preparation stays visible without reading possibly secret-bearing task.json.
|
|
29
|
+
if (!existsSync(summary)) return { task_id: ent.name, scope: "unknown", server_ids: [], project_ids: [], status: "incomplete", updated_at: null, run_ids: [] };
|
|
30
|
+
const s = readBoundedJson(summary);
|
|
31
|
+
if (s.schema_version !== 1 || s.artifact !== "ops-task-summary" || s.task_id !== ent.name || !["server", "project"].includes(s.scope)) throw new OpsError("invalid task summary");
|
|
32
|
+
ids(s.server_ids, "server_ids"); ids(s.project_ids, "project_ids");
|
|
33
|
+
const result = ["result.json", "progress.json"].map((f) => join(folder, f)).find((p) => { noSymlinks(p); return existsSync(p); });
|
|
34
|
+
const r = result ? readBoundedJson(result) : null;
|
|
35
|
+
if (r && (r.artifact !== "ops-task-result" || r.task_id !== ent.name || !Array.isArray(r.steps))) throw new OpsError("invalid task result");
|
|
36
|
+
for (const f of ["started.json", "authorization.json", "task.json"]) noSymlinks(join(folder, f));
|
|
37
|
+
let st = r ? status(r.status) : existsSync(join(folder, "started.json")) ? "needs-inspection"
|
|
38
|
+
: existsSync(join(folder, "authorization.json")) ? "authorized" : existsSync(join(folder, "task.json")) ? "prepared" : "incomplete";
|
|
39
|
+
// A running record is not proof that a live process still owns the task.
|
|
40
|
+
if (st === "running") st = "needs-inspection";
|
|
41
|
+
const runIds = (r?.steps || []).map((step) => identifier(step.run_id, "run_id"));
|
|
42
|
+
return { task_id: s.task_id, scope: s.scope, server_ids: s.server_ids, project_ids: s.project_ids,
|
|
43
|
+
status: st, updated_at: time(r?.updated_at ?? s.created_at), run_ids: [...new Set(runIds)] };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function projectFleet(registry, checks = {}, tasks = [], { at = new Date(), staleHours = 24 } = {}) {
|
|
48
|
+
if (!Number.isFinite(at.getTime()) || !Number.isFinite(staleHours) || staleHours <= 0 || staleHours > 8760) throw new OpsError("invalid inventory time/staleness policy");
|
|
49
|
+
const deployments = sorted(registry.deployments).map((d) => ({
|
|
50
|
+
deployment_id: d.deployment_id, project_id: d.project_id, server_id: d.host_id,
|
|
51
|
+
environment: text(d.environment), instance: text(d.instance), method: text(d.method), root: text(d.root),
|
|
52
|
+
status: status(d.status), desired_version: text(d.version), observed_version: d.observed_version == null ? null : text(d.observed_version),
|
|
53
|
+
updated_at: time(d.updated_at), run_id: d.run_id ? identifier(d.run_id, "run_id") : null,
|
|
54
|
+
}));
|
|
55
|
+
const byServer = new Map(), byProject = new Map();
|
|
56
|
+
for (const d of deployments) {
|
|
57
|
+
if (!byServer.has(d.server_id)) byServer.set(d.server_id, []);
|
|
58
|
+
if (!byProject.has(d.project_id)) byProject.set(d.project_id, []);
|
|
59
|
+
byServer.get(d.server_id).push(d); byProject.get(d.project_id).push(d);
|
|
60
|
+
}
|
|
61
|
+
const servers = sorted(registry.hosts).map((h) => {
|
|
62
|
+
const c = checks[h.host_id];
|
|
63
|
+
let checkStatus = "unknown";
|
|
64
|
+
if (c) {
|
|
65
|
+
try { if (targetDigest(h) === c.host_digest) checkStatus = readiness(c.checks, c.checked_at, { at, staleHours }); }
|
|
66
|
+
catch { /* A changed/missing pinned host key is not evidence of readiness. */ }
|
|
67
|
+
}
|
|
68
|
+
const own = byServer.get(h.host_id) || [];
|
|
69
|
+
return { server_id: h.host_id, name: text(h.display_name), platform: text(h.platform), transport: h.transport,
|
|
70
|
+
address: h.transport === "ssh" ? text(h.connection.hostname) : "local (explicit)", port: h.transport === "ssh" ? h.connection.port ?? 22 : null,
|
|
71
|
+
root: text(h.root), readiness: checkStatus, profile: c?.profile ?? null, checked_at: time(c?.checked_at),
|
|
72
|
+
checks: (c?.checks || []).map((v) => ({ id: v.id, status: v.status, required: v.required })),
|
|
73
|
+
project_ids: [...new Set(own.filter((d) => d.status !== "retired").map((d) => d.project_id))].sort(),
|
|
74
|
+
deployment_ids: own.map((d) => d.deployment_id),
|
|
75
|
+
host_services: (h.host_services || []).map((s) => ({ service_id: identifier(s.id, "host service"), kind: text(s.kind), unit: text(s.unit), port: Number.isInteger(s.listen_port) ? s.listen_port : null })),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
const projects = sorted(registry.projects).map((p) => {
|
|
79
|
+
const own = byProject.get(p.project_id) || [];
|
|
80
|
+
return { project_id: p.project_id, name: text(p.display_name ?? p.project_id), kind: text(p.kind), service_type: text(p.service_type),
|
|
81
|
+
server_ids: [...new Set(own.filter((d) => d.status !== "retired").map((d) => d.server_id))].sort(), deployment_ids: own.map((d) => d.deployment_id) };
|
|
82
|
+
});
|
|
83
|
+
const bindings = sorted(registry.bindings).map((b) => {
|
|
84
|
+
if (!registry.deployments[b.consumer_deployment_id] || (b.provider_deployment_id && !registry.deployments[b.provider_deployment_id])) throw new OpsError("orphan binding cannot be projected");
|
|
85
|
+
return { binding_id: b.binding_id, consumer_deployment_id: b.consumer_deployment_id,
|
|
86
|
+
provider_deployment_id: b.provider_deployment_id ?? null, mode: text(b.mode), status: text(b.status) };
|
|
87
|
+
});
|
|
88
|
+
const releases = sorted(registry.releases).map((r) => ({ run_id: identifier(r.run_id, "run_id"), worker: text(r.worker), status: status(r.status),
|
|
89
|
+
server_ids: ids(r.host_ids || [], "release hosts"), deployment_ids: ids(r.deployment_ids || [], "release deployments"), updated_at: time(r.updated_at) }));
|
|
90
|
+
return { schema_version: 1, artifact: "ops-fleet-view", generated_at: at.toISOString(), controller_id: registry.controller.controller_id,
|
|
91
|
+
source_revision: registry.revision, source_digest: digest(registry), stale_hours: staleHours,
|
|
92
|
+
notice: "ready 只表示指定 profile 的必需检查项通过;completed 是历史执行结果。未测、过期或身份变化不能视为生产验收通过。",
|
|
93
|
+
servers, projects, deployments, bindings, releases, tasks };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function renderMarkdown(data, html = "index.html", json = "inventory.json") {
|
|
97
|
+
const table = (headers, rows) => `| ${headers.join(" | ")} |\n| ${headers.map(() => "---").join(" | ")} |\n` + (rows.length ? rows.map((r) => `| ${r.map(md).join(" | ")} |`).join("\n") : `| ${headers.map((_, i) => i ? "—" : "暂无记录").join(" | ")} |`) + "\n";
|
|
98
|
+
return `# OPS 服务器与部署清单\n\n[打开离线看板](${html}) · [结构化快照](${json})\n\n生成:${data.generated_at};资源修订:${data.source_revision};控制端:${data.controller_id}。\n\n这是离线记录,不是实时监控。ready 仅代表指定检查 profile 的必需项通过;completed 是历史执行结果。未测、过期、身份变化不视为正常。\n\n## 服务器 → 项目\n\n` +
|
|
99
|
+
table(["服务器", "名称 / 地址", "系统", "检查 / profile", "检查时间", "项目", "主机级服务"], data.servers.map((s) => [s.server_id, `${s.name} / ${s.address}`, s.platform, `${s.readiness} / ${s.profile ?? "未检查"}`, s.checked_at, s.project_ids.join(", "), s.host_services.map((v) => v.service_id).join(", ")])) +
|
|
100
|
+
`\n## 项目 → 服务器\n\n` + table(["项目", "名称", "类别", "服务器", "部署实例"], data.projects.map((p) => [p.project_id, p.name, p.kind, p.server_ids.join(", "), p.deployment_ids.join(", ")])) +
|
|
101
|
+
`\n## 部署实例\n\n` + table(["部署", "项目", "服务器", "环境", "记录状态", "计划版本", "验证版本", "根目录"], data.deployments.map((d) => [d.deployment_id, d.project_id, d.server_id, d.environment, d.status, d.desired_version, d.observed_version, d.root])) +
|
|
102
|
+
`\n## 任务记录\n\n` + table(["任务", "范围", "状态", "服务器", "Run"], data.tasks.map((t) => [t.task_id, t.scope, t.status, t.server_ids.join(", "), t.run_ids.join(", ")])) +
|
|
103
|
+
`\n## 原始 Run / Release 索引\n\n` + table(["Run", "Work", "记录状态", "服务器", "更新时间"], data.releases.map((r) => [r.run_id, r.worker, r.status, r.server_ids.join(", "), r.updated_at])) +
|
|
104
|
+
`\n原始不可覆盖证据仍位于 hosts/<host_id>/runs/<run_id>/ 或 releases/<run_id>/;通过 ops.mjs inspect-run 核对。此视图不移动、不替代执行回执,不导出 env、凭据账本或配置镜像。\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function renderHtml(data, template = readFileSync(TEMPLATE, "utf8")) {
|
|
108
|
+
template = template.replace(/\r\n?/g, "\n");
|
|
109
|
+
for (const marker of ["{{FLEET_DATA}}", "{{SCRIPT_CSP}}", "{{STYLE_CSP}}"]) if (template.split(marker).length !== 2) throw new OpsError(`HTML template must contain one ${marker}`);
|
|
110
|
+
const payload = JSON.stringify(data).replace(/[<>&\u2028\u2029]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
111
|
+
let html = template.replace("{{FLEET_DATA}}", () => payload);
|
|
112
|
+
const scripts = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)].filter((m) => !m[0].startsWith('<script type="application/json"'));
|
|
113
|
+
const styles = [...html.matchAll(/<style>([\s\S]*?)<\/style>/g)];
|
|
114
|
+
if (scripts.length !== 1 || styles.length !== 1) throw new OpsError("offline template requires one audited inline script and style");
|
|
115
|
+
const csp = (s) => "sha256-" + createHash("sha256").update(s).digest("base64");
|
|
116
|
+
html = html.replace("{{SCRIPT_CSP}}", csp(scripts[0][1])).replace("{{STYLE_CSP}}", csp(styles[0][1]));
|
|
117
|
+
return html;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function generateFleet(state, { at = new Date(), staleHours = 24 } = {}) {
|
|
121
|
+
statePath(state);
|
|
122
|
+
return withLock(join(state, "records", ".locks", "views"), { operation: "fleet" }, () => {
|
|
123
|
+
catalogUnlocked(state);
|
|
124
|
+
const registry = readRegistry(state);
|
|
125
|
+
const checks = Object.fromEntries(sorted(registry.hosts).map((h) => [h.host_id, latestCheck(state, h)]));
|
|
126
|
+
const data = projectFleet(registry, checks, taskSummaries(state), { at, staleHours });
|
|
127
|
+
const html = renderHtml(data), markdown = renderMarkdown(data);
|
|
128
|
+
// Snapshot identity includes renderer bytes, so a template update cannot collide with an older view.
|
|
129
|
+
const viewId = "view-" + digest({ data, html, markdown }).slice(0, 32);
|
|
130
|
+
const folder = join(state, "records", "views", viewId);
|
|
131
|
+
const files = { "inventory.json": JSON.stringify(data, null, 2) + "\n", "index.html": html, "FLEET.md": markdown };
|
|
132
|
+
const receipt = { schema_version: 1, artifact: "ops-fleet-receipt", view_id: viewId, source_digest: data.source_digest,
|
|
133
|
+
files: Object.fromEntries(Object.entries(files).map(([name, body]) => [name, digest(Buffer.from(body))])) };
|
|
134
|
+
noSymlinks(folder);
|
|
135
|
+
if (existsSync(folder)) {
|
|
136
|
+
const old = readBoundedJson(join(folder, "manifest.json"));
|
|
137
|
+
if (digest(old) !== digest(receipt)) throw new OpsError("existing view receipt differs; preserve evidence");
|
|
138
|
+
} else {
|
|
139
|
+
for (const [name, body] of Object.entries(files)) atomicWrite(join(folder, name), body, 0o600, { exclusive: true });
|
|
140
|
+
writeJson(join(folder, "manifest.json"), receipt, { exclusive: true });
|
|
141
|
+
}
|
|
142
|
+
for (const [name, expected] of Object.entries(receipt.files)) {
|
|
143
|
+
const path = join(folder, name); noSymlinks(path, { allowMissing: false });
|
|
144
|
+
if (digest(readFileSync(path)) !== expected) throw new OpsError("view readback mismatch; previous landing page preserved");
|
|
145
|
+
}
|
|
146
|
+
catalogUnlocked(state);
|
|
147
|
+
if (digest(readRegistry(state)) !== data.source_digest) throw new OpsError("catalog drift during rendering; previous landing page preserved");
|
|
148
|
+
const prefix = `records/views/${viewId}/`;
|
|
149
|
+
// Publish one landing page last; JSON + HTML always belong to the same immutable generation.
|
|
150
|
+
atomicWrite(join(state, "FLEET.md"), renderMarkdown(data, prefix + "index.html", prefix + "inventory.json"));
|
|
151
|
+
return { status: "generated", view_id: viewId, markdown: join(state, "FLEET.md"), html: join(folder, "index.html"),
|
|
152
|
+
json: join(folder, "inventory.json"), source_revision: data.source_revision, servers: data.servers.length, projects: data.projects.length,
|
|
153
|
+
note: "Local restricted snapshot only; no server connection, no live health claim, no credential export" };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -237,6 +237,7 @@ export function compilePlan(state, specPath) {
|
|
|
237
237
|
const current = load(state);
|
|
238
238
|
if (current.controller == null) throw new OpsError("initialize the controller first");
|
|
239
239
|
const after = structuredClone(current);
|
|
240
|
+
if (spec.plaintext_documentation !== undefined) after.policies.plaintext_documentation = spec.plaintext_documentation;
|
|
240
241
|
const ledger = ledgerLoad(state);
|
|
241
242
|
const revisions = spec.resource_updates || {};
|
|
242
243
|
for (const [group, idkey, fixed] of [["hosts", "host_id", ["host_id", "root", "identity", "platform"]], ["projects", "project_id", ["project_id", "kind", "service_type"]]]) {
|
|
@@ -277,7 +278,12 @@ export function compilePlan(state, specPath) {
|
|
|
277
278
|
return op;
|
|
278
279
|
};
|
|
279
280
|
const fileOp = (hostId, did, path, { content = undefined, binary = undefined, mode = undefined } = {}) => {
|
|
280
|
-
const
|
|
281
|
+
const secretBearing = credentialsIn(content ?? "").size > 0;
|
|
282
|
+
const selectedMode = mode ?? (secretBearing ? 0o600 : defaultFileMode(path));
|
|
283
|
+
if ((secretBearing || /(?:^|[\\/])env[\\/]/.test(path)) && (selectedMode & 0o077)) {
|
|
284
|
+
throw new OpsError("secret-bearing config/env mode must be 0600 or stricter; use explicit service ownership, not world-readable secrets");
|
|
285
|
+
}
|
|
286
|
+
const data = { path, mode: selectedMode };
|
|
281
287
|
if (content !== undefined) data.content = content;
|
|
282
288
|
else data.content_b64 = Buffer.from(binary).toString("base64");
|
|
283
289
|
return add(hostId, did, "write", data);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Bounded, read-only SSH/public-key and runtime checks; never installs or changes sshd. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join, posix } from "node:path";
|
|
5
|
+
import { digest, identifier, newId, now, noSymlinks, OpsError, withLock, writeJson } from "./core.mjs";
|
|
6
|
+
import { readBoundedJson, readRegistry, statePath, targetDigest } from "./workspace.mjs";
|
|
7
|
+
|
|
8
|
+
export const CHECK_IDS = Object.freeze([
|
|
9
|
+
"registration", "ssh-publickey", "machine-identity", "linux", "node-runtime",
|
|
10
|
+
"deployment-root", "disk-space", "memory", "docker-daemon", "compose-version", "docker-data-root",
|
|
11
|
+
"least-privilege", "time-sync", "firewall", "egress", "backup-restore",
|
|
12
|
+
]);
|
|
13
|
+
const profiles = new Set(["base", "compose", "native"]);
|
|
14
|
+
const statuses = new Set(["pass", "fail", "warn", "unknown", "not-applicable"]);
|
|
15
|
+
|
|
16
|
+
function version(v) {
|
|
17
|
+
const m = typeof v === "string" && /(?:^|\s)v?(\d+)\.(\d+)\.(\d+)(?:\s|$|[-+])/.exec(v);
|
|
18
|
+
return m ? m.slice(1).map(Number) : null;
|
|
19
|
+
}
|
|
20
|
+
function atLeast(v, min) {
|
|
21
|
+
const found = version(v);
|
|
22
|
+
if (!found) return null;
|
|
23
|
+
for (let i = 0; i < 3; i++) if (found[i] !== min[i]) return found[i] > min[i];
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
function nodeSupported(v) {
|
|
27
|
+
const parsed = version(v);
|
|
28
|
+
return parsed ? atLeast(v, [22, 22, 3]) && parsed[0] < 25 : null;
|
|
29
|
+
}
|
|
30
|
+
function result(id, value, required, detail) {
|
|
31
|
+
return { id, status: value == null ? "unknown" : value ? "pass" : "fail", required, detail };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assessServer(host, { profile = "base", inventory = null, keyLogin = null,
|
|
35
|
+
minFreeMiB = 1024, minMemoryMiB = 256 } = {}) {
|
|
36
|
+
if (!profiles.has(profile)) throw new OpsError("profile must be base, compose or native");
|
|
37
|
+
for (const n of [minFreeMiB, minMemoryMiB]) if (!Number.isSafeInteger(n) || n < 1) throw new OpsError("resource thresholds must be positive integer MiB");
|
|
38
|
+
const inv = inventory;
|
|
39
|
+
const dc = inv?.docker_control;
|
|
40
|
+
const requiredRuntime = profile !== "base";
|
|
41
|
+
const disk = inv?.diagnostics?.disks?.[host.root]?.free;
|
|
42
|
+
const memory = inv?.diagnostics?.memory?.MemAvailable;
|
|
43
|
+
const checks = [
|
|
44
|
+
result("registration", true, true, "Registered resource; not proof of connectivity"),
|
|
45
|
+
host.transport === "ssh" ? result("ssh-publickey", keyLogin, true, "Strict pinned host key; publickey only; no password fallback")
|
|
46
|
+
: { id: "ssh-publickey", status: "not-applicable", required: false, detail: "Explicit local target; not the controller by inference" },
|
|
47
|
+
result("machine-identity", inv ? inv.identity === host.identity : null, true, "Observed machine identity must equal registered identity"),
|
|
48
|
+
result("linux", inv ? inv.platform === "linux" : null, requiredRuntime, "This readiness profile covers Linux; other platforms retain legacy support"),
|
|
49
|
+
result("node-runtime", nodeSupported(inv?.tools?.node?.version), requiredRuntime, "Node >=22.22.3 <25; missing Node does not prevent initial registration"),
|
|
50
|
+
result("deployment-root", inv?.snapshots?.[host.root] ? inv.snapshots[host.root].kind === "directory" : null, requiredRuntime, "Dedicated root exists without symlink traversal; does not prove write permission"),
|
|
51
|
+
result("disk-space", Number.isFinite(disk) ? disk >= minFreeMiB * 1024 * 1024 : null, requiredRuntime, `At least ${minFreeMiB} MiB free on root filesystem; project capacity still requires sizing`),
|
|
52
|
+
result("memory", Number.isFinite(memory) ? memory >= minMemoryMiB * 1024 * 1024 : null, requiredRuntime, `At least ${minMemoryMiB} MiB available; not a workload capacity guarantee`),
|
|
53
|
+
result("docker-daemon", dc ? dc.status === "observed" && typeof dc.endpoint === "string" && dc.endpoint.startsWith("unix://") : null, profile === "compose", "Reachable local Linux Docker daemon; remote Docker contexts are not this server"),
|
|
54
|
+
result("compose-version", atLeast(dc?.compose_version, [2, 30, 0]), profile === "compose", "Compose >=2.30 for raw env_file; container health remains a deployment check"),
|
|
55
|
+
result("docker-data-root", dc?.status === "observed" ? dc.data_root === posix.join(host.root, "_runtime/docker") : null, profile === "compose", "Existing Docker data must never be silently moved"),
|
|
56
|
+
...["least-privilege", "time-sync", "firewall", "egress", "backup-restore"].map((id) => ({
|
|
57
|
+
id, status: "unknown", required: false, detail: "Requires requirement-specific evidence; no automatic mutation or blanket hardening",
|
|
58
|
+
})),
|
|
59
|
+
];
|
|
60
|
+
return checks;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readiness(checks, checkedAt, { at = new Date(), staleHours = 24 } = {}) {
|
|
64
|
+
if (!Number.isFinite(staleHours) || staleHours <= 0) throw new OpsError("staleHours must be positive");
|
|
65
|
+
const timestamp = Date.parse(checkedAt);
|
|
66
|
+
if (!Number.isFinite(timestamp) || !Number.isFinite(at.getTime())) return "unknown";
|
|
67
|
+
if (timestamp > at.getTime() + 60000) return "unknown";
|
|
68
|
+
if (at.getTime() - timestamp > staleHours * 3600000) return "stale";
|
|
69
|
+
if (!Array.isArray(checks) || !checks.length) return "unknown";
|
|
70
|
+
const required = checks.filter((c) => c.required);
|
|
71
|
+
if (!required.length) return "unknown";
|
|
72
|
+
if (required.some((c) => c.status === "fail")) return "blocked";
|
|
73
|
+
if (required.some((c) => c.status !== "pass")) return "unknown";
|
|
74
|
+
return "ready";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validateCheck(report, host) {
|
|
78
|
+
if (report?.schema_version !== 1 || report.artifact !== "ops-server-check" || report.host_id !== host.host_id
|
|
79
|
+
|| !profiles.has(report.profile) || !Number.isFinite(Date.parse(report.checked_at)) || !Array.isArray(report.checks)) {
|
|
80
|
+
throw new OpsError("invalid server check report");
|
|
81
|
+
}
|
|
82
|
+
const found = new Set();
|
|
83
|
+
for (const c of report.checks) {
|
|
84
|
+
if (!CHECK_IDS.includes(c.id) || found.has(c.id) || !statuses.has(c.status) || typeof c.required !== "boolean") throw new OpsError("invalid or duplicate check item");
|
|
85
|
+
found.add(c.id);
|
|
86
|
+
}
|
|
87
|
+
if (found.size !== CHECK_IDS.length) throw new OpsError("incomplete server checklist");
|
|
88
|
+
if (!/^[a-f0-9]{64}$/.test(report.host_digest || "")) throw new OpsError("missing check host digest");
|
|
89
|
+
return report;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function latestCheck(state, host) {
|
|
93
|
+
identifier(host.host_id);
|
|
94
|
+
const folder = join(state, "records", "servers", host.host_id, "checks");
|
|
95
|
+
const pointer = join(folder, "latest.json");
|
|
96
|
+
noSymlinks(pointer);
|
|
97
|
+
if (!existsSync(pointer)) return null;
|
|
98
|
+
const p = readBoundedJson(pointer);
|
|
99
|
+
identifier(p.check_id, "check_id");
|
|
100
|
+
const value = readBoundedJson(join(folder, p.check_id + ".json"));
|
|
101
|
+
if (digest(value) !== p.digest) throw new OpsError("server check receipt digest mismatch");
|
|
102
|
+
validateCheck(value, host);
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function checkServer(state, serverId, options = {}, adapter = null) {
|
|
107
|
+
statePath(state);
|
|
108
|
+
identifier(serverId, "server");
|
|
109
|
+
const registry = readRegistry(state);
|
|
110
|
+
const host = registry.hosts[serverId];
|
|
111
|
+
if (!host) throw new OpsError("unknown server; enroll through S first");
|
|
112
|
+
if (host.platform !== "linux") throw new OpsError("server-check currently covers Linux only; use the existing platform-specific probe for other servers");
|
|
113
|
+
// Reject malformed options before performing any network reads.
|
|
114
|
+
assessServer(host, options);
|
|
115
|
+
const pinnedTarget = targetDigest(host);
|
|
116
|
+
if (!adapter) {
|
|
117
|
+
const transport = await import("./transport.mjs");
|
|
118
|
+
adapter = { call: transport.call, sshArgv: transport.sshArgv, spawn: spawnSync };
|
|
119
|
+
}
|
|
120
|
+
let keyLogin = null, inventory = null;
|
|
121
|
+
if (host.transport === "ssh") {
|
|
122
|
+
const argv = adapter.sshArgv(host.connection);
|
|
123
|
+
argv.push("-o", "PreferredAuthentications=publickey", "-o", "PasswordAuthentication=no",
|
|
124
|
+
"-o", "KbdInteractiveAuthentication=no", "--", host.connection.username + "@" + host.connection.hostname, "true");
|
|
125
|
+
const p = adapter.spawn(argv[0], argv.slice(1), { encoding: "utf8", timeout: 30000, maxBuffer: 1024 * 1024, windowsHide: true });
|
|
126
|
+
keyLogin = !p.error && p.status === 0;
|
|
127
|
+
}
|
|
128
|
+
if (host.transport === "local" || keyLogin) {
|
|
129
|
+
try { inventory = adapter.call(host, { action: "probe", include_docker: true, disk_roots: [host.root], paths: [host.root] }, { timeout: 180 }); }
|
|
130
|
+
catch { /* A failed read is not evidence of health; keep inventory-dependent checks unknown. */ }
|
|
131
|
+
}
|
|
132
|
+
const report = {
|
|
133
|
+
schema_version: 1, artifact: "ops-server-check", host_id: serverId,
|
|
134
|
+
profile: options.profile ?? "base", checked_at: now(), host_digest: pinnedTarget,
|
|
135
|
+
checks: assessServer(host, { ...options, keyLogin, inventory }),
|
|
136
|
+
};
|
|
137
|
+
validateCheck(report, host);
|
|
138
|
+
// Refuse to attribute observations to a changed endpoint or identity.
|
|
139
|
+
if (targetDigest(readRegistry(state).hosts[serverId]) !== report.host_digest) throw new OpsError("server changed while checking; do not publish misattributed evidence");
|
|
140
|
+
const checkId = newId("check");
|
|
141
|
+
const folder = join(state, "records", "servers", serverId, "checks");
|
|
142
|
+
withLock(join(state, "records", ".locks", "check-" + serverId), { operation: "server-check" }, () => {
|
|
143
|
+
writeJson(join(folder, checkId + ".json"), report, { exclusive: true });
|
|
144
|
+
writeJson(join(folder, "latest.json"), { check_id: checkId, digest: digest(report) });
|
|
145
|
+
});
|
|
146
|
+
const status = readiness(report.checks, report.checked_at);
|
|
147
|
+
return { status, server_id: serverId, profile: report.profile, report: join(folder, checkId + ".json"), checks: report.checks,
|
|
148
|
+
note: "Read-only baseline, not application readiness, live monitoring or production acceptance" };
|
|
149
|
+
}
|