From 5f777c59f8551e488ab1ed7d6ef2f85816a61b55 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 09:49:17 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20AgentRun=20Secret?= =?UTF-8?q?=20=E5=90=8C=E6=AD=A5=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unidesk-cicd/references/platform-ops.md | 11 + docs/reference/cli.md | 6 + .../src/agentrun-secret-sync-output.test.ts | 206 +++++++++ scripts/src/agentrun/control-plane.ts | 28 +- scripts/src/agentrun/entry.ts | 7 +- scripts/src/agentrun/options.ts | 3 +- scripts/src/agentrun/secret-sync-output.ts | 395 ++++++++++++++++++ scripts/src/agentrun/trigger.ts | 13 +- 8 files changed, 655 insertions(+), 14 deletions(-) create mode 100644 scripts/src/agentrun-secret-sync-output.test.ts create mode 100644 scripts/src/agentrun/secret-sync-output.ts diff --git a/.agents/skills/unidesk-cicd/references/platform-ops.md b/.agents/skills/unidesk-cicd/references/platform-ops.md index 11490e87..ca42944c 100644 --- a/.agents/skills/unidesk-cicd/references/platform-ops.md +++ b/.agents/skills/unidesk-cicd/references/platform-ops.md @@ -13,10 +13,21 @@ bun scripts/cli.ts hwlab g14 secret ensure --lane v02 \ bun scripts/cli.ts hwlab g14 secret delete --lane v02 \ --name [--dry-run|--confirm] + +bun scripts/cli.ts agentrun control-plane secret-sync \ + --node --lane [--secret-id ] \ + [--dry-run|--confirm] [-o json|yaml] [--full|--raw] ``` Secret 只通过 YAML sourceRef/targetKey 和受控 CLI 下发;输出只披露对象名、key 名、presence、fingerprint 和摘要,不打印完整凭据。 +- AgentRun `secret-sync --dry-run` 的默认文本、JSON 和 YAML 使用同一个有界投影: + - 直接展示 YAML Secret ID、目标 Secret/key、source presence/fingerprint; + - 阻断时展示 activation fence identity、状态、关联 runner/run/command identity 和可执行只读下钻; + - 所有模式固定 `valuesPrinted=false`。 +- `--secret-id ` 是单个声明的稳定下钻;`--full` 与 `--raw` 才请求 exhaustive probe/capture。 +- `secret-sync` 是 YAML 声明的配置维护入口,不是 source delivery 或 CI/CD 恢复入口。 + ## Runtime Migration ```bash diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ffab34eb..d46759b1 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -158,6 +158,12 @@ PipelineRun 失败或长时间未完成时,先按定点 `control-plane status - `agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send` 是当前指挥官新任务和 AgentRun session 控制入口。UniDesk CLI 是 render-only client:客户端保留 k8s 风格命令解析、human 表格、生命周期摘要、下一步命令、分页、`-o json|yaml` 稳定客户端 schema 和错误展示;AgentRun 服务端只提供稳定 RESTful API、鉴权和业务事实,不承载 UniDesk CLI 渲染。日常查看用 `get tasks --queue commander`、`describe task/`、`events run/`、`logs session/`、`result run/ --command `;日常写入用 `create task --aipod Artificer --prompt-stdin`、`apply -f -`、`dispatch task/`、`send session/`、`ack/cancel task|session/`。用户级 CLI 取消 `turn` 和 `steer` 路径;`send session/` 是唯一 session follow-up 写入口,AgentRun 服务端按 durable session/run/command 状态自动决定内部 `steer` 或新 `turn`,dry-run 必须真实返回这个 decision 且不写状态。兼容 group `queue|runs|commands|runner|sessions|aipod-specs` 也走同一 direct HTTP transport,`--raw` 只披露直连 AgentRun REST envelope。 - `agentrun explain session-policy --node --lane `、`agentrun send session/ --node --lane --dry-run` 和 `agentrun aipod-specs render --node --lane ` 必须使用同一套目标 lane YAML 解析。非默认 lane 的短命令形态和 `--json-stdin -o json` 形态都应显示目标 `backendProfile`、`providerId`、`workspaceRef`、executionPolicy、provider credential SecretRef 和 tool credential SecretRef 摘要;Secret 输出只显示对象名、key 名、presence/fingerprint、projection 和 `valuesPrinted=false`。如果默认 human 输出触发 `/tmp/unidesk-cli-output` dump,应先把命令收敛为 summary/table/drill-down;机器完整输出留给显式 `--full`、`--raw`、`-o json` 或等价机器消费参数。`aipod-specs render` 的残余默认 dump 归 [#862](https://github.com/pikasTech/unidesk/issues/862) 跟踪。 - `agentrun` 资源原语的默认 transport 是直连 AgentRun REST API,配置来源是 UniDesk 自有 YAML `config/agentrun.yaml`。不带 `--node`/`--lane` 时按 YAML 的默认 manager `baseUrl` 访问;显式 `--node --lane ` 时按同一 YAML 选中 runtime lane,经 `lane-k8s-service-proxy` 进入 manager `internalBaseUrl`,并用 manager pod env 中声明的 API key metadata 发起请求;输出只披露 node/lane/namespace/baseUrl/auth env metadata 和 `valuesPrinted=false`,不得打印 key value。该模式用于 D601 `agentrun-v02` 等非默认 lane 的资源原语操作与证据采集,尤其是 `get/describe/events/logs/result`,不替代 `agentrun control-plane ...` 发布或运维控制。鉴权可以复用 `HWLAB_API_KEY` 的环境变量/固定文件发现风格,但不得依赖 HWLAB runtime、HWLAB backend-core、HWLAB frontend 代理或 SSH official CLI;多一层转发会增加故障面,不能作为正式路径。`agentrun control-plane ...` 和 `git-mirror ...` 仍属于 G14 source/runtime 运维控制路径,可以继续使用 UniDesk SSH capture bridge;这些控制面路径不得反向成为 queue/session 资源原语的默认 transport。 +- `agentrun control-plane secret-sync --node --lane --dry-run` 是 YAML SecretRef 配置维护的有界预检入口: + - 默认文本、`-o json` 和 `-o yaml` 由同一个 compact payload 渲染; + - payload 展示 YAML Secret ID、目标 Secret/key、source presence/fingerprint、activation fence identity/status 和关联资源 identity; + - `--secret-id ` 精确下钻单个声明; + - `--full` 与 `--raw` 才展开 exhaustive probe/capture; + - 全部输出固定 `valuesPrinted=false`,且该入口不得替代 source PR merge 的自动 CI/CD 链。 - `agentrun cancel ... --dry-run` 必须显示 `CancelLifecycle` 摘要:transport/authority、YAML lane、cascade scope、runner abort 窗口、cancel epoch 与 late-write fencing。取消策略来自 `config/agentrun.yaml` 的 `controlPlane.lanes..deployment.runner.cancelLifecycle`;字段缺失或 lane 选择错误应暴露为配置错误,不得在 CLI、manifest 或服务里补隐式默认。操作非默认 lane 时先加 `--node --lane --dry-run` 核对 policy,再移除 `--dry-run` 发起真实取消。 - `agentrun control-plane expose --dry-run|--confirm` 按 `config/agentrun.yaml` 维护 AgentRun 公网 HTTPS 入口,模式与 Sub2API 暴露一致:G14 AgentRun runtime 通过 frpc 出到 master `127.0.0.1:`,master Caddy 提供 `https://agentrun.74-48-78-17.nip.io/`。该命令只补 master `frps` allow port 和 Caddy vhost;G14 frpc Deployment/ConfigMap 必须由 AgentRun `deploy/deploy.json` + GitOps render 管理,不能在 UniDesk 侧手写 Kubernetes manifest。 - `codex submit/enqueue`、`codex steer`、`codex resume`、`codex queue create`、`codex queue merge`、`codex move`、旧 Web 提交表单、旧队列管理和旧 workdir 管理是冻结的 legacy Code Queue 写入口。CLI 必须返回 `ok=false`、`frozen=true`、`degradedReason=legacy-code-queue-frozen` 和 AgentRun 替代命令;服务端旧 API 写入口必须返回 410。新任务、session follow-up、events/logs/result、ack 和 cancel 走 AgentRun 资源原语,其中 session follow-up 只用 `agentrun send session/`。 diff --git a/scripts/src/agentrun-secret-sync-output.test.ts b/scripts/src/agentrun-secret-sync-output.test.ts new file mode 100644 index 00000000..d5abfc76 --- /dev/null +++ b/scripts/src/agentrun-secret-sync-output.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; + +import { parseSecretSyncOptions } from "./agentrun/control-plane"; +import type { SecretSyncOptions } from "./agentrun/options"; +import { compactAgentRunSecretSyncPayload, renderAgentRunSecretSyncResult } from "./agentrun/secret-sync-output"; + +const options = (output: SecretSyncOptions["output"], overrides: Partial = {}): SecretSyncOptions => ({ + confirm: false, + dryRun: true, + node: "NC01", + lane: "nc01-v02", + secretIds: [], + full: false, + raw: false, + output, + ...overrides, +}); + +function blockedFixture(): Record { + const planItems = Array.from({ length: 13 }, (_, index) => { + const id = index === 11 ? "tool-github-ssh-private-key" : index === 12 ? "tool-github-ssh-known-hosts" : `fixture-secret-${index}`; + return { + id, + namespace: "agentrun-v02", + secret: index >= 11 ? "agentrun-v01-tool-github-ssh" : `fixture-target-${index}`, + key: index === 11 ? "id_ed25519" : index === 12 ? "known_hosts" : `key-${index}`, + sourceMode: "file", + sourcePath: "/root/.unidesk/.state/secrets/", + present: true, + sourceFilePresent: true, + fingerprint: `sha256:${String(index).padStart(64, "a")}`, + valueBytes: 120 + index, + degradedReason: null, + valuesPrinted: false, + }; + }); + return { + ok: false, + command: "agentrun control-plane secret-sync", + mode: "dry-run-blocked-before-mutation", + status: "blocked", + mutation: false, + blockedBeforeMutation: true, + secretSyncStarted: false, + target: { node: "NC01", lane: "nc01-v02", namespace: "agentrun-v02", valuesPrinted: false }, + degradedReason: "provider-secret-activation-fence-unavailable", + plan: { secretCount: planItems.length, items: planItems, omittedCount: 0, valuesPrinted: false }, + activationPreflight: { + ok: false, + status: "blocked", + reason: "provider-secret-activation-fence-unavailable", + targetSecretCount: 1, + targetSecrets: [{ + namespace: "agentrun-v02", + name: "agentrun-v01-provider-codex", + desiredKeys: ["auth.json", "config.toml"], + queryOk: true, + present: true, + mutationCount: 1, + noOp: false, + mutations: [{ + id: "provider-codex-config", + key: "config.toml", + desiredFingerprintSuffix: "aaaaaaaaaaaa", + runtimeFingerprintSuffix: "bbbbbbbbbbbb", + changeKind: "change-key", + valuesPrinted: false, + }], + valuesPrinted: false, + }], + activationRiskCount: 1, + activationRisks: [{ + pod: "agentrun-runner-rjob_fixture-pod", + jobName: "agentrun-runner-rjob_fixture", + phase: "Pending", + runId: "run_fixture", + commandId: "cmd_fixture", + runnerJobId: "rjob_fixture", + waitingReasons: ["CreateContainerConfigError"], + classification: "nonterminal-runner-required-provider-secret-missing", + dependencies: [{ + source: "volume-secret", + name: "agentrun-v01-provider-codex", + affectedDesiredKeys: ["config.toml"], + mutationKinds: ["change-key"], + reason: "nonterminal-runner-provider-secret-mutation", + valuesPrinted: false, + }], + valuesPrinted: false, + }], + requiresManagerFence: true, + targetSecretObservationComplete: true, + runnerObservationComplete: true, + allObservationsComplete: true, + providerSecretMutationCount: 1, + providerSecretDataNoOp: false, + providerSecretMutationAuthorized: false, + managerAuthority: { + owner: "agentrun-manager-provider-activation-fence", + atomicFenceAvailable: false, + mutationAuthorized: false, + noOpOnly: true, + trackingIssue: "https://github.com/pikasTech/agentrun/issues/284", + valuesPrinted: false, + }, + capture: { + stdoutTail: "FULL_PROBE_BODY_ONLY_VISIBLE_WITH_EXPLICIT_FULL", + valuesPrinted: false, + }, + valuesPrinted: false, + }, + execution: { + providerSecretClassification: "exact-target-ref", + providerSecretWriteBlockedCount: 2, + secretSyncStarted: false, + valuesPrinted: false, + }, + next: { + observeRunners: "bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --dry-run", + retryAfterManagerFence: "bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --confirm", + }, + valuesPrinted: false, + }; +} + +describe("AgentRun secret-sync bounded output", () => { + test("parses compact machine output and explicit disclosure options", () => { + expect(parseSecretSyncOptions(["--node", "NC01", "--lane", "nc01-v02", "--dry-run", "-o", "yaml"])).toEqual({ + confirm: false, + dryRun: true, + node: "NC01", + lane: "nc01-v02", + secretIds: [], + full: false, + raw: false, + output: "yaml", + }); + expect(parseSecretSyncOptions(["--node", "NC01", "--lane", "nc01-v02", "--secret-id", "tool-github-ssh-private-key", "--full", "--output=json"])).toMatchObject({ + secretIds: ["tool-github-ssh-private-key"], + full: true, + raw: false, + output: "json", + }); + expect(parseSecretSyncOptions(["--node", "NC01", "--lane", "nc01-v02", "--raw"])).toMatchObject({ full: true, raw: true }); + expect(() => parseSecretSyncOptions(["--node", "NC01", "--lane", "nc01-v02", "-o", "wide"])).toThrow("must be one of human,json,yaml"); + }); + + test("uses one bounded Secret-safe projection for human, JSON, and YAML", () => { + const fixture = blockedFixture(); + const compact = compactAgentRunSecretSyncPayload(fixture); + const human = renderAgentRunSecretSyncResult(fixture, options("human")).renderedText; + const jsonText = renderAgentRunSecretSyncResult(fixture, options("json")).renderedText; + const yamlText = renderAgentRunSecretSyncResult(fixture, options("yaml")).renderedText; + const json = JSON.parse(jsonText) as Record; + const yaml = Bun.YAML.parse(yamlText) as Record; + + expect(json).toEqual(compact); + expect(yaml).toEqual(json); + expect(json.kind).toBe("AgentRunSecretSyncPlan"); + expect(json.status).toMatchObject({ status: "blocked", reason: "provider-secret-activation-fence-unavailable", mutation: false, valuesPrinted: false }); + expect(json.plan.secretCount).toBe(13); + expect(json.plan.omittedCount).toBe(0); + expect(json.plan.items.some((item: Record) => item.id === "tool-github-ssh-private-key")).toBe(true); + expect(json.plan.items.some((item: Record) => item.id === "tool-github-ssh-known-hosts")).toBe(true); + expect(json.activationFence).toMatchObject({ + kind: "ProviderSecretActivationFence", + identity: "providersecretactivationfence/NC01/nc01-v02", + status: "blocked", + reason: "provider-secret-activation-fence-unavailable", + resourceCount: 1, + valuesPrinted: false, + }); + expect(json.activationFence.resources[0]).toMatchObject({ + identity: "runnerjob/rjob_fixture", + status: "Pending", + runId: "run_fixture", + commandId: "cmd_fixture", + runnerJobId: "rjob_fixture", + }); + expect(json.activationFence.resources[0].drillDown.map((item: Record) => item.identity)).toEqual([ + "runnerjob/rjob_fixture", + "command/cmd_fixture", + "run/run_fixture", + ]); + expect(json.next.secretById).toContain("--secret-id --dry-run -o json"); + expect(human).toContain("ACTIVATION FENCE"); + expect(human).toContain("runnerjob/rjob_fixture"); + expect(human).toContain("tool-github-ssh-private-key"); + expect(human).toContain("VALUES PRINTED false"); + for (const output of [human, jsonText, yamlText]) { + expect(Buffer.byteLength(output, "utf8")).toBeLessThan(10_240); + expect(output).not.toContain("FULL_PROBE_BODY_ONLY_VISIBLE_WITH_EXPLICIT_FULL"); + expect(output).not.toContain("/root/.unidesk/.state/secrets/"); + } + }); + + test("keeps exhaustive probe details behind explicit full or raw", () => { + const fixture = blockedFixture(); + const full = renderAgentRunSecretSyncResult(fixture, options("json", { full: true })).renderedText; + const raw = renderAgentRunSecretSyncResult(fixture, options("human", { full: true, raw: true })).renderedText; + expect(JSON.parse(full)).toEqual(fixture); + expect(JSON.parse(raw)).toEqual(fixture); + expect(full).toContain("FULL_PROBE_BODY_ONLY_VISIBLE_WITH_EXPLICIT_FULL"); + expect(raw).toContain("FULL_PROBE_BODY_ONLY_VISIBLE_WITH_EXPLICIT_FULL"); + }); +}); diff --git a/scripts/src/agentrun/control-plane.ts b/scripts/src/agentrun/control-plane.ts index b12aeddc..25a76ffc 100644 --- a/scripts/src/agentrun/control-plane.ts +++ b/scripts/src/agentrun/control-plane.ts @@ -51,10 +51,36 @@ export function parseSecretSyncOptions(args: string[]): SecretSyncOptions { const base = parseConfirmOptions(args); let node: string | null = null; let lane: string | null = null; + let full = false; + let raw = false; + let output: SecretSyncOptions["output"] = "human"; const secretIds: string[] = []; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--confirm" || arg === "--dry-run") continue; + if (arg === "--full") { + full = true; + continue; + } + if (arg === "--raw") { + raw = true; + full = true; + continue; + } + if (arg === "-o" || arg === "--output") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`); + if (value !== "human" && value !== "json" && value !== "yaml") throw new Error(`${arg} must be one of human,json,yaml`); + output = value; + index += 1; + continue; + } + if (arg.startsWith("--output=") || arg.startsWith("-o=")) { + const value = arg.slice(arg.indexOf("=") + 1); + if (value !== "human" && value !== "json" && value !== "yaml") throw new Error(`${arg.split("=")[0]} must be one of human,json,yaml`); + output = value; + continue; + } if (arg === "--node") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--node requires a value"); @@ -79,7 +105,7 @@ export function parseSecretSyncOptions(args: string[]): SecretSyncOptions { } throw new Error(`unsupported secret-sync option: ${arg}`); } - return { ...base, node, lane, secretIds }; + return { ...base, node, lane, secretIds, full, raw, output }; } export function parseLaneConfirmOptions(args: string[]): LaneConfirmOptions { diff --git a/scripts/src/agentrun/entry.ts b/scripts/src/agentrun/entry.ts index afa071d9..373d6e1f 100644 --- a/scripts/src/agentrun/entry.ts +++ b/scripts/src/agentrun/entry.ts @@ -43,6 +43,7 @@ import { providerProfileHelp, runProviderProfileCommand } from "./provider-profi import { renderedCliResult } from "./render"; import { agentRunGetKindHelp, runAgentRunResourceCommand } from "./resource-actions"; import { runAgentRunRestCompatCommand, runGitMirrorJob, startAsyncAgentRunJob } from "./rest-bridge"; +import { renderAgentRunSecretSyncResult } from "./secret-sync-output"; import { exposeAgentRun, restartYamlLane, secretSync, triggerCurrent } from "./trigger"; import { unsupported } from "./utils"; import { cleanupLocalPostgres, cleanupReleasedPvs, cleanupRunners, cleanupRuns, cleanupSessionPvcs, refresh } from "./yaml-lane"; @@ -126,7 +127,10 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str const result = await status(config, options); return options.full || options.raw ? result : renderAgentRunControlPlaneStatusSummary(result); } - if (action === "secret-sync") return await secretSync(config, parseSecretSyncOptions(actionArgs)); + if (action === "secret-sync") { + const options = parseSecretSyncOptions(actionArgs); + return renderAgentRunSecretSyncResult(await secretSync(config, options), options); + } if (action === "restart") { const options = parseLaneConfirmOptions(actionArgs); const result = await restartYamlLane(config, options); @@ -333,6 +337,7 @@ export function agentRunHelpText(args: string[]): string { if (kind === "platform-maintenance") { return [ "Usage: bun scripts/cli.ts agentrun control-plane --node --lane [--dry-run|--confirm]", + " bun scripts/cli.ts agentrun control-plane secret-sync --node --lane [--secret-id ] --dry-run [-o json|yaml] [--full|--raw]", "", "Scope: explicit YAML-declared platform/configuration maintenance only.", "These commands do not replace the PaC source delivery chain and must not be used after a source PR merge to complete or recover rollout.", diff --git a/scripts/src/agentrun/options.ts b/scripts/src/agentrun/options.ts index f49d9ebe..bd2ccb9b 100644 --- a/scripts/src/agentrun/options.ts +++ b/scripts/src/agentrun/options.ts @@ -216,10 +216,11 @@ export interface ConfirmOptions { dryRun: boolean; } -export interface SecretSyncOptions extends ConfirmOptions { +export interface SecretSyncOptions extends ConfirmOptions, DisclosureOptions { node: string | null; lane: string | null; secretIds: string[]; + output: "human" | "json" | "yaml"; } export interface LaneConfirmOptions extends ConfirmOptions, DisclosureOptions { diff --git a/scripts/src/agentrun/secret-sync-output.ts b/scripts/src/agentrun/secret-sync-output.ts new file mode 100644 index 00000000..53fd76ea --- /dev/null +++ b/scripts/src/agentrun/secret-sync-output.ts @@ -0,0 +1,395 @@ +import type { RenderedCliResult } from "../output"; + +import type { SecretSyncOptions } from "./options"; +import { displayValue, renderTable } from "./options"; +import { record, stringOrNull } from "./utils"; + +const MAX_PLAN_ITEMS = 14; +const MAX_TARGET_SECRETS = 4; +const MAX_ACTIVATION_RESOURCES = 4; + +export function renderAgentRunSecretSyncResult(result: Record, options: SecretSyncOptions): RenderedCliResult { + const command = "agentrun control-plane secret-sync"; + if (options.full || options.raw) return renderSecretSyncMachine(command, result, options.output === "yaml" && !options.raw ? "yaml" : "json"); + const payload = compactAgentRunSecretSyncPayload(result); + if (options.output === "json" || options.output === "yaml") return renderSecretSyncMachine(command, payload, options.output); + return { + ok: result.ok !== false, + command, + renderedText: `${renderAgentRunSecretSyncText(payload)}\n`, + contentType: "text/plain", + }; +} + +export function compactAgentRunSecretSyncPayload(result: Record): Record { + const target = record(result.target); + const targetNode = record(target.node); + const identity = { + node: boundedText(targetNode.id ?? target.node), + lane: boundedText(target.lane), + namespace: boundedText(target.namespace ?? record(target.runtime).namespace), + valuesPrinted: false, + }; + const plan = record(result.plan); + const rawItems = records(plan.items); + const planItems = rawItems.slice(0, MAX_PLAN_ITEMS).map((item) => compactSecretPlanItem(item, identity)); + const requestedSecretIds = record(result.filter).secretIds; + const secretIds = Array.isArray(requestedSecretIds) + ? requestedSecretIds.map((item) => boundedText(item)).filter((item): item is string => item !== null) + : []; + const baseCommand = secretSyncBaseCommand(identity, secretIds); + const activationPreflight = record(result.activationPreflight); + return { + apiVersion: "unidesk.pikastech.local/v1alpha1", + kind: "AgentRunSecretSyncPlan", + identity, + status: { + ok: result.ok !== false, + status: boundedText(result.status ?? (result.ok === false ? "blocked" : "ready")), + mode: boundedText(result.mode), + reason: boundedText(result.degradedReason), + blockedBeforeMutation: result.blockedBeforeMutation === true, + secretSyncStarted: result.secretSyncStarted === true || result.mutation === true, + mutation: result.mutation === true, + valuesPrinted: false, + }, + selection: { + requestedSecretIds: secretIds, + selectedCount: rawItems.length, + valuesPrinted: false, + }, + plan: { + secretCount: numberOr(plan.secretCount, rawItems.length), + readyCount: numberOrNull(plan.readyCount), + unavailableCount: numberOrNull(plan.unavailableCount), + items: planItems, + omittedCount: Math.max(0, numberOr(plan.secretCount, rawItems.length) - planItems.length), + valuesPrinted: false, + }, + activationFence: Object.keys(activationPreflight).length === 0 + ? null + : compactActivationFence(activationPreflight, identity), + execution: compactExecution(record(result.execution)), + next: { + secretById: `${secretSyncBaseCommand(identity, [])} --secret-id --dry-run -o json`, + observeRunners: boundedText(record(result.next).observeRunners), + retryDryRun: boundedText(record(result.next).retryDryRun), + retryAfterManagerFence: boundedText(record(result.next).retryAfterManagerFence), + confirm: boundedText(record(result.next).confirm), + status: boundedText(record(result.next).status), + full: `${baseCommand} --dry-run --full -o json`, + raw: `${baseCommand} --dry-run --raw`, + valuesPrinted: false, + }, + redaction: { + secretValues: "omitted", + runtimeSecretValuesDecoded: false, + valuesPrinted: false, + }, + valuesPrinted: false, + }; +} + +export function renderAgentRunSecretSyncText(payload: Record): string { + const identity = record(payload.identity); + const status = record(payload.status); + const plan = record(payload.plan); + const planItems = records(plan.items); + const fence = record(payload.activationFence); + const next = record(payload.next); + const lines = [ + "AGENTRUN SECRET SYNC", + renderTable( + ["NODE", "LANE", "NAMESPACE", "STATUS", "MODE", "MUTATION", "VALUES"], + [[ + displayValue(identity.node), + displayValue(identity.lane), + displayValue(identity.namespace), + displayValue(status.status), + displayValue(status.mode), + String(status.mutation === true), + "false", + ]], + ), + `Reason: ${displayValue(status.reason)}`, + "", + `SECRET PLAN count=${displayValue(plan.secretCount)} omitted=${displayValue(plan.omittedCount)}`, + planItems.length === 0 + ? "-" + : renderTable(["ID", "TARGET", "KEY", "SOURCE", "PRESENT", "FINGERPRINT", "STATUS"], planItems.map((item) => { + const source = record(item.source); + const target = record(item.target); + return [ + displayValue(item.id), + displayValue(target.identity), + displayValue(target.key), + displayValue(source.mode), + displayValue(source.present), + shortFingerprint(source.fingerprint), + source.present === true ? "ready" : "unavailable", + ]; + })), + ]; + if (Object.keys(fence).length > 0) { + const authority = record(fence.authority); + const observation = record(fence.observation); + const targetSecrets = records(fence.targetSecrets); + const resources = records(fence.resources); + lines.push( + "", + "ACTIVATION FENCE", + renderTable(["IDENTITY", "TYPE", "STATUS", "REASON", "RISKS", "ATOMIC"], [[ + displayValue(fence.identity), + displayValue(fence.type), + displayValue(fence.status), + displayValue(fence.reason), + displayValue(fence.resourceCount), + displayValue(authority.atomicFenceAvailable), + ]]), + `Observation: targets=${displayValue(observation.targetSecretObservationComplete)} runners=${displayValue(observation.runnerObservationComplete)} mutations=${displayValue(observation.providerSecretMutationCount)} noOp=${displayValue(observation.providerSecretDataNoOp)}`, + ); + if (targetSecrets.length > 0) { + lines.push( + "", + "FENCE TARGET SECRETS", + renderTable(["IDENTITY", "KEYS", "PRESENT", "STATUS", "MUTATIONS"], targetSecrets.map((item) => [ + displayValue(item.identity), + stringList(item.desiredKeys), + displayValue(item.present), + displayValue(item.status), + displayValue(item.mutationCount), + ])), + ); + } + if (resources.length > 0) { + lines.push( + "", + "FENCE RESOURCES", + renderTable(["IDENTITY", "STATUS", "RUN", "COMMAND", "CLASSIFICATION"], resources.map((item) => [ + displayValue(item.identity), + displayValue(item.status), + displayValue(item.runId), + displayValue(item.commandId), + displayValue(item.classification), + ])), + ...resources.flatMap((item) => records(item.drillDown).map((entry) => ` ${displayValue(item.identity)}: ${displayValue(entry.command)}`)), + ); + } + } + const nextLines = ["secretById", "observeRunners", "retryDryRun", "retryAfterManagerFence", "confirm", "status", "full", "raw"] + .map((key) => boundedText(next[key])) + .filter((value): value is string => value !== null); + lines.push("", "NEXT", ...nextLines.map((line) => ` ${line}`), "", "VALUES PRINTED false"); + return lines.join("\n"); +} + +function compactSecretPlanItem(item: Record, identity: Record): Record { + const id = boundedText(item.id); + const namespace = boundedText(item.namespace); + const secret = boundedText(item.secret); + const key = boundedText(item.key); + void identity; + return { + id, + target: { + identity: namespace === null || secret === null ? null : `secret/${namespace}/${secret}`, + key, + }, + source: { + mode: boundedText(item.sourceMode), + present: item.present === true, + fingerprint: boundedText(item.fingerprint, 96), + ...(boundedText(item.degradedReason) === null ? {} : { reason: boundedText(item.degradedReason) }), + }, + }; +} + +function compactActivationFence(preflight: Record, identity: Record): Record { + const authority = record(preflight.managerAuthority); + const nestedAuthority = record(preflight.activationAuthority); + const targetSecrets = records(preflight.targetSecrets).slice(0, MAX_TARGET_SECRETS).map(compactFenceTargetSecret); + const rawRisks = records(preflight.activationRisks); + const resources = rawRisks.slice(0, MAX_ACTIVATION_RESOURCES).map((risk, index) => compactFenceResource(risk, identity, index)); + return { + apiVersion: "unidesk.pikastech.local/v1alpha1", + kind: "ProviderSecretActivationFence", + identity: `providersecretactivationfence/${displayValue(identity.node)}/${displayValue(identity.lane)}`, + type: "provider-secret-activation", + status: boundedText(preflight.status ?? (preflight.ok === true ? "ready" : "blocked")), + reason: boundedText(preflight.reason), + requiresManagerFence: preflight.requiresManagerFence === true, + authority: { + owner: boundedText(authority.owner ?? nestedAuthority.owner), + atomicFenceAvailable: authority.atomicFenceAvailable === true || nestedAuthority.atomicFenceAvailable === true, + mutationAuthorized: authority.mutationAuthorized === true || preflight.providerSecretMutationAuthorized === true, + noOpOnly: authority.noOpOnly !== false && nestedAuthority.noOpOnly !== false, + trackingIssue: boundedText(authority.trackingIssue), + }, + observation: { + targetSecretObservationComplete: preflight.targetSecretObservationComplete === true, + runnerObservationComplete: preflight.runnerObservationComplete === true, + allObservationsComplete: preflight.allObservationsComplete === true, + providerSecretMutationCount: numberOrNull(preflight.providerSecretMutationCount), + providerSecretDataNoOp: preflight.providerSecretDataNoOp === true, + }, + targetSecretCount: numberOr(preflight.targetSecretCount, targetSecrets.length), + targetSecrets, + targetSecretOmittedCount: Math.max(0, numberOr(preflight.targetSecretCount, targetSecrets.length) - targetSecrets.length), + resourceCount: numberOr(preflight.activationRiskCount, rawRisks.length), + resources, + resourceOmittedCount: Math.max(0, numberOr(preflight.activationRiskCount, rawRisks.length) - resources.length), + valuesPrinted: false, + }; +} + +function compactFenceTargetSecret(item: Record): Record { + const namespace = boundedText(item.namespace); + const name = boundedText(item.name); + const mutations = records(item.mutations).slice(0, 6).map((mutation) => { + const id = boundedText(mutation.id); + return { + id, + key: boundedText(mutation.key), + changeKind: boundedText(mutation.changeKind), + desiredFingerprintSuffix: boundedText(mutation.desiredFingerprintSuffix ?? mutation.desiredEncodedFingerprint, 24)?.slice(-12) ?? null, + runtimeFingerprintSuffix: boundedText(mutation.runtimeFingerprintSuffix ?? mutation.runtimeEncodedFingerprint, 24)?.slice(-12) ?? null, + }; + }); + const queryOk = item.queryOk === true; + const mutationCount = numberOr(item.mutationCount, mutations.length); + return { + kind: "Secret", + identity: namespace === null || name === null ? null : `secret/${namespace}/${name}`, + namespace, + name, + desiredKeys: stringArray(item.desiredKeys, 8), + queryOk, + present: typeof item.present === "boolean" ? item.present : null, + status: !queryOk ? "observation-incomplete" : mutationCount === 0 ? "ready-no-op" : "mutation-pending", + mutationCount, + mutations, + }; +} + +function compactFenceResource(risk: Record, identity: Record, index: number): Record { + const namespace = boundedText(identity.namespace); + const runId = boundedText(risk.runId); + const commandId = boundedText(risk.commandId); + const runnerJobId = boundedText(risk.runnerJobId); + const jobName = boundedText(risk.jobName); + const pod = boundedText(risk.pod); + const resourceIdentity = runnerJobId !== null + ? `runnerjob/${runnerJobId}` + : jobName !== null && namespace !== null + ? `job/${namespace}/${jobName}` + : pod !== null && namespace !== null + ? `pod/${namespace}/${pod}` + : `activationrisk/${index + 1}`; + const drillDown = fenceResourceDrillDown(identity, { runId, commandId, runnerJobId }); + return { + identity: resourceIdentity, + status: boundedText(risk.phase), + classification: boundedText(risk.classification), + runId, + commandId, + runnerJobId, + waitingReasons: stringArray(risk.waitingReasons, 4), + dependencies: records(risk.dependencies).slice(0, 3).map((dependency) => ({ + identity: boundedText(dependency.name) === null || namespace === null ? null : `secret/${namespace}/${boundedText(dependency.name)}`, + source: boundedText(dependency.source), + affectedKeys: stringArray(dependency.affectedDesiredKeys, 6), + mutationKinds: stringArray(dependency.mutationKinds, 4), + reason: boundedText(dependency.reason), + })), + drillDown, + }; +} + +function fenceResourceDrillDown( + identity: Record, + resource: { runId: string | null; commandId: string | null; runnerJobId: string | null }, +): Record[] { + const nodeLane = nodeLaneArgs(identity); + const result: Record[] = []; + if (resource.runId !== null && resource.runnerJobId !== null) { + result.push({ kind: "RunnerJob", identity: `runnerjob/${resource.runnerJobId}`, command: `bun scripts/cli.ts agentrun describe runnerjob/${resource.runnerJobId} --run ${resource.runId}${nodeLane}` }); + } + if (resource.runId !== null && resource.commandId !== null) { + result.push({ kind: "Command", identity: `command/${resource.commandId}`, command: `bun scripts/cli.ts agentrun describe command/${resource.commandId} --run ${resource.runId}${nodeLane}` }); + } + if (resource.runId !== null) { + result.push({ kind: "Run", identity: `run/${resource.runId}`, command: `bun scripts/cli.ts agentrun events run/${resource.runId} --after-seq 0 --limit 100${nodeLane}` }); + } + return result.slice(0, 3); +} + +function compactExecution(value: Record): Record { + return { + providerSecretClassification: boundedText(value.providerSecretClassification), + providerSecretWritePolicy: boundedText(value.providerSecretWritePolicy), + providerSecretWriteBlockedCount: numberOrNull(value.providerSecretWriteBlockedCount), + providerSecretWriteSkippedCount: numberOrNull(value.providerSecretWriteSkippedCount), + writableNonProviderSecretCount: numberOrNull(value.writableNonProviderSecretCount), + secretSyncStarted: value.secretSyncStarted === true, + }; +} + +function secretSyncBaseCommand(identity: Record, secretIds: readonly string[]): string { + const node = boundedText(identity.node) ?? ""; + const lane = boundedText(identity.lane) ?? ""; + return [ + "bun scripts/cli.ts agentrun control-plane secret-sync", + `--node ${node}`, + `--lane ${lane}`, + ...secretIds.map((id) => `--secret-id ${id}`), + ].join(" "); +} + +function nodeLaneArgs(identity: Record): string { + const node = boundedText(identity.node); + const lane = boundedText(identity.lane); + return node === null || lane === null ? "" : ` --node ${node} --lane ${lane}`; +} + +function renderSecretSyncMachine(command: string, value: unknown, mode: "json" | "yaml"): RenderedCliResult { + return { + ok: record(value).ok !== false && record(record(value).status).ok !== false, + command, + renderedText: mode === "json" ? `${JSON.stringify(value)}\n` : `${Bun.YAML.stringify(value)}\n`, + contentType: mode === "json" ? "application/json" : "application/yaml", + }; +} + +function records(value: unknown): Record[] { + return Array.isArray(value) ? value.map(record).filter((item) => Object.keys(item).length > 0) : []; +} + +function boundedText(value: unknown, max = 240): string | null { + const text = stringOrNull(value); + if (text === null) return null; + const oneLine = text.replace(/\s+/gu, " ").trim(); + return oneLine.length <= max ? oneLine : `${oneLine.slice(0, Math.max(0, max - 3))}...`; +} + +function stringArray(value: unknown, max: number): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => boundedText(item, 120)).filter((item): item is string => item !== null).slice(0, max); +} + +function numberOr(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function shortFingerprint(value: unknown): string { + const fingerprint = boundedText(value, 96); + if (fingerprint === null) return "-"; + return fingerprint.length <= 20 ? fingerprint : `${fingerprint.slice(0, 7)}…${fingerprint.slice(-12)}`; +} + +function stringList(value: unknown): string { + return Array.isArray(value) && value.length > 0 ? value.map(String).join(",") : "-"; +} diff --git a/scripts/src/agentrun/trigger.ts b/scripts/src/agentrun/trigger.ts index e450f2a5..06d1ae31 100644 --- a/scripts/src/agentrun/trigger.ts +++ b/scripts/src/agentrun/trigger.ts @@ -298,17 +298,8 @@ export async function secretSync(config: UniDeskConfig, options: SecretSyncOptio filter: options.secretIds.length === 0 ? null : { secretIds: options.secretIds, selected: sources.map((source) => source.id) }, plan: { secretCount: plan.length, - items: plan.slice(0, 8).map((item) => ({ - id: item.id, - namespace: item.namespace, - secret: item.secret, - key: item.key, - present: item.present, - fingerprint: item.fingerprint, - valueBytes: item.valueBytes, - valuesPrinted: false, - })), - omittedCount: Math.max(0, plan.length - 8), + items: plan, + omittedCount: 0, valuesPrinted: false, }, activationPreflight,