diff --git a/.agents/skills/unidesk-selfmedia/SKILL.md b/.agents/skills/unidesk-selfmedia/SKILL.md new file mode 100644 index 00000000..3a1d3eaa --- /dev/null +++ b/.agents/skills/unidesk-selfmedia/SKILL.md @@ -0,0 +1,42 @@ +--- +name: unidesk-selfmedia +description: UniDesk SelfMedia 工厂的 YAML-first 运维技能,覆盖 NC01 生产 Secret 分发与消费者生效、GitHub 到 Gitea/PaC/Tekton/GitOps/Argo 自动交付、WebTerm/Codex 权限边界、公网 IP 健康与登录鉴权验收。用户提到 SelfMedia、自媒体工厂、selfmedia-nc01、管理员账号密码更新、WebTerm、微信公众号文章、4317 公网入口、SelfMedia 部署或流水线运维时使用。 +--- + +# UniDesk SelfMedia 运维 + +遵循 `Skill(cli-spec)`,只通过 UniDesk owning YAML 和受控 CLI 操作 NC01 运行面。 + +## 固定边界 + +- 把 `config/selfmedia.yaml` 作为部署、存储、运行时、权限和公网暴露真相。 +- 把 `config/secrets-distribution.yaml` 作为管理员凭据、API key、Codex 凭据和消费者 rollout 真相。 +- 把 `config/platform-infra/pipelines-as-code.yaml` 与 `config/platform-infra/gitea.yaml` 作为自动交付真相。 +- 通过 `trans NC01:` 在 NC01 选中的最新干净 UniDesk worktree 执行命令。 +- 只披露 sourceRef、键名、presence、fingerprint、字节数和步骤摘要;不得读取、打印或从运行面反解 Secret 值。 +- SelfMedia 源码交付只由 `pikainc/selfmedia` PR merge 触发;不得人工创建 PipelineRun、推送 Gitea、刷新 Argo 或 patch 运行时。 + +## 高频流程 + +1. 管理员凭据更新时,按[运维入口](references/operations.md#管理员凭据分发)依次执行 `plan`、`sync`、job 查询和 `status`。 +2. 确认 `selfmedia-nc01` 的 `consumerRollout` 已声明 `selfmedia` Deployment;应用启动时读取 Secret,缺少 rollout 不能判定新凭据已生效。 +3. 代码发布只观察 PR merge 后的自动 PaC/GitOps 收敛;只有自动链失败时才下钻 status/history/debug-step。 +4. 最后从 YAML 解析公网地址,验证 `/` 与 `/healthz` 可达、匿名业务 API 返回 `401`,再用用户提供的新账号完成登录。 + +## 验收判定 + +- Secret 同步结果必须为 `succeeded`、`valuesPrinted=false`,并且消费者 restart/status 均为 `exitCode=0`。 +- `secrets status` 必须确认 `selfmedia-access`、`selfmedia-runtime`、`selfmedia-codex` 的声明键完整。 +- WebTerm 必须保留 PVC session、restart/resume、统一 SelfMedia CLI 与非 root、无 ServiceAccount token/Kubernetes/Docker/宿主权限边界。 +- 公网验收只记录 HTTP 状态、session/job/trace、fingerprint 和 artifact SHA,不记录 cookie、账号、密码或 API key。 + +## 相关技能 + +- Secret/YAML 归属或 renderer 修改同时使用 `$unidesk-ymalops`。 +- PaC、Tekton、GitOps、Argo 或自动交付排障同时使用 `$unidesk-cicd`。 +- 跨节点现场闭环同时使用 `$dad-dev` 与 `$unidesk-trans`。 +- 公网页面、布局或浏览器原入口验收同时使用 `$unidesk-webdev`。 + +## 详细入口 + +- 凭据分发、自动交付和公网验收命令见[运维入口](references/operations.md)。 diff --git a/.agents/skills/unidesk-selfmedia/agents/openai.yaml b/.agents/skills/unidesk-selfmedia/agents/openai.yaml new file mode 100644 index 00000000..094bfcbf --- /dev/null +++ b/.agents/skills/unidesk-selfmedia/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "UniDesk SelfMedia 运维" + short_description: "SelfMedia YAML-first 部署、凭据与公网验收" + default_prompt: "使用 $unidesk-selfmedia 完成 SelfMedia 的 YAML-first 运维和原入口验收。" diff --git a/.agents/skills/unidesk-selfmedia/references/operations.md b/.agents/skills/unidesk-selfmedia/references/operations.md new file mode 100644 index 00000000..eb36362a --- /dev/null +++ b/.agents/skills/unidesk-selfmedia/references/operations.md @@ -0,0 +1,70 @@ +# SelfMedia 运维入口 + +## 管理员凭据分发 + +- 在 owner-only `/root/.unidesk/.env/selfmedia-access.env` 中维护以下键: + - `SELFMEDIA_ADMIN_USERNAME`; + - `SELFMEDIA_ADMIN_PASSWORD`; + - `SELFMEDIA_API_KEY`; + - `SELFMEDIA_SESSION_SECRET`。 +- 不用 `cat`、`source`、`env`、Pod env 或 Secret decode 检查值。 +- 先预检 sourceRef、键和 fingerprint: + +```bash +bun scripts/cli.ts secrets plan --config config/secrets-distribution.yaml --scope selfmedia --target selfmedia-nc01 +``` + +- 提交异步同步作业: + +```bash +bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope selfmedia --target selfmedia-nc01 --confirm +``` + +- 使用返回的 job ID 查询,直到终态: + +```bash +bun scripts/cli.ts job status --tail-bytes 12000 +``` + +- confirmed sync 默认只返回 source/Secret fingerprint、键和 rollout 摘要;只有一次性下钻时才使用 `--full`,不得让默认输出因超限隐藏 ready 证据。 + +- 再核对目标 Secret 的键存在性: + +```bash +bun scripts/cli.ts secrets status --config config/secrets-distribution.yaml --scope selfmedia --target selfmedia-nc01 +``` + +- `consumerRollout` 是凭据生效合同: + - `selfmedia` Deployment 通过 Secret `subPath` 挂载凭据; + - 后端在启动时加载管理员凭据和 session secret; + - `sync` 必须按 YAML 重启消费者,并通过多次短连接轮询等待 ready; + - 总等待时限和轮询间隔分别由 `timeoutSeconds`、`pollIntervalSeconds` 声明; + - 只更新 Secret 而没有成功 rollout 时,不得声称新账号已生效。 + +## 自动交付观察 + +- SelfMedia PaC consumer 为 `selfmedia-nc01`: + +```bash +bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01 --consumer selfmedia-nc01 +bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer selfmedia-nc01 --limit 10 +``` + +- 只有失败归因时才按具体 PipelineRun 下钻: + +```bash +bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target NC01 --consumer selfmedia-nc01 --id +``` + +- 正常发布只由 `pikainc/selfmedia` 的 GitHub PR merge 触发,不执行 bootstrap、apply、trigger-current、mirror sync、人工 PipelineRun 或 Argo refresh/sync。 + +## 公网原入口验收 + +- 从 `config/selfmedia.yaml#delivery.targets.NC01.deployment` 读取公网 host、端口和健康路径,不在脚本中复制配置值。 +- 无凭据检查: + - `/` 返回 `200`; + - `/healthz` 返回 `200`; + - `/api/v1/system` 返回 `401`; + - `/api/v1/terminal/status` 返回 `401`。 +- 使用 owner 提供的新账号完成浏览器登录;不得把账号密码写入命令历史、截图、报告、issue 或日志。 +- 登录后核对 WebTerm 打开、Codex session resume、统一 CLI 请求和流水线 job 状态;不使用终端访问 Kubernetes、Docker socket、ServiceAccount token 或宿主路径。 diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index f276e943..76c65db2 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -187,6 +187,11 @@ targets: namespace: selfmedia scope: selfmedia enabled: true + consumerRollout: + deployments: + - selfmedia + timeoutSeconds: 180 + pollIntervalSeconds: 5 kubernetesSecrets: - name: selfmedia-access diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md index 871c7dc4..5be35a28 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -11,3 +11,7 @@ ### R1.2 [completed] 有界恢复 NC01 AgentRun manager 受控代理可见性:定位连续 agentrun-proxy-exec-failed,只允许检查并恢复既有 manager.baseUrl、lane service proxy 与授权 presence,不打印凭据,不修改 Artificer 主线源码,不新增安全围栏;恢复后用原始 agentrun events/logs 入口验证并写任务报告,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.2_Task_Report.md)。 + +### R1.3 [in_progress] + +重新分发用户更新后的 SelfMedia 管理员凭据,并将 owning YAML、Secret sync/status、自动交付与公网鉴权验收最短路径沉淀到 `unidesk-selfmedia` skill;不得读取或输出凭据值,不得影响其他仓库 token 选择,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.3_Task_Report.md)。 diff --git a/scripts/src/secrets.ts b/scripts/src/secrets.ts index ce94512e..d7c391f2 100644 --- a/scripts/src/secrets.ts +++ b/scripts/src/secrets.ts @@ -76,6 +76,11 @@ interface DistributionTarget { namespace: string; scope: string; enabled: boolean; + consumerRollout: { + deployments: string[]; + timeoutSeconds: number; + pollIntervalSeconds: number; + } | null; } interface KubernetesSecretConfig { @@ -263,8 +268,9 @@ async function sync(config: UniDeskConfig, options: SecretsOptions): Promise, path: string): } function parseTarget(record: Record, path: string): DistributionTarget { + const rollout = record.consumerRollout === undefined ? null : objectField(record, "consumerRollout", path); + const deployments = rollout === null + ? [] + : stringArrayField(rollout, "deployments", `${path}.consumerRollout`).map((value, index) => kubernetesNameValue(value, `${path}.consumerRollout.deployments[${index}]`)); + const timeoutSeconds = rollout === null ? 0 : integerField(rollout, "timeoutSeconds", `${path}.consumerRollout`); + const pollIntervalSeconds = rollout === null ? 0 : integerField(rollout, "pollIntervalSeconds", `${path}.consumerRollout`); + if (rollout !== null && (deployments.length === 0 || timeoutSeconds <= 0 || pollIntervalSeconds <= 0)) { + throw new Error(`${path}.consumerRollout requires deployments, a positive timeoutSeconds, and a positive pollIntervalSeconds`); + } return { id: simpleId(stringField(record, "id", path), `${path}.id`), route: stringField(record, "route", path), namespace: kubernetesNameField(record, "namespace", path), scope: simpleId(stringField(record, "scope", path), `${path}.scope`), enabled: booleanField(record, "enabled", path), + consumerRollout: rollout === null ? null : { deployments, timeoutSeconds, pollIntervalSeconds }, }; } @@ -596,10 +612,17 @@ async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTar const yaml = renderSecretManifest(target, secrets); const result = await capture(config, target.route, ["sh"], applySecretScript(target, secrets, yaml)); const parsed = parseJsonOutput(result.stdout); + const applied = result.exitCode === 0 && boolField(parsed, "ok", false); + const consumerRolloutStatus = applied && target.consumerRollout !== null + ? await waitForConsumerRollout(config, target) + : null; return { - ok: result.exitCode === 0 && boolField(parsed, "ok", false), + ok: applied && (consumerRolloutStatus === null || consumerRolloutStatus.ok === true), target: targetSummary(target), - summary: parsed, + summary: { + ...parsed, + ...(consumerRolloutStatus === null ? {} : { consumerRolloutStatus }), + }, remote: secretCaptureSummary(result), ...(options.raw ? { rawCaptureOmitted: true, rawPolicy: "Secret distribution never returns raw SSH capture because remote output can contain credential-bearing diagnostics." } : {}), }; @@ -646,6 +669,9 @@ ${Object.entries(secret.data).sort(([a], [b]) => a.localeCompare(b)).map(([key, function applySecretScript(target: DistributionTarget, secrets: DesiredSecret[], yaml: string): string { const manifestB64 = Buffer.from(yaml, "utf8").toString("base64"); const summaryB64 = Buffer.from(JSON.stringify(secrets.map(remoteSecretSummary)), "utf8").toString("base64"); + const rollout = target.consumerRollout; + const rolloutDeployments = rollout?.deployments.join(" ") ?? ""; + const rolloutSummaryB64 = Buffer.from(JSON.stringify(rollout), "utf8").toString("base64"); return ` set -u tmp="$(mktemp -d)" @@ -662,22 +688,38 @@ else printf '%s\\n' 'skipped because namespace sync failed' >"$tmp/apply.err" apply_rc=1 fi -python3 - "$ns_rc" "$apply_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" <<'PY' +rollout_restart_rc=0 +: >"$tmp/rollout-restart.out" +: >"$tmp/rollout-restart.err" +if [ "$apply_rc" -eq 0 ] && [ -n '${rolloutDeployments}' ]; then + for deployment in ${rolloutDeployments}; do + kubectl -n ${target.namespace} rollout restart "deployment/$deployment" >>"$tmp/rollout-restart.out" 2>>"$tmp/rollout-restart.err" || { + rollout_restart_rc=$? + break + } + done +elif [ "$apply_rc" -ne 0 ] && [ -n '${rolloutDeployments}' ]; then + rollout_restart_rc=1 + printf '%s\\n' 'skipped because Secret apply failed' >"$tmp/rollout-restart.err" +fi +python3 - "$ns_rc" "$apply_rc" "$rollout_restart_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout-restart.out" "$tmp/rollout-restart.err" <<'PY' import base64, json, os, sys -ns_rc, apply_rc = int(sys.argv[1]), int(sys.argv[2]) +ns_rc, apply_rc, rollout_restart_rc = [int(value) for value in sys.argv[1:4]] def byte_count(path): try: return os.path.getsize(path) except FileNotFoundError: return 0 payload = { - "ok": ns_rc == 0 and apply_rc == 0, + "ok": ns_rc == 0 and apply_rc == 0 and rollout_restart_rc == 0, "namespace": "${target.namespace}", "secrets": json.loads(base64.b64decode("${summaryB64}").decode("utf-8")), + "consumerRollout": json.loads(base64.b64decode("${rolloutSummaryB64}").decode("utf-8")), "valuesPrinted": False, "steps": { - "namespace": {"exitCode": ns_rc, "stdoutBytes": byte_count(sys.argv[3]), "stderrBytes": byte_count(sys.argv[4]), "outputOmitted": True}, - "apply": {"exitCode": apply_rc, "stdoutBytes": byte_count(sys.argv[5]), "stderrBytes": byte_count(sys.argv[6]), "outputOmitted": True}, + "namespace": {"exitCode": ns_rc, "stdoutBytes": byte_count(sys.argv[4]), "stderrBytes": byte_count(sys.argv[5]), "outputOmitted": True}, + "apply": {"exitCode": apply_rc, "stdoutBytes": byte_count(sys.argv[6]), "stderrBytes": byte_count(sys.argv[7]), "outputOmitted": True}, + "consumerRolloutRestart": {"exitCode": rollout_restart_rc, "stdoutBytes": byte_count(sys.argv[8]), "stderrBytes": byte_count(sys.argv[9]), "outputOmitted": True}, }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) @@ -686,6 +728,105 @@ PY `; } +async function waitForConsumerRollout(config: UniDeskConfig, target: DistributionTarget): Promise> { + const rollout = target.consumerRollout; + if (rollout === null) return { ok: true, mode: "not-declared", mutation: false }; + const startedAt = Date.now(); + const deadline = startedAt + rollout.timeoutSeconds * 1000; + let attempts = 0; + let lastSummary: Record | null = null; + let lastRemote: Record | null = null; + while (true) { + attempts += 1; + const result = await capture(config, target.route, ["sh"], consumerRolloutStatusScript(target)); + const parsed = parseJsonOutput(result.stdout); + lastSummary = parsed; + lastRemote = secretCaptureSummary(result); + if (result.exitCode === 0 && boolField(parsed, "ok", false)) { + return { + ok: true, + mutation: false, + attempts, + elapsedSeconds: Math.ceil((Date.now() - startedAt) / 1000), + summary: parsed, + remote: lastRemote, + valuesPrinted: false, + }; + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + return { + ok: false, + mutation: false, + attempts, + elapsedSeconds: Math.ceil((Date.now() - startedAt) / 1000), + reason: "consumer-rollout-timeout", + summary: lastSummary, + remote: lastRemote, + valuesPrinted: false, + }; + } + await Bun.sleep(Math.min(rollout.pollIntervalSeconds * 1000, remainingMs)); + } +} + +function consumerRolloutStatusScript(target: DistributionTarget): string { + const rollout = target.consumerRollout; + if (rollout === null) throw new Error(`target ${target.id} does not declare consumerRollout`); + const commands = rollout.deployments.map((deployment, index) => [ + `kubectl -n ${target.namespace} get deployment ${deployment} -o json >"$tmp/deployment.${index}.json" 2>"$tmp/deployment.${index}.err"`, + `printf '%s' "$?" >"$tmp/deployment.${index}.rc"`, + ].join("\n")).join("\n"); + const expectedB64 = Buffer.from(JSON.stringify(rollout.deployments), "utf8").toString("base64"); + return ` +set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +${commands} +python3 - "$tmp" <<'PY' +import base64, json, os, sys +tmp = sys.argv[1] +expected = json.loads(base64.b64decode("${expectedB64}").decode("utf-8")) +items = [] +ok = True +for index, name in enumerate(expected): + try: + rc = int(open(os.path.join(tmp, f"deployment.{index}.rc"), encoding="utf-8").read() or "1") + except FileNotFoundError: + rc = 1 + try: + observed = json.load(open(os.path.join(tmp, f"deployment.{index}.json"), encoding="utf-8")) + except Exception: + observed = None + metadata = (observed or {}).get("metadata") or {} + spec = (observed or {}).get("spec") or {} + status = (observed or {}).get("status") or {} + generation = int(metadata.get("generation") or 0) + observed_generation = int(status.get("observedGeneration") or 0) + desired = int(spec.get("replicas") or 0) + updated = int(status.get("updatedReplicas") or 0) + ready = int(status.get("readyReplicas") or 0) + available = int(status.get("availableReplicas") or 0) + item_ok = rc == 0 and desired > 0 and observed_generation >= generation and updated >= desired and ready >= desired and available >= desired + ok = ok and item_ok + items.append({ + "name": name, + "exists": rc == 0, + "generation": generation, + "observedGeneration": observed_generation, + "desiredReplicas": desired, + "updatedReplicas": updated, + "readyReplicas": ready, + "availableReplicas": available, + "ready": item_ok, + }) +payload = {"ok": ok, "namespace": "${target.namespace}", "deployments": items, "valuesPrinted": False} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if ok else 1) +PY +`; +} + function statusSecretScript(target: DistributionTarget, secrets: DesiredSecret[]): string { const summaryB64 = Buffer.from(JSON.stringify(secrets.map(remoteSecretSummary)), "utf8").toString("base64"); const commands = secrets.map((secret, index) => [ @@ -767,6 +908,22 @@ function sourceSummary(sources: SourceInspection): Record { return { ok: sources.ok, root: sources.root, entries: sources.entries, valuesPrinted: false }; } +function compactSourceSummary(sources: SourceInspection): Record { + return { + ok: sources.ok, + entries: sources.entries.map((entry) => ({ + sourceRef: entry.sourceRef, + type: entry.type, + presence: entry.presence ?? entry.exists, + missingKeys: entry.missingKeys ?? [], + action: entry.action ?? "none", + fingerprint: entry.fingerprint ?? null, + valuesPrinted: false, + })), + valuesPrinted: false, + }; +} + function desiredSecretSummary(secret: DesiredSecret): Record { return { name: secret.name, @@ -781,6 +938,17 @@ function desiredSecretSummary(secret: DesiredSecret): Record { }; } +function compactDesiredSecretSummary(secret: DesiredSecret): Record { + return { + name: secret.name, + secretName: secret.secretName, + keys: Object.keys(secret.data).sort(), + missingKeys: secret.missingKeys, + fingerprint: secret.fingerprint, + valuesPrinted: false, + }; +} + function remoteSecretSummary(secret: DesiredSecret): Record { return { name: secret.name, @@ -792,7 +960,7 @@ function remoteSecretSummary(secret: DesiredSecret): Record { } function targetSummary(target: DistributionTarget): Record { - return { id: target.id, route: target.route, namespace: target.namespace, scope: target.scope }; + return { id: target.id, route: target.route, namespace: target.namespace, scope: target.scope, consumerRollout: target.consumerRollout }; } function readOptionValue(args: string[], index: number, option: string): string { @@ -994,6 +1162,10 @@ function kubernetesNameField(obj: Record, key: string, path: st return yamlKubernetesNameField(obj, key, path, ""); } +function kubernetesNameValue(value: string, path: string): string { + return yamlKubernetesNameField({ value }, "value", path, ""); +} + function kubernetesSecretKeyField(obj: Record, key: string, path: string): string { const value = stringField(obj, key, path); if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path}.${key} must be a Kubernetes Secret key`);