feat: add ssh workspace routes
This commit is contained in:
@@ -78,7 +78,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- `bun scripts/cli.ts server cleanup plan [--min-age-hours N] [--limit N]`:只读/干跑生成主 server Docker 镜像清理计划,默认只列出至少 24 小时前创建的非保护镜像,输出 active/protected images、stale candidates、预计释放空间、风险等级和必须人工确认的 `docker image rm` 命令;禁止默认删除、禁止 prune、禁止触碰 database volume、registry storage 或 Baidu Netdisk 状态。
|
||||
- `bun scripts/cli.ts server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>`:以 build-first、Compose lock、no-deps force-recreate 和 post-up validation 的异步 job 重建主 server Compose 内单个服务;对 database、File Browser、Code Queue 执行面、k3sctl-adapter 或未知对象返回结构化 `unsupported-server-rebuild`,规则见 `docs/reference/deployment.md` 与 `docs/reference/cicd-standardization.md`。
|
||||
- `bun scripts/cli.ts provider attach <providerId> [--master-server URL] [--up] [--force]` / `bun scripts/cli.ts provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]`:前者在新增计算节点上生成两项配置的 provider-gateway 挂载包;后者是只读多信号健康裁决入口,默认低噪声输出 `decision`、`healthyScopes`、`failedScopes`、`retryable` 和异常信号摘要,用来把单路径 `provider is not online`、SSH 超时、registry 失败或 proxy 失败归类为 `retryable-transient`、`service-degraded` 或 `global-offline`,完整 evidence 需显式 `--full|--raw`,规则见 `docs/reference/provider-gateway.md` 和 `docs/reference/code-queue-supervision.md`。
|
||||
- `bun scripts/cli.ts ssh <providerId> [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch`、`glob` 与 `skill-discover`;`apply-patch`、`py`、`skills`、结构化 `find`、`glob` 和 `argv` 子命令用于避免远端补丁、Python stdin、skill 发现与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。
|
||||
- `bun scripts/cli.ts ssh <route> [operation args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥进入 provider、host workspace、k3s 控制面或 pod workspace,并在远端 PATH 注入 `apply_patch`、`glob` 与 `skill-discover`;`apply-patch`、`py`、`skills`、结构化 `find`、`glob`、`argv` 和 k3s route operation 用于避免远端补丁、Python stdin、skill 发现与分布式命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。
|
||||
- `bun scripts/cli.ts microservice list/status/health/diagnostics/tunnel-self-test/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 k3s 控制面上的用户服务,`status/health/diagnostics` 默认 compact summary 并用 `--full|--raw` 展开完整 body,`proxy` 支持受控 JSON body,OA Event Flow/Todo Note/Baidu Netdisk/Code Queue Manager on main-server、k3s Control/Code Queue 执行面/MDTODO/Decision Center/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。
|
||||
- `bun scripts/cli.ts microservice health/diagnostics/proxy code-agent-sandbox`:验证独立 Code Agent Sandbox 的 health、只读 diagnostics、trace 和 adapter/mode/credential boundary 契约,规则见 `docs/reference/code-agent-sandbox.md`。
|
||||
- `bun scripts/cli.ts decision upload/list/show/health`:通过 backend-core 用户服务代理上传会议记录/需求/决议 Markdown、列出记录和查看详情;Decision Center 运行在 D601 k3s,规则见 `docs/reference/microservices.md`。
|
||||
|
||||
@@ -177,7 +177,9 @@ 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}[:{scope...}] {operation} [operation-args...]`。第一个 argv token 只负责定位分布式目标,不表达操作;第一个 token 后面的所有 token 才进入 operation 解析器。当前稳定 plane 是 `k3s`:`D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`<provider>:k3s:<namespace>:<workload>[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,workload 段写成 `pod/<podid>`,若目标是 Deployment,也可以显式写 `deployment/<name>` 或简写 `<name>`。`kubectl`、`logs`、`script`、`apply-patch`、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。
|
||||
`ssh` 的 route 语法是 `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`。第一个 argv token 只负责定位分布式目标,不表达操作;第一个 token 后面的所有 token 才进入 operation 解析器。Host workspace route 使用 `<provider>:/absolute/workspace`,例如 `D601:/home/ubuntu/workspace/hwlab-dev`,CLI 会把该路径作为远端 cwd 传给 Host SSH 维护桥,后续 `pwd`、`git`、`script`、`apply-patch` 等操作仍按同一套 operation parser 执行。`<provider>:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。
|
||||
|
||||
当前稳定 plane 是 `k3s`:`D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`<provider>:k3s:<namespace>:<workload>[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,workload 段写成 `pod/<podid>`,若目标是 Deployment,也可以显式写 `deployment/<name>` 或简写 `<name>`。pod 内 workspace 使用 slash 后缀表达,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api/app` 会定位到 deployment `hwlab-cloud-api` 并在 pod 内先 `cd /app`,`D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc/workspace/app:api` 会定位到 pod、container 和 `/workspace/app`。`kubectl`、`logs`、`script`、`apply-patch`、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。
|
||||
|
||||
`k3s` 必须出现在 route 的 plane 段里,禁止使用 `ssh G14 k3s ...` 或 `ssh D601 k3s ...` 这类 post-provider shorthand;正确形态是 `ssh G14:k3s kubectl ...` 或 `ssh D601:k3s kubectl ...`。定位和操作必须保持分离,`kubectl`、`logs`、`script`、`apply-patch`、`exec` 等 operation 名也不得放进任何 colon route 段,包括 namespace、workload 或 container 段;新增分布式目标时按 `{provider}:{plane}:{scope}` 扩展 route,而不是在 operation args 中新增另一套定位语法。
|
||||
|
||||
@@ -186,11 +188,13 @@ bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*-
|
||||
```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:/home/ubuntu/workspace/hwlab-dev git status --short --branch
|
||||
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)'
|
||||
bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app pwd
|
||||
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
|
||||
|
||||
+4
-1
@@ -26,6 +26,7 @@ export function rootHelp(): unknown {
|
||||
{ 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 <providerId>:/absolute/workspace <operation args...>", description: "Route directly into a host workspace while keeping the operation parser independent from the location." },
|
||||
{ command: "ssh <providerId>:k3s[:namespace:workload[:container]] <kubectl|logs|exec|script|apply-patch|command> ...", 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 <providerId> argv <command> [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `ssh <providerId> script` when shell features are required." },
|
||||
{ command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." },
|
||||
@@ -153,11 +154,13 @@ 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:/home/ubuntu/workspace/hwlab-dev git status --short --branch",
|
||||
"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/app pwd",
|
||||
"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:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'",
|
||||
@@ -166,7 +169,7 @@ export function sshHelp(): unknown {
|
||||
notes: [
|
||||
"ssh --help and ssh <route> --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}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. For native k3s providers such as D601 and G14, <provider>:k3s locates the control plane, <provider>:k3s:<namespace>:<workload> locates a workload, and kubectl/script/logs/apply-patch/exec are operations placed after the route.",
|
||||
"Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `<provider>:/absolute/workspace`; native k3s providers such as D601 and G14 use <provider>:k3s for the control plane, <provider>:k3s:<namespace>:<workload> for a workload, and <provider>:k3s:<namespace>:<workload>/<pod-workspace> for a pod workspace.",
|
||||
"Do not put operation names in any colon route segment, including nested k3s namespace/workload/container segments.",
|
||||
"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.",
|
||||
|
||||
+100
-16
@@ -18,6 +18,7 @@ export interface ParsedSshRoute {
|
||||
namespace: string | null;
|
||||
resource: string | null;
|
||||
container: string | null;
|
||||
workspace: string | null;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ export interface SshFailureHint {
|
||||
|
||||
const argvQuotedSshSubcommands = new Set(["rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]);
|
||||
const nativeK3sKubeconfig = "/etc/rancher/k3s/k3s.yaml";
|
||||
const k3sResourceKindAliases = new Set(["pod", "po", "pods", "deployment", "deploy", "deployments", "statefulset", "sts", "daemonset", "ds", "job", "jobs"]);
|
||||
const legacyK3sOperationRouteSegments = new Set([
|
||||
"guard",
|
||||
"kubectl",
|
||||
@@ -859,28 +861,94 @@ export function parseSshInvocation(target: string, args: string[]): ParsedSshInv
|
||||
|
||||
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;
|
||||
const firstColon = target.indexOf(":");
|
||||
if (firstColon < 0) {
|
||||
return hostSshRoute(target, target, null);
|
||||
}
|
||||
const providerId = target.slice(0, firstColon);
|
||||
const tail = target.slice(firstColon + 1);
|
||||
if (!providerId) throw new Error("ssh route requires a provider id before ':'");
|
||||
if (tail.length === 0) {
|
||||
return hostSshRoute(providerId, target, null);
|
||||
}
|
||||
if (tail.startsWith("/")) {
|
||||
return hostSshRoute(providerId, target, tail);
|
||||
}
|
||||
const [plane, ...rest] = tail.split(":");
|
||||
if (plane === undefined || plane.length === 0 || plane === "host") {
|
||||
return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, raw: target };
|
||||
const workspace = rest.length > 0 ? rest.join(":") : null;
|
||||
if (workspace !== null && !workspace.startsWith("/")) throw new Error("ssh host workspace route requires an absolute path after provider:host:");
|
||||
return hostSshRoute(providerId, target, workspace);
|
||||
}
|
||||
if (plane !== "k3s") throw new Error(`unsupported ssh route plane: ${plane}`);
|
||||
const [first, second, third, fourth] = rest;
|
||||
const operationInRoute = [first, second, third].find((segment) => segment !== undefined && legacyK3sOperationRouteSegments.has(segment));
|
||||
const operationInRoute = [first, second, third].map((segment) => segment === undefined ? undefined : routeSegmentHead(segment)).find((segment) => segment !== undefined && legacyK3sOperationRouteSegments.has(segment));
|
||||
if (operationInRoute !== undefined) throw new Error(k3sOperationInRouteMessage(target, operationInRoute));
|
||||
if (fourth !== undefined) throw new Error("ssh k3s target route supports at most provider:k3s:namespace:resource:container");
|
||||
const targetRoute = parseK3sRouteTargetSegments(second ?? null, third ?? null);
|
||||
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,
|
||||
resource: targetRoute.resource,
|
||||
container: targetRoute.container,
|
||||
workspace: targetRoute.workspace,
|
||||
raw: target,
|
||||
};
|
||||
}
|
||||
|
||||
function hostSshRoute(providerId: string, raw: string, workspace: string | null): ParsedSshRoute {
|
||||
return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, workspace, raw };
|
||||
}
|
||||
|
||||
function routeSegmentHead(segment: string): string {
|
||||
return segment.split("/")[0] ?? segment;
|
||||
}
|
||||
|
||||
function parseK3sRouteTargetSegments(rawResource: string | null, rawContainer: string | null): { resource: string | null; container: string | null; workspace: string | null } {
|
||||
const resourceParts = splitK3sResourceWorkspace(rawResource);
|
||||
const containerParts = splitK3sContainerWorkspace(rawContainer);
|
||||
return {
|
||||
resource: resourceParts.resource,
|
||||
container: containerParts.container,
|
||||
workspace: combineK3sRouteWorkspace(resourceParts.workspace, containerParts.workspace),
|
||||
};
|
||||
}
|
||||
|
||||
function splitK3sResourceWorkspace(value: string | null): { resource: string | null; workspace: string | null } {
|
||||
if (value === null || value.length === 0) return { resource: null, workspace: null };
|
||||
const parts = value.split("/");
|
||||
if (parts.length <= 1) return { resource: value, workspace: null };
|
||||
if (isK3sResourceKindAlias(parts[0] ?? "") && (parts[1] ?? "").length > 0) {
|
||||
const workspaceParts = parts.slice(2);
|
||||
return {
|
||||
resource: `${parts[0]}/${parts[1]}`,
|
||||
workspace: workspaceParts.length > 0 ? `/${workspaceParts.join("/")}` : null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
resource: parts[0] ?? value,
|
||||
workspace: parts.length > 1 ? `/${parts.slice(1).join("/")}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function splitK3sContainerWorkspace(value: string | null): { container: string | null; workspace: string | null } {
|
||||
if (value === null || value.length === 0) return { container: null, workspace: null };
|
||||
const parts = value.split("/");
|
||||
if (parts.length <= 1) return { container: value, workspace: null };
|
||||
return { container: parts[0] ?? value, workspace: `/${parts.slice(1).join("/")}` };
|
||||
}
|
||||
|
||||
function combineK3sRouteWorkspace(first: string | null, second: string | null): string | null {
|
||||
if (first !== null && second !== null && first !== second) throw new Error("ssh k3s route workspace can be specified once, either after the workload or after the container");
|
||||
return first ?? second;
|
||||
}
|
||||
|
||||
function isK3sResourceKindAlias(value: string): boolean {
|
||||
return k3sResourceKindAliases.has(value);
|
||||
}
|
||||
|
||||
function k3sOperationInRouteMessage(target: string, operation: string): string {
|
||||
const providerId = target.split(":")[0] || "<provider>";
|
||||
const operationExample = operation === "guard" ? "guard" : `${operation} ...`;
|
||||
@@ -1053,6 +1121,7 @@ function k3sRouteTargetArgs(route: ParsedSshRoute): string[] {
|
||||
"--namespace", route.namespace,
|
||||
"--resource", normalizeK3sRouteResource(route.resource),
|
||||
...(route.container === null ? [] : ["--container", route.container]),
|
||||
...(route.workspace === null ? [] : ["--workdir", route.workspace]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1108,6 +1177,7 @@ interface K3sTargetOptions {
|
||||
namespace: string | null;
|
||||
resource: string | null;
|
||||
container: string | null;
|
||||
workspace: string | null;
|
||||
stdin: boolean;
|
||||
tty: boolean;
|
||||
shell: string | null;
|
||||
@@ -1134,7 +1204,7 @@ function buildK3sExecCommand(args: string[]): string {
|
||||
...(parsed.tty ? ["-t"] : []),
|
||||
...parsed.kubectlOptions,
|
||||
"--",
|
||||
...parsed.command,
|
||||
...withK3sWorkspace(parsed, parsed.command),
|
||||
];
|
||||
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
||||
}
|
||||
@@ -1154,10 +1224,7 @@ function buildK3sScriptCommand(args: string[]): string {
|
||||
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
||||
...parsed.kubectlOptions,
|
||||
"--",
|
||||
shell,
|
||||
"-s",
|
||||
"--",
|
||||
...parsed.command,
|
||||
...withK3sWorkspace(parsed, [shell, "-s", "--", ...parsed.command]),
|
||||
];
|
||||
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
||||
}
|
||||
@@ -1177,10 +1244,7 @@ function buildK3sApplyPatchCommand(args: string[]): ParsedSshArgs {
|
||||
parsed.resource,
|
||||
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
||||
"--",
|
||||
"sh",
|
||||
"-s",
|
||||
"--",
|
||||
...parsed.command,
|
||||
...withK3sWorkspace(parsed, ["sh", "-s", "--", ...parsed.command]),
|
||||
];
|
||||
const wrapper = podApplyPatchStdinWrapper();
|
||||
return {
|
||||
@@ -1196,6 +1260,7 @@ 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.workspace !== null) throw new Error("ssh k3s script without a workload does not accept --workdir");
|
||||
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=${nativeK3sKubeconfig}`, shell, "-s", "--", ...parsed.command]);
|
||||
@@ -1206,6 +1271,7 @@ function buildK3sLogsCommand(args: string[]): string {
|
||||
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");
|
||||
if (parsed.workspace !== null) throw new Error("ssh k3s logs does not accept --workdir");
|
||||
const kubectlArgs = [
|
||||
"logs",
|
||||
"-n", parsed.namespace,
|
||||
@@ -1220,6 +1286,7 @@ function parseK3sTargetOptions(args: string[], commandName: string, options: Par
|
||||
let namespace: string | null = null;
|
||||
let resource: string | null = null;
|
||||
let container: string | null = null;
|
||||
let workspace: string | null = null;
|
||||
let stdin = false;
|
||||
let tty = false;
|
||||
let shell: string | null = null;
|
||||
@@ -1262,6 +1329,11 @@ function parseK3sTargetOptions(args: string[], commandName: string, options: Par
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--workdir" || arg === "--cwd") {
|
||||
workspace = k3sWorkspaceValue(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} ${arg}`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--shell") {
|
||||
if (!options.allowShell) throw new Error(`${commandName} does not support --shell`);
|
||||
shell = k3sScriptShell(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} ${arg}`);
|
||||
@@ -1303,7 +1375,7 @@ function parseK3sTargetOptions(args: string[], commandName: string, options: Par
|
||||
|
||||
if (options.requireCommand && command.length === 0) throw new Error(`${commandName} requires -- <command> [args...]`);
|
||||
if (!options.requireCommand && options.allowCommand !== true && command.length > 0) throw new Error(`${commandName} does not accept a command after --`);
|
||||
return { namespace, resource, container, stdin, tty, shell, command, kubectlOptions };
|
||||
return { namespace, resource, container, workspace, stdin, tty, shell, command, kubectlOptions };
|
||||
}
|
||||
|
||||
function k3sOptionValue(args: string[], index: number, option: string): string {
|
||||
@@ -1312,6 +1384,17 @@ function k3sOptionValue(args: string[], index: number, option: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function k3sWorkspaceValue(value: string, option: string): string {
|
||||
if (!value.startsWith("/")) throw new Error(`${option} must be an absolute pod workspace path`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function withK3sWorkspace(parsed: K3sTargetOptions, command: string[]): string[] {
|
||||
if (parsed.workspace === null) return command;
|
||||
if (command.length === 0) throw new Error("ssh k3s workspace route requires a command to execute");
|
||||
return ["sh", "-c", 'cd "$1" || exit; shift; exec "$@"', "unidesk-cwd", parsed.workspace, ...command];
|
||||
}
|
||||
|
||||
function normalizeK3sResource(value: string): string {
|
||||
if (value.includes("/")) return value;
|
||||
return `pod/${value}`;
|
||||
@@ -1588,6 +1671,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
const payload = {
|
||||
providerId: invocation.providerId,
|
||||
command: wrapSshRemoteCommand(parsed.remoteCommand),
|
||||
cwd: invocation.route.plane === "host" ? invocation.route.workspace ?? undefined : undefined,
|
||||
tty: parsed.remoteCommand === null,
|
||||
stdinEotOnEnd: parsed.remoteCommand !== null,
|
||||
openTimeoutMs,
|
||||
|
||||
@@ -30,6 +30,14 @@ 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 hostWorkspace = parseSshInvocation("D601:/home/ubuntu/workspace/hwlab-dev", ["pwd"]);
|
||||
assertCondition(hostWorkspace.route.plane === "host" && hostWorkspace.route.workspace === "/home/ubuntu/workspace/hwlab-dev", "host workspace route must parse provider:/absolute/path", hostWorkspace);
|
||||
assertCondition(hostWorkspace.parsed.remoteCommand === "'pwd'", "host workspace route must leave operation argv independent from location", hostWorkspace);
|
||||
|
||||
const hostWorkspaceLongForm = parseSshInvocation("D601:host:/home/ubuntu/workspace/hwlab-dev", ["argv", "git", "status", "--short"]);
|
||||
assertCondition(hostWorkspaceLongForm.route.workspace === "/home/ubuntu/workspace/hwlab-dev", "host: workspace route must parse as the same location model", hostWorkspaceLongForm);
|
||||
assertCondition(hostWorkspaceLongForm.parsed.remoteCommand === "'git' 'status' '--short'", "host workspace argv operation must stay argv-quoted", hostWorkspaceLongForm);
|
||||
|
||||
const script = parseSshArgs(["script", "--shell", "bash", "--", "alpha beta"]);
|
||||
assertCondition(script.invocationKind === "helper", "script stdin helper must be classified as helper", script);
|
||||
assertCondition(script.remoteCommand === "'bash' '-s' '--' 'alpha beta'", "script helper must pass stdin to shell directly", script);
|
||||
@@ -101,6 +109,18 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
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);
|
||||
|
||||
const routePodWorkspace = parseSshInvocation("D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc/workspace/app:api", ["pwd"]);
|
||||
assertCondition(routePodWorkspace.route.resource === "pod/hwlab-cloud-api-abc" && routePodWorkspace.route.container === "api" && routePodWorkspace.route.workspace === "/workspace/app", "pod route must support a workspace suffix after the pod id", routePodWorkspace);
|
||||
assertCondition(routePodWorkspace.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'pod/hwlab-cloud-api-abc' '-c' 'api' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/workspace/app' 'pwd'", "pod workspace route must run commands through a fixed cwd wrapper", routePodWorkspace);
|
||||
|
||||
const routeDeploymentWorkspaceScript = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["script"]);
|
||||
assertCondition(routeDeploymentWorkspaceScript.route.resource === "hwlab-cloud-api" && routeDeploymentWorkspaceScript.route.workspace === "/app", "deployment shorthand route must support workspace suffix", routeDeploymentWorkspaceScript);
|
||||
assertCondition(routeDeploymentWorkspaceScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/app' 'sh' '-s' '--'", "pod workspace script must set cwd before shell -s consumes stdin", routeDeploymentWorkspaceScript);
|
||||
|
||||
const routeApplyPatchWorkspace = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["apply-patch"]);
|
||||
assertCondition(routeApplyPatchWorkspace.parsed.requiresStdin === true, "pod workspace apply-patch must still stream patch stdin", routeApplyPatchWorkspace);
|
||||
assertCondition(routeApplyPatchWorkspace.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/app' 'sh' '-s' '--'", "pod workspace apply-patch must set cwd before injecting the sh helper", routeApplyPatchWorkspace);
|
||||
|
||||
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);
|
||||
@@ -115,8 +135,10 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
|
||||
const helpText = JSON.stringify(sshHelp());
|
||||
assertCondition(helpText.includes("ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT'"), "ssh help must recommend stdin script passthrough for shell scripts", helpText);
|
||||
assertCondition(helpText.includes("ssh D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch"), "ssh help must document host workspace routes", 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:hwlab-dev:hwlab-cloud-api/app pwd"), "ssh help must document k3s pod workspace route", 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);
|
||||
|
||||
Reference in New Issue
Block a user