Merge pull request #1995 from pikasTech/feat/selfmedia-ops-skill
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

feat: 增加 SelfMedia YAML-first 运维 skill
This commit is contained in:
Lyon
2026-07-14 14:06:09 +08:00
committed by GitHub
6 changed files with 307 additions and 10 deletions
+42
View File
@@ -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:<workspace>` 在 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)。
@@ -0,0 +1,4 @@
interface:
display_name: "UniDesk SelfMedia 运维"
short_description: "SelfMedia YAML-first 部署、凭据与公网验收"
default_prompt: "使用 $unidesk-selfmedia 完成 SelfMedia 的 YAML-first 运维和原入口验收。"
@@ -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 <job-id> --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 <pipeline-run-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 或宿主路径。
+5
View File
@@ -187,6 +187,11 @@ targets:
namespace: selfmedia
scope: selfmedia
enabled: true
consumerRollout:
deployments:
- selfmedia
timeoutSeconds: 180
pollIntervalSeconds: 5
kubernetesSecrets:
- name: selfmedia-access
@@ -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)。
+182 -10
View File
@@ -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<Rec
mode: "confirmed",
mutation: true,
config: configSummary(distribution, options),
localSources: sourceSummary(sources),
desiredSecrets: desired.map(desiredSecretSummary),
...(options.full
? { localSources: sourceSummary(sources), desiredSecrets: desired.map(desiredSecretSummary) }
: { localSources: compactSourceSummary(sources), desiredSecrets: desired.map(compactDesiredSecretSummary) }),
targets: perTarget,
valuesPrinted: false,
};
@@ -370,12 +376,22 @@ function parseExternalSourceFile(record: Record<string, unknown>, path: string):
}
function parseTarget(record: Record<string, unknown>, 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<Record<string, unknown>> {
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<string, unknown> | null = null;
let lastRemote: Record<string, unknown> | 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<string, unknown> {
return { ok: sources.ok, root: sources.root, entries: sources.entries, valuesPrinted: false };
}
function compactSourceSummary(sources: SourceInspection): Record<string, unknown> {
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<string, unknown> {
return {
name: secret.name,
@@ -781,6 +938,17 @@ function desiredSecretSummary(secret: DesiredSecret): Record<string, unknown> {
};
}
function compactDesiredSecretSummary(secret: DesiredSecret): Record<string, unknown> {
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<string, unknown> {
return {
name: secret.name,
@@ -792,7 +960,7 @@ function remoteSecretSummary(secret: DesiredSecret): Record<string, unknown> {
}
function targetSummary(target: DistributionTarget): Record<string, unknown> {
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<string, unknown>, 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<string, unknown>, 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`);