feat: add structured ssh route passthrough
This commit is contained in:
+16
-1
@@ -19,10 +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 <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>` 创建异步 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 <providerId> [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-<ID>.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-<ID>.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage <providerId> [--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 <providerId> host.ssh --wait-ms 15000`、`ssh <providerId> argv true`、`artifact-registry health --provider-id <providerId>`、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。
|
||||
- `ssh <providerId> [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;无后续参数时进入远端登录 shell,有后续参数时按 ssh 远端命令体验执行并返回远端 exit code。非交互远端命令优先使用 `ssh <providerId> argv ...`,需要 shell 特性时用 `bun scripts/cli.ts ssh D601 argv bash -lc '<command>'`;ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 argv 重试和 provider triage 交叉验证。
|
||||
- `ssh <route> [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`。非交互远端命令优先使用 `ssh <providerId> argv ...`,需要 shell 特性时用 `bun scripts/cli.ts ssh D601 argv bash -lc '<command>'`;ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 argv 重试和 provider triage 交叉验证。
|
||||
- `ssh <providerId> apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点。
|
||||
- `ssh <providerId> py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。
|
||||
- `ssh <providerId> 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 之间反复手写多层引号。
|
||||
- `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 <markdown-file>` 将带 `# 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 <YYYY-MM-DD|id> [--source-file path]` 查看单日正文,`--source-file` 用于同一天存在多个导入来源时精确选择;`decision diary edit|upsert <YYYY-MM-DD|id> --body-file <path> [--title text] [--source-file path] [--tag tag]` 通过 `PUT /api/diary/entries/:idOrDate` 创建当天或历史条目并编辑既有条目。
|
||||
@@ -164,6 +165,20 @@ 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:<namespace>:<workload>[:container]` 表示 namespace 下的一个默认 deployment workload,后续 argv 默认通过 `kubectl exec` 进入该对象;如果目标是具体 Pod,workload 段写成 `pod/<podid>`,如果目标是 Deployment,也可以显式写 `deployment/<name>` 或简写 `<name>`。
|
||||
|
||||
该 route 入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制和容器命令组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`。`D601:k3s` 无后续参数时执行 native k3s guard;`D601:k3s:kubectl` 接收原始 kubectl argv;`D601:k3s:logs:<namespace>:<workload>` 读取有界日志;`D601:k3s:exec:<namespace>:<workload>` 和 `D601:k3s:<namespace>:<workload>` 进入目标 workload。典型用法:
|
||||
|
||||
```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:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'
|
||||
bun scripts/cli.ts ssh D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc:api sh -lc 'printf "%s\n" "$HOSTNAME"'
|
||||
```
|
||||
|
||||
route logs 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。route exec 不要求手写 `--`,CLI 会把 route 后续 argv 放到 `kubectl exec --` 后;如果命令本身需要复杂 shell 语法,把它作为容器内 shell 的单个 argv,例如 `sh -lc 'printf "%s\n" "$HOSTNAME"'`。这比把整条 `kubectl exec ... -- sh -lc ...` 放进远端 `bash -lc` 更稳定,因为本地 CLI 只需要处理一层容器内 shell 字符串。
|
||||
|
||||
`ssh <providerId> argv <command> [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `bun scripts/cli.ts ssh D601 argv true`;需要管道、重定向、变量展开或多条命令时使用 `bun scripts/cli.ts ssh D601 argv bash -lc '<command>'`,让 shell 脚本作为 `bash -lc` 的一个 argv token 传递。`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 <providerId>` 执行多行脚本时,优先使用结构化 helper,例如 `bun scripts/cli.ts ssh D601 py < script.py` 或 `printf ... | (bun scripts/cli.ts ssh D601 'bash -s')` 这种单层 stdin 传输。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <<EOF ...'` 形态;多层 shell 解析容易把 stdin 绑定到错误进程,结果会打开远端交互解释器并留下悬挂的 broker/SSH 会话。长脚本需要复用时,优先通过 stdin 写入目标节点的临时脚本,再在同一个远端命令中显式执行并清理。
|
||||
|
||||
@@ -44,6 +44,21 @@ Any manual repair that changes live credentials, env wiring, DNS/egress assumpti
|
||||
|
||||
If a manual repair is needed to unblock the platform, the durable fix must be committed and pushed, then redeployed or revalidated through the normal path. Do not preserve the repair only as hidden runtime state.
|
||||
|
||||
## Distributed Command Passthrough
|
||||
|
||||
Distributed runtime work should prefer structured CLI passthrough over ad-hoc nested shell strings. The standard escalation order is:
|
||||
|
||||
1. Use a purpose-built UniDesk route or helper such as `ssh D601:k3s:kubectl`, `ssh D601:k3s:<namespace>:<workload>`, `ssh <providerId> py`, `ssh <providerId> apply-patch`, `ssh <providerId> find`, `ssh <providerId> glob` or `ssh <providerId> skills`.
|
||||
2. If no helper exists, use `ssh <providerId> argv <command> [args...]` so the CLI quotes each argv token once.
|
||||
3. Use `ssh <providerId> argv bash -lc '<script>'` only when shell features such as pipes, redirects, loops or variable expansion are genuinely required.
|
||||
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, and `D601:k3s:<namespace>:<workload>[:container]` means exec into a namespaced workload or pod. The route fixes `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`, refuses long-follow logs, and assembles common `kubectl exec` / `kubectl logs` 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 `bash -lc`, `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` or `ssh apply-patch`) or as committed source followed by a short remote command. Avoid heredocs nested inside remote command strings, `python - <<EOF` inside SSH strings, or JSON/Markdown bodies passed through shell arguments. These patterns often bind stdin to the wrong process, strip quotes, or leave a half-open provider SSH session that looks like a platform outage.
|
||||
|
||||
When structured passthrough is missing for a recurring workflow, fix the CLI first and then document the durable helper. Do not preserve a growing collection of one-off shell recipes as the long-term runbook.
|
||||
|
||||
## D601 Recovery Hotfix Exception
|
||||
|
||||
D601 reboot recovery has a narrow hotfix exception because k3s, Code Queue and hostPath readiness can fail before normal UniDesk proxy/CD surfaces are healthy. The exception authorizes diagnosis and carefully scoped host repair only; it does not make live host edits a new deployment path.
|
||||
|
||||
+9
-3
@@ -19,12 +19,13 @@ export function rootHelp(): unknown {
|
||||
{ command: "server cleanup plan [--min-age-hours N] [--limit N]", description: "Dry-run Docker image cleanup plan only: list active/protected images, stale candidates older than the default 24h threshold, risk, estimated reclaim, and manual review commands without deleting anything." },
|
||||
{ command: "server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", 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 <providerId> [--master-server URL] [--up] [--force] | provider triage <providerId> [--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 <providerId> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; prefer `ssh <providerId> argv ...` for non-interactive remote commands." },
|
||||
{ command: "ssh <route> [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 <providerId> 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 <providerId> 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 <providerId> 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 <providerId> find <path...> [--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 <providerId> 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|:namespace:workload[:container]] ...", description: "Run D601 native k3s kubectl or direct workload exec through route syntax with KUBECONFIG fixed and argv assembled by the CLI." },
|
||||
{ command: "ssh <providerId> argv <command> [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `argv bash -lc '<command>'` when shell features are required." },
|
||||
{ command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." },
|
||||
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
|
||||
@@ -143,7 +144,7 @@ export function sshHelp(): unknown {
|
||||
output: "json",
|
||||
description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge.",
|
||||
usage: [
|
||||
"bun scripts/cli.ts ssh <providerId>",
|
||||
"bun scripts/cli.ts ssh <route>",
|
||||
"bun scripts/cli.ts ssh <providerId> argv <command> [args...]",
|
||||
"bun scripts/cli.ts ssh D601 argv bash -lc '<command>'",
|
||||
"bun scripts/cli.ts ssh <providerId> apply-patch < patch.diff",
|
||||
@@ -151,10 +152,15 @@ export function sshHelp(): unknown {
|
||||
"bun scripts/cli.ts ssh <providerId> skills [--scope all|wsl|windows] [--limit N]",
|
||||
"bun scripts/cli.ts ssh <providerId> find <path...> [--contains TEXT] [--limit N]",
|
||||
"bun scripts/cli.ts ssh <providerId> 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:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:logs:hwlab-dev:hwlab-cloud-api --tail 80",
|
||||
],
|
||||
notes: [
|
||||
"ssh --help and ssh <providerId> --help print this JSON help and never open an interactive session.",
|
||||
"ssh --help and ssh <route> --help print this JSON help and never open an interactive session.",
|
||||
"For non-interactive remote commands, prefer argv: bun scripts/cli.ts ssh D601 argv bash -lc '<command>'.",
|
||||
"Route syntax is provider:plane:entry-or-namespace:resource:container. For D601 native k3s, D601:k3s controls the cluster, D601:k3s:kubectl exposes kubectl, and D601:k3s:<namespace>:<workload> defaults to kubectl exec.",
|
||||
"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.",
|
||||
],
|
||||
|
||||
+315
-5
@@ -9,6 +9,22 @@ export interface ParsedSshArgs {
|
||||
|
||||
export type SshInvocationKind = "interactive" | "argv" | "helper" | "ssh-like";
|
||||
|
||||
export interface ParsedSshRoute {
|
||||
providerId: string;
|
||||
plane: "host" | "k3s";
|
||||
entry: string | null;
|
||||
namespace: string | null;
|
||||
resource: string | null;
|
||||
container: string | null;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface ParsedSshInvocation {
|
||||
providerId: string;
|
||||
route: ParsedSshRoute;
|
||||
parsed: ParsedSshArgs;
|
||||
}
|
||||
|
||||
export interface SshFailureHint {
|
||||
code: "ssh-like-command-friction";
|
||||
providerId: string;
|
||||
@@ -21,6 +37,24 @@ 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([
|
||||
"guard",
|
||||
"kubectl",
|
||||
"exec",
|
||||
"logs",
|
||||
"get",
|
||||
"describe",
|
||||
"top",
|
||||
"rollout",
|
||||
"wait",
|
||||
"config",
|
||||
"version",
|
||||
"cluster-info",
|
||||
"auth",
|
||||
"api-resources",
|
||||
"api-versions",
|
||||
]);
|
||||
|
||||
const remoteApplyPatchSource = String.raw`#!/usr/bin/env python3
|
||||
import sys
|
||||
@@ -513,6 +547,9 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
if (subcommand === "glob") {
|
||||
return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (subcommand === "k3s") {
|
||||
return { remoteCommand: buildK3sCommand(args.slice(1)), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (argvQuotedSshSubcommands.has(subcommand)) {
|
||||
return { remoteCommand: shellArgv(args), requiresStdin: false, invocationKind: "argv" };
|
||||
}
|
||||
@@ -542,6 +579,46 @@ 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 };
|
||||
}
|
||||
|
||||
export function parseSshRoute(target: string): ParsedSshRoute {
|
||||
if (!target) throw new Error("ssh requires provider id, for example: bun scripts/cli.ts ssh D601");
|
||||
const parts = target.split(":");
|
||||
const [providerId, plane, ...rest] = parts;
|
||||
if (!providerId) throw new Error("ssh route requires a provider id before ':'");
|
||||
if (plane === undefined || plane.length === 0 || plane === "host") {
|
||||
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,
|
||||
};
|
||||
}
|
||||
if (fourth !== undefined) throw new Error("ssh k3s target route supports at most provider:k3s:namespace:resource:container");
|
||||
return {
|
||||
providerId,
|
||||
plane: "k3s",
|
||||
entry: null,
|
||||
namespace: first && first.length > 0 ? first : null,
|
||||
resource: second && second.length > 0 ? second : null,
|
||||
container: third && third.length > 0 ? third : null,
|
||||
raw: target,
|
||||
};
|
||||
}
|
||||
|
||||
function shellArgv(args: string[]): string {
|
||||
return args.map(shellQuote).join(" ");
|
||||
}
|
||||
@@ -556,6 +633,10 @@ function positiveInt(value: string, option: string): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalPositiveInt(value: string, option: string): string {
|
||||
return String(positiveInt(value, option));
|
||||
}
|
||||
|
||||
function findOptionValue(args: string[], index: number, option: string): string {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.length === 0) throw new Error(`ssh find ${option} requires a value`);
|
||||
@@ -642,6 +723,235 @@ function buildFindCommand(args: string[]): string {
|
||||
return command;
|
||||
}
|
||||
|
||||
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 !== null) {
|
||||
const k3sArgs = [route.entry, ...(route.namespace === null ? [] : [route.namespace]), ...(route.resource === null ? [] : [route.resource]), ...args];
|
||||
return { remoteCommand: buildK3sCommand(k3sArgs), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (route.namespace === null || route.resource === null) {
|
||||
throw new Error("ssh k3s target route requires provider:k3s:<namespace>:<deployment|pod/resource>");
|
||||
}
|
||||
if (args.length === 0) {
|
||||
return {
|
||||
remoteCommand: shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", "get", "-n", route.namespace, normalizeK3sRouteResource(route.resource), "-o", "wide"]),
|
||||
requiresStdin: false,
|
||||
invocationKind: "helper",
|
||||
};
|
||||
}
|
||||
return { remoteCommand: buildK3sExecCommand([...k3sRouteTargetArgs(route), ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
|
||||
function k3sRouteTargetArgs(route: ParsedSshRoute): string[] {
|
||||
if (route.namespace === null) throw new Error(`ssh route ${route.raw} requires a namespace segment`);
|
||||
if (route.resource === null) throw new Error(`ssh route ${route.raw} requires a workload or pod segment`);
|
||||
return [
|
||||
"--namespace", route.namespace,
|
||||
"--resource", normalizeK3sRouteResource(route.resource),
|
||||
...(route.container === null ? [] : ["--container", route.container]),
|
||||
];
|
||||
}
|
||||
|
||||
function k3sRouteCommandArgs(args: string[]): string[] {
|
||||
if (args.length === 0) throw new Error("ssh k3s target route requires a command to exec");
|
||||
return args[0] === "--" ? args : ["--", ...args];
|
||||
}
|
||||
|
||||
function buildK3sCommand(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 === "exec") return buildK3sExecCommand(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=${d601NativeKubeconfig}`, "kubectl", ...args]);
|
||||
}
|
||||
|
||||
function buildD601K3sGuardCommand(): string {
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
`export KUBECONFIG=${shellQuote(d601NativeKubeconfig)}`,
|
||||
"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 '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; }",
|
||||
"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'",
|
||||
].join("; ");
|
||||
return shellArgv(["bash", "-lc", script]);
|
||||
}
|
||||
|
||||
interface K3sTargetOptions {
|
||||
namespace: string | null;
|
||||
resource: string | null;
|
||||
container: string | null;
|
||||
stdin: boolean;
|
||||
tty: boolean;
|
||||
command: string[];
|
||||
kubectlOptions: string[];
|
||||
}
|
||||
|
||||
function buildK3sExecCommand(args: string[]): string {
|
||||
const parsed = parseK3sTargetOptions(args, "ssh k3s exec", { requireCommand: true });
|
||||
if (parsed.namespace === null) throw new Error("ssh k3s exec requires --namespace <name>");
|
||||
if (parsed.resource === null) throw new Error("ssh k3s exec requires --deployment <name>, --pod <name> or --resource <type/name>");
|
||||
const kubectlArgs = [
|
||||
"exec",
|
||||
"-n", parsed.namespace,
|
||||
parsed.resource,
|
||||
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
||||
...(parsed.stdin ? ["-i"] : []),
|
||||
...(parsed.tty ? ["-t"] : []),
|
||||
...parsed.kubectlOptions,
|
||||
"--",
|
||||
...parsed.command,
|
||||
];
|
||||
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]);
|
||||
}
|
||||
|
||||
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 <name>");
|
||||
if (parsed.resource === null) throw new Error("ssh k3s logs requires --deployment <name>, --pod <name> or --resource <type/name>");
|
||||
if (parsed.stdin || parsed.tty) throw new Error("ssh k3s logs does not support --stdin or --tty");
|
||||
const kubectlArgs = [
|
||||
"logs",
|
||||
"-n", parsed.namespace,
|
||||
parsed.resource,
|
||||
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
||||
...parsed.kubectlOptions,
|
||||
];
|
||||
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]);
|
||||
}
|
||||
|
||||
function parseK3sTargetOptions(args: string[], commandName: string, options: { requireCommand: boolean }): K3sTargetOptions {
|
||||
let namespace: string | null = null;
|
||||
let resource: string | null = null;
|
||||
let container: string | null = null;
|
||||
let stdin = false;
|
||||
let tty = false;
|
||||
const kubectlOptions: string[] = [];
|
||||
const command: string[] = [];
|
||||
let afterDoubleDash = false;
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (afterDoubleDash) {
|
||||
command.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--") {
|
||||
afterDoubleDash = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--namespace" || arg === "-n") {
|
||||
namespace = k3sOptionValue(args, index, `${commandName} ${arg}`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--deployment" || arg === "--deploy") {
|
||||
resource = `deployment/${k3sOptionValue(args, index, `${commandName} ${arg}`)}`;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--pod") {
|
||||
resource = `pod/${k3sOptionValue(args, index, `${commandName} ${arg}`)}`;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--resource") {
|
||||
resource = normalizeK3sResource(k3sOptionValue(args, index, `${commandName} ${arg}`));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--container" || arg === "-c") {
|
||||
container = k3sOptionValue(args, index, `${commandName} ${arg}`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--stdin" || arg === "-i") {
|
||||
stdin = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tty" || arg === "-t") {
|
||||
tty = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tail") {
|
||||
kubectlOptions.push("--tail", optionalPositiveInt(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} --tail`));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--since" || arg === "--since-time" || arg === "--limit-bytes") {
|
||||
kubectlOptions.push(arg, k3sOptionValue(args, index, `${commandName} ${arg}`));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--previous" || arg === "--timestamps" || arg === "--all-containers" || arg === "--prefix") {
|
||||
kubectlOptions.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--follow" || arg === "-f") {
|
||||
throw new Error(`${commandName} does not support ${arg}; use a bounded logs command and poll again`);
|
||||
}
|
||||
if (arg.startsWith("-")) throw new Error(`unsupported ${commandName} option: ${arg}`);
|
||||
if (resource === null) {
|
||||
resource = normalizeK3sResource(arg);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unexpected ${commandName} argument before --: ${arg}`);
|
||||
}
|
||||
|
||||
if (options.requireCommand && command.length === 0) throw new Error(`${commandName} requires -- <command> [args...]`);
|
||||
if (!options.requireCommand && command.length > 0) throw new Error(`${commandName} does not accept a command after --`);
|
||||
return { namespace, resource, container, stdin, tty, command, kubectlOptions };
|
||||
}
|
||||
|
||||
function k3sOptionValue(args: string[], index: number, option: string): string {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.length === 0) throw new Error(`${option} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeK3sResource(value: string): string {
|
||||
if (value.includes("/")) return value;
|
||||
return `pod/${value}`;
|
||||
}
|
||||
|
||||
function normalizeK3sRouteResource(value: string): string {
|
||||
if (value.startsWith("deploy/")) return `deployment/${value.slice("deploy/".length)}`;
|
||||
if (value.startsWith("po/")) return `pod/${value.slice("po/".length)}`;
|
||||
if (value.includes("/")) return value;
|
||||
return `deployment/${value}`;
|
||||
}
|
||||
|
||||
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"';
|
||||
@@ -850,12 +1160,12 @@ function terminalSize(): { cols: number; rows: number } {
|
||||
}
|
||||
|
||||
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
|
||||
if (!providerId) throw new Error("ssh requires provider id, for example: bun scripts/cli.ts ssh D518");
|
||||
const parsed = parseSshArgs(args);
|
||||
const invocation = parseSshInvocation(providerId, args);
|
||||
const parsed = invocation.parsed;
|
||||
const size = terminalSize();
|
||||
const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000));
|
||||
const payload = {
|
||||
providerId,
|
||||
providerId: invocation.providerId,
|
||||
command: wrapSshRemoteCommand(parsed.remoteCommand),
|
||||
tty: parsed.remoteCommand === null,
|
||||
stdinEotOnEnd: parsed.remoteCommand !== null,
|
||||
@@ -912,14 +1222,14 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
const message = `unidesk ssh failed to start broker: ${error.message}\n`;
|
||||
appendStderrTail(message);
|
||||
process.stderr.write(message);
|
||||
const hint = sshFailureHint(providerId, parsed, 255, stderrTail);
|
||||
const hint = sshFailureHint(invocation.providerId, parsed, 255, stderrTail);
|
||||
if (hint !== null) process.stderr.write(formatSshFailureHint(hint));
|
||||
resolve(255);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
restore();
|
||||
const exitCode = code ?? 255;
|
||||
const hint = sshFailureHint(providerId, parsed, exitCode, stderrTail);
|
||||
const hint = sshFailureHint(invocation.providerId, parsed, exitCode, stderrTail);
|
||||
if (hint !== null) process.stderr.write(formatSshFailureHint(hint));
|
||||
resolve(exitCode);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sshHelp } from "./src/help";
|
||||
import { providerTriageRecommendedCrossChecks } from "./src/provider-triage";
|
||||
import { formatSshFailureHint, parseSshArgs, sshFailureHint } from "./src/ssh";
|
||||
import { formatSshFailureHint, parseSshArgs, parseSshInvocation, sshFailureHint } from "./src/ssh";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -19,6 +19,26 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
assertCondition(shortcut.invocationKind === "argv", "safe command shortcuts must use argv quoting", shortcut);
|
||||
assertCondition(shortcut.remoteCommand === "'pwd'", "safe command shortcut should be shell-quoted", shortcut);
|
||||
|
||||
const k3sGuard = parseSshArgs(["k3s", "guard"]);
|
||||
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)"]);
|
||||
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"]);
|
||||
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);
|
||||
|
||||
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:<namespace>:<workload> must default to deployment exec", routeTarget);
|
||||
|
||||
const routePodTarget = parseSshInvocation("D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc:api", ["--", "sh", "-lc", "echo ok"]);
|
||||
assertCondition(routePodTarget.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'pod/hwlab-cloud-api-abc' '-c' 'api' '--' 'sh' '-lc' 'echo ok'", "pod route with container must preserve explicit pod kind", routePodTarget);
|
||||
|
||||
const sshLike = parseSshArgs(["echo hello"]);
|
||||
const hint = sshFailureHint("D601", sshLike, 255, "kex_exchange_identification: Connection closed by remote host");
|
||||
assertCondition(hint !== null, "ssh-like kex failure must produce a hint", sshLike);
|
||||
@@ -33,6 +53,7 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
|
||||
const helpText = JSON.stringify(sshHelp());
|
||||
assertCondition(helpText.includes("ssh D601 argv bash -lc '<command>'"), "ssh help must recommend argv bash -lc for non-interactive commands", helpText);
|
||||
assertCondition(helpText.includes("ssh D601:k3s:kubectl get pods -n hwlab-dev"), "ssh help must document k3s kubectl route", helpText);
|
||||
assertCondition(helpText.includes("UNIDESK_SSH_HINT"), "ssh help must document structured failure hint", helpText);
|
||||
|
||||
const crossChecks = providerTriageRecommendedCrossChecks("D601");
|
||||
@@ -42,6 +63,7 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
ok: true,
|
||||
checks: [
|
||||
"argv form is classified and quoted as the success path for non-interactive commands",
|
||||
"k3s route fixes native kubeconfig and assembles kubectl exec as argv",
|
||||
"ssh-like timeout/kex failures emit one structured argv retry hint",
|
||||
"help text documents argv bash -lc and UNIDESK_SSH_HINT",
|
||||
"provider triage recommendedCrossChecks keeps ssh D601 argv true",
|
||||
|
||||
Reference in New Issue
Block a user