diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 294d68a9..e7756540 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -42,6 +42,7 @@ bun scripts/cli.ts cicd branch-follower status - 触发或验收 rollout 时必须绑定 lane、source commit、PipelineRun/GitOps revision、runtime ready 和 `/health` 端点验证结果;web-probe/Playwright 结果只能作为单独的 post-deploy 证据。 - CI/CD 状态、日志和事件查询必须减少 trans/SSH 传输:能在目标 NODE/k8s 内解析、聚合、裁剪的内容,必须在目标侧计算成短 JSON/table 摘要后再回传;禁止为了本地解析而把完整 ConfigMap、大对象、长日志或原始 API payload 透传回来。 - CI/CD 验证、测试和性能度量必须在目标 NODE/k8s 内执行,尤其是 branch-follower、Tekton/Argo、runtime reuse/env reuse、git mirror 和 runtime-ready 相关改动;不要在 master/local host 跑 test 或用本地验证结果替代目标运行面证据。本机只用于源码阅读、编辑和必要静态语法检查,正式收敛结论必须来自目标 NODE 计算出的短摘要。 +- 一旦发现 CI/CD CLI 被误用且可能写入错误状态、产生伪证据或绕过目标运行面,必须立刻先把用法改成更符合直觉的公开入口并更新本 skill/reference,再继续验证或交付;不要只靠口头记忆、隐藏 flag、手动约定或后续小心来避免复发。内部 in-cluster 模式必须只由目标 k8s Job/Pod 调用,操作者从本机只能用公开入口提交目标侧 Job 或读取目标侧摘要。 - Secret 只通过 YAML sourceRef/targetKey 和受控 CLI 下发;输出只披露 presence/fingerprint。 - 长命令用异步 job 或短轮询;不要长时间挂住 trans/ssh。 diff --git a/.agents/skills/unidesk-cicd/references/branch-follower.md b/.agents/skills/unidesk-cicd/references/branch-follower.md index a3f26d7d..a6e3c137 100644 --- a/.agents/skills/unidesk-cicd/references/branch-follower.md +++ b/.agents/skills/unidesk-cicd/references/branch-follower.md @@ -93,4 +93,8 @@ Status readers must compute near the data. When the operator CLI reaches a targe Validation, test and performance evidence for branch-follower changes must also run on the target NODE/k8s runtime, not on the local/master host. For CI/CD changes, use the target node's Tekton/Argo/runtime objects, controlled CLI jobs, and target-side summary scripts as the evidence source; local tests may not be cited as convergence or performance proof. +Operator-facing commands must use intuitive target-side verbs instead of internal execution flags. From a local/master host, use `status --live`, `run-once ...`, `events`, or `logs`; these commands create a bounded target-side Job when live state is needed. The internal `--in-cluster` flag is reserved for the Kubernetes Job/Pod command line after the registry, serviceaccount, in-cluster API endpoint and EmptyDir source checkout are mounted. It must not appear in user-facing examples. + +Legacy `--controller` is accepted only as a compatibility spelling: inside Kubernetes it maps to `--in-cluster`, while outside Kubernetes it behaves like the ordinary public target-side path rather than running in-cluster logic locally. If an internal flag, hidden mode, or operator shortcut is misused and can write partial state or misleading evidence, stop feature work and simplify the public command semantics plus this reference before continuing. + `run-once --dry-run` is read-only for deployment: it may refresh the state ConfigMap with current native observations, but it must not trigger adapters. diff --git a/scripts/native/cicd/controller-loop.sh b/scripts/native/cicd/controller-loop.sh index e56f5515..ed432607 100644 --- a/scripts/native/cicd/controller-loop.sh +++ b/scripts/native/cicd/controller-loop.sh @@ -17,7 +17,7 @@ while true; do git clone --branch "${UNIDESK_CONTROLLER_SOURCE_BRANCH}" "/cache/${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}.git" /work/unidesk cp /etc/unidesk-cicd-branch-follower/cicd-branch-followers.yaml /work/unidesk/config/cicd-branch-followers.yaml cd /work/unidesk - bun scripts/cli.ts cicd branch-follower run-once --all --confirm --controller --config config/cicd-branch-followers.yaml --timeout-seconds "${timeout}" || true + bun scripts/cli.ts cicd branch-follower run-once --all --confirm --in-cluster --config config/cicd-branch-followers.yaml --timeout-seconds "${timeout}" || true echo "branch-follower loop finished $(date -Iseconds)" cd /work sleep "${interval}" diff --git a/scripts/src/cicd-controller-render.ts b/scripts/src/cicd-controller-render.ts index 8527bdd4..37f48465 100644 --- a/scripts/src/cicd-controller-render.ts +++ b/scripts/src/cicd-controller-render.ts @@ -18,7 +18,7 @@ export function renderControllerReconcileJob(registry: BranchFollowerRegistry, o ...(options.followerId === null ? ["--all"] : ["--follower", options.followerId]), mode.dryRun ? "--dry-run" : "--confirm", "--wait", - "--controller", + "--in-cluster", "--config", "config/cicd-branch-followers.yaml", "--timeout-seconds", diff --git a/scripts/src/cicd-types.ts b/scripts/src/cicd-types.ts index 2b0d912a..32132f64 100644 --- a/scripts/src/cicd-types.ts +++ b/scripts/src/cicd-types.ts @@ -23,7 +23,7 @@ export interface ParsedOptions { confirm: boolean; dryRun: boolean; wait: boolean; - controller: boolean; + inCluster: boolean; live: boolean; noLive: boolean; full: boolean; diff --git a/scripts/src/cicd.ts b/scripts/src/cicd.ts index f6a23b64..678e0a33 100644 --- a/scripts/src/cicd.ts +++ b/scripts/src/cicd.ts @@ -117,8 +117,11 @@ function parseOptions(args: string[]): ParsedOptions { options.dryRun = true; } else if (arg === "--wait") { options.wait = true; + } else if (arg === "--in-cluster") { + options.inCluster = true; } else if (arg === "--controller") { - options.controller = true; + if (isInClusterRuntime()) options.inCluster = true; + else if (options.action === "status") options.live = true; } else if (arg === "--live") { options.live = true; } else if (arg === "--no-live") { @@ -162,6 +165,10 @@ function parseOptions(args: string[]): ParsedOptions { return options; } +function isInClusterRuntime(): boolean { + return Boolean(process.env.KUBERNETES_SERVICE_HOST && process.env.KUBERNETES_SERVICE_PORT); +} + function defaultOptions(action: BranchFollowerAction, _args: string[]): ParsedOptions { return { action, @@ -171,7 +178,7 @@ function defaultOptions(action: BranchFollowerAction, _args: string[]): ParsedOp confirm: false, dryRun: false, wait: false, - controller: false, + inCluster: false, live: false, noLive: false, full: false, @@ -525,9 +532,9 @@ async function applyController(registry: BranchFollowerRegistry, options: Parsed async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { let k8s = readK8sState(registry, options); const wantsLive = options.live || (!options.noLive && Object.keys(k8s.stateByFollower).length === 0); - const refresh = wantsLive && !options.controller ? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true }) : null; + const refresh = wantsLive && !options.inCluster ? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true }) : null; if (refresh !== null) k8s = readK8sState(registry, options); - const shouldLive = wantsLive && options.controller; + const shouldLive = wantsLive && options.inCluster; const selected = selectFollowers(registry, options, { includeDisabled: true }); const followers = []; for (const follower of selected) { @@ -553,7 +560,7 @@ async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOpti } async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { - if (!options.controller) { + if (!options.inCluster) { const refresh = runControllerReconcileJob(registry, options, { dryRun: options.dryRun, wait: true, recordState: true }); const k8s = readK8sState(registry, options); const selected = selectFollowers(registry, options, { includeDisabled: false }); @@ -599,7 +606,7 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions) dryRun: options.dryRun, confirm: options.confirm, wait: options.wait, - controller: options.controller, + controller: options.inCluster, registry: registrySummary(registry), followers: results, warnings: stateWriteWarnings, @@ -663,7 +670,7 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P } const follower = registry.followers.find((item) => item.id === options.followerId); if (follower === undefined) throw new Error(`unknown follower ${options.followerId}`); - if (!options.controller) { + if (!options.inCluster) { const refresh = runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true }); const k8s = readK8sState(registry, options); const stored = k8s.stateByFollower[follower.id] ?? {}; @@ -781,7 +788,7 @@ async function decideAndMaybeTrigger( if (options.confirm && (phase === "PendingTrigger" || phase === "Superseded" || (phase === "Observed" && observedSha !== null))) { const trigger = await executeTrigger(registry, follower, observedSha, options); triggerCommand = trigger.command; - phase = trigger.ok ? (options.wait || options.controller ? "ClosingOut" : "Triggering") : "Failed"; + phase = trigger.ok ? (options.wait || options.inCluster ? "ClosingOut" : "Triggering") : "Failed"; decision = trigger.ok ? `trigger submitted for ${shortSha(observedSha)}` : `trigger failed for ${shortSha(observedSha)}`; inFlightJob = trigger.jobId ?? live.inFlightJob; lastTriggeredSha = observedSha; @@ -839,7 +846,7 @@ async function decideAndMaybeTrigger( inFlightJob, budgetSource: follower.budgets, controller: { - mode: options.controller ? "k8s-controller" : "local-cli", + mode: options.inCluster ? "k8s-controller" : "local-cli", stateConfigMap: registry.controller.stateConfigMapName, leaseName: registry.controller.leaseName, }, @@ -861,16 +868,16 @@ async function decideAndMaybeTrigger( async function executeTrigger(registry: BranchFollowerRegistry, follower: FollowerSpec, observedSha: string | null, options: ParsedOptions): Promise { const spec = follower.commands.trigger; const timeoutSeconds = options.timeoutSeconds ?? spec.timeoutSeconds; - if (follower.adapter === "hwlab-node-runtime" && options.controller) { + if (follower.adapter === "hwlab-node-runtime" && options.inCluster) { return await executeNativeHwlabNodeTrigger(registry, follower, observedSha, options, timeoutSeconds); } - if (follower.adapter === "agentrun-yaml-lane" && options.controller) { + if (follower.adapter === "agentrun-yaml-lane" && options.inCluster) { return await executeNativeAgentRunTrigger(registry, follower, observedSha, options, timeoutSeconds); } - if (follower.adapter === "web-probe-sentinel-cicd" && options.controller) { + if (follower.adapter === "web-probe-sentinel-cicd" && options.inCluster) { return await executeNativeSentinelTrigger(registry, follower, observedSha, options, timeoutSeconds); } - if (!options.wait && !options.controller) { + if (!options.wait && !options.inCluster) { const job = startJob(`cicd_branch_follower_${safeJobSegment(follower.id)}`, spec.argv, `Trigger ${follower.id} for observed sha ${observedSha ?? "unknown"}`); return { ok: true, @@ -1957,7 +1964,7 @@ function kubePodList(registry: BranchFollowerRegistry, options: ParsedOptions, s } function runKubeScript(registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number): CommandResult { - if (options.controller) { + if (options.inCluster) { return runCommand(["sh", "-lc", script], repoRoot, { input, timeoutMs }); } return runCommand([transPath(), registry.controller.kubeRoute, "sh"], repoRoot, { input: `${script}\n`, timeoutMs }); @@ -2349,7 +2356,7 @@ function roundSeconds(value: number): number { function writeFollowerState(registry: BranchFollowerRegistry, state: FollowerState, options: ParsedOptions): CommandResult { const json = JSON.stringify(compactFollowerStateForConfigMap(state)); const dataPatch = JSON.stringify({ data: { [state.id]: json, _updatedAt: new Date().toISOString(), _specRef: SPEC_REF } }); - if (options.controller) { + if (options.inCluster) { const patchBase64 = Buffer.from(dataPatch, "utf8").toString("base64"); const createBase64 = Buffer.from(JSON.stringify({ metadata: {