From cf8b7b1f5c42c6fb0d17afb025a9d8fddea925c0 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Tue, 15 Sep 2026 06:36:20 +0800 Subject: [PATCH] =?UTF-8?q?chore(k8s):=20=E4=B8=8B=E7=BA=BF=20K8s=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=BD=A2=E6=80=81=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=20@kubernetes/client-node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产形态是单机 local(DEFAULT_DEPLOY_MODE=local,env 未覆盖)⇒ K8s 分支 在 local 下本来不可达;且该形态与官方 dsh 基座、插件体系均无关 => 整体下线,并移除该形态唯一的第三方依赖。 移除(备份在 D:/github/_dsh_shenxian_K8s后端备份_20260915/,含还原命令与 「集群化方案要复用的模板清单」): src/supervisor/{k8s-spawner,leader,reconcile}.ts src/fs/k8s-user-fs.ts · src/tcp-bridge.ts · src/web/file-service.ts test/{k8s-spawner,leader}.test.mjs · scripts/smoke-file-service.mjs 改写调用方:cli.ts(5 条 import / 选主块 / file-service 与 tcp-bridge 两个 子命令 / dispatch / HELP)、web/server.ts(改为 fail-loud 守卫 + 恒用 LocalSpawner)、fs/provider.ts(只留 LocalUserFs)、package.json(测试与 smoke 入口),外加 3 处指向已删类型的悬空 JSDoc。 保留(集群化方案列为未来可选):deploy/ · poc/01-04 · Dockerfile.dsh · docs/k8s*.md(已加「代码已下线」状态横幅)· config.ts 的 K8s 配置字段与 DeployMode 联合类型。 验证:tsc --noEmit exit 0;npm test 36 测试 / 35 通过 / 0 失败 / 1 跳过; npm run verify exit 0;依赖与被删符号全仓 0 命中。 --- README.md | 2 +- docs/k8s-deploy.md | 2 +- docs/k8s-deployment.md | 2 +- package-lock.json | 716 +-------------------------------- package.json | 8 +- scripts/smoke-file-service.mjs | 103 ----- src/cli.ts | 72 ---- src/fs/k8s-user-fs.ts | 167 -------- src/fs/provider.ts | 30 +- src/fs/user-fs.ts | 9 +- src/supervisor/k8s-spawner.ts | 687 ------------------------------- src/supervisor/leader.ts | 254 ------------ src/supervisor/reconcile.ts | 170 -------- src/supervisor/spawner.ts | 18 +- src/tcp-bridge.ts | 38 -- src/web/file-service.ts | 150 ------- src/web/server.ts | 11 +- test/k8s-spawner.test.mjs | 163 -------- test/leader.test.mjs | 117 ------ 19 files changed, 29 insertions(+), 2690 deletions(-) delete mode 100644 scripts/smoke-file-service.mjs delete mode 100644 src/fs/k8s-user-fs.ts delete mode 100644 src/supervisor/k8s-spawner.ts delete mode 100644 src/supervisor/leader.ts delete mode 100644 src/supervisor/reconcile.ts delete mode 100644 src/tcp-bridge.ts delete mode 100644 src/web/file-service.ts delete mode 100644 test/k8s-spawner.test.mjs delete mode 100644 test/leader.test.mjs diff --git a/README.md b/README.md index 8bc5c53..1c098ff 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ nginx(TLS 终结,主域 + *.子域 通配) | 弹性/HA | 无(单点) | 控制面 3 副本 + leader election,DSH Pod 自动重建 | | 交付 | `git clone` + 脚本 | `kubectl apply -f deploy/` | -模式 B 已完整落地(Phase 0–4):每用户 DSH Pod(dsh + tcp-bridge sidecar)+ file sidecar(8082)+ Headless Service + NetworkPolicy;控制面 3 副本 + Lease 选主 + reconcile + 崩溃接管;NAS(CNFS) 共享卷 + PSA restricted + ResourceQuota。见 [K8s 部署教程](docs/k8s-deployment.md) 与 [踩坑记录](docs/k8s-deploy.md)。 +模式 B(K8s)**已于 2026-09-15 下线**:K8s 后端代码从本仓移除(备份在 `D:\github\_dsh_shenxian_K8s后端备份_20260915\`),`deploy/` 清单与设计文档**保留**作为未来可选路线;当前生产形态为**模式 A 单机部署**。历史实现(每用户 DSH Pod + tcp-bridge sidecar + file sidecar(8082)+ Headless Service + NetworkPolicy + 控制面 3 副本 + Lease 选主 + reconcile)见 [K8s 部署教程](docs/k8s-deployment.md) 与 [踩坑记录](docs/k8s-deploy.md)。 ## 快速开始(模式 A) diff --git a/docs/k8s-deploy.md b/docs/k8s-deploy.md index e9ed942..669a170 100644 --- a/docs/k8s-deploy.md +++ b/docs/k8s-deploy.md @@ -1,5 +1,5 @@ # K8s 踩坑记录(模式 B)— 部署流程 + 根因排查 - +> ⚠️ **2026-09-15:模式 B 的 K8s 后端代码已从本仓下线**(`k8s-spawner` / `leader` / `reconcile` / `k8s-user-fs` / `tcp-bridge` / `web/file-service` 六个模块 + 2 个测试 + 1 个脚本)。备份:`D:\github\_dsh_shenxian_K8s后端备份_20260915\`。本文按「**历史设计稿 + 未来可选路线**」保留;文中的 `dshs file-service` / `dshs tcp-bridge` 两条子命令**现已不存在**,照着敲会 `command not found`。> ⚠️ **2026-09-15:模式 B 的 K8s 后端代码已从本仓下线**(`k8s-spawner` / `leader` / `reconcile` / `k8s-user-fs` / `tcp-bridge` / `web/file-service` 六个模块 + 2 个测试 + 1 个脚本)。备份:`D:\github\_dsh_shenxian_K8s后端备份_20260915\`。本文按「**历史设计稿 + 未来可选路线**」保留;文中的 `dshs file-service` / `dshs tcp-bridge` 两条子命令**现已不存在**,照着敲会 `command not found`。 > 🧭 [← 返回 README](../README.md) · 分步教程:[K8s 部署教程](k8s-deployment.md) > 记录模式 B 实机部署踩到的坑与根因:先在**阿里云 2C2G 单机 k3s** 上 PoC 验证,后在 **ACK 智能托管**上完整落地(§5–§8 含部署流程实录)。 diff --git a/docs/k8s-deployment.md b/docs/k8s-deployment.md index a2e41f2..79d3eae 100644 --- a/docs/k8s-deployment.md +++ b/docs/k8s-deployment.md @@ -1,5 +1,5 @@ # K8s 部署教程(模式 B)— DSH 服务端登录插件 - +> ⚠️ **2026-09-15:模式 B 的 K8s 后端代码已从本仓下线**(`k8s-spawner` / `leader` / `reconcile` / `k8s-user-fs` / `tcp-bridge` / `web/file-service` 六个模块 + 2 个测试 + 1 个脚本)。备份:`D:\github\_dsh_shenxian_K8s后端备份_20260915\`。本文按「**历史设计稿 + 未来可选路线**」保留;文中的 `dshs file-service` / `dshs tcp-bridge` 两条子命令**现已不存在**,照着敲会 `command not found`。> ⚠️ **2026-09-15:模式 B 的 K8s 后端代码已从本仓下线**(`k8s-spawner` / `leader` / `reconcile` / `k8s-user-fs` / `tcp-bridge` / `web/file-service` 六个模块 + 2 个测试 + 1 个脚本)。备份:`D:\github\_dsh_shenxian_K8s后端备份_20260915\`。本文按「**历史设计稿 + 未来可选路线**」保留;文中的 `dshs file-service` / `dshs tcp-bridge` 两条子命令**现已不存在**,照着敲会 `command not found`。 > 🧭 [← 返回 README](../README.md) · 卡住了:[踩坑记录](k8s-deploy.md) · 模式 A 教程:[deployment](deployment.md) > 把 `dshs` 部署成**多机 HA、每用户独立 Pod** 的形态。本文是**可复制的分步部署教程**; diff --git a/package-lock.json b/package-lock.json index db0f532..0827852 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,9 @@ "": { "name": "dshs", "version": "0.1.0", - "license": "MIT", "dependencies": { "@fastify/rate-limit": "^10.0.0", "@fastify/static": "^10.1.3", - "@kubernetes/client-node": "^2.0.0", "better-sqlite3": "^11.10.0", "fastify": "^5.0.0", "pg": "^8.23.0" @@ -241,95 +239,6 @@ ], "license": "MIT" }, - "node_modules/@jsep-plugin/assignment": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", - "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", - "license": "MIT", - "engines": { - "node": ">= 10.16.0" - }, - "peerDependencies": { - "jsep": "^0.4.0||^1.0.0" - } - }, - "node_modules/@jsep-plugin/regex": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", - "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", - "license": "MIT", - "engines": { - "node": ">= 10.16.0" - }, - "peerDependencies": { - "jsep": "^0.4.0||^1.0.0" - } - }, - "node_modules/@kubernetes/client-node": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-2.0.0.tgz", - "integrity": "sha512-Jx2cRxEVb4XkDNiR/cg8SX9dH6ZHdMhKmZdQcfhn2vdBWHdb2ldwM0P3I0dK4msYhFmC6TCODsNHH144IpA06w==", - "license": "Apache-2.0", - "dependencies": { - "@types/js-yaml": "^4.0.1", - "@types/node": "^26.0.0", - "@types/stream-buffers": "^3.0.3", - "form-data": "^4.0.0", - "hpagent": "^1.2.0", - "isomorphic-ws": "^5.0.0", - "js-yaml": "^5.1.0", - "jsonpath-plus": "^10.3.0", - "openid-client": "^6.1.3", - "rfc4648": "^1.3.0", - "socks": "^2.8.4", - "socks-proxy-agent": "^10.0.0", - "stream-buffers": "^3.0.2", - "tar-fs": "^3.0.9", - "undici": "^8.7.0", - "ws": "^8.18.2" - } - }, - "node_modules/@kubernetes/client-node/node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@kubernetes/client-node/node_modules/tar-fs": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", - "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/@kubernetes/client-node/node_modules/tar-stream": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", - "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "bare-fs": "^4.5.5", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/@kubernetes/client-node/node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "license": "MIT" - }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -355,16 +264,11 @@ "@types/node": "*" } }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -382,30 +286,12 @@ "pg-types": "^2.2.0" } }, - "node_modules/@types/stream-buffers": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.8.tgz", - "integrity": "sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, - "node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -455,18 +341,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -496,20 +370,6 @@ "fastq": "^1.17.1" } }, - "node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -519,86 +379,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/bare-events": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", - "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.0.tgz", - "integrity": "sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.28.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", - "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", - "license": "Apache-2.0" - }, - "node_modules/bare-stream": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", - "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.8.1", - "streamx": "^2.25.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-abort-controller": "*", - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - }, - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.2.tgz", - "integrity": "sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==", - "license": "Apache-2.0", - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -686,37 +466,12 @@ "ieee754": "^1.1.13" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/content-disposition": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", @@ -743,23 +498,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -784,15 +522,6 @@ "node": ">=4.0.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -820,20 +549,6 @@ "node": ">=8" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -843,66 +558,12 @@ "once": "^1.4.0" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -924,12 +585,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-json-stringify": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", @@ -1057,74 +712,12 @@ "node": ">=20" } }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -1148,66 +741,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hpagent": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", - "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1260,15 +793,6 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", @@ -1278,55 +802,6 @@ "node": ">= 10" } }, - "node_modules/isomorphic-ws": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", - "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", - "license": "MIT", - "peerDependencies": { - "ws": "*" - } - }, - "node_modules/jose": { - "version": "6.2.10", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", - "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-yaml": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz", - "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.mjs" - } - }, - "node_modules/jsep": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", - "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", - "license": "MIT", - "engines": { - "node": ">= 10.16.0" - } - }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -1352,24 +827,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/jsonpath-plus": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", - "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", - "license": "MIT", - "dependencies": { - "@jsep-plugin/assignment": "^1.3.0", - "@jsep-plugin/regex": "^1.0.4", - "jsep": "^1.4.0" - }, - "bin": { - "jsonpath": "bin/jsonpath-cli.js", - "jsonpath-plus": "bin/jsonpath-cli.js" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -1416,15 +873,6 @@ "node": "20 || >=22" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -1437,27 +885,6 @@ "node": ">=10.0.0" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -1509,12 +936,6 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -1533,15 +954,6 @@ "node": ">=10" } }, - "node_modules/oauth4webapi": { - "version": "3.8.7", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz", - "integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1560,19 +972,6 @@ "wrappy": "1" } }, - "node_modules/openid-client": { - "version": "6.8.7", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.7.tgz", - "integrity": "sha512-gtKthNu7evSBvTdrrlHb4F3Fi9dcwlb5QaITlCs+9mfpvuOi0Q3qtBf5+iY4sEP8hy1qCoAdxBNPDcmZeVSDzQ==", - "license": "MIT", - "dependencies": { - "jose": "^6.2.8", - "oauth4webapi": "^3.8.7" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -1879,12 +1278,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfc4648": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", - "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", - "license": "MIT" - }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -2027,44 +1420,6 @@ "simple-concat": "^1.0.0" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz", - "integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==", - "license": "MIT", - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -2092,26 +1447,6 @@ "node": ">= 0.8" } }, - "node_modules/stream-buffers": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", - "integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==", - "license": "Unlicense", - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/streamx": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", - "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2158,24 +1493,6 @@ "node": ">=6" } }, - "node_modules/teex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", - "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "license": "MIT", - "dependencies": { - "streamx": "^2.12.5" - } - }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -2238,19 +1555,11 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", - "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/util-deprecate": { @@ -2265,27 +1574,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index dfeac17..98c06aa 100644 --- a/package.json +++ b/package.json @@ -22,15 +22,14 @@ "prepare": "npm run build", "dev": "node lib/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", - "verify": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs && node scripts/verify-models-dict.mjs && node scripts/verify-models-render.cjs && node scripts/verify-dsh-compat.mjs", - "test": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js", + "verify": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs && node scripts/verify-models-dict.mjs && node scripts/verify-models-render.cjs && node scripts/verify-dsh-compat.mjs", + "test": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js", "smoke": "node scripts/smoke.mjs", "smoke:admin": "node scripts/smoke-admin.mjs", "smoke:auth": "node scripts/smoke-auth.mjs", "smoke:fs": "node scripts/smoke-fs.mjs", - "smoke:file-service": "node scripts/smoke-file-service.mjs", "smoke:dsh": "node scripts/smoke-dsh.mjs", - "smoke:domain": "node scripts/smoke-domain.mjs", + "smoke:domain": "node scripts/smoke-domain.mjs", "smoke:isolation": "node scripts/smoke-isolation.mjs", "smoke:subdomain": "node scripts/smoke-subdomain.mjs" }, @@ -65,7 +64,6 @@ "dependencies": { "@fastify/rate-limit": "^10.0.0", "@fastify/static": "^10.1.3", - "@kubernetes/client-node": "^2.0.0", "better-sqlite3": "^11.10.0", "fastify": "^5.0.0", "pg": "^8.23.0" diff --git a/scripts/smoke-file-service.mjs b/scripts/smoke-file-service.mjs deleted file mode 100644 index dcd7b1b..0000000 --- a/scripts/smoke-file-service.mjs +++ /dev/null @@ -1,103 +0,0 @@ -// File sidecar round-trip: drive K8sUserFs against a real buildFileService -// instance and assert it behaves identically to LocalUserFs on the same volume. -// This is the k8s desktop-FS path (docs/k8s.md §4.10) exercised without a -// cluster: the sidecar is bound on loopback and the client's Service-DNS -// resolution is stubbed to point at it. -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { buildFileService } from '../lib/web/file-service.js' -import { K8sUserFs } from '../lib/fs/k8s-user-fs.js' -import { LocalUserFs } from '../lib/fs/local-user-fs.js' -import { UserFsError } from '../lib/fs/user-fs.js' - -function assert(condition, message) { - if (!condition) throw new Error('ASSERT: ' + message) -} - -async function rejectsWith(fn, code, message) { - try { - await fn() - } catch (err) { - assert(err instanceof UserFsError, `${message} (got ${err})`) - assert(err.code === code, `${message} (got ${err.code})`) - return - } - throw new Error('ASSERT: ' + message + ' (resolved instead)') -} - -const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-sidecar-')) -const userRoot = join(dataRoot, 'users', 'u1') - -// The sidecar serves exactly this one user's root, as its Pod would. -const sidecar = buildFileService(userRoot, { bodyLimit: 8 * 1024 * 1024, logLevel: 'warn' }) -await sidecar.listen({ host: '127.0.0.1', port: 0 }) -const port = sidecar.server.address().port - -// In-cluster this resolves to `dsh-files-..svc.cluster.local:8082`; -// here it points at the loopback sidecar bound above. -const local = new LocalUserFs(() => userRoot) -let ensured = 0 -const remote = new K8sUserFs(async () => { ensured += 1 }, () => ({ host: '127.0.0.1', port })) - -try { - await remote.initUserRoot('u1') - assert(ensured > 0, 'initUserRoot brings the sidecar up first') - - // Seed a plugin profile so the catalog read has something to find. - const profile = join(userRoot, 'home', 'profiles', 'web') - mkdirSync(join(profile, 'node_modules', 'p1'), { recursive: true }) - writeFileSync(join(profile, 'package.json'), JSON.stringify({ dsh: { profile: { bundles: ['p1', '@deepseek-ai/core'] } } })) - writeFileSync(join(profile, 'node_modules', 'p1', 'package.json'), JSON.stringify({ description: 'first plugin' })) - - let entries = await remote.listDir('u1', '') - assert(entries.length === 0, 'fresh workspace is empty') - - const dirName = await remote.createEntry('u1', '', 'proj', 'dir') - assert(dirName === 'proj', 'createEntry returns the sanitized name') - assert(await remote.isDirectory('u1', 'proj'), 'created entry is a directory') - - const uploaded = await remote.upload('u1', 'proj', 'notes.txt', Buffer.from('hello sidecar')) - assert(uploaded === 'notes.txt', 'upload returns the sanitized name') - - await remote.mkdir('u1', 'proj/sub') - - // Same volume, two implementations → identical view. - entries = await remote.listDir('u1', 'proj') - const localEntries = await local.listDir('u1', 'proj') - assert(JSON.stringify(entries) === JSON.stringify(localEntries), 'sidecar and local agree on the listing') - const file = entries.find((e) => e.name === 'notes.txt') - assert(file.type === 'file' && file.size === 13, 'uploaded file has the right type/size') - - const plugins = await remote.listInstalledPlugins('u1') - assert(plugins.length === 1 && plugins[0].id === 'p1', 'installation-scoped bundles are filtered out') - assert(plugins[0].description === 'first plugin', 'plugin description comes from its package.json') - assert(JSON.stringify(plugins) === JSON.stringify(await local.listInstalledPlugins('u1')), 'catalogs agree') - - await remote.writeHandoff('u1', JSON.stringify({ command: 'echo hi' })) - - // Error codes survive the HTTP hop as the same UserFsError the UI switches on. - await rejectsWith(() => remote.listDir('u1', '../../etc'), 'bad_path', 'traversal rejected') - await rejectsWith(() => remote.listDir('u1', 'nope'), 'not_found', 'missing dir is not_found') - await rejectsWith(() => remote.createEntry('u1', '', 'proj', 'dir'), 'exists', 'duplicate create is exists') - await rejectsWith(() => remote.createEntry('u1', 'nope', 'x', 'file'), 'parent_missing', 'missing parent') - await rejectsWith(() => remote.upload('u1', '', '..', Buffer.from('x')), 'bad_name', 'path in name rejected') - - // resolvePath is pure POSIX path math on the in-Pod layout. - assert( - remote.resolvePath('u1', 'proj') === '/var/lib/dshs/users/u1/ws/proj', - 'resolvePath yields the in-Pod path', - ) - let escaped = false - try { - remote.resolvePath('u1', '../../etc') - } catch (err) { - escaped = err instanceof UserFsError && err.code === 'bad_path' - } - assert(escaped, 'resolvePath rejects traversal') - - console.log('OK: file sidecar round-trip matches LocalUserFs') -} finally { - await sidecar.close() - rmSync(dataRoot, { recursive: true, force: true }) -} diff --git a/src/cli.ts b/src/cli.ts index c460a5a..c22eeff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,6 @@ * * Subcommands: * dshs bootstrap-admin --username --password

- * dshs file-service per-user file sidecar (k8s) * dshs [server flags] * @module dshs/cli */ @@ -17,11 +16,6 @@ import { createUserFs } from './fs/provider.js' import { homeRoot, userRoot } from './fs/workspace.js' import { hashPassword } from './web/auth.js' import { hashUid } from './isolation.js' -import { LeaderElector } from './supervisor/leader.js' -import { ReconcileController } from './supervisor/reconcile.js' -import { K8sSpawner } from './supervisor/k8s-spawner.js' -import { buildFileService, FILE_SERVICE_PORT, USER_ROOT_ENV } from './web/file-service.js' -import { startTcpBridge } from './tcp-bridge.js' import { buildServer } from './web/server.js' const HELP = `dshs — DSH server login orchestrator @@ -29,7 +23,6 @@ const HELP = `dshs — DSH server login orchestrator Usage: dshs [options] start the server dshs bootstrap-admin [options] create the first admin - dshs file-service run the per-user file sidecar Server options: --port Bind port (0 = ephemeral). Default 3080. @@ -138,47 +131,6 @@ async function runServer(args: string[]): Promise { app.log.info(`dshs listening on http://${config.host}:${actualPort}`) app.log.info(`data root: ${config.dataRoot}; db: ${config.dbPath}`) - // In k8s mode, only the elected leader runs the controller (reconcile + Pod - // watch). The web layer serves on every replica. - let controller: ReconcileController | undefined - if (config.deployMode === 'k8s') { - const spawner = app.supervisor as K8sSpawner - const elector = new LeaderElector({ namespace: config.k8sNamespace, identity: config.podName }) - controller = new ReconcileController(app.db, spawner, elector) - await controller.start() - app.log.info(`leader election started (identity ${config.podName})`) - } - - const shutdown = async (signal: string): Promise => { - app.log.info(`received ${signal}, shutting down`) - controller?.stop() - await app.close() - process.exit(0) - } - process.on('SIGINT', () => void shutdown('SIGINT')) - process.on('SIGTERM', () => void shutdown('SIGTERM')) -} - -/** - * Run the per-user file sidecar. Serves one user's volume over HTTP on 8082 so - * the control plane (which holds no users volume under k8s) can reach it; the - * root comes from the Pod's env, not from argv. - */ -async function runFileService(): Promise { - const root = process.env[USER_ROOT_ENV] - if (root === undefined || root === '') { - console.error(`file-service requires ${USER_ROOT_ENV} (the user's data root inside the Pod)`) - process.exit(2) - } - // The sidecar runs as the user's uid with no home dir; `homedir()` falls back - // to `/` and `resolveConfig` would try to mkdir `/.dshs`. It only - // needs `maxUploadBytes`/`logLevel` here, so pin dataRoot to a writable path - // and skip the (unused) encryption-secret file. - const config = resolveConfig({ dataRoot: '/tmp', encryptionSecret: 'file-service-unused' }) - const app = buildFileService(root, { bodyLimit: config.maxUploadBytes, logLevel: config.logLevel }) - await app.listen({ host: '0.0.0.0', port: FILE_SERVICE_PORT }) - app.log.info(`file sidecar serving ${root} on 0.0.0.0:${FILE_SERVICE_PORT}`) - const shutdown = async (signal: string): Promise => { app.log.info(`received ${signal}, shutting down`) await app.close() @@ -188,22 +140,6 @@ async function runFileService(): Promise { process.on('SIGTERM', () => void shutdown('SIGTERM')) } -/** TCP bridge sidecar (`dshs tcp-bridge `). */ -async function runTcpBridge(args: string[]): Promise { - const [listen, target] = args - if (listen === undefined || target === undefined) { - console.error('usage: dshs tcp-bridge ') - process.exit(2) - } - const server = await startTcpBridge(listen, target) - console.error(`tcp-bridge ${listen} -> ${target}`) - const shutdown = (): void => { - server.close(() => process.exit(0)) - } - process.on('SIGINT', shutdown) - process.on('SIGTERM', shutdown) -} - async function uidForUserCmd(args: string[]): Promise { const { values, positionals } = parseArgs({ args, @@ -234,14 +170,6 @@ async function main(): Promise { await uidForUserCmd(rest) return } - if (first === 'file-service') { - await runFileService() - return - } - if (first === 'tcp-bridge') { - await runTcpBridge(rest) - return - } await runServer(process.argv.slice(2)) } diff --git a/src/fs/k8s-user-fs.ts b/src/fs/k8s-user-fs.ts deleted file mode 100644 index 5d7b173..0000000 --- a/src/fs/k8s-user-fs.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * HTTP {@link UserFs}: every file operation is delegated to the user's own file - * sidecar (docs/k8s.md §4.10), because the control plane runs as uid 65532 with - * no users volume and could not touch a `0700` user directory even if it did. - * - * The sidecar is addressed through its per-user Headless Service. That DNS A - * record has a ~30s TTL, so a Pod that was just rebuilt can still resolve to - * its old IP — connection-level failures therefore drop the keep-alive pool and - * retry once, mirroring what the DSH proxy does (docs/k8s.md §5.4). - * @module dshs/fs/k8s-user-fs - */ - -import { Agent, request as httpRequest } from 'node:http' -import type { Endpoint } from '../supervisor/spawner.js' -import { POSIX, PathEscapeError, resolveWithinRoot } from '../web/middleware/fs-guard.js' -import type { PluginInfo } from './plugins.js' -import { isUserFsErrorCode, UserFsError, type UserFs } from './user-fs.js' -import { HOME_DIR, USERS_DIR, WORKSPACE_DIR, type FsEntry } from './workspace.js' - -/** Data root inside every per-user Pod (mirrors `k8s-spawner`'s POD_DATA_ROOT). */ -const POD_DATA_ROOT = '/var/lib/dshs' - -/** Errors that mean "the connection never got anywhere" — worth one retry - * against a freshly resolved address. */ -const RETRYABLE = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ETIMEDOUT']) - -/** Ensure the user's file sidecar exists and is ready; supplied by the spawner. */ -export type EnsureFileService = (userId: string) => Promise - -interface Reply { - status: number - body: unknown -} - -export class K8sUserFs implements UserFs { - private agent = new Agent({ keepAlive: true, maxSockets: 16 }) - - /** - * @param ensureFileService - brings the sidecar up before the first call. - * @param endpointFor - the sidecar's address; the k8s backend supplies the - * per-user Headless Service DNS, tests supply a loopback bind. - */ - constructor( - private readonly ensureFileService: EnsureFileService, - private readonly endpointFor: (userId: string) => Endpoint, - ) {} - - async initUserRoot(userId: string): Promise { - // Creating the sidecar Pod *is* the initialization: its init container - // builds `//{ws,home}` as the user's own uid (docs/k8s.md §4.9). - await this.ensureFileService(userId) - await this.call(userId, 'POST', '/fs/init') - } - - resolvePath(userId: string, relPath: string): string { - const root = `${POD_DATA_ROOT}/${USERS_DIR}/${userId}/${WORKSPACE_DIR}` - try { - return resolveWithinRoot(root, relPath, POSIX) - } catch (err) { - if (err instanceof PathEscapeError) throw new UserFsError('bad_path') - throw err - } - } - - /** The user's DSH state directory inside their Pod. */ - homePath(userId: string): string { - return `${POD_DATA_ROOT}/${USERS_DIR}/${userId}/${HOME_DIR}` - } - - async listDir(userId: string, relPath: string): Promise { - const body = await this.call(userId, 'GET', `/fs/tree?path=${encodeURIComponent(relPath)}`) - return (body as { entries: FsEntry[] }).entries - } - - async mkdir(userId: string, relPath: string): Promise { - await this.call(userId, 'POST', '/fs/mkdir', { path: relPath }) - } - - async createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise { - const body = await this.call(userId, 'POST', '/fs/create', { path: relPath, name, type }) - return (body as { name: string }).name - } - - async upload(userId: string, relPath: string, name: string, data: Buffer): Promise { - const body = await this.call(userId, 'POST', '/fs/upload', { - path: relPath, - name, - data: data.toString('base64'), - }) - return (body as { name: string }).name - } - - /** 档案 56:k8s 路径未验证 —— sidecar 尚无 read 端点,明确报 `unsupported` 而非静默失败。 */ - async readFile(): Promise<{ name: string; data: Buffer }> { - throw new UserFsError('unsupported') - } - - async isDirectory(userId: string, relPath: string): Promise { - const body = await this.call(userId, 'GET', `/fs/stat?path=${encodeURIComponent(relPath)}`) - return (body as { isDirectory: boolean }).isDirectory - } - - async listInstalledPlugins(userId: string): Promise { - const body = await this.call(userId, 'GET', '/fs/plugins') - return (body as { plugins: PluginInfo[] }).plugins - } - - async writeHandoff(userId: string, content: string): Promise { - await this.call(userId, 'POST', '/fs/handoff', { content }) - } - - /** One sidecar call: ensure the Pod, send, retry once on a dead connection, - * then translate a non-2xx `{error}` back into a {@link UserFsError}. */ - private async call(userId: string, method: string, path: string, payload?: unknown): Promise { - await this.ensureFileService(userId) - let reply: Reply - try { - reply = await this.send(userId, method, path, payload) - } catch (err) { - const code = (err as NodeJS.ErrnoException).code - if (code === undefined || !RETRYABLE.has(code)) throw err - // The keep-alive pool may hold sockets to a Pod IP that no longer exists; - // drop them so the retry re-resolves the Headless Service. - this.agent.destroy() - this.agent = new Agent({ keepAlive: true, maxSockets: 16 }) - reply = await this.send(userId, method, path, payload) - } - if (reply.status >= 200 && reply.status < 300) return reply.body - const error = (reply.body as { error?: unknown }).error - if (typeof error === 'string' && isUserFsErrorCode(error)) throw new UserFsError(error) - throw new Error(`file sidecar for ${userId} returned ${reply.status}`) - } - - private send(userId: string, method: string, path: string, payload?: unknown): Promise { - const body = payload === undefined ? undefined : JSON.stringify(payload) - const endpoint = this.endpointFor(userId) - return new Promise((resolve, reject) => { - const req = httpRequest( - { - host: endpoint.host, - port: endpoint.port, - path, - method, - agent: this.agent, - headers: body === undefined - ? {} - : { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }, - }, - (res) => { - const chunks: Buffer[] = [] - res.on('data', (chunk: Buffer) => chunks.push(chunk)) - res.on('end', () => { - const text = Buffer.concat(chunks).toString('utf8') - try { - resolve({ status: res.statusCode ?? 502, body: text === '' ? {} : JSON.parse(text) }) - } catch { - reject(new Error(`file sidecar for ${userId} returned non-JSON (${res.statusCode})`)) - } - }) - }, - ) - req.on('error', reject) - if (body !== undefined) req.write(body) - req.end() - }) - } -} diff --git a/src/fs/provider.ts b/src/fs/provider.ts index 6381792..50e9acb 100644 --- a/src/fs/provider.ts +++ b/src/fs/provider.ts @@ -1,37 +1,19 @@ /** - * {@link UserFs} factory. Picks the implementation from `deployMode`, the same - * way {@link createDbAdapter} picks a DB backend and `buildServer` picks a - * {@link Spawner}. + * {@link UserFs} factory. Builds the single-machine per-user filesystem, the + * same way {@link createDbAdapter} picks a DB backend and `buildServer` builds + * a {@link Spawner}. * @module dshs/fs/provider */ import type { ServerConfig } from '../config.js' -import type { Endpoint } from '../supervisor/spawner.js' -import { FILE_SERVICE_PORT } from '../web/file-service.js' -import { K8sUserFs, type EnsureFileService } from './k8s-user-fs.js' import { LocalUserFs } from './local-user-fs.js' import type { UserFs } from './user-fs.js' import { userRoot } from './workspace.js' -/** Headless Service fronting a user's file sidecar (docs/k8s.md §4.10). */ -export function fileServiceName(userId: string): string { - return `dsh-files-${userId}` -} - -/** In-cluster address of a user's file sidecar. */ -export function fileServiceEndpoint(namespace: string, userId: string): Endpoint { - return { host: `${fileServiceName(userId)}.${namespace}.svc.cluster.local`, port: FILE_SERVICE_PORT } -} - /** - * Build the configured per-user filesystem. - * - * `local` touches the users volume in-process. `k8s` delegates to each user's - * file sidecar, which the spawner brings up on demand via `ensureFileService`. + * Build the configured per-user filesystem. The single-machine backend + * touches the users volume in-process. */ -export function createUserFs(config: ServerConfig, ensureFileService?: EnsureFileService): UserFs { - if (config.deployMode === 'k8s' && ensureFileService !== undefined) { - return new K8sUserFs(ensureFileService, (userId) => fileServiceEndpoint(config.k8sNamespace, userId)) - } +export function createUserFs(config: ServerConfig): UserFs { return new LocalUserFs((userId) => userRoot(config.dataRoot, userId)) } diff --git a/src/fs/user-fs.ts b/src/fs/user-fs.ts index 197c4d1..1542c5b 100644 --- a/src/fs/user-fs.ts +++ b/src/fs/user-fs.ts @@ -1,13 +1,10 @@ /** * The per-user filesystem seam (docs/k8s.md §4.10 / §6.0-1). * - * Under `local` the control plane owns the users volume and touches it directly - * ({@link LocalUserFs}). Under `k8s` it must not: it runs as uid 65532 while - * each user's directory is `0700` owned by that user's uid, so every file - * operation is delegated over HTTP to a per-user file sidecar - * ({@link K8sUserFs}). Routes depend only on this interface. + * The control plane owns the users volume and touches it in-process + * ({@link LocalUserFs}); routes depend only on this interface. * - * All paths crossing this interface are **workspace-relative**; each + * All paths crossing this interface are **workspace-relative**; the * implementation resolves them against its own root via `resolveWithinRoot`. * @module dshs/fs/user-fs */ diff --git a/src/supervisor/k8s-spawner.ts b/src/supervisor/k8s-spawner.ts deleted file mode 100644 index 4aa4780..0000000 --- a/src/supervisor/k8s-spawner.ts +++ /dev/null @@ -1,687 +0,0 @@ -/** - * K8s backend for per-user DSH lifecycle (docs/k8s.md §5.2/§4.3/§4.4/§4.8). - * - * Each user gets a `dsh-` Pod (dsh + socat sidecar), a Headless Service, - * a NetworkPolicy, and a `dsh-key-` Secret for the API key. A crash - * pulls up a one-shot `dsh--watchdog` Job. The control plane runs - * in-cluster and talks to the K8s API via @kubernetes/client-node. - * @module dshs/supervisor/k8s-spawner - */ - -import * as k8s from '@kubernetes/client-node' -import type { ServerConfig } from '../config.js' -import type { DbAdapter } from '../db/adapter.js' -import { HANDOFF_FILE, HOME_DIR, USERS_DIR, WORKSPACE_DIR } from '../fs/workspace.js' -import { FILE_SERVICE_PORT, USER_ROOT_ENV } from '../web/file-service.js' -import type { Fencing } from './leader.js' -import { AlreadyRunningError, type Endpoint, type Instance, type LivePod, type Spawner, type UserStatus } from './spawner.js' - -/** Loopback port the dsh container binds; the socat sidecar bridges 8081 → 8080. */ -const DSH_LOOPBACK_PORT = 8080 -/** Sidecar bridge port the per-user Service targets (80 → 8081). */ -const SOCAT_PORT = 8081 -/** Task the one-shot headless watchdog runs (executes the handoff command). */ -const WATCHDOG_TASK = 'Read DSHS_HANDOFF_PATH. If it contains a JSON {"command": ...}, run that command. Then exit.' -/** Shared RWX PVC every user's Pod mounts via subPath. */ -const USERS_PVC = 'dsh-users' - -/** Per-user resource names (deterministic — idempotent create). */ -function names(userId: string) { - return { - pod: `dsh-${userId}`, - service: `dsh-${userId}`, - networkPolicy: `dsh-${userId}`, - secret: `dsh-key-${userId}`, - job: `dsh-${userId}-watchdog`, - patch: `dsh-${userId}-patch`, - filesPod: `dsh-files-${userId}`, - filesService: `dsh-files-${userId}`, - filesNetworkPolicy: `dsh-files-${userId}`, - } -} - -/** The data root inside every per-user Pod, independent of the control plane's - * own `dataRoot` (which under k8s points at a volume the Pod never sees). */ -const POD_DATA_ROOT = '/var/lib/dshs' - -/** Home/workspace paths inside the DSH Pod (docs/k8s.md §4.3). Built with - * POSIX separators — the control plane may be developed/tested on Windows. */ -function userPaths(userId: string): { home: string; ws: string; mount: string } { - const mount = `${POD_DATA_ROOT}/${USERS_DIR}/${userId}` - return { home: `${mount}/${HOME_DIR}`, ws: `${mount}/${WORKSPACE_DIR}`, mount } -} - -/** Pod-safe labels shared by the DSH Pod/Service/NetworkPolicy/Job. */ -function podLabels(userId: string): { app: string; user: string } { - return { app: 'dsh', user: userId } -} - -/** Labels for the per-user file sidecar (a distinct `app`, so its own - * Service/NetworkPolicy select it without touching the DSH Pod). */ -function filesLabels(userId: string): { app: string; user: string } { - return { app: 'dsh-files', user: userId } -} - -/** API-key env entry, omitted when the user has no key. */ -function apiKeyEnv(userId: string, apiKey: string | null): k8s.V1EnvVar[] { - if (apiKey === null) return [] - return [{ name: 'DEEPSEEK_API_KEY', valueFrom: { secretKeyRef: { name: names(userId).secret, key: 'key' } } }] -} - -/** Common per-user container security context (non-root + drop ALL + seccomp + - * read-only rootfs — /tmp comes from an emptyDir, docs/k8s.md Phase 4). */ -function containerSecurity(uid: number): k8s.V1SecurityContext { - return { - runAsNonRoot: true, - runAsUser: uid, - allowPrivilegeEscalation: false, - capabilities: { drop: ['ALL'] }, - seccompProfile: { type: 'RuntimeDefault' }, - readOnlyRootFilesystem: true, - } -} - -/** Writable scratch volume + mount for containers whose rootfs is read-only. */ -function tmpVolume(): k8s.V1Volume[] { - return [{ name: 'tmp', emptyDir: {} }] -} -function tmpMount(): k8s.V1VolumeMount { - return { name: 'tmp', mountPath: '/tmp' } -} - -/** The shared RWX volume (subPath mounts per-user at use time). */ -function dataVolume(): k8s.V1Volume[] { - return [{ name: 'data', persistentVolumeClaim: { claimName: USERS_PVC } }] -} - -/** The shared RWX volume mounted at its **root** (no subPath) so an init - * container can create/chown `/` as the user's own uid. */ -function dataRootVolume(): k8s.V1Volume[] { - return [{ name: 'data-root', persistentVolumeClaim: { claimName: USERS_PVC } }] -} - -/** Every per-user Pod/Job references ACR private images, so each must carry the - * cluster's pull secret (the control-plane Deployment has it in its manifest, - * but generated Pods don't inherit it). */ -function pullSecrets(name: string): k8s.V1LocalObjectReference[] { - return name === '' ? [] : [{ name }] -} - -/** Fencing labels stamped onto resources created by the leader, so a successor - * can identify work a dead leader left in flight (docs/k8s.md §5.3). */ -function stampFencing(labels: Record, fencing: Fencing | undefined): void { - if (fencing === undefined) return - labels['dsh.io/holder'] = fencing.holder - labels['dsh.io/operation-id'] = String(fencing.operationId) -} - -/** - * K8s backend implementing {@link Spawner}. State lives in the cluster; every - * method is a K8s API call (or a read). - */ -export class K8sSpawner implements Spawner { - private readonly core: k8s.CoreV1Api - private readonly networking: k8s.NetworkingV1Api - private readonly batch: k8s.BatchV1Api - private readonly namespace: string - private readonly kc: k8s.KubeConfig - private fencing: Fencing | undefined - - constructor( - private readonly config: ServerConfig, - private readonly db: DbAdapter, - private readonly resolveApiKey: (userId: string) => Promise, - private readonly resolveUid: (userId: string) => Promise, - clients?: { core: k8s.CoreV1Api; networking: k8s.NetworkingV1Api; batch: k8s.BatchV1Api }, - ) { - // Injecting clients short-circuits cluster auth (used by tests). Otherwise - // build them from the in-cluster config, which needs a mounted SA token. - const kc = new k8s.KubeConfig() - if (clients === undefined) { - kc.loadFromCluster() - clients = { - core: kc.makeApiClient(k8s.CoreV1Api), - networking: kc.makeApiClient(k8s.NetworkingV1Api), - batch: kc.makeApiClient(k8s.BatchV1Api), - } - } - this.kc = kc - this.core = clients.core - this.networking = clients.networking - this.batch = clients.batch - this.namespace = config.k8sNamespace - } - - async launch(userId: string, folder: string, patch?: string): Promise { - const n = names(userId) - if (await this.podExists(n.pod)) throw new AlreadyRunningError(userId) - await this.ensureFileService(userId) // the DSH Pod's subPath must already exist - const apiKey = await this.resolveApiKey(userId) - const uid = await this.resolveUid(userId) - const hasPatch = this.config.enablePatch && patch !== undefined - if (hasPatch) await this.ensurePatchConfigMap(n.patch, patch) - await this.ensureSecret(n.secret, apiKey) - await this.ensurePod(n.pod, userId, uid, apiKey, hasPatch ? n.patch : undefined) - await this.ensureService(n.service, userId) - await this.ensureNetworkPolicy(n.networkPolicy, userId) - try { - await this.db.upsertInstance({ id: n.pod, userId, role: 'main', status: 'starting', folder, patch }) - } catch (err) { - this.logError(err) // reconcile needs this row; don't fail the launch over it - } - return { id: n.pod, userId, role: 'main', folder, status: 'starting', patch } - } - - async restartMain(userId: string): Promise { - const desired = await this.db.findUserInstance(userId, 'main') - if (desired === undefined) return undefined - await this.stop(userId) - return await this.launch(userId, desired.folder ?? '', desired.patch ?? undefined) - } - - async restartAllMains(): Promise { - const rows = await this.db.listInstancesByRole('main') - for (const row of rows) { - try { - await this.restartMain(row.userId) - } catch (err) { - this.logError(err) // keep broadcasting — one pod failure must not abort the rest - } - } - } - - async spawnWatchdog(userId: string): Promise { - const n = names(userId) - const apiKey = await this.resolveApiKey(userId) - const uid = await this.resolveUid(userId) - const { home, ws, mount } = userPaths(userId) - const jobLabels = podLabels(userId) - const podTemplateLabels = podLabels(userId) - stampFencing(jobLabels, this.fencing) - stampFencing(podTemplateLabels, this.fencing) - await this.batch.createNamespacedJob({ namespace: this.namespace, body: { - apiVersion: 'batch/v1', - kind: 'Job', - metadata: { name: n.job, namespace: this.namespace, labels: jobLabels }, - spec: { - ttlSecondsAfterFinished: 300, - template: { - metadata: { labels: podTemplateLabels }, - spec: { - automountServiceAccountToken: false, - imagePullSecrets: pullSecrets(this.config.imagePullSecret), - restartPolicy: 'Never', - securityContext: { - runAsNonRoot: true, - runAsUser: uid, - fsGroup: uid, - seccompProfile: { type: 'RuntimeDefault' }, - }, - containers: [ - { - name: 'dsh', - image: this.config.dshImage, - args: ['--profile', 'headless', WATCHDOG_TASK], - env: [ - { name: 'HOME', value: ws }, - { name: 'DSH_HOME', value: home }, - { name: 'DSHS_ROLE', value: 'watchdog' }, - { name: 'DSHS_HANDOFF_PATH', value: `${mount}/${HANDOFF_FILE}` }, - ...apiKeyEnv(userId, apiKey), - ], - volumeMounts: [{ name: 'data', mountPath: mount, subPath: userId }, tmpMount()], - securityContext: containerSecurity(uid), - }, - ], - volumes: [...dataVolume(), ...tmpVolume()], - }, - }, - }, - } }) - return { id: n.job, userId, role: 'watchdog', folder: ws, status: 'starting' } - } - - async status(userId: string): Promise { - return { main: await this.readInstance(names(userId).pod, userId, 'main') } - } - - async endpointFor(userId: string): Promise { - let pod: k8s.V1Pod - try { - pod = await this.core.readNamespacedPod({ name: names(userId).pod, namespace: this.namespace }) - } catch (err) { - if (this.isNotFound(err)) return undefined - throw err // 403/500 are real failures, not "not running" - } - if (pod.status?.phase !== 'Running') return undefined - // Dial the Pod IP directly (not the Headless Service DNS): re-reading the Pod - // on every request gives the *current* IP immediately after a restart, so a - // rebuilt Pod never leaves the proxy pointing at a stale IP for the ~30s DNS - // TTL. Port is the sidecar's 8081 (no kube-proxy DNAT on Headless Services). - const ip = pod.status?.podIP - if (ip === undefined || ip === '') return undefined - return { host: ip, port: SOCAT_PORT } - } - - // k8s 模式无 launch token 概念(Pod 就绪由 endpointFor 的 phase 判断),no-op。 - async waitForLaunchTokenForUser(_userId: string, _timeoutMs?: number): Promise {} - - /** - * k8s spawner 未实现探活(本部署走本地 spawner)。返回 ok:true 保持与旧行为一致: - * 不做回滚,交由 k8s 自身 readiness/liveness 处理。 - */ - async restartAndProbe(_userId: string, _settleMs?: number): Promise<{ ok: boolean; reason: string }> { - return { ok: true, reason: "k8s spawner: 探活未实现" } - } - - async stop(userId: string): Promise { - const n = names(userId) - await this.ignoreNotFound(() => this.core.deleteNamespacedPod({ name: n.pod, namespace: this.namespace })) - await this.ignoreNotFound(() => this.core.deleteNamespacedService({ name: n.service, namespace: this.namespace })) - await this.ignoreNotFound(() => this.networking.deleteNamespacedNetworkPolicy({ name: n.networkPolicy, namespace: this.namespace })) - await this.ignoreNotFound(() => this.batch.deleteNamespacedJob({ name: n.job, namespace: this.namespace })) - await this.ignoreNotFound(() => this.core.deleteNamespacedConfigMap({ name: n.patch, namespace: this.namespace })) - try { - await this.db.deleteInstance(n.pod) // desired state is gone once stopped - } catch (err) { - this.logError(err) - } - } - - /** No-op: per-user Pods outlive any single control-plane replica (reconcile/leader manages them). */ - async teardown(): Promise {} - - /** Activity signal is unused under k8s: the reconcile loop idles-reaps by - * session presence (reconcile.ts Phase 4), not by proxied-traffic recency. */ - touch(_userId: string): void {} - - /** Bring up the user's file sidecar (docs/k8s.md §4.10). The sidecar is a - * distinct always-on Pod so the desktop is usable *before* the on-demand DSH - * launches; its init container creates the user's subPath directory. */ - async ensureFileService(userId: string): Promise { - if (this.config.controlPlaneImage === '') { - throw new Error('DSHS_CONTROL_PLANE_IMAGE is required in k8s mode (file sidecar image)') - } - const n = names(userId) - const uid = await this.resolveUid(userId) - await this.ensureFilesPod(n.filesPod, userId, uid) - await this.ensureFilesService(n.filesService, userId) - await this.ensureFilesNetworkPolicy(n.filesNetworkPolicy, userId) - // The Headless Service only publishes an A record once the Pod is Ready; - // the caller resolves it immediately, so block until it comes up. - await this.waitForPodReady(n.filesPod) - } - - // --- reconcile / watch support (leader-only callers) --- - - /** Stamp the current leader's fencing token onto resources created from now on. */ - setFencing(fencing: Fencing | undefined): void { - this.fencing = fencing - } - - /** Main DSH Pods (`app=dsh`), mapped to the shape the controller needs. */ - async listUserPods(): Promise { - const res = await this.core.listNamespacedPod({ namespace: this.namespace, labelSelector: 'app=dsh' }) - return (res.items ?? []).map((pod) => { - const phase = pod.status?.phase ?? '' - return { - name: pod.metadata?.name ?? '', - userId: pod.metadata?.labels?.user ?? '', - running: phase === 'Running', - crashed: phase === 'Failed' || phase === 'Succeeded', - } - }) - } - - /** Recreate a lost Service/NetworkPolicy for a user whose main Pod exists. */ - async ensureUserResources(userId: string): Promise { - await this.ensureService(names(userId).service, userId) - await this.ensureNetworkPolicy(names(userId).networkPolicy, userId) - } - - /** Watch main DSH Pods; `callback` fires on add/update/delete with their state. */ - watchMainPods(callback: (pod: LivePod) => void): k8s.Informer & k8s.ObjectCache { - const informer = k8s.makeInformer( - this.kc, - `/api/v1/namespaces/${this.namespace}/pods`, - () => this.core.listNamespacedPod({ namespace: this.namespace, labelSelector: 'app=dsh' }), - 'app=dsh', - ) - const emit = (obj: k8s.V1Pod | undefined): void => { - const phase = obj?.status?.phase ?? '' - callback({ - name: obj?.metadata?.name ?? '', - userId: obj?.metadata?.labels?.user ?? '', - running: phase === 'Running', - crashed: phase === 'Failed' || phase === 'Succeeded', - }) - } - informer.on('add', (obj) => emit(obj)) - informer.on('update', (obj) => emit(obj)) - informer.on('delete', (obj) => emit(obj)) - return informer - } - - /** Best-effort error surface for the controller's tick loop. */ - logError(err: unknown): void { - console.error('[dsh-reconcile]', err) - } - - // --- helpers --- - - private async podExists(name: string): Promise { - try { - await this.core.readNamespacedPod({ name, namespace: this.namespace }) - return true - } catch (err) { - if (this.isNotFound(err)) return false - throw err // 403/500 are real failures, not "no Pod" - } - } - - private async readInstance(name: string, userId: string, role: 'main' | 'watchdog'): Promise { - try { - const pod = await this.core.readNamespacedPod({ name, namespace: this.namespace }) - const status = pod.status?.phase === 'Running' ? 'running' : pod.status?.phase === 'Failed' ? 'crashed' : 'starting' - return { id: name, userId, role, folder: '', status } - } catch (err) { - if (this.isNotFound(err)) return undefined - throw err - } - } - - /** Create-or-replace, so a relaunch after `stop()` (which deletes these) can - * never 409 on a stale Secret/ConfigMap from a prior launch. */ - private async ensureSecret(name: string, apiKey: string | null): Promise { - if (apiKey === null) return - await this.replace({ - read: () => this.core.readNamespacedSecret({ name, namespace: this.namespace }), - create: () => this.core.createNamespacedSecret({ namespace: this.namespace, body: { - apiVersion: 'v1', - kind: 'Secret', - metadata: { name, namespace: this.namespace }, - type: 'Opaque', - stringData: { key: apiKey }, - } }), - del: () => this.core.deleteNamespacedSecret({ name, namespace: this.namespace }), - }) - } - - /** Create-or-replace a generated resource. Reads first for the cheap path, - * then treats a 409 race by deleting and recreating (we own every resource - * this helper touches, so replacement is safe). */ - private async replace(ops: { - read: () => Promise - create: () => Promise - del: () => Promise - }): Promise { - try { - await ops.read() - } catch (err) { - if (!this.isNotFound(err)) throw err - try { - await ops.create() - } catch (createErr) { - if (!this.isConflict(createErr)) throw createErr - await this.ignoreNotFound(ops.del) - await ops.create() - } - } - } - - private async ensurePod(name: string, userId: string, uid: number, apiKey: string | null, patchConfigMapName?: string): Promise { - const { home, ws, mount } = userPaths(userId) - const args = ['--profile', 'web', '--host', '127.0.0.1', '--port', String(DSH_LOOPBACK_PORT)] - // The runtime plugin (dshs/runtime) is baked into the dsh image - // but loaded via --patch; the rendered patch is mounted at /etc/dsh/patch.yml. - if (patchConfigMapName !== undefined) args.splice(1, 0, '--patch', '/etc/dsh/patch.yml') - const labels = podLabels(userId) - stampFencing(labels, this.fencing) - await this.core.createNamespacedPod({ namespace: this.namespace, body: { - apiVersion: 'v1', - kind: 'Pod', - metadata: { name, namespace: this.namespace, labels }, - spec: { - automountServiceAccountToken: false, - imagePullSecrets: pullSecrets(this.config.imagePullSecret), - hostNetwork: false, - hostPID: false, - securityContext: { - runAsNonRoot: true, - runAsUser: uid, - fsGroup: uid, - seccompProfile: { type: 'RuntimeDefault' }, - }, - containers: [ - { - name: 'dsh', - image: this.config.dshImage, - args, - // DSH binds loopback only, so a tcpSocket probe (which hits the Pod IP) - // would never succeed; probe 127.0.0.1:8080 from inside the container. - readinessProbe: { - exec: { command: ['node', '-e', 'require("http").get("http://127.0.0.1:8080", r => process.exit(r.statusCode < 500 ? 0 : 1)).on("error", () => process.exit(1))'] }, - initialDelaySeconds: 5, - periodSeconds: 3, - }, - env: [ - { name: 'HOME', value: ws }, - { name: 'DSH_HOME', value: home }, - ...apiKeyEnv(userId, apiKey), - ], - volumeMounts: [ - { name: 'data', mountPath: mount, subPath: userId }, - ...(patchConfigMapName !== undefined ? [{ name: 'patch', mountPath: '/etc/dsh', readOnly: true }] : []), - tmpMount(), - ], - securityContext: containerSecurity(uid), - resources: { requests: { cpu: '500m', memory: '1Gi' }, limits: { cpu: '2', memory: '4Gi' } }, - }, - { - name: 'sidecar', - image: this.config.controlPlaneImage, - args: ['tcp-bridge', `0.0.0.0:${SOCAT_PORT}`, `127.0.0.1:${DSH_LOOPBACK_PORT}`], - ports: [{ containerPort: SOCAT_PORT }], - securityContext: containerSecurity(uid), - resources: { requests: { cpu: '10m', memory: '32Mi' }, limits: { cpu: '100m', memory: '128Mi' } }, - }, - ], - volumes: [ - ...dataVolume(), - ...(patchConfigMapName !== undefined ? [{ name: 'patch', configMap: { name: patchConfigMapName } }] : []), - ...tmpVolume(), - ], - }, - } }) - } - - private async ensurePatchConfigMap(name: string, patch: string): Promise { - await this.replace({ - read: () => this.core.readNamespacedConfigMap({ name, namespace: this.namespace }), - create: () => this.core.createNamespacedConfigMap({ namespace: this.namespace, body: { - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name, namespace: this.namespace }, - data: { 'patch.yml': patch }, - } }), - del: () => this.core.deleteNamespacedConfigMap({ name, namespace: this.namespace }), - }) - } - - private async ensureService(name: string, userId: string): Promise { - await this.replace({ - read: () => this.core.readNamespacedService({ name, namespace: this.namespace }), - create: () => this.core.createNamespacedService({ namespace: this.namespace, body: { - apiVersion: 'v1', - kind: 'Service', - metadata: { name, namespace: this.namespace }, - spec: { - clusterIP: 'None', // Headless: no ClusterIP, DNS A record → Pod IP - selector: podLabels(userId), - ports: [{ port: 80, targetPort: SOCAT_PORT }], - }, - } }), - del: () => this.core.deleteNamespacedService({ name, namespace: this.namespace }), - }) - } - - private async ensureNetworkPolicy(name: string, userId: string): Promise { - await this.replace({ - read: () => this.networking.readNamespacedNetworkPolicy({ name, namespace: this.namespace }), - create: () => this.networking.createNamespacedNetworkPolicy({ namespace: this.namespace, body: { - apiVersion: 'networking.k8s.io/v1', - kind: 'NetworkPolicy', - metadata: { name, namespace: this.namespace }, - spec: { - podSelector: { matchLabels: podLabels(userId) }, - policyTypes: ['Ingress', 'Egress'], - ingress: [{ _from: [{ podSelector: { matchLabels: { app: 'dsh-orchestrator' } } }] }], - egress: [ - { - // DNS to anywhere: the DNS backend varies by distribution (kube-dns / - // coredns / node-local-dns on ACK), so don't pin a pod label. - to: [{ ipBlock: { cidr: '0.0.0.0/0' } }], - ports: [ - { port: 53, protocol: 'UDP' }, - { port: 53, protocol: 'TCP' }, - ], - }, - { - to: this.config.egressCidrs.length > 0 - ? this.config.egressCidrs.map((cidr) => ({ ipBlock: { cidr, except: ['169.254.169.254/32', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] } })) - : [{ ipBlock: { cidr: '0.0.0.0/0', except: ['169.254.169.254/32', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] } }], - ports: [{ port: 443 }], - }, - ], - }, - } }), - del: () => this.networking.deleteNamespacedNetworkPolicy({ name, namespace: this.namespace }), - }) - } - - private async ensureFilesPod(name: string, userId: string, uid: number): Promise { - const mount = userPaths(userId).mount - await this.replace({ - read: () => this.core.readNamespacedPod({ name, namespace: this.namespace }), - create: () => this.core.createNamespacedPod({ namespace: this.namespace, body: { - apiVersion: 'v1', - kind: 'Pod', - metadata: { name, namespace: this.namespace, labels: filesLabels(userId) }, - spec: { - automountServiceAccountToken: false, - imagePullSecrets: pullSecrets(this.config.imagePullSecret), - hostNetwork: false, - hostPID: false, - securityContext: { - runAsNonRoot: true, - runAsUser: uid, - fsGroup: uid, - seccompProfile: { type: 'RuntimeDefault' }, - }, - // Creates `//{ws,home}` as the user's uid (docs/k8s.md - // §4.9). Runs on the PVC *root* (no subPath), because the subPath dir - // may not exist yet or be root-owned; the user's 0700 dir keeps other - // users' files out of reach. - initContainers: [ - { - name: 'init-user', - image: this.config.controlPlaneImage, - command: ['sh', '-ec', `mkdir -p /mnt/${userId}/${WORKSPACE_DIR} /mnt/${userId}/${HOME_DIR} && chmod 0700 /mnt/${userId}`], - volumeMounts: [{ name: 'data-root', mountPath: '/mnt' }], - securityContext: containerSecurity(uid), - }, - ], - containers: [ - { - name: 'files', - image: this.config.controlPlaneImage, - args: ['file-service'], - env: [{ name: USER_ROOT_ENV, value: mount }], - ports: [{ containerPort: FILE_SERVICE_PORT }], - readinessProbe: { tcpSocket: { port: FILE_SERVICE_PORT }, initialDelaySeconds: 2, periodSeconds: 3 }, - volumeMounts: [{ name: 'data', mountPath: mount, subPath: userId }, tmpMount()], - securityContext: containerSecurity(uid), - resources: { requests: { cpu: '50m', memory: '128Mi' }, limits: { cpu: '200m', memory: '256Mi' } }, - }, - ], - volumes: [...dataVolume(), ...dataRootVolume(), ...tmpVolume()], - }, - } }), - del: () => this.core.deleteNamespacedPod({ name, namespace: this.namespace }), - }) - } - - private async ensureFilesService(name: string, userId: string): Promise { - await this.replace({ - read: () => this.core.readNamespacedService({ name, namespace: this.namespace }), - create: () => this.core.createNamespacedService({ namespace: this.namespace, body: { - apiVersion: 'v1', - kind: 'Service', - metadata: { name, namespace: this.namespace }, - spec: { - clusterIP: 'None', - selector: filesLabels(userId), - ports: [{ port: FILE_SERVICE_PORT, targetPort: FILE_SERVICE_PORT }], - }, - } }), - del: () => this.core.deleteNamespacedService({ name, namespace: this.namespace }), - }) - } - - private async ensureFilesNetworkPolicy(name: string, userId: string): Promise { - await this.replace({ - read: () => this.networking.readNamespacedNetworkPolicy({ name, namespace: this.namespace }), - create: () => this.networking.createNamespacedNetworkPolicy({ namespace: this.namespace, body: { - apiVersion: 'networking.k8s.io/v1', - kind: 'NetworkPolicy', - metadata: { name, namespace: this.namespace }, - spec: { - podSelector: { matchLabels: filesLabels(userId) }, - policyTypes: ['Ingress', 'Egress'], - ingress: [{ _from: [{ podSelector: { matchLabels: { app: 'dsh-orchestrator' } } }], ports: [{ port: FILE_SERVICE_PORT }] }], - // Files only; the sidecar never talks to the LLM API. - egress: [ - { - to: [{ ipBlock: { cidr: '0.0.0.0/0' } }], - ports: [ - { port: 53, protocol: 'UDP' }, - { port: 53, protocol: 'TCP' }, - ], - }, - ], - }, - } }), - del: () => this.networking.deleteNamespacedNetworkPolicy({ name, namespace: this.namespace }), - }) - } - - /** Poll until a Pod's Ready condition is true, or fail after `timeoutMs`. */ - private async waitForPodReady(name: string, timeoutMs = 60_000): Promise { - const deadline = Date.now() + timeoutMs - for (;;) { - const pod = await this.core.readNamespacedPod({ name, namespace: this.namespace }) - const ready = pod.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True') - if (ready) return - if (Date.now() >= deadline) throw new Error(`Pod ${name} not ready within ${timeoutMs}ms`) - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - } - - private isNotFound(err: unknown): boolean { - return (err as { code?: number }).code === 404 - } - - private isConflict(err: unknown): boolean { - return (err as { code?: number }).code === 409 - } - - private async ignoreNotFound(fn: () => Promise): Promise { - try { - await fn() - } catch (err) { - // `@kubernetes/client-node` throws `ApiException` with a `code` field. - if (this.isNotFound(err)) return - throw err - } - } -} diff --git a/src/supervisor/leader.ts b/src/supervisor/leader.ts deleted file mode 100644 index c0cae81..0000000 --- a/src/supervisor/leader.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Hand-rolled leader election over `coordination.k8s.io/v1` Lease - * (docs/k8s.md §5.3). `@kubernetes/client-node` ships no election helper, so - * this holds the small state machine: try to create the lease, and when it - * already exists either renew (we hold it) or take it over only after the - * holder's renew time has exceeded `leaseDurationSeconds`. - * - * Timings follow the doc's anti-split-brain ordering - * `LeaseDuration > RenewDeadline > RetryPeriod`. - * @module dshs/supervisor/leader - */ - -import * as k8s from '@kubernetes/client-node' -import { hostname } from 'node:os' - -/** The fencing token a controller stamps onto resources it creates. */ -export interface Fencing { - holder: string - operationId: number -} - -export interface LeaderOptions { - identity?: string - leaseName?: string - namespace: string - leaseDurationSeconds?: number - renewDeadlineSeconds?: number - retryPeriodSeconds?: number - /** Injectable clock (tests); defaults to Date.now. */ - now?: () => number - onStartedLeading?: (fencing: Fencing) => void - onStoppedLeading?: () => void - /** Injectable lease client (tests); defaults to the in-cluster config. */ - coordination?: k8s.CoordinationV1Api -} - -/** `@kubernetes/client-node` deserializes `V1MicroTime` back to an ISO string, - * not a Date — `renewTime.getTime()` would throw. Normalize either shape. */ -function toMillis(time: unknown): number { - if (time === undefined || time === null) return 0 - if (typeof time === 'string') { - const ms = Date.parse(time) - return Number.isNaN(ms) ? 0 : ms - } - if (time instanceof Date) return time.getTime() - return 0 -} - -/** Normalize a read-back `V1MicroTime` (string) back to a Date for the replace body. */ -function toMicroTime(time: unknown): k8s.V1MicroTime | undefined { - const ms = toMillis(time) - return ms > 0 ? new k8s.V1MicroTime(ms) : undefined -} - -/** Acquire/keep a Lease, notifying callers across leadership changes. */ -export class LeaderElector { - private readonly coordination: k8s.CoordinationV1Api - private readonly identity: string - private readonly leaseName: string - private readonly namespace: string - private readonly leaseDurationSeconds: number - private readonly renewDeadlineSeconds: number - private readonly retryPeriodSeconds: number - private readonly now: () => number - private onStartedLeading?: (fencing: Fencing) => void - private onStoppedLeading?: () => void - - private leading = false - private operationId = 0 - private lastRenew = 0 - private timer: NodeJS.Timeout | undefined - - constructor(options: LeaderOptions) { - if (options.coordination !== undefined) { - this.coordination = options.coordination - } else { - const kc = new k8s.KubeConfig() - kc.loadFromCluster() - this.coordination = kc.makeApiClient(k8s.CoordinationV1Api) - } - this.identity = options.identity ?? process.env.POD_NAME ?? hostname() - this.leaseName = options.leaseName ?? 'dsh-orchestrator' - this.namespace = options.namespace - this.leaseDurationSeconds = options.leaseDurationSeconds ?? 15 - this.renewDeadlineSeconds = options.renewDeadlineSeconds ?? 10 - this.retryPeriodSeconds = options.retryPeriodSeconds ?? 2 - this.now = options.now ?? Date.now - this.onStartedLeading = options.onStartedLeading - this.onStoppedLeading = options.onStoppedLeading - } - - get isLeader(): boolean { - return this.leading - } - - /** Wire (or rewire) leadership callbacks. The controller owns the reaction, - * not the elector, so it attaches itself here. */ - setLeadershipCallbacks(onStarted?: (fencing: Fencing) => void, onStopped?: () => void): void { - this.onStartedLeading = onStarted - this.onStoppedLeading = onStopped - } - - /** The current fencing token (valid only while {@link isLeader}). */ - get fencing(): Fencing { - return { holder: this.identity, operationId: this.operationId } - } - - /** Start the acquire/renew loop. Resolves once started (does not wait for leadership). */ - async start(): Promise { - if (this.timer !== undefined) return - await this.tryAcquire() - } - - /** Stop the loop and yield leadership if held. */ - stop(): void { - if (this.timer !== undefined) { - clearTimeout(this.timer) - this.timer = undefined - } - if (this.leading) { - this.leading = false - this.onStoppedLeading?.() - } - } - - /** One acquire/renew round, rescheduling itself with retry/backoff. */ - private async tryAcquire(): Promise { - try { - if (this.leading) { - await this.renew() - } else { - await this.acquire() - } - } catch { - // Transient API/network failure: retry. Leadership is only lost after the - // renewDeadline elapses without a successful renew, checked in the loop. - } - this.scheduleNext() - } - - private scheduleNext(): void { - if (this.timer !== undefined) return - // When leader, renew well inside renewDeadline; otherwise back off and retry. - const delay = this.leading ? Math.min(this.renewDeadlineSeconds / 2, this.retryPeriodSeconds) : this.retryPeriodSeconds - this.timer = setTimeout(() => { - this.timer = undefined - if (this.leading && this.now() - this.lastRenew > this.renewDeadlineSeconds * 1000) { - // Too long without a successful renew → another holder may have taken over. - this.leading = false - this.onStoppedLeading?.() - } - void this.tryAcquire() - }, delay * 1000) - this.timer.unref?.() - } - - private lease(now: number, transitions: number): k8s.V1Lease { - return { - apiVersion: 'coordination.k8s.io/v1', - kind: 'Lease', - metadata: { name: this.leaseName, namespace: this.namespace }, - spec: { - holderIdentity: this.identity, - leaseDurationSeconds: this.leaseDurationSeconds, - acquireTime: new k8s.V1MicroTime(now), - renewTime: new k8s.V1MicroTime(now), - leaseTransitions: transitions, - }, - } - } - - private async acquire(): Promise { - try { - await this.coordination.createNamespacedLease({ - namespace: this.namespace, - body: this.lease(this.now(), 0), - }) - this.becomeLeader(0, this.now()) - } catch (err) { - if ((err as { code?: number }).code !== 409) throw err - // Lease exists — take over only if the holder's renew time is stale. - const current = await this.coordination.readNamespacedLease({ - name: this.leaseName, - namespace: this.namespace, - }) - const holder = current.spec?.holderIdentity - const renew = toMillis(current.spec?.renewTime) - if (holder === this.identity) { - // We already hold it (e.g. after a restart) — renew, then resume leading. - await this.renew() - this.becomeLeader(current.spec?.leaseTransitions ?? 0, this.now()) - return - } - const expired = this.now() - renew > this.leaseDurationSeconds * 1000 - if (!expired) return // a live leader holds it; back off - const transitions = (current.spec?.leaseTransitions ?? 0) + 1 - const now = this.now() - // `patchNamespacedLease` uses JSON Patch (an array); a full replace with a - // resourceVersion gives the optimistic-concurrency takeover we want. - await this.coordination.replaceNamespacedLease({ - name: this.leaseName, - namespace: this.namespace, - body: { - apiVersion: 'coordination.k8s.io/v1', - kind: 'Lease', - metadata: { name: this.leaseName, namespace: this.namespace, resourceVersion: current.metadata?.resourceVersion }, - spec: { - holderIdentity: this.identity, - leaseDurationSeconds: this.leaseDurationSeconds, - acquireTime: new k8s.V1MicroTime(now), - renewTime: new k8s.V1MicroTime(now), - leaseTransitions: transitions, - }, - }, - }) - this.becomeLeader(transitions, now) - } - } - - private async renew(): Promise { - const now = this.now() - // Always read the latest lease so we preserve holder/acquireTime/transitions - // and bump only renewTime. - const current = await this.coordination.readNamespacedLease({ - name: this.leaseName, - namespace: this.namespace, - }) - await this.coordination.replaceNamespacedLease({ - name: this.leaseName, - namespace: this.namespace, - body: { - apiVersion: 'coordination.k8s.io/v1', - kind: 'Lease', - metadata: { name: this.leaseName, namespace: this.namespace, resourceVersion: current.metadata?.resourceVersion }, - spec: { - holderIdentity: current.spec?.holderIdentity ?? this.identity, - leaseDurationSeconds: this.leaseDurationSeconds, - acquireTime: toMicroTime(current.spec?.acquireTime), - renewTime: new k8s.V1MicroTime(now), - leaseTransitions: current.spec?.leaseTransitions ?? 0, - }, - }, - }) - this.lastRenew = now - } - - private becomeLeader(operationId: number, now: number): void { - const wasLeader = this.leading - this.leading = true - this.operationId = operationId - this.lastRenew = now - if (!wasLeader) this.onStartedLeading?.({ holder: this.identity, operationId }) - } -} diff --git a/src/supervisor/reconcile.ts b/src/supervisor/reconcile.ts deleted file mode 100644 index 3f76b73..0000000 --- a/src/supervisor/reconcile.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Leader-only controller: reconciles the cluster against the desired state in - * `dsh_instances` (docs/k8s.md §5.7) and watches the main DSH Pods for crashes. - * - * The k8s backend has no child-process `exit` event the way the local backend - * does — a crashed Pod is observed either by the informer (here) or by the next - * reconcile tick (a desired main whose Pod is gone). Both funnel into the same - * repair path: mark the instance crashed, pull up the one-shot watchdog Job, - * and let the reconcile relaunch the main if its Pod stays absent. - * @module dshs/supervisor/reconcile - */ - -import { makeInformer, type Informer } from '@kubernetes/client-node' -import type { DbAdapter } from '../db/adapter.js' -import type { DshInstance } from '../db/types.js' -import type { LeaderElector } from './leader.js' -import type { K8sSpawner } from './k8s-spawner.js' -import type { LivePod } from './spawner.js' - -/** The result of diffing desired state against live Pods. Pure and testable. */ -export interface ReconcilePlan { - /** Desired mains whose Pod is missing → relaunch. */ - launch: DshInstance[] - /** Live Pods with no desired row → delete (orphans). */ - delete: LivePod[] -} - -/** Diff desired mains vs live Pods. */ -export function planReconcile(desired: DshInstance[], live: LivePod[]): ReconcilePlan { - const liveByUser = new Map(live.map((pod) => [pod.userId, pod])) - const launch: DshInstance[] = [] - for (const instance of desired) { - if (instance.role !== 'main') continue - const pod = liveByUser.get(instance.userId) - if (pod === undefined || !pod.running) launch.push(instance) - } - const desiredUsers = new Set(desired.filter((i) => i.role === 'main').map((i) => i.userId)) - const del = live.filter((pod) => !desiredUsers.has(pod.userId)) - return { launch, delete: del } -} - -/** Loop that reconciles only while leader, plus a Pod informer for crashes. */ -export class ReconcileController { - private informer: Informer | undefined - private timer: NodeJS.Timeout | undefined - private leading = false - private readonly watchdogFired = new Set() - private readonly intervalMs: number - - constructor( - private readonly db: DbAdapter, - private readonly spawner: K8sSpawner, - private readonly elector: LeaderElector, - intervalMs = 10_000, - ) { - this.intervalMs = intervalMs - elector.setLeadershipCallbacks( - (fencing) => { - this.leading = true - this.spawner.setFencing(fencing) - this.startInformer() - this.scheduleTick(0) - }, - () => { - this.leading = false - this.stopInformer() - }, - ) - } - - async start(): Promise { - await this.elector.start() - } - - stop(): void { - this.elector.stop() - this.stopInformer() - if (this.timer !== undefined) { - clearTimeout(this.timer) - this.timer = undefined - } - } - - private startInformer(): void { - if (this.informer !== undefined) return - this.informer = this.spawner.watchMainPods((pod) => this.onPodEvent(pod)) - void this.informer.start().catch(() => { - // The informer is best-effort; the reconcile tick still converges. - this.informer = undefined - }) - } - - private stopInformer(): void { - void this.informer?.stop().catch(() => {}) - this.informer = undefined - } - - private scheduleTick(delayMs: number): void { - if (this.timer !== undefined) return - this.timer = setTimeout(() => { - this.timer = undefined - if (!this.leading) return - void this.tick().finally(() => this.scheduleTick(this.intervalMs)) - }, delayMs) - this.timer.unref?.() - } - - private async tick(): Promise { - const [desired, live] = await Promise.all([ - this.db.listInstancesByRole('main'), - this.spawner.listUserPods(), - ]) - const plan = planReconcile(desired, live) - - for (const orphan of plan.delete) { - // Orphan = a main Pod with no desired row (user deleted or disabled). - await this.safe(`orphan ${orphan.userId}`, async () => { - await this.db.deleteInstance(orphan.name) - await this.spawner.stop(orphan.userId) - }) - } - for (const instance of plan.launch) { - await this.safe(`launch ${instance.userId}`, () => - this.spawner.launch(instance.userId, instance.folder ?? '', instance.patch ?? undefined)) - } - // Recreate a lost Service/NetworkPolicy for any desired main that still has a Pod. - for (const instance of desired) { - if (plan.launch.includes(instance)) continue - await this.safe(`ensure ${instance.userId}`, () => this.spawner.ensureUserResources(instance.userId)) - } - // Idle reap (Phase 4): a desired main whose user has no active session has - // outlived its session TTL — stop the Pod and drop the desired row so the - // next tick does not relaunch it. - for (const instance of desired) { - await this.safe(`reap ${instance.userId}`, async () => { - if (await this.db.hasActiveSession(instance.userId)) return - await this.spawner.stop(instance.userId) - await this.db.deleteInstance(instance.id) - }) - } - } - - /** Run one per-user step without letting its failure abort the rest of the tick. */ - private async safe(label: string, fn: () => Promise): Promise { - try { - await fn() - } catch (err) { - // One bad user (e.g. a files Pod that can't become Ready) must not block - // launches/idle-reap for every other user. - this.spawner.logError?.(new Error(`reconcile ${label}: ${err instanceof Error ? err.message : String(err)}`)) - } - } - - private async onPodEvent(pod: LivePod): Promise { - if (!pod.crashed || !pod.running) { - // A healthy transition clears the "already fired" guard so a later crash - // triggers the watchdog again. - if (pod.running && !pod.crashed) this.watchdogFired.delete(pod.userId) - return - } - if (this.watchdogFired.has(pod.userId)) return - this.watchdogFired.add(pod.userId) - try { - await this.spawner.spawnWatchdog(pod.userId) - } catch (err) { - this.watchdogFired.delete(pod.userId) - this.spawner.logError?.(err) - } - } -} diff --git a/src/supervisor/spawner.ts b/src/supervisor/spawner.ts index 0d1cbea..e9dc60f 100644 --- a/src/supervisor/spawner.ts +++ b/src/supervisor/spawner.ts @@ -1,11 +1,9 @@ /** * Backend abstraction for per-user DSH lifecycle (docs/k8s.md §5.2). * - * `local` spawns child processes (setuid/iptables) via `LocalSpawner`; `k8s` - * creates/deletes per-user DSH Pods via the K8s API (`K8sSpawner`). The route - * layer depends only on this interface, so both backends coexist behind the - * same API. Shared instance/status types live here so neither backend owns - * them. + * `LocalSpawner` spawns child processes (setuid/iptables). The route layer + * depends only on this interface. Shared instance/status types live here so + * no backend owns them. * @module dshs/supervisor/spawner */ @@ -95,13 +93,11 @@ export interface LivePod { /** * The lifecycle seam the route layer delegates to. * - * `endpointFor` is spawner-specific: local → `127.0.0.1:`, k8s → the - * per-user Headless Service DNS (docs/k8s.md §5.4). + * `endpointFor` is spawner-specific: `127.0.0.1:` for the local backend + * (docs/k8s.md §5.4). * - * `launch` takes the rendered cordis patch as **content**, not a path: under - * k8s the control plane holds no user volume, so it can neither write the patch - * nor read it back. `LocalSpawner` materializes it to a file (it does have the - * volume) and `K8sSpawner` puts it straight into a ConfigMap. + * `launch` takes the rendered cordis patch as **content**, not a path; + * `LocalSpawner` materializes it to a file inside the user's own volume. */ export interface Spawner { launch(userId: string, folder: string, patch?: string, opts?: { force?: boolean }): Promise diff --git a/src/tcp-bridge.ts b/src/tcp-bridge.ts deleted file mode 100644 index e26f5d2..0000000 --- a/src/tcp-bridge.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Tiny TCP bridge: listen on one address and forward every connection to - * another. Replaces the `alpine/socat` sidecar in the per-user DSH Pod, so the - * Pod no longer depends on a docker.io image (blocked on ACK, docs/k8s-deploy.md - * §7). Runs from the control-plane image's Node runtime. - * @module dshs/tcp-bridge - */ - -import { createConnection, createServer, type Server } from 'node:net' - -/** - * Start a TCP bridge: `listen` (e.g. `0.0.0.0:8081`) → `target` - * (e.g. `127.0.0.1:8080`). Resolves when listening. - */ -export function startTcpBridge(listen: string, target: string): Promise { - const [targetHost, targetPort] = splitHostPort(target) - const server = createServer((socket) => { - // A fresh outbound connection to the target — NOT `socket.connect`, which - // would try to re-connect the already-connected inbound socket. - const upstream = createConnection(Number(targetPort), targetHost) - upstream.on('error', () => socket.destroy()) - socket.on('error', () => upstream.destroy()) - socket.pipe(upstream) - upstream.pipe(socket) - }) - const [listenHost, listenPort] = splitHostPort(listen) - return new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(Number(listenPort), listenHost, () => resolve(server)) - }) -} - -/** `host:port` → `[host, port]`, defaulting the host to `0.0.0.0`. */ -function splitHostPort(addr: string): [string, string] { - const idx = addr.lastIndexOf(':') - if (idx === -1) return ['0.0.0.0', addr] - return [addr.slice(0, idx), addr.slice(idx + 1)] -} diff --git a/src/web/file-service.ts b/src/web/file-service.ts deleted file mode 100644 index 70fb37d..0000000 --- a/src/web/file-service.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * The per-user file sidecar (docs/k8s.md §4.10). - * - * Runs inside the user's own Pod as the user's uid, so it *can* read and write - * their `0700` directory — the thing the control plane (uid 65532, no volume) - * cannot do. It serves exactly one user: the root is fixed at startup, and no - * request carries a user id. - * - * **No authentication.** The boundary is the NetworkPolicy that lets only the - * control plane reach port 8082, the same argument that lets the socat sidecar - * bridge 8081 unauthenticated (docs/k8s.md §6.1 item 4). If that policy is ever - * widened, this service needs a token check *first*. - * @module dshs/web/file-service - */ - -import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify' -import { LocalUserFs } from '../fs/local-user-fs.js' -import { UserFsError } from '../fs/user-fs.js' - -/** Port the sidecar binds; the per-user Service targets it directly. */ -export const FILE_SERVICE_PORT = 8082 - -/** Env var carrying the single user root this sidecar serves. */ -export const USER_ROOT_ENV = 'DSHS_USER_ROOT' - -/** The sidecar serves one user, so the id crossing {@link UserFs} is a constant. */ -const SOLE_USER = 'self' - -const pathSchema = { - body: { - type: 'object', - required: ['path'], - additionalProperties: false, - properties: { path: { type: 'string', maxLength: 512 } }, - }, -} as const - -const entrySchema = { - body: { - type: 'object', - required: ['path', 'name', 'type'], - additionalProperties: false, - properties: { - path: { type: 'string', maxLength: 512 }, - name: { type: 'string', maxLength: 255 }, - type: { type: 'string', enum: ['file', 'dir'] }, - }, - }, -} as const - -const uploadSchema = { - body: { - type: 'object', - required: ['path', 'name', 'data'], - additionalProperties: false, - properties: { - path: { type: 'string', maxLength: 512 }, - name: { type: 'string', maxLength: 255 }, - data: { type: 'string' }, - }, - }, -} as const - -const handoffSchema = { - body: { - type: 'object', - required: ['content'], - additionalProperties: false, - properties: { content: { type: 'string', maxLength: 8192 } }, - }, -} as const - -/** Reply with the seam's own wire form so the client can rebuild the error. */ -function fail(reply: FastifyReply, err: unknown): FastifyReply { - if (err instanceof UserFsError) return reply.code(err.status).send({ error: err.code }) - throw err -} - -/** - * Build the sidecar's Fastify instance. Does not listen; the caller binds. - * @param root - the user's data root inside the Pod. - * @param options - `bodyLimit` must exceed the control plane's upload cap. - */ -export function buildFileService(root: string, options: { bodyLimit: number; logLevel: string }): FastifyInstance { - const fs = new LocalUserFs(() => root) - const app = Fastify({ logger: { level: options.logLevel }, bodyLimit: options.bodyLimit }) - - app.get('/healthz', async () => ({ ok: true })) - - app.get('/fs/tree', async (request, reply) => { - const { path = '' } = request.query as { path?: string } - try { - return { entries: await fs.listDir(SOLE_USER, path) } - } catch (err) { - return fail(reply, err) - } - }) - - app.get('/fs/stat', async (request, reply) => { - const { path = '' } = request.query as { path?: string } - try { - return { isDirectory: await fs.isDirectory(SOLE_USER, path) } - } catch (err) { - return fail(reply, err) - } - }) - - app.get('/fs/plugins', async () => ({ plugins: await fs.listInstalledPlugins(SOLE_USER) })) - - app.post('/fs/init', async () => { - await fs.initUserRoot(SOLE_USER) - return { ok: true } - }) - - app.post('/fs/mkdir', { schema: pathSchema }, async (request, reply) => { - const { path } = request.body as { path: string } - try { - await fs.mkdir(SOLE_USER, path) - return { ok: true } - } catch (err) { - return fail(reply, err) - } - }) - - app.post('/fs/create', { schema: entrySchema }, async (request, reply) => { - const { path, name, type } = request.body as { path: string; name: string; type: 'file' | 'dir' } - try { - return { name: await fs.createEntry(SOLE_USER, path, name, type) } - } catch (err) { - return fail(reply, err) - } - }) - - app.post('/fs/upload', { schema: uploadSchema }, async (request, reply) => { - const { path, name, data } = request.body as { path: string; name: string; data: string } - try { - return { name: await fs.upload(SOLE_USER, path, name, Buffer.from(data, 'base64')) } - } catch (err) { - return fail(reply, err) - } - }) - - app.post('/fs/handoff', { schema: handoffSchema }, async (request) => { - const { content } = request.body as { content: string } - await fs.writeHandoff(SOLE_USER, content) - return { ok: true } - }) - - return app -} diff --git a/src/web/server.ts b/src/web/server.ts index 6769d0a..dae659a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -17,7 +17,6 @@ import type { UserFs } from '../fs/user-fs.js' import { decrypt, deriveKey } from '../crypto.js' import { hashUid } from '../isolation.js' import { LocalSpawner } from '../supervisor/orchestrator.js' -import { K8sSpawner } from '../supervisor/k8s-spawner.js' import { registerDshProxy } from '../supervisor/proxy.js' import type { Spawner } from '../supervisor/spawner.js' import { @@ -257,11 +256,11 @@ export async function buildServer(config: ServerConfig): Promise supervisor.ensureFileService(userId)) + if (config.deployMode === 'k8s') { + throw new Error('deployMode "k8s" is not supported by this build: only the single-machine backend ships') + } + const supervisor: Spawner = new LocalSpawner(config, resolveApiKey, resolveUid) + const userFs = createUserFs(config) const app = Fastify({ logger: { level: config.logLevel }, diff --git a/test/k8s-spawner.test.mjs b/test/k8s-spawner.test.mjs deleted file mode 100644 index 9d29ddc..0000000 --- a/test/k8s-spawner.test.mjs +++ /dev/null @@ -1,163 +0,0 @@ -// K8sSpawner unit tests against fake API clients. Covers the six defects fixed -// in Stage 4 without needing a cluster: 404-vs-500 error handling, full -// teardown of every generated object, idempotent Secret/ConfigMap, and the -// file-sidecar Pod + init container shape. -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { K8sSpawner } from '../lib/supervisor/k8s-spawner.js' -import { resolveConfig } from '../lib/config.js' -import { AlreadyRunningError } from '../lib/supervisor/spawner.js' -import { FILE_SERVICE_PORT } from '../lib/web/file-service.js' - -function notFound() { - const err = new Error('not found') - err.code = 404 - return err -} -function conflict() { - const err = new Error('already exists') - err.code = 409 - return err -} - -/** A tiny in-memory fake for the K8s API surface K8sSpawner touches. */ -function makeClients() { - const pods = new Map() - const services = new Map() - const secrets = new Map() - const configMaps = new Map() - const policies = new Map() - const jobs = new Map() - const deletes = [] - - return { - pods, services, secrets, configMaps, policies, jobs, deletes, - core: { - async readNamespacedPod({ name }) { return pods.get(name) ?? (() => { throw notFound() })() }, - async createNamespacedPod({ body }) { - // Stored pods report Ready so `waitForPodReady` returns immediately. - const pod = { ...body, status: { phase: 'Running', conditions: [{ type: 'Ready', status: 'True' }] } } - pods.set(body.metadata.name, pod) - return pod - }, - async deleteNamespacedPod({ name }) { deletes.push(`pod:${name}`); pods.delete(name) }, - async readNamespacedSecret({ name }) { return secrets.get(name) ?? (() => { throw notFound() })() }, - async createNamespacedSecret({ body }) { secrets.set(body.metadata.name, body); return body }, - async deleteNamespacedSecret({ name }) { deletes.push(`secret:${name}`); secrets.delete(name) }, - async readNamespacedConfigMap({ name }) { return configMaps.get(name) ?? (() => { throw notFound() })() }, - async createNamespacedConfigMap({ body }) { configMaps.set(body.metadata.name, body); return body }, - async deleteNamespacedConfigMap({ name }) { deletes.push(`configmap:${name}`); configMaps.delete(name) }, - async readNamespacedService({ name }) { return services.get(name) ?? (() => { throw notFound() })() }, - async createNamespacedService({ body }) { services.set(body.metadata.name, body); return body }, - async deleteNamespacedService({ name }) { deletes.push(`service:${name}`); services.delete(name) }, - }, - networking: { - async readNamespacedNetworkPolicy({ name }) { return policies.get(name) ?? (() => { throw notFound() })() }, - async createNamespacedNetworkPolicy({ body }) { policies.set(body.metadata.name, body); return body }, - async deleteNamespacedNetworkPolicy({ name }) { deletes.push(`np:${name}`); policies.delete(name) }, - }, - batch: { - async createNamespacedJob({ body }) { jobs.set(body.metadata.name, body); return body }, - async deleteNamespacedJob({ name }) { deletes.push(`job:${name}`); jobs.delete(name) }, - }, - } -} - -function spawner(clients) { - const config = resolveConfig({ deployMode: 'k8s', controlPlaneImage: 'acr/cp:tag', dshImage: 'acr/dsh:tag' }) - const db = { findUserInstance: async () => undefined } - return new K8sSpawner(config, db, async () => 'sk-test', async () => 100042, clients) -} - -test('k8s: launch creates DSH Pod/Service/NP and calls ensureFileService first', async () => { - const clients = makeClients() - const s = spawner(clients) - const inst = await s.launch('u1', '/ws/proj') - assert.equal(inst.id, 'dsh-u1') - assert.ok(clients.pods.has('dsh-files-u1'), 'files Pod created first') - assert.ok(clients.pods.has('dsh-u1'), 'dsh Pod created') - assert.ok(clients.services.has('dsh-u1'), 'dsh Service created') - assert.ok(clients.policies.has('dsh-u1'), 'dsh NetworkPolicy created') - - const filesPod = clients.pods.get('dsh-files-u1') - assert.equal(filesPod.spec.containers[0].args[0], 'file-service', 'files container runs the file-service subcommand') - assert.equal(filesPod.spec.initContainers.length, 1, 'files Pod carries the user-dir init container') - const filesMounts = filesPod.spec.containers[0].volumeMounts - assert.equal(filesMounts.length, 2, 'files container mounts its subPath + /tmp') - assert.equal(filesMounts[0].name, 'data') - assert.equal(filesMounts[0].subPath, 'u1', 'files container mounts /u1 via subPath') - assert.equal(filesMounts[1].name, 'tmp', 'files container mounts emptyDir /tmp (read-only rootfs)') - assert.equal(filesPod.spec.containers[0].securityContext.readOnlyRootFilesystem, true, 'files container rootfs is read-only') - const initMount = filesPod.spec.initContainers[0].volumeMounts[0] - assert.equal(initMount.name, 'data-root', 'init container mounts the PVC root (no subPath)') - assert.equal(initMount.subPath, undefined, 'init container has no subPath') - - await assert.rejects(() => s.launch('u1', '/ws/proj'), AlreadyRunningError) -}) - -test('k8s: endpointFor returns the Pod IP (not Service DNS) for a Running Pod', async () => { - const clients = makeClients() - const s = spawner(clients) - assert.equal(await s.endpointFor('u1'), undefined, 'absent Pod → undefined') - clients.pods.set('dsh-u1', { status: { phase: 'Running', podIP: '10.42.0.7' } }) - assert.deepEqual(await s.endpointFor('u1'), { host: '10.42.0.7', port: 8081 }) - clients.pods.set('dsh-u1', { status: { phase: 'Pending' } }) - assert.equal(await s.endpointFor('u1'), undefined, 'non-Running Pod → undefined') -}) - -test('k8s: stop deletes every generated object (Pod/Service/NP/Job/ConfigMap)', async () => { - const clients = makeClients() - const s = spawner(clients) - await s.launch('u1', '/ws/proj', '- insert:\n') - // Pre-seed the patch ConfigMap + watchdog Job as a prior launch would have. - clients.configMaps.set('dsh-u1-patch', { metadata: { name: 'dsh-u1-patch' } }) - clients.jobs.set('dsh-u1-watchdog', { metadata: { name: 'dsh-u1-watchdog' } }) - - await s.stop('u1') - const names = clients.deletes - for (const expected of ['pod:dsh-u1', 'service:dsh-u1', 'np:dsh-u1', 'job:dsh-u1-watchdog', 'configmap:dsh-u1-patch']) { - assert.ok(names.includes(expected), `stop deletes ${expected}`) - } - // stop() must NOT touch the files Pod (it lives independently of the DSH). - assert.ok(clients.pods.has('dsh-files-u1'), 'files Pod survives a DSH stop') -}) - -test('k8s: a 404 is swallowed, but 403/500 propagate', async () => { - const clients = makeClients() - const s = spawner(clients) - // endpointFor must not mask a real failure as "not running". - clients.core.readNamespacedPod = async () => { const e = new Error('forbidden'); e.code = 403; throw e } - await assert.rejects(() => s.endpointFor('u1'), (err) => err.code === 403, '403 surfaces') - clients.core.readNamespacedPod = async () => { throw notFound() } - assert.equal(await s.endpointFor('u1'), undefined, '404 is "no Pod"') -}) - -test('k8s: ensureSecret is idempotent (no 409 on relaunch)', async () => { - const clients = makeClients() - const s = spawner(clients) - // First call: read → 404 → create. - await s.ensureFileService('u1') // exercises the image gate, not the secret - await s.launch('u1', '/ws') - // Simulate a stale Secret still present after a stop that skipped it. - clients.secrets.set('dsh-key-u1', { metadata: { name: 'dsh-key-u1' } }) - clients.pods.delete('dsh-u1') - await s.launch('u1', '/ws') // read → present → no create, no throw - assert.ok(clients.secrets.has('dsh-key-u1'), 'secret still present, no 409') -}) - -test('k8s: ensureFileService fails loudly without a control-plane image', async () => { - const config = resolveConfig({ deployMode: 'k8s', controlPlaneImage: '', dshImage: 'acr/dsh:tag' }) - const s = new K8sSpawner(config, { findUserInstance: async () => undefined }, async () => null, async () => 100042, makeClients()) - await assert.rejects(() => s.ensureFileService('u1'), /CONTROL_PLANE_IMAGE/, 'missing image is a hard failure') -}) - -test('k8s: files NetworkPolicy only allows the control plane on the file port', async () => { - const clients = makeClients() - const s = spawner(clients) - await s.ensureFileService('u1') - const np = clients.policies.get('dsh-files-u1') - assert.equal(np.spec.ingress[0]._from[0].podSelector.matchLabels.app, 'dsh-orchestrator') - assert.equal(np.spec.ingress[0].ports[0].port, FILE_SERVICE_PORT) - // No 443 egress for the files sidecar — it never reaches the LLM API. - assert.equal(np.spec.egress.some((rule) => (rule.ports ?? []).some((p) => p.port === 443)), false) -}) diff --git a/test/leader.test.mjs b/test/leader.test.mjs deleted file mode 100644 index 1e541d4..0000000 --- a/test/leader.test.mjs +++ /dev/null @@ -1,117 +0,0 @@ -// Leader election + reconcile planning, without a cluster. `planReconcile` is -// pure; `LeaderElector` is driven through a fake CoordinationV1Api and a fake -// clock so the acquire / back-off / take-over state machine is deterministic. -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { LeaderElector } from '../lib/supervisor/leader.js' -import { planReconcile } from '../lib/supervisor/reconcile.js' - -// The real client deserializes V1MicroTime back to an ISO *string*, so fake the -// same shape here to exercise the normalize-on-read path. -const lease = (holder, renewMs, transitions = 0, rv = '1') => ({ - metadata: { resourceVersion: rv }, - spec: { - holderIdentity: holder, - renewTime: new Date(renewMs).toISOString(), - leaseTransitions: transitions, - }, -}) - -test('reconcile: relaunch desired mains with a missing/stopped Pod, delete orphans', () => { - const desired = [ - { id: 'dsh-a', userId: 'a', role: 'main', folder: '/ws', patch: null }, - { id: 'dsh-b', userId: 'b', role: 'main', folder: '/ws', patch: null }, - ] - const live = [ - { name: 'dsh-a', userId: 'a', running: true, crashed: false }, - { name: 'dsh-orphan', userId: 'x', running: true, crashed: false }, - ] - const plan = planReconcile(desired, live) - assert.deepEqual(plan.launch.map((i) => i.userId), ['b'], 'missing main is relaunched') - assert.deepEqual(plan.delete.map((p) => p.userId), ['x'], 'orphan Pod is deleted') -}) - -test('reconcile: a crashed (non-running) Pod still counts as absent', () => { - const plan = planReconcile( - [{ id: 'dsh-a', userId: 'a', role: 'main', folder: '/ws', patch: null }], - [{ name: 'dsh-a', userId: 'a', running: false, crashed: true }], - ) - assert.equal(plan.launch.length, 1, 'crashed Pod → relaunch') - assert.equal(plan.delete.length, 0) -}) - -test('leader: first candidate creates the lease and leads', async () => { - let created = false - let patched = [] - const coordination = { - async createNamespacedLease({ body }) { - created = true - return body - }, - async readNamespacedLease() { throw Object.assign(new Error('nf'), { code: 404 }) }, - async replaceNamespacedLease({ body }) { patched.push(body) }, - } - const started = [] - const elector = new LeaderElector({ namespace: 'dsh', identity: 'pod-1', coordination, now: () => 1_000_000 }) - elector.setLeadershipCallbacks((f) => started.push(f)) - await elector.start() - assert.equal(created, true, 'first candidate creates the lease') - assert.equal(elector.isLeader, true) - assert.equal(started[0].holder, 'pod-1') - assert.equal(started[0].operationId, 0) - elector.stop() -}) - -test('leader: a second candidate backs off while a live holder leads', async () => { - const coordination = { - async createNamespacedLease() { throw Object.assign(new Error('exists'), { code: 409 }) }, - async readNamespacedLease() { return lease('pod-1', 10_000_000) }, // renew 1s ago, live - async replaceNamespacedLease() { throw new Error('should not replace') }, - } - const elector = new LeaderElector({ - namespace: 'dsh', - identity: 'pod-2', - coordination, - now: () => 11_000_000, // 1s after the holder's renew, still within 15s - }) - await elector.start() - assert.equal(elector.isLeader, false, 'live holder → back off') - elector.stop() -}) - -test('leader: a stale lease is taken over with a bumped transition', async () => { - let patched - const coordination = { - async createNamespacedLease() { throw Object.assign(new Error('exists'), { code: 409 }) }, - async readNamespacedLease() { return lease('pod-1', 10_000_000, 3, 'rv-9') }, // renew 20s ago, expired - async replaceNamespacedLease({ body }) { patched = body }, - } - const started = [] - const elector = new LeaderElector({ - namespace: 'dsh', - identity: 'pod-2', - coordination, - now: () => 30_000_000, - }) - elector.setLeadershipCallbacks((f) => started.push(f)) - await elector.start() - assert.equal(elector.isLeader, true, 'expired lease → take over') - assert.equal(patched.metadata.resourceVersion, 'rv-9', 'CAS on the read resourceVersion') - assert.equal(patched.spec.leaseTransitions, 4, 'transition bumps') - assert.equal(started[0].operationId, 4, 'fencing token carries the new transition') - elector.stop() -}) - -test('leader: re-acquiring our own lease renews rather than bumps', async () => { - let patched - const coordination = { - async createNamespacedLease() { throw Object.assign(new Error('exists'), { code: 409 }) }, - async readNamespacedLease() { return lease('pod-1', 30_000_000, 0, 'rv-2') }, - async replaceNamespacedLease({ body }) { patched = body }, - } - const elector = new LeaderElector({ namespace: 'dsh', identity: 'pod-1', coordination, now: () => 30_000_000 }) - await elector.start() - assert.equal(elector.isLeader, true) - assert.equal(patched.spec.leaseTransitions, 0, 'renewing preserves transitions (does not bump)') - elector.stop() -})