diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 38ae0d7b..1a562ee5 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 [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 [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `G14:k3s`、`D601:k3s` 或 `G14:k3s::`。非交互远端命令优先使用 `ssh argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `bun scripts/cli.ts ssh G14 script <<'SCRIPT'`、`bun scripts/cli.ts ssh G14:k3s script <<'SCRIPT'` 或 `bun scripts/cli.ts ssh G14:k3s:: script <<'SCRIPT'`,把脚本走 stdin,而不是把脚本压成多层引号字符串。需要在 pod 内改文件时优先使用 `: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[: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 之间反复手写多层引号。 +- `ssh :k3s[:namespace:workload[:container]] ...` 是原生 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 之间反复手写多层引号;D601 与 G14 都有 provider-specific guard,分别校验 `d601` 和 G14 k3s 节点身份。 - `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,13 +177,17 @@ 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[: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,这样路由子模块和操作子模块可以独立扩展。 +`ssh` 的 route 语法是 `provider:plane[:namespace:resource[:container]]`,只负责定位分布式目标,不表达操作。当前稳定 plane 是 `k3s`:`D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,workload 段写成 `pod/`,若目标是 Deployment,也可以显式写 `deployment/` 或简写 ``。`kubectl`、`logs`、`script`、`apply-patch`、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。 -该入口解决运行面调试中最常见的多层 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`。典型用法: +`k3s` 必须出现在 route 的 plane 段里,禁止使用 `ssh G14 k3s ...` 或 `ssh D601 k3s ...` 这类 post-provider shorthand;正确形态是 `ssh G14:k3s kubectl ...` 或 `ssh D601:k3s kubectl ...`。定位和操作必须保持分离,新增分布式目标时也按 `provider:plane[:scope...]` 扩展 route,而不是在 operation args 中新增另一套定位语法。 + +该入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制、容器命令、stdin script 和 pod apply-patch 组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`。`:k3s` 无后续参数时执行 native k3s guard;`:k3s kubectl ...` 接收原始 kubectl argv;`:k3s script` 执行带 native kubeconfig 的 host stdin 脚本;`:k3s:: logs` 读取有界日志;`:k3s:: exec ...` 和 `:k3s:: ...` 进入目标 workload;`:k3s:: script` 把本地 stdin 作为 pod 内 shell 脚本执行;`: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 G14:k3s +bun scripts/cli.ts ssh G14:k3s kubectl get pipelineruns -n hwlab-ci 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)' @@ -202,7 +206,7 @@ PATCH `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:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <` 执行多行脚本时,优先使用结构化 helper,例如 `bun scripts/cli.ts ssh G14 py < script.py`、`bun scripts/cli.ts ssh G14 script <<'SCRIPT'` 或 `bun scripts/cli.ts ssh G14:k3s script <<'SCRIPT'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <", 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 [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 [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `G14:k3s` 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[: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 :k3s[:namespace:workload[:container]] ...", description: "Locate a 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." }, @@ -155,6 +155,8 @@ export function sshHelp(): unknown { "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 G14:k3s", + "bun scripts/cli.ts ssh G14:k3s kubectl get pipelineruns -n hwlab-ci", "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)'", @@ -164,7 +166,8 @@ export function sshHelp(): unknown { 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[: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.", + "Route syntax is provider:plane[:namespace:resource[:container]] and locates a distributed target only. For native k3s providers such as D601 and G14, :k3s locates the control plane, :k3s:: locates a workload, and kubectl/script/logs/apply-patch/exec are operations placed after the route.", + "Do not use post-provider shorthand such as `ssh G14 k3s ...`; write `ssh G14:k3s ...` so location and operation stay separated.", "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 123926ca..e9dd154e 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -39,7 +39,7 @@ 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 nativeK3sKubeconfig = "/etc/rancher/k3s/k3s.yaml"; const legacyK3sOperationRouteSegments = new Set([ "guard", "kubectl", @@ -815,8 +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" }; + throw new Error("ssh k3s shorthand is unsupported; put k3s in the route, for example: ssh D601:k3s kubectl get nodes"); } if (argvQuotedSshSubcommands.has(subcommand)) { return { remoteCommand: shellArgv(args), requiresStdin: false, invocationKind: "argv" }; @@ -849,8 +848,13 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { export function parseSshInvocation(target: string, args: string[]): ParsedSshInvocation { const route = parseSshRoute(target); - const parsed = route.plane === "k3s" ? parseK3sRouteArgs(route, args) : parseSshArgs(args); - return { providerId: route.providerId, route, parsed }; + if (route.plane === "k3s") { + return { providerId: route.providerId, route, parsed: parseK3sRouteArgs(route, args) }; + } + if ((args[0] ?? "") === "k3s") { + throw new Error(`ssh k3s shorthand is unsupported; use route syntax instead: ssh ${route.providerId}:k3s ${args.slice(1).join(" ")}`.trim()); + } + return { providerId: route.providerId, route, parsed: parseSshArgs(args) }; } export function parseSshRoute(target: string): ParsedSshRoute { @@ -877,11 +881,12 @@ 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} ..."`; + const providerId = target.split(":")[0] || ""; + if (operation === "kubectl") return `ssh k3s route must locate a target only; use "ssh ${providerId}:k3s kubectl ..." instead of "${target}"`; + if (operation === "script" && namespace === undefined) return `ssh k3s route must locate a target only; use "ssh ${providerId}:k3s script <<'SCRIPT'" instead of "${target}"`; + if (operation === "guard") return `ssh k3s route must locate a target only; use "ssh ${providerId}:k3s guard" or "ssh ${providerId}:k3s" instead of "${target}"`; + if (namespace !== undefined && resource !== undefined) return `ssh k3s route must locate a target only; use "ssh ${providerId}: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 ${providerId}:k3s ${operation} ..."`; } function shellArgv(args: string[]): string { @@ -990,7 +995,7 @@ function buildFindCommand(args: string[]): string { function parseK3sRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs { if (route.entry === null && route.namespace === null && route.resource === null) { - return parseK3sControlPlaneOperation(args); + return parseK3sControlPlaneOperation(route, args); } if (route.namespace === null || route.resource === null) { throw new Error("ssh k3s target route requires provider:k3s::"); @@ -998,19 +1003,19 @@ function parseK3sRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs return parseK3sTargetOperation(route, args); } -function parseK3sControlPlaneOperation(args: string[]): ParsedSshArgs { +function parseK3sControlPlaneOperation(route: ParsedSshRoute, 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"); + throw new Error(`ssh ${route.providerId}:k3s apply-patch requires a workload route: ssh ${route.providerId}: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" }; + if (args.length > 1) throw new Error(`ssh ${route.providerId}:k3s guard does not accept extra arguments`); + return { remoteCommand: buildK3sGuardCommand(route.providerId), requiresStdin: false, invocationKind: "helper" }; } - return { remoteCommand: buildK3sCommand(args), requiresStdin: false, invocationKind: "helper" }; + return { remoteCommand: buildK3sCommand(route.providerId, args), requiresStdin: false, invocationKind: "helper" }; } function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedSshArgs { @@ -1030,7 +1035,7 @@ function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedS 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 === "kubectl") throw new Error(`ssh k3s kubectl is a control-plane operation; use ssh ${route.providerId}:k3s kubectl ...`); if (operation === "exec") { return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(operationArgs)]), requiresStdin: false, invocationKind: "helper" }; } @@ -1040,7 +1045,7 @@ function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedS 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]); + return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", action, "-n", route.namespace, normalizeK3sRouteResource(route.resource), ...args]); } function k3sRouteTargetArgs(route: ParsedSshRoute): string[] { @@ -1058,37 +1063,45 @@ function k3sRouteCommandArgs(args: string[]): string[] { return args[0] === "--" ? args : ["--", ...args]; } -function buildK3sCommand(args: string[]): string { +function buildK3sCommand(providerId: string, args: string[]): string { const action = args[0] ?? ""; if (action.length === 0 || action === "--help" || action === "-h" || action === "help") { throw new Error("ssh k3s requires a subcommand: guard, kubectl, get, describe, logs or exec"); } - if (action === "guard") return buildD601K3sGuardCommand(); + if (action === "guard") return buildK3sGuardCommand(providerId); if (action === "exec") return buildK3sExecCommand(args.slice(1)); if (action === "script") return buildK3sScriptCommand(args.slice(1)); if (action === "logs") return buildK3sLogsCommand(args.slice(1)); if (action === "kubectl") { const kubectlArgs = args.slice(1); if (kubectlArgs.length === 0) throw new Error("ssh k3s kubectl requires kubectl arguments"); - return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]); + return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]); } - return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...args]); + return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...args]); } -function buildD601K3sGuardCommand(): string { +function buildK3sGuardCommand(providerId: string): string { + const provider = providerId.toUpperCase(); + const providerNodeCheck = provider === "D601" + ? "printf '%s\\n' \"$nodes\" | grep -Fx d601 >/dev/null || { printf 'native_k3s_guard=blocked provider=%s reason=d601-node-missing\\n' \"$UNIDESK_K3S_PROVIDER_ID\" >&2; exit 1; }" + : provider === "G14" + ? "printf '%s\\n' \"$nodes\" | grep -Fx ubuntu-rog-zephyrus-g14-ga401iv-ga401iv >/dev/null || { printf 'native_k3s_guard=blocked provider=%s reason=g14-node-missing\\n' \"$UNIDESK_K3S_PROVIDER_ID\" >&2; exit 1; }" + : "[ -n \"$nodes\" ] || { printf 'native_k3s_guard=blocked provider=%s reason=node-list-empty\\n' \"$UNIDESK_K3S_PROVIDER_ID\" >&2; exit 1; }"; const script = [ "set -euo pipefail", - `export KUBECONFIG=${shellQuote(d601NativeKubeconfig)}`, + `export KUBECONFIG=${shellQuote(nativeK3sKubeconfig)}`, + `export UNIDESK_K3S_PROVIDER_ID=${shellQuote(providerId)}`, "context=$(kubectl config current-context)", "server=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')", "nodes=$(kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{\"\\n\"}{end}')", "printf 'kubeconfig=%s\\n' \"$KUBECONFIG\"", + "printf 'provider=%s\\n' \"$UNIDESK_K3S_PROVIDER_ID\"", "printf 'context=%s\\n' \"$context\"", "printf 'server=%s\\n' \"$server\"", "printf 'nodes=%s\\n' \"$(printf '%s' \"$nodes\" | tr '\\n' ' ')\"", - "printf '%s\\n' \"$nodes\" | grep -Fx d601 >/dev/null || { printf 'native_k3s_guard=blocked reason=d601-node-missing\\n' >&2; exit 1; }", + providerNodeCheck, "case \"$server\" in *127.0.0.1:11700*|*docker-desktop*) printf 'native_k3s_guard=blocked reason=docker-desktop-context server=%s\\n' \"$server\" >&2; exit 1;; esac", - "printf 'native_k3s_guard=ok\\n'", + "printf 'native_k3s_guard=ok provider=%s\\n' \"$UNIDESK_K3S_PROVIDER_ID\"", ].join("; "); return shellArgv(["bash", "-c", script]); } @@ -1125,7 +1138,7 @@ function buildK3sExecCommand(args: string[]): string { "--", ...parsed.command, ]; - return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]); + return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]); } function buildK3sScriptCommand(args: string[]): string { @@ -1148,7 +1161,7 @@ function buildK3sScriptCommand(args: string[]): string { "--", ...parsed.command, ]; - return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]); + return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]); } function buildK3sApplyPatchCommand(args: string[]): ParsedSshArgs { @@ -1173,7 +1186,7 @@ function buildK3sApplyPatchCommand(args: string[]): ParsedSshArgs { ]; const wrapper = podApplyPatchStdinWrapper(); return { - remoteCommand: shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]), + remoteCommand: shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]), requiresStdin: true, invocationKind: "helper", stdinPrefix: wrapper.prefix, @@ -1187,7 +1200,7 @@ function buildK3sHostScriptCommand(parsed: K3sTargetOptions): string { 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]); + return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, shell, "-s", "--", ...parsed.command]); } function buildK3sLogsCommand(args: string[]): string { @@ -1202,7 +1215,7 @@ function buildK3sLogsCommand(args: string[]): string { ...(parsed.container === null ? [] : ["-c", parsed.container]), ...parsed.kubectlOptions, ]; - return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]); + return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]); } function parseK3sTargetOptions(args: string[], commandName: string, options: ParseK3sTargetOptionsOptions): K3sTargetOptions { diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 0e68a2e2..833e63ab 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -35,11 +35,11 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(script.remoteCommand === "'bash' '-s' '--' 'alpha beta'", "script helper must pass stdin to shell directly", script); assertCondition(script.requiresStdin === true, "script helper must require stdin", script); - const k3sGuard = parseSshArgs(["k3s", "guard"]); + const k3sGuard = parseSshInvocation("D601:k3s", ["guard"]).parsed; assertCondition(k3sGuard.invocationKind === "helper", "k3s guard must be classified as helper", k3sGuard); assertCondition(k3sGuard.remoteCommand?.includes("KUBECONFIG") && k3sGuard.remoteCommand.includes("/etc/rancher/k3s/k3s.yaml"), "k3s guard must force native k3s kubeconfig", k3sGuard); - const k3sExec = parseSshArgs(["k3s", "exec", "--namespace", "hwlab-dev", "--deployment", "hwlab-cloud-api", "--", "node", "-e", "console.log(process.version)"]); + const k3sExec = parseSshInvocation("D601:k3s", ["exec", "--namespace", "hwlab-dev", "--deployment", "hwlab-cloud-api", "--", "node", "-e", "console.log(process.version)"]).parsed; 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); @@ -48,6 +48,16 @@ export function runSshArgvGuidanceContract(): JsonRecord { 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 g14Guard = parseSshInvocation("G14:k3s", []); + assertCondition(g14Guard.providerId === "G14" && g14Guard.route.plane === "k3s", "G14:k3s must parse as a native k3s route", g14Guard); + assertCondition(g14Guard.parsed.remoteCommand?.includes("UNIDESK_K3S_PROVIDER_ID") && g14Guard.parsed.remoteCommand.includes("ubuntu-rog-zephyrus-g14-ga401iv-ga401iv"), "G14:k3s guard must use the G14 node profile", g14Guard); + + assertThrows( + () => parseSshInvocation("G14", ["k3s", "kubectl", "get", "nodes"]), + /unsupported.*ssh G14:k3s/u, + "k3s must be a route plane, not a post-provider shorthand", + ); + 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); @@ -96,6 +106,7 @@ 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 operation", helpText); + assertCondition(helpText.includes("ssh G14:k3s kubectl get pipelineruns -n hwlab-ci"), "ssh help must document G14 k3s route 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); @@ -111,6 +122,7 @@ export function runSshArgvGuidanceContract(): JsonRecord { "stdin script form removes shell-command strings for host and k3s workload scripts", "pod apply-patch operation injects helper and forwards patch stdin", "legacy operation-in-route forms are rejected with canonical route-plus-operation guidance", + "post-provider k3s shorthand is rejected so location and operation stay separated", "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",