diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d8da82dc..401a1228 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -115,7 +115,11 @@ PipelineRun 失败或长时间未完成时,先按定点 `control-plane status - `gc plan|run --confirm|db-trace|policy|remote` 是主 server 和受控 provider 的磁盘高水位一次性缓解与长期防膨胀入口。`plan` 只读输出候选、风险、估算收益和保护对象;`run` 必须显式 `--confirm`;`gc remote ...` 通过 UniDesk SSH 透传执行远端 GC,`--target-use-percent N` 会在 `summary.target` 中报告目标水位所需释放量、候选估算、预计水位、缺口和 safe-stop 决策。默认只包含 allowlisted `/tmp` 诊断目录;非 allowlist stale `/tmp` 直接子项必须显式 `--include-stale-tmp`,并只允许删除 `/tmp` 一级子项且避开系统 socket/session 前缀。G14/HWLAB registry retention、受限 core dump、保护对象、safe-stop 线和长期收益表的权威规则见 `docs/reference/gc.md`。 - `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`。Todo Note 的 CC01/旧 Compose 回退入口已在迁移验收后删除;正式发布只走 NC01 PaC/Argo。D601 Code Queue 执行面不由 `server rebuild` 管理;Rust backend-core 常规迭代不得用该命令在 master server 编译,只有明确的 backend-core 主 server 上线例外可以按限流、异步轮询和 health 证据执行,规则见 `docs/reference/dev-environment.md`。 - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`trans argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 -- `platform-db postgres plan|status|export-secrets|apply --config config/platform-db/postgres-pk01.yaml` 管理 YAML 声明的 PK01 host-native PostgreSQL。`plan` 和 `status` 只读采集远端 host、PostgreSQL、TLS、DNS alias 和 Secret 形态;`export-secrets --confirm` 只按 YAML 物化本地 Secret source/export 文件,不触碰远端 PostgreSQL;`apply --confirm` 默认创建本地异步 job,`apply --confirm --wait` 用远端 root-owned job 收敛 systemd PostgreSQL、TLS、`pg_hba`、role/database、Secret export 和备份 timer。输出不得打印密码或完整 `DATABASE_URL`。跨节点消费者使用 YAML 中的 `connectionHost` 直连 PK01 公网 endpoint,DNS alias 不作为 `sslmode=require` 切库 blocker,PK01 规则见 `docs/reference/pk01.md`。 +- `platform-db postgres plan|status|export-secrets|apply --config config/platform-db/postgres-pk01.yaml` 管理 YAML 声明的 PK01 host-native PostgreSQL: + - `plan`、`status` 和 `export-secrets` 默认返回有界摘要,保留 role/database、Secret/export key 的 presence、fingerprint、mutation、typed failure、`valuesPrinted=false` 和语义化下钻;只有显式 `--full` / `--raw` 才披露完整结构化结果。 + - `plan` 和 `status` 只读采集远端 host、PostgreSQL、TLS、DNS alias 和 Secret 形态;`export-secrets --confirm` 只按 YAML 物化本地 Secret source/export 文件,不触碰远端 PostgreSQL。 + - `apply --confirm` 默认创建本地异步 job;`apply --confirm --wait` 用远端 root-owned job 收敛 systemd PostgreSQL、TLS、`pg_hba`、role/database、Secret export 和备份 timer。 + - 输出不得打印密码或完整 `DATABASE_URL`;跨节点消费者使用 YAML 中的 `connectionHost` 直连 PK01 公网 endpoint,DNS alias 不作为 `sslmode=require` 切库 blocker,PK01 规则见 `docs/reference/pk01.md`。 - `trans [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点: - `route` 基础形态是 provider id,例如 `D601` 或 `G14`;也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。 - WSL provider 的 Windows plane 固定使用 `win`,不得使用 `win32`;Windows route 中 `win` 只负责定位,operation 直接写 `ps`、`cmd`、`git` 或 fs helper。 diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index 95496255..b2441927 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -304,8 +304,8 @@ export function platformDbHelp(): unknown { usage: [ `bun scripts/cli.ts platform-db postgres plan --config ${defaultConfigPath} [--full|--raw]`, `bun scripts/cli.ts platform-db postgres status --config ${defaultConfigPath} [--full|--raw]`, - `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --dry-run`, - `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --confirm`, + `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --dry-run [--full|--raw]`, + `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --confirm [--full|--raw]`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --dry-run`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm --wait`, @@ -318,6 +318,7 @@ export function platformDbHelp(): unknown { configTruth: defaultConfigPath, defaultMutation: false, exportSecrets: "export-secrets only materializes YAML-declared local Secret source/export files; it does not touch the remote PostgreSQL service.", + disclosure: "plan, status and export-secrets use bounded summaries by default; --full and --raw explicitly disclose the complete structured result without printing Secret values.", apply: "apply --confirm returns an async UniDesk job; the job uses short trans calls and a remote PK01 job instead of keeping one long SSH session open.", monitorReset: "monitor reset is restricted by owning YAML to the dedicated web_probe_monitor database and requires --confirm; it cannot accept a database name or arbitrary SQL.", redaction: "Passwords and full DATABASE_URL values are never printed; output is limited to key names, presence, fingerprints and state.", @@ -488,7 +489,8 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis ...(secrets.ok ? [] : ["secrets-unhealthy"]), ...(endpointHealthy ? [] : [controllerConnection.attempted ? "connection-host-probe-failed" : "connection-host-probe-unavailable"]), ]; - return { + const exportTargets = inspectExportTargets(pg); + const result = { ok: remote.capture.exitCode === 0 && facts !== null && deploymentHealthy && secrets.ok, action: "platform-db-postgres-status", mutation: false, @@ -525,9 +527,12 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis }, remoteFacts: facts, secrets: secretSummary(secrets), + exports: exportTargets, remote: compactCapture(remote.capture, { full: options.full || remote.capture.exitCode !== 0 }), ...(options.raw ? { raw: remote.capture } : {}), }; + if (options.full) return result; + return compactStatus(pg, secrets, exportTargets, facts, controllerConnection, remote.capture, result.summary, result.ok); } async function apply(config: UniDeskConfig, options: PlatformDbOptions): Promise> { @@ -603,7 +608,7 @@ async function exportSecrets(options: PlatformDbOptions): Promise>, + facts: RemoteFacts | null, + controllerConnection: ControllerConnectionProbe, + capture: SshCaptureResult, + summary: Record | null, + ok: boolean, +): Record { + const blockers = summary === null + ? ["remote-facts-unavailable"] + : Array.isArray(summary.cutoverBlockers) + ? summary.cutoverBlockers + : []; + return { + ok, + action: "platform-db-postgres-status", + mutation: false, + failure: ok ? null : { + type: capture.exitCode !== 0 || facts === null + ? "remote-facts-unavailable" + : secrets.ok + ? "deployment-unhealthy" + : "secrets-unhealthy", + blockers, + }, + target: { + node: pg.node.id, + route: pg.node.route, + mode: pg.node.mode, + }, + config: { + path: pg.configPath, + id: pg.metadata.id, + pgVersion: pg.postgres.package.version, + connectionHost: pg.postgres.network.connectionHost, + port: pg.postgres.network.port, + sslmode: pg.postgres.network.sslmode, + }, + summary: summary === null ? null : { + healthy: summary.healthy, + deploymentHealthy: summary.deploymentHealthy, + cutoverReady: summary.cutoverReady, + cutoverBlockers: summary.cutoverBlockers, + packageInstalled: summary.packageInstalled, + serviceActive: summary.serviceActive, + serviceEnabled: summary.serviceEnabled, + sslOn: summary.sslOn, + port5432Listening: summary.port5432Listening, + dnsResolves: summary.dnsResolves, + dnsDisposition: summary.dnsDisposition, + secretsOk: summary.secretsOk, + connectionHostProbe: { + attempted: controllerConnection.attempted, + ok: controllerConnection.ok, + ssl: controllerConnection.ssl, + user: controllerConnection.user, + database: controllerConnection.database, + host: controllerConnection.host, + port: controllerConnection.port, + error: controllerConnection.error, + }, + }, + roles: pg.objects.roles.map((role) => ({ + name: role.name, + exists: facts?.postgres.roles.find((item) => item.name === role.name)?.exists ?? null, + })), + databases: pg.objects.databases.map((database) => ({ + name: database.name, + owner: database.owner, + exists: facts?.postgres.databases.find((item) => item.name === database.name)?.exists ?? null, + })), + appConnections: { + ok: facts?.postgres.appConnections.every((connection) => connection.ok === true && connection.ssl === true) ?? false, + passed: facts?.postgres.appConnections.filter((connection) => connection.ok === true && connection.ssl === true).length ?? 0, + total: facts?.postgres.appConnections.length ?? 0, + failed: facts?.postgres.appConnections + .filter((connection) => connection.ok !== true || connection.ssl !== true) + .map((connection) => ({ user: connection.user, database: connection.database, error: connection.error })) ?? [], + }, + secrets: compactSecretInspection(secrets), + exports: exports.map((item) => ({ + targetRef: item.targetRef, + key: item.key, + exists: item.exists, + present: item.present, + fingerprint: item.fingerprint, + })), + remote: compactPlanCapture(capture), + next: { + plan: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath}`, + full: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath} --full`, + raw: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath} --raw`, + }, + disclosure: { + compact: true, + omitted: ["host facts", "package repository", "TLS paths", "raw remote capture"], + fullAvailable: true, + rawAvailable: true, + }, + valuesPrinted: false, + }; +} + +function compactExportSecrets( + pg: PostgresHostConfig, + localState: { inspection: SecretInspection; summary: Record; exports: Array> }, + mode: "dry-run" | "confirmed", + mutation: boolean, + ok: boolean, +): Record { + const suffix = mode === "dry-run" ? " --dry-run" : " --confirm"; + return { + ok, + action: "platform-db-postgres-export-secrets", + mode, + mutation, + failure: ok ? null : { + type: "secrets-unhealthy", + blockers: localState.inspection.entries.filter((entry) => entry.missingKeys.length > 0).map((entry) => entry.sourceRef), + }, + config: { + path: pg.configPath, + id: pg.metadata.id, + secretRoot: localState.inspection.root, + }, + secrets: compactSecretInspection(localState.inspection), + exports: localState.exports.map((item) => ({ + name: item.name, + sourceRef: item.sourceRef, + targetRef: item.targetRef, + key: item.key, + exists: item.exists, + present: item.present, + action: item.action, + fingerprint: item.fingerprint, + })), + next: { + ...(mode === "dry-run" ? { confirm: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath} --confirm` } : {}), + full: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath}${suffix} --full`, + raw: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath}${suffix} --raw`, + syncConsumers: "run the consumer-specific secret sync command after confirmed export", + }, + disclosure: { + compact: true, + omitted: ["source paths", "required key lists", "consumer Secret details"], + fullAvailable: true, + rawAvailable: true, + }, + valuesPrinted: false, + }; +} + +function compactSecretInspection(secrets: SecretInspection): Record { + return { + ok: secrets.ok, + root: secrets.root, + entries: secrets.entries.map((entry) => ({ + sourceRef: entry.sourceRef, + exists: entry.exists, + keys: { + present: entry.presentKeys, + missing: entry.missingKeys, + }, + action: entry.action, + fingerprint: entry.fingerprint, + })), + valuesPrinted: false, + }; +} + function compactRemoteFacts(facts: RemoteFacts | null): Record | null { if (facts === null) return null; return { @@ -1384,7 +1565,8 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI const rendered = renderConnectionString(item, source.values); const root = secretRoot(pg); const targetPath = join(root, item.writeToSecretSource.sourceRef); - const existing = existsSync(targetPath) ? parseEnvFile(readFileSync(targetPath, "utf8")) : {}; + const exists = existsSync(targetPath); + const existing = exists ? parseEnvFile(readFileSync(targetPath, "utf8")) : {}; const before = existing[item.writeToSecretSource.key]; const next = { ...existing, [item.writeToSecretSource.key]: rendered }; if (materialize) writeEnvFile(targetPath, next); @@ -1393,6 +1575,8 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI sourceRef: item.sourceSecretRef, targetRef: item.writeToSecretSource.sourceRef, key: item.writeToSecretSource.key, + exists, + present: before !== undefined && before.length > 0, action: before === rendered ? "none" : before === undefined ? "create" : "update", fingerprint: fingerprintValues({ [item.writeToSecretSource.key]: rendered }, [item.writeToSecretSource.key]), consumers: item.consumers, @@ -1400,6 +1584,28 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI }; } +function inspectExportTargets(pg: PostgresHostConfig): Array> { + const root = secretRoot(pg); + return pg.exports.connectionStrings.map((item) => { + const targetPath = join(root, item.writeToSecretSource.sourceRef); + const exists = existsSync(targetPath); + const values = exists ? parseEnvFile(readFileSync(targetPath, "utf8")) : {}; + const value = values[item.writeToSecretSource.key]; + const present = value !== undefined && value.length > 0; + return { + name: item.name, + sourceRef: item.sourceSecretRef, + targetRef: item.writeToSecretSource.sourceRef, + key: item.writeToSecretSource.key, + exists, + present, + fingerprint: present ? fingerprintValues({ [item.writeToSecretSource.key]: value }, [item.writeToSecretSource.key]) : null, + consumerScopes: item.consumers.map((consumer) => consumer.scope), + valuesPrinted: false, + }; + }); +} + function renderConnectionString(item: ConnectionStringExportConfig, values: Record): string { let output = item.render.format; const merged = { ...values, ...(item.render.variables ?? {}) };