diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml new file mode 100644 index 00000000..b1cb181d --- /dev/null +++ b/config/platform-infra/webterm.yaml @@ -0,0 +1,64 @@ +version: 1 +kind: platform-infra-webterm + +metadata: + id: webterm-public + owner: unidesk + +defaults: + targetId: local-7683 + +targets: + - id: local-7683 + enabled: true + runtime: + mode: host-docker + workDir: /root/webterm + composePath: /root/webterm/build/docker-compose.7683.yaml + composeProject: webterm-7683 + serviceName: web-terminal + containerName: web-terminal-7683 + image: webterm-web-terminal-7682:latest + envFile: /root/webterm/.env + hostPort: 7683 + containerPort: 7681 + privileged: true + pid: host + sourceMounts: + - source: /root/webterm/src + target: /app/src + readOnly: true + - source: /root/webterm/public + target: /app/public + readOnly: true + - source: /root/webterm/scripts/host-shell.sh + target: /usr/local/bin/host-shell + readOnly: true + environment: + HOST: 0.0.0.0 + PORT: "7681" + SERVER_PROVIDER_ID: remote-server + SERVER_PROVIDER_NAME: Remote Server 7683 + TERMINAL_SHELL: /usr/local/bin/host-shell + TERMINAL_CWD: /root + TERMINAL_REMOTE_CWD: /root/unidesk + TERMINAL_START_COMMAND: mycx + TERMINAL_HISTORY_BYTES: "2097152" + TERMINAL_SNAPSHOT_BYTES: "524288" + BROWSER_KEEPALIVE_INTERVAL_MS: "30000" + BROWSER_STALE_TIMEOUT_MS: "180000" + publicExposure: + enabled: true + publicBaseUrl: https://term.pikapython.com + dns: + hostname: term.pikapython.com + expectedA: 82.156.23.220 + resolvers: [1.1.1.1, 8.8.8.8, 223.5.5.5, 114.114.114.114] + upstream: + host: 152.53.229.148 + port: 7683 + pk01: + route: PK01 + caddyConfigPath: /etc/caddy/Caddyfile + caddyServiceName: caddy + responseHeaderTimeoutSeconds: 600 diff --git a/config/unidesk-host-k8s.yaml b/config/unidesk-host-k8s.yaml index b3f73d7d..fe08aaa7 100644 --- a/config/unidesk-host-k8s.yaml +++ b/config/unidesk-host-k8s.yaml @@ -74,7 +74,7 @@ runtimeConfig: databasePoolMax: "4" sessionTtlSeconds: "28800" authUsername: admin - sshClientRouteAllowlist: NC01,NC01:*,JD01,JD01:*,PK01,PK01:* + sshClientRouteAllowlist: "*" microservicesJson: "[]" runtimeSecrets: diff --git a/scripts/src/help.ts b/scripts/src/help.ts index ed6ba1d7..1f7407ac 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -70,7 +70,7 @@ export function rootHelp(): unknown { { command: "hwlab nodes control-plane|git-mirror|hwpod-preinstall|secret|test-accounts --node --lane ", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared k3s infra/tools-image/Argo bootstrap, HWPOD preinstall configRefs, redacted test-account preparation, and G14 v0.3+ runtime lanes, with the node identity passed as data." }, { command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." }, { command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; session follow-up uses send only and the server decides internal steer vs turn." }, - { command: "platform-infra sub2api|langbot|n8n|wechat-archive ...", description: "Deploy platform-infra services such as Sub2API, LangBot and n8n, manage YAML-controlled public FRP/Caddy exposure and WeChat archive workflows, and inspect status/logs without printing secrets." }, + { command: "platform-infra sub2api|langbot|n8n|webterm|wechat-archive ...", description: "Deploy platform-infra services such as Sub2API, LangBot, n8n and Web Terminal, manage YAML-controlled public Caddy/FRP exposure and WeChat archive workflows, and inspect status/logs without printing secrets." }, { command: "secrets plan|sync|status", description: "Plan, push and inspect YAML-declared local secret source keys to Kubernetes Secrets without printing or reverse-engineering values." }, { command: "platform-db postgres plan|status|export-secrets|apply", description: "Manage YAML-declared host-native PostgreSQL 16 on PK01 for Sub2API/platform state, with PostgreSQL native TLS and redacted secret exports." }, { command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Legacy D601 HWLAB DEV CD wrapper kept for explicit old-path diagnostics; current HWLAB rollout uses G14 GitOps." }, @@ -663,7 +663,7 @@ function agentRunHelpSummary(): unknown { function platformInfraHelpSummary(): unknown { return { - command: "platform-infra sub2api|egress-proxy|langbot|n8n|wechat-archive|gitea ...", + command: "platform-infra sub2api|egress-proxy|langbot|n8n|webterm|wechat-archive|gitea ...", output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2api plan", @@ -685,6 +685,9 @@ function platformInfraHelpSummary(): unknown { "bun scripts/cli.ts platform-infra n8n apply --confirm", "bun scripts/cli.ts platform-infra n8n status", "bun scripts/cli.ts platform-infra n8n validate --full", + "bun scripts/cli.ts platform-infra webterm apply --confirm", + "bun scripts/cli.ts platform-infra webterm status", + "bun scripts/cli.ts platform-infra webterm validate", "bun scripts/cli.ts platform-infra wechat-archive plan", "bun scripts/cli.ts platform-infra wechat-archive apply --confirm", "bun scripts/cli.ts platform-infra wechat-archive validate --full", @@ -699,7 +702,7 @@ function platformInfraHelpSummary(): unknown { "bun scripts/cli.ts platform-infra gitea mirror sync --target JD01 --confirm", "bun scripts/cli.ts platform-infra gitea mirror status --target JD01", ], - description: "Operate platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, WeChat archive workflows, the YAML-controlled Codex pool, and internal Gitea for the GH-1548/GH-1549 CI/CD migration.", + description: "Operate platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, Web Terminal, WeChat archive workflows, the YAML-controlled Codex pool, and internal Gitea for the GH-1548/GH-1549 CI/CD migration.", }; } diff --git a/scripts/src/platform-infra-webterm.ts b/scripts/src/platform-infra-webterm.ts new file mode 100644 index 00000000..495e366e --- /dev/null +++ b/scripts/src/platform-infra-webterm.ts @@ -0,0 +1,628 @@ +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import type { UniDeskConfig } from "./config"; +import { rootPath } from "./config"; +import type { RenderedCliResult } from "./output"; +import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy"; +import { + asRecord, + booleanField, + integerField, + parseOpsApplyOptions, + parseOpsCommonOptions, + readYamlRecord, + recordField, + stringField, +} from "./platform-infra-ops-library"; + +const configPath = rootPath("config", "platform-infra", "webterm.yaml"); +const serviceName = "webterm"; + +interface WebtermConfig { + defaults: { targetId: string }; + targets: WebtermTarget[]; +} + +interface WebtermTarget { + id: string; + enabled: boolean; + runtime: { + mode: "host-docker"; + workDir: string; + composePath: string; + composeProject: string; + serviceName: string; + containerName: string; + image: string; + envFile: string; + hostPort: number; + containerPort: number; + privileged: boolean; + pid: "host" | "container"; + sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>; + environment: Record; + }; + publicExposure: { + enabled: boolean; + publicBaseUrl: string; + dns: { hostname: string; expectedA: string; resolvers: string[] }; + upstream: { host: string; port: number }; + pk01: { + route: string; + caddyConfigPath: string; + caddyServiceName: string; + responseHeaderTimeoutSeconds: number; + }; + }; +} + +export async function runWebtermCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { + const [action = "plan", ...rest] = args; + if (action === "plan") { + const result = plan(parseCommon(rest).targetId); + return wantsFull(rest) ? result : renderPlan(result); + } + if (action === "apply") { + const options = parseOpsApplyOptions(rest); + const result = await apply(config, options.targetId, options.confirm); + return options.full || options.raw ? result : renderApply(result); + } + if (action === "status") { + const options = parseCommon(rest); + const result = status(options.targetId); + return options.full || options.raw ? result : renderStatus(result); + } + if (action === "validate") { + const options = parseCommon(rest); + const result = validate(options.targetId); + return options.full || options.raw ? result : renderValidate(result); + } + return { + ok: false, + error: "unsupported-webterm-command", + args, + help: webtermHelp(), + }; +} + +export function webtermHelp(): Record { + return { + command: "platform-infra webterm plan|apply|status|validate", + examples: [ + "bun scripts/cli.ts platform-infra webterm plan", + "bun scripts/cli.ts platform-infra webterm apply --dry-run", + "bun scripts/cli.ts platform-infra webterm apply --confirm", + "bun scripts/cli.ts platform-infra webterm status", + "bun scripts/cli.ts platform-infra webterm validate", + ], + configPath, + description: "Deploy the host-Docker Web Terminal from YAML and expose it through PK01 Caddy HTTPS without touching the existing 7682 service.", + }; +} + +function plan(targetId: string | null): Record { + const target = resolveTarget(targetId); + const compose = renderCompose(target); + return { + ok: true, + service: serviceName, + configPath, + target: targetSummary(target), + compose: { + path: target.runtime.composePath, + project: target.runtime.composeProject, + service: target.runtime.serviceName, + container: target.runtime.containerName, + fingerprint: fingerprint(compose), + hostPort: target.runtime.hostPort, + containerPort: target.runtime.containerPort, + }, + publicExposure: exposureSummary(target), + policy: policyChecks(target, compose), + next: { + dryRun: "bun scripts/cli.ts platform-infra webterm apply --dry-run", + apply: "bun scripts/cli.ts platform-infra webterm apply --confirm", + status: "bun scripts/cli.ts platform-infra webterm status", + validate: "bun scripts/cli.ts platform-infra webterm validate", + }, + valuesPrinted: false, + }; +} + +async function apply(config: UniDeskConfig, targetId: string | null, confirm: boolean): Promise> { + const target = resolveTarget(targetId); + const compose = renderCompose(target); + const policy = policyChecks(target, compose); + const failedPolicy = policy.filter((item) => item.ok === false); + if (!confirm) { + return { + ok: failedPolicy.length === 0, + mode: "dry-run", + target: targetSummary(target), + compose: { path: target.runtime.composePath, fingerprint: fingerprint(compose), preview: compose }, + publicExposure: exposureSummary(target), + policy, + valuesPrinted: false, + }; + } + if (failedPolicy.length > 0) { + return { + ok: false, + mode: "policy-blocked", + target: targetSummary(target), + policy, + valuesPrinted: false, + }; + } + + mkdirSync(dirname(target.runtime.composePath), { recursive: true }); + writeFileSync(target.runtime.composePath, compose, { encoding: "utf8", mode: 0o644 }); + const composeResult = spawnSync("docker", ["compose", "-f", target.runtime.composePath, "-p", target.runtime.composeProject, "up", "-d", "--force-recreate"], { + cwd: target.runtime.workDir, + encoding: "utf8", + }); + const localProbe = localHttpProbeWithRetry(target.runtime.hostPort, "/login", 10, 500); + const caddy = target.publicExposure.enabled + ? await applyPk01CaddySiteBlock( + config, + serviceName, + caddyExposure(target), + renderWebtermCaddySiteBlock({ + hostname: target.publicExposure.dns.hostname, + upstream: `${target.publicExposure.upstream.host}:${target.publicExposure.upstream.port}`, + responseHeaderTimeoutSeconds: target.publicExposure.pk01.responseHeaderTimeoutSeconds, + }), + caddyManagedBlockMarkers(serviceName), + ) + : { ok: true, action: "not-enabled" }; + const publicProbe = target.publicExposure.enabled ? publicHttpProbeWithRetry(target.publicExposure.publicBaseUrl, "/login", 6, 1000) : { ok: true, skipped: true }; + return { + ok: composeResult.status === 0 && localProbe.ok === true && caddy.ok !== false && publicProbe.ok === true, + mode: "confirmed", + target: targetSummary(target), + compose: { + path: target.runtime.composePath, + project: target.runtime.composeProject, + fingerprint: fingerprint(compose), + up: commandSummary(composeResult), + }, + localProbe, + pk01Caddy: caddy, + publicProbe, + valuesPrinted: false, + }; +} + +function status(targetId: string | null): Record { + const target = resolveTarget(targetId); + const inspect = spawnSync("docker", ["inspect", target.runtime.containerName], { encoding: "utf8" }); + const ps = spawnSync("docker", ["ps", "--filter", `name=^/${target.runtime.containerName}$`, "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"], { encoding: "utf8" }); + const localProbe = localHttpProbe(target.runtime.hostPort, "/login"); + const publicProbe = target.publicExposure.enabled ? publicHttpProbe(target.publicExposure.publicBaseUrl, "/login") : { ok: true, skipped: true }; + return { + ok: ps.status === 0 && ps.stdout.trim().length > 0 && localProbe.ok === true && publicProbe.ok === true, + target: targetSummary(target), + compose: { + path: target.runtime.composePath, + exists: existsSync(target.runtime.composePath), + fingerprint: existsSync(target.runtime.composePath) ? fingerprint(readFileSync(target.runtime.composePath, "utf8")) : null, + }, + container: { + exists: inspect.status === 0, + ps: ps.stdout.trim(), + }, + localProbe, + publicProbe, + valuesPrinted: false, + }; +} + +function validate(targetId: string | null): Record { + const target = resolveTarget(targetId); + const dns = dnsProbe(target.publicExposure.dns.hostname); + const localProbe = localHttpProbe(target.runtime.hostPort, "/login"); + const publicProbe = target.publicExposure.enabled ? publicHttpProbe(target.publicExposure.publicBaseUrl, "/login") : { ok: true, skipped: true }; + return { + ok: localProbe.ok === true && publicProbe.ok === true && dns.addresses.includes(target.publicExposure.dns.expectedA), + target: targetSummary(target), + dns, + localProbe, + publicProbe, + valuesPrinted: false, + }; +} + +function readWebtermConfig(): WebtermConfig { + const yaml = readYamlRecord(configPath, "platform-infra-webterm"); + const defaults = recordField(yaml, "defaults", "webterm"); + const targetsRaw = yaml.targets; + if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array"); + return { + defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") }, + targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`)), + }; +} + +function parseTarget(record: Record, path: string): WebtermTarget { + const runtime = recordField(record, "runtime", path); + const exposure = recordField(record, "publicExposure", path); + const dns = recordField(exposure, "dns", `${path}.publicExposure`); + const upstream = recordField(exposure, "upstream", `${path}.publicExposure`); + const pk01 = recordField(exposure, "pk01", `${path}.publicExposure`); + const environment = parseEnvironment(recordField(runtime, "environment", `${path}.runtime`), `${path}.runtime.environment`); + const sourceMounts = parseSourceMounts(runtime.sourceMounts, `${path}.runtime.sourceMounts`); + const target: WebtermTarget = { + id: stringField(record, "id", path), + enabled: booleanField(record, "enabled", path), + runtime: { + mode: parseLiteral(stringField(runtime, "mode", `${path}.runtime`), "host-docker", `${path}.runtime.mode`), + workDir: stringField(runtime, "workDir", `${path}.runtime`), + composePath: stringField(runtime, "composePath", `${path}.runtime`), + composeProject: stringField(runtime, "composeProject", `${path}.runtime`), + serviceName: stringField(runtime, "serviceName", `${path}.runtime`), + containerName: stringField(runtime, "containerName", `${path}.runtime`), + image: stringField(runtime, "image", `${path}.runtime`), + envFile: stringField(runtime, "envFile", `${path}.runtime`), + hostPort: portField(runtime, "hostPort", `${path}.runtime`), + containerPort: portField(runtime, "containerPort", `${path}.runtime`), + privileged: booleanField(runtime, "privileged", `${path}.runtime`), + pid: parsePid(stringField(runtime, "pid", `${path}.runtime`), `${path}.runtime.pid`), + sourceMounts, + environment, + }, + publicExposure: { + enabled: booleanField(exposure, "enabled", `${path}.publicExposure`), + publicBaseUrl: stringField(exposure, "publicBaseUrl", `${path}.publicExposure`), + dns: { + hostname: stringField(dns, "hostname", `${path}.publicExposure.dns`), + expectedA: stringField(dns, "expectedA", `${path}.publicExposure.dns`), + resolvers: stringArray(dns.resolvers, `${path}.publicExposure.dns.resolvers`), + }, + upstream: { + host: stringField(upstream, "host", `${path}.publicExposure.upstream`), + port: portField(upstream, "port", `${path}.publicExposure.upstream`), + }, + pk01: { + route: stringField(pk01, "route", `${path}.publicExposure.pk01`), + caddyConfigPath: stringField(pk01, "caddyConfigPath", `${path}.publicExposure.pk01`), + caddyServiceName: stringField(pk01, "caddyServiceName", `${path}.publicExposure.pk01`), + responseHeaderTimeoutSeconds: integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.publicExposure.pk01`), + }, + }, + }; + if (target.runtime.hostPort === 7682) throw new Error(`${path}.runtime.hostPort must not reuse existing 7682`); + if (target.runtime.containerName === "web-terminal-7682") throw new Error(`${path}.runtime.containerName must not reuse web-terminal-7682`); + if (!target.publicExposure.publicBaseUrl.startsWith("https://")) throw new Error(`${path}.publicExposure.publicBaseUrl must be https`); + return target; +} + +function resolveTarget(targetId: string | null): WebtermTarget { + const config = readWebtermConfig(); + const id = targetId ?? config.defaults.targetId; + const target = config.targets.find((item) => item.id === id); + if (target === undefined) throw new Error(`unknown webterm target: ${id}`); + return target; +} + +function renderCompose(target: WebtermTarget): string { + const envLines = Object.entries(target.runtime.environment) + .map(([key, value]) => ` ${key}: ${quoteYaml(value)}`) + .join("\n"); + const volumeLines = target.runtime.sourceMounts + .map((mount) => ` - ${quoteYaml(`${mount.source}:${mount.target}${mount.readOnly ? ":ro" : ""}`)}`) + .join("\n"); + return `name: ${target.runtime.composeProject} + +services: + ${target.runtime.serviceName}: + image: ${target.runtime.image} + container_name: ${target.runtime.containerName} + restart: unless-stopped + ports: + - "${target.runtime.hostPort}:${target.runtime.containerPort}" + privileged: ${target.runtime.privileged ? "true" : "false"} + pid: ${target.runtime.pid} + env_file: + - ${target.runtime.envFile} + volumes: +${volumeLines} + environment: +${envLines} +`; +} + +function policyChecks(target: WebtermTarget, compose: string): Array & { ok: boolean }> { + return [ + { name: "enabled", ok: target.enabled, detail: "target must be enabled" }, + { name: "no-7682-host-port", ok: target.runtime.hostPort !== 7682, detail: "existing 7682 service must not be reused" }, + { name: "no-7682-container", ok: target.runtime.containerName !== "web-terminal-7682", detail: "existing 7682 container must not be reused" }, + { name: "compose-path-7683", ok: target.runtime.composePath.includes("7683"), detail: target.runtime.composePath }, + { name: "public-https", ok: target.publicExposure.publicBaseUrl.startsWith("https://"), detail: target.publicExposure.publicBaseUrl }, + { name: "compose-renders-7683", ok: compose.includes(`"${target.runtime.hostPort}:${target.runtime.containerPort}"`), detail: `${target.runtime.hostPort}:${target.runtime.containerPort}` }, + { name: "source-mounts-present", ok: target.runtime.sourceMounts.length >= 3, detail: `mounts=${target.runtime.sourceMounts.length}` }, + ]; +} + +function caddyExposure(target: WebtermTarget): Parameters[2] { + return { + enabled: target.publicExposure.enabled, + dns: { hostname: target.publicExposure.dns.hostname }, + frpc: { remotePort: target.publicExposure.upstream.port }, + pk01: target.publicExposure.pk01, + }; +} + +function renderWebtermCaddySiteBlock(params: { + hostname: string; + upstream: string; + responseHeaderTimeoutSeconds: number; +}): string { + return `${params.hostname} { + reverse_proxy ${params.upstream} { + flush_interval -1 + stream_close_delay 5m + transport http { + response_header_timeout ${params.responseHeaderTimeoutSeconds}s + } + } +}`; +} + +function targetSummary(target: WebtermTarget): Record { + return { + id: target.id, + enabled: target.enabled, + mode: target.runtime.mode, + workDir: target.runtime.workDir, + composeProject: target.runtime.composeProject, + containerName: target.runtime.containerName, + image: target.runtime.image, + sourceMounts: target.runtime.sourceMounts.map((mount) => `${mount.source}->${mount.target}${mount.readOnly ? ":ro" : ""}`), + hostPort: target.runtime.hostPort, + containerPort: target.runtime.containerPort, + }; +} + +function exposureSummary(target: WebtermTarget): Record { + return { + enabled: target.publicExposure.enabled, + publicBaseUrl: target.publicExposure.publicBaseUrl, + hostname: target.publicExposure.dns.hostname, + expectedA: target.publicExposure.dns.expectedA, + upstream: `${target.publicExposure.upstream.host}:${target.publicExposure.upstream.port}`, + pk01Route: target.publicExposure.pk01.route, + marker: caddyManagedBlockMarkers(serviceName).start, + }; +} + +function localHttpProbe(port: number, path: string): Record { + return httpProbe(`http://127.0.0.1:${port}`, path); +} + +function publicHttpProbe(baseUrl: string, path: string): Record { + return httpProbe(baseUrl, path); +} + +function localHttpProbeWithRetry(port: number, path: string, attempts: number, delayMs: number): Record { + return httpProbeWithRetry(() => localHttpProbe(port, path), attempts, delayMs); +} + +function publicHttpProbeWithRetry(baseUrl: string, path: string, attempts: number, delayMs: number): Record { + return httpProbeWithRetry(() => publicHttpProbe(baseUrl, path), attempts, delayMs); +} + +function httpProbeWithRetry(run: () => Record, attempts: number, delayMs: number): Record { + let latest = run(); + for (let attempt = 1; attempt < attempts && latest.ok !== true; attempt += 1) { + spawnSync("sleep", [(delayMs / 1000).toFixed(3)]); + latest = run(); + } + return { ...latest, attempts }; +} + +function httpProbe(baseUrl: string, path: string): Record { + const url = `${baseUrl.replace(/\/+$/u, "")}${path}`; + const result = spawnSync("curl", ["-fsS", "-L", "--connect-timeout", "5", "--max-time", "15", "-o", "/dev/null", "-w", "%{http_code}", url], { encoding: "utf8" }); + const status = Number(result.stdout.trim()); + return { + ok: result.status === 0 && Number.isInteger(status) && status >= 200 && status < 500, + url, + status: Number.isInteger(status) ? status : null, + exitCode: result.status, + stderrTail: result.status === 0 ? "" : result.stderr.slice(-1000), + }; +} + +function dnsProbe(hostname: string): Record & { addresses: string[] } { + const result = spawnSync("getent", ["hosts", hostname], { encoding: "utf8" }); + const addresses = result.stdout + .split(/\r?\n/u) + .map((line) => line.trim().split(/\s+/u)[0] ?? "") + .filter((value) => /^\d+\.\d+\.\d+\.\d+$/u.test(value)); + return { ok: result.status === 0 && addresses.length > 0, hostname, addresses, exitCode: result.status }; +} + +function commandSummary(result: SpawnSyncReturns): Record { + return { + exitCode: result.status, + stdoutTail: result.status === 0 ? result.stdout.slice(-2000) : result.stdout.slice(-4000), + stderrTail: result.status === 0 ? "" : result.stderr.slice(-4000), + }; +} + +function parseCommon(args: string[]): ReturnType { + return parseOpsCommonOptions(args); +} + +function wantsFull(args: string[]): boolean { + return args.includes("--full") || args.includes("--raw"); +} + +function parseEnvironment(record: Record, path: string): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (!/^[A-Z_][A-Z0-9_]*$/u.test(key)) throw new Error(`${path}.${key} must be an env key`); + if (typeof value !== "string") throw new Error(`${path}.${key} must be a string`); + result[key] = value; + } + return result; +} + +function parseSourceMounts(value: unknown, path: string): WebtermTarget["runtime"]["sourceMounts"] { + if (!Array.isArray(value)) throw new Error(`${path} must be an array`); + return value.map((item, index) => { + const record = asRecord(item, `${path}[${index}]`); + return { + source: stringField(record, "source", `${path}[${index}]`), + target: stringField(record, "target", `${path}[${index}]`), + readOnly: booleanField(record, "readOnly", `${path}[${index}]`), + }; + }); +} + +function parseLiteral(value: string, expected: T, path: string): T { + if (value !== expected) throw new Error(`${path} must be ${expected}`); + return expected; +} + +function parsePid(value: string, path: string): "host" | "container" { + if (value !== "host" && value !== "container") throw new Error(`${path} must be host or container`); + return value; +} + +function portField(record: Record, key: string, path: string): number { + const value = integerField(record, key, path); + if (value < 1 || value > 65535) throw new Error(`${path}.${key} must be a TCP port`); + return value; +} + +function stringArray(value: unknown, path: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of strings`); + return value as string[]; +} + +function quoteYaml(value: string): string { + return JSON.stringify(value); +} + +function fingerprint(value: string): string { + const result = spawnSync("sha256sum", { input: value, encoding: "utf8" }); + return `sha256:${(result.stdout.split(/\s+/u)[0] ?? "").trim()}`; +} + +function renderPlan(result: Record): RenderedCliResult { + const target = rec(result.target); + const exposure = rec(result.publicExposure); + const failed = arr(result.policy).filter((item) => item.ok === false); + const lines = [ + "PLATFORM-INFRA WEBTERM PLAN", + ...table(["FIELD", "VALUE", "DETAIL", "VALUE"], [ + ["TARGET", str(target.id), "mode", str(target.mode)], + ["PORT", str(target.hostPort), "container", str(target.containerName)], + ["IMAGE", str(target.image), "compose", str(rec(result.compose).path)], + ["PUBLIC", bool(exposure.enabled), "url", str(exposure.publicBaseUrl)], + ["UPSTREAM", str(exposure.upstream), "pk01", str(exposure.pk01Route)], + ["POLICY", failed.length === 0 ? "ok" : `failed=${failed.length}`, "valuesPrinted", "false"], + ]), + "", + "NEXT", + " dry-run: bun scripts/cli.ts platform-infra webterm apply --dry-run", + " apply: bun scripts/cli.ts platform-infra webterm apply --confirm", + " status: bun scripts/cli.ts platform-infra webterm status", + ]; + return rendered(result, "platform-infra webterm plan", lines); +} + +function renderApply(result: Record): RenderedCliResult { + const target = rec(result.target); + const compose = rec(result.compose); + const localProbe = rec(result.localProbe); + const publicProbe = rec(result.publicProbe); + const caddy = rec(result.pk01Caddy); + const lines = [ + "PLATFORM-INFRA WEBTERM APPLY", + ...table(["TARGET", "PORT", "MODE", "STATUS"], [[str(target.id), str(target.hostPort), str(result.mode), result.ok === false ? "failed" : "ok"]]), + "", + "COMPOSE", + ...table(["PATH", "PROJECT", "UP"], [[str(compose.path), str(target.composeProject), str(rec(compose.up).exitCode, "-")]]), + "", + "CHECKS", + ...table(["CHECK", "OK", "DETAIL"], [ + ["local", bool(localProbe.ok), str(localProbe.url, "-")], + ["pk01Caddy", bool(caddy.ok), str(caddy.action, str(caddy.hostname, "-"))], + ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], + ]), + "", + "NEXT", + " status: bun scripts/cli.ts platform-infra webterm status", + " validate: bun scripts/cli.ts platform-infra webterm validate", + ]; + return rendered(result, "platform-infra webterm apply", lines); +} + +function renderStatus(result: Record): RenderedCliResult { + const target = rec(result.target); + const container = rec(result.container); + const localProbe = rec(result.localProbe); + const publicProbe = rec(result.publicProbe); + const lines = [ + "PLATFORM-INFRA WEBTERM STATUS", + ...table(["TARGET", "PORT", "CONTAINER", "STATUS"], [[str(target.id), str(target.hostPort), str(target.containerName), result.ok === false ? "failed" : "ok"]]), + "", + "CONTAINER", + str(container.ps, "-"), + "", + "CHECKS", + ...table(["CHECK", "OK", "DETAIL"], [ + ["local", bool(localProbe.ok), str(localProbe.url, "-")], + ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], + ]), + ]; + return rendered(result, "platform-infra webterm status", lines); +} + +function renderValidate(result: Record): RenderedCliResult { + const dns = rec(result.dns); + const localProbe = rec(result.localProbe); + const publicProbe = rec(result.publicProbe); + const lines = [ + "PLATFORM-INFRA WEBTERM VALIDATE", + ...table(["CHECK", "OK", "DETAIL"], [ + ["dns", bool(dns.ok), values(dns.addresses).map(String).join(",") || "-"], + ["local", bool(localProbe.ok), str(localProbe.url, "-")], + ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], + ]), + ]; + return rendered(result, "platform-infra webterm validate", lines); +} + +function rendered(result: Record, command: string, lines: string[]): RenderedCliResult { + return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" }; +} + +function rec(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function arr(value: unknown): Record[] { + return Array.isArray(value) ? value.map(rec) : []; +} + +function values(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function str(value: unknown, fallback = "-"): string { + return value === undefined || value === null || value === "" ? fallback : String(value); +} + +function bool(value: unknown): string { + return value === true ? "yes" : value === false ? "no" : "-"; +} + +function table(headers: string[], rows: string[][]): string[] { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0))); + const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd(); + return [renderRow(headers), ...rows.map(renderRow)]; +} diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index 2e2a5918..c201e0f6 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -369,7 +369,7 @@ export interface ManagedResourceCleanupPlan { export function platformInfraHelp(): unknown { const target = sub2ApiHelpTargetSummary(); return { - command: "platform-infra sub2api|langbot|n8n|wechat-archive|observability|secret-plane|kafka|gitea|pipelines-as-code ...", + command: "platform-infra sub2api|langbot|n8n|webterm|wechat-archive|observability|secret-plane|kafka|gitea|pipelines-as-code ...", output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]", @@ -396,6 +396,10 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra n8n status [--target G14] [--full|--raw]", "bun scripts/cli.ts platform-infra n8n logs [--target G14] [--component app|frpc|all]", "bun scripts/cli.ts platform-infra n8n validate [--target G14] [--full|--raw]", + "bun scripts/cli.ts platform-infra webterm plan", + "bun scripts/cli.ts platform-infra webterm apply --confirm", + "bun scripts/cli.ts platform-infra webterm status", + "bun scripts/cli.ts platform-infra webterm validate", "bun scripts/cli.ts platform-infra wechat-archive plan [--target G14]", "bun scripts/cli.ts platform-infra wechat-archive apply [--target G14] --confirm", "bun scripts/cli.ts platform-infra wechat-archive status [--target G14] [--full|--raw]", @@ -443,7 +447,7 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer ", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer --limit 10", ], - description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the D518 Kafka event bus, internal Gitea source authority, and Gitea + Pipelines-as-Code closeout for JD01 migrated consumers. Public services use PK01 Caddy+FRP rather than Kubernetes Ingress, NodePort, or LoadBalancer.", + description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, Web Terminal, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the D518 Kafka event bus, internal Gitea source authority, and Gitea + Pipelines-as-Code closeout for JD01 migrated consumers. Public services use PK01 Caddy/FRP or YAML-declared PK01 Caddy upstreams rather than Kubernetes Ingress, NodePort, or LoadBalancer.", target, codexPool: { usage: [ diff --git a/scripts/src/platform-infra/index.ts b/scripts/src/platform-infra/index.ts index 6371b18e..a5473f8b 100644 --- a/scripts/src/platform-infra/index.ts +++ b/scripts/src/platform-infra/index.ts @@ -11,3 +11,4 @@ export * from "./apply-script"; export * from "./status-script"; export * from "./validate-script"; export * from "./utils"; +export * from "../platform-infra-webterm"; diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index d380de56..b9abf0b9 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -49,6 +49,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin const { runN8nCommand } = await import("../platform-infra-n8n"); return await runN8nCommand(config, args.slice(1)); } + if (target === "webterm") { + const { runWebtermCommand } = await import("../platform-infra-webterm"); + return await runWebtermCommand(config, args.slice(1)); + } if (target === "wechat-archive") { const { runWechatArchiveCommand } = await import("../platform-infra-wechat-archive"); return await runWechatArchiveCommand(config, args.slice(1));