diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1dd8d0cc..38ae0d7b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -19,11 +19,11 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `server cleanup plan [--min-age-hours N] [--limit N]` 只生成主 server Docker 镜像清理 dry-run 计划,不执行删除;默认 `--min-age-hours 24`,避免把刚发布或刚验证的镜像列为 stale。输出必须包含 `dryRun=true`、`mutation=false`、`policy.deletionExecuted=false`、active containers/images、受保护镜像、candidate stale images、估算释放空间、风险等级、`commandsToReview` 和人工审批清单。计划必须保守白名单:保留 running containers 使用的 image ID,保留 stopped containers 引用的 image ID 直到人工先复核容器,保留 `deploy.json`/`CI.json` 当前 commit-pinned artifact、Compose stable image、上游 digest pin 和 provider-gateway runner image;`protectedStorage` 必须显式列出 PostgreSQL named volume、Baidu Netdisk `.state`、D601 registry storage 和 Docker volumes/host data policy。该入口禁止生成或执行 `docker system prune`、`docker image prune`、`docker builder prune`、`docker volume rm`、`docker compose down -v`、数据库清理或 host data `rm` 命令;未来若增加真实删除,必须另设显式审批参数并先复核 dry-run 输出。 - `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`;该命令用于替代手工删除容器的兜底流程,其中 `dev-frontend-proxy` 只更新主 server dev 入口薄代理,`todo-note`、`code-queue-mgr`、`project-manager`、`baidu-netdisk` 和 `oa-event-flow` 只重建主 server 承载的对应后端,不会重建或删除 database 命名卷。D601 Code Queue 执行面不由 `server rebuild` 管理,Rust backend-core 迭代不得用 `server rebuild backend-core` 在 master server 编译,规则见 `docs/reference/dev-environment.md`。 - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`ssh argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 -- `ssh [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601`,也可以扩展为 `provider:plane:entry-or-scope...`,例如 `D601:k3s:kubectl`、`D601:k3s:hwlab-dev:hwlab-cloud-api` 或 `D601:k3s:script:hwlab-dev:hwlab-cloud-api`。非交互远端命令优先使用 `ssh argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `bun scripts/cli.ts ssh D601 script <<'SCRIPT'` 或 `bun scripts/cli.ts ssh D601:k3s:script:: <<'SCRIPT'`,把脚本走 stdin,而不是把脚本压成多层引号字符串。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 +- `ssh [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:k3s` 或 `D601:k3s:hwlab-dev:hwlab-cloud-api`。非交互远端命令优先使用 `ssh argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `bun scripts/cli.ts ssh D601 script <<'SCRIPT'`、`bun scripts/cli.ts ssh D601:k3s script <<'SCRIPT'` 或 `bun scripts/cli.ts ssh D601:k3s:: script <<'SCRIPT'`,把脚本走 stdin,而不是把脚本压成多层引号字符串。需要在 pod 内改文件时优先使用 `D601:k3s:: apply-patch`,CLI 会临时注入 pod 内 `apply_patch` helper 并把 patch stdin 交给它。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 - `ssh apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点。 - `ssh py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `ssh skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 -- `ssh D601:k3s[:kubectl|:namespace:workload[:container]] ...` 是 D601 原生 k3s 结构化 route 入口,CLI 固定注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并把 kubectl、workload exec 和 logs 参数组装成 argv,避免在 Host SSH、bash、kubectl exec 和容器 shell 之间反复手写多层引号。 +- `ssh D601:k3s[:namespace:workload[:container]] ...` 是 D601 原生 k3s 结构化 route 入口,route 只定位控制面或 workload,`kubectl`、`logs`、`exec`、`script`、`apply-patch` 和普通容器命令作为 operation 放在 route 之后;CLI 固定注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并把 kubectl、workload exec 和 logs 参数组装成 argv,避免在 Host SSH、bash、kubectl exec 和容器 shell 之间反复手写多层引号。 - `microservice list/status/health/diagnostics/tunnel-self-test/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 或 k3s 控制面中的用户服务(底层命令名仍为 microservice);`health`、`status` 和 `diagnostics` 默认返回 compact summary、body 字节数和 `--full|--raw` 展开命令,只有小 body 或无法抽取 summary 时才带有界 body preview,避免 Code Queue/k3s 诊断一次性输出爆炸;`tunnel-self-test` 和 `proxy` 会走真实 backend-core -> provider-gateway 或 k3sctl-adapter -> 节点服务链路。`microservice health code-queue` 使用 commander-safe 专用摘要,必须保留 ok/status、service id、running count、queue count、heartbeat freshness/risk、split-brain/live/degraded 解释和 raw drill-down 命令;需要完整健康 JSON 时显式加 `--raw` 或 `--full`,等价深挖路径是 `microservice proxy code-queue /health --raw --full`。`proxy` 支持受控 JSON 请求体并对超大响应 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。 - `decision upload/list/show/health` 通过 backend-core 用户服务代理访问 D601 k3s Decision Center,用于上传会议记录/决议 Markdown、列出权威记录、查看详情和健康检查;`decision list` 默认只返回摘要并省略完整 Markdown body,需要排查大正文时显式加 `--include-body`。正式文书字段通过 records 模型一等字段返回和查询:`--doc-no DC-...`、`--doc-type DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS`、`--doc-priority P0|P1|P2|P3`、`--year YYYY`、`--signer`、`--issued-at`、`--effective-scope`、`--supersedes`、`--superseded-by`;`show` 和 `requirement update` 可使用 `id` 或 `docNo`。`decision requirement list/create/upsert/update/show` 在同一 records 模型上管理 `goal|decision|blocker|debt|experiment` 需求记录,`docNo` 唯一,未传 `--doc-no` 但提供 `--doc-type/--doc-priority/--year` 时由服务分配下一个序号。它们不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。 - `decision diary import ` 将带 `# YYYY年M月D日`、`# YYYY-MM-DD` 或 `# YYYY/M/D` 标题的工作日志拆成每天一篇 Markdown 日记,按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径写入 Decision Center PostgreSQL;`decision diary list/history` 默认只返回摘要,需要完整 Markdown 时显式加 `--include-body`;`decision diary show [--source-file path]` 查看单日正文,`--source-file` 用于同一天存在多个导入来源时精确选择;`decision diary edit|upsert --body-file [--title text] [--source-file path] [--tag tag]` 通过 `PUT /api/diary/entries/:idOrDate` 创建当天或历史条目并编辑既有条目。 @@ -177,23 +177,32 @@ bun scripts/cli.ts ssh D601 find /home/ubuntu --max-depth 4 --type d --icontains bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*-test.cpp' --limit 20 --sort ``` -`ssh` 的 route 语法是 `provider:plane:entry-or-scope...`。当前稳定 plane 是 `k3s`:`D601:k3s` 表示 D601 原生 k3s 控制面;`D601:k3s:kubectl` 表示该控制面的 kubectl CLI;`D601:k3s::[:container]` 表示 namespace 下的一个默认 deployment workload,后续 argv 默认通过 `kubectl exec` 进入该对象;`D601:k3s:script::[:container]` 表示把本地 stdin 脚本送入目标 workload。若目标是具体 Pod,workload 段写成 `pod/`;若目标是 Deployment,也可以显式写 `deployment/` 或简写 ``。 +`ssh` 的 route 语法是 `provider:plane[:namespace:resource[:container]]`,只负责定位分布式目标,不表达操作。当前稳定 plane 是 `k3s`:`D601:k3s` 定位到 D601 原生 k3s 控制面;`D601:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,workload 段写成 `pod/`,若目标是 Deployment,也可以显式写 `deployment/` 或简写 ``。`kubectl`、`logs`、`script`、`apply-patch`、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。 -该 route 入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制、容器命令和 stdin script 路由组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`。`D601:k3s` 无后续参数时执行 native k3s guard;`D601:k3s:kubectl` 接收原始 kubectl argv;`D601:k3s:logs::` 读取有界日志;`D601:k3s:exec::` 和 `D601:k3s::` 进入目标 workload;`D601:k3s:script::` 把本地 stdin 作为 pod 内 shell 脚本执行。典型用法: +该入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制、容器命令、stdin script 和 pod apply-patch 组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`。`D601:k3s` 无后续参数时执行 native k3s guard;`D601:k3s kubectl ...` 接收原始 kubectl argv;`D601:k3s script` 执行带 native kubeconfig 的 D601 host stdin 脚本;`D601:k3s:: logs` 读取有界日志;`D601:k3s:: exec ...` 和 `D601:k3s:: ...` 进入目标 workload;`D601:k3s:: script` 把本地 stdin 作为 pod 内 shell 脚本执行;`D601:k3s:: apply-patch` 把本地标准 patch 作为 stdin 送入 pod 内 `apply_patch`。典型用法: ```bash bun scripts/cli.ts ssh D601:k3s -bun scripts/cli.ts ssh D601:k3s:kubectl get pods -n hwlab-dev -bun scripts/cli.ts ssh D601:k3s:logs:hwlab-dev:hwlab-cloud-api --tail 80 +bun scripts/cli.ts ssh D601:k3s kubectl get pods -n hwlab-dev +printf 'kubectl get deploy -n hwlab-dev\n' | bun scripts/cli.ts ssh D601:k3s script +bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api logs --tail 80 bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)' -printf 'printf "pod=%s\n" "$HOSTNAME"\n' | bun scripts/cli.ts ssh D601:k3s:script:hwlab-dev:hwlab-cloud-api +printf 'printf "pod=%s\n" "$HOSTNAME"\n' | bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api script +bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH' +*** Begin Patch +*** Update File: /tmp/example.txt +@@ +-old ++new +*** End Patch +PATCH ``` -route logs 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。route exec 不要求手写 `--`,CLI 会把 route 后续 argv 放到 `kubectl exec --` 后;如果命令本身需要复杂 shell 语法,优先改用 route script,把脚本走 stdin,而不是把 `kubectl exec ... -- sh -c ...` 放进远端命令字符串。 +`logs` operation 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。目标 route 后面直接跟普通命令时,CLI 会把 argv 放到 `kubectl exec --` 后;显式 `exec` operation 可用于让命令边界更清晰。如果命令本身需要复杂 shell 语法,优先改用 `script` operation,把脚本走 stdin,而不是把 `kubectl exec ... -- sh -c ...` 放进远端命令字符串。pod 内 `apply-patch` operation 临时注入的是 `sh` 版 helper,不要求目标容器自带 `python3`、`node` 或仓库里的工具脚本;它面向文本热修复,不用于大文件或二进制改写。 `ssh argv [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `bun scripts/cli.ts ssh D601 argv true`。需要管道、重定向、变量展开或多条命令时,优先改用 `ssh script <<'SCRIPT'`。`find`、`glob` 和 `apply-patch` 有专用入口;`rg`、`grep`、`sed`、`nl`、`stat`、`du`、`ls`、`cat`、`head`、`tail`、`wc` 和 `pwd` 可以直接作为 `ssh` 子命令使用,CLI 会对每个 argv token 做 shell quoting。旧的自由 ssh-like 远端命令入口只保留为近似原生 ssh 的人工兼容路径。 -通过 `ssh ` 执行多行脚本时,优先使用结构化 helper,例如 `bun scripts/cli.ts ssh D601 py < script.py`、`bun scripts/cli.ts ssh D601 script <<'SCRIPT'` 或 `bun scripts/cli.ts ssh D601:k3s:script:hwlab-dev:hwlab-cloud-api <<'SCRIPT'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <` 执行多行脚本时,优先使用结构化 helper,例如 `bun scripts/cli.ts ssh D601 py < script.py`、`bun scripts/cli.ts ssh D601 script <<'SCRIPT'` 或 `bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <:`, `ssh py`, `ssh apply-patch`, `ssh find`, `ssh glob` or `ssh skills`. +1. Use a purpose-built UniDesk route plus operation or helper such as `ssh D601:k3s kubectl ...`, `ssh D601:k3s script`, `ssh D601:k3s:: logs`, `ssh D601:k3s:: script`, `ssh D601:k3s:: apply-patch`, `ssh py`, `ssh apply-patch`, `ssh find`, `ssh glob` or `ssh skills`. 2. If no helper exists, use `ssh argv [args...]` so the CLI quotes each argv token once. -3. If shell features such as pipes, redirects, loops or variable expansion are required, use a single quoted heredoc with `ssh script` or `ssh D601:k3s:script::` so the script body travels over stdin instead of through shell command-string arguments. +3. If shell features such as pipes, redirects, loops or variable expansion are required, use a single quoted heredoc with `ssh script` or `ssh D601:k3s:: script` so the script body travels over stdin instead of through shell command-string arguments. 4. Treat free-form ssh-like command strings as an interactive compatibility path, not as the default automation surface. -For D601 Kubernetes work, route syntax is preferred over positional shell recipes. `D601:k3s` means the native k3s control plane, `D601:k3s:kubectl` means kubectl on that plane, `D601:k3s::[:container]` means exec into a namespaced workload or pod, and `D601:k3s:script::[:container]` means stream a local heredoc/stdin script into that workload. The route fixes `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`, refuses long-follow logs, and assembles common `kubectl exec` / `kubectl logs` / stdin script target arguments without adding a provider-gateway protocol change. This prevents the common failure mode where a command crosses local shell, UniDesk SSH broker, remote shell command strings, `kubectl exec`, and container shell quoting layers before reaching the process that should run it. +For D601 Kubernetes work, route syntax is preferred over positional shell recipes, but the route must stay a pure locator. `D601:k3s` means the native k3s control plane, and `D601:k3s::[:container]` means a namespaced workload or pod. Operations come after the route: `kubectl` runs on the control plane, `logs` reads bounded workload logs, `script` streams a local heredoc/stdin script into the host or target pod, and `apply-patch` applies a standard patch inside the target pod. The route-operation split keeps distributed location and execution behavior independently extensible, fixes `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`, refuses long-follow logs, and assembles common `kubectl exec` / `kubectl logs` / stdin script / pod patch target arguments without adding a provider-gateway protocol change. This prevents the common failure mode where a command crosses local shell, UniDesk SSH broker, remote shell command strings, `kubectl exec`, and container shell quoting layers before reaching the process that should run it. -Longer scripts should move across stdin as files (`ssh py`, `ssh script`, k3s route `script`, or `ssh apply-patch`) or as committed source followed by a short route command. Avoid heredocs nested inside remote command strings, `python - <", description: "Maintenance-only local Compose rebuild for reviewed main-server services; frontend standard release must use CI artifact plus deploy apply dev/prod artifact consumers." }, { command: "provider attach [--master-server URL] [--up] [--force] | provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." }, - { command: "ssh [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `D601:k3s:kubectl` keeps distributed targets structured." }, + { command: "ssh [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `D601:k3s:hwlab-dev:hwlab-cloud-api` only locates distributed targets." }, { command: "ssh apply-patch [tool args...] < patch.diff", description: "Invoke the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." }, { command: "ssh py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." }, { command: "ssh script [--shell sh|bash] [script-args...] <<'SCRIPT' ...", description: "Run a remote shell script from local stdin using shell -s, avoiding shell-command strings and nested remote heredocs." }, { command: "ssh skills [--scope all|wsl|windows] [--limit N]", description: "Discover WSL/Linux and, for WSL providers, Windows skill directories in one SSH passthrough call." }, { command: "ssh find [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." }, { command: "ssh glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." }, - { command: "ssh D601:k3s[:kubectl|:script:namespace:workload|:namespace:workload[:container]] ...", description: "Run D601 native k3s kubectl, direct workload exec, or stdin shell scripts through route syntax with KUBECONFIG fixed and argv assembled by the CLI." }, + { command: "ssh D601:k3s[:namespace:workload[:container]] ...", description: "Locate the D601 native k3s control plane or workload with route syntax, then run a separate operation with KUBECONFIG fixed and argv assembled by the CLI." }, { command: "ssh argv [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `ssh script` when shell features are required." }, { command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." }, { command: "microservice status ", description: "Show one user service config, repository reference, backend mapping, and runtime status." }, @@ -154,15 +154,17 @@ export function sshHelp(): unknown { "bun scripts/cli.ts ssh find [--contains TEXT] [--limit N]", "bun scripts/cli.ts ssh glob [--root DIR] [--pattern PATTERN]", "bun scripts/cli.ts ssh D601:k3s", - "bun scripts/cli.ts ssh D601:k3s:kubectl get pods -n hwlab-dev", + "bun scripts/cli.ts ssh D601:k3s kubectl get pods -n hwlab-dev", + "bun scripts/cli.ts ssh D601:k3s script <<'SCRIPT'", + "bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH'", "bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'", - "bun scripts/cli.ts ssh D601:k3s:script:hwlab-dev:hwlab-cloud-api <<'SCRIPT'", - "bun scripts/cli.ts ssh D601:k3s:logs:hwlab-dev:hwlab-cloud-api --tail 80", + "bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'", + "bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api logs --tail 80", ], notes: [ "ssh --help and ssh --help print this JSON help and never open an interactive session.", "For non-interactive remote commands, prefer argv for a single process and script/stdin for shell logic.", - "Route syntax is provider:plane:entry-or-namespace:resource:container. For D601 native k3s, D601:k3s controls the cluster, D601:k3s:kubectl exposes kubectl, D601:k3s:: defaults to kubectl exec, and D601:k3s:script:: streams local stdin to shell -s in the target pod.", + "Route syntax is provider:plane[:namespace:resource[:container]] and locates a distributed target only. For D601 native k3s, D601:k3s locates the control plane, D601:k3s:: locates a workload, and kubectl/script/logs/apply-patch/exec are operations placed after the route.", "If an ssh-like remote command fails with timeout/kex/exit-255 friction, stderr includes one low-noise UNIDESK_SSH_HINT JSON line with the argv retry command.", "Use -- before a remote command that intentionally starts with a dash.", ], diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index d7df1057..123926ca 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -5,6 +5,8 @@ export interface ParsedSshArgs { remoteCommand: string | null; requiresStdin: boolean; invocationKind: SshInvocationKind; + stdinPrefix?: string; + stdinSuffix?: string; } export type SshInvocationKind = "interactive" | "argv" | "helper" | "ssh-like"; @@ -38,11 +40,12 @@ export interface SshFailureHint { const argvQuotedSshSubcommands = new Set(["rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]); const d601NativeKubeconfig = "/etc/rancher/k3s/k3s.yaml"; -const k3sRouteEntries = new Set([ +const legacyK3sOperationRouteSegments = new Set([ "guard", "kubectl", "exec", "script", + "apply-patch", "logs", "get", "describe", @@ -200,6 +203,266 @@ if __name__ == "__main__": main() `; +const remoteShApplyPatchSource = String.raw`#!/bin/sh +set -eu + +die() { + printf 'apply_patch: %s\n' "$*" >&2 + exit 1 +} + +mk_temp() { + mktemp "${"$"}{TMPDIR:-/tmp}/unidesk-apply-patch.XXXXXX" || exit 1 +} + +ensure_parent() { + case "$1" in + */*) + dir=${"$"}{1%/*} + [ -n "$dir" ] || dir=/ + mkdir -p "$dir" + ;; + esac +} + +read_text_preserve_newlines() { + marker="__UNIDESK_APPLY_PATCH_EOF_$$__" + text=$(cat "$1"; printf '%s' "$marker") || die "failed to read $1" + printf '%s' "${"$"}{text%"$marker"}" +} + +write_file() { + target=$1 + source=$2 + ensure_parent "$target" + cp "$source" "$target" || die "failed to write $target" +} + +replace_once() { + target=$1 + search_file=$2 + replace_file=$3 + [ -e "$target" ] || die "file not found: $target" + + marker="__UNIDESK_APPLY_PATCH_EOF_$$__" + old=$(cat "$target"; printf '%s' "$marker") || die "failed to read $target" + old=${"$"}{old%"$marker"} + search=$(cat "$search_file"; printf '%s' "$marker") || die "failed to read hunk search" + search=${"$"}{search%"$marker"} + replacement=$(cat "$replace_file"; printf '%s' "$marker") || die "failed to read hunk replacement" + replacement=${"$"}{replacement%"$marker"} + + if [ -z "$search" ]; then + new=$replacement$old + else + case "$old" in + *"$search"*) + prefix=${"$"}{old%%"$search"*} + suffix=${"$"}{old#*"$search"} + new=$prefix$replacement$suffix + ;; + *) + die "hunk context not found in $target" + ;; + esac + fi + + out=$(mk_temp) + printf '%s' "$new" > "$out" || die "failed to render patched file" + write_file "$target" "$out" + rm -f "$out" +} + +apply_update() { + target=$1 + body=$2 + in_hunk=0 + search_file= + replace_file= + changed=0 + + finish_hunk() { + [ "$in_hunk" = 1 ] || return 0 + replace_once "$target" "$search_file" "$replace_file" + rm -f "$search_file" "$replace_file" + search_file= + replace_file= + in_hunk=0 + changed=1 + } + + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "*** End of File"*) + continue + ;; + "@@"*) + finish_hunk + search_file=$(mk_temp) + replace_file=$(mk_temp) + in_hunk=1 + continue + ;; + esac + [ "$in_hunk" = 1 ] || die "expected hunk header in $target" + case "$line" in + " "*) + text=${"$"}{line#?} + printf '%s\n' "$text" >> "$search_file" + printf '%s\n' "$text" >> "$replace_file" + ;; + "-"*) + text=${"$"}{line#?} + printf '%s\n' "$text" >> "$search_file" + ;; + "+"*) + text=${"$"}{line#?} + printf '%s\n' "$text" >> "$replace_file" + ;; + "\\ No newline at end of file") + ;; + *) + die "bad hunk line in $target: $line" + ;; + esac + done < "$body" + + finish_hunk + [ "$changed" = 1 ] || [ -e "$target" ] || die "file not found: $target" +} + +is_top_header() { + case "$1" in + "*** Add File: "*|"*** Update File: "*|"*** Delete File: "*|"*** End Patch") + return 0 + ;; + *) + return 1 + ;; + esac +} + +pushed=0 +pushed_line= +next_patch_line() { + if [ "$pushed" = 1 ]; then + line=$pushed_line + pushed=0 + pushed_line= + return 0 + fi + if IFS= read -r line; then + return 0 + fi + [ -n "${"$"}{line:-}" ] +} + +push_patch_line() { + pushed_line=$1 + pushed=1 +} + +parse_add_file() { + target=$1 + [ ! -e "$target" ] || die "file already exists: $target" + tmp=$(mk_temp) + while next_patch_line; do + if is_top_header "$line"; then + push_patch_line "$line" + break + fi + case "$line" in + "+"*) + printf '%s\n' "${"$"}{line#?}" >> "$tmp" + ;; + *) + rm -f "$tmp" + die "add file lines must start with + for $target" + ;; + esac + done + write_file "$target" "$tmp" + rm -f "$tmp" +} + +parse_update_file() { + target=$1 + move_to= + body=$(mk_temp) + if next_patch_line; then + case "$line" in + "*** Move to: "*) + move_to=${"$"}{line#"*** Move to: "} + ;; + *) + push_patch_line "$line" + ;; + esac + fi + while next_patch_line; do + if is_top_header "$line"; then + push_patch_line "$line" + break + fi + case "$line" in + "*** Move to: "*) + rm -f "$body" + die "move marker must appear before update hunks" + ;; + *) + printf '%s\n' "$line" >> "$body" + ;; + esac + done + if [ -s "$body" ]; then + apply_update "$target" "$body" + elif [ -z "$move_to" ] && [ ! -e "$target" ]; then + rm -f "$body" + die "file not found: $target" + fi + rm -f "$body" + if [ -n "$move_to" ]; then + [ -e "$target" ] || die "file not found: $target" + [ ! -e "$move_to" ] || die "target file already exists: $move_to" + ensure_parent "$move_to" + mv "$target" "$move_to" || die "failed to move $target to $move_to" + fi +} + +main() { + if [ "${"$"}{1:-}" = "-h" ] || [ "${"$"}{1:-}" = "--help" ]; then + printf 'apply_patch: sh-only helper; reads *** Begin Patch format from stdin\n' + return 0 + fi + next_patch_line || die "patch must start with *** Begin Patch" + [ "$line" = "*** Begin Patch" ] || die "patch must start with *** Begin Patch" + while next_patch_line; do + case "$line" in + "*** End Patch") + printf 'Done!\n' + return 0 + ;; + "*** Add File: "*) + parse_add_file "${"$"}{line#"*** Add File: "}" + ;; + "*** Delete File: "*) + target=${"$"}{line#"*** Delete File: "} + rm -f "$target" || die "failed to delete $target" + ;; + "*** Update File: "*) + parse_update_file "${"$"}{line#"*** Update File: "}" + ;; + *) + die "unexpected patch line: $line" + ;; + esac + done + die "missing *** End Patch" +} + +main "$@" +`; + const remoteGlobSource = String.raw`#!/usr/bin/env python3 import argparse import glob @@ -552,6 +815,7 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false, invocationKind: "helper" }; } if (subcommand === "k3s") { + if (args[1] === "apply-patch") return buildK3sApplyPatchCommand(args.slice(2)); return { remoteCommand: buildK3sCommand(args.slice(1)), requiresStdin: false, invocationKind: "helper" }; } if (argvQuotedSshSubcommands.has(subcommand)) { @@ -598,19 +862,8 @@ export function parseSshRoute(target: string): ParsedSshRoute { return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, raw: target }; } if (plane !== "k3s") throw new Error(`unsupported ssh route plane: ${plane}`); - const [first, second, third, fourth, ...extra] = rest; - if (first && k3sRouteEntries.has(first)) { - if (extra.length > 0) throw new Error("ssh k3s command route supports at most provider:k3s:entry:namespace:resource:container"); - return { - providerId, - plane: "k3s", - entry: first, - namespace: second && second.length > 0 ? second : null, - resource: third && third.length > 0 ? third : null, - container: fourth && fourth.length > 0 ? fourth : null, - raw: target, - }; - } + const [first, second, third, fourth] = rest; + if (first && legacyK3sOperationRouteSegments.has(first)) throw new Error(k3sOperationInRouteMessage(target, first, second, third)); if (fourth !== undefined) throw new Error("ssh k3s target route supports at most provider:k3s:namespace:resource:container"); return { providerId, @@ -623,6 +876,14 @@ export function parseSshRoute(target: string): ParsedSshRoute { }; } +function k3sOperationInRouteMessage(target: string, operation: string, namespace: string | undefined, resource: string | undefined): string { + if (operation === "kubectl") return `ssh k3s route must locate a target only; use "ssh D601:k3s kubectl ..." instead of "${target}"`; + if (operation === "script" && namespace === undefined) return `ssh k3s route must locate a target only; use "ssh D601:k3s script <<'SCRIPT'" instead of "${target}"`; + if (operation === "guard") return `ssh k3s route must locate a target only; use "ssh D601:k3s guard" or "ssh D601:k3s" instead of "${target}"`; + if (namespace !== undefined && resource !== undefined) return `ssh k3s route must locate a target only; use "ssh D601:k3s:${namespace}:${resource} ${operation} ..." instead of "${target}"`; + return `ssh k3s route must locate a target only; put operation "${operation}" after the route, for example "ssh D601:k3s ${operation} ..."`; +} + function shellArgv(args: string[]): string { return args.map(shellQuote).join(" "); } @@ -729,41 +990,57 @@ function buildFindCommand(args: string[]): string { function parseK3sRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs { if (route.entry === null && route.namespace === null && route.resource === null) { - const k3sArgs = args.length === 0 ? ["guard"] : args; - return { remoteCommand: buildK3sCommand(k3sArgs), requiresStdin: false, invocationKind: "helper" }; - } - if (route.entry === "guard") { - if (args.length > 0) throw new Error("ssh route D601:k3s:guard does not accept extra arguments"); - return { remoteCommand: buildD601K3sGuardCommand(), requiresStdin: false, invocationKind: "helper" }; - } - if (route.entry === "kubectl") { - if (args.length === 0) throw new Error("ssh route D601:k3s:kubectl requires kubectl arguments"); - return { remoteCommand: buildK3sCommand(["kubectl", ...args]), requiresStdin: false, invocationKind: "helper" }; - } - if (route.entry === "logs") { - return { remoteCommand: buildK3sLogsCommand([...k3sRouteTargetArgs(route), ...args]), requiresStdin: false, invocationKind: "helper" }; - } - if (route.entry === "exec") { - return { remoteCommand: buildK3sExecCommand([...k3sRouteTargetArgs(route), ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" }; - } - if (route.entry === "script") { - return { remoteCommand: buildK3sScriptCommand([...k3sRouteTargetArgs(route), ...args]), requiresStdin: true, invocationKind: "helper" }; - } - if (route.entry !== null) { - const k3sArgs = [route.entry, ...(route.namespace === null ? [] : [route.namespace]), ...(route.resource === null ? [] : [route.resource]), ...args]; - return { remoteCommand: buildK3sCommand(k3sArgs), requiresStdin: false, invocationKind: "helper" }; + return parseK3sControlPlaneOperation(args); } if (route.namespace === null || route.resource === null) { throw new Error("ssh k3s target route requires provider:k3s::"); } + return parseK3sTargetOperation(route, args); +} + +function parseK3sControlPlaneOperation(args: string[]): ParsedSshArgs { + const operation = args[0] ?? "guard"; + if (operation === "apply-patch" || operation === "patch") { + throw new Error("ssh D601:k3s apply-patch requires a workload route: ssh D601:k3s:: apply-patch"); + } + if (operation === "script" || operation === "sh") { + return { remoteCommand: buildK3sScriptCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" }; + } + if (operation === "guard") { + if (args.length > 1) throw new Error("ssh D601:k3s guard does not accept extra arguments"); + return { remoteCommand: buildD601K3sGuardCommand(), requiresStdin: false, invocationKind: "helper" }; + } + return { remoteCommand: buildK3sCommand(args), requiresStdin: false, invocationKind: "helper" }; +} + +function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedSshArgs { + const targetArgs = k3sRouteTargetArgs(route); if (args.length === 0) { return { - remoteCommand: shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", "get", "-n", route.namespace, normalizeK3sRouteResource(route.resource), "-o", "wide"]), + remoteCommand: buildK3sTargetObjectCommand("get", route, ["-o", "wide"]), requiresStdin: false, invocationKind: "helper", }; } - return { remoteCommand: buildK3sExecCommand([...k3sRouteTargetArgs(route), ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" }; + const operation = args[0] ?? ""; + const operationArgs = args.slice(1); + if (operation === "apply-patch" || operation === "patch") return buildK3sApplyPatchCommand([...targetArgs, ...operationArgs]); + if (operation === "script") return { remoteCommand: buildK3sScriptCommand([...targetArgs, ...operationArgs]), requiresStdin: true, invocationKind: "helper" }; + if (operation === "logs") return { remoteCommand: buildK3sLogsCommand([...targetArgs, ...operationArgs]), requiresStdin: false, invocationKind: "helper" }; + if (operation === "get" || operation === "describe") { + return { remoteCommand: buildK3sTargetObjectCommand(operation, route, operationArgs), requiresStdin: false, invocationKind: "helper" }; + } + if (operation === "kubectl") throw new Error("ssh k3s kubectl is a control-plane operation; use ssh D601:k3s kubectl ..."); + if (operation === "exec") { + return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(operationArgs)]), requiresStdin: false, invocationKind: "helper" }; + } + return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" }; +} + +function buildK3sTargetObjectCommand(action: "get" | "describe", route: ParsedSshRoute, args: string[]): string { + if (route.namespace === null || route.resource === null) throw new Error(`ssh k3s ${action} target requires namespace and workload route`); + if (args.includes("--follow") || args.includes("-f")) throw new Error(`ssh k3s target ${action} does not support follow mode`); + return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", action, "-n", route.namespace, normalizeK3sRouteResource(route.resource), ...args]); } function k3sRouteTargetArgs(route: ParsedSshRoute): string[] { @@ -853,8 +1130,9 @@ function buildK3sExecCommand(args: string[]): string { function buildK3sScriptCommand(args: string[]): string { const parsed = parseK3sTargetOptions(args, "ssh k3s script", { requireCommand: false, allowCommand: true, allowShell: true }); - if (parsed.namespace === null) throw new Error("ssh k3s script requires --namespace "); - if (parsed.resource === null) throw new Error("ssh k3s script requires --deployment , --pod or --resource "); + if (parsed.namespace === null && parsed.resource === null) return buildK3sHostScriptCommand(parsed); + if (parsed.namespace === null) throw new Error("ssh k3s script target requires --namespace "); + if (parsed.resource === null) throw new Error("ssh k3s script target requires --deployment , --pod or --resource "); if (parsed.tty) throw new Error("ssh k3s script does not support --tty; stdin is reserved for the script body"); const shell = parsed.shell ?? "sh"; const kubectlArgs = [ @@ -873,6 +1151,45 @@ function buildK3sScriptCommand(args: string[]): string { return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]); } +function buildK3sApplyPatchCommand(args: string[]): ParsedSshArgs { + const parsed = parseK3sTargetOptions(args, "ssh k3s apply-patch", { requireCommand: false, allowCommand: true }); + if (parsed.namespace === null) throw new Error("ssh k3s apply-patch requires --namespace "); + if (parsed.resource === null) throw new Error("ssh k3s apply-patch requires --deployment , --pod or --resource "); + if (parsed.tty) throw new Error("ssh k3s apply-patch does not support --tty; stdin is reserved for the patch body"); + if (parsed.stdin) throw new Error("ssh k3s apply-patch does not accept --stdin; stdin is always the patch body"); + if (parsed.shell !== null) throw new Error("ssh k3s apply-patch does not accept --shell"); + if (parsed.kubectlOptions.length > 0) throw new Error("ssh k3s apply-patch does not accept kubectl log options"); + const kubectlArgs = [ + "exec", + "-i", + "-n", parsed.namespace, + parsed.resource, + ...(parsed.container === null ? [] : ["-c", parsed.container]), + "--", + "sh", + "-s", + "--", + ...parsed.command, + ]; + const wrapper = podApplyPatchStdinWrapper(); + return { + remoteCommand: shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]), + requiresStdin: true, + invocationKind: "helper", + stdinPrefix: wrapper.prefix, + stdinSuffix: wrapper.suffix, + }; +} + +function buildK3sHostScriptCommand(parsed: K3sTargetOptions): string { + if (parsed.tty) throw new Error("ssh k3s script does not support --tty; stdin is reserved for the script body"); + if (parsed.stdin) throw new Error("ssh k3s script does not accept --stdin; stdin is always the script body"); + if (parsed.container !== null) throw new Error("ssh k3s script without a workload does not accept --container"); + if (parsed.kubectlOptions.length > 0) throw new Error("ssh k3s script without a workload does not accept kubectl log options"); + const shell = parsed.shell ?? "sh"; + return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, shell, "-s", "--", ...parsed.command]); +} + function buildK3sLogsCommand(args: string[]): string { const parsed = parseK3sTargetOptions(args, "ssh k3s logs", { requireCommand: false }); if (parsed.namespace === null) throw new Error("ssh k3s logs requires --namespace "); @@ -1026,6 +1343,25 @@ function buildShellStdinCommand(args: string[]): string { return shellArgv([shell, "-s", "--", ...scriptArgs]); } +function podApplyPatchStdinWrapper(): { prefix: string; suffix: string } { + const toolMarker = "__UNIDESK_APPLY_PATCH_TOOL__"; + const patchMarker = "__UNIDESK_APPLY_PATCH_PAYLOAD__"; + if (remoteShApplyPatchSource.includes(toolMarker)) throw new Error("remote apply_patch source contains reserved heredoc marker"); + return { + prefix: [ + "set -eu", + 'UNIDESK_SSH_TOOL_DIR="${UNIDESK_SSH_TOOL_DIR:-/tmp/unidesk-ssh-tools}"', + 'mkdir -p "$UNIDESK_SSH_TOOL_DIR"', + `cat > "$UNIDESK_SSH_TOOL_DIR/apply_patch" <<'${toolMarker}'`, + remoteShApplyPatchSource.trimEnd(), + toolMarker, + 'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"', + `"$UNIDESK_SSH_TOOL_DIR/apply_patch" "$@" <<'${patchMarker}'`, + ].join("\n") + "\n", + suffix: `\n${patchMarker}\n`, + }; +} + function buildPythonStdinCommand(args: string[]): string { const pythonArgs = args.map(shellQuote).join(" "); const execArgs = pythonArgs.length > 0 ? ` "$UNIDESK_SSH_PY_FILE" ${pythonArgs}` : ' "$UNIDESK_SSH_PY_FILE"'; @@ -1274,7 +1610,16 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st const rawMode = parsed.remoteCommand === null && process.stdin.isTTY; if (rawMode) process.stdin.setRawMode(true); process.stdin.resume(); - process.stdin.pipe(child.stdin); + if (parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined) { + if (parsed.stdinPrefix) child.stdin.write(parsed.stdinPrefix); + process.stdin.pipe(child.stdin, { end: false }); + process.stdin.once("end", () => { + if (parsed.stdinSuffix) child.stdin.write(parsed.stdinSuffix); + child.stdin.end(); + }); + } else { + process.stdin.pipe(child.stdin); + } let stderrTail = ""; const appendStderrTail = (chunk: Buffer | string): void => { const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk; diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 89d3f909..0e68a2e2 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -8,6 +8,17 @@ function assertCondition(condition: unknown, message: string, detail: unknown = if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); } +function assertThrows(fn: () => unknown, pattern: RegExp, message: string): void { + try { + fn(); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + assertCondition(pattern.test(text), message, { error: text }); + return; + } + throw new Error(`${message}: expected throw`); +} + export function runSshArgvGuidanceContract(): JsonRecord { const argv = parseSshArgs(["argv", "true"]); assertCondition(argv.invocationKind === "argv", "argv subcommand must be classified as argv", argv); @@ -32,18 +43,40 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(k3sExec.invocationKind === "helper", "k3s exec must be classified as helper", k3sExec); assertCondition(k3sExec.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'node' '-e' 'console.log(process.version)'", "k3s exec must assemble kubectl argv without nested shell quoting", k3sExec); - const routeKubectl = parseSshInvocation("D601:k3s:kubectl", ["get", "pods", "-n", "hwlab-dev"]); + const routeKubectl = parseSshInvocation("D601:k3s", ["kubectl", "get", "pods", "-n", "hwlab-dev"]); assertCondition(routeKubectl.providerId === "D601", "route must preserve provider id", routeKubectl); - assertCondition(routeKubectl.route.plane === "k3s" && routeKubectl.route.entry === "kubectl", "route must parse k3s kubectl entry", routeKubectl); - assertCondition(routeKubectl.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'get' 'pods' '-n' 'hwlab-dev'", "D601:k3s:kubectl must map to kubectl argv", routeKubectl); + assertCondition(routeKubectl.route.plane === "k3s" && routeKubectl.route.entry === null, "route must keep kubectl as an operation, not as a route entry", routeKubectl); + assertCondition(routeKubectl.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'get' 'pods' '-n' 'hwlab-dev'", "D601:k3s kubectl must map to kubectl argv", routeKubectl); const routeTarget = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["node", "-e", "console.log(process.version)"]); assertCondition(routeTarget.route.namespace === "hwlab-dev" && routeTarget.route.resource === "hwlab-cloud-api", "route target must parse namespace and workload", routeTarget); assertCondition(routeTarget.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'node' '-e' 'console.log(process.version)'", "D601:k3s:: must default to deployment exec", routeTarget); - const routeScript = parseSshInvocation("D601:k3s:script:hwlab-dev:hwlab-cloud-api", ["--shell", "bash", "--", "arg"]); - assertCondition(routeScript.parsed.requiresStdin === true, "k3s script route must stream local stdin", routeScript); - assertCondition(routeScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'bash' '-s' '--' 'arg'", "D601:k3s:script:: must map stdin to shell -s", routeScript); + const routeScript = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["script", "--shell", "bash", "--", "arg"]); + assertCondition(routeScript.parsed.requiresStdin === true, "k3s script operation must stream local stdin", routeScript); + assertCondition(routeScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'bash' '-s' '--' 'arg'", "D601:k3s:: script must map stdin to shell -s", routeScript); + + const routeControlScript = parseSshInvocation("D601:k3s", ["script", "--shell", "bash", "--", "arg"]); + assertCondition(routeControlScript.parsed.requiresStdin === true, "k3s control-plane script operation must stream local stdin", routeControlScript); + assertCondition(routeControlScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'bash' '-s' '--' 'arg'", "D601:k3s script must inject native kubeconfig without manual export", routeControlScript); + + const routeApplyPatch = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["apply-patch"]); + assertCondition(routeApplyPatch.parsed.requiresStdin === true, "k3s apply-patch operation must stream local patch stdin", routeApplyPatch); + assertCondition(routeApplyPatch.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-s' '--'", "D601:k3s:: apply-patch must enter pod with stdin", routeApplyPatch); + assertCondition(routeApplyPatch.parsed.stdinPrefix?.includes("apply_patch") && routeApplyPatch.parsed.stdinPrefix.includes("__UNIDESK_APPLY_PATCH_PAYLOAD__"), "k3s apply-patch operation must inject pod helper before patch stdin", routeApplyPatch); + assertCondition(!routeApplyPatch.parsed.stdinPrefix?.includes("python3") && !routeApplyPatch.parsed.stdinPrefix?.includes("node "), "k3s apply-patch operation must use the sh-only pod helper", routeApplyPatch); + assertCondition(routeApplyPatch.parsed.stdinSuffix === "\n__UNIDESK_APPLY_PATCH_PAYLOAD__\n", "k3s apply-patch operation must terminate patch heredoc", routeApplyPatch); + + assertThrows( + () => parseSshInvocation("D601:k3s:kubectl", ["get", "pods"]), + /route must locate a target only.*ssh D601:k3s kubectl/u, + "operation names must not be accepted as k3s route segments", + ); + assertThrows( + () => parseSshInvocation("D601:k3s:apply-patch:hwlab-dev:hwlab-cloud-api", []), + /route must locate a target only.*apply-patch/u, + "pod apply-patch must be an operation after the route", + ); const routePodTarget = parseSshInvocation("D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc:api", ["printenv", "HOSTNAME"]); assertCondition(routePodTarget.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'pod/hwlab-cloud-api-abc' '-c' 'api' '--' 'printenv' 'HOSTNAME'", "pod route with container must preserve explicit pod kind", routePodTarget); @@ -62,8 +95,10 @@ export function runSshArgvGuidanceContract(): JsonRecord { const helpText = JSON.stringify(sshHelp()); assertCondition(helpText.includes("ssh script [--shell sh|bash] [script-args...] <<'SCRIPT'"), "ssh help must recommend stdin script passthrough for shell scripts", helpText); - assertCondition(helpText.includes("ssh D601:k3s:kubectl get pods -n hwlab-dev"), "ssh help must document k3s kubectl route", helpText); - assertCondition(helpText.includes("ssh D601:k3s:script:hwlab-dev:hwlab-cloud-api <<'SCRIPT'"), "ssh help must document k3s script route", helpText); + assertCondition(helpText.includes("ssh D601:k3s kubectl get pods -n hwlab-dev"), "ssh help must document k3s kubectl operation", helpText); + assertCondition(helpText.includes("ssh D601:k3s script <<'SCRIPT'"), "ssh help must document k3s control-plane script operation", helpText); + assertCondition(helpText.includes("ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH'"), "ssh help must document k3s pod apply-patch operation", helpText); + assertCondition(helpText.includes("ssh D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'"), "ssh help must document k3s script operation", helpText); assertCondition(helpText.includes("UNIDESK_SSH_HINT"), "ssh help must document structured failure hint", helpText); const crossChecks = providerTriageRecommendedCrossChecks("D601"); @@ -74,7 +109,9 @@ export function runSshArgvGuidanceContract(): JsonRecord { checks: [ "argv form is classified and quoted as the success path for non-interactive commands", "stdin script form removes shell-command strings for host and k3s workload scripts", - "k3s route fixes native kubeconfig and assembles kubectl exec as argv", + "pod apply-patch operation injects helper and forwards patch stdin", + "legacy operation-in-route forms are rejected with canonical route-plus-operation guidance", + "k3s route stays location-only while operations fix native kubeconfig and assemble kubectl exec as argv", "ssh-like timeout/kex failures emit one structured argv retry hint", "help text documents stdin script passthrough and UNIDESK_SSH_HINT", "provider triage recommendedCrossChecks keeps ssh D601 argv true",