@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,138 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:speculo:ops:task-request:v1",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"schema_version": {
|
|
7
|
+
"const": 1
|
|
8
|
+
},
|
|
9
|
+
"task_id": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
12
|
+
"maxLength": 80
|
|
13
|
+
},
|
|
14
|
+
"title": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"minLength": 3,
|
|
17
|
+
"maxLength": 200
|
|
18
|
+
},
|
|
19
|
+
"plan": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"minLength": 40,
|
|
22
|
+
"maxLength": 32000
|
|
23
|
+
},
|
|
24
|
+
"valid_for_hours": {
|
|
25
|
+
"type": "integer",
|
|
26
|
+
"minimum": 1,
|
|
27
|
+
"maximum": 24
|
|
28
|
+
},
|
|
29
|
+
"scope": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"scope": {
|
|
33
|
+
"enum": [
|
|
34
|
+
"server",
|
|
35
|
+
"project"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"server_ids": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"items": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
43
|
+
"maxLength": 80
|
|
44
|
+
},
|
|
45
|
+
"uniqueItems": true,
|
|
46
|
+
"minItems": 1
|
|
47
|
+
},
|
|
48
|
+
"project_ids": {
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
53
|
+
"maxLength": 80
|
|
54
|
+
},
|
|
55
|
+
"uniqueItems": true,
|
|
56
|
+
"maxItems": 1
|
|
57
|
+
},
|
|
58
|
+
"related_project_ids": {
|
|
59
|
+
"type": "array",
|
|
60
|
+
"items": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
63
|
+
"maxLength": 80
|
|
64
|
+
},
|
|
65
|
+
"uniqueItems": true
|
|
66
|
+
},
|
|
67
|
+
"allow_policy_changes": {
|
|
68
|
+
"type": "boolean"
|
|
69
|
+
},
|
|
70
|
+
"allow_public_ingress": {
|
|
71
|
+
"type": "boolean"
|
|
72
|
+
},
|
|
73
|
+
"allow_destructive": {
|
|
74
|
+
"type": "boolean"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"required": [
|
|
78
|
+
"scope",
|
|
79
|
+
"server_ids",
|
|
80
|
+
"project_ids",
|
|
81
|
+
"related_project_ids",
|
|
82
|
+
"allow_policy_changes",
|
|
83
|
+
"allow_public_ingress",
|
|
84
|
+
"allow_destructive"
|
|
85
|
+
],
|
|
86
|
+
"additionalProperties": false
|
|
87
|
+
},
|
|
88
|
+
"input_paths": {
|
|
89
|
+
"type": "array",
|
|
90
|
+
"items": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"minLength": 1
|
|
93
|
+
},
|
|
94
|
+
"uniqueItems": true
|
|
95
|
+
},
|
|
96
|
+
"steps": {
|
|
97
|
+
"type": "array",
|
|
98
|
+
"minItems": 1,
|
|
99
|
+
"maxItems": 20,
|
|
100
|
+
"items": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {
|
|
103
|
+
"id": {
|
|
104
|
+
"type": "string",
|
|
105
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
106
|
+
"maxLength": 80
|
|
107
|
+
},
|
|
108
|
+
"title": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"minLength": 1,
|
|
111
|
+
"maxLength": 200
|
|
112
|
+
},
|
|
113
|
+
"spec": {
|
|
114
|
+
"$ref": "urn:speculo:ops:spec:v1"
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
"required": [
|
|
118
|
+
"id",
|
|
119
|
+
"title",
|
|
120
|
+
"spec"
|
|
121
|
+
],
|
|
122
|
+
"additionalProperties": false
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"required": [
|
|
127
|
+
"schema_version",
|
|
128
|
+
"task_id",
|
|
129
|
+
"title",
|
|
130
|
+
"plan",
|
|
131
|
+
"valid_for_hours",
|
|
132
|
+
"scope",
|
|
133
|
+
"input_paths",
|
|
134
|
+
"steps"
|
|
135
|
+
],
|
|
136
|
+
"additionalProperties": false,
|
|
137
|
+
"$comment": "Register the existing spec.schema.json $id with a standards-compliant validator. Runtime validates each frozen spec through the original spec gateway."
|
|
138
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src '{{SCRIPT_CSP}}'; style-src '{{STYLE_CSP}}'; base-uri 'none'; form-action 'none'; connect-src 'none'; img-src 'none'">
|
|
7
|
+
<meta name="referrer" content="no-referrer">
|
|
8
|
+
<title>Speculo · OPS 资产清单</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root{color-scheme:light;--ink:#182633;--muted:#596b78;--line:#d7e0e5;--paper:#f3f6f8;--accent:#126c62;--card:#fff}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.65 system-ui,-apple-system,"Segoe UI",sans-serif}header{background:#142d36;color:white;padding:34px max(24px,calc((100vw - 1360px)/2));border-bottom:5px solid #45b9a6}.eyebrow{letter-spacing:.15em;font-size:12px;text-transform:uppercase;color:#a2d8d0}h1{font-size:32px;line-height:1.25;margin:10px 0}h2{font-size:21px;margin:0 0 14px}p{margin:8px 0}header p{color:#cbdde3;max-width:1000px}main{max-width:1408px;margin:auto;padding:24px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:0 0 22px}.stat,section,.notice,.filters{background:var(--card);border:1px solid var(--line);border-radius:10px}.stat{padding:16px 20px}.stat strong{display:block;font-size:30px;line-height:1.25}.stat span{color:var(--muted);font-size:13px}.notice{padding:14px 18px;margin-bottom:20px;border-left:4px solid #c38835}.filters{padding:16px;display:flex;flex-wrap:wrap;gap:12px;align-items:end;position:sticky;top:0;z-index:1;box-shadow:0 4px 15px #142d3610}label{display:flex;flex-direction:column;font-size:12px;color:var(--muted);gap:4px}label.search{flex:1;min-width:220px}input,select,button{font:inherit;border:1px solid #b9cbd1;border-radius:6px;padding:9px 12px;background:#fff;color:var(--ink)}input:focus,select:focus,button:focus{outline:3px solid #79c8bb;outline-offset:2px}button{cursor:pointer}button.link{border:0;padding:0;color:var(--accent);background:transparent;text-align:left;text-decoration:underline;text-underline-offset:3px}section{padding:22px;margin:20px 0}.scroll{overflow:auto}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;background:#eef4f5;font-weight:650;color:#3e5764}td,th{padding:11px 12px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}tr:last-child td{border-bottom:0}td{max-width:380px;min-width:80px}.muted{color:var(--muted);font-size:12px}.badge{display:inline-block;padding:2px 9px;border-radius:20px;background:#e8eef2;font-size:12px;white-space:nowrap}.pass,.ready,.completed{background:#e0f2e8;color:#216844}.fail,.failed,.blocked{background:#fbe6e5;color:#993b37}.unknown,.stale,.docs_pending,.interrupted,.needs-inspection,.incomplete{background:#fff0d6;color:#865719}.details{font-size:12px}summary{cursor:pointer;color:var(--accent)}.detail-list{padding:6px 0}.check{display:flex;justify-content:space-between;gap:16px;margin:4px 0}.empty{padding:20px;color:var(--muted)}footer{overflow-wrap:anywhere;color:var(--muted);font-size:12px;padding:10px 0 30px}code{font-size:12px}noscript{display:block;padding:24px}@media(max-width:760px){.stats{grid-template-columns:repeat(2,1fr)}main{padding:14px}section{padding:16px}.filters{position:static}header{padding:25px 20px}h1{font-size:25px}td,th{padding:9px}}@media print{.filters{display:none}header{background:white;color:var(--ink);padding:10px}header p{color:var(--muted)}section,.stat{break-inside:avoid}main{padding:0}}
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<header><div class="eyebrow">Speculo / Operations workspace</div><h1>服务器与项目,一处查看</h1><p id="meta"></p><p>服务器 → 项目 · 项目 → 服务器 · 系统服务与业务部署分别记录</p></header>
|
|
15
|
+
<main>
|
|
16
|
+
<div class="stats" id="stats"></div>
|
|
17
|
+
<div class="notice"><strong>这是离线快照,不是实时监控。</strong> <span id="notice-text">“ready”仅说明指定 profile 的必需检查项通过;“completed”是记录中的执行结果。未测、过期、身份变化及未完成文档均不能视为生产验收通过。</span></div>
|
|
18
|
+
<div class="filters"><label class="search">搜索服务器、项目、部署、版本或路径<input id="search" type="search" placeholder="输入关键词" autocomplete="off"></label><label>服务器<select id="server"><option value="">全部服务器</option></select></label><label>项目<select id="project"><option value="">全部项目</option></select></label><label>部署记录状态<select id="status"><option value="">全部状态</option></select></label><button id="reset" type="button">重置筛选</button></div>
|
|
19
|
+
<section><h2>01 / 服务器清单</h2><p class="muted">点击项目名称反向查询部署位置。展开检查项可查看尚未验证的基础环境。</p><div class="scroll" id="servers"></div></section>
|
|
20
|
+
<section><h2>02 / 项目清单</h2><p class="muted">项目是逻辑资源;同一项目可以部署在多台服务器。退役实例保留历史记录。</p><div class="scroll" id="projects"></div></section>
|
|
21
|
+
<section><h2>03 / 部署实例</h2><div class="scroll" id="deployments"></div></section>
|
|
22
|
+
<section><h2>04 / 系统服务与依赖</h2><p class="muted">Nginx、WireGuard 等主机级服务不伪装成业务项目;共享依赖通过部署关系显示。</p><div class="scroll" id="services"></div><div class="scroll" id="bindings"></div></section>
|
|
23
|
+
<section><h2>05 / 任务与执行记录</h2><p class="muted">缺少已验证终态的任务需核对原始回执;看板不证明进程仍在运行,也不触发重试、回滚或部署。</p><div class="scroll" id="tasks"></div><details><summary>查看原始 Run / Release 索引</summary><div class="scroll" id="releases"></div></details></section>
|
|
24
|
+
<footer id="footer"></footer>
|
|
25
|
+
</main>
|
|
26
|
+
<noscript>此看板需要本地 JavaScript 才能筛选;同目录 FLEET.md 提供无需脚本的清单。</noscript>
|
|
27
|
+
<script type="application/json" id="fleet-data">{{FLEET_DATA}}</script>
|
|
28
|
+
<script>
|
|
29
|
+
"use strict";
|
|
30
|
+
const data=JSON.parse(document.getElementById("fleet-data").textContent);
|
|
31
|
+
const $=(id)=>document.getElementById(id);
|
|
32
|
+
const byDeployment=new Map(data.deployments.map(d=>[d.deployment_id,d]));
|
|
33
|
+
const labels={registration:"资源登记","ssh-publickey":"SSH 公钥认证","machine-identity":"服务器身份",linux:"Linux 系统","node-runtime":"Node 运行环境","deployment-root":"部署根存在","disk-space":"磁盘空间",memory:"可用内存","docker-daemon":"本机 Docker","compose-version":"Compose 版本","docker-data-root":"Docker 数据目录","least-privilege":"最小权限","time-sync":"时间同步",firewall:"防火墙",egress:"外部依赖连通性","backup-restore":"备份恢复"};
|
|
34
|
+
const node=(tag,value,cls)=>{const n=document.createElement(tag);if(value!=null)n.textContent=String(value);if(cls)n.className=cls;return n;};
|
|
35
|
+
const badge=(value)=>node("span",value||"unknown","badge "+String(value||"unknown").replace(/[^a-z_-]/g,""));
|
|
36
|
+
function choose(kind,id){$(kind).value=id;$(kind==="server"?"project":"server").value="";$("search").value="";$("status").value="";render();}
|
|
37
|
+
const link=(kind,id)=>{const n=node("button",id,"link");n.type="button";n.addEventListener("click",()=>choose(kind,id));return n;};
|
|
38
|
+
function links(kind,ids){const n=node("div");if(!ids.length)n.textContent="—";ids.forEach((id,i)=>{if(i)n.append(document.createTextNode(", "));n.append(link(kind,id));});return n;}
|
|
39
|
+
function table(target,headers,rows){const root=$(target);root.replaceChildren();if(!rows.length){root.append(node("p","没有匹配记录。","empty"));return;}const t=node("table"),head=node("thead"),hr=node("tr"),body=node("tbody");for(const h of headers)hr.append(node("th",h));head.append(hr);for(const row of rows){const tr=node("tr");for(const value of row){const td=node("td");if(value instanceof Node)td.append(value);else td.textContent=value==null||value===""?"—":String(value);tr.append(td);}body.append(tr);}t.append(head,body);root.append(t);}
|
|
40
|
+
function checks(s){const d=node("details",null,"details");d.append(node("summary",`${s.profile||"未检查"} · ${s.checked_at||"无时间记录"}`));const wrap=node("div",null,"detail-list");if(!s.checks.length)wrap.textContent="未检查:不能据此推断服务器可用。";for(const c of s.checks){const row=node("div",null,"check");row.append(node("span",(labels[c.id]||c.id)+(c.required?" *":"")),badge(c.status));wrap.append(row);}d.append(wrap);return d;}
|
|
41
|
+
for(const [id,values] of [["server",data.servers.map(s=>s.server_id)],["project",data.projects.map(p=>p.project_id)],["status",[...new Set(data.deployments.map(d=>d.status))].sort()]])for(const value of values){const o=node("option",value);o.value=value;$(id).append(o);}
|
|
42
|
+
$("meta").textContent=`控制端 ${data.controller_id} / 资源修订 ${data.source_revision} / 生成于 ${data.generated_at}`;
|
|
43
|
+
$("notice-text").textContent=data.notice;
|
|
44
|
+
for(const [label,value] of [["已登记服务器",data.servers.length],["项目",data.projects.length],["部署实例(含历史)",data.deployments.length],["需关注的服务器",data.servers.filter(s=>s.readiness!=="ready").length]]){const box=node("div",null,"stat");box.append(node("strong",value),node("span",label));$("stats").append(box);}
|
|
45
|
+
$("footer").textContent=`数据格式 v${data.schema_version} · 检查过期阈值 ${data.stale_hours} 小时 · 资源摘要 ${data.source_digest}。此文件可能包含内部拓扑,请勿直接公开发布。`;
|
|
46
|
+
function render(){const server=$("server").value,project=$("project").value,st=$("status").value,q=$("search").value.toLocaleLowerCase();const match=(v)=>!q||JSON.stringify(v).toLocaleLowerCase().includes(q);
|
|
47
|
+
const ds=data.deployments.filter(d=>(!server||d.server_id===server)&&(!project||d.project_id===project)&&(!st||d.status===st)&&match(d));
|
|
48
|
+
const ss=data.servers.filter(s=>(!server||s.server_id===server)&&(!project||s.deployment_ids.some(id=>byDeployment.get(id)?.project_id===project))&&(!st||ds.some(d=>d.server_id===s.server_id))&&(match(s)||ds.some(d=>d.server_id===s.server_id)));
|
|
49
|
+
const ps=data.projects.filter(p=>(!project||p.project_id===project)&&(!server||p.deployment_ids.some(id=>byDeployment.get(id)?.server_id===server))&&(!st||ds.some(d=>d.project_id===p.project_id))&&(match(p)||ds.some(d=>d.project_id===p.project_id)));
|
|
50
|
+
table("servers",["服务器 / 名称","地址 / 系统","检查结果","检查详情(* 必需项)","项目","部署根"],ss.map(s=>[links("server",[s.server_id]),`${s.name} · ${s.address}${s.port?":"+s.port:""} / ${s.platform}`,badge(s.readiness),checks(s),links("project",s.project_ids),s.root]));
|
|
51
|
+
table("projects",["项目","名称","类别 / 服务类型","所在服务器","部署数量"],ps.map(p=>[link("project",p.project_id),p.name,`${p.kind} / ${p.service_type||"—"}`,links("server",p.server_ids),p.deployment_ids.length]));
|
|
52
|
+
table("deployments",["部署实例","项目 / 服务器","环境 / 方法","记录状态","计划版本","验证版本","根目录 / 更新时间"],ds.map(d=>{const where=node("div");where.append(link("project",d.project_id),node("div"),link("server",d.server_id));return[d.deployment_id,where,`${d.environment} / ${d.instance} / ${d.method}`,badge(d.status),d.desired_version,d.observed_version,`${d.root} / ${d.updated_at||"未验证"}`];}));
|
|
53
|
+
table("services",["服务器","主机级服务","类型","systemd 单元","监听端口"],ss.flatMap(s=>s.host_services.map(v=>[link("server",s.server_id),v.service_id,v.kind,v.unit,v.port])));
|
|
54
|
+
const depIds=new Set(ds.map(d=>d.deployment_id));table("bindings",["依赖关系","消费者部署","提供者部署","模式 / 状态"],data.bindings.filter(b=>depIds.has(b.consumer_deployment_id)||depIds.has(b.provider_deployment_id)).map(b=>[b.binding_id,b.consumer_deployment_id,b.provider_deployment_id||"external(不推断服务器)",`${b.mode} / ${b.status}`]));
|
|
55
|
+
table("tasks",["任务","范围","服务器","项目","记录状态","Run"],data.tasks.filter(t=>(!server||t.server_ids.includes(server))&&(!project||t.project_ids.includes(project))&&match(t)).map(t=>[t.task_id,t.scope,links("server",t.server_ids),links("project",t.project_ids),badge(t.status),t.run_ids.join(", ")]));
|
|
56
|
+
table("releases",["Run","Work","服务器","部署实例","记录状态","更新时间"],data.releases.filter(r=>(!server||r.server_ids.includes(server))&&(!project||r.deployment_ids.some(id=>byDeployment.get(id)?.project_id===project))&&match(r)).map(r=>[r.run_id,r.worker,links("server",r.server_ids),r.deployment_ids.join(", "),badge(r.status),r.updated_at]));}
|
|
57
|
+
for(const id of ["search","server","project","status"])$(id).addEventListener("input",render);
|
|
58
|
+
$("reset").addEventListener("click",()=>{for(const id of ["search","server","project","status"])$(id).value="";render();});render();
|
|
59
|
+
</script>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
@@ -580,8 +580,9 @@ test("planner contracts", async (t) => {
|
|
|
580
580
|
});
|
|
581
581
|
});
|
|
582
582
|
|
|
583
|
-
test("real native and dual docs", () => runFx((fx) => {
|
|
584
|
-
const
|
|
583
|
+
test("real native and explicitly approved plaintext dual docs", () => runFx((fx) => {
|
|
584
|
+
const input = fx.spec(); input.plaintext_documentation = true;
|
|
585
|
+
const [info, , result] = fx.runSpec(input);
|
|
585
586
|
assert.equal(result.status, "completed", JSON.stringify(result));
|
|
586
587
|
const dep = model.load(fx.state).deployments["app-a-prod"];
|
|
587
588
|
assert.equal(dep.observed_version, "v1");
|
|
@@ -980,3 +981,33 @@ test("host services and public ingress are delivered", () => runFx((fx) => {
|
|
|
980
981
|
assert.ok(fleet.includes("公网访问内网"));
|
|
981
982
|
assert.ok(!fleet.includes("<code>"));
|
|
982
983
|
}));
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
test("new controllers keep secrets out of default documentation", () => runFx((fx) => {
|
|
987
|
+
assert.equal(model.load(fx.state).policies.plaintext_documentation, false);
|
|
988
|
+
const [info, plan, result] = fx.runSpec();
|
|
989
|
+
assert.equal(result.status, "completed", JSON.stringify(result));
|
|
990
|
+
const remote = join(fx.target, "app-a");
|
|
991
|
+
const local = join(fx.state, "hosts", "node-a", "deployments", "app-a-prod");
|
|
992
|
+
for (const path of [join(remote,"README.md"), join(remote,"OPERATIONS.md"), join(local,"README.md"), join(local,"OPERATIONS.md"), join(fx.state,"FLEET-DEPLOYMENTS.md"), join(fx.target,"DEPLOYMENTS.md")]) {
|
|
993
|
+
assert.ok(!readFileSync(path,"utf8").includes(PASSWORD), path);
|
|
994
|
+
}
|
|
995
|
+
assert.match(readFileSync(join(local,"OPERATIONS.md"),"utf8"), /secret_ref:.*app-auth@1/);
|
|
996
|
+
assert.ok(readFileSync(join(remote,"env","app.env"),"utf8").includes(PASSWORD));
|
|
997
|
+
assert.equal(plan.operations.find((o) => o.path?.endsWith("env/app.env")).mode, 0o600);
|
|
998
|
+
if (process.platform !== "win32") assert.equal(statSync(join(remote,"env","app.env")).mode & 0o777, 0o600);
|
|
999
|
+
assert.equal(readJson(join(local,"docs-receipt.json")).status, "both-sides-verified");
|
|
1000
|
+
}));
|
|
1001
|
+
|
|
1002
|
+
test("legacy absent policy retains previous explicit plaintext behavior without migration", () => {
|
|
1003
|
+
const status = emptyStatus(); delete status.policies.plaintext_documentation;
|
|
1004
|
+
assert.equal(docs.plaintextDocumentation(status), true);
|
|
1005
|
+
status.policies.plaintext_documentation = false;
|
|
1006
|
+
assert.equal(docs.plaintextDocumentation(status), false);
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
test("credential-bearing config rejects permissive explicit file modes", () => runFx((fx) => {
|
|
1010
|
+
const spec = fx.spec();
|
|
1011
|
+
spec.deployments[0].files.push({path:"config/auth.conf",content:"{{credential:app-auth@1:password}}",mode:0o644});
|
|
1012
|
+
assert.throws(() => fx.plan(spec), /secret-bearing|private|0600|permission/i);
|
|
1013
|
+
}));
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, statSync, symlinkSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { digest, emptyStatus, now, writeJson } from "../tools/opslib/core.mjs";
|
|
10
|
+
import { route, readRegistry, statePath, targetDigest } from "../tools/opslib/workspace.mjs";
|
|
11
|
+
import { assessServer, readiness, checkServer, latestCheck } from "../tools/opslib/server_checks.mjs";
|
|
12
|
+
import { assertSpecScope, prepareTask, authorizeTask, runTask } from "../tools/opslib/tasks.mjs";
|
|
13
|
+
import { projectFleet, renderHtml, renderMarkdown, generateFleet } from "../tools/opslib/fleet.mjs";
|
|
14
|
+
import { parseWorkspaceArgs, workspaceCommand } from "../tools/opslib/workspace_cli.mjs";
|
|
15
|
+
|
|
16
|
+
function fixture(t) {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), "speculo-workspace-"));
|
|
18
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
19
|
+
const state = join(root, "state"); mkdirSync(state);
|
|
20
|
+
const s = emptyStatus(); s.controller = { controller_id: "controller-a", state_root: state, created_at: now() };
|
|
21
|
+
for (const id of ["node-a", "node-b"]) s.hosts[id] = { host_id: id, display_name: id, platform: "linux", transport: "local", root: join(root, id), identity: "a".repeat(64), connection: {} };
|
|
22
|
+
for (const id of ["app-a", "app-b"]) s.projects[id] = { project_id: id, display_name: id, kind: "app", service_type: null, source: { type: "local", location: join(root, "source"), revision: "v1" } };
|
|
23
|
+
writeJson(join(state, "status.json"), s);
|
|
24
|
+
const save = () => writeJson(join(state, "status.json"), s);
|
|
25
|
+
return { root, state, s, save };
|
|
26
|
+
}
|
|
27
|
+
function request(id = "task-one") {
|
|
28
|
+
return { schema_version: 1, task_id: id, title: "Prepare one selected server", plan: "Inspect the explicitly selected server, prepare only its declared directory, verify the result, retain the original evidence, and never retry an unknown operation.", valid_for_hours: 2,
|
|
29
|
+
scope: { scope: "server", server_ids: ["node-a"], project_ids: [], related_project_ids: [], allow_policy_changes: false, allow_public_ingress: false, allow_destructive: false }, input_paths: [],
|
|
30
|
+
steps: [{ id: "prepare-root", title: "Prepare explicit directory", spec: { schema_version: 1, worker: "H", operation: "prepare", reason: "unit fixture", rollback_note: "retain", hosts: ["node-a"], host_actions: [{ host_id: "node-a", kind: "mkdir", path: "_host/cache", reason: "Create only the temporary fixture cache directory" }] } }] };
|
|
31
|
+
}
|
|
32
|
+
function inventory(h) {
|
|
33
|
+
return { identity: h.identity, platform: "linux", tools: { node: { version: "v22.22.3" } },
|
|
34
|
+
snapshots: { [h.root]: { kind: "directory" } }, diagnostics: { disks: { [h.root]: { free: 4 * 1024 ** 3 } }, memory: { MemAvailable: 1024 ** 3 } },
|
|
35
|
+
docker_control: { status: "observed", endpoint: "unix:///var/run/docker.sock", data_root: join(h.root, "_runtime/docker"), compose_version: "2.30.0" } };
|
|
36
|
+
}
|
|
37
|
+
function adapterFor(f, options = {}) {
|
|
38
|
+
const calls = { compile: 0, approve: 0, apply: 0 };
|
|
39
|
+
let engine = "e".repeat(64), ledger = { entries: {} };
|
|
40
|
+
const adapter = { load: readRegistry, validate: (s, kind) => { assert.equal(kind, "spec"); assert.equal(s.schema_version, 1); }, engineDigest: () => engine, ledgerLoad: () => ledger,
|
|
41
|
+
compilePlan: (state, path) => {
|
|
42
|
+
calls.compile++;
|
|
43
|
+
const spec = JSON.parse(readFileSync(path, "utf8")), current = readRegistry(state), id = `run-${calls.compile}`;
|
|
44
|
+
const plan = { run_id: id, worker: spec.worker, operation: spec.operation, controller_id: current.controller.controller_id, engine_digest: engine,
|
|
45
|
+
hosts: { "node-a": current.hosts["node-a"] }, registry_scope: { hosts: ["node-a"], projects: [], deployments: [], allocations: [], bindings: [], policies: false, public_ingress: false },
|
|
46
|
+
registry_after: structuredClone(current), operations: [{ host_id: "node-a", kind: "mkdir", deployment_id: null }], document_targets: [], affected_consumers: [], source_digests: {} };
|
|
47
|
+
options.plan?.(plan);
|
|
48
|
+
const planPath = join(state, "hosts/node-a/runs", id, "plan.json"); writeJson(planPath, plan);
|
|
49
|
+
return { run_id: id, plan_path: planPath, plan_digest: digest(plan) };
|
|
50
|
+
},
|
|
51
|
+
approval: (_state, _run, _digest, by, statement) => { calls.approve++; assert.match(by, /^task:/); assert.match(statement, /Delegated exact-spec/); },
|
|
52
|
+
apply: (state, _run) => { calls.apply++; options.beforeApply?.(); if (options.throwApply) throw new Error("transport disconnected");
|
|
53
|
+
const s = readRegistry(state); s.revision++; writeJson(join(state, "status.json"), s); return { status: options.result ?? "completed" }; },
|
|
54
|
+
};
|
|
55
|
+
return { adapter, calls, changeEngine: () => { engine = "f".repeat(64); }, changeLedger: () => { ledger = { entries: { changed: true } }; } };
|
|
56
|
+
}
|
|
57
|
+
async function authorized(f, req, adapter) {
|
|
58
|
+
const p = await prepareTask(f.state, req, adapter);
|
|
59
|
+
authorizeTask(f.state, req.task_id, p.task_digest, "test-user", "Execute this exact task without per-step reconfirmation", req.scope.allow_destructive ? "I-APPROVE-THIS-EXACT-DESTRUCTIVE-TASK" : null);
|
|
60
|
+
return p;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test("explicit layer routing distinguishes controller, access, server, project and inventory", (t) => {
|
|
64
|
+
const f = fixture(t);
|
|
65
|
+
assert.equal(route(f.s, { scope: "controller", server_ids: [], project_ids: [] }).work, "I-initialize");
|
|
66
|
+
assert.equal(route(f.s, { scope: "server", server_ids: ["node-a"], project_ids: [] }).work, "H-host-manage");
|
|
67
|
+
assert.equal(route(f.s, { scope: "project", server_ids: ["node-b"], project_ids: ["app-a"] }).work, "D-project-deploy");
|
|
68
|
+
assert.equal(route(f.s, { scope: "inventory", server_ids: [], project_ids: [] }).work, "V-inventory-view");
|
|
69
|
+
assert.deepEqual(route(f.s, { scope: "access", server_ids: ["new-node"], project_ids: [] }).registration_required, ["new-node"]);
|
|
70
|
+
for (const c of [
|
|
71
|
+
{ scope: "controller", server_ids: ["node-a"], project_ids: [] }, { scope: "server", server_ids: ["node-a"], project_ids: ["app-a"] },
|
|
72
|
+
{ scope: "project", server_ids: [], project_ids: ["app-a"] }, { scope: "server", server_ids: ["missing"], project_ids: [] },
|
|
73
|
+
{ scope: "project", server_ids: ["node-a", "node-a"], project_ids: ["app-a"] },
|
|
74
|
+
]) assert.throws(() => route(f.s, c));
|
|
75
|
+
});
|
|
76
|
+
test("workspace rejects guessed roots, traversal, symlinks, duplicate JSON keys and orphan deployments", (t) => {
|
|
77
|
+
const f = fixture(t);
|
|
78
|
+
for (const path of [".", "/", f.state + "/../state"]) assert.throws(() => statePath(path));
|
|
79
|
+
const link = join(f.root, "linked"); symlinkSync(f.state, link, "dir"); assert.throws(() => statePath(link), /symlink/);
|
|
80
|
+
writeFileSync(join(f.state, "status.json"), '{"schema_version":3,"schema_version":3}'); assert.throws(() => readRegistry(f.state), /duplicate JSON/);
|
|
81
|
+
f.s.deployments.dangling = { deployment_id: "dangling", host_id: "missing", project_id: "app-a" }; f.save(); assert.throws(() => readRegistry(f.state), /orphan/);
|
|
82
|
+
});
|
|
83
|
+
test("workspace argument parser rejects duplicates, unknown flags and missing values", () => {
|
|
84
|
+
assert.equal(workspaceCommand(["--state", "/x", "fleet"]), "fleet"); assert.equal(workspaceCommand(["--state", "/x", "apply", "--run", "abc"]), null);
|
|
85
|
+
assert.deepEqual(parseWorkspaceArgs(["fleet", "--state", "/x", "--stale-hours", "8"]), { command: "fleet", state: "/x", "stale-hours": "8" });
|
|
86
|
+
for (const a of [["fleet"], ["fleet", "--state", "/x", "--state", "/y"], ["fleet", "--state"], ["fleet", "--state", "/x", "--project", "app-a"]]) assert.throws(() => parseWorkspaceArgs(a));
|
|
87
|
+
});
|
|
88
|
+
test("CLI help works without network, server dependencies or state", () => {
|
|
89
|
+
const path = resolve(dirname(fileURLToPath(import.meta.url)), "../tools/ops.mjs");
|
|
90
|
+
const r = spawnSync(process.execPath, [path, "workspace-help"], { encoding: "utf8" }); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /server-check/);
|
|
91
|
+
});
|
|
92
|
+
test("Linux checklist distinguishes missing, failed, profile-ready and stale evidence", (t) => {
|
|
93
|
+
const f = fixture(t), h = f.s.hosts["node-a"], inv = inventory(h), at = new Date("2026-01-01T00:00:00Z");
|
|
94
|
+
assert.equal(readiness(assessServer(h), at.toISOString(), { at }), "unknown");
|
|
95
|
+
assert.equal(readiness(assessServer(h, { profile: "compose", inventory: inv }), at.toISOString(), { at }), "ready");
|
|
96
|
+
assert.equal(readiness(assessServer(h, { profile: "compose", inventory: inv }), at.toISOString(), { at: new Date("2026-01-03T00:00:00Z") }), "stale");
|
|
97
|
+
assert.equal(readiness(assessServer(h, { inventory: inv }), "2027-01-01", { at }), "unknown");
|
|
98
|
+
for (const change of [i => { i.identity = "b".repeat(64); }, i => { i.tools.node.version = "v22.16.0"; }, i => { i.tools.node.version = "v25.0.0"; }, i => { i.docker_control.compose_version = "2.29.9"; }, i => { i.docker_control.endpoint = "tcp://other:2375"; }, i => { i.docker_control.data_root = "/var/lib/docker"; }, i => { i.diagnostics.memory.MemAvailable = 1; }]) {
|
|
99
|
+
const v = structuredClone(inv); change(v); assert.equal(readiness(assessServer(h, { profile: "compose", inventory: v }), at.toISOString(), { at }), "blocked");
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
test("server check uses pinned public-key-only SSH and records restricted evidence", async (t) => {
|
|
103
|
+
const f = fixture(t), h = f.s.hosts["node-a"], kh = join(f.root, "known_hosts"); writeFileSync(kh, "fixture verified key");
|
|
104
|
+
Object.assign(h, { transport: "ssh", connection: { hostname: "example.invalid", username: "deploy", known_hosts: kh } }); f.save();
|
|
105
|
+
let spawned = 0, called = 0;
|
|
106
|
+
const a = { sshArgv: () => ["ssh", "-o", "StrictHostKeyChecking=yes"], spawn: (_cmd, args) => { spawned++; assert.ok(args.includes("PasswordAuthentication=no")); assert.ok(args.includes("KbdInteractiveAuthentication=no")); return { status: 0 }; }, call: () => { called++; return inventory(h); } };
|
|
107
|
+
await assert.rejects(checkServer(f.state, "node-a", { profile: "bad" }, a)); assert.equal(spawned, 0);
|
|
108
|
+
const r = await checkServer(f.state, "node-a", { profile: "compose" }, a); assert.equal(r.status, "ready"); assert.equal(called, 1);
|
|
109
|
+
const check = latestCheck(f.state, h); assert.equal(check.host_digest, targetDigest(h)); assert.equal(check.checks.length, 16);
|
|
110
|
+
if (process.platform !== "win32") assert.equal(statSync(r.report).mode & 0o777, 0o600);
|
|
111
|
+
writeFileSync(kh, "different key"); assert.notEqual(check.host_digest, targetDigest(h));
|
|
112
|
+
});
|
|
113
|
+
test("failed SSH login never triggers Node probe and is not reported ready", async (t) => {
|
|
114
|
+
const f = fixture(t), h = f.s.hosts["node-a"], kh = join(f.root, "known_hosts"); writeFileSync(kh, "fixture"); Object.assign(h, { transport: "ssh", connection: { hostname: "invalid", username: "deploy", known_hosts: kh } }); f.save();
|
|
115
|
+
const r = await checkServer(f.state, "node-a", {}, { sshArgv: () => ["ssh"], spawn: () => ({ status: 255 }), call: () => { throw new Error("must not probe"); } });
|
|
116
|
+
assert.equal(r.status, "blocked"); assert.equal(r.checks.find(c => c.id === "machine-identity").status, "unknown");
|
|
117
|
+
});
|
|
118
|
+
test("task preparation does not execute; one authorization runs ordered steps without repeated confirmation", async (t) => {
|
|
119
|
+
const f = fixture(t), a = adapterFor(f), req = request(); req.steps.push({ ...structuredClone(req.steps[0]), id: "verify-root" });
|
|
120
|
+
const p = await prepareTask(f.state, req, a.adapter); assert.deepEqual(a.calls, { compile: 0, approve: 0, apply: 0 });
|
|
121
|
+
await assert.rejects(runTask(f.state, req.task_id, a.adapter), /missing path/);
|
|
122
|
+
assert.throws(() => authorizeTask(f.state, req.task_id, "0".repeat(64), "test-user", "explicit confirmation"), /digest mismatch/);
|
|
123
|
+
authorizeTask(f.state, req.task_id, p.task_digest, "test-user", "Execute the already confirmed exact task");
|
|
124
|
+
const r = await runTask(f.state, req.task_id, a.adapter); assert.equal(r.status, "completed"); assert.equal(r.steps.length, 2); assert.deepEqual(a.calls, { compile: 2, approve: 2, apply: 2 });
|
|
125
|
+
await assert.rejects(runTask(f.state, req.task_id, a.adapter), /already started/); assert.equal(a.calls.apply, 2);
|
|
126
|
+
});
|
|
127
|
+
test("task IDs, frozen specs and authorization are immutable", async (t) => {
|
|
128
|
+
const f = fixture(t), a = adapterFor(f), req = request(), p = await authorized(f, req, a.adapter);
|
|
129
|
+
await assert.rejects(prepareTask(f.state, req, a.adapter), /already exists/);
|
|
130
|
+
assert.throws(() => authorizeTask(f.state, req.task_id, p.task_digest, "test-user", "same explicit confirmation"), /immutable/);
|
|
131
|
+
writeJson(join(f.state, "records/tasks/task-one/specs/prepare-root.json"), { changed: true });
|
|
132
|
+
assert.equal((await runTask(f.state, req.task_id, a.adapter)).status, "blocked"); assert.equal(a.calls.compile, 0);
|
|
133
|
+
});
|
|
134
|
+
test("task rejects server/project confusion and indirect provider/consumer scope escalation before planning", (t) => {
|
|
135
|
+
const f = fixture(t), req = request();
|
|
136
|
+
assert.throws(() => assertSpecScope({ ...req.steps[0].spec, worker: "D", host_actions: [] }, req.scope, f.s), /cannot silently/);
|
|
137
|
+
assert.throws(() => assertSpecScope({ ...req.steps[0].spec, hosts: ["node-b"] }, req.scope, f.s), /outside task/);
|
|
138
|
+
f.s.deployments.provider = { deployment_id: "provider", host_id: "node-b", project_id: "app-b", status: "completed" };
|
|
139
|
+
f.s.allocations.db = { allocation_id: "db", provider_deployment_id: "provider", owner_project_id: "app-a", shared_owners: [] };
|
|
140
|
+
assert.throws(() => assertSpecScope({ schema_version: 1, worker: "D", operation: "deploy", provision: [{ allocation_id: "db" }] }, { ...req.scope, scope: "project", project_ids: ["app-a"], related_project_ids: ["app-b"] }, f.s), /outside task/);
|
|
141
|
+
f.s.deployments.consumer = { deployment_id: "consumer", host_id: "node-a", project_id: "app-a", status: "completed" };
|
|
142
|
+
f.s.bindings.link = { binding_id: "link", provider_deployment_id: "provider", consumer_deployment_id: "consumer", status: "active" };
|
|
143
|
+
assert.throws(() => assertSpecScope(req.steps[0].spec, { ...req.scope, related_project_ids: ["app-a", "app-b"] }, f.s), /outside task/);
|
|
144
|
+
});
|
|
145
|
+
test("task stops on expiry, executor, credential, input and catalog drift before any execution", async (t) => {
|
|
146
|
+
for (const kind of ["expiry", "engine", "ledger", "input", "catalog"]) {
|
|
147
|
+
await t.test(kind, async (sub) => {
|
|
148
|
+
const f = fixture(sub), a = adapterFor(f), req = request();
|
|
149
|
+
const input = join(f.root, "installer.bin"); writeFileSync(input, "reviewed"); req.input_paths = [input];
|
|
150
|
+
if (kind === "expiry") {
|
|
151
|
+
const p = await prepareTask(f.state, req, a.adapter); const path = join(f.state, "records/tasks/task-one/task.json"), task = JSON.parse(readFileSync(path, "utf8")); task.expires_at = "2020-01-01T00:00:00Z"; writeJson(path, task);
|
|
152
|
+
assert.throws(() => authorizeTask(f.state, req.task_id, digest(task), "test-user", "explicit confirmed task"), /expired/); assert.ok(p.task_digest); return;
|
|
153
|
+
}
|
|
154
|
+
await authorized(f, req, a.adapter);
|
|
155
|
+
if (kind === "engine") a.changeEngine(); if (kind === "ledger") a.changeLedger(); if (kind === "input") writeFileSync(input, "modified"); if (kind === "catalog") { f.s.revision++; f.save(); }
|
|
156
|
+
await assert.rejects(runTask(f.state, req.task_id, a.adapter), /drift|changed/); assert.equal(a.calls.apply, 0);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
test("destructive and policy changes need explicitly bounded authorization", async (t) => {
|
|
161
|
+
const f = fixture(t), a = adapterFor(f), req = request(); req.steps[0].spec.operation = "migrate";
|
|
162
|
+
await assert.rejects(prepareTask(f.state, req, a.adapter), /destructive/);
|
|
163
|
+
assert.throws(() => assertSpecScope({ worker: "D", operation: "deploy", retire_deployments: ["app-a-prod"] }, { ...request().scope, scope: "project", project_ids: ["app-a"] }, f.s), /destructive/);
|
|
164
|
+
req.scope.allow_destructive = true; const p = await prepareTask(f.state, req, a.adapter);
|
|
165
|
+
assert.throws(() => authorizeTask(f.state, req.task_id, p.task_digest, "test-user", "explicit confirmed task"), /independently explicit/);
|
|
166
|
+
assert.throws(() => assertSpecScope({ ...request().steps[0].spec, plaintext_documentation: true }, request().scope, f.s), /policy change/);
|
|
167
|
+
});
|
|
168
|
+
test("compiled scope escalation and unpinned artifacts stop before approval and preserve plan references", async (t) => {
|
|
169
|
+
for (const kind of ["target", "source", "hidden-catalog"]) await t.test(kind, async sub => {
|
|
170
|
+
const f = fixture(sub), a = adapterFor(f, { plan: p => {
|
|
171
|
+
if (kind === "target") p.hosts["node-b"] = f.s.hosts["node-b"];
|
|
172
|
+
if (kind === "source") p.source_digests[join(f.root, "unreviewed")] = "a".repeat(64);
|
|
173
|
+
if (kind === "hidden-catalog") p.registry_after.projects["app-b"].service_type = "changed";
|
|
174
|
+
} }); await authorized(f, request(), a.adapter);
|
|
175
|
+
const r = await runTask(f.state, "task-one", a.adapter); assert.equal(r.status, "blocked"); assert.equal(a.calls.approve, 0); assert.equal(a.calls.apply, 0); assert.equal(r.steps[0].run_id, "run-1");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
test("failed, unknown and docs_pending results stop later steps and cannot replay", async (t) => {
|
|
179
|
+
for (const outcome of ["failed", "unknown", "docs_pending", "throw"]) await t.test(outcome, async sub => {
|
|
180
|
+
const f = fixture(sub), a = adapterFor(f, { result: outcome, throwApply: outcome === "throw" }), req = request(); req.steps.push({ ...structuredClone(req.steps[0]), id: "next-step" });
|
|
181
|
+
await authorized(f, req, a.adapter); const r = await runTask(f.state, req.task_id, a.adapter);
|
|
182
|
+
assert.equal(r.status, outcome === "throw" ? "unknown" : outcome); assert.equal(a.calls.apply, 1); assert.equal(a.calls.compile, 1);
|
|
183
|
+
await assert.rejects(runTask(f.state, req.task_id, a.adapter), /already started/);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
test("fleet projects both directions, host services and external providers without exporting secret fields", (t) => {
|
|
187
|
+
const f = fixture(t), sentinel = "NEVER-EXPORT-THIS-SECRET";
|
|
188
|
+
f.s.hosts["node-a"].connection.password = sentinel; f.s.hosts["node-a"].host_services = [{ id: "edge", kind: "nginx", notes: sentinel, public_url: `https://invalid/?token=${sentinel}` }];
|
|
189
|
+
f.s.projects["app-a"].source = { repository: `https://user:${sentinel}@invalid` };
|
|
190
|
+
f.s.deployments["app-prod"] = { deployment_id: "app-prod", host_id: "node-a", project_id: "app-a", status: "docs_pending", version: "v2", observed_version: "v1", root: "/srv/ops/app-a", env: { PASSWORD: sentinel }, notes: [sentinel], credential_refs: [sentinel] };
|
|
191
|
+
f.s.bindings.external = { binding_id: "external", consumer_deployment_id: "app-prod", provider_deployment_id: null, mode: "external", status: "active", credential_ref: sentinel };
|
|
192
|
+
const d = projectFleet(f.s); assert.deepEqual(d.servers[0].project_ids, ["app-a"]); assert.deepEqual(d.projects[0].server_ids, ["node-a"]); assert.equal(d.servers[0].host_services[0].service_id, "edge");
|
|
193
|
+
assert.equal(d.deployments[0].desired_version, "v2"); assert.equal(d.deployments[0].observed_version, "v1"); assert.equal(d.bindings[0].provider_deployment_id, null);
|
|
194
|
+
assert.ok(!JSON.stringify(d).includes(sentinel)); assert.ok(!renderHtml(d).includes(sentinel)); assert.ok(!renderMarkdown(d).includes(sentinel));
|
|
195
|
+
});
|
|
196
|
+
test("HTML rendering is deterministic, escaped, offline and CSP hashes match executable bytes", (t) => {
|
|
197
|
+
const f = fixture(t); f.s.hosts["node-a"].display_name = '</script><img src=x onerror="alert(1)">'; const d = projectFleet(f.s, {}, [], { at: new Date("2026-01-01") });
|
|
198
|
+
const html = renderHtml(d); assert.equal(html, renderHtml(d)); assert.ok(!html.includes(f.s.hosts["node-a"].display_name)); assert.ok(html.includes("\\u003c/script\\u003e")); assert.ok(!html.includes("innerHTML")); assert.ok(!html.includes("fetch("));
|
|
199
|
+
const script = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)].find(m => m[0].startsWith("<script>"))[1];
|
|
200
|
+
assert.ok(html.includes("sha256-" + createHash("sha256").update(script).digest("base64"))); assert.match(html, /connect-src 'none'/);
|
|
201
|
+
const md = renderMarkdown(d); assert.ok(!md.includes("<img"));
|
|
202
|
+
});
|
|
203
|
+
test("fleet writes immutable generations, verifies readback and retains one consistent landing page", async (t) => {
|
|
204
|
+
const f = fixture(t), at = new Date("2026-01-01T00:00:00Z");
|
|
205
|
+
// A secret ledger is deliberately unreadable JSON. View generation must not open it.
|
|
206
|
+
mkdirSync(join(f.state, "private")); writeFileSync(join(f.state, "private/credentials.json"), "NOT JSON, DO NOT READ");
|
|
207
|
+
const a = adapterFor(f); await authorized(f, request(), a.adapter);
|
|
208
|
+
const first = generateFleet(f.state, { at }), second = generateFleet(f.state, { at }); assert.equal(first.view_id, second.view_id);
|
|
209
|
+
assert.ok(readFileSync(first.markdown, "utf8").includes(first.view_id)); assert.ok(existsSync(first.html));
|
|
210
|
+
const before = readFileSync(first.markdown, "utf8"); writeFileSync(first.html, "tampered"); assert.throws(() => generateFleet(f.state, { at }), /readback mismatch/); assert.equal(readFileSync(first.markdown, "utf8"), before);
|
|
211
|
+
});
|
|
212
|
+
test("fleet refuses symlink outputs and catalog locks without overwriting old entry", (t) => {
|
|
213
|
+
const f = fixture(t), outside = join(f.root, "outside.txt"); writeFileSync(outside, "untouched"); symlinkSync(outside, join(f.state, "FLEET.md"));
|
|
214
|
+
assert.throws(() => generateFleet(f.state), /symlink/); assert.equal(readFileSync(outside, "utf8"), "untouched");
|
|
215
|
+
rmSync(join(f.state, "FLEET.md")); mkdirSync(join(f.state, ".locks/catalog"), { recursive: true }); assert.throws(() => generateFleet(f.state), /catalog lock/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
test("workspace task and fleet integrate with the real legacy planner/approval/apply gateway", async (t) => {
|
|
220
|
+
// A partial source overlay can run pure tests; installed-repository CI must exercise this branch.
|
|
221
|
+
const modelPath = resolve(dirname(fileURLToPath(import.meta.url)), "../tools/opslib/model.mjs");
|
|
222
|
+
if (!existsSync(modelPath)) { t.skip("legacy runtime absent in partial source overlay; full repository CI required"); return; }
|
|
223
|
+
const f = fixture(t);
|
|
224
|
+
const { fingerprint } = await import("../tools/opslib/agent.mjs");
|
|
225
|
+
const { validate } = await import("../tools/opslib/model.mjs");
|
|
226
|
+
const example = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/task-host.example.json"), "utf8"));
|
|
227
|
+
validate(example.steps[0].spec, "spec");
|
|
228
|
+
f.s.hosts["node-a"].identity = fingerprint(); f.save();
|
|
229
|
+
writeJson(join(f.state, "private/credentials.json"), { schema_version: 1, entries: {} });
|
|
230
|
+
const p = await prepareTask(f.state, request());
|
|
231
|
+
authorizeTask(f.state, "task-one", p.task_digest, "local-test-user", "Execute only this temporary local test directory preparation");
|
|
232
|
+
const r = await runTask(f.state, "task-one");
|
|
233
|
+
assert.equal(r.status, "completed", JSON.stringify(r));
|
|
234
|
+
assert.ok(existsSync(join(f.s.hosts["node-a"].root, "_host/cache")));
|
|
235
|
+
const view = generateFleet(f.state); assert.equal(view.status, "generated");
|
|
236
|
+
const data = JSON.parse(readFileSync(view.json, "utf8")); assert.equal(data.tasks[0].status, "completed");
|
|
237
|
+
assert.ok(data.releases.some(v => v.run_id === r.steps[0].run_id));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("H and D cannot be mislabeled to cross server/project boundaries", (t) => {
|
|
241
|
+
const f = fixture(t), s = request().scope;
|
|
242
|
+
const d = { deployment_id: "app-prod", host_id: "node-a", project_id: "app-a" };
|
|
243
|
+
assert.throws(() => assertSpecScope({ worker: "H", deployments: [d] }, { ...s, related_project_ids: ["app-a"] }, f.s), /H cannot/);
|
|
244
|
+
assert.throws(() => assertSpecScope({ worker: "D", host_actions: [{ host_id: "node-a", kind: "mkdir" }] }, { ...s, scope: "project", project_ids: ["app-a"] }, f.s), /D cannot/);
|
|
245
|
+
assert.doesNotThrow(() => assertSpecScope({ worker: "D", operation: "deploy", deployments: [{ ...d, compose: { services: { app: { environment: { project_id: "vendor-data-not-a-resource", host_id: "vendor-setting" } } } } }] }, { ...s, scope: "project", project_ids: ["app-a"] }, f.s));
|
|
246
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/** OPS entry point: Node >=22, no third-party packages. */
|
|
3
|
-
import {
|
|
4
|
-
process.
|
|
2
|
+
/** OPS entry point: Node >=22.22.3 <25, no third-party packages. */
|
|
3
|
+
import { workspaceCommand } from "./opslib/workspace_cli.mjs";
|
|
4
|
+
const argv = process.argv.slice(2);
|
|
5
|
+
const { main } = await import(workspaceCommand(argv) ? "./opslib/workspace_cli.mjs" : "./opslib/cli.mjs");
|
|
6
|
+
process.exitCode = await main(argv);
|
|
@@ -397,7 +397,7 @@ export function emptyStatus() {
|
|
|
397
397
|
allocations: {},
|
|
398
398
|
bindings: {},
|
|
399
399
|
releases: {},
|
|
400
|
-
policies: { server_readme_credentials: false, server_operations: true, strict_docker_root: true },
|
|
400
|
+
policies: { server_readme_credentials: false, server_operations: true, strict_docker_root: true, plaintext_documentation: false },
|
|
401
401
|
updated_at: null,
|
|
402
402
|
};
|
|
403
403
|
}
|
|
@@ -274,13 +274,13 @@ export function deploymentReadme(status, dep, runId, generatedAt, { ledger = nul
|
|
|
274
274
|
out.push("## 同项目部署位置\n", replicas.sort((a, b) => a.deployment_id < b.deployment_id ? -1 : 1).map((d) => `- ${d.host_id} / ${d.deployment_id}:${code(d.root)}`).join("\n") + "\n");
|
|
275
275
|
if (controller) out.push("\n本目录是部署机的配置、凭据、运行证据和远端文档记录;不声称自动复制了远端业务数据。数据备份需要独立的备份策略和回执。\n");
|
|
276
276
|
if (includeCredentials) out.push(credentialsText(credentialRefs(status, dep), ledger));
|
|
277
|
-
else out.push("\n## 凭据记录\n\
|
|
277
|
+
else out.push("\n## 凭据记录\n\nsecret_ref: " + credentialRefs(status, dep).map(code).join(", ") + "\n\n本文件默认不写密码。凭据按 ID@版本定位受限 private/credentials.json;真实值仅在批准 plaintext_documentation 后导出。未知值不得编造。\n");
|
|
278
278
|
return out.join("\n") + "\n";
|
|
279
279
|
}
|
|
280
280
|
|
|
281
281
|
export function hostReadme(status, hid, runId, at, { ledger = null, full = false, controller = false } = {}) {
|
|
282
282
|
const host = status.hosts[hid];
|
|
283
|
-
const includeCredentials = Boolean(full && controller && ledger);
|
|
283
|
+
const includeCredentials = Boolean(full && controller && ledger && plaintextDocumentation(status));
|
|
284
284
|
const rows = overviewRows(status, hid, { ledger, includeCredentials });
|
|
285
285
|
const out = [
|
|
286
286
|
`# 主机 ${host.display_name} / ${hid}\n`,
|
|
@@ -368,6 +368,11 @@ const KNOWLEDGE_INDEX = `# 共享知识索引
|
|
|
368
368
|
只收录经用户确认、带来源和最后验证时间的知识,不存密码。
|
|
369
369
|
`;
|
|
370
370
|
|
|
371
|
+
// Legacy absent field retains existing deliberate behavior; init seeds false. Changing policy requires a new approved plan.
|
|
372
|
+
export function plaintextDocumentation(status) {
|
|
373
|
+
return status.policies.plaintext_documentation ?? true;
|
|
374
|
+
}
|
|
375
|
+
|
|
371
376
|
export function deliveryBundle(state, plan, status, ledger, verifiedIds) {
|
|
372
377
|
const at = now();
|
|
373
378
|
const rid = plan.run_id;
|
|
@@ -381,9 +386,9 @@ export function deliveryBundle(state, plan, status, ledger, verifiedIds) {
|
|
|
381
386
|
for (const did of [...new Set(verifiedIds)].sort()) {
|
|
382
387
|
const d = status.deployments[did];
|
|
383
388
|
const hid = d.host_id;
|
|
384
|
-
const readme = deploymentReadme(status, d, rid, at, { ledger, includeCredentials: status.policies.server_readme_credentials });
|
|
389
|
+
const readme = deploymentReadme(status, d, rid, at, { ledger, includeCredentials: Boolean(status.policies.server_readme_credentials && plaintextDocumentation(status)) });
|
|
385
390
|
addRemote(hid, pathFor(status, d, "README.md"), readme);
|
|
386
|
-
const operations = deploymentReadme(status, d, rid, at, { ledger, includeCredentials:
|
|
391
|
+
const operations = deploymentReadme(status, d, rid, at, { ledger, includeCredentials: plaintextDocumentation(status) });
|
|
387
392
|
if (status.policies.server_operations) addRemote(hid, pathFor(status, d, "OPERATIONS.md"), operations);
|
|
388
393
|
addRemote(hid, pathFor(status, d, "project.yaml"), JSON.stringify(d, null, 2) + "\n");
|
|
389
394
|
addRemote(hid, pathFor(status, d, "run/release-state.json"), JSON.stringify({
|
|
@@ -397,7 +402,7 @@ export function deliveryBundle(state, plan, status, ledger, verifiedIds) {
|
|
|
397
402
|
addRemote(hid, targetJoin({ ...status.hosts[hid], root: projectroot }, "README.md"), text);
|
|
398
403
|
}
|
|
399
404
|
const prefix = `hosts/${hid}/deployments/${did}`;
|
|
400
|
-
local[prefix + "/README.md"] = deploymentReadme(status, d, rid, at, { ledger, includeCredentials:
|
|
405
|
+
local[prefix + "/README.md"] = deploymentReadme(status, d, rid, at, { ledger, includeCredentials: plaintextDocumentation(status), controller: true });
|
|
401
406
|
local[prefix + "/OPERATIONS.md"] = operations;
|
|
402
407
|
local[prefix + "/deployment.json"] = JSON.stringify(d, null, 2) + "\n";
|
|
403
408
|
local[prefix + "/server/README.md"] = readme;
|
|
@@ -420,8 +425,8 @@ export function deliveryBundle(state, plan, status, ledger, verifiedIds) {
|
|
|
420
425
|
local[`hosts/${hid}/knowledge/host-services.json`] = hostServicesDocument(h);
|
|
421
426
|
if (ingressJson) local[`hosts/${hid}/knowledge/public-ingress.json`] = ingressJson;
|
|
422
427
|
}
|
|
423
|
-
local["FLEET-DEPLOYMENTS.md"] = fleetDocument(status, Object.keys(status.hosts), rid, at, { ledger, includeCredentials:
|
|
424
|
-
local["README.md"] = "# OPS 控制端\n\n主机记录位于 hosts/<host_id>/;项目关联位于 status.json;项目部署记录位于 hosts/<host_id>/deployments/<deployment_id
|
|
428
|
+
local["FLEET-DEPLOYMENTS.md"] = fleetDocument(status, Object.keys(status.hosts), rid, at, { ledger, includeCredentials: plaintextDocumentation(status) });
|
|
429
|
+
local["README.md"] = "# OPS 控制端\n\n主机记录位于 hosts/<host_id>/;项目关联位于 status.json;项目部署记录位于 hosts/<host_id>/deployments/<deployment_id>/;总册位于 FLEET-DEPLOYMENTS.md(新安装默认仅引用凭据版本);凭据账本位于 private/credentials.json;执行证据按 host runs / release 保存。主机级入口见 hosts/<id>/knowledge/host-services.json,跨主机公网入口见 knowledge/public-ingress.json。\n\n不得清理此运行态目录来替换静态 workflow。双边文档完成由每次运行 docs-receipt.json 证明。\n";
|
|
425
430
|
local["docs/standards/DEPLOYMENT-STANDARD.md"] = STANDARD;
|
|
426
431
|
if (!existsSync(join(state, "knowledge", "INDEX.md"))) {
|
|
427
432
|
local["knowledge/INDEX.md"] = "# 共享知识索引\n\n只收录经用户批准、带来源与最后验证日期的通用知识。运行时环境和密码不自动提升为共享知识。跨主机入口规范见各主机 knowledge/public-ingress.json。\n";
|