@namewta/speculo 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/workflows/ops/H-host-manage/H-host-manage.md +4 -0
- package/template/workflows/ops/README.md +6 -3
- package/template/workflows/ops/common/USAGE.md +1 -1
- package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
- package/template/workflows/ops/common/schemas/host.schema.json +64 -1
- package/template/workflows/ops/common/schemas/plan.schema.json +312 -0
- package/template/workflows/ops/common/schemas/spec.schema.json +136 -0
- package/template/workflows/ops/common/schemas/status.schema.json +142 -0
- package/template/workflows/ops/common/service-profiles/elasticsearch.md +13 -0
- package/template/workflows/ops/common/service-profiles/redis.md +4 -0
- package/template/workflows/ops/common/templates/CONTROLLER-RECORD.md +16 -2
- package/template/workflows/ops/common/templates/HOST-README.md +12 -2
- package/template/workflows/ops/common/tests/test_ops.mjs +232 -2
- package/template/workflows/ops/common/tools/opslib/agent.mjs +44 -13
- package/template/workflows/ops/common/tools/opslib/control_files.mjs +57 -0
- package/template/workflows/ops/common/tools/opslib/docs.mjs +202 -18
- package/template/workflows/ops/common/tools/opslib/execution.mjs +31 -13
- package/template/workflows/ops/common/tools/opslib/planner.mjs +108 -14
- package/template/workflows/ops/common/tools/opslib/transport.mjs +1 -1
|
@@ -194,6 +194,16 @@
|
|
|
194
194
|
],
|
|
195
195
|
"additionalProperties": false
|
|
196
196
|
},
|
|
197
|
+
"public_ingress": {
|
|
198
|
+
"oneOf": [
|
|
199
|
+
{
|
|
200
|
+
"type": "null"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"$ref": "#/$defs/public_ingress"
|
|
204
|
+
}
|
|
205
|
+
]
|
|
206
|
+
},
|
|
197
207
|
"updated_at": {
|
|
198
208
|
"type": [
|
|
199
209
|
"string",
|
|
@@ -290,6 +300,64 @@
|
|
|
290
300
|
},
|
|
291
301
|
"required": [],
|
|
292
302
|
"additionalProperties": false
|
|
303
|
+
},
|
|
304
|
+
"host_services": {
|
|
305
|
+
"type": "array",
|
|
306
|
+
"items": {
|
|
307
|
+
"type": "object",
|
|
308
|
+
"properties": {
|
|
309
|
+
"id": {
|
|
310
|
+
"type": "string",
|
|
311
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
312
|
+
"maxLength": 80
|
|
313
|
+
},
|
|
314
|
+
"kind": {
|
|
315
|
+
"enum": [
|
|
316
|
+
"wireguard",
|
|
317
|
+
"nginx",
|
|
318
|
+
"systemd",
|
|
319
|
+
"probe",
|
|
320
|
+
"other"
|
|
321
|
+
]
|
|
322
|
+
},
|
|
323
|
+
"unit": {
|
|
324
|
+
"type": "string",
|
|
325
|
+
"minLength": 1
|
|
326
|
+
},
|
|
327
|
+
"listen_port": {
|
|
328
|
+
"type": "integer",
|
|
329
|
+
"minimum": 1,
|
|
330
|
+
"maximum": 65535
|
|
331
|
+
},
|
|
332
|
+
"listen": {
|
|
333
|
+
"type": "array",
|
|
334
|
+
"items": {
|
|
335
|
+
"type": "string",
|
|
336
|
+
"minLength": 1
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
"tunnel_address": {
|
|
340
|
+
"type": "string",
|
|
341
|
+
"minLength": 1
|
|
342
|
+
},
|
|
343
|
+
"config_path": {
|
|
344
|
+
"type": "string",
|
|
345
|
+
"minLength": 1
|
|
346
|
+
},
|
|
347
|
+
"public_url": {
|
|
348
|
+
"type": "string",
|
|
349
|
+
"minLength": 1
|
|
350
|
+
},
|
|
351
|
+
"notes": {
|
|
352
|
+
"type": "string"
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
"required": [
|
|
356
|
+
"id",
|
|
357
|
+
"kind"
|
|
358
|
+
],
|
|
359
|
+
"additionalProperties": false
|
|
360
|
+
}
|
|
293
361
|
}
|
|
294
362
|
},
|
|
295
363
|
"required": [
|
|
@@ -766,6 +834,80 @@
|
|
|
766
834
|
"network"
|
|
767
835
|
],
|
|
768
836
|
"additionalProperties": false
|
|
837
|
+
},
|
|
838
|
+
"public_ingress": {
|
|
839
|
+
"type": "object",
|
|
840
|
+
"properties": {
|
|
841
|
+
"schema_version": {
|
|
842
|
+
"const": 1
|
|
843
|
+
},
|
|
844
|
+
"title": {
|
|
845
|
+
"type": "string",
|
|
846
|
+
"minLength": 1
|
|
847
|
+
},
|
|
848
|
+
"path": {
|
|
849
|
+
"type": "string",
|
|
850
|
+
"minLength": 1
|
|
851
|
+
},
|
|
852
|
+
"not_the_same_as": {
|
|
853
|
+
"type": "string"
|
|
854
|
+
},
|
|
855
|
+
"mappings": {
|
|
856
|
+
"type": "array",
|
|
857
|
+
"items": {
|
|
858
|
+
"type": "object",
|
|
859
|
+
"properties": {
|
|
860
|
+
"public": {
|
|
861
|
+
"type": "string",
|
|
862
|
+
"minLength": 1
|
|
863
|
+
},
|
|
864
|
+
"layer4": {
|
|
865
|
+
"type": "string"
|
|
866
|
+
},
|
|
867
|
+
"via": {
|
|
868
|
+
"type": "array",
|
|
869
|
+
"items": {
|
|
870
|
+
"type": "string"
|
|
871
|
+
}
|
|
872
|
+
},
|
|
873
|
+
"backend": {
|
|
874
|
+
"type": "string",
|
|
875
|
+
"minLength": 1
|
|
876
|
+
},
|
|
877
|
+
"backend_deployment": {
|
|
878
|
+
"type": "string"
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
"required": [
|
|
882
|
+
"public",
|
|
883
|
+
"backend"
|
|
884
|
+
],
|
|
885
|
+
"additionalProperties": false
|
|
886
|
+
}
|
|
887
|
+
},
|
|
888
|
+
"open_http_checklist": {
|
|
889
|
+
"type": "array",
|
|
890
|
+
"items": {
|
|
891
|
+
"type": "string",
|
|
892
|
+
"minLength": 1
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
"forbidden_ports": {
|
|
896
|
+
"type": "array",
|
|
897
|
+
"items": {
|
|
898
|
+
"type": "integer",
|
|
899
|
+
"minimum": 1,
|
|
900
|
+
"maximum": 65535
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
"required": [
|
|
905
|
+
"schema_version",
|
|
906
|
+
"title",
|
|
907
|
+
"path",
|
|
908
|
+
"mappings"
|
|
909
|
+
],
|
|
910
|
+
"additionalProperties": false
|
|
769
911
|
}
|
|
770
912
|
}
|
|
771
913
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# elasticsearch 服务档案
|
|
2
|
+
|
|
3
|
+
提供者是独立 elasticsearch 项目(或用户选定的 kebab ID),`service_type` 用实际产品名,不伪装成内置分配 adapter。image 固定摘要。数据在 `data/elasticsearch/data`,日志在 `logs/elasticsearch`;容器内 data/logs 路径按所用镜像 VOLUME 显式 bind,禁止匿名卷。
|
|
4
|
+
|
|
5
|
+
官方 Elasticsearch 8 会在启动时写 `elasticsearch.keystore`(临时文件 `elasticsearch.keystore.tmp`)。把 config/data/logs bind 到项目目录后:
|
|
6
|
+
|
|
7
|
+
- 容器用户必须能写 data 与实际 keystore 目录;不要把 keystore 放进只读 config bind。
|
|
8
|
+
- 仅当镜像仍必须写容器根文件系统时,才允许 `read_only: false`,并提供不少于 12 字符的 `writable_root_justification`。默认仍是只读根。
|
|
9
|
+
- `config/` 与 `*.conf`/`*.yml` 默认文件模式 0644,供非 root 镜像用户读取;config/env bind 必须 `read_only: true`。
|
|
10
|
+
|
|
11
|
+
生产健康检查不能只用 docker-proxy 上的 TCP。Compose 服务应声明 `healthcheck`;`compose --wait` 之后执行器要求 `State.Status=running`、非 OOMKilled/Restarting,若存在 Health 则必须 `healthy`。
|
|
12
|
+
|
|
13
|
+
没有内置集群分配/账号 adapter。跨项目访问通过 existing + 实际授权探测,或专用 host/project 脚本;不能把「能 compose up」写成已具备逻辑资源恢复能力。备份使用该版本可验证的 snapshot/仓库工具;单 APP 回滚不得恢复整个集群。
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
提供者独立 redis-main 项目,service_type=redis,service.compose_service 指向实际服务。数据在 data/redis/instance;ACL 文件配置为 data/redis/acl/users.acl(service.acl_persistence 必须完全匹配),容器 ACL 写入路径必须映射到这个项目数据目录,不能将 ACL SAVE 写到只读 config。
|
|
4
4
|
|
|
5
|
+
`config/redis.conf` 以及 `*.acl` 默认文件模式 0644。Redis 镜像若以非 root 用户运行,0600 root 配置会导致 `can't open config file` 后重启循环。config bind 必须只读;可写数据只在 data/logs/run。不要为了读配置而把整个容器根改成可写。
|
|
6
|
+
|
|
5
7
|
每个 APP 使用不同 ACL username、prefix 和凭据;禁用/限制危险管理命令,内置分配使用受限命令类别。必须核验真实客户端兼容性、ACL SAVE 和新账号认证。prefix/数字 DB 不等于独立内存/CPU/备份恢复边界;要求强隔离时部署专用实例。
|
|
6
8
|
|
|
9
|
+
生产健康检查使用 redis-cli PING 或镜像 healthcheck,不能把 docker-proxy 的 TCP listen 当成进程可用。无 healthcheck 时 `compose --wait` 只表示容器已创建。
|
|
10
|
+
|
|
7
11
|
持久化 AOF/RDB 属于业务数据不是可随意清理的日志。公共实例恢复影响所有消费者,需要整体维护批准。
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
# 部署机完整明文记录
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
此文件是内容合同;控制端总册在策略启用时于表格单元格内保存真实账号密码,服务端 README 默认仍不写密码。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 服务一览表
|
|
6
|
+
|
|
7
|
+
主机、服务(含主机级入口)、访问地址、账号、密码、版本、最近部署时间、持久化目录。特殊字符逐字保留。路径使用 Markdown,不用 HTML `<code>`。
|
|
8
|
+
|
|
9
|
+
## 公网访问内网
|
|
10
|
+
|
|
11
|
+
当前映射行、四层条件、新开公网 HTTP 清单、禁止项。与 Clash/TUN 出口不是同一条连接。
|
|
12
|
+
|
|
13
|
+
## 说明
|
|
14
|
+
|
|
15
|
+
特别约束、启停、备份恢复。不把每个项目 README 全文拼进 FLEET / 主机 DEPLOYMENTS。
|
|
6
16
|
|
|
7
17
|
## 真实用户名、密码、访问密钥与用途
|
|
8
18
|
|
|
19
|
+
表格单元格 + 项目 OPERATIONS;未知旧密码不能编造。密钥认证记录实际认证方式。
|
|
20
|
+
|
|
9
21
|
## 服务端配置和 README 镜像位置
|
|
10
22
|
|
|
23
|
+
hosts/<host_id>/deployments/<id>/server-files 与 server/README。主机级入口:hosts/<id>/knowledge/host-services.json。
|
|
24
|
+
|
|
11
25
|
## 运行记录、局部失败/unknown、双边回执
|
|
12
26
|
|
|
13
27
|
## 业务数据备份实际位置,不声称仅记录配置就备份数据
|
|
@@ -4,8 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
## host_id、实际身份、host_root、更新 UTC 时间
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 服务一览表
|
|
8
|
+
|
|
9
|
+
主机、服务(含 APP / 共享服务 / WireGuard / Nginx / 探测单元)、访问地址、账号(服务端不写密码)、版本、最近验证时间、持久化或配置路径。未写入 host_services 或部署账本的入口下次交付不会出现。
|
|
10
|
+
|
|
11
|
+
## 公网访问内网(若已登记 public_ingress)
|
|
12
|
+
|
|
13
|
+
映射表、四层条件、新开公网 HTTP 服务步骤、禁止暴露的端口。入口与出口不是同一条连接。
|
|
14
|
+
|
|
15
|
+
## 说明
|
|
16
|
+
|
|
17
|
+
各主机级服务的 ListenPort / 隧道地址 / 配置路径 / 规范,以及 APP 备份恢复摘要。完整启停命令在项目 README,不把项目全文拼进主机总册。
|
|
8
18
|
|
|
9
19
|
## 通用规范、知识与部署总册索引
|
|
10
20
|
|
|
11
|
-
|
|
21
|
+
`docs/standards/DEPLOYMENT-STANDARD.md`;`knowledge/host-services.json`;`knowledge/public-ingress.json`;`DEPLOYMENTS.md`。
|
|
@@ -13,6 +13,7 @@ import * as agent from "../tools/opslib/agent.mjs";
|
|
|
13
13
|
import * as transport from "../tools/opslib/transport.mjs";
|
|
14
14
|
import * as docs from "../tools/opslib/docs.mjs";
|
|
15
15
|
import * as services from "../tools/opslib/services.mjs";
|
|
16
|
+
import * as controlFiles from "../tools/opslib/control_files.mjs";
|
|
16
17
|
import { initialize, analyze, cliHooks } from "../tools/opslib/cli.mjs";
|
|
17
18
|
import { taskFiles } from "../tools/opslib/native_windows.mjs";
|
|
18
19
|
|
|
@@ -440,9 +441,16 @@ test("planner contracts", async (t) => {
|
|
|
440
441
|
const [info] = fx.plan();
|
|
441
442
|
assert.throws(() => execution.approval(fx.state, info.run_id, "0".repeat(64), "test", "explicit statement"), OpsError);
|
|
442
443
|
});
|
|
443
|
-
await each("
|
|
444
|
+
await each("unrelated host registration does not block approval", (fx) => {
|
|
444
445
|
const [info] = fx.plan();
|
|
445
|
-
model.register(fx.state, {});
|
|
446
|
+
model.register(fx.state, { hosts: [{ ...fx.host, host_id: "node-b", display_name: "Node B", root: join(fx.tmp, "server-b") }] });
|
|
447
|
+
fx.approve(info);
|
|
448
|
+
});
|
|
449
|
+
await each("touched host catalog drift blocks approval", (fx) => {
|
|
450
|
+
const [info] = fx.plan();
|
|
451
|
+
const s = model.load(fx.state);
|
|
452
|
+
s.hosts["node-a"] = { ...s.hosts["node-a"], display_name: "Renamed" };
|
|
453
|
+
model.save(fx.state, s);
|
|
446
454
|
assert.throws(() => fx.approve(info), OpsError);
|
|
447
455
|
});
|
|
448
456
|
await each("approval is immutable", (fx) => {
|
|
@@ -489,6 +497,81 @@ test("planner contracts", async (t) => {
|
|
|
489
497
|
await each("compose rejects writable root", (fx) => {
|
|
490
498
|
assert.throws(() => planner.composeModel({ compose: { services: { app: { image: "example@sha256:" + "a".repeat(64), read_only: false } } } }, fx.host, join(fx.target, "app-a"), "ops-app-a", {}, {}), OpsError);
|
|
491
499
|
});
|
|
500
|
+
await each("compose allows justified writable root", (fx) => {
|
|
501
|
+
const [value] = planner.composeModel({
|
|
502
|
+
compose: {
|
|
503
|
+
services: {
|
|
504
|
+
app: {
|
|
505
|
+
image: "example@sha256:" + "a".repeat(64),
|
|
506
|
+
read_only: false,
|
|
507
|
+
writable_root_justification: "elasticsearch keystore tmp must be created at process start",
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
}, fx.host, join(fx.target, "app-a"), "ops-app-a", {}, {});
|
|
512
|
+
assert.equal(value.services.app.read_only, false);
|
|
513
|
+
assert.equal("writable_root_justification" in value.services.app, false);
|
|
514
|
+
});
|
|
515
|
+
await each("config files default to 0644", (fx) => {
|
|
516
|
+
const s = fx.spec();
|
|
517
|
+
s.deployments[0].files.push({ path: "config/redis.conf", content: "port 6379\n" });
|
|
518
|
+
const [, plan] = fx.plan(s);
|
|
519
|
+
const conf = plan.operations.find((o) => o.kind === "write" && String(o.path).replaceAll("\\", "/").endsWith("config/redis.conf"));
|
|
520
|
+
const env = plan.operations.find((o) => o.kind === "write" && String(o.path).replaceAll("\\", "/").includes("/env/"));
|
|
521
|
+
assert.equal(conf.mode, 0o644);
|
|
522
|
+
assert.equal(env.mode, 0o600);
|
|
523
|
+
});
|
|
524
|
+
await each("host write-file cannot overlay generated docs", (fx) => {
|
|
525
|
+
assert.throws(() => fx.plan({
|
|
526
|
+
schema_version: 1, worker: "H", operation: "prepare", reason: "test overlay",
|
|
527
|
+
rollback_note: "Retain generated host documentation.",
|
|
528
|
+
host_actions: [{ host_id: "node-a", kind: "write-file", reason: "Overwrite generated README", path: "README.md", content: "fake success" }],
|
|
529
|
+
}), OpsError);
|
|
530
|
+
});
|
|
531
|
+
await each("declared nginx control file is planned", (fx) => {
|
|
532
|
+
const [, plan] = fx.plan({
|
|
533
|
+
schema_version: 1, worker: "H", operation: "prepare", reason: "Persist reviewed nginx site",
|
|
534
|
+
rollback_note: "Restore previous nginx conf from backup copy.",
|
|
535
|
+
host_actions: [{
|
|
536
|
+
host_id: "node-a", kind: "write-control", reason: "Public ingress site for reviewed backend",
|
|
537
|
+
path: "/etc/nginx/conf.d/app.conf", content: "server { listen 57492; }\n",
|
|
538
|
+
rollback: "Restore /etc/nginx/conf.d/app.conf from the run backup.",
|
|
539
|
+
verification: [{ type: "command", argv: [process.execPath, "-e", "process.exit(0)"] }],
|
|
540
|
+
}],
|
|
541
|
+
});
|
|
542
|
+
assert.ok(plan.external_files["node-a"].includes("/etc/nginx/conf.d/app.conf"));
|
|
543
|
+
assert.equal(plan.operations.find((o) => o.path === "/etc/nginx/conf.d/app.conf").mode, 0o644);
|
|
544
|
+
});
|
|
545
|
+
await each("undeclared control path rejected", (fx) => {
|
|
546
|
+
assert.throws(() => fx.plan({
|
|
547
|
+
schema_version: 1, worker: "H", operation: "prepare", reason: "should fail",
|
|
548
|
+
rollback_note: "Do not write arbitrary system files.",
|
|
549
|
+
host_actions: [{ host_id: "node-a", kind: "write-control", reason: "not a control file", path: "/etc/passwd", content: "x\n" }],
|
|
550
|
+
}), OpsError);
|
|
551
|
+
});
|
|
552
|
+
await each("core systemd unit control rejected", (fx) => {
|
|
553
|
+
assert.throws(() => fx.plan({
|
|
554
|
+
schema_version: 1, worker: "H", operation: "prepare", reason: "should fail",
|
|
555
|
+
rollback_note: "Do not replace sshd unit files.",
|
|
556
|
+
host_actions: [{
|
|
557
|
+
host_id: "node-a", kind: "write-control", reason: "core unit forbidden",
|
|
558
|
+
path: "/etc/systemd/system/sshd.service", content: "[Unit]\n",
|
|
559
|
+
rollback: "Restore sshd.service from package.",
|
|
560
|
+
verification: [{ type: "command", argv: [process.execPath, "-e", "process.exit(0)"] }],
|
|
561
|
+
}],
|
|
562
|
+
}), OpsError);
|
|
563
|
+
});
|
|
564
|
+
await each("declared control file needs verification", (fx) => {
|
|
565
|
+
assert.throws(() => fx.plan({
|
|
566
|
+
schema_version: 1, worker: "H", operation: "prepare", reason: "should fail",
|
|
567
|
+
rollback_note: "Restore previous nginx conf from backup copy.",
|
|
568
|
+
host_actions: [{
|
|
569
|
+
host_id: "node-a", kind: "write-control", reason: "Public ingress site",
|
|
570
|
+
path: "/etc/nginx/nginx.conf", content: "http {}\n",
|
|
571
|
+
rollback: "Restore nginx.conf from backup.",
|
|
572
|
+
}],
|
|
573
|
+
}), OpsError);
|
|
574
|
+
});
|
|
492
575
|
await each("compose safe persistence model", (fx) => {
|
|
493
576
|
const [value] = planner.composeModel({ compose: { services: { app: { image: "example@sha256:" + "a".repeat(64), volumes: [{ type: "bind", source: "data/app/storage", target: "/data" }] } } } }, fx.host, join(fx.target, "app-a"), "ops-app-a", { "app.env": {} }, {});
|
|
494
577
|
assert.equal(value.services.app.read_only, true);
|
|
@@ -737,6 +820,8 @@ test("ssh contract", () => {
|
|
|
737
820
|
assert.ok(captured.argv.includes("BatchMode=yes"));
|
|
738
821
|
assert.ok(!joined.includes(PASSWORD));
|
|
739
822
|
assert.ok(captured.argv.includes("UserKnownHostsFile=" + kh));
|
|
823
|
+
assert.ok(Buffer.isBuffer(captured.input));
|
|
824
|
+
assert.equal(captured.encoding, "buffer");
|
|
740
825
|
} finally { transport.transportHooks.spawnSync = prev; rmSync(d, { recursive: true, force: true }); }
|
|
741
826
|
});
|
|
742
827
|
|
|
@@ -750,3 +835,148 @@ test("knownhosts digest drift", () => {
|
|
|
750
835
|
assert.notEqual(before, transport.hostTransportDigest(h));
|
|
751
836
|
rmSync(d, { recursive: true, force: true });
|
|
752
837
|
});
|
|
838
|
+
|
|
839
|
+
test("command keeps raw docker json when secrets appear", () => {
|
|
840
|
+
const fx = agentFixture();
|
|
841
|
+
try {
|
|
842
|
+
const secret = "1234";
|
|
843
|
+
const json = JSON.stringify({ State: { Status: "running" }, Env: ["MYSQL_ROOT_PASSWORD=" + secret] });
|
|
844
|
+
const prev = agent.agentHooks.spawnSync;
|
|
845
|
+
agent.agentHooks.spawnSync = () => ({ status: 0, stdout: Buffer.from(json), stderr: Buffer.alloc(0) });
|
|
846
|
+
try {
|
|
847
|
+
const r = agent.command(["docker", "inspect", "x"], { cwd: fx.root, secrets: [secret] });
|
|
848
|
+
assert.equal(JSON.parse(r.stdout).Env[0], "MYSQL_ROOT_PASSWORD=" + secret);
|
|
849
|
+
assert.ok(!r.log_stdout.includes(secret));
|
|
850
|
+
assert.equal(agent.parseDockerJson(r.stdout, "inspect").State.Status, "running");
|
|
851
|
+
} finally { agent.agentHooks.spawnSync = prev; }
|
|
852
|
+
} finally { fx.cleanup(); }
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test("command stdin is buffer with encoding buffer", () => {
|
|
856
|
+
const fx = agentFixture();
|
|
857
|
+
try {
|
|
858
|
+
const captured = {};
|
|
859
|
+
const prev = agent.agentHooks.spawnSync;
|
|
860
|
+
agent.agentHooks.spawnSync = (_cmd, _args, kw) => {
|
|
861
|
+
Object.assign(captured, kw);
|
|
862
|
+
return { status: 0, stdout: Buffer.from("ok"), stderr: Buffer.alloc(0) };
|
|
863
|
+
};
|
|
864
|
+
try {
|
|
865
|
+
agent.command(["echo"], { cwd: fx.root, stdin: "hello" });
|
|
866
|
+
assert.ok(Buffer.isBuffer(captured.input));
|
|
867
|
+
assert.equal(captured.input.toString("utf8"), "hello");
|
|
868
|
+
assert.equal(captured.encoding, "buffer");
|
|
869
|
+
} finally { agent.agentHooks.spawnSync = prev; }
|
|
870
|
+
} finally { fx.cleanup(); }
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test("inspect container gate requires running healthy", () => {
|
|
874
|
+
agent.inspectContainerGate({ Name: "/ok", State: { Status: "running" } });
|
|
875
|
+
agent.inspectContainerGate({ Name: "/okh", State: { Status: "running", Health: { Status: "healthy" } } });
|
|
876
|
+
assert.throws(() => agent.inspectContainerGate({ Name: "/exited", State: { Status: "exited" } }), agent.Failure);
|
|
877
|
+
assert.throws(() => agent.inspectContainerGate({ Name: "/oom", State: { Status: "running", OOMKilled: true } }), agent.Failure);
|
|
878
|
+
assert.throws(() => agent.inspectContainerGate({ Name: "/restart", State: { Status: "running", Restarting: true } }), agent.Failure);
|
|
879
|
+
assert.throws(() => agent.inspectContainerGate({ Name: "/starting", State: { Status: "running", Health: { Status: "starting" } } }), agent.Failure);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test("control file helpers", () => {
|
|
883
|
+
assert.equal(controlFiles.isBuiltinControlPath("/etc/docker/daemon.json"), true);
|
|
884
|
+
assert.equal(controlFiles.isDeclaredControlPath("/etc/nginx/conf.d/app.conf"), true);
|
|
885
|
+
assert.equal(controlFiles.isDeclaredControlPath("/etc/wireguard/wg0.conf"), true);
|
|
886
|
+
assert.equal(controlFiles.defaultFileMode("config/redis.conf"), 0o644);
|
|
887
|
+
assert.equal(controlFiles.defaultFileMode("env/app.env"), 0o600);
|
|
888
|
+
assert.throws(() => controlFiles.assertSafeControlPath("/etc/shadow"), OpsError);
|
|
889
|
+
assert.throws(() => controlFiles.assertSafeControlPath("/etc/systemd/system/sshd.service", { reason: "reviewed unit", rollback: "restore package unit" }), OpsError);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("disjoint host plans apply sequentially", () => runFx((fx) => {
|
|
893
|
+
model.register(fx.state, { hosts: [{ ...fx.host, host_id: "node-b", display_name: "Node B", root: join(fx.tmp, "server-b") }] });
|
|
894
|
+
const specA = {
|
|
895
|
+
schema_version: 1, worker: "H", operation: "maintain", hosts: ["node-a"], reason: "Host A cache fixture",
|
|
896
|
+
rollback_note: "Keep isolated cache until a separate purge is approved.",
|
|
897
|
+
host_actions: [{ host_id: "node-a", kind: "write-file", reason: "Create only fixture cache A", path: "_host/cache/a/cache.txt", content: "A" }],
|
|
898
|
+
};
|
|
899
|
+
const specB = {
|
|
900
|
+
schema_version: 1, worker: "H", operation: "maintain", hosts: ["node-b"], reason: "Host B cache fixture",
|
|
901
|
+
rollback_note: "Keep isolated cache until a separate purge is approved.",
|
|
902
|
+
host_actions: [{ host_id: "node-b", kind: "write-file", reason: "Create only fixture cache B", path: "_host/cache/b/cache.txt", content: "B" }],
|
|
903
|
+
};
|
|
904
|
+
const [infoA] = fx.plan(specA);
|
|
905
|
+
const [infoB] = fx.plan(specB);
|
|
906
|
+
fx.approve(infoA);
|
|
907
|
+
fx.approve(infoB);
|
|
908
|
+
const rB = execution.apply(fx.state, infoB.run_id);
|
|
909
|
+
assert.equal(rB.status, "completed", JSON.stringify(rB));
|
|
910
|
+
const rA = execution.apply(fx.state, infoA.run_id);
|
|
911
|
+
assert.equal(rA.status, "completed", JSON.stringify(rA));
|
|
912
|
+
assert.equal(readFileSync(join(fx.target, "_host", "cache", "a", "cache.txt"), "utf8"), "A");
|
|
913
|
+
assert.equal(readFileSync(join(fx.tmp, "server-b", "_host", "cache", "b", "cache.txt"), "utf8"), "B");
|
|
914
|
+
}));
|
|
915
|
+
|
|
916
|
+
test("host and fleet handbooks are markdown service tables", () => runFx((fx) => {
|
|
917
|
+
const s = fx.graph();
|
|
918
|
+
s.hosts["node-a"].host_services = [{
|
|
919
|
+
id: "wg0", kind: "wireguard", unit: "wg-quick@wg0", listen_port: 51820,
|
|
920
|
+
tunnel_address: "10.77.0.2/24", config_path: "/etc/wireguard/wg0.conf",
|
|
921
|
+
notes: "入口隧道,不是 Clash 出口",
|
|
922
|
+
}, {
|
|
923
|
+
id: "nginx-public", kind: "nginx", unit: "nginx.service", listen_port: 57492,
|
|
924
|
+
public_url: "http://203.0.113.10:57492/", config_path: "/etc/nginx/conf.d/app.conf",
|
|
925
|
+
}];
|
|
926
|
+
s.public_ingress = {
|
|
927
|
+
schema_version: 1, title: "公网访问内网", path: "knowledge/public-ingress.json",
|
|
928
|
+
not_the_same_as: "Clash TUN 是出口",
|
|
929
|
+
mappings: [{ public: "http://203.0.113.10:57492/", layer4: "57492/tcp", via: ["nginx-public", "wg0"], backend: "10.77.0.2:8080" }],
|
|
930
|
+
open_http_checklist: ["在入口主机增加 Nginx listen", "更新 WG AllowedIPs"],
|
|
931
|
+
forbidden_ports: [22, 3306],
|
|
932
|
+
};
|
|
933
|
+
const fleet = docs.fleetDocument(s, ["node-a"], "run-test", now(), { ledger: model.ledgerLoad(fx.state), includeCredentials: true });
|
|
934
|
+
const server = docs.hostReadme(s, "node-a", "run-test", now(), { full: true });
|
|
935
|
+
for (const text of [fleet, server, docs.code("/srv/ops")]) {
|
|
936
|
+
assert.ok(!text.includes("<code>"));
|
|
937
|
+
assert.ok(!text.includes("<br>"));
|
|
938
|
+
}
|
|
939
|
+
assert.ok(fleet.includes("服务一览"));
|
|
940
|
+
assert.ok(fleet.includes("说明"));
|
|
941
|
+
assert.ok(fleet.includes("公网访问内网"));
|
|
942
|
+
assert.ok(fleet.includes("10.77.0.2:8080"));
|
|
943
|
+
assert.ok(fleet.includes("wg-quick@wg0"));
|
|
944
|
+
assert.ok(fleet.includes("新开公网 HTTP"));
|
|
945
|
+
assert.ok(fleet.includes(PASSWORD));
|
|
946
|
+
assert.ok(!server.includes(PASSWORD));
|
|
947
|
+
assert.ok(server.includes("服务一览"));
|
|
948
|
+
assert.ok(docs.hostServicesDocument(s.hosts["node-a"]).includes("wg0"));
|
|
949
|
+
}));
|
|
950
|
+
|
|
951
|
+
test("host services and public ingress are delivered", () => runFx((fx) => {
|
|
952
|
+
const host = { ...fx.host, host_services: [{
|
|
953
|
+
id: "wg0", kind: "wireguard", unit: "wg-quick@wg0", listen_port: 51820,
|
|
954
|
+
tunnel_address: "10.77.0.2/24", config_path: "/etc/wireguard/wg0.conf",
|
|
955
|
+
}] };
|
|
956
|
+
const spec = {
|
|
957
|
+
schema_version: 1, worker: "H", operation: "maintain", hosts: ["node-a"],
|
|
958
|
+
reason: "Record host ingress ledger",
|
|
959
|
+
rollback_note: "Keep previous host-services ledger until a new plan replaces it.",
|
|
960
|
+
resource_updates: { hosts: [host] },
|
|
961
|
+
public_ingress: {
|
|
962
|
+
schema_version: 1, title: "公网访问内网", path: "knowledge/public-ingress.json",
|
|
963
|
+
mappings: [{ public: "http://203.0.113.10:57492/", layer4: "57492/tcp", via: ["wg0"], backend: "10.77.0.2:8080" }],
|
|
964
|
+
open_http_checklist: ["更新 Nginx 与 WG", "刷新 public-ingress.json"],
|
|
965
|
+
forbidden_ports: [22],
|
|
966
|
+
},
|
|
967
|
+
host_actions: [{ host_id: "node-a", kind: "write-file", reason: "Create only fixture cache", path: "_host/cache/demo/cache.txt", content: "cache" }],
|
|
968
|
+
};
|
|
969
|
+
const [, , r] = fx.runSpec(spec);
|
|
970
|
+
assert.equal(r.status, "completed", JSON.stringify(r));
|
|
971
|
+
const catalog = model.load(fx.state);
|
|
972
|
+
assert.equal(catalog.hosts["node-a"].host_services[0].id, "wg0");
|
|
973
|
+
assert.equal(catalog.public_ingress.mappings[0].backend, "10.77.0.2:8080");
|
|
974
|
+
const remoteServices = readJson(join(fx.target, "knowledge", "host-services.json"));
|
|
975
|
+
assert.equal(remoteServices.services[0].unit, "wg-quick@wg0");
|
|
976
|
+
const remoteIngress = readJson(join(fx.target, "knowledge", "public-ingress.json"));
|
|
977
|
+
assert.ok(remoteIngress.mappings[0].public.includes("57492"));
|
|
978
|
+
const fleet = readFileSync(join(fx.state, "FLEET-DEPLOYMENTS.md"), "utf8");
|
|
979
|
+
assert.ok(fleet.includes("服务一览"));
|
|
980
|
+
assert.ok(fleet.includes("公网访问内网"));
|
|
981
|
+
assert.ok(!fleet.includes("<code>"));
|
|
982
|
+
}));
|
|
@@ -288,9 +288,6 @@ function under(path, root, allowRoot = false) {
|
|
|
288
288
|
|
|
289
289
|
function checkedPath(path, req, allowRoot = false) {
|
|
290
290
|
if ((req.external_files ?? []).includes(path)) {
|
|
291
|
-
if (path !== "/etc/docker/daemon.json" && !/^\/etc\/systemd\/system\/ops-[a-z0-9-]+\.service$/.test(path)) {
|
|
292
|
-
throw new Failure("unrecognized external control file");
|
|
293
|
-
}
|
|
294
291
|
noLinks(path);
|
|
295
292
|
return resolve(path);
|
|
296
293
|
}
|
|
@@ -304,7 +301,14 @@ export function stripSecrets(text, values) {
|
|
|
304
301
|
return text.replace(/(password|passwd|token|secret|access_key)(\s*[=:]\s*)[^\s,;]+/gi, "$1$2[REDACTED]");
|
|
305
302
|
}
|
|
306
303
|
|
|
307
|
-
|
|
304
|
+
export const agentHooks = { spawnSync };
|
|
305
|
+
|
|
306
|
+
function stdinBuffer(stdin) {
|
|
307
|
+
if (stdin == null) return undefined;
|
|
308
|
+
return Buffer.isBuffer(stdin) ? stdin : Buffer.from(String(stdin), "utf8");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function command(argv, { cwd, env = null, stdin = null, timeout = 300, secrets = null, success_codes = null } = {}) {
|
|
308
312
|
if (!Array.isArray(argv) || !argv.length || !argv.every((x) => typeof x === "string" && !x.includes("\0"))) {
|
|
309
313
|
throw new Failure("argv must be a nonempty string array");
|
|
310
314
|
}
|
|
@@ -313,10 +317,11 @@ function command(argv, { cwd, env = null, stdin = null, timeout = 300, secrets =
|
|
|
313
317
|
mkdir(runTmp, 0o700);
|
|
314
318
|
const outPath = join(runTmp, "out.bin"), errPath = join(runTmp, "err.bin");
|
|
315
319
|
try {
|
|
316
|
-
|
|
320
|
+
// Node 24: encoding "buffer" with a string input throws ERR_UNKNOWN_ENCODING.
|
|
321
|
+
const p = agentHooks.spawnSync(argv[0], argv.slice(1), {
|
|
317
322
|
cwd,
|
|
318
323
|
env: { ...process.env, ...(env || {}), TMPDIR: runTmp, TMP: runTmp, TEMP: runTmp },
|
|
319
|
-
input: stdin
|
|
324
|
+
input: stdinBuffer(stdin),
|
|
320
325
|
timeout: timeout * 1000,
|
|
321
326
|
maxBuffer: 32 * 1024 * 1024,
|
|
322
327
|
encoding: "buffer",
|
|
@@ -326,14 +331,18 @@ function command(argv, { cwd, env = null, stdin = null, timeout = 300, secrets =
|
|
|
326
331
|
if (p.error) throw new Failure(String(p.error.message || p.error));
|
|
327
332
|
const rawOut = (p.stdout || Buffer.alloc(0)).subarray(0, 2 * 1024 * 1024);
|
|
328
333
|
const rawErr = (p.stderr || Buffer.alloc(0)).subarray(0, 2 * 1024 * 1024);
|
|
334
|
+
const stdout = rawOut.toString("utf8");
|
|
335
|
+
const stderr = rawErr.toString("utf8");
|
|
329
336
|
const result = {
|
|
330
337
|
exit_code: p.status,
|
|
331
|
-
stdout
|
|
332
|
-
stderr
|
|
338
|
+
stdout,
|
|
339
|
+
stderr,
|
|
340
|
+
log_stdout: stripSecrets(stdout, values),
|
|
341
|
+
log_stderr: stripSecrets(stderr, values),
|
|
333
342
|
output_sha256: sha(Buffer.concat([rawOut, rawErr])),
|
|
334
343
|
};
|
|
335
344
|
if (!(success_codes || [0]).includes(p.status)) {
|
|
336
|
-
throw new Failure("command failed with exit=" + p.status + "; output_sha256=" + result.output_sha256);
|
|
345
|
+
throw new Failure("command failed with exit=" + p.status + "; output_sha256=" + result.output_sha256 + "; " + result.log_stderr.slice(-1500));
|
|
337
346
|
}
|
|
338
347
|
return result;
|
|
339
348
|
} finally {
|
|
@@ -343,6 +352,27 @@ function command(argv, { cwd, env = null, stdin = null, timeout = 300, secrets =
|
|
|
343
352
|
}
|
|
344
353
|
}
|
|
345
354
|
|
|
355
|
+
function parseDockerJson(text, label) {
|
|
356
|
+
try {
|
|
357
|
+
return JSON.parse(String(text).trim());
|
|
358
|
+
} catch (e) {
|
|
359
|
+
throw new Failure(label + " is not valid JSON: " + (e.message || e));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export { parseDockerJson };
|
|
364
|
+
|
|
365
|
+
export function inspectContainerGate(item) {
|
|
366
|
+
const st = item.State || {};
|
|
367
|
+
const name = item.Name || item.Id || "unknown";
|
|
368
|
+
if (st.Status !== "running" || st.OOMKilled || st.Restarting) {
|
|
369
|
+
throw new Failure("container not running after compose-up: " + name + " status=" + (st.Status || "unknown"));
|
|
370
|
+
}
|
|
371
|
+
if (st.Health && st.Health.Status && st.Health.Status !== "healthy") {
|
|
372
|
+
throw new Failure("container health is " + st.Health.Status + " after compose --wait; TCP/proxy listen is not sufficient");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
346
376
|
function dockerBase(op) {
|
|
347
377
|
const cmd = [op.docker || "docker"];
|
|
348
378
|
if (op.context) cmd.push("--context", op.context);
|
|
@@ -357,7 +387,7 @@ function composeUp(op, req) {
|
|
|
357
387
|
const base = composeBase(op);
|
|
358
388
|
const docker = dockerBase(op);
|
|
359
389
|
const info = command(docker.concat(["info", "--format", "{{json .}}"]), { cwd: root, timeout: 30, secrets: req.secrets || [] });
|
|
360
|
-
const daemon =
|
|
390
|
+
const daemon = parseDockerJson(info.stdout, "docker info");
|
|
361
391
|
if (daemon.ID !== op.expected_docker_id) throw new Failure("Docker daemon identity changed since approval");
|
|
362
392
|
const actual = daemon.DockerRootDir;
|
|
363
393
|
if (req.strict_docker_root !== false) {
|
|
@@ -375,7 +405,7 @@ function composeUp(op, req) {
|
|
|
375
405
|
for (const [name, service] of Object.entries(model.services)) {
|
|
376
406
|
const image = service.image || op.compose_name + "-" + name;
|
|
377
407
|
const conf = command(docker.concat(["image", "inspect", image, "--format", "{{json .Config.Volumes}}"]), { cwd: root, timeout: 30, secrets: req.secrets || [] });
|
|
378
|
-
const declared =
|
|
408
|
+
const declared = parseDockerJson(conf.stdout, "image volumes for " + image) || {};
|
|
379
409
|
const mapped = new Set([...(service.volumes || []).map((v) => v.target), ...(service.tmpfs || [])]);
|
|
380
410
|
const missing = Object.keys(declared).filter((k) => !mapped.has(k));
|
|
381
411
|
if (missing.length) throw new Failure("image declares unmapped VOLUME(s), refusing anonymous persistence: " + JSON.stringify(missing.sort()));
|
|
@@ -384,7 +414,8 @@ function composeUp(op, req) {
|
|
|
384
414
|
const ids = command(base.concat(["ps", "--all", "--quiet"]), { cwd: root, timeout: 30 }).stdout.split(/\s+/).filter(Boolean);
|
|
385
415
|
if (!ids.length) throw new Failure("Compose returned no containers");
|
|
386
416
|
for (const cid of ids) {
|
|
387
|
-
const item =
|
|
417
|
+
const item = parseDockerJson(command(docker.concat(["inspect", cid]), { cwd: root, timeout: 30, secrets: req.secrets || [] }).stdout, "docker inspect")[0];
|
|
418
|
+
inspectContainerGate(item);
|
|
388
419
|
for (const mount of item.Mounts || []) {
|
|
389
420
|
if (mount.Type === "volume") throw new Failure("anonymous/named persistence detected after start; stop and reconcile");
|
|
390
421
|
if (mount.Type === "bind") under(mount.Source, root);
|
|
@@ -403,7 +434,7 @@ function allocation(op, req) {
|
|
|
403
434
|
checkId(aid);
|
|
404
435
|
const marker = join(provider, "allocations", aid + ".json");
|
|
405
436
|
if (op.compose_name) {
|
|
406
|
-
const info =
|
|
437
|
+
const info = parseDockerJson(command(dockerBase(op).concat(["info", "--format", "{{json .}}"]), { cwd: provider, timeout: 30 }).stdout, "provider docker info");
|
|
407
438
|
if (info.ID !== op.expected_docker_id) throw new Failure("provider Docker daemon identity drift");
|
|
408
439
|
}
|
|
409
440
|
const ownership = {
|