Merge pull request #1773 from pikasTech/fix/1764-pac-manual-repair-hints
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

fix: 收敛 PaC 已迁移链路的手工修复提示
This commit is contained in:
Lyon
2026-07-11 19:19:20 +08:00
committed by GitHub
55 changed files with 2953 additions and 1472 deletions
+106 -31
View File
@@ -33,6 +33,12 @@ import {
type AgentRunArtifactService,
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";
import {
pacAutomaticDeliveryFix,
pacReadOnlyNext,
resolveCicdDeliveryAuthority,
type CicdDeliveryAuthority,
} from "../cicd-delivery-authority";
import type { CleanupReleasedPvOptions, CleanupRunnersOptions, CleanupRunsOptions, CleanupSessionPvcsOptions, ConfirmOptions, GitMirrorOptions, LaneConfirmOptions, RefreshOptions, SecretSyncOptions, StatusOptions } from "./options";
import { agentRunControlPlaneStatusCommand } from "./public-exposure";
@@ -258,11 +264,37 @@ export function positiveIntegerOption(args: string[], name: string, defaultValue
export async function controlPlanePlan(_config: UniDeskConfig, options: StatusOptions): Promise<Record<string, unknown>> {
const target = resolveAgentRunLaneTarget(options);
const spec = target.spec;
const deliveryAuthority = resolveCicdDeliveryAuthority({
node: spec.nodeId,
lane: spec.lane,
sourceRepository: spec.source.repository,
sourceBranch: spec.source.branch,
declaredSourceAuthorityMode: spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
const statusCommand = `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`;
const next = deliveryAuthority.kind === "pac-pr-merge"
? pacReadOnlyNext(deliveryAuthority.consumer.node, deliveryAuthority.consumer.consumerId)
: deliveryAuthority.kind === "unknown"
? {
status: statusCommand,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${spec.nodeId} --limit 10`,
fixAutomaticDelivery: pacAutomaticDeliveryFix(deliveryAuthority),
valuesPrinted: false,
}
: {
status: statusCommand,
postgresStatus: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres status --config ${spec.database.configRef}` : null,
platformMaintenanceHelp: "bun scripts/cli.ts agentrun control-plane platform-maintenance --help",
legacyCicdHelp: "bun scripts/cli.ts agentrun control-plane legacy-cicd --help",
valuesPrinted: false,
};
return {
ok: true,
command: "agentrun control-plane plan",
mode: "yaml-declared-node-lane",
configPath: target.configPath,
deliveryAuthority,
target: agentRunLaneSummary(spec),
plannedChecks: [
"source-branch-exists",
@@ -278,12 +310,7 @@ export async function controlPlanePlan(_config: UniDeskConfig, options: StatusOp
mutation: false,
note: "plan/status are read-only. Long writes must use controlled AgentRun/Platform DB commands and this YAML target.",
},
next: {
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
postgresStatus: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres status --config ${spec.database.configRef}` : null,
postgresApply: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres apply --config ${spec.database.configRef} --confirm` : null,
controlPlaneApply: `bun scripts/cli.ts agentrun control-plane apply --node ${spec.nodeId} --lane ${spec.lane} --dry-run`,
},
next,
valuesPrinted: false,
};
}
@@ -345,29 +372,18 @@ export async function status(config: UniDeskConfig, options: StatusOptions): Pro
return await statusYamlLane(config, options, resolveAgentRunLaneTarget(options));
}
function isPipelinesAsCodeMigratedLane(spec: AgentRunLaneSpec): boolean {
return spec.source.remote.includes("gitea-http.")
|| spec.source.remote.includes("/mirrors/")
|| spec.gitMirror.readUrl.includes("gitea-http.")
|| spec.gitops.repoURL.includes("gitea-http.")
|| spec.gitMirror.readUrl.includes("/mirrors/");
}
function pacStatusCommand(spec: AgentRunLaneSpec, full = false): string {
return `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${spec.nodeId} --consumer ${pacConsumerId(spec)}${full ? " --full" : ""}`;
}
function pacConsumerId(spec: AgentRunLaneSpec): string {
return `agentrun-${spec.lane.toLowerCase()}`;
function pacStatusCommand(spec: AgentRunLaneSpec, consumerId: string, full = false): string {
return `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${spec.nodeId} --consumer ${consumerId}${full ? " --full" : ""}`;
}
function giteaMirrorStatusCommand(spec: AgentRunLaneSpec): string {
return `bun scripts/cli.ts platform-infra gitea mirror status --target ${spec.nodeId}`;
}
export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown>> {
export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }, authority: Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }>): Promise<Record<string, unknown>> {
const spec = target.spec;
const pacStatus = record(await runPlatformInfraPipelinesAsCodeCommand(config, ["status", "--target", spec.nodeId, "--consumer", pacConsumerId(spec), "--full"]));
const consumerId = authority.consumer.consumerId;
const pacStatus = record(await runPlatformInfraPipelinesAsCodeCommand(config, ["status", "--target", spec.nodeId, "--consumer", consumerId, "--full"]));
const pacSummary = record(pacStatus.summary);
const latestPipelineRun = record(pacSummary.latestPipelineRun);
const artifact = record(pacSummary.artifact);
@@ -412,13 +428,13 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
};
const nextAction = aligned
? { code: "none", summary: "PaC/Gitea migrated lane aligned; no action required", command: null }
: blockers.includes("argo-revision-stale")
? { code: "argo-refresh", summary: "GitOps commit is published but Argo has not observed it yet", command: `bun scripts/cli.ts agentrun control-plane refresh --node ${spec.nodeId} --lane ${spec.lane} --confirm` }
: { code: "inspect-pac-status", summary: `PaC/Gitea closeout blocked: ${blockers.join(", ")}`, command: pacStatusCommand(spec, true) };
: { code: "inspect-automatic-delivery", summary: `PaC/Gitea automatic delivery is blocked: ${blockers.join(", ")}`, command: pacStatusCommand(spec, consumerId, true) };
const migration = {
migrated: true,
sourceAuthority: "gitea-pipelines-as-code",
triggerPath: "Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime",
consumerId,
configRefs: authority.consumer.configRefs,
triggerPath: "GitHub PR merge -> GitHub webhook -> Gitea mirror/snapshot -> Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime",
legacyGitMirrorDisposition: "migration-readonly",
legacyTriggerCurrentDisabled: true,
valuesPrinted: false,
@@ -426,6 +442,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
const summary = {
aligned,
runtimeAligned: runtimeAlignment.runtimeAligned,
deliveryAuthority: authority,
blockers,
warnings: [],
sourceCommit,
@@ -486,6 +503,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
summary,
alignment: {
aligned,
deliveryAuthority: authority,
blockers,
warnings: [],
sourceCommit,
@@ -504,14 +522,15 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
raw: options.raw,
legacyGitMirror: "migration-readonly",
fullCommand: agentRunControlPlaneStatusCommand(spec, options, true),
pacStatusCommand: pacStatusCommand(spec, true),
pacStatusCommand: pacStatusCommand(spec, consumerId, true),
},
next: {
action: nextAction,
pacStatus: pacStatusCommand(spec),
statusFull: agentRunControlPlaneStatusCommand(spec, options, true),
pacStatus: pacStatusCommand(spec, consumerId),
pacHistory: record(pacReadOnlyNext(spec.nodeId, consumerId)).history,
giteaMirrorStatus: giteaMirrorStatusCommand(spec),
refresh: `bun scripts/cli.ts agentrun control-plane refresh --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
triggerCurrent: null,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
},
valuesPrinted: false,
};
@@ -534,9 +553,62 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
return result;
}
function unknownDeliveryAuthorityStatus(options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }, authority: Extract<CicdDeliveryAuthority, { kind: "unknown" }>): Record<string, unknown> {
const spec = target.spec;
const statusFull = agentRunControlPlaneStatusCommand(spec, options, true);
const nextAction = {
code: "delivery-authority-unknown",
summary: "无法从 owning YAML 唯一确认 PaC 或 legacy delivery authority;已停止生成任何人工推进提示。",
command: null,
};
return {
ok: false,
command: "agentrun control-plane status",
mode: "delivery-authority-unknown",
mutation: false,
configPath: target.configPath,
target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec),
summary: {
aligned: false,
runtimeAligned: false,
blockers: ["delivery-authority-unknown"],
warnings: [],
deliveryAuthority: authority,
nextAction,
},
alignment: {
aligned: false,
runtimeAligned: false,
blockers: ["delivery-authority-unknown"],
warnings: [],
deliveryAuthority: authority,
},
disclosure: {
output: options.full || options.raw ? "full" : "compact-summary",
full: options.full,
raw: options.raw,
fullCommand: statusFull,
},
next: {
statusFull,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
},
valuesPrinted: false,
};
}
export async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown>> {
const spec = target.spec;
if (isPipelinesAsCodeMigratedLane(spec)) return await statusPipelinesAsCodeLane(config, options, target);
const deliveryAuthority = resolveCicdDeliveryAuthority({
node: spec.nodeId,
lane: spec.lane,
sourceRepository: spec.source.repository,
sourceBranch: spec.source.branch,
declaredSourceAuthorityMode: spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
if (deliveryAuthority.kind === "pac-pr-merge") return await statusPipelinesAsCodeLane(config, options, target, deliveryAuthority);
if (deliveryAuthority.kind === "unknown") return unknownDeliveryAuthorityStatus(options, target, deliveryAuthority);
const sourceProbe = await timedStatusStage("source", () => spec.source.statusMode === "k3s-git-mirror"
? capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)])
: capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)]));
@@ -714,6 +786,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
const detailedSummary = {
aligned,
runtimeAligned,
deliveryAuthority,
ciEvidenceMissing,
mirrorAlreadySynced,
blockers,
@@ -777,6 +850,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
const commanderSummary = {
aligned,
runtimeAligned,
deliveryAuthority,
ciEvidenceMissing,
mirrorAlreadySynced,
blockers,
@@ -803,6 +877,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
alignment: {
aligned,
runtimeAligned,
deliveryAuthority,
ciEvidenceMissing,
mirrorAlreadySynced,
blockers,
+73 -25
View File
@@ -32,6 +32,7 @@ import {
type AgentRunArtifactService,
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";
import { decideCicdDeliveryMutation, resolveCicdDeliveryAuthority } from "../cicd-delivery-authority";
import type { AgentRunResourceVerb, AgentRunRestCompatGroup } from "./utils";
import { controlPlaneApply, controlPlanePlan, parseCleanupReleasedPvOptions, parseCleanupRunnersOptions, parseCleanupRunsOptions, parseCleanupSessionPvcsOptions, parseConfirmOptions, parseGitMirrorOptions, parseLaneConfirmOptions, parseRefreshOptions, parseSecretSyncOptions, status } from "./control-plane";
@@ -68,25 +69,14 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun explain task",
"bun scripts/cli.ts agentrun explain session-policy",
"bun scripts/cli.ts agentrun control-plane plan --node NC01 --lane nc01-v02",
"bun scripts/cli.ts agentrun control-plane apply --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane status --node NC01 --lane nc01-v02",
"bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --secret-id tool-github-pr-token --confirm",
"bun scripts/cli.ts agentrun control-plane restart --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane restart --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts agentrun control-plane trigger-current --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane trigger-current --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer agentrun-nc01-v02 --limit 10",
"bun scripts/cli.ts agentrun control-plane platform-maintenance --help",
"bun scripts/cli.ts agentrun control-plane legacy-cicd --help",
"bun scripts/cli.ts agentrun control-plane status",
"bun scripts/cli.ts agentrun control-plane status --full",
"bun scripts/cli.ts agentrun control-plane status --pipeline-run <yaml-lane-pipelinerun>",
"bun scripts/cli.ts agentrun control-plane status --source-commit <full-sha>",
"bun scripts/cli.ts agentrun control-plane expose --dry-run",
"bun scripts/cli.ts agentrun control-plane expose --confirm",
"bun scripts/cli.ts agentrun control-plane trigger-current --dry-run",
"bun scripts/cli.ts agentrun control-plane trigger-current --confirm",
"bun scripts/cli.ts agentrun control-plane refresh --dry-run",
"bun scripts/cli.ts agentrun control-plane refresh --confirm",
"bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts agentrun control-plane cleanup-runs --min-age-minutes 30 --limit 200 --dry-run",
@@ -101,12 +91,12 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun provider-profile validate --node NC01 --lane nc01-v02 --profile gpt.pika --wait",
"bun scripts/cli.ts agentrun git-mirror status",
"bun scripts/cli.ts agentrun git-mirror status --full",
"bun scripts/cli.ts agentrun git-mirror sync --confirm",
"bun scripts/cli.ts agentrun git-mirror flush --confirm",
"bun scripts/cli.ts agentrun git-mirror legacy-ops --help",
],
resources: ["task/qt", "run", "command/cmd", "runnerjob/rjob", "session/ses", "aipodspec/aps"],
description: "Operate AgentRun through Kubernetes-style resource verbs. Human output is compact by default; -o json|yaml returns the UniDesk render-only client schema, --raw exposes the REST envelope, and --node/--lane targets a YAML-declared runtime lane.",
legacyCompatibility: "queue/runs/commands/runner/sessions/aipod-specs remain as compatibility groups backed by direct HTTP; new commander work should use get/describe/events/logs/result/ack/cancel/dispatch/create/apply/send. sessions turn/steer are removed; use send only.",
cicdBoundary: "NC01 PaC consumer 仅由 GitHub PR merge 自动触发;默认帮助只给 status/history。legacy 与平台维护写入口只在显式 scoped help 中展示。",
};
}
@@ -141,11 +131,17 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str
if (action === "expose") return await exposeAgentRun(config, parseConfirmOptions(actionArgs));
if (action === "trigger-current") {
const options = parseTriggerOptions(actionArgs);
const target = resolveAgentRunLaneTarget(options);
const decision = agentRunDeliveryMutationDecision(target, "trigger-current");
if (!decision.allowed) return decision.result;
const result = await triggerCurrent(config, options);
return options.full || options.raw || !isRecord(result.target) ? result : renderAgentRunControlPlaneActionSummary(result, "AGENTRUN TRIGGER CURRENT");
}
if (action === "refresh") {
const options = parseRefreshOptions(actionArgs);
const target = resolveAgentRunLaneTarget(options);
const decision = agentRunDeliveryMutationDecision(target, "refresh");
if (!decision.allowed) return decision.result;
const result = await refresh(config, options);
return options.full || options.raw ? result : renderAgentRunControlPlaneActionSummary(result, "AGENTRUN CONTROL-PLANE REFRESH");
}
@@ -166,7 +162,10 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str
if (action === "status") return await gitMirrorStatus(config, parseGitMirrorStatusOptions(actionArgs));
if (action === "sync" || action === "flush") {
const options = parseGitMirrorOptions(actionArgs);
const { spec } = resolveAgentRunLaneTarget(options);
const target = resolveAgentRunLaneTarget(options);
const { spec } = target;
const decision = agentRunDeliveryMutationDecision(target, `git-mirror-${action}`);
if (!decision.allowed) return decision.result;
if (options.confirm && !options.wait) {
return startAsyncAgentRunJob(
`agentrun_${spec.lane}_git_mirror_${action}`,
@@ -180,6 +179,29 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str
return unsupported(route.canonicalArgs);
}
function agentRunDeliveryMutationDecision(
target: ReturnType<typeof resolveAgentRunLaneTarget>,
action: "trigger-current" | "refresh" | "git-mirror-sync" | "git-mirror-flush",
) {
const authority = resolveCicdDeliveryAuthority({
node: target.spec.nodeId,
lane: target.spec.lane,
sourceRepository: target.spec.source.repository,
sourceBranch: target.spec.source.branch,
declaredSourceAuthorityMode: target.spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
return decideCicdDeliveryMutation(authority, {
command: action.startsWith("git-mirror-")
? `agentrun git-mirror ${action.slice("git-mirror-".length)}`
: `agentrun control-plane ${action}`,
statusCommand: `bun scripts/cli.ts agentrun control-plane status --node ${target.spec.nodeId} --lane ${target.spec.lane}`,
action,
node: target.spec.nodeId,
lane: target.spec.lane,
});
}
export function isAgentRunRestCompatGroup(group: string | undefined): group is AgentRunRestCompatGroup {
return group === "queue" || group === "sessions" || group === "aipod-specs" || group === "aipods" || group === "runs" || group === "commands" || group === "runner";
}
@@ -285,32 +307,58 @@ export function agentRunHelpText(args: string[]): string {
}
if (verb === "explain") return agentRunExplain(kind ?? "task");
if (verb === "control-plane") {
if (kind === "legacy-cicd") {
return [
"Usage: bun scripts/cli.ts agentrun control-plane <trigger-current|refresh> --node <node> --lane <lane> [--dry-run|--confirm]",
"",
"Scope: explicit legacy/manual CI/CD only.",
"The owning YAML delivery authority must resolve to legacy-manual. PaC migrated or unknown authority must fail closed and must not use these commands for delivery or recovery.",
].join("\n");
}
if (kind === "platform-maintenance") {
return [
"Usage: bun scripts/cli.ts agentrun control-plane <apply|secret-sync|restart|expose> --node <node> --lane <lane> [--dry-run|--confirm]",
"",
"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.",
].join("\n");
}
return [
"Usage: bun scripts/cli.ts agentrun control-plane <action> [options]",
"",
"Actions: plan, apply, status, secret-sync, expose, trigger-current, refresh, cleanup-runners, cleanup-runs, cleanup-session-pvcs, cleanup-local-postgres, cleanup-released-pvs",
"Default actions: plan, status, cleanup-runners, cleanup-runs, cleanup-session-pvcs, cleanup-local-postgres, cleanup-released-pvs",
"Examples:",
" bun scripts/cli.ts agentrun control-plane plan --node NC01 --lane nc01-v02",
" bun scripts/cli.ts agentrun control-plane apply --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane status --node NC01 --lane nc01-v02",
" bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane trigger-current --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer agentrun-nc01-v02 --limit 10",
" bun scripts/cli.ts agentrun control-plane status",
" bun scripts/cli.ts agentrun control-plane status --pipeline-run <yaml-lane-pipelinerun>",
" bun scripts/cli.ts agentrun control-plane expose --dry-run",
" bun scripts/cli.ts agentrun control-plane trigger-current --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-session-pvcs --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-local-postgres --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-runs --min-age-minutes 30 --limit 200 --dry-run",
"",
"Scoped maintenance help:",
" bun scripts/cli.ts agentrun control-plane platform-maintenance --help",
" bun scripts/cli.ts agentrun control-plane legacy-cicd --help",
"PaC migrated consumer 的唯一触发是 GitHub PR merge;自动链故障只用 status/history 调查并修 owning YAML/controller/source。",
].join("\n");
}
if (verb === "provider-profile") return providerProfileHelp();
if (verb === "git-mirror") {
if (kind === "legacy-ops") {
return [
"Usage: bun scripts/cli.ts agentrun git-mirror <sync|flush> --node <node> --lane <lane> [--dry-run|--confirm] [--wait]",
"",
"Scope: explicit legacy/manual mirror maintenance only.",
"The owning YAML delivery authority must resolve to legacy-manual. PaC migrated or unknown authority must fail closed.",
].join("\n");
}
return [
"Usage: bun scripts/cli.ts agentrun git-mirror <status|sync|flush> [--full|--raw|--confirm]",
"Usage: bun scripts/cli.ts agentrun git-mirror status [--full|--raw]",
"",
"Confirmed sync/flush returns an async job unless --wait is set.",
"PaC migrated consumer uses platform-infra Gitea/PaC status and never uses manual mirror sync/flush to complete delivery.",
"Explicit legacy operations: bun scripts/cli.ts agentrun git-mirror legacy-ops --help",
].join("\n");
}
if (verb !== undefined && isAgentRunRestCompatGroup(verb)) {
+22 -4
View File
@@ -32,6 +32,7 @@ import {
type AgentRunArtifactService,
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";
import { pacAutomaticDeliveryFix, pacReadOnlyNext, resolveCicdDeliveryAuthority } from "../cicd-delivery-authority";
import type { GitMirrorStatusOptions } from "./options";
import { readGitMirrorStatus } from "./rest-bridge";
@@ -473,6 +474,14 @@ export function refreshYamlLaneScript(spec: AgentRunLaneSpec): string {
export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorStatusOptions = { full: false, raw: false, node: null, lane: null }): Promise<Record<string, unknown>> {
const target = resolveAgentRunLaneTarget(options);
const spec = target.spec;
const deliveryAuthority = resolveCicdDeliveryAuthority({
node: spec.nodeId,
lane: spec.lane,
sourceRepository: spec.source.repository,
sourceBranch: spec.source.branch,
declaredSourceAuthorityMode: spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
const observation = await readGitMirrorStatus(config, target);
const summary = observation.summary;
return {
@@ -480,6 +489,7 @@ export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorS
command: "agentrun git-mirror status",
mode: "yaml-declared-node-lane",
configPath: target.configPath,
deliveryAuthority,
target: agentRunLaneSummary(spec),
namespace: spec.gitMirror.namespace,
readUrl: spec.gitMirror.readUrl,
@@ -496,9 +506,17 @@ export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorS
expandWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --full`,
rawWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --raw`,
},
next: {
sync: `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
flush: summary.pendingFlush === true ? `bun scripts/cli.ts agentrun git-mirror flush --node ${spec.nodeId} --lane ${spec.lane} --confirm` : null,
},
next: deliveryAuthority.kind === "pac-pr-merge"
? pacReadOnlyNext(deliveryAuthority.consumer.node, deliveryAuthority.consumer.consumerId)
: deliveryAuthority.kind === "unknown"
? {
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
fixAutomaticDelivery: pacAutomaticDeliveryFix(deliveryAuthority),
valuesPrinted: false,
}
: {
sync: `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
flush: summary.pendingFlush === true ? `bun scripts/cli.ts agentrun git-mirror flush --node ${spec.nodeId} --lane ${spec.lane} --confirm` : null,
},
};
}
+5 -2
View File
@@ -359,7 +359,10 @@ export function unsupported(args: string[]): RenderedCliResult {
" agentrun aipod-specs|queue|runs|commands|runner|sessions",
"",
"Operations:",
" agentrun control-plane status|trigger-current|refresh",
" agentrun git-mirror status|sync|flush",
" agentrun control-plane status",
" agentrun git-mirror status",
" agentrun control-plane legacy-cicd --help",
" agentrun git-mirror legacy-ops --help",
" agentrun control-plane platform-maintenance --help",
].join("\n"));
}
@@ -0,0 +1,176 @@
// Responsibility: fail-closed delivery authority and safe navigation for the retired branch-follower surface.
import {
pacAutomaticDeliveryFix,
pacNodeReadOnlyNext,
resolveCicdDeliveryAuthority,
type CicdDeliveryAuthority,
} from "./cicd-delivery-authority";
import type { BranchFollowerRegistry, FollowerSpec, ParsedOptions } from "./cicd-types";
export type BranchFollowerDeliveryAuthority =
| {
readonly kind: "retired-readonly";
readonly mutationHintsAllowed: false;
readonly followers: readonly string[];
readonly pacAuthorities: readonly Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }>[];
readonly reason: "all-selected-followers-retired-to-pac";
}
| {
readonly kind: "legacy-manual";
readonly mutationHintsAllowed: true;
readonly followers: readonly string[];
readonly reason: "all-selected-followers-explicit-legacy-manual";
}
| {
readonly kind: "unknown";
readonly mutationHintsAllowed: false;
readonly followers: readonly string[];
readonly reason: "authority-not-declared" | "authority-mixed" | "retired-pac-identity-invalid" | "legacy-conflicts-with-pac";
readonly details: readonly Record<string, unknown>[];
};
export function branchFollowerDeliveryAuthority(
registry: BranchFollowerRegistry,
options: ParsedOptions,
): BranchFollowerDeliveryAuthority {
const followers = selectedFollowers(registry, options);
const rows = followers.map(resolveFollowerAuthority);
if (rows.every((row) => row.kind === "retired-readonly")) {
return {
kind: "retired-readonly",
mutationHintsAllowed: false,
followers: followers.map((follower) => follower.id),
pacAuthorities: rows.map((row) => row.pacAuthority).filter((item): item is Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }> => item !== null),
reason: "all-selected-followers-retired-to-pac",
};
}
if (rows.every((row) => row.kind === "legacy-manual")) {
return {
kind: "legacy-manual",
mutationHintsAllowed: true,
followers: followers.map((follower) => follower.id),
reason: "all-selected-followers-explicit-legacy-manual",
};
}
const reason = rows.some((row) => row.reason === "retired-pac-identity-invalid")
? "retired-pac-identity-invalid"
: rows.some((row) => row.reason === "legacy-conflicts-with-pac")
? "legacy-conflicts-with-pac"
: rows.some((row) => row.kind === "unknown")
? "authority-not-declared"
: "authority-mixed";
return {
kind: "unknown",
mutationHintsAllowed: false,
followers: followers.map((follower) => follower.id),
reason,
details: rows.map((row) => ({ follower: row.follower, kind: row.kind, reason: row.reason, authority: row.pacAuthority })),
};
}
export function branchFollowerMutationGuard(
registry: BranchFollowerRegistry,
options: ParsedOptions,
): Record<string, unknown> | null {
const deliveryMutation = options.action === "apply"
|| options.action === "run-once"
|| options.action === "job"
|| options.action === "gate"
|| (options.action === "debug-step" && options.debugStep === "state-write");
const retiredCleanup = options.action === "cleanup-state";
if (!deliveryMutation && !retiredCleanup) return null;
const authority = branchFollowerDeliveryAuthority(registry, options);
if (deliveryMutation && authority.kind === "legacy-manual" && options.scope === "legacy-cicd") return null;
if (retiredCleanup && authority.kind === "retired-readonly" && options.scope === "retired-maintenance") return null;
return {
ok: false,
action: options.action,
mutation: false,
mode: authority.kind === "retired-readonly"
? "retired-branch-follower-mutation-blocked"
: authority.kind === "legacy-manual"
? "legacy-branch-follower-scope-required"
: "branch-follower-delivery-authority-unknown",
reason: retiredCleanup
? "退役状态清理只允许通过 retired-maintenance scoped command;默认入口不修改控制器状态。"
: "该 branch-follower 已退役或 authority 无法唯一确认;CLI 不执行交付写操作。",
deliveryAuthority: authority,
next: branchFollowerReadOnlyNext(registry, options, authority),
valuesPrinted: false,
};
}
export function branchFollowerReadOnlyNext(
registry: BranchFollowerRegistry,
options: ParsedOptions,
authority = branchFollowerDeliveryAuthority(registry, options),
): Record<string, unknown> {
const suffix = options.followerId === null ? "" : ` --follower ${options.followerId}`;
const nodes = authority.kind === "retired-readonly"
? Array.from(new Set(authority.pacAuthorities.map((item) => item.consumer.node)))
: [];
const followerNext = {
status: `bun scripts/cli.ts cicd branch-follower status${suffix}`,
events: `bun scripts/cli.ts cicd branch-follower events${suffix}`,
logs: `bun scripts/cli.ts cicd branch-follower logs${suffix}`,
fixAutomaticDelivery: authority.kind === "unknown"
? pacAutomaticDeliveryFix(authorityFromUnknown(authority))
: { reference: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界", mutation: false },
valuesPrinted: false,
};
if (nodes.length !== 1) return followerNext;
const pacNext = pacNodeReadOnlyNext(nodes[0] as string);
return {
...followerNext,
pacStatus: pacNext.status,
pacHistory: pacNext.history,
};
}
function selectedFollowers(registry: BranchFollowerRegistry, options: ParsedOptions): FollowerSpec[] {
const selected = options.followerId === null
? registry.followers
: registry.followers.filter((follower) => follower.id === options.followerId);
if (selected.length === 0) throw new Error(`unknown follower ${options.followerId ?? "-"}`);
return selected;
}
function resolveFollowerAuthority(follower: FollowerSpec): {
follower: string;
kind: "retired-readonly" | "legacy-manual" | "unknown";
reason: string;
pacAuthority: Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }> | null;
} {
const pacAuthority = resolveCicdDeliveryAuthority({
node: follower.target.node,
lane: follower.target.lane,
sourceRepository: follower.source.repository,
sourceBranch: follower.source.branch,
});
if (follower.deliveryAuthority === "retired-readonly") {
return pacAuthority.kind === "pac-pr-merge"
? { follower: follower.id, kind: "retired-readonly", reason: "exact-pac-consumer", pacAuthority }
: { follower: follower.id, kind: "unknown", reason: "retired-pac-identity-invalid", pacAuthority: null };
}
if (follower.deliveryAuthority === "legacy-manual") {
return pacAuthority.kind === "pac-pr-merge"
? { follower: follower.id, kind: "unknown", reason: "legacy-conflicts-with-pac", pacAuthority }
: { follower: follower.id, kind: "legacy-manual", reason: "explicit-legacy-manual", pacAuthority: null };
}
return { follower: follower.id, kind: "unknown", reason: "authority-not-declared", pacAuthority: null };
}
function authorityFromUnknown(authority: Extract<BranchFollowerDeliveryAuthority, { kind: "unknown" }>): Extract<CicdDeliveryAuthority, { kind: "unknown" }> {
return {
kind: "unknown",
migrated: false,
mutationHintsAllowed: false,
consumer: null,
reason: "authority-identity-mismatch",
detail: authority.reason,
configRefs: ["config/cicd-branch-followers.yaml", "config/platform-infra/pipelines-as-code.yaml"],
valuesPrinted: false,
};
}
+116 -65
View File
@@ -2,7 +2,7 @@
// Responsibility: YAML-first K8s branch-follower controller, status, and adapter orchestration.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { isAbsolute } from "node:path";
import { isAbsolute, resolve } from "node:path";
import { repoRoot, rootPath, type UniDeskConfig } from "./config";
import { runCommand, type CommandResult } from "./command";
import { startJob } from "./jobs";
@@ -34,11 +34,16 @@ import { runBranchFollowerTaskRunDrillDown } from "./cicd-taskrun-drilldown";
import { runBranchFollowerJobDrillDown, runBranchFollowerRuntimeDrillDown } from "./cicd-job-runtime-drilldown";
import { runBranchFollowerGate } from "./cicd-gates";
import { buildCicdHelp } from "./cicd-help";
import {
branchFollowerDeliveryAuthority,
branchFollowerMutationGuard,
branchFollowerReadOnlyNext,
} from "./cicd-branch-follower-authority";
import { attachReconcileTimeline, compactReconcileTimeline, finishReconcileStep, finishReconcileTimeline, startReconcileStep, startReconcileTimeline } from "./cicd-reconcile-timeline";
import { orderFollowersForControllerCloseout, shouldYieldAfterAutomaticTrigger } from "./cicd-reconcile-scheduler";
import { buildFollowerTimings, compactListTimings, compactTimings, storedFollowerTimingsForStatus, timingPerformanceSummary } from "./cicd-timings";
import { timingAttributionSummary } from "./cicd-timing-attribution";
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerGate, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, TriggerResult } from "./cicd-types";
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerGate, BranchFollowerPhase, BranchFollowerRegistry, BranchFollowerScope, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, TriggerResult } from "./cicd-types";
import {
arrayField,
asRecord,
@@ -56,20 +61,32 @@ const DEFAULT_CONFIG_PATH = "config/cicd-branch-followers.yaml";
const SPEC_REF = "PJ2026-01060703";
const SPEC_VERSION = "draft-2026-07-03-p0-branch-follower";
export function cicdHelp(): unknown {
return buildCicdHelp(DEFAULT_CONFIG_PATH, `${SPEC_REF} ${SPEC_VERSION}`);
export function cicdHelp(scope: BranchFollowerScope = "default", configPath = DEFAULT_CONFIG_PATH): unknown {
let legacyConfigured = false;
if (scope === "legacy-cicd" && isCanonicalBranchFollowerConfigPath(configPath)) {
try {
legacyConfigured = readRegistry(configPath).followers.some((follower) => follower.deliveryAuthority === "legacy-manual");
} catch {
legacyConfigured = false;
}
}
return buildCicdHelp(configPath, `${SPEC_REF} ${SPEC_VERSION}`, scope, legacyConfigured);
}
export async function runCicdCommand(_config: UniDeskConfig | null, args: string[]): Promise<RenderedCliResult> {
const top = args[0];
if (top === undefined || isHelpToken(top)) return renderMachine("cicd", cicdHelp(), "json");
if (top !== "branch-follower") {
throw new Error("cicd usage: cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime|gate");
throw new Error("cicd usage: cicd branch-follower status|events|logs|taskrun|runtime|debug-step");
}
const options = parseOptions(args.slice(1));
const command = commandLabel(options);
if (options.action === "help") return renderMachine(command, cicdHelp(), "json");
if (options.action === "help") return renderMachine(command, cicdHelp(options.scope, options.configPath), "json");
const configBlocked = branchFollowerConfigMutationGuard(options);
if (configBlocked !== null) return renderMachine(command, configBlocked, options.output === "yaml" ? "yaml" : "json", false);
const registry = readRegistry(options.configPath);
const blocked = branchFollowerMutationGuard(registry, options);
if (blocked !== null) return renderMachine(command, blocked, options.output === "yaml" ? "yaml" : "json", false);
switch (options.action) {
case "plan":
return renderResult(command, buildPlan(registry, options), options);
@@ -96,21 +113,60 @@ export async function runCicdCommand(_config: UniDeskConfig | null, args: string
case "gate":
return renderResult(command, await runGate(registry, options), options);
case "help":
return renderMachine(command, cicdHelp(), "json");
return renderMachine(command, cicdHelp(options.scope, options.configPath), "json");
}
}
function branchFollowerConfigMutationGuard(options: ParsedOptions): Record<string, unknown> | null {
const mutation = options.action === "apply"
|| options.action === "run-once"
|| options.action === "job"
|| options.action === "gate"
|| options.action === "cleanup-state"
|| options.action === "debug-step" && options.debugStep === "state-write";
if (!mutation || isCanonicalBranchFollowerConfigPath(options.configPath)) return null;
return {
ok: false,
action: options.action,
mutation: false,
mode: "noncanonical-branch-follower-config-mutation-blocked",
reason: "branch-follower 写操作只信任仓库内固定 owning YAML;--config 仅用于只读诊断,不能声明或扩大 delivery authority。",
configPath: options.configPath,
owningConfigPath: DEFAULT_CONFIG_PATH,
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
fixAutomaticDelivery: { reference: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界", mutation: false },
valuesPrinted: false,
},
valuesPrinted: false,
};
}
function isCanonicalBranchFollowerConfigPath(configPath: string): boolean {
const absolute = isAbsolute(configPath) ? resolve(configPath) : resolve(rootPath(configPath));
return absolute === resolve(rootPath(DEFAULT_CONFIG_PATH));
}
function parseOptions(args: string[]): ParsedOptions {
const actionToken = args[0];
const firstToken = args[0];
const scope: BranchFollowerScope = firstToken === "legacy-cicd" || firstToken === "retired-maintenance" ? firstToken : "default";
const actionOffset = scope === "default" ? 0 : 1;
const actionToken = args[actionOffset];
if (actionToken === undefined || isHelpToken(actionToken)) {
return defaultOptions("help", args.slice(actionToken === undefined ? 0 : 1));
const options = defaultOptions("help", []);
options.scope = scope;
return options;
}
if (!["plan", "apply", "status", "run-once", "debug-step", "cleanup-state", "events", "logs", "taskrun", "job", "runtime", "gate"].includes(actionToken)) {
throw new Error(`cicd branch-follower unknown action: ${actionToken}`);
}
const action = actionToken as BranchFollowerAction;
if (scope === "default" && action === "plan") {
throw new Error("retired branch-follower default surface is read-only; use status/events/logs/taskrun/runtime/debug-step or PaC status/history");
}
const options = defaultOptions(action, []);
const rest = args.slice(1);
options.scope = scope;
const rest = args.slice(actionOffset + 1);
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index] ?? "";
if (isHelpToken(arg)) {
@@ -205,6 +261,9 @@ function parseOptions(args: string[]): ParsedOptions {
throw new Error(`${options.action} requires --follower <id>`);
}
if (options.action === "gate" && (options.followerId === null || options.gate === null)) throw new Error("gate requires --follower <id> --gate <name>");
if (options.scope === "retired-maintenance" && options.action !== "cleanup-state" && options.action !== "help") {
throw new Error("cicd branch-follower retired-maintenance only supports cleanup-state");
}
return options;
}
@@ -225,6 +284,7 @@ function isInClusterRuntime(): boolean {
function defaultOptions(action: BranchFollowerAction, _args: string[]): ParsedOptions {
return {
action,
scope: "default",
configPath: DEFAULT_CONFIG_PATH,
followerId: null,
all: false,
@@ -393,6 +453,7 @@ function parseFollower(root: Record<string, unknown>, index: number): FollowerSp
return {
id: simpleId(stringField(root, "id", label), `${label}.id`),
enabled: booleanField(root, "enabled", label),
deliveryAuthority: followerDeliveryAuthority(root.deliveryAuthority),
adapter: stringField(root, "adapter", label),
description: stringField(root, "description", label),
source: {
@@ -432,6 +493,10 @@ function parseFollower(root: Record<string, unknown>, index: number): FollowerSp
};
}
function followerDeliveryAuthority(value: unknown): FollowerSpec["deliveryAuthority"] {
return value === "retired-readonly" || value === "legacy-manual" ? value : "unknown";
}
function parseNativeStatus(root: Record<string, unknown>, label: string): NativeStatusSpec {
const source = recordField(root, "source", label);
const tekton = asOptionalRecord(root.tekton);
@@ -514,6 +579,7 @@ function stringMap(root: Record<string, unknown>, label: string): Record<string,
}
function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Record<string, unknown> {
const deliveryAuthority = branchFollowerDeliveryAuthority(registry, options);
const selected = selectFollowers(registry, options, { includeDisabled: true });
const followers = selected.map((follower) => {
const branchValue = safeResolveString(follower.source.branchRef);
@@ -528,6 +594,7 @@ function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Re
return {
id: follower.id,
enabled: follower.enabled,
deliveryAuthority: follower.deliveryAuthority,
adapter: follower.adapter,
description: follower.description,
source: {
@@ -570,14 +637,9 @@ function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Re
loop: registry.controller.loop,
budgets: registry.controller.budgets,
},
deliveryAuthority,
followers,
next: {
pacStatus: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
pacHistory: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
legacyApply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
legacyStatus: "bun scripts/cli.ts cicd branch-follower status",
legacyDryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
@@ -619,21 +681,21 @@ async function applyController(registry: BranchFollowerRegistry, options: Parsed
sourceMode: "k8s-git-mirror-to-emptyDir",
},
command: commandCompact(result, options),
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${registry.followers[0]?.id ?? "<id>"}`,
dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOptions): Promise<Record<string, unknown>> {
const deliveryAuthority = branchFollowerDeliveryAuthority(registry, options);
const legacyScoped = deliveryAuthority.kind === "legacy-manual" && options.scope === "legacy-cicd";
let k8s = readK8sState(registry, options);
const beforeRefreshStateByFollower = k8s.stateByFollower;
const wantsLive = options.live || (!options.noLive && Object.keys(k8s.stateByFollower).length === 0);
const refresh = wantsLive && !options.inCluster ? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true }) : null;
const refresh = wantsLive && !options.inCluster && legacyScoped
? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true })
: null;
if (refresh !== null) k8s = readK8sState(registry, options);
const shouldLive = wantsLive && options.inCluster;
const shouldLive = wantsLive && options.inCluster && legacyScoped;
const selected = selectFollowers(registry, options, { includeDisabled: true });
const followers = [];
const detailedFollowers = options.full;
@@ -647,21 +709,16 @@ async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOpti
ok: k8s.ok && followers.every((item) => item.ok !== false),
action: "status",
live: wantsLive,
liveMode: shouldLive ? "in-cluster-adapter-status" : refresh !== null ? "operator-status-refresh-job" : "stored-state",
liveMode: shouldLive ? "in-cluster-adapter-status" : refresh !== null ? "operator-status-refresh-job" : "stored-state-readonly",
liveRefresh: liveRefreshSummary(wantsLive, shouldLive, refresh),
registry: registrySummary(registry),
deliveryAuthority,
controller: controllerStatusSummary(registry, k8s),
followers,
refresh,
errors: k8s.errors,
warnings: k8s.warnings,
next: {
pacStatus: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
pacHistory: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
legacyApply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
legacyDryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
@@ -683,10 +740,7 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions)
job: refresh,
followers: selected.map((follower) => mergeFollowerStatus(registry, follower, k8s.stateByFollower[follower.id] ?? {}, null, false, false, before.stateByFollower[follower.id] ?? {})),
warnings: refresh.ok ? [] : [`reconcile job failed: ${refresh.message}`],
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
const selected = selectFollowers(registry, options, { includeDisabled: false });
@@ -740,10 +794,7 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions)
followers: results,
stateWrites,
warnings: stateWriteWarnings,
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
@@ -775,12 +826,7 @@ function cleanupState(registry: BranchFollowerRegistry, options: ParsedOptions):
parsedDownstreamCliOutput: false,
command: command === null ? null : commandCompact(command, options),
errors: before.ok ? [] : [before.error],
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
runOnce: options.followerId === null
? "bun scripts/cli.ts cicd branch-follower run-once --all --confirm --wait"
: `bun scripts/cli.ts cicd branch-follower run-once --follower ${options.followerId} --confirm --wait`,
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
@@ -800,8 +846,12 @@ 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.inCluster) {
const refresh = runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true });
const deliveryAuthority = branchFollowerDeliveryAuthority(registry, options);
const legacyScoped = deliveryAuthority.kind === "legacy-manual" && options.scope === "legacy-cicd";
if (!options.inCluster || !legacyScoped) {
const refresh = !options.inCluster && legacyScoped
? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true })
: null;
const k8s = readK8sState(registry, options);
const stored = k8s.stateByFollower[follower.id] ?? {};
const storedSource = asOptionalRecord(stored.source);
@@ -809,7 +859,7 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P
const command = asOptionalRecord(stored.command);
const native = asOptionalRecord(command?.payload);
return {
ok: refresh.ok && k8s.ok && Object.keys(stored).length > 0,
ok: (refresh === null || refresh.ok) && k8s.ok && Object.keys(stored).length > 0,
action: options.action,
follower: follower.id,
adapter: follower.adapter,
@@ -826,11 +876,7 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P
native,
refresh,
errors: k8s.errors,
next: {
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
liveStatus: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`,
runOnceDryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`,
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
const live = await readAdapterStatus(registry, follower, options);
@@ -850,10 +896,7 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P
message: live.message,
},
native: live.payload,
next: {
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
runOnceDryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`,
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
@@ -2015,7 +2058,7 @@ function mergeFollowerStatus(
evidence: detailed ? evidence : null,
reconcileTimeline: detailed ? reconcileTimeline : null,
rawStateDiagnostic: detailed ? asOptionalRecord(stored.rawStateDiagnostic) : null,
drilldown: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`,
drilldown: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
};
if (!detailed) return summary;
return {
@@ -2498,11 +2541,20 @@ function registrySummary(registry: BranchFollowerRegistry): Record<string, unkno
stateConfigMapName: registry.controller.stateConfigMapName,
leaseName: registry.controller.leaseName,
},
followers: registry.followers.map((item) => item.id),
followers: registry.followers.map((item) => ({ id: item.id, enabled: item.enabled, deliveryAuthority: item.deliveryAuthority })),
};
}
function redactCommands(follower: FollowerSpec): Record<string, string> {
if (follower.deliveryAuthority !== "legacy-manual") {
return {
plan: "retired-readonly",
status: "native:k8s-state+tekton+argocd+runtime",
trigger: "blocked-by-delivery-authority",
events: "native:k8s-readonly",
logs: "native:k8s-readonly",
};
}
return {
plan: follower.commands.plan.argv.join(" "),
status: "native:k8s-git-mirror+tekton+argocd+runtime",
@@ -2656,11 +2708,9 @@ function nativeGateTimingSummary(payload: Record<string, unknown> | null, timing
function followerNextCommands(follower: FollowerSpec): Record<string, string> {
const next: Record<string, string> = {
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
liveStatus: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`,
dryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`,
trigger: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --confirm --wait`,
events: `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`,
logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${follower.id}`,
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
};
if (follower.nativeStatus.tekton !== null) next.pipelineRuns = `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`;
if (follower.nativeStatus.argo !== null) next.argoApplication = `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`;
@@ -2804,8 +2854,9 @@ function tailText(text: string, maxChars: number): string {
}
function commandLabel(options: ParsedOptions): string {
if (options.taskRunName !== null) return "cicd branch-follower taskrun";
return `cicd branch-follower ${options.action}`;
const scope = options.scope === "default" ? "" : ` ${options.scope}`;
if (options.taskRunName !== null) return `cicd branch-follower${scope} taskrun`;
return `cicd branch-follower${scope} ${options.action}`;
}
function arrayRecords(value: unknown): Record<string, unknown>[] {
+3 -119
View File
@@ -5,9 +5,8 @@ import { existsSync, readFileSync } from "node:fs";
import { runCommand, type CommandResult } from "./command";
import { repoRoot, rootPath } from "./config";
import type { AdapterSummary, BranchFollowerDebugStep, BranchFollowerRegistry, FollowerSpec, FollowerState, K8sStateRead, ParsedOptions } from "./cicd-types";
import { renderControllerDebugJob, waitForJobShell } from "./cicd-controller-render";
import { taskRunItems } from "./cicd-taskruns";
import { redactText, shQuote } from "./platform-infra-ops-library";
import { redactText } from "./platform-infra-ops-library";
type KubeScriptRunner = (registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number) => CommandResult;
@@ -23,7 +22,6 @@ export interface CicdDebugDeps {
export async function buildDebugStep(registry: BranchFollowerRegistry, options: ParsedOptions, deps: CicdDebugDeps): Promise<Record<string, unknown>> {
const step = options.debugStep ?? "state-read";
if (options.followerId === null) throw new Error("debug-step requires --follower <id>");
if (!options.inCluster) return runTargetDebugStepJob(registry, options, step, deps);
const selected = deps.selectFollowers(registry, options, { includeDisabled: true });
if (selected.length !== 1) throw new Error("debug-step operates on exactly one follower");
@@ -53,7 +51,7 @@ export async function buildDebugStep(registry: BranchFollowerRegistry, options:
action: "debug-step",
step,
follower: follower.id,
execution: "k8s-native-in-cluster",
execution: options.inCluster ? "k8s-native-in-cluster" : "operator-readonly",
dryRun: !options.confirm,
stateBefore: stateSnapshot(before, follower.id),
stateWrite: { ok: false, skipped: true, reason: "stored-state-missing" },
@@ -74,7 +72,7 @@ export async function buildDebugStep(registry: BranchFollowerRegistry, options:
action: "debug-step",
step,
follower: follower.id,
execution: "k8s-native-in-cluster",
execution: options.inCluster ? "k8s-native-in-cluster" : "operator-readonly",
dryRun: !options.confirm,
stateBefore: stateSnapshot(before, follower.id),
controllerSource,
@@ -128,123 +126,10 @@ export function renderDebugStepHuman(payload: Record<string, unknown>): string {
`controller-source: ${next?.controllerSource ?? "-"}`,
`status-read: ${next?.statusRead ?? "-"}`,
`decide: ${next?.decide ?? "-"}`,
`state-write: ${next?.stateWrite ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
function runTargetDebugStepJob(registry: BranchFollowerRegistry, options: ParsedOptions, step: BranchFollowerDebugStep, deps: CicdDebugDeps): Record<string, unknown> {
const timeoutSeconds = options.timeoutSeconds ?? registry.controller.budgets.runOnceSeconds;
const jobName = `${registry.controller.deploymentName}-debug-${step}-${Date.now().toString(36)}`.replace(/[^a-z0-9-]+/gu, "-").slice(0, 63);
const manifest = renderControllerDebugJob(registry, options, jobName, step, timeoutSeconds);
const manifestYaml = `${Bun.YAML.stringify(manifest).trim()}\n`;
const script = [
"set -eu",
"tmp=$(mktemp)",
"base64 -d >\"$tmp\" <<'UNIDESK_CICD_DEBUG_JOB_B64'",
Buffer.from(manifestYaml, "utf8").toString("base64"),
"UNIDESK_CICD_DEBUG_JOB_B64",
`kubectl -n ${shQuote(registry.controller.namespace)} delete job ${shQuote(jobName)} --ignore-not-found=true >/dev/null 2>&1 || true`,
`kubectl apply --server-side --force-conflicts --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp" >/dev/null`,
waitForJobShell(registry.controller.namespace, jobName, timeoutSeconds),
].join("\n");
const result = deps.runKubeScript(registry, options, script, "", (timeoutSeconds + registry.controller.budgets.reconcileTransportGraceSeconds) * 1000);
const parsed = parseLastJsonObject(result.stdout);
const state = deps.readK8sState(registry, options);
const followerId = options.followerId ?? "";
const compact = compactTargetDebugResult(parsed);
const ok = result.exitCode === 0 && parsed?.ok !== false;
const includeTargetTail = !ok || parsed === null;
const fallbackStateAfter = stateSnapshot(state, followerId);
return {
ok,
action: "debug-step",
step,
follower: followerId,
execution: "k8s-native-debug-job",
dryRun: !options.confirm,
stateBefore: compact?.stateBefore ?? compactStateLike(asOptionalRecord(parsed?.stateBefore)),
controllerSource: compact?.controllerSource ?? asOptionalRecord(parsed?.controllerSource),
status: compact?.status ?? null,
decision: compact?.decision ?? null,
stateWrite: compact?.stateWrite ?? null,
target: {
name: jobName,
namespace: registry.controller.namespace,
exitCode: result.exitCode,
timedOut: result.timedOut,
parsed: parsed !== null,
stdoutTail: includeTargetTail ? redactText(tailText(result.stdout, 1000)) : "",
stderrTail: includeTargetTail ? redactText(tailText(result.stderr, 800)) : "",
},
stateAfter: compact?.stateAfter ?? compactStateLike(asOptionalRecord(parsed?.stateAfter) ?? fallbackStateAfter),
parsedDownstreamCliOutput: false,
next: debugNext(followerId),
};
}
function compactTargetDebugResult(parsed: Record<string, unknown> | null): Record<string, unknown> | null {
if (parsed === null) return null;
const stateBefore = asOptionalRecord(parsed.stateBefore);
const controllerSource = asOptionalRecord(parsed.controllerSource);
const status = asOptionalRecord(parsed.status);
const decision = asOptionalRecord(parsed.decision);
const stateWrite = asOptionalRecord(parsed.stateWrite);
const stateAfter = asOptionalRecord(parsed.stateAfter);
return {
ok: parsed.ok === true,
action: stringOrNull(parsed.action),
step: stringOrNull(parsed.step),
follower: stringOrNull(parsed.follower),
stateBefore: compactStateLike(stateBefore),
controllerSource: controllerSource === null ? null : {
ok: controllerSource.ok === true,
repository: stringOrNull(controllerSource.repository),
branch: stringOrNull(controllerSource.branch),
head: stringOrNull(controllerSource.head),
registrySha256: stringOrNull(controllerSource.registrySha256),
closeoutRereadMarker: controllerSource.closeoutRereadMarker === true,
branchFollowerFile: asOptionalRecord(controllerSource.branchFollowerFile),
errors: Array.isArray(controllerSource.errors) ? controllerSource.errors.map(String).slice(0, 5) : [],
},
status: status === null ? null : {
ok: status.ok === true,
phase: stringOrNull(status.phase),
observedSha: stringOrNull(status.observedSha),
targetSha: stringOrNull(status.targetSha),
aligned: status.aligned === true ? true : status.aligned === false ? false : null,
pipelineRun: stringOrNull(status.pipelineRun),
message: stringOrNull(status.message),
gates: asOptionalRecord(status.gates),
},
decision: decision === null ? null : {
phase: stringOrNull(decision.phase),
observedSha: stringOrNull(decision.observedSha),
targetSha: stringOrNull(decision.targetSha),
decision: stringOrNull(decision.decision),
totalSeconds: numberOrNull(asOptionalRecord(decision.timings)?.totalSeconds),
totalStatus: stringOrNull(asOptionalRecord(decision.timings)?.totalStatus),
},
stateWrite,
stateAfter: compactStateLike(stateAfter),
};
}
function compactStateLike(value: Record<string, unknown> | null): Record<string, unknown> | null {
if (value === null) return null;
const metadata = asOptionalRecord(value.metadata);
return {
ok: value.ok === true,
phase: stringOrNull(value.phase),
observedSha: stringOrNull(value.observedSha),
targetSha: stringOrNull(value.targetSha),
totalSeconds: numberOrNull(value.totalSeconds),
timingStatus: stringOrNull(value.timingStatus),
resourceVersion: stringOrNull(metadata?.resourceVersion),
rawStateDiagnostic: asOptionalRecord(value.rawStateDiagnostic),
};
}
function controllerSourceSnapshot(registry: BranchFollowerRegistry): Record<string, unknown> {
const head = commandText(["git", "rev-parse", "HEAD"]);
const branch = commandText(["git", "symbolic-ref", "--short", "HEAD"]);
@@ -573,7 +458,6 @@ function debugNext(followerId: string): Record<string, string> {
controllerSource: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step controller-source`,
statusRead: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step status-read`,
decide: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step decide`,
stateWrite: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step state-write --confirm`,
};
}
+577
View File
@@ -0,0 +1,577 @@
import { describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { runAgentRunCommand, agentRunHelpText } from "./agentrun/entry";
import { cicdHelp as branchFollowerHelp, runCicdCommand as runBranchFollowerCommand } from "./cicd-branch-follower";
import { branchFollowerReadOnlyNext } from "./cicd-branch-follower-authority";
import { cicdGiteaActionsPocHelp, runGiteaActionsPocCommand } from "./cicd-gitea-actions-poc";
import { unsupported as unsupportedAgentRunCommand } from "./agentrun/utils";
import {
PAC_AUTOMATIC_DELIVERY_REFERENCE,
decideCicdDeliveryMutation,
gitRepositoryIdentity,
pacReadOnlyNext,
readCicdDeliveryAuthorityCatalog,
resolveCicdDeliveryAuthority,
resolveCicdDeliveryAuthorityFromCatalog,
type CicdDeliveryAuthorityCatalog,
} from "./cicd-delivery-authority";
import { hwlabNodeHelp } from "./hwlab-node-help";
import { runHwlabNodeCommand } from "./hwlab-node/entry";
import {
guardedSentinelDeliveryAction,
runWebProbeSentinelCommand,
webProbeSentinelDeliveryAuthorityFromCatalog,
} from "./hwlab-node-web-sentinel-cicd";
import { parseNodeScopedDelegatedOptions } from "./hwlab-node/plan";
import { parseNodeWebProbeSentinelOptions } from "./hwlab-node/web-probe-observe";
import { nodeRuntimeVisiblePipelineRunDiagnostics, summarizeNodeRuntimeControlPlaneStatus } from "./hwlab-node/render";
import { renderControlPlaneResult, renderPublishCurrentResult } from "./hwlab-node-web-sentinel-cicd-shared";
import { resolvePacHistoryConsumerSelection, runPlatformInfraPipelinesAsCodeCommand } from "./platform-infra-pipelines-as-code";
import { giteaHelp, runPlatformInfraGiteaCommand } from "./platform-infra-gitea";
import { platformInfraHelp } from "./platform-infra/entry";
import {
authorizeSentinelPacInternalPublish,
readSentinelPacInternalPublishCapability,
type SentinelPacInternalPublishCapability,
} from "./sentinel-pac-execution-context";
import type { BranchFollowerRegistry, FollowerSpec, ParsedOptions } from "./cicd-types";
const mutationCommand = /\b(?:apply|closeout|trigger-current|refresh|sync|flush|webhook-test)\b/u;
const pacEvaluator = createRequire(import.meta.url)("../native/cicd/pac-status-evaluator.cjs") as {
classifyPacPipelineRun: (consumer: Record<string, unknown>, item: Record<string, unknown>) => Record<string, unknown>;
};
describe("YAML 组合的 CI/CD delivery authority", () => {
test("组合 PaC 与 Gitea YAML,覆盖所有实际 consumer 且 id 唯一", () => {
const catalog = readCicdDeliveryAuthorityCatalog();
expect(catalog.consumers).toHaveLength(8);
expect(new Set(catalog.consumers.map((item) => item.consumerId)).size).toBe(8);
for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host", "platform-infra-gitea-nc01"]) {
const authority = resolveCicdDeliveryAuthority({ consumerId });
expect(authority.kind).toBe("pac-pr-merge");
expect(authority.mutationHintsAllowed).toBe(false);
}
});
test("repository identity 从通用 Git remote 解析,不依赖仓库特例", () => {
expect(gitRepositoryIdentity("git@github.com:acme/widgets.git")).toBe("acme/widgets");
expect(gitRepositoryIdentity("https://example.test/acme/widgets.git")).toBe("acme/widgets");
expect(gitRepositoryIdentity("not-a-repository")).toBeNull();
});
test("未知、冲突和歧义 authority 均 fail-closed", () => {
expect(resolveCicdDeliveryAuthority({ node: "NC01", lane: "v03" })).toMatchObject({
kind: "unknown",
reason: "authority-ambiguous",
mutationHintsAllowed: false,
});
expect(resolveCicdDeliveryAuthority({ consumerId: "hwlab-nc01-v03", node: "JD01" })).toMatchObject({
kind: "unknown",
reason: "authority-identity-mismatch",
mutationHintsAllowed: false,
});
expect(resolveCicdDeliveryAuthority({ declaredSourceAuthorityMode: "giteaSnapshot", declaredConfigRef: "fixture.yaml" })).toMatchObject({
kind: "unknown",
reason: "authority-not-declared",
mutationHintsAllowed: false,
});
expect(resolveCicdDeliveryAuthority({
node: "NC01",
lane: "nc01-v02",
sourceRepository: "acme/drifted",
sourceBranch: "v0.2",
declaredSourceAuthorityMode: "gitMirrorSnapshot",
declaredConfigRef: "config/agentrun.yaml",
})).toMatchObject({
kind: "unknown",
reason: "authority-identity-mismatch",
mutationHintsAllowed: false,
});
});
test("只有显式 legacy-manual authority 允许旧 mutation 实现", () => {
const catalog: CicdDeliveryAuthorityCatalog = {
consumers: [{
consumerId: "arbitrary-pac",
node: "NODE-A",
lane: "lane-a",
namespace: "ci-a",
pipelineRunPrefix: "widgets-a-",
repositoryRef: "repo-a",
sourceRepository: "acme/widgets",
sourceBranch: "main",
sourceSnapshotPrefix: "refs/unidesk/snapshots/widgets/main",
giteaOwner: "mirrors",
giteaRepository: "acme-widgets",
giteaRepositoryUrl: "https://gitea.example.test/mirrors/acme-widgets",
pacControllerVersion: "v0.48.0",
configRefs: ["pac.yaml", "gitea.yaml"],
}],
configRefs: ["pac.yaml", "gitea.yaml"],
};
const legacy = resolveCicdDeliveryAuthorityFromCatalog(catalog, {
node: "NODE-B",
lane: "lane-b",
sourceRepository: "acme/legacy",
sourceBranch: "release",
declaredSourceAuthorityMode: "gitMirrorSnapshot",
declaredConfigRef: "legacy-lanes.yaml",
});
expect(legacy).toMatchObject({ kind: "legacy-manual", mutationHintsAllowed: true });
expect(decideCicdDeliveryMutation(legacy, {
command: "fixture trigger-current",
statusCommand: "fixture status",
action: "trigger-current",
node: "NODE-B",
lane: "lane-b",
})).toMatchObject({ allowed: true });
});
test("只读 Next 只有 status/history 和稳定自动链修复引用", () => {
const next = pacReadOnlyNext("NC01", "hwlab-nc01-v03");
expect(Object.keys(next).sort()).toEqual(["fixAutomaticDelivery", "history", "status", "valuesPrinted"]);
expect(JSON.stringify(next)).not.toMatch(mutationCommand);
expect(PAC_AUTOMATIC_DELIVERY_REFERENCE).toBe("docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界");
expect(PAC_AUTOMATIC_DELIVERY_REFERENCE).not.toContain("issues/");
});
});
describe("migrated CLI 执行 guard 与提示清理", () => {
test("AgentRun migrated trigger/refresh/mirror mutation 在远端调用前结构化拒绝", async () => {
const commands = [
["control-plane", "trigger-current", "--node", "NC01", "--lane", "nc01-v02", "--confirm"],
["control-plane", "refresh", "--node", "NC01", "--lane", "nc01-v02", "--confirm"],
["git-mirror", "sync", "--node", "NC01", "--lane", "nc01-v02", "--confirm", "--wait"],
["git-mirror", "flush", "--node", "NC01", "--lane", "nc01-v02", "--confirm", "--wait"],
];
for (const args of commands) {
const result = await runAgentRunCommand({} as never, args) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, mode: "pac-pr-merge-mutation-blocked", mutation: false });
expect(JSON.stringify(result.next)).not.toMatch(mutationCommand);
}
});
test("HWLAB migrated delivery mutation 在异步 job 创建前结构化拒绝", async () => {
const commands = [
["control-plane", "trigger-current", "--node", "NC01", "--lane", "v03", "--confirm"],
["control-plane", "refresh", "--node", "NC01", "--lane", "v03", "--confirm"],
["control-plane", "sync", "--node", "NC01", "--lane", "v03", "--confirm"],
["control-plane", "source-workspace", "sync", "--node", "NC01", "--lane", "v03", "--confirm"],
["git-mirror", "sync", "--node", "NC01", "--lane", "v03", "--confirm", "--wait"],
["git-mirror", "flush", "--node", "NC01", "--lane", "v03", "--confirm", "--wait"],
];
for (const args of commands) {
const result = await runHwlabNodeCommand({} as never, args) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, mode: "pac-pr-merge-mutation-blocked", mutation: false });
expect(JSON.stringify(result.next)).not.toMatch(mutationCommand);
}
});
test("Web sentinel migrated delivery mutation 在 state load 和异步 job 前拒绝", () => {
const spec = parseNodeScopedDelegatedOptions("control-plane", ["status", "--node", "NC01", "--lane", "v03"]).spec;
const sourceOverride = { sourceCommit: null, sourceStageRef: null, sourceMirrorCommit: null, sourceAuthority: null };
const commands = [
{ kind: "image", action: "build", node: "NC01", lane: "v03", sentinelId: null, dryRun: true, confirm: false, wait: false, timeoutSeconds: 30, sourceOverride },
{ kind: "control-plane", action: "trigger-current", node: "NC01", lane: "v03", sentinelId: null, dryRun: true, confirm: false, wait: false, timeoutSeconds: 30, rerun: false, manualRecovery: false, reason: null, sourceOverride },
{ kind: "publish", action: "publish-current", node: "NC01", lane: "v03", sentinelId: null, dryRun: true, confirm: false, wait: false, timeoutSeconds: 30, rerun: false, manualRecovery: true, reason: "explicit recovery", sourceOverride },
];
for (const options of commands) {
const result = runWebProbeSentinelCommand(spec, options as never) as unknown as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, mode: "pac-pr-merge-mutation-blocked", mutation: false });
expect(Object.keys(result.next as Record<string, unknown>).sort()).toEqual([
"controlPlaneStatus",
"fixAutomaticDelivery",
"history",
"pacHistory",
"pacStatus",
"status",
]);
expect(JSON.stringify(result.next)).not.toMatch(mutationCommand);
}
});
test("Web sentinel 稳定 PaC consumer 漂移或缺失时保持 unknown,不降级为 legacy", () => {
const spec = parseNodeScopedDelegatedOptions("control-plane", ["status", "--node", "NC01", "--lane", "v03"]).spec;
const catalog = readCicdDeliveryAuthorityCatalog();
const consumerId = "sentinel-nc01-v03";
const drifted: CicdDeliveryAuthorityCatalog = {
...catalog,
consumers: catalog.consumers.map((consumer) => consumer.consumerId === consumerId
? { ...consumer, node: "DRIFTED" }
: consumer),
};
const missing: CicdDeliveryAuthorityCatalog = {
...catalog,
consumers: catalog.consumers.filter((consumer) => consumer.consumerId !== consumerId),
};
for (const fixture of [drifted, missing]) {
const authority = webProbeSentinelDeliveryAuthorityFromCatalog(spec, fixture);
expect(authority).toMatchObject({ kind: "unknown", mutationHintsAllowed: false });
const decision = decideCicdDeliveryMutation(authority, {
command: "web-probe sentinel publish-current",
statusCommand: "web-probe sentinel control-plane status",
action: "sentinel-publish-current",
node: spec.nodeId,
lane: spec.lane,
});
expect(decision).toMatchObject({ allowed: false });
if (!decision.allowed) expect(JSON.stringify(decision.result.next)).not.toMatch(mutationCommand);
}
});
test("Web sentinel 内部 publish capability 默认关闭,启用后仍因 admission provenance 缺失而拒绝", () => {
const authority = resolveCicdDeliveryAuthority({ consumerId: "sentinel-nc01-v03" });
expect(authority.kind).toBe("pac-pr-merge");
const options = {
kind: "publish",
action: "publish-current",
node: "NC01",
lane: "v03",
sentinelId: "nc01-web-probe-sentinel",
dryRun: false,
confirm: true,
wait: true,
timeoutSeconds: 180,
rerun: false,
manualRecovery: false,
reason: null,
internalExecution: "pac-controller",
sourceOverride: { sourceCommit: "a".repeat(40), sourceStageRef: null, sourceMirrorCommit: null, sourceAuthority: "gitea-snapshot" },
} as const;
const capability = readSentinelPacInternalPublishCapability();
expect(capability).toMatchObject({ enabled: false, admissionProvenance: "unavailable" });
expect(authorizeSentinelPacInternalPublish(options, authority, capability)).toMatchObject({
allowed: false,
reason: "capability-disabled",
});
const enabled: SentinelPacInternalPublishCapability = { ...capability, enabled: true };
expect(authorizeSentinelPacInternalPublish(options, authority, enabled)).toMatchObject({
allowed: false,
reason: "admission-provenance-unavailable",
});
expect(authorizeSentinelPacInternalPublish({ ...options, internalExecution: null }, authority, enabled)).toMatchObject({
allowed: false,
reason: "operator-entrypoint",
});
});
test("Web sentinel 保持旧自动入口,不把 default SA、Pod env 或可写 metadata 当 creator proof", () => {
const pacYaml = readFileSync("config/platform-infra/pipelines-as-code.yaml", "utf8");
expect(pacYaml).toContain("sentinelInternalPublish:");
expect(pacYaml).toContain("enabled: false");
expect(pacYaml).toContain("issues/1769");
expect(pacYaml).toContain("service_account: default");
for (const node of ["jd01", "nc01"] as const) {
const source = readFileSync(`.tekton/web-probe-sentinel-${node}-pac.yaml`, "utf8");
expect(source).toContain("serviceAccountName: default");
expect(source).toContain("web-probe sentinel publish-current \\");
expect(source).not.toContain("pac-publish-current");
expect(source).not.toContain("UNIDESK_PAC_INTERNAL_ENTRYPOINT");
expect(source).not.toContain("UNIDESK_PAC_POD_UID");
}
const parsed = parseNodeWebProbeSentinelOptions([
"pac-publish-current",
"--node", "NC01",
"--lane", "v03",
"--sentinel", "nc01-web-probe-sentinel",
"--confirm",
"--wait",
"--source-commit", "a".repeat(40),
"--source-stage-ref", `refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01/${"a".repeat(40)}`,
"--source-authority", "gitea-snapshot",
]);
expect(parsed.sentinel).toMatchObject({ kind: "publish", internalExecution: "pac-controller", manualRecovery: false });
});
test("PaC 外层事件与内层 deterministic publish 分开归类,只有外层可驱动 delivery", () => {
const consumer = { repository: "sentinel-nc01-v03", pipelineRunPrefix: "hwlab-web-probe-sentinel-nc01-", pipeline: "hwlab-web-probe-sentinel-nc01" };
const outer = {
metadata: {
name: "hwlab-web-probe-sentinel-nc01-9h49j",
labels: {
"app.kubernetes.io/managed-by": "pipelinesascode.tekton.dev",
"pipelinesascode.tekton.dev/event-type": "push",
"pipelinesascode.tekton.dev/repository": "sentinel-nc01-v03",
},
annotations: { "pipelinesascode.tekton.dev/git-provider": "gitea" },
},
};
const inner = {
metadata: {
name: "hwlab-web-probe-sentinel-nc01-web-probe-sentinel-b125fb358d46",
labels: {
"app.kubernetes.io/part-of": "hwlab-web-probe-sentinel",
"unidesk.ai/ci-system": "tekton",
},
annotations: {
"unidesk.ai/source-authority": "gitea-snapshot",
"unidesk.ai/publish-gitops": "true",
},
},
};
expect(pacEvaluator.classifyPacPipelineRun(consumer, outer)).toMatchObject({
deliveryClass: "outer-pac-event",
deliveryAuthorityEligible: true,
admissionProvenanceVerified: false,
metadataObservationOnly: true,
});
expect(pacEvaluator.classifyPacPipelineRun(consumer, inner)).toMatchObject({
deliveryClass: "inner-deterministic-publish",
deliveryAuthorityEligible: false,
parentPipelineRun: null,
parentRelation: "unproven",
});
const catalog = readCicdDeliveryAuthorityCatalog();
for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host"]) {
const configured = catalog.consumers.find((item) => item.consumerId === consumerId);
if (configured === undefined) throw new Error(`missing classification fixture ${consumerId}`);
const classified = pacEvaluator.classifyPacPipelineRun({
repository: configured.repositoryRef,
pipelineRunPrefix: configured.pipelineRunPrefix,
}, {
metadata: {
name: `${configured.pipelineRunPrefix}-outer-fixture`,
labels: {
"app.kubernetes.io/managed-by": "pipelinesascode.tekton.dev",
"pipelinesascode.tekton.dev/event-type": "push",
"pipelinesascode.tekton.dev/repository": configured.repositoryRef,
},
annotations: { "pipelinesascode.tekton.dev/git-provider": "gitea" },
},
});
expect(classified).toMatchObject({ deliveryClass: "outer-pac-event", deliveryAuthorityEligible: true });
}
});
test("Web sentinel 业务观测和仪表盘动作不被 delivery guard 误阻断", () => {
for (const options of [
{ kind: "dashboard", action: "trigger" },
{ kind: "validate", action: "validate" },
{ kind: "maintenance", action: "status" },
{ kind: "control-plane", action: "status" },
{ kind: "image", action: "status" },
]) {
expect(guardedSentinelDeliveryAction(options as never)).toBeNull();
}
});
test("HWLAB degraded summary 不再返回 flush 或 triggerCurrent", () => {
const scoped = parseNodeScopedDelegatedOptions("control-plane", ["status", "--node", "NC01", "--lane", "v03"]);
const summary = summarizeNodeRuntimeControlPlaneStatus({ ok: false, degradedReason: "git-mirror-pending-flush" }, scoped);
expect(summary.nextAction).toBe("bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01 --consumer hwlab-nc01-v03");
expect(summary.next).not.toHaveProperty("triggerCurrent");
expect(summary.next).not.toHaveProperty("gitMirrorFlush");
expect(JSON.stringify(summary.next)).not.toMatch(mutationCommand);
});
test("HWLAB migrated PipelineRun diagnostics 不泄漏 rerun Next 或文本提示", () => {
const authority = resolveCicdDeliveryAuthority({ consumerId: "hwlab-nc01-v03" });
const diagnostics = nodeRuntimeVisiblePipelineRunDiagnostics({
failureSummary: { nextAction: "choose controlled rerun" },
next: { rerun: "bun scripts/cli.ts hwlab nodes control-plane trigger-current --confirm --rerun" },
}, authority);
expect(diagnostics?.next).toBeNull();
expect(JSON.stringify(diagnostics)).not.toContain("trigger-current");
expect(JSON.stringify(diagnostics)).not.toContain("controlled rerun");
expect(JSON.stringify(diagnostics)).toContain("不得人工 rerun");
});
test("默认 help 只暴露观察入口,legacy/platform 写入口只在 scoped help", async () => {
const agentRunDefault = agentRunHelpText(["control-plane", "--help"]);
const hwlabDefault = JSON.stringify(hwlabNodeHelp());
const pacDefault = JSON.stringify(await runPlatformInfraPipelinesAsCodeCommand({} as never, ["help"]));
const giteaDefault = JSON.stringify(giteaHelp());
expect(agentRunDefault).not.toMatch(mutationCommand);
expect(hwlabDefault).not.toMatch(mutationCommand);
expect(pacDefault).not.toMatch(mutationCommand);
expect(giteaDefault).not.toMatch(mutationCommand);
expect(agentRunHelpText(["control-plane", "legacy-cicd", "--help"])).toContain("trigger-current");
expect(JSON.stringify(hwlabNodeHelp("legacy-cicd"))).toContain("git-mirror flush");
expect(JSON.stringify(await runPlatformInfraPipelinesAsCodeCommand({} as never, ["help", "platform-bootstrap"]))).toContain("pipelines-as-code apply");
expect(JSON.stringify(giteaHelp("platform-bootstrap"))).toContain("platform-infra gitea apply");
});
test("所有 PaC consumer 与 AgentRun/HWLAB plan 均只返回 authority-aware 只读 Next", async () => {
const results = [
await runAgentRunCommand({} as never, ["control-plane", "plan", "--node", "NC01", "--lane", "nc01-v02", "--raw"]),
await runHwlabNodeCommand({} as never, ["control-plane", "plan", "--node", "NC01", "--lane", "v03", "--raw"]),
...await Promise.all(readCicdDeliveryAuthorityCatalog().consumers.map((consumer) => (
runPlatformInfraPipelinesAsCodeCommand({} as never, ["plan", "--target", consumer.node, "--consumer", consumer.consumerId, "--raw"])
))),
] as Record<string, unknown>[];
for (const result of results) {
const next = result.next as Record<string, unknown>;
expect(Object.keys(next).sort()).toEqual(["fixAutomaticDelivery", "history", "status", "valuesPrinted"]);
expect(JSON.stringify(next)).not.toMatch(mutationCommand);
}
});
test("Gitea 与 mirror 默认 plan 只返回只读 Next", async () => {
const plans = [
await runPlatformInfraGiteaCommand({} as never, ["plan", "--target", "NC01", "--raw"]),
await runPlatformInfraGiteaCommand({} as never, ["mirror", "plan", "--target", "NC01", "--raw"]),
] as Record<string, unknown>[];
for (const plan of plans) {
expect(plan.mutation).toBe(false);
expect(JSON.stringify(plan.next)).not.toMatch(mutationCommand);
expect(JSON.stringify(plan.next)).toContain("status");
expect(JSON.stringify(plan.next)).toContain(PAC_AUTOMATIC_DELIVERY_REFERENCE);
}
});
test("PaC history detail 只按唯一 consumer 生成 Next,四类前缀无默认 consumer 回退", () => {
const ids = ["sentinel-nc01-v03", "unidesk-host", "agentrun-nc01-v02", "hwlab-nc01-v03"];
const consumers = readCicdDeliveryAuthorityCatalog().consumers.filter((consumer) => ids.includes(consumer.consumerId)).map((consumer) => ({
id: consumer.consumerId,
node: consumer.node,
pipelineRunPrefix: consumer.pipelineRunPrefix,
}));
for (const consumer of consumers) {
const detailId = `${consumer.pipelineRunPrefix}-fixture`;
expect(resolvePacHistoryConsumerSelection(consumers, "NC01", null, detailId)).toEqual({
selectedConsumerIds: [consumer.id],
nextConsumerId: consumer.id,
});
}
expect(() => resolvePacHistoryConsumerSelection(consumers, "NC01", null, "no-matching-pipelinerun")).toThrow("matched 0");
expect(() => resolvePacHistoryConsumerSelection([
{ id: "a", node: "NC01", pipelineRunPrefix: "overlap-" },
{ id: "b", node: "NC01", pipelineRunPrefix: "overlap-run" },
], "NC01", null, "overlap-run-1")).toThrow("matched 2");
expect(() => resolvePacHistoryConsumerSelection(consumers, "NC01", "hwlab-nc01-v03", "hwlab-web-probe-sentinel-nc01-fixture")).toThrow("belongs to sentinel-nc01-v03");
});
test("PaC、HWLAB scoped help 的真实 CLI 路由可达且默认入口不泄漏 delivery mutation", () => {
const pacDefault = runCliJson(["platform-infra", "pipelines-as-code", "--help"]);
const pacBootstrap = runCliJson(["platform-infra", "pipelines-as-code", "help", "platform-bootstrap"]);
const pacCompatibility = runCliJson(["platform-infra", "pipelines-as-code", "help", "compatibility-diagnostics"]);
const hwlabPlatform = runCliJson(["hwlab", "nodes", "control-plane", "platform-maintenance", "--help"]);
const hwlabLegacy = runCliJson(["hwlab", "nodes", "control-plane", "legacy-cicd", "--help"]);
expect(JSON.stringify(pacDefault.data)).not.toMatch(mutationCommand);
expect(JSON.stringify(pacBootstrap.data)).toContain("pipelines-as-code apply");
expect(JSON.stringify(pacCompatibility.data)).not.toMatch(/webhook-test|trigger-current|\bsync\b|\bflush\b|apply --/u);
expect((hwlabPlatform.data as Record<string, unknown>).command).toBe("hwlab nodes control-plane platform-maintenance");
expect((hwlabLegacy.data as Record<string, unknown>).command).toBe("hwlab nodes control-plane legacy-cicd");
});
test("PaC webhook-test 已从公开路由与远端执行器移除", async () => {
const result = await runPlatformInfraPipelinesAsCodeCommand({} as never, ["webhook-test", "--target", "NC01", "--confirm"]) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, error: "unsupported-platform-infra-pipelines-as-code-command" });
expect(JSON.stringify(result.help)).not.toContain("webhook-test");
expect(readFileSync("scripts/src/platform-infra-pipelines-as-code-remote.sh", "utf8")).not.toContain("webhook-test");
});
test("Gitea webhook-test 已从公开路由与远端执行器移除", async () => {
const result = await runPlatformInfraGiteaCommand({} as never, ["mirror", "webhook", "test", "--target", "NC01", "--confirm"]) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, error: "unsupported-platform-infra-gitea-mirror-webhook-command" });
expect(readFileSync("scripts/src/platform-infra-gitea-remote.sh", "utf8")).not.toContain("mirror-webhook-test");
});
test("Sentinel migrated 文本输出只渲染实际只读 status/history/fix,不打印空 mutation 标签", () => {
const next = {
status: "bun scripts/cli.ts web-probe sentinel control-plane status --node NC01 --lane v03",
history: "bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer sentinel-nc01-v03",
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
for (const renderedText of [
renderPublishCurrentResult({ command: "fixture publish", ok: false, next }),
renderControlPlaneResult({ command: "fixture status", ok: false, next }),
]) {
expect(renderedText).toContain("status:");
expect(renderedText).toContain("history:");
expect(renderedText).toContain("fix-automatic-delivery:");
expect(renderedText).not.toMatch(/pac-closeout:|manual-recovery:|trigger-current:|apply:|sync:|flush:/u);
}
});
test("retired branch-follower 写操作和默认 cleanup 在任何远端执行前结构化拒绝", async () => {
const commands = [
["branch-follower", "apply", "--confirm", "--json"],
["branch-follower", "run-once", "--follower", "hwlab-jd01-v03", "--confirm", "--wait", "--json"],
["branch-follower", "job", "--follower", "agentrun-jd01-v02", "--job", "image-build", "--json"],
["branch-follower", "gate", "--follower", "hwlab-jd01-v03", "--gate", "runtime-closeout", "--confirm", "--json"],
["branch-follower", "debug-step", "--follower", "web-probe-sentinel-master", "--step", "state-write", "--confirm", "--json"],
["branch-follower", "cleanup-state", "--all", "--confirm", "--json"],
];
for (const args of commands) {
const rendered = await runBranchFollowerCommand(null, args);
const payload = JSON.parse(rendered.renderedText) as Record<string, unknown>;
expect(payload).toMatchObject({ ok: false, mutation: false });
expect(JSON.stringify(payload.next)).not.toMatch(mutationCommand);
}
const forged = await runBranchFollowerCommand(null, ["branch-follower", "legacy-cicd", "apply", "--config", "/tmp/forged-legacy.yaml", "--confirm", "--json"]);
expect(JSON.parse(forged.renderedText)).toMatchObject({
ok: false,
mutation: false,
mode: "noncanonical-branch-follower-config-mutation-blocked",
});
expect(Object.keys((JSON.parse(forged.renderedText) as { next: Record<string, unknown> }).next).sort()).toEqual([
"fixAutomaticDelivery",
"status",
"valuesPrinted",
]);
await expect(runBranchFollowerCommand(null, ["branch-follower", "plan", "--json"])).rejects.toThrow("default surface is read-only");
});
test("branch-follower unknown 与多 node scope 不猜测 JD01 PaC 入口", () => {
const options = { followerId: null } as ParsedOptions;
const unknownRegistry = {
followers: [{
id: "unknown-fixture",
deliveryAuthority: "unknown",
source: { repository: "acme/unknown", branch: "main" },
target: { node: "NODE-X", lane: "lane-x" },
} as FollowerSpec],
} as BranchFollowerRegistry;
const unknownNext = branchFollowerReadOnlyNext(unknownRegistry, options);
expect(Object.keys(unknownNext).sort()).toEqual(["events", "fixAutomaticDelivery", "logs", "status", "valuesPrinted"]);
expect(JSON.stringify(unknownNext)).not.toContain("JD01");
const catalog = readCicdDeliveryAuthorityCatalog();
const consumers = ["hwlab-jd01-v03", "hwlab-nc01-v03"].map((id) => {
const consumer = catalog.consumers.find((item) => item.consumerId === id);
if (consumer === undefined) throw new Error(`missing fixture consumer ${id}`);
return {
id: `${id}-retired-follower`,
deliveryAuthority: "retired-readonly",
source: { repository: consumer.sourceRepository, branch: consumer.sourceBranch },
target: { node: consumer.node, lane: consumer.lane },
} as FollowerSpec;
});
const multiNodeNext = branchFollowerReadOnlyNext({ followers: consumers } as BranchFollowerRegistry, options);
expect(Object.keys(multiNodeNext).sort()).toEqual(["events", "fixAutomaticDelivery", "logs", "status", "valuesPrinted"]);
expect(JSON.stringify(multiNodeNext)).not.toContain("--target JD01");
});
test("retired branch-follower 与 Gitea Actions POC 默认帮助只读", async () => {
expect(JSON.stringify(branchFollowerHelp())).not.toMatch(mutationCommand);
expect(JSON.stringify(cicdGiteaActionsPocHelp())).not.toMatch(mutationCommand);
await expect(runGiteaActionsPocCommand(null, ["apply"])).rejects.toThrow("intentionally unavailable");
});
test("platform-infra 默认 delivery 帮助和 AgentRun 失败帮助不暴露人工推进", () => {
const platformHelp = platformInfraHelp() as { usage: string[] };
const deliveryUsage = platformHelp.usage.filter((line) => line.includes("gitea") || line.includes("pipelines-as-code"));
expect(JSON.stringify(deliveryUsage)).not.toMatch(mutationCommand);
expect(deliveryUsage).toContain("bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01");
expect(deliveryUsage).toContain("bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap");
const agentRunOperations = unsupportedAgentRunCommand(["unexpected"]).renderedText.split("Operations:")[1] ?? "";
expect(agentRunOperations).not.toMatch(mutationCommand);
const root = runCliJson(["--help"]);
expect(JSON.stringify(root)).not.toMatch(/platform-infra gitea apply|platform-infra gitea mirror sync/u);
});
});
function runCliJson(args: string[]): { ok: boolean; command: string; data: unknown } {
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
cwd: process.cwd(),
encoding: "utf8",
timeout: 15_000,
});
if (result.status !== 0) throw new Error(`CLI ${args.join(" ")} failed: ${result.stderr}`);
return JSON.parse(result.stdout) as { ok: boolean; command: string; data: unknown };
}
+388
View File
@@ -0,0 +1,388 @@
import { rootPath } from "./config";
import { readYamlRecord } from "./platform-infra-ops-library";
import { materializeYamlComposition } from "./yaml-composition";
const pacConfigPath = "config/platform-infra/pipelines-as-code.yaml";
const giteaConfigPath = "config/platform-infra/gitea.yaml";
export const PAC_AUTOMATIC_DELIVERY_REFERENCE = "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界";
export interface PacDeliveryConsumerAuthority {
readonly consumerId: string;
readonly node: string;
readonly lane: string;
readonly namespace: string;
readonly pipelineRunPrefix: string;
readonly repositoryRef: string;
readonly sourceRepository: string;
readonly sourceBranch: string;
readonly sourceSnapshotPrefix: string;
readonly giteaOwner: string;
readonly giteaRepository: string;
readonly giteaRepositoryUrl: string;
readonly pacControllerVersion: string;
readonly configRefs: readonly [string, string];
}
export interface CicdDeliveryAuthorityCatalog {
readonly consumers: readonly PacDeliveryConsumerAuthority[];
readonly configRefs: readonly [string, string];
}
export interface CicdDeliveryAuthorityQuery {
readonly consumerId?: string | null;
readonly node?: string | null;
readonly lane?: string | null;
readonly sourceRepository?: string | null;
readonly sourceBranch?: string | null;
readonly declaredSourceAuthorityMode?: string | null;
readonly declaredConfigRef?: string | null;
}
export interface CicdDeliveryMutationContext {
readonly command: string;
readonly statusCommand: string;
readonly action: string;
readonly node: string;
readonly lane: string;
}
export type CicdDeliveryMutationDecision =
| {
readonly allowed: true;
readonly authority: Extract<CicdDeliveryAuthority, { kind: "legacy-manual" }>;
}
| {
readonly allowed: false;
readonly authority: Exclude<CicdDeliveryAuthority, { kind: "legacy-manual" }>;
readonly result: Record<string, unknown>;
};
export type CicdDeliveryAuthority =
| {
readonly kind: "pac-pr-merge";
readonly migrated: true;
readonly mutationHintsAllowed: false;
readonly consumer: PacDeliveryConsumerAuthority;
readonly reason: "exact-pac-consumer";
readonly valuesPrinted: false;
}
| {
readonly kind: "legacy-manual";
readonly migrated: false;
readonly mutationHintsAllowed: true;
readonly consumer: null;
readonly reason: "explicit-legacy-source-authority";
readonly declaredSourceAuthorityMode: string;
readonly declaredConfigRef: string;
readonly valuesPrinted: false;
}
| {
readonly kind: "unknown";
readonly migrated: false;
readonly mutationHintsAllowed: false;
readonly consumer: null;
readonly reason: "authority-config-invalid" | "authority-identity-mismatch" | "authority-not-declared" | "authority-ambiguous";
readonly detail: string;
readonly configRefs: readonly string[];
readonly valuesPrinted: false;
};
interface PacRepositoryRow {
readonly id: string;
readonly owner: string;
readonly repo: string;
readonly sourceBranch: string;
readonly sourceSnapshotPrefix: string;
readonly url: string;
}
interface GiteaRepositoryRow {
readonly targetId: string;
readonly owner: string;
readonly repo: string;
readonly sourceRepository: string;
readonly sourceBranch: string;
}
const legacyAuthorityModes = new Set(["gitMirrorSnapshot", "git-mirror-snapshot", "k8s-git-mirror-snapshot"]);
export function readCicdDeliveryAuthorityCatalog(): CicdDeliveryAuthorityCatalog {
const pacRoot = composedYaml(pacConfigPath, "platform-infra-pipelines-as-code");
const giteaRoot = composedYaml(giteaConfigPath, "platform-infra-gitea");
const release = record(pacRoot.release, `${pacConfigPath}#release`);
const pacControllerVersion = requiredString(release.version, `${pacConfigPath}#release.version`);
const pacRepositories = recordArray(pacRoot.repositories, `${pacConfigPath}#repositories`).map((item, index) => {
const path = `${pacConfigPath}#repositories[${index}]`;
const params = record(item.params, `${path}.params`);
return {
id: requiredString(item.id ?? item.name, `${path}.id`),
owner: requiredString(item.owner, `${path}.owner`),
repo: requiredString(item.repo, `${path}.repo`),
sourceBranch: requiredString(params.source_branch, `${path}.params.source_branch`),
sourceSnapshotPrefix: requiredString(params.source_snapshot_prefix, `${path}.params.source_snapshot_prefix`),
url: requiredString(item.url, `${path}.url`),
} satisfies PacRepositoryRow;
});
const sourceAuthority = record(giteaRoot.sourceAuthority, `${giteaConfigPath}#sourceAuthority`);
const giteaRepositories = recordArray(sourceAuthority.repositories, `${giteaConfigPath}#sourceAuthority.repositories`).map((item, index) => {
const path = `${giteaConfigPath}#sourceAuthority.repositories[${index}]`;
const upstream = record(item.upstream, `${path}.upstream`);
const gitea = record(item.gitea, `${path}.gitea`);
return {
targetId: requiredString(item.targetId, `${path}.targetId`),
owner: requiredString(gitea.owner, `${path}.gitea.owner`),
repo: requiredString(gitea.name, `${path}.gitea.name`),
sourceRepository: requiredString(upstream.repository, `${path}.upstream.repository`),
sourceBranch: requiredString(upstream.branch, `${path}.upstream.branch`),
} satisfies GiteaRepositoryRow;
});
const consumers = recordArray(pacRoot.consumers, `${pacConfigPath}#consumers`).map((item, index) => {
const path = `${pacConfigPath}#consumers[${index}]`;
const consumerId = requiredString(item.id, `${path}.id`);
const node = requiredString(item.node, `${path}.node`);
const lane = requiredString(item.lane, `${path}.lane`);
const namespace = requiredString(item.namespace, `${path}.namespace`);
const pipelineRunPrefix = requiredString(item.pipelineRunPrefix, `${path}.pipelineRunPrefix`);
const repositoryRef = requiredString(item.repositoryRef, `${path}.repositoryRef`);
const pacRepository = exactlyOne(
pacRepositories.filter((candidate) => same(candidate.id, repositoryRef)),
`${path}.repositoryRef=${repositoryRef}`,
);
const authorityRepository = exactlyOne(
giteaRepositories.filter((candidate) => same(candidate.targetId, node)
&& same(candidate.owner, pacRepository.owner)
&& same(candidate.repo, pacRepository.repo)
&& same(candidate.sourceBranch, pacRepository.sourceBranch)),
`${path} source authority ${node}/${pacRepository.owner}/${pacRepository.repo}@${pacRepository.sourceBranch}`,
);
return {
consumerId,
node,
lane,
namespace,
pipelineRunPrefix,
repositoryRef,
sourceRepository: authorityRepository.sourceRepository,
sourceBranch: authorityRepository.sourceBranch,
sourceSnapshotPrefix: pacRepository.sourceSnapshotPrefix,
giteaOwner: pacRepository.owner,
giteaRepository: pacRepository.repo,
giteaRepositoryUrl: pacRepository.url,
pacControllerVersion,
configRefs: [pacConfigPath, giteaConfigPath],
} satisfies PacDeliveryConsumerAuthority;
});
const duplicateIds = consumers.filter((item, index) => consumers.findIndex((candidate) => same(candidate.consumerId, item.consumerId)) !== index);
if (duplicateIds.length > 0) throw new Error(`${pacConfigPath} contains duplicate consumer ids: ${duplicateIds.map((item) => item.consumerId).join(", ")}`);
return { consumers, configRefs: [pacConfigPath, giteaConfigPath] };
}
export function resolveCicdDeliveryAuthority(query: CicdDeliveryAuthorityQuery): CicdDeliveryAuthority {
try {
return resolveCicdDeliveryAuthorityFromCatalog(readCicdDeliveryAuthorityCatalog(), query);
} catch (error) {
return {
kind: "unknown",
migrated: false,
mutationHintsAllowed: false,
consumer: null,
reason: "authority-config-invalid",
detail: error instanceof Error ? error.message : String(error),
configRefs: [pacConfigPath, giteaConfigPath],
valuesPrinted: false,
};
}
}
export function resolveCicdDeliveryAuthorityFromCatalog(catalog: CicdDeliveryAuthorityCatalog, query: CicdDeliveryAuthorityQuery): CicdDeliveryAuthority {
const suppliedIdentity = [query.consumerId, query.node, query.lane, query.sourceRepository, query.sourceBranch]
.some((value) => normalized(value) !== null);
const candidates = suppliedIdentity ? catalog.consumers.filter((consumer) => matchesQuery(consumer, query)) : [];
const stableCandidates = stableIdentityCandidates(catalog, query);
if (candidates.length === 1) {
return {
kind: "pac-pr-merge",
migrated: true,
mutationHintsAllowed: false,
consumer: candidates[0],
reason: "exact-pac-consumer",
valuesPrinted: false,
};
}
if (candidates.length > 1) {
return unknownAuthority("authority-ambiguous", `identity matches multiple PaC consumers: ${candidates.map((item) => item.consumerId).join(", ")}`, catalog.configRefs);
}
if (suppliedIdentity && query.consumerId !== undefined && query.consumerId !== null) {
return unknownAuthority("authority-identity-mismatch", `consumer identity does not match ${query.consumerId}`, catalog.configRefs);
}
if (stableCandidates.length > 0) {
return unknownAuthority(
stableCandidates.length > 1 ? "authority-ambiguous" : "authority-identity-mismatch",
`stable PaC identity conflicts with source details: ${stableCandidates.map((item) => item.consumerId).join(", ")}`,
catalog.configRefs,
);
}
const declaredMode = normalized(query.declaredSourceAuthorityMode);
const declaredConfigRef = normalized(query.declaredConfigRef);
if (declaredMode !== null && legacyAuthorityModes.has(declaredMode) && declaredConfigRef !== null) {
return {
kind: "legacy-manual",
migrated: false,
mutationHintsAllowed: true,
consumer: null,
reason: "explicit-legacy-source-authority",
declaredSourceAuthorityMode: declaredMode,
declaredConfigRef,
valuesPrinted: false,
};
}
return unknownAuthority(
suppliedIdentity ? "authority-identity-mismatch" : "authority-not-declared",
suppliedIdentity ? "no exact PaC consumer or explicit legacy authority matched" : "delivery authority identity is not declared",
catalog.configRefs,
);
}
function stableIdentityCandidates(catalog: CicdDeliveryAuthorityCatalog, query: CicdDeliveryAuthorityQuery): readonly PacDeliveryConsumerAuthority[] {
const consumerId = normalized(query.consumerId);
if (consumerId !== null) return catalog.consumers.filter((consumer) => same(consumer.consumerId, consumerId));
const node = normalized(query.node);
const lane = normalized(query.lane);
if (node === null || lane === null) return [];
return catalog.consumers.filter((consumer) => same(consumer.node, node) && same(consumer.lane, lane));
}
export function decideCicdDeliveryMutation(authority: CicdDeliveryAuthority, context: CicdDeliveryMutationContext): CicdDeliveryMutationDecision {
if (authority.kind === "legacy-manual") return { allowed: true, authority };
const next = authority.kind === "pac-pr-merge"
? pacReadOnlyNext(authority.consumer.node, authority.consumer.consumerId)
: {
status: context.statusCommand,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
valuesPrinted: false,
};
return {
allowed: false,
authority,
result: {
ok: false,
command: context.command,
mode: authority.kind === "pac-pr-merge" ? "pac-pr-merge-mutation-blocked" : "delivery-authority-unknown",
mutation: false,
requestedAction: context.action,
node: context.node,
lane: context.lane,
reason: authority.kind === "pac-pr-merge"
? "该 lane 已迁移到 PaCGitHub PR merge 是唯一交付触发,CLI 不执行人工推进。"
: "无法从 owning YAML 唯一确认 delivery authorityCLI 已 fail-closed。",
deliveryAuthority: authority,
next,
valuesPrinted: false,
},
};
}
export function gitRepositoryIdentity(remoteUrl: string): string | null {
const value = remoteUrl.trim();
if (value.length === 0) return null;
const scp = value.match(/^[^@\s]+@[^:\s]+:([^\s?#]+?)(?:\.git)?$/u);
if (scp !== null) return normalizedRepositoryPath(scp[1]);
try {
const url = new URL(value);
return normalizedRepositoryPath(url.pathname);
} catch {
return normalizedRepositoryPath(value);
}
}
export function pacAutomaticDeliveryFix(authority: CicdDeliveryAuthority): Record<string, unknown> {
const configRefs = authority.kind === "pac-pr-merge" ? authority.consumer.configRefs : authority.kind === "unknown" ? authority.configRefs : [authority.declaredConfigRef];
return automaticDeliveryFix(configRefs, authority.kind === "unknown" ? "fix-delivery-authority" : "fix-automatic-delivery");
}
export function pacReadOnlyNext(targetId: string, consumerId: string): Record<string, unknown> {
return {
status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumerId}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --consumer ${consumerId}`,
fixAutomaticDelivery: automaticDeliveryFix([pacConfigPath, giteaConfigPath], "fix-automatic-delivery"),
valuesPrinted: false,
};
}
export function pacNodeReadOnlyNext(targetId: string): Record<string, unknown> {
return {
status: `bun scripts/cli.ts cicd status --node ${targetId}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --limit 10`,
fixAutomaticDelivery: automaticDeliveryFix([pacConfigPath, giteaConfigPath], "fix-automatic-delivery"),
valuesPrinted: false,
};
}
function automaticDeliveryFix(configRefs: readonly string[], code: "fix-automatic-delivery" | "fix-delivery-authority"): Record<string, unknown> {
return {
code,
reference: PAC_AUTOMATIC_DELIVERY_REFERENCE,
configRefs,
instruction: "修复 owning YAML、controller 或源码中的自动链缺陷,并以新的正常 GitHub PR merge 验收;不得人工补齐当前交付。",
mutation: false,
valuesPrinted: false,
};
}
function normalizedRepositoryPath(value: string): string | null {
const result = value.replace(/^\/+|\/+$/gu, "").replace(/\.git$/iu, "").trim();
if (!/^[^/\s]+\/[^/\s]+$/u.test(result)) return null;
return result;
}
function matchesQuery(consumer: PacDeliveryConsumerAuthority, query: CicdDeliveryAuthorityQuery): boolean {
return matches(consumer.consumerId, query.consumerId)
&& matches(consumer.node, query.node)
&& matches(consumer.lane, query.lane)
&& matches(consumer.sourceRepository, query.sourceRepository)
&& matches(consumer.sourceBranch, query.sourceBranch);
}
function matches(actual: string, expected: string | null | undefined): boolean {
const value = normalized(expected);
return value === null || same(actual, value);
}
function same(left: string, right: string): boolean {
return left.toLowerCase() === right.toLowerCase();
}
function normalized(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const result = value.trim();
return result.length === 0 ? null : result;
}
function unknownAuthority(reason: Extract<CicdDeliveryAuthority, { kind: "unknown" }>["reason"], detail: string, configRefs: readonly string[]): CicdDeliveryAuthority {
return { kind: "unknown", migrated: false, mutationHintsAllowed: false, consumer: null, reason, detail, configRefs, valuesPrinted: false };
}
function composedYaml(path: string, kind: string): Record<string, unknown> {
return materializeYamlComposition(readYamlRecord<Record<string, unknown>>(rootPath(...path.split("/")), kind), { label: path }).value;
}
function record(value: unknown, path: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function recordArray(value: unknown, path: string): Record<string, unknown>[] {
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
return value.map((item, index) => record(item, `${path}[${index}]`));
}
function requiredString(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} must be a non-empty string`);
return value.trim();
}
function exactlyOne<T>(items: T[], path: string): T {
if (items.length !== 1) throw new Error(`${path} must resolve exactly one row; got ${items.length}`);
return items[0];
}
+59 -21
View File
@@ -1,36 +1,74 @@
// SPEC: PJ2026-01060703 CI/CD branch follower help text.
// Responsibility: bounded CLI help payloads for the branch-follower entrypoint.
// Responsibility: bounded, authority-scoped CLI help for the retired branch-follower entrypoint.
import type { BranchFollowerScope } from "./cicd-types";
export function buildCicdHelp(configPath: string, spec: string): unknown {
export function buildCicdHelp(
configPath: string,
spec: string,
scope: BranchFollowerScope = "default",
legacyConfigured = false,
): unknown {
if (scope === "retired-maintenance") {
return {
command: "cicd branch-follower retired-maintenance cleanup-state",
scope,
mutation: "explicit-confirm-required",
usage: [
"bun scripts/cli.ts cicd branch-follower retired-maintenance cleanup-state --all --dry-run",
"bun scripts/cli.ts cicd branch-follower retired-maintenance cleanup-state --all --confirm",
],
boundary: "仅清理 owning YAML 已标记 retired-readonly 的历史 follower state;不得触发、恢复或补齐交付。",
config: configPath,
spec,
};
}
if (scope === "legacy-cicd") {
return {
command: "cicd branch-follower legacy-cicd",
scope,
configured: legacyConfigured,
mutation: legacyConfigured ? "explicit-confirm-required" : false,
usage: legacyConfigured
? [
"bun scripts/cli.ts cicd branch-follower legacy-cicd plan --follower <id>",
"bun scripts/cli.ts cicd branch-follower legacy-cicd apply --confirm --wait",
"bun scripts/cli.ts cicd branch-follower legacy-cicd run-once --follower <id> --confirm --wait",
"bun scripts/cli.ts cicd branch-follower legacy-cicd debug-step --follower <id> --step state-write --confirm",
"bun scripts/cli.ts cicd branch-follower legacy-cicd job --follower <id> --job <name>",
"bun scripts/cli.ts cicd branch-follower legacy-cicd gate --follower <id> --gate <name> --confirm",
]
: [],
boundary: legacyConfigured
? "仅 owning YAML 精确声明 legacy-manual 的 follower 可执行;PaC、retired-readonly 与 unknown 一律 fail-closed。"
: "当前 owning YAML 未配置 legacy-manual follower;本 scope 不提供写入口。",
config: configPath,
spec,
};
}
return {
command: "cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime|gate",
command: "cicd branch-follower status|events|logs|taskrun|runtime|debug-step",
deprecated: true,
mode: "retired-readonly-for-jd01-consumers",
mode: "retired-readonly",
replacement: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
issue: "https://github.com/pikasTech/unidesk/issues/1560",
output: "text by default; use --json, --raw, or -o json|yaml for machine output",
output: "默认输出文本;机器输出使用 --json、--raw 或 -o json|yaml",
usage: [
"bun scripts/cli.ts cicd branch-follower status",
"bun scripts/cli.ts cicd branch-follower status --live",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step controller-source",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step state-read",
"bun scripts/cli.ts cicd branch-follower events --follower agentrun-jd01-v02",
"bun scripts/cli.ts cicd branch-follower logs --follower web-probe-sentinel-master",
"bun scripts/cli.ts cicd branch-follower taskrun --follower hwlab-jd01-v03 --taskrun runtime-ready --logs-tail 120 --json",
"bun scripts/cli.ts cicd branch-follower job --follower agentrun-jd01-v02 --source-commit <sha> --job image-build --json",
"bun scripts/cli.ts cicd branch-follower gate --follower hwlab-jd01-v03 --gate control-plane-refresh --source-commit <sha> --confirm --json",
"bun scripts/cli.ts cicd branch-follower gate --follower hwlab-jd01-v03 --gate git-mirror-flush --source-commit <sha> --confirm --json",
"bun scripts/cli.ts cicd branch-follower gate --follower hwlab-jd01-v03 --gate runtime-closeout --source-commit <sha> --confirm --json",
"bun scripts/cli.ts cicd branch-follower gate --follower agentrun-jd01-v02 --gate reuse-plan --source-commit <sha> --json",
"bun scripts/cli.ts cicd branch-follower runtime --follower hwlab-jd01-v03 --json",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step controller-source",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step state-read",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step status-read",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step decide",
],
config: configPath,
spec,
description: "Retired branch-follower controller surface for the JD01 migrated consumers. Use PaC status/history for current closeout; branch-follower commands remain only for historical state and bounded migration diagnostics.",
currentCloseout: [
"bun scripts/cli.ts platform-infra gitea mirror status --target JD01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
],
description: "退役 branch-follower 的只读历史状态与有界下钻入口;当前交付只观察 PaC 自动链。",
next: {
status: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
history: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
},
};
}
+2 -2
View File
@@ -159,15 +159,15 @@ function missingPolicyPayload(action: string, follower: FollowerSpec, registry:
follower: follower.id,
adapter: follower.adapter,
degradedReason: "drilldown-policy-missing",
message: `follower ${follower.id} registry is missing drillDown policy; apply the current config before drill-down`,
message: `follower ${follower.id} registry is missing drillDown policy; inspect the owning YAML before drill-down`,
query,
policy: null,
result: null,
statusAuthority: options.inCluster ? "kubernetes-api-serviceaccount" : "target-node-kubectl-raw",
parsedDownstreamCliOutput: false,
next: {
apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
},
};
}
+3 -3
View File
@@ -14,7 +14,7 @@ interface NodeStatusOptions {
export function cicdNodeStatusHelp(): Record<string, unknown> {
return {
command: "cicd status --node <NODE>",
description: "Read a node-level CI/CD closeout summary for every Pipelines-as-Code consumer on the node.",
description: "只读汇总节点上每个 Pipelines-as-Code consumer 的自动交付状态。",
usage: [
"bun scripts/cli.ts cicd status --node NC01",
"bun scripts/cli.ts cicd status --node NC01 --full",
@@ -119,9 +119,9 @@ function renderNodeStatus(result: Record<string, unknown>, full: boolean): Rende
"",
] : []),
"NEXT",
` refresh: ${stringValue(record(result.next).status)}`,
` status: ${stringValue(record(result.next).status)}`,
` history: ${stringValue(record(result.next).history)}`,
` closeout: ${stringValue(record(result.next).closeout)}`,
` fix-automatic-delivery: ${stringValue(record(record(result.next).fixAutomaticDelivery).reference)}`,
];
return { ok: result.ok !== false, command: "cicd status", renderedText: lines.join("\n"), contentType: "text/plain" };
}
@@ -35,8 +35,9 @@ describe("PR 合并驱动的自动交付文档合同", () => {
test("状态与单步入口只读,自动链不通时不得人工恢复", () => {
expect(skill).toContain(
"status/history/events/logs/closeout/debug-step 可用于定位,但不得改变交付状态",
"status/history/events/logs/debug-step 可用于定位,但不得改变交付状态",
);
expect(skill).toContain("`closeout` 只属于显式 compatibility diagnostics");
expect(skill).toContain("PaC migrated consumer 禁止执行 `trigger-current`");
expect(skill).not.toContain("需要保留的旧命令只能标为 debug/test/recovery");
expect(reference).toContain("`closeout` 只保留为可选的只读历史/诊断兼容入口");
@@ -68,6 +69,31 @@ describe("PR 合并驱动的自动交付文档合同", () => {
expect(reference).toContain("不是合并后的 CI/CD 触发、同步、恢复或补跑入口");
});
test("delivery authority 由两份 YAML 组合,unknown 与 migrated 都禁止 mutation", () => {
for (const document of [skill, reference, platform]) {
expect(document).toContain("config/platform-infra/gitea.yaml");
expect(document).toContain("config/platform-infra/pipelines-as-code.yaml");
expect(document).toMatch(/unknown[\s\S]*mutation=false|unknown[\s\S]*`mutation=false`/);
expect(document).toMatch(/legacy-(?:manual|cicd)|legacy\/manual/);
}
});
test("durable inbox 只证明 accepted,同 delivery refs proof 才能 committed", () => {
for (const document of [skill, reference, platform]) {
expect(document).toContain("202 Accepted");
expect(document).toContain("accepted");
expect(document).toContain("processing");
expect(document).toContain("committed");
expect(document).toContain("failed");
expect(document).toContain("deliveryId");
expect(document).toMatch(/fsync[\s\S]*atomic (?:rename|write)/);
expect(document).toMatch(/immutable snapshot[\s\S]*authority branch|authority branch[\s\S]*immutable snapshot/);
expect(document).toMatch(/重新读取 refs|refs proof/);
expect(document).toMatch(/不是.*(?:source|源码).*第二份真相|不是业务 source\/ref 的第二份真相/);
expect(document).not.toMatch(/只有 HTTP 200|返回 HTTP 200 后.*committed/);
}
});
test("可调整预算继续归属 YAML,长期规则不固化当前数值", () => {
expect(skill).toContain("wall-clock 目标及预算只由 owning YAML 声明");
expect(skill).not.toContain("低于 2 分钟");
+14 -8
View File
@@ -64,9 +64,10 @@ function renderPlanHuman(payload: Record<string, unknown>): string {
"NEXT",
`pac-status: ${next?.pacStatus ?? "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01"}`,
`pac-history: ${next?.pacHistory ?? "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10"}`,
`legacy-apply: ${next?.legacyApply ?? "-"}`,
`legacy-status: ${next?.legacyStatus ?? next?.status ?? "-"}`,
`legacy-dry-run: ${next?.legacyDryRun ?? next?.dryRun ?? "-"}`,
`status: ${next?.status ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
`fix-automatic-delivery: ${asOptionalRecord(next?.fixAutomaticDelivery)?.reference ?? next?.fixAutomaticDelivery ?? "-"}`,
"",
].join("\n");
}
@@ -88,7 +89,8 @@ function renderApplyHuman(payload: Record<string, unknown>): string {
"",
"NEXT",
`status: ${next?.status ?? "-"}`,
`dry-run: ${next?.dryRun ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
@@ -148,8 +150,10 @@ function renderStatusHuman(payload: Record<string, unknown>, _options: ParsedOpt
"NEXT",
`pac-status: bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01`,
`pac-history: bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10`,
`legacy-live-status: ${next?.liveStatus ?? "-"}`,
`legacy-dry-run: ${next?.legacyDryRun ?? next?.dryRun ?? "-"}`,
`status: ${next?.status ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
`fix-automatic-delivery: ${asOptionalRecord(next?.fixAutomaticDelivery)?.reference ?? next?.fixAutomaticDelivery ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
@@ -205,7 +209,8 @@ function renderRunOnceHuman(payload: Record<string, unknown>): string {
"",
"NEXT",
`status: ${next?.status ?? "-"}`,
`live-status: ${next?.liveStatus ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
"",
].join("\n");
}
@@ -233,7 +238,8 @@ function renderCleanupStateHuman(payload: Record<string, unknown>): string {
"",
"NEXT",
`status: ${next?.status ?? "-"}`,
`run-once: ${next?.runOnce ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
+2 -2
View File
@@ -23,7 +23,7 @@ export async function runBranchFollowerTaskRunDrillDown(
follower: follower.id,
adapter: follower.adapter,
degradedReason: "drilldown-policy-missing",
message: `follower ${follower.id} registry is missing drillDown policy; apply the current config before TaskRun drill-down`,
message: `follower ${follower.id} registry is missing drillDown policy; inspect the owning YAML before TaskRun drill-down`,
query: {
taskRun,
pipelineRun: options.pipelineRunName,
@@ -35,8 +35,8 @@ export async function runBranchFollowerTaskRunDrillDown(
statusAuthority: options.inCluster ? "kubernetes-api-serviceaccount" : "target-node-kubectl-raw",
parsedDownstreamCliOutput: false,
next: {
apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
},
};
}
+4
View File
@@ -3,6 +3,8 @@
export type OutputMode = "human" | "json" | "yaml";
export type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "debug-step" | "cleanup-state" | "events" | "logs" | "taskrun" | "job" | "runtime" | "gate";
export type BranchFollowerScope = "default" | "legacy-cicd" | "retired-maintenance";
export type BranchFollowerDeclaredDeliveryAuthority = "retired-readonly" | "legacy-manual" | "unknown";
export type BranchFollowerDebugStep = "state-read" | "controller-source" | "status-read" | "decide" | "state-write";
export type BranchFollowerGate = "reuse-plan" | "ci-taskrun-plan" | "cd-rollout-plan" | "post-deploy-health" | "control-plane-refresh" | "git-mirror-flush" | "runtime-closeout";
export type BranchFollowerPhase =
@@ -19,6 +21,7 @@ export type BranchFollowerPhase =
export interface ParsedOptions {
action: BranchFollowerAction;
scope: BranchFollowerScope;
configPath: string;
followerId: string | null;
all: boolean;
@@ -54,6 +57,7 @@ export interface CommandSpec {
export interface FollowerSpec {
id: string;
enabled: boolean;
deliveryAuthority: BranchFollowerDeclaredDeliveryAuthority;
adapter: string;
description: string;
source: {
+2 -2
View File
@@ -13,7 +13,7 @@ export function cicdHelp(): unknown {
output: "text by default for subcommands; top-level help is json",
nodeStatus: {
primary: "bun scripts/cli.ts cicd status --node NC01",
note: "Use this for node-level CI/CD status. It aggregates all current Pipelines-as-Code consumers for the node, including AgentRun, HWLAB and Web sentinel.",
note: "用于只读汇总节点上的 AgentRun、HWLAB、Web sentinel 与 unidesk-host PaC consumer。",
},
migration: {
issue: "https://github.com/pikasTech/unidesk/issues/1560",
@@ -22,7 +22,7 @@ export function cicdHelp(): unknown {
primaryHistory: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
archived: "cicd gitea-actions-poc",
deprecated: "cicd branch-follower",
note: "JD01 CI/CD source/trigger authority is Gitea mirror -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime. Gitea Actions POC and branch-follower are historical or migration-only surfaces.",
note: "当前 source/trigger authority 是 GitHub PR merge -> Gitea mirror -> Pipelines-as-Code -> Tekton -> Argo/k8s runtimeGitea Actions POC branch-follower 仅保留只读历史观察。",
},
subcommands: [
cicdNodeStatusHelp(),
+19 -2
View File
@@ -63,7 +63,7 @@ export function rootHelp(): unknown {
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from origin/master:deploy.json environments; --commit overrides one reviewed artifact consumer such as frontend for release/v1 validation or rollback. code-queue artifact consumption is dev-only." },
{ command: "cicd status --node <NODE>", description: "Show one node's CI/CD closeout summary across current Pipelines-as-Code consumers, including PipelineRun, Argo and runtime readiness." },
{ command: "cicd gitea-actions-poc plan|status", description: "Archived read-only CI/CD migration evidence for GH-1548/GH-1549; never use as a delivery or closeout path." },
{ command: "cicd branch-follower status|events|logs", description: "Archived migration diagnostics only; PaC-migrated NC01 lane must use cicd status and platform-infra pipelines-as-code closeout/status/history." },
{ command: "cicd branch-follower status|events|logs|taskrun|runtime|debug-step", description: "退役 branch-follower 的只读历史诊断;当前 lane 只使用 cicd status platform-infra pipelines-as-code status/history 观察自动链。" },
{ command: "dev-env validate|prewarm-images|worktree add", description: "Validate D601 guardrails, prewarm dev images, or create UniDesk task worktrees with .worktreecopy local config copying." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
{ command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." },
@@ -777,6 +777,8 @@ function platformInfraHelpSummary(): unknown {
"bun scripts/cli.ts platform-infra gitea validate --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror status --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --limit 10",
],
description: "Operate platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, Web Terminal, WeChat archive workflows, the YAML-controlled Codex pool, and internal Gitea for the GH-1548/GH-1549 CI/CD migration.",
};
@@ -900,7 +902,18 @@ export async function staticNamespaceHelp(args: string[]): Promise<unknown | nul
if (top === "gh") return ghScopedHelp(args.slice(1)) ?? ghHelp();
if (top === "cicd") return loadHelp(async () => (await import("./cicd")).cicdHelp(), cicdHelpSummary());
if (top === "agentrun") return loadHelp(async () => (await import("./agentrun")).agentRunHelp(), agentRunHelpSummary());
if (top === "platform-infra" && (sub === "pipelines-as-code" || sub === "pac") && args[2] === "source-artifact") return null;
if (top === "platform-infra" && (sub === "pipelines-as-code" || sub === "pac")) {
return loadHelp(
async () => (await import("./platform-infra-pipelines-as-code")).runPlatformInfraPipelinesAsCodeCommand({} as never, args.slice(2)),
platformInfraHelpSummary(),
);
}
if (top === "platform-infra" && sub === "gitea") {
return loadHelp(
async () => (await import("./platform-infra-gitea")).runPlatformInfraGiteaCommand({} as never, args.slice(2)),
platformInfraHelpSummary(),
);
}
if (top === "platform-infra") return loadHelp(async () => (await import("./platform-infra")).platformInfraHelp(), platformInfraHelpSummary());
if (top === "platform-db") return platformDbHelp();
if (top === "secrets") return secretsHelp();
@@ -909,6 +922,10 @@ export async function staticNamespaceHelp(args: string[]): Promise<unknown | nul
if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "control-plane" && args[3] === "infra") {
return loadHelp(async () => (await import("./hwlab-node-control-plane")).hwlabNodeControlPlaneInfraHelp(), hwlabNodeHelpSummary());
}
if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "control-plane" && (args[3] === "platform-maintenance" || args[3] === "legacy-cicd")) {
const scope = args[3];
return loadHelp(async () => (await import("./hwlab-node-help")).hwlabNodeHelp(scope), hwlabNodeHelpSummary());
}
if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "test-accounts") {
return loadHelp(async () => (await import("./hwlab-test-accounts")).hwlabTestAccountsHelp(), hwlabNodeHelpSummary());
}
+41 -11
View File
@@ -3,7 +3,38 @@
// Responsibility: Help payloads for HWLAB node/lane and web-probe CLI entries.
import { hwlabRuntimeLaneConfigPath } from "./hwlab-node-lanes";
export function hwlabNodeHelp(): Record<string, unknown> {
export function hwlabNodeHelp(scope: "legacy-cicd" | "platform-maintenance" | null = null): Record<string, unknown> {
if (scope === "legacy-cicd") {
return {
ok: true,
command: "hwlab nodes control-plane legacy-cicd",
description: "仅用于 owning YAML 明确解析为 legacy-manual 的旧交付入口。PaC 或 unknown authority 会 fail-closed。",
examples: [
"bun scripts/cli.ts hwlab nodes control-plane trigger-current --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane refresh --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane sync --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes git-mirror sync --node <node> --lane <lane> --confirm --wait",
"bun scripts/cli.ts hwlab nodes git-mirror flush --node <node> --lane <lane> --confirm --wait",
],
valuesPrinted: false,
};
}
if (scope === "platform-maintenance") {
return {
ok: true,
command: "hwlab nodes control-plane platform-maintenance",
description: "YAML 声明的平台配置、Secret 与容量维护;不得作为 PaC 源码交付的补偿或恢复入口。",
examples: [
"bun scripts/cli.ts hwlab nodes control-plane apply --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane public-exposure --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-runs --node <node> --lane <lane> --min-age-minutes 30 --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-released-pvs --node <node> --lane <lane> --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes secret status --node <node> --lane <lane> --name <secret>",
],
valuesPrinted: false,
};
}
return {
ok: true,
command: "hwlab nodes",
@@ -13,12 +44,8 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"bun scripts/cli.ts hwlab nodes control-plane infra plan --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes control-plane status --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes control-plane status --node <node> --lane <lane> --json",
"bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node JD01 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-runs --node JD01 --lane v03 --min-age-minutes 30 --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-released-pvs --node JD01 --lane v03 --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-legacy-docker-images --node JD01 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-legacy-docker-registry-volume --node JD01 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes git-mirror status --node <node> --lane <lane>",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target <node> --consumer <consumer>",
"bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node <node> --lane <lane> --dry-run",
"bun scripts/cli.ts hwlab nodes hwpod-node plan --node G14-WSL --lane v03",
"bun scripts/cli.ts hwlab nodes fake-model-provider plan --node D518 --lane v03 --provider fake-echo",
@@ -26,14 +53,16 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"bun scripts/cli.ts hwlab nodes test-accounts status --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes observability performance-summary --node <node> --lane <lane>",
"bun scripts/cli.ts web-probe --help",
"bun scripts/cli.ts hwlab nodes control-plane platform-maintenance --help",
"bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help",
],
actions: {
"control-plane": "YAML-first node-local CI/CD, git-mirror, source-workspace sync, public exposure, runtime-image, Argo, PipelineRun and CI workspace retention operations.",
"control-plane": "YAML-first node-local CI/CD status, public exposure, runtime-image, Argo, PipelineRun and CI workspace retention domains; delivery writes are documented only by scoped help.",
"git-mirror": "Inspect or operate the selected node/lane source mirror.",
"hwpod-preinstall": "Render YAML-first HWPOD preinstall configRefs, runtime mount targets, PM MDTODO source, and gateway profile status.",
"hwpod-node": "通过 YAML 和 trans 部署、查询 Windows 原生 Python HWPOD 节点。",
"fake-model-provider": "Materialize and operate YAML-declared fake Responses model providers for HWLAB/AgentRun sentinel checks.",
secret: "Inspect and sync YAML-declared runtime Secrets without printing secret values.",
secret: "Inspect YAML-declared runtime Secrets without printing secret values; writes are documented only by platform-maintenance help.",
"test-accounts": "Prepare YAML-declared HWLAB admin/test account API keys with redacted sourceRef/fingerprint output.",
observability: "Read runtime metrics and authenticated Web Performance summaries.",
},
@@ -43,7 +72,8 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"control-plane cleanup-runs deletes terminal PipelineRuns; cleanup-released-pvs deletes only orphaned hwlab-ci PipelineRun PVCs with no active pod mounts and Released local-path/Delete PVs.",
"cleanup-legacy-docker-images is a transitional legacy-cache GC for Docker images matching YAML allowlisted repositories; it protects container-referenced images, does not run prune, and does not touch Docker volumes.",
"cleanup-legacy-docker-registry-volume removes only an exited legacy Docker registry container and its unique /var/lib/registry volume after the YAML-declared k8s node-local-registry is ready.",
"`trigger-current --confirm --wait` is the one-command CICD path for current node/lane runtime publish.",
"PaC consumer 只由 GitHub PR merge 触发;默认帮助只给只读 status/history,自动链故障必须修 owning YAML、controller 或源码。",
"旧交付与平台维护写入口分别只在 legacy-cicd、platform-maintenance scoped help 中展示。",
"control-plane status reads its total budget, heartbeat cadence, termination grace period, and probe switches from config/hwlab-node-control-plane.yaml; --json keeps stdout as one compact JSON document while progress remains on stderr.",
],
};
@@ -86,8 +116,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe observe gc --node JD01 --lane v03 --keep-hours 24 --confirm",
"bun scripts/cli.ts web-probe sentinel plan --node <node> --lane <lane> --dry-run",
"bun scripts/cli.ts web-probe sentinel plan --node <node> --lane <lane> --sentinel workbench-auth-session-switch-2users",
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer sentinel-jd01-v03 --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer sentinel-jd01-v03",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer sentinel-jd01-v03 --limit 10",
"bun scripts/cli.ts web-probe sentinel dashboard verify --node <node> --lane <lane> --sentinel workbench-dsflash-go-tool-call-10x",
"bun scripts/cli.ts web-probe sentinel dashboard screenshot --node <node> --lane <lane> --sentinel workbench-auth-session-switch-2users",
"bun scripts/cli.ts web-probe sentinel dashboard trigger --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel",
@@ -103,7 +133,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
script: "Run caller-provided Playwright JS after CLI-managed /auth/login; scripts must not handle secrets themselves.",
screenshot: "Capture a page through the selected YAML semantic origin and node/lane remote browser, then download PNG artifacts to the caller /tmp by default.",
observe: "Start, inspect, control, stop, collect, analyze, and garbage-collect raw artifacts for long-running observers.",
sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, dashboard verification, maintenance and report views; migrated JD01 formal closeout uses platform-infra pipelines-as-code, while publish-current is debug/recovery only.",
sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, dashboard verification, maintenance and report views; migrated consumers are delivered only by the automatic PR-merge chain.",
},
notes: [
"Named internal/public origins, default origin, per-origin browser proxy mode, observe/analyze thresholds, and project-management command allowlist come from config/hwlab-node-lanes.yaml webProbe.",
@@ -72,6 +72,9 @@ export type WebProbeSentinelOptions =
readonly wait: boolean;
readonly timeoutSeconds: number;
readonly rerun: boolean;
readonly manualRecovery: boolean;
readonly reason: string | null;
readonly internalExecution: "pac-controller" | null;
readonly sourceOverride: WebProbeSentinelSourceOverrideOptions;
}
| {
@@ -465,14 +468,16 @@ export function renderPublishResult(publish: Record<string, unknown>): string {
lines.push(
"",
"PUBLISH_DRILLDOWN",
` status: ${commands.cliStatus ?? "-"}`,
` logs: ${commands.logs ?? "-"}`,
` describe: ${commands.describe ?? "-"}`,
` manual-recovery: ${commands.publishCurrent ?? "-"}`,
` git-mirror: ${commands.gitMirrorStatus ?? "-"}`,
` sync: ${commands.gitMirrorSync ?? "-"}`,
` flush: ${commands.gitMirrorFlush ?? "-"}`,
` apply: ${commands.controlPlaneApply ?? "-"}`,
...commandRows([
["status", commands.cliStatus],
["logs", commands.logs],
["describe", commands.describe],
["manual-recovery", commands.publishCurrent],
["git-mirror", commands.gitMirrorStatus],
["sync", commands.gitMirrorSync],
["flush", commands.gitMirrorFlush],
["apply", commands.controlPlaneApply],
]),
);
}
return lines.join("\n");
@@ -592,43 +597,14 @@ export function renderPublishCurrentResult(result: Record<string, unknown>): str
"",
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
"",
Object.keys(drillDown).length === 0 ? "DRILLDOWN\n-" : [
"DRILLDOWN",
table(["PIPELINERUN", "ASYNC_FLUSH_JOB"], [[drillDown.pipelineRun ?? "-", drillDown.asyncFlushJob ?? "-"]]),
` pipeline-run: ${drillDown.pipelineRunStatus ?? "-"}`,
` pipeline-run-full: ${drillDown.pipelineRunFull ?? "-"}`,
` pac-status: ${drillDown.consumerStatus ?? "-"}`,
` image-status: ${drillDown.imageStatus ?? "-"}`,
` git-mirror: ${drillDown.gitMirrorStatus ?? "-"}`,
` flush: ${drillDown.gitMirrorFlush ?? "-"}`,
` async-flush-status: ${drillDown.asyncFlushStatus ?? "-"}`,
].join("\n"),
renderSentinelDrillDown(drillDown),
"",
Object.keys(recoveryNext).length === 0 ? "RECOVERY_NEXT\n-" : [
"RECOVERY_NEXT",
table(["REASON", "PIPELINERUN", "DIGEST", "GITOPS"], [[recoveryNext.reason, recoveryNext.pipelineRun ?? "-", short(recoveryNext.digestRef), short(recoveryNext.gitopsCommit)]]),
` pac-closeout: ${recoveryNext.pacCloseout ?? "-"}`,
` manual-recovery: ${recoveryNext.publishCurrent ?? "-"}`,
` status: ${recoveryNext.nextStatus ?? "-"}`,
` git-mirror: ${recoveryNext.gitMirrorStatus ?? "-"}`,
` sync: ${recoveryNext.gitMirrorSync ?? "-"}`,
` flush: ${recoveryNext.gitMirrorFlush ?? "-"}`,
` apply: ${recoveryNext.controlPlaneApply ?? "-"}`,
].join("\n"),
renderSentinelRecoveryNext(recoveryNext),
"",
"NEXT",
` pac-closeout: ${next.pacCloseout ?? "-"}`,
` pac-status: ${next.pacStatus ?? "-"}`,
` pac-history: ${next.pacHistory ?? "-"}`,
` status: ${next.controlPlaneStatus ?? "-"}`,
` post-deploy-dashboard: ${next.dashboardVerify ?? "-"}`,
` git-mirror: ${next.gitMirrorStatus ?? "-"}`,
` sync: ${next.gitMirrorSync ?? "-"}`,
` flush: ${next.gitMirrorFlush ?? "-"}`,
renderSentinelNext(next),
"",
"DISCLOSURE",
" formal closeout for migrated JD01 sentinel CI/CD is platform-infra pipelines-as-code closeout/status/history.",
" publish-current is a debug/test/recovery step only; operator-side recovery must be explicit.",
" migrated PaC output only exposes status/history and the stable automatic-chain repair reference.",
` end-to-end and stage budgets are read from ${Object.keys(validationPlan).length > 0 ? "publishCurrent YAML and runtime.healthPath" : "YAML-required publishCurrent fields"}.`,
" CI/CD validation only checks the configured health endpoint; web-probe, Playwright and browser dashboard checks are post-deploy evidence, not this gate.",
" image build uses Tekton PipelineRun and BuildKit; this command does not require Docker daemon/socket/build.",
@@ -670,12 +646,7 @@ export function renderImageResult(result: Record<string, unknown>): string {
"",
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
"",
"NEXT",
` status: ${next.status ?? "-"}`,
` dry-run: ${next.dryRun ?? "-"}`,
` confirm: ${next.confirm ?? "-"}`,
` trigger: ${next.controlPlaneTrigger ?? "-"}`,
` control-plane: ${next.controlPlanePlan ?? "-"}`,
renderSentinelNext(next),
"",
"DISCLOSURE",
" valuesRedacted=true; image status shows refs, hashes and object names only.",
@@ -758,13 +729,7 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
"",
flush.mode === "async-job" ? `ASYNC_FLUSH_STATUS\n ${flushNext.status ?? "-"}` : "ASYNC_FLUSH_STATUS\n-",
"",
"NEXT",
` pac-closeout: ${next.pacCloseout ?? "-"}`,
` pac-status: ${next.pacStatus ?? "-"}`,
` pac-history: ${next.pacHistory ?? "-"}`,
` pipeline-run: ${drillDown.pipelineRunStatus ?? "-"}`,
` status: ${next.controlPlaneStatus ?? next.status ?? "-"}`,
` post-deploy-dashboard: ${next.dashboardVerify ?? "-"}`,
renderSentinelNext({ ...next, pipelineRunStatus: drillDown.pipelineRunStatus }),
"",
"DISCLOSURE",
" compact success view keeps CI/CD closeout fields below the global stdout guard.",
@@ -801,17 +766,7 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
]]),
].join("\n"),
"",
Object.keys(drillDown).length === 0 ? "DRILLDOWN\n-" : [
"DRILLDOWN",
table(["PIPELINERUN", "ASYNC_FLUSH_JOB"], [[drillDown.pipelineRun ?? "-", drillDown.asyncFlushJob ?? "-"]]),
` pipeline-run: ${drillDown.pipelineRunStatus ?? "-"}`,
` pipeline-run-full: ${drillDown.pipelineRunFull ?? "-"}`,
` pac-status: ${drillDown.consumerStatus ?? "-"}`,
` image-status: ${drillDown.imageStatus ?? "-"}`,
` git-mirror: ${drillDown.gitMirrorStatus ?? "-"}`,
` flush: ${drillDown.gitMirrorFlush ?? "-"}`,
` async-flush-status: ${drillDown.asyncFlushStatus ?? "-"}`,
].join("\n"),
renderSentinelDrillDown(drillDown),
"",
Object.keys(sourceMirrorSync).length === 0 ? "SOURCE_MIRROR_SYNC\n-" : table(["OK", "PHASE", "JOB", "COMMIT", "STAGE_REF", "ELAPSED"], [[sourceMirrorSync.ok, sourceMirrorSync.phase, sourceMirrorSync.jobName, short(record(sourceMirrorSync.payload).mirrorCommit), short(record(sourceMirrorSync.payload).stageRef), sourceMirrorSync.elapsedMs ?? "-"]]),
"",
@@ -850,32 +805,9 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
"",
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
"",
Object.keys(recoveryNext).length === 0 ? "RECOVERY_NEXT\n-" : [
"RECOVERY_NEXT",
table(["REASON", "PIPELINERUN", "DIGEST", "GITOPS"], [[recoveryNext.reason, recoveryNext.pipelineRun ?? "-", short(recoveryNext.digestRef), short(recoveryNext.gitopsCommit)]]),
` pac-closeout: ${recoveryNext.pacCloseout ?? "-"}`,
` manual-recovery: ${recoveryNext.publishCurrent ?? "-"}`,
` status: ${recoveryNext.nextStatus ?? "-"}`,
` git-mirror: ${recoveryNext.gitMirrorStatus ?? "-"}`,
` sync: ${recoveryNext.gitMirrorSync ?? "-"}`,
` flush: ${recoveryNext.gitMirrorFlush ?? "-"}`,
` apply: ${recoveryNext.controlPlaneApply ?? "-"}`,
].join("\n"),
renderSentinelRecoveryNext(recoveryNext),
"",
"NEXT",
` pac-closeout: ${next.pacCloseout ?? "-"}`,
` pac-status: ${next.pacStatus ?? "-"}`,
` pac-history: ${next.pacHistory ?? "-"}`,
` plan: ${next.plan ?? "-"}`,
` status: ${next.status ?? "-"}`,
` image: ${next.image ?? "-"}`,
` trigger-current: ${next.triggerCurrent ?? "-"}`,
` apply: ${next.apply ?? "-"}`,
` validate: ${next.validate ?? "-"}`,
` quick-verify: ${next.quickVerify ?? "-"}`,
` git-mirror: ${next.gitMirrorStatus ?? "-"}`,
` sync: ${next.gitMirrorSync ?? "-"}`,
` flush: ${next.gitMirrorFlush ?? "-"}`,
renderSentinelNext(next),
"",
"DISCLOSURE",
" default view is a bounded CI/CD summary; full manifest content is represented by object counts and sha256.",
@@ -883,6 +815,89 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
].join("\n");
}
function renderSentinelNext(next: Record<string, unknown>): string {
const status = commandText(next.pacStatus) ?? commandText(next.status) ?? commandText(next.controlPlaneStatus);
const controlPlaneStatus = commandText(next.controlPlaneStatus);
const history = commandText(next.pacHistory) ?? commandText(next.history);
const fix = commandText(next.fixAutomaticDelivery) ?? commandText(record(next.fixAutomaticDelivery).reference);
const rows: Array<[string, unknown]> = [
["status", status],
["control-plane-status", controlPlaneStatus === status ? null : controlPlaneStatus],
["history", history],
["fix-automatic-delivery", fix],
["pipeline-run", next.pipelineRunStatus],
["events", next.events],
["logs", next.logs],
["pac-closeout", next.pacCloseout],
["plan", next.plan ?? next.controlPlanePlan],
["image", next.image],
["image-status", next.imageStatus],
["dry-run", next.dryRun],
["confirm", next.confirm],
["trigger", next.controlPlaneTrigger ?? next.triggerCurrent],
["apply", next.apply],
["validate", next.validate],
["quick-verify", next.quickVerify],
["post-deploy-dashboard", next.dashboardVerify],
["git-mirror", next.gitMirrorStatus],
["sync", next.gitMirrorSync],
["flush", next.gitMirrorFlush],
];
const lines = commandRows(rows);
return ["NEXT", ...(lines.length === 0 ? ["-"] : lines)].join("\n");
}
function renderSentinelDrillDown(drillDown: Record<string, unknown>): string {
if (Object.keys(drillDown).length === 0) return "DRILLDOWN\n-";
const lines = commandRows([
["pipeline-run", drillDown.pipelineRunStatus],
["pipeline-run-full", drillDown.pipelineRunFull],
["pac-status", drillDown.consumerStatus],
["image-status", drillDown.imageStatus],
["git-mirror", drillDown.gitMirrorStatus],
["flush", drillDown.gitMirrorFlush],
["async-flush-status", drillDown.asyncFlushStatus],
]);
return [
"DRILLDOWN",
table(["PIPELINERUN", "ASYNC_FLUSH_JOB"], [[drillDown.pipelineRun ?? "-", drillDown.asyncFlushJob ?? "-"]]),
...lines,
].join("\n");
}
function renderSentinelRecoveryNext(recoveryNext: Record<string, unknown>): string {
if (Object.keys(recoveryNext).length === 0) return "RECOVERY_NEXT\n-";
const lines = commandRows([
["status", recoveryNext.nextStatus ?? recoveryNext.pacStatus ?? recoveryNext.status],
["history", recoveryNext.pacHistory ?? recoveryNext.history],
["fix-automatic-delivery", commandText(recoveryNext.fixAutomaticDelivery) ?? commandText(record(recoveryNext.fixAutomaticDelivery).reference)],
["git-mirror", recoveryNext.gitMirrorStatus],
["pac-closeout", recoveryNext.pacCloseout],
["manual-recovery", recoveryNext.publishCurrent],
["sync", recoveryNext.gitMirrorSync],
["flush", recoveryNext.gitMirrorFlush],
["apply", recoveryNext.controlPlaneApply],
]);
return [
"RECOVERY_NEXT",
table(["REASON", "PIPELINERUN", "DIGEST", "GITOPS"], [[recoveryNext.reason ?? "-", recoveryNext.pipelineRun ?? "-", short(recoveryNext.digestRef), short(recoveryNext.gitopsCommit)]]),
...lines,
].join("\n");
}
function commandRows(rows: Array<[string, unknown]>): string[] {
return rows.flatMap(([label, value]) => {
const command = commandText(value);
return command === null ? [] : [` ${label}: ${command}`];
});
}
function commandText(value: unknown): string | null {
if (typeof value !== "string") return null;
const command = value.trim();
return command.length === 0 || command === "-" ? null : command;
}
export function renderObservedStatus(observed: Record<string, unknown>): string {
const rows = [
observedStatusRow("source", observed.sourceMirror),
+88 -75
View File
@@ -16,11 +16,22 @@ import { join } from "node:path";
import { repoRoot, rootPath } from "./config";
import { runCommand, type CommandResult } from "./command";
import { runtimeReuseService, summarizeRuntimeReuseConfig, type RuntimeReuseConfig } from "./cicd-reuse-config";
import type { CicdDeliveryAuthority } from "./cicd-delivery-authority";
import { startJob } from "./jobs";
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
import { readWebProbeSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-config-ref";
import { effectiveWebProbeSentinelPublicExposure, requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import {
guardedSentinelDeliveryAction,
publishCurrentNext,
renderBlockedSentinelDeliveryMutation,
sentinelReadOnlyNext,
sentinelSourceOverrideFromOptions,
sentinelSourceResolveMode,
webProbeSentinelDeliveryAuthority,
type SentinelSourceOverride,
} from "./hwlab-node/sentinel-delivery-authority";
import type { RenderedCliResult } from "./output";
import { probeSentinelRuntimeHealthEndpoint, runSentinelDashboard, runSentinelErrorDetail, runSentinelInspect, runSentinelMaintenance, runSentinelReport, runSentinelValidate } from "./hwlab-node-web-sentinel-p5";
import { runChildCli, sentinelP5Next } from "./hwlab-node-web-sentinel-p5-observe";
@@ -136,21 +147,26 @@ export {
table,
text,
};
export {
guardedSentinelDeliveryAction,
webProbeSentinelDeliveryAuthority,
webProbeSentinelDeliveryAuthorityFromCatalog,
webProbeSentinelDeliveryMutationDecision,
} from "./hwlab-node/sentinel-delivery-authority";
export type { SentinelSourceOverride } from "./hwlab-node/sentinel-delivery-authority";
const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-07-01-p16-cicd-source-snapshot";
export type SourceResolveMode = "cached" | "sync";
export interface SentinelSourceOverride {
readonly commit: string;
readonly stageRef?: string | null;
readonly mirrorCommit?: string | null;
readonly sourceAuthority: WebProbeSentinelSourceAuthority;
}
export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult {
if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action, options.sentinelId));
const guardedAction = guardedSentinelDeliveryAction(options);
if (guardedAction !== null) {
const blocked = renderBlockedSentinelDeliveryMutation(spec, options, guardedAction.command, guardedAction.action);
if (blocked !== null) return blocked;
}
requireSentinelIdForRegistry(spec, options.sentinelId, `web-probe sentinel ${options.kind}`);
const state = loadSentinelCicdState(spec, options.sentinelId, options.timeoutSeconds, sentinelSourceResolveMode(options), sentinelSourceOverrideFromOptions(options));
if (options.kind === "image") return runSentinelImage(state, options);
@@ -164,28 +180,9 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options:
return runSentinelReport(state, options);
}
function sentinelSourceOverrideFromOptions(options: WebProbeSentinelOptions): SentinelSourceOverride | null {
if (options.kind !== "image" && options.kind !== "control-plane" && options.kind !== "publish") return null;
const override = options.sourceOverride;
if (override.sourceCommit === null && override.sourceStageRef === null && override.sourceMirrorCommit === null && override.sourceAuthority === null) return null;
if (override.sourceCommit === null) throw new Error("--source-commit is required when overriding web-probe sentinel source");
return {
commit: override.sourceCommit,
stageRef: override.sourceStageRef,
mirrorCommit: override.sourceMirrorCommit,
sourceAuthority: override.sourceAuthority ?? "git-mirror-snapshot",
};
}
function sentinelSourceResolveMode(options: WebProbeSentinelOptions): SourceResolveMode {
if (options.kind === "image" && options.action === "build" && options.confirm && options.wait) return "sync";
if (options.kind === "control-plane" && options.action === "trigger-current" && options.confirm && options.wait) return "sync";
if (options.kind === "publish" && options.confirm && options.wait) return "sync";
return "cached";
}
function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "image" }>): RenderedCliResult {
const command = `web-probe sentinel image ${options.action}`;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (options.action === "build" && options.confirm) {
if (!options.wait) return renderAsyncSentinelJob(state, "image", "build", options.timeoutSeconds);
return runSentinelImageBuildConfirmed(state, options);
@@ -210,12 +207,15 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
blocker: sourceMirrorReady
? registryReady ? null : { code: "sentinel-image-missing", reason: "expected sentinel image tag is not present in the node-local registry" }
: { code: "sentinel-source-mirror-not-ready", reason: "source.gitMirrorReadUrl does not expose the selected source commit yet" },
next: {
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
dryRun: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
confirm: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --confirm`,
controlPlanePlan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
},
deliveryAuthority,
next: deliveryAuthority.kind === "legacy-manual"
? {
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
dryRun: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
confirm: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --confirm`,
controlPlanePlan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
}
: sentinelReadOnlyNext(state, deliveryAuthority),
valuesRedacted: true,
};
return rendered(result.ok, command, renderImageResult(result));
@@ -223,6 +223,7 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "control-plane" }>): RenderedCliResult {
const command = `web-probe sentinel control-plane ${options.action}`;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
const mutationAction = options.action === "apply" || options.action === "trigger-current";
if (options.confirm && mutationAction) {
if (!options.wait) return renderAsyncSentinelJob(state, "control-plane", options.action, options.timeoutSeconds);
@@ -241,6 +242,7 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
sentinelId: state.sentinelId,
mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run",
mutation: false,
deliveryAuthority,
specRef: SPEC_REF,
source: state.sourceHead,
image: state.image,
@@ -1094,29 +1096,6 @@ function publishCurrentBlocker(controlResult: Record<string, unknown>, health: R
return { code: "sentinel-publish-current-blocked", reason: "publish-current did not satisfy all checks", valuesRedacted: true };
}
function publishCurrentNext(state: SentinelCicdState): Record<string, string> {
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
const consumer = sentinelPacConsumerId(state);
const sourceCommit = state.sourceHead.commit === null ? "" : ` --source-commit ${state.sourceHead.commit}`;
return {
pacCloseout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${node} --consumer ${consumer}${sourceCommit} --wait`,
pacStatus: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${node} --consumer ${consumer}`,
pacHistory: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${node} --consumer ${consumer} --limit 10`,
manualRecovery: `bun scripts/cli.ts web-probe sentinel publish-current --node ${node} --lane ${lane}${suffix} --confirm --wait --manual-recovery --reason <reason>`,
controlPlaneStatus: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}${suffix}`,
dashboardVerify: `bun scripts/cli.ts web-probe sentinel dashboard verify --node ${node} --lane ${lane}${suffix}`,
gitMirrorStatus: `bun scripts/cli.ts hwlab nodes git-mirror status --node ${node} --lane ${lane}`,
gitMirrorSync: `bun scripts/cli.ts hwlab nodes git-mirror sync --node ${node} --lane ${lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${node} --lane ${lane} --confirm --wait`,
};
}
function sentinelPacConsumerId(state: SentinelCicdState): string {
return `sentinel-${state.spec.nodeId.toLowerCase()}-${state.spec.lane}`;
}
function renderSentinelManifests(
spec: HwlabRuntimeLaneSpec,
sentinelId: string,
@@ -2009,6 +1988,8 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
const argoReady = argo.ok === true;
const runtimeReady = record(observed.runtime).ok === true;
const cadenceReady = cadence.ok === true;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
const legacyDelivery = deliveryAuthority.kind === "legacy-manual";
const code = sourceReady && !registryPresent
? "sentinel-publish-half-state-registry-missing"
: registryPresent && !gitopsReady
@@ -2020,7 +2001,7 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
: !cadenceReady
? "sentinel-cadence-not-ready"
: "sentinel-control-plane-observed-not-ready";
const reason = code === "sentinel-publish-half-state-registry-missing"
const legacyReason = code === "sentinel-publish-half-state-registry-missing"
? "source mirror contains the selected commit, but the expected registry tag is missing; inspect PaC closeout/history before considering explicit manual recovery"
: code === "sentinel-gitops-manifest-not-updated"
? "registry contains the selected image, but GitOps manifest is not yet updated to a digest-pinned runtime image"
@@ -2031,6 +2012,9 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
: code === "sentinel-cadence-not-ready"
? "runtime is aligned, but cadence CronJob validation did not pass"
: "one or more source, registry, GitOps, Argo, runtime or cadence checks did not pass";
const reason = legacyDelivery
? legacyReason
: `Web sentinel automatic delivery observation is not aligned (${code}); inspect read-only status/history and fix the owning YAML, controller, or source.`;
const next = publishCurrentNext(state);
const controlNext = controlPlaneNext(state, "trigger-current");
const shouldRetryPublish = code === "sentinel-publish-half-state-registry-missing" || code === "sentinel-gitops-manifest-not-updated";
@@ -2055,26 +2039,38 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
argo: `${argo.syncStatus ?? "-"} ${argo.healthStatus ?? "-"} ${short(argo.revision)}/${short(argo.expectedRevision)}`,
runtime: `ready=${runtimeDeployment.readyReplicas ?? "-"}/${runtimeDeployment.desiredReplicas ?? "-"} image=${short(runtimeDeployment.image)} expected=${short(runtimeDeployment.expectedImage)}`,
pipelineRun,
warning: code === "sentinel-publish-half-state-registry-missing"
deliveryAuthority,
warning: !legacyDelivery
? `自动链未对齐;检查 ${next.pacStatus ?? next.status}${next.pacHistory ?? next.status},按 ${next.fixAutomaticDelivery} 修复后用新的正常 GitHub PR merge 验收。`
: code === "sentinel-publish-half-state-registry-missing"
? `source mirror already exposes ${short(state.sourceHead.commit)} but registry tag ${state.image.tag} is missing; run ${next.pacCloseout} and inspect ${next.pacHistory}.`
: code === "sentinel-git-mirror-not-in-sync"
? `runtime is aligned but git-mirror is not in sync; run ${next.gitMirrorSync} and then recheck ${next.controlPlaneStatus}.`
: null,
blocker: { code, reason, valuesRedacted: true },
recoveryNext: {
reason,
pipelineRun,
digestRef: registryPresent ? expectedRuntimeImageFromRegistry(state, record(observed.registry)) : null,
gitopsCommit: gitops.revision ?? null,
pacCloseout: next.pacCloseout,
publishCurrent: shouldRetryPublish ? next.manualRecovery : null,
nextStatus: next.pacStatus,
gitMirrorStatus: next.gitMirrorStatus,
gitMirrorSync: !gitMirrorReady ? next.gitMirrorSync : null,
gitMirrorFlush: null,
controlPlaneApply: shouldRetryPublish || code === "sentinel-runtime-not-aligned" ? controlNext.apply : null,
valuesRedacted: true,
},
recoveryNext: legacyDelivery
? {
reason,
pipelineRun,
digestRef: registryPresent ? expectedRuntimeImageFromRegistry(state, record(observed.registry)) : null,
gitopsCommit: gitops.revision ?? null,
pacCloseout: next.pacCloseout,
publishCurrent: shouldRetryPublish ? next.manualRecovery : null,
nextStatus: next.pacStatus,
gitMirrorStatus: next.gitMirrorStatus,
gitMirrorSync: !gitMirrorReady ? next.gitMirrorSync : null,
gitMirrorFlush: null,
controlPlaneApply: shouldRetryPublish || code === "sentinel-runtime-not-aligned" ? controlNext.apply : null,
valuesRedacted: true,
}
: {
reason,
status: next.status,
pacStatus: next.pacStatus,
pacHistory: next.pacHistory,
fixAutomaticDelivery: next.fixAutomaticDelivery,
valuesRedacted: true,
},
valuesRedacted: true,
};
}
@@ -2485,10 +2481,12 @@ function confirmBlocked(action: string, state: SentinelCicdState): Record<string
}
function controlPlaneNext(state: SentinelCicdState, action: WebProbeSentinelControlPlaneAction): Record<string, string> {
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (deliveryAuthority.kind !== "legacy-manual") return sentinelReadOnlyNext(state, deliveryAuthority);
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
const consumer = sentinelPacConsumerId(state);
const consumer = sentinelPacConsumerId(state.spec);
const sourceCommit = state.sourceHead.commit === null ? "" : ` --source-commit ${state.sourceHead.commit}`;
return {
pacCloseout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${node} --consumer ${consumer}${sourceCommit} --wait`,
@@ -2512,6 +2510,14 @@ function controlPlaneNext(state: SentinelCicdState, action: WebProbeSentinelCont
function controlPlaneRecoveryNext(state: SentinelCicdState, ok: boolean, publish: unknown, flush: unknown, observed: unknown): Record<string, unknown> | null {
const payload = record(record(publish).payload);
if (ok || nonEmptyString(payload.digestRef) === null) return null;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (deliveryAuthority.kind !== "legacy-manual") {
return {
reason: "平台维护后自动交付仍未对齐;只读检查 sentinel/PaC 状态并修复自动链,不得人工 publish、sync 或 flush。",
...sentinelReadOnlyNext(state, deliveryAuthority),
valuesRedacted: true,
};
}
const next = controlPlaneNext(state, "apply");
const flushRecord = record(flush);
const observedRecord = record(observed);
@@ -2535,12 +2541,13 @@ function controlPlaneRecoveryNext(state: SentinelCicdState, ok: boolean, publish
function controlPlaneDrillDown(state: SentinelCicdState, pipelineRun: unknown, flush: unknown): Record<string, unknown> {
const pipelineRunName = nonEmptyString(pipelineRun);
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
const next = controlPlaneNext(state, "status");
const pacBase = "bun scripts/cli.ts platform-infra pipelines-as-code";
const flushRecord = record(flush);
const flushNext = record(flushRecord.next);
const flushJob = record(flushRecord.job);
return {
const readOnly = {
pipelineRun: pipelineRunName,
pipelineRunStatus: pipelineRunName === null
? null
@@ -2549,12 +2556,18 @@ function controlPlaneDrillDown(state: SentinelCicdState, pipelineRun: unknown, f
? null
: `${pacBase} history --target ${state.spec.nodeId} --id ${pipelineRunName} --full`,
consumerStatus: `${pacBase} status --target ${state.spec.nodeId} --consumer sentinel-${state.spec.nodeId.toLowerCase()}-${state.spec.lane}`,
controlPlaneStatus: next.controlPlaneStatus ?? next.status,
fixAutomaticDelivery: next.fixAutomaticDelivery ?? PAC_AUTOMATIC_DELIVERY_REFERENCE,
valuesRedacted: true,
};
if (deliveryAuthority.kind !== "legacy-manual") return readOnly;
return {
...readOnly,
imageStatus: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
gitMirrorStatus: next.gitMirrorStatus,
gitMirrorFlush: next.gitMirrorFlush,
asyncFlushJob: nonEmptyString(flushJob.id),
asyncFlushStatus: nonEmptyString(flushNext.status),
valuesRedacted: true,
};
}
+2 -2
View File
@@ -202,7 +202,7 @@ export function nodeRuntimeBaseImageCommand(scoped: ReturnType<typeof parseNodeS
: "node-runtime-image-seed-failed",
next: scoped.dryRun
? { preload: `bun scripts/cli.ts hwlab nodes control-plane runtime-image preload --node ${scoped.node} --lane ${scoped.lane} --confirm` }
: { triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm --rerun` },
: { status: `bun scripts/cli.ts hwlab nodes control-plane runtime-image status --node ${scoped.node} --lane ${scoped.lane}` },
};
}
@@ -416,7 +416,7 @@ export function nodeRuntimeApply(scoped: ReturnType<typeof parseNodeScopedDelega
next: scoped.dryRun
? { apply: `bun scripts/cli.ts hwlab nodes control-plane apply --node ${scoped.node} --lane ${scoped.lane} --confirm` }
: {
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm`,
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
...(sshTcpPoolDiagnostics === null ? {} : { sshPoolStatus: `bun scripts/cli.ts debug ssh-pool ${scoped.node}` }),
},
};
@@ -0,0 +1,92 @@
import {
decideCicdDeliveryMutation,
gitRepositoryIdentity,
pacAutomaticDeliveryFix,
pacReadOnlyNext,
resolveCicdDeliveryAuthority,
type CicdDeliveryAuthority,
type CicdDeliveryMutationDecision,
} from "../cicd-delivery-authority";
import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
export interface HwlabNodeDeliveryTarget {
readonly node: string;
readonly lane: string;
readonly spec: HwlabRuntimeLaneSpec;
}
export function hwlabNodeDeliveryAuthority(target: HwlabNodeDeliveryTarget): CicdDeliveryAuthority {
return resolveCicdDeliveryAuthority({
node: target.node,
lane: target.lane,
sourceRepository: gitRepositoryIdentity(target.spec.gitUrl),
sourceBranch: target.spec.sourceBranch,
declaredSourceAuthorityMode: target.spec.sourceAuthority?.mode ?? null,
declaredConfigRef: hwlabRuntimeLaneConfigPath(),
});
}
export function hwlabNodeDeliveryMutationDecision(
target: HwlabNodeDeliveryTarget,
command: string,
action: string,
): CicdDeliveryMutationDecision {
return decideCicdDeliveryMutation(hwlabNodeDeliveryAuthority(target), {
command,
statusCommand: `bun scripts/cli.ts hwlab nodes control-plane status --node ${target.node} --lane ${target.lane}`,
action,
node: target.node,
lane: target.lane,
});
}
export function hwlabNodeDeliveryNext(
target: HwlabNodeDeliveryTarget,
authority: CicdDeliveryAuthority,
runtimeStatusFull: string,
): Record<string, unknown> {
if (authority.kind === "pac-pr-merge") {
return {
runtimeStatusFull,
...pacReadOnlyNext(authority.consumer.node, authority.consumer.consumerId),
};
}
if (authority.kind === "unknown") {
return {
runtimeStatusFull,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
valuesPrinted: false,
};
}
return {
full: runtimeStatusFull,
plan: `bun scripts/cli.ts hwlab nodes control-plane plan --node ${target.node} --lane ${target.lane}`,
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${target.node} --lane ${target.lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${target.node} --lane ${target.lane} --confirm --wait`,
webProbe: `bun scripts/cli.ts web-probe run --node ${target.node} --lane ${target.lane}`,
};
}
export function hwlabNodePlanNext(
target: HwlabNodeDeliveryTarget,
authority: CicdDeliveryAuthority,
): Record<string, unknown> {
const status = `bun scripts/cli.ts hwlab nodes control-plane status --node ${target.node} --lane ${target.lane}`;
if (authority.kind === "pac-pr-merge") {
return pacReadOnlyNext(authority.consumer.node, authority.consumer.consumerId);
}
if (authority.kind === "unknown") {
return {
status,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${target.node} --limit 10`,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
valuesPrinted: false,
};
}
return {
status,
platformMaintenanceHelp: "bun scripts/cli.ts hwlab nodes control-plane platform-maintenance --help",
legacyCicdHelp: "bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help",
valuesPrinted: false,
};
}
+16 -1
View File
@@ -46,6 +46,7 @@ import { emitNodeRuntimeStatusWorkerEvent, isNodeRuntimeStatusWorker, readNodeRu
import { legacyHwlabNodeWebProbeUnsupported } from "../web-probe";
import { startNodeDelegatedJob } from "./web-probe";
import { assertKnownOptions } from "./web-probe-observe";
import { hwlabNodeDeliveryMutationDecision } from "./delivery-authority";
export { hwlabNodeHelp, hwlabNodeWebProbeHelp, hwlabNodeObservabilityHelp } from "../hwlab-node-help";
@@ -571,7 +572,10 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom
if (args.length === 1 || args.includes("--help") || args.includes("-h") || args[1] === "help") return hwlabNodeObservabilityHelp();
return runNodeObservability(parseNodeObservabilityOptions(args.slice(1)));
}
if (args.includes("--help") || args.includes("-h")) return hwlabNodeHelp();
if (args.includes("--help") || args.includes("-h")) {
const scope = args.includes("legacy-cicd") ? "legacy-cicd" : args.includes("platform-maintenance") ? "platform-maintenance" : null;
return hwlabNodeHelp(scope);
}
if (domain === "control-plane" || domain === "git-mirror") {
return runNodeDelegatedDomain(_config, domain, args.slice(1));
}
@@ -614,6 +618,17 @@ export function parseNodeObservabilityOptions(args: string[]): NodeObservability
export async function runNodeDelegatedDomain(config: Config, domain: DelegatedNodeDomain, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const scoped = parseNodeScopedDelegatedOptions(domain, args);
const guardedDeliveryAction = domain === "git-mirror" && (scoped.action === "sync" || scoped.action === "flush")
|| domain === "control-plane" && (scoped.action === "trigger-current" || scoped.action === "refresh" || scoped.action === "sync")
|| domain === "control-plane" && scoped.action === "source-workspace" && scoped.originalArgs[1] === "sync";
if (guardedDeliveryAction) {
const decision = hwlabNodeDeliveryMutationDecision(
scoped,
`hwlab nodes ${domain} ${scoped.action}${scoped.action === "source-workspace" ? " sync" : ""}`,
scoped.action === "source-workspace" ? "source-workspace-sync" : scoped.action,
);
if (!decision.allowed) return decision.result;
}
if (domain === "control-plane" && scoped.action === "public-exposure") {
return runNodePublicExposure({
action: "public-exposure",
+4 -8
View File
@@ -38,9 +38,11 @@ import { compactCommandResultRedacted, record, shellQuote } from "./utils";
import { readBootstrapAdminPasswordMaterial } from "./web-probe";
import { webProbeCredential } from "./web-probe-observe";
import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes";
import { hwlabNodeDeliveryAuthority, hwlabNodePlanNext } from "./delivery-authority";
export function nodeRuntimeControlPlanePlan(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
const activeExternalPostgres = hwlabRuntimeActiveExternalPostgres(scoped.spec);
const deliveryAuthority = hwlabNodeDeliveryAuthority(scoped);
return {
ok: true,
command: `hwlab nodes control-plane plan --node ${scoped.node} --lane ${scoped.lane}`,
@@ -48,6 +50,7 @@ export function nodeRuntimeControlPlanePlan(scoped: ReturnType<typeof parseNodeS
mutation: false,
node: scoped.node,
lane: scoped.lane,
deliveryAuthority,
expected: nodeRuntimeExpected(scoped.spec),
checks: {
nodeScopedTargetConfigured: true,
@@ -58,14 +61,7 @@ export function nodeRuntimeControlPlanePlan(scoped: ReturnType<typeof parseNodeS
localPostgresExpectedAbsent: nodeRuntimeLocalPostgresExpectedAbsent(scoped.spec),
publicExposureDeclared: scoped.spec.publicExposure !== null,
},
next: {
infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${scoped.node} --lane ${scoped.lane}`,
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
platformDbStatus: activeExternalPostgres === undefined
? null
: `bun scripts/cli.ts platform-db postgres status --config ${activeExternalPostgres.configRef}`,
publicExposure: scoped.spec.publicExposure === null ? null : `bun scripts/cli.ts hwlab nodes control-plane public-exposure --node ${scoped.node} --lane ${scoped.lane} --confirm`,
},
next: hwlabNodePlanNext(scoped, deliveryAuthority),
};
}
@@ -525,7 +525,6 @@ export function runNodePublicExposure(options: NodePublicExposureOptions): Recor
valuesRedacted: true,
next: ok
? {
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${options.node} --lane ${options.lane} --confirm`,
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${options.node} --lane ${options.lane}`,
}
: { retry: `bun scripts/cli.ts hwlab nodes control-plane public-exposure --node ${options.node} --lane ${options.lane} --confirm` },
+47 -18
View File
@@ -42,6 +42,8 @@ import { readNodeRuntimeStatusPolicy, runNodeRuntimeStatusProbe, skippedNodeRunt
import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes";
import { nodeRuntimeDeployYamlOverlayShellScript } from "./deploy-overlay";
import { pipelineProvenanceAnnotationKeys } from "../pipeline-provenance";
import { hwlabNodeDeliveryAuthority, hwlabNodeDeliveryNext } from "./delivery-authority";
import type { CicdDeliveryAuthority } from "../cicd-delivery-authority";
const runtimeGitopsObservabilityNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-observability.mjs"), "utf8").trimEnd();
const runtimeGitopsPipelineGuardNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs"), "utf8").trimEnd();
@@ -346,6 +348,9 @@ export function nodeRuntimeControlPlaneStatus(scoped: ReturnType<typeof parseNod
runtimeDegradedReason,
publicReady,
});
const deliveryAuthority = hwlabNodeDeliveryAuthority(scoped);
const visiblePipelineRunDiagnostics = nodeRuntimeVisiblePipelineRunDiagnostics(pipelineRunDiagnostics, deliveryAuthority);
const runtimeStatusFull = `${nodeRuntimeStatusCommand(scoped)} --full`;
const fullStatus = {
ok: controlPlaneReady && runtimeReady && argoReady && pipelineRunReady && publicReady && gitMirrorReady,
command: `hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
@@ -389,7 +394,7 @@ export function nodeRuntimeControlPlaneStatus(scoped: ReturnType<typeof parseNod
result: compactRuntimeCommand(argo),
},
pipelineRun: pipelineRunProbe,
pipelineRunDiagnostics,
pipelineRunDiagnostics: visiblePipelineRunDiagnostics,
runtime: {
ready: runtimeReady,
skipped: !statusPolicy.probes.runtime,
@@ -420,17 +425,32 @@ export function nodeRuntimeControlPlaneStatus(scoped: ReturnType<typeof parseNod
postgresObjects: postgresObjects === null ? null : compactRuntimeCommand(postgresObjects),
},
degradedReason,
next: {
plan: `bun scripts/cli.ts hwlab nodes control-plane plan --node ${scoped.node} --lane ${scoped.lane}`,
apply: `bun scripts/cli.ts hwlab nodes control-plane apply --node ${scoped.node} --lane ${scoped.lane} --confirm`,
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm`,
},
deliveryAuthority,
next: hwlabNodeDeliveryNext(scoped, deliveryAuthority, runtimeStatusFull),
};
const summary = summarizeNodeRuntimeControlPlaneStatus(fullStatus, scoped);
const summary = summarizeNodeRuntimeControlPlaneStatus(fullStatus, scoped, deliveryAuthority);
if (scoped.originalArgs.includes("--full") || scoped.originalArgs.includes("--raw")) return { ...fullStatus, summary };
return summary;
}
export function nodeRuntimeVisiblePipelineRunDiagnostics(
diagnostics: Record<string, unknown> | null,
deliveryAuthority: CicdDeliveryAuthority,
): Record<string, unknown> | null {
if (deliveryAuthority.kind === "legacy-manual" || diagnostics === null) return diagnostics;
const failureSummary = record(diagnostics.failureSummary);
return {
...diagnostics,
failureSummary: Object.keys(failureSummary).length === 0
? diagnostics.failureSummary ?? null
: {
...failureSummary,
nextAction: "检查失败 TaskRun 与有界日志,修复 owning YAML、controller 或源码中的自动链缺陷,再以新的正常 GitHub PR merge 验收;不得人工 rerun。",
},
next: null,
};
}
export function parseNodeRuntimeWorkloadReadiness(text: string): Array<Record<string, unknown>> {
return text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).map((line) => {
const [ref = "", counts = ""] = line.split(/\t/u);
@@ -724,7 +744,11 @@ export function nodeRuntimePipelinePendingTaskRunSummaries(
});
}
export function summarizeNodeRuntimeControlPlaneStatus(status: Record<string, unknown>, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
export function summarizeNodeRuntimeControlPlaneStatus(
status: Record<string, unknown>,
scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>,
deliveryAuthority: CicdDeliveryAuthority = hwlabNodeDeliveryAuthority(scoped),
): Record<string, unknown> {
const pipelineRun = record(status.pipelineRun);
const pipelineRunDiagnostics = record(status.pipelineRunDiagnostics);
const argo = record(status.argo);
@@ -770,7 +794,7 @@ export function summarizeNodeRuntimeControlPlaneStatus(status: Record<string, un
pendingTaskRuns: pipelineRunDiagnostics.pendingTaskRuns ?? [],
unscheduledPods: pipelineRunDiagnostics.unscheduledPods ?? [],
schedulingMessages: pipelineRunDiagnostics.schedulingMessages ?? [],
next: pipelineRunDiagnostics.next ?? null,
next: deliveryAuthority.kind === "legacy-manual" ? pipelineRunDiagnostics.next ?? null : null,
},
},
argo: {
@@ -842,14 +866,9 @@ export function summarizeNodeRuntimeControlPlaneStatus(status: Record<string, un
flushNeeded: gitMirrorCompact.flushNeeded === true,
githubInSync: gitMirrorCompact.githubInSync === true,
},
nextAction: nodeRuntimeStatusNextAction(status, scoped),
next: {
full: `${nodeRuntimeStatusCommand(scoped)} --full`,
plan: `bun scripts/cli.ts hwlab nodes control-plane plan --node ${scoped.node} --lane ${scoped.lane}`,
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${scoped.node} --lane ${scoped.lane} --confirm --wait`,
webProbe: `bun scripts/cli.ts web-probe run --node ${scoped.node} --lane ${scoped.lane}`,
},
deliveryAuthority,
nextAction: nodeRuntimeStatusNextAction(status, scoped, deliveryAuthority),
next: hwlabNodeDeliveryNext(scoped, deliveryAuthority, `${nodeRuntimeStatusCommand(scoped)} --full`),
};
}
@@ -967,8 +986,18 @@ function argoEventRows(text: string): Record<string, unknown>[] {
}).filter((item): item is Record<string, unknown> => item !== null);
}
export function nodeRuntimeStatusNextAction(status: Record<string, unknown>, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): string {
export function nodeRuntimeStatusNextAction(
status: Record<string, unknown>,
scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>,
deliveryAuthority: CicdDeliveryAuthority = hwlabNodeDeliveryAuthority(scoped),
): string {
const reason = typeof status.degradedReason === "string" ? status.degradedReason : null;
if (deliveryAuthority.kind === "pac-pr-merge") {
return reason === null
? `${nodeRuntimeStatusCommand(scoped)} --full`
: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${deliveryAuthority.consumer.node} --consumer ${deliveryAuthority.consumer.consumerId}`;
}
if (deliveryAuthority.kind === "unknown") return `${nodeRuntimeStatusCommand(scoped)} --full`;
if (reason === null) return `${nodeRuntimeStatusCommand(scoped)} --full`;
if (reason === "control-plane-not-ready" || reason === "runtime-namespace-missing") {
return `bun scripts/cli.ts hwlab nodes control-plane apply --node ${scoped.node} --lane ${scoped.lane} --confirm`;
+6 -2
View File
@@ -197,7 +197,6 @@ export function sshTcpPoolDiagnosticsFromCommand(spec: HwlabRuntimeLaneSpec, res
poolStatus: `bun scripts/cli.ts debug ssh-pool ${spec.nodeId}`,
retrySmoke: `trans ${spec.nodeId} argv true`,
retryApply: `bun scripts/cli.ts hwlab nodes control-plane apply --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
retryTriggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
},
};
}
@@ -210,7 +209,12 @@ export function nodeRuntimeUnsupportedAction(scoped: ReturnType<typeof parseNode
lane: scoped.lane,
mutation: false,
degradedReason: "unsupported-node-scoped-runtime-action",
message: "node-scoped runtime currently supports plan/status/apply/refresh/sync/trigger-current/cleanup-runs/cleanup-released-pvs/cleanup-legacy-docker-images/cleanup-legacy-docker-registry-volume/runtime-image/runtime-migration/source-workspace",
message: "node-scoped runtime default help exposes read-only plan/status; delivery mutations are available only from explicit legacy-cicd help, and platform maintenance from platform-maintenance help",
next: {
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
legacyHelp: "bun scripts/cli.ts hwlab nodes help legacy-cicd",
maintenanceHelp: "bun scripts/cli.ts hwlab nodes help platform-maintenance",
},
expected: nodeRuntimeExpected(scoped.spec),
};
}
@@ -0,0 +1,207 @@
import {
PAC_AUTOMATIC_DELIVERY_REFERENCE,
decideCicdDeliveryMutation,
readCicdDeliveryAuthorityCatalog,
resolveCicdDeliveryAuthority,
resolveCicdDeliveryAuthorityFromCatalog,
type CicdDeliveryAuthority,
type CicdDeliveryAuthorityCatalog,
type CicdDeliveryMutationDecision,
} from "../cicd-delivery-authority";
import { authorizeSentinelPacInternalPublish } from "../sentinel-pac-execution-context";
import {
record,
rendered,
sentinelCliSuffix,
text,
type SentinelCicdState,
type WebProbeSentinelOptions,
type WebProbeSentinelSourceAuthority,
} from "../hwlab-node-web-sentinel-cicd-shared";
import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { RenderedCliResult } from "../output";
export function webProbeSentinelDeliveryAuthority(spec: HwlabRuntimeLaneSpec): CicdDeliveryAuthority {
try {
return webProbeSentinelDeliveryAuthorityFromCatalog(spec, readCicdDeliveryAuthorityCatalog());
} catch {
return resolveCicdDeliveryAuthority({
consumerId: sentinelPacConsumerId(spec),
node: spec.nodeId,
lane: spec.lane,
});
}
}
export function webProbeSentinelDeliveryAuthorityFromCatalog(
spec: HwlabRuntimeLaneSpec,
catalog: CicdDeliveryAuthorityCatalog,
): CicdDeliveryAuthority {
const consumerId = sentinelPacConsumerId(spec);
const exactConsumer = resolveCicdDeliveryAuthorityFromCatalog(catalog, {
consumerId,
node: spec.nodeId,
lane: spec.lane,
});
if (exactConsumer.kind === "pac-pr-merge") return exactConsumer;
const stableSentinelIdentity = spec.sourceAuthority?.mode === "giteaSnapshot"
|| catalog.consumers.some((consumer) => consumer.consumerId.toLowerCase() === consumerId.toLowerCase())
|| catalog.consumers.some((consumer) => consumer.consumerId.toLowerCase().startsWith("sentinel-")
&& consumer.node.toLowerCase() === spec.nodeId.toLowerCase()
&& consumer.lane.toLowerCase() === spec.lane.toLowerCase());
if (stableSentinelIdentity) return exactConsumer;
return resolveCicdDeliveryAuthorityFromCatalog(catalog, {
declaredSourceAuthorityMode: spec.sourceAuthority?.mode ?? null,
declaredConfigRef: hwlabRuntimeLaneConfigPath(),
});
}
export function sentinelPacConsumerId(spec: HwlabRuntimeLaneSpec): string {
return `sentinel-${spec.nodeId.toLowerCase()}-${spec.lane}`;
}
export function webProbeSentinelDeliveryMutationDecision(
spec: HwlabRuntimeLaneSpec,
command: string,
action: string,
): CicdDeliveryMutationDecision {
return decideCicdDeliveryMutation(webProbeSentinelDeliveryAuthority(spec), {
command,
statusCommand: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
action,
node: spec.nodeId,
lane: spec.lane,
});
}
export function guardedSentinelDeliveryAction(options: WebProbeSentinelOptions): { command: string; action: string } | null {
if (options.kind === "image" && options.action === "build") {
return { command: "web-probe sentinel image build", action: "sentinel-image-build" };
}
if (options.kind === "control-plane" && options.action === "trigger-current") {
return { command: "web-probe sentinel control-plane trigger-current", action: "sentinel-control-plane-trigger-current" };
}
if (options.kind === "publish" && (options.internalExecution === "pac-controller" || options.manualRecovery)) {
return { command: "web-probe sentinel publish-current", action: "sentinel-publish-current" };
}
return null;
}
export function renderBlockedSentinelDeliveryMutation(
spec: HwlabRuntimeLaneSpec,
options: WebProbeSentinelOptions,
command: string,
action: string,
): RenderedCliResult | null {
const decision = webProbeSentinelDeliveryMutationDecision(spec, command, action);
if (decision.allowed) return null;
const executionAuthorization = options.kind === "publish" && options.internalExecution === "pac-controller"
? authorizeSentinelPacInternalPublish(options, decision.authority)
: null;
const result = {
...decision.result,
...(executionAuthorization === null ? {} : {
mode: executionAuthorization.mode,
reason: executionAuthorization.reason,
capability: executionAuthorization.capability,
}),
executionAuthorization,
next: sentinelReadOnlyNextForSpec(spec, options.sentinelId, decision.authority),
sentinelId: options.sentinelId,
valuesRedacted: true,
};
const next = record(result.next);
const fixReference = typeof next.fixAutomaticDelivery === "string"
? next.fixAutomaticDelivery
: record(next.fixAutomaticDelivery).reference ?? PAC_AUTOMATIC_DELIVERY_REFERENCE;
return Object.assign(
rendered(false, command, [
"WEB SENTINEL DELIVERY BLOCKED",
`mode=${text(result.mode)} mutation=false authority=${text(record(result.deliveryAuthority).kind)}`,
`status=${text(next.status)}`,
`history=${text(next.history ?? next.pacHistory)}`,
`fix=${text(fixReference)}`,
"GitHub PR merge 是唯一正式交付触发;修复自动链后用新的正常合并事件验收。",
].join("\n")),
result,
);
}
export function sentinelReadOnlyNextForSpec(
spec: HwlabRuntimeLaneSpec,
sentinelId: string | null,
authority: CicdDeliveryAuthority,
): Record<string, string> {
const suffix = sentinelId === null ? "" : ` --sentinel ${sentinelId}`;
const controlPlaneStatus = `bun scripts/cli.ts web-probe sentinel control-plane status --node ${spec.nodeId} --lane ${spec.lane}${suffix}`;
const base = {
status: controlPlaneStatus,
controlPlaneStatus,
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
if (authority.kind !== "pac-pr-merge") return base;
const pacHistory = `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${authority.consumer.node} --consumer ${authority.consumer.consumerId} --limit 10`;
return {
...base,
pacStatus: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${authority.consumer.node} --consumer ${authority.consumer.consumerId}`,
pacHistory,
history: pacHistory,
};
}
export function sentinelReadOnlyNext(
state: SentinelCicdState,
authority: CicdDeliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec),
): Record<string, string> {
return sentinelReadOnlyNextForSpec(state.spec, state.sentinelId, authority);
}
export function publishCurrentNext(state: SentinelCicdState): Record<string, string> {
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (deliveryAuthority.kind !== "legacy-manual") return sentinelReadOnlyNext(state, deliveryAuthority);
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
const consumer = sentinelPacConsumerId(state.spec);
const sourceCommit = state.sourceHead.commit === null ? "" : ` --source-commit ${state.sourceHead.commit}`;
return {
pacCloseout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${node} --consumer ${consumer}${sourceCommit} --wait`,
pacStatus: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${node} --consumer ${consumer}`,
pacHistory: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${node} --consumer ${consumer} --limit 10`,
manualRecovery: `bun scripts/cli.ts web-probe sentinel publish-current --node ${node} --lane ${lane}${suffix} --confirm --wait --manual-recovery --reason <reason>`,
controlPlaneStatus: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}${suffix}`,
dashboardVerify: `bun scripts/cli.ts web-probe sentinel dashboard verify --node ${node} --lane ${lane}${suffix}`,
gitMirrorStatus: `bun scripts/cli.ts hwlab nodes git-mirror status --node ${node} --lane ${lane}`,
gitMirrorSync: `bun scripts/cli.ts hwlab nodes git-mirror sync --node ${node} --lane ${lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${node} --lane ${lane} --confirm --wait`,
};
}
export function sentinelSourceResolveMode(options: WebProbeSentinelOptions): "cached" | "sync" {
if (options.kind === "image" && options.action === "build" && options.confirm && options.wait) return "sync";
if (options.kind === "control-plane" && options.action === "trigger-current" && options.confirm && options.wait) return "sync";
if (options.kind === "publish" && options.confirm && options.wait) return "sync";
return "cached";
}
export interface SentinelSourceOverride {
readonly commit: string;
readonly stageRef?: string | null;
readonly mirrorCommit?: string | null;
readonly sourceAuthority: WebProbeSentinelSourceAuthority;
}
export function sentinelSourceOverrideFromOptions(options: WebProbeSentinelOptions): SentinelSourceOverride | null {
if (options.kind !== "image" && options.kind !== "control-plane" && options.kind !== "publish") return null;
const override = options.sourceOverride;
if (override.sourceCommit === null && override.sourceStageRef === null && override.sourceMirrorCommit === null && override.sourceAuthority === null) return null;
if (override.sourceCommit === null) throw new Error("--source-commit is required when overriding web-probe sentinel source");
return {
commit: override.sourceCommit,
stageRef: override.sourceStageRef,
mirrorCommit: override.sourceMirrorCommit,
sourceAuthority: override.sourceAuthority ?? "git-mirror-snapshot",
};
}
+15 -2
View File
@@ -37,6 +37,7 @@ import { parseJsonObject, record, shellQuote, statusText } from "./utils";
import { webObserveTable } from "./web-observe-render";
import { nodeRuntimeGitMirrorTarget, printNodeRuntimeTriggerProgress, sleepSync } from "./web-probe";
import { webObserveShort, webObserveText } from "./web-probe-observe";
import { hwlabNodeDeliveryAuthority, hwlabNodeDeliveryNext } from "./delivery-authority";
type NodeScopedDelegatedOptions = ReturnType<typeof parseNodeScopedDelegatedOptions>;
export type NodeRuntimeGitMirrorRunOptions = {
@@ -61,8 +62,20 @@ export function nodeRuntimeExternalPostgresSecretRows(secrets: Record<string, un
}
export function nodeRuntimeGitMirrorStatus(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
if (scoped.spec.sourceAuthority?.mode === "giteaSnapshot") return nodeRuntimeGiteaSourceStatus(scoped);
return nodeRuntimeLegacyGitMirrorStatus(scoped);
const result = scoped.spec.sourceAuthority?.mode === "giteaSnapshot"
? nodeRuntimeGiteaSourceStatus(scoped)
: nodeRuntimeLegacyGitMirrorStatus(scoped);
const deliveryAuthority = hwlabNodeDeliveryAuthority(scoped);
if (deliveryAuthority.kind === "legacy-manual") return { ...result, deliveryAuthority };
return {
...result,
deliveryAuthority,
next: hwlabNodeDeliveryNext(
scoped,
deliveryAuthority,
`bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane} --full`,
),
};
}
function nodeRuntimeGiteaSourceStatus(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
+24 -5
View File
@@ -52,6 +52,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
&& sentinelActionRaw !== "image"
&& sentinelActionRaw !== "control-plane"
&& sentinelActionRaw !== "publish-current"
&& sentinelActionRaw !== "pac-publish-current"
&& sentinelActionRaw !== "validate"
&& sentinelActionRaw !== "maintenance"
&& sentinelActionRaw !== "dashboard"
@@ -60,7 +61,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
&& sentinelActionRaw !== "inspect-url"
&& sentinelActionRaw !== "inspect-id"
) {
throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|publish-current|validate|maintenance|dashboard|error|report|inspect-url|inspect-id --node NODE --lane vNN [--dry-run|--confirm]");
throw new Error("web-probe sentinel usage: sentinel plan|status|image status|control-plane status|validate|maintenance|dashboard|error|report|inspect-url|inspect-id --node NODE --lane vNN; migrated delivery is triggered only by GitHub PR merge");
}
assertKnownOptions(args, new Set([
"--node",
@@ -131,19 +132,37 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
sentinel = { kind: "config", action: sentinelActionRaw, node, lane, sentinelId, dryRun };
} else if (sentinelActionRaw === "image") {
const imageAction = args[1];
if (imageAction !== "status" && imageAction !== "build") throw new Error("web-probe sentinel image usage: image status|build --node NODE --lane vNN [--dry-run|--confirm]");
if (imageAction !== "status" && imageAction !== "build") throw new Error("web-probe sentinel image usage: image status --node NODE --lane vNN; migrated image delivery is triggered only by GitHub PR merge");
sentinel = { kind: "image", action: imageAction, node, lane, sentinelId, dryRun: imageAction === "build" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, sourceOverride };
} else if (sentinelActionRaw === "control-plane") {
const controlPlaneAction = args[1];
if (controlPlaneAction !== "plan" && controlPlaneAction !== "apply" && controlPlaneAction !== "status" && controlPlaneAction !== "trigger-current") {
throw new Error("web-probe sentinel control-plane usage: control-plane plan|apply|status|trigger-current --node NODE --lane vNN [--dry-run|--confirm]");
throw new Error("web-probe sentinel control-plane usage: control-plane plan|status --node NODE --lane vNN; delivery writes are unavailable for migrated PaC consumers");
}
sentinel = { kind: "control-plane", action: controlPlaneAction, node, lane, sentinelId, dryRun: controlPlaneAction === "apply" || controlPlaneAction === "trigger-current" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, rerun: args.includes("--rerun"), sourceOverride };
} else if (sentinelActionRaw === "publish-current") {
} else if (sentinelActionRaw === "publish-current" || sentinelActionRaw === "pac-publish-current") {
const manualRecovery = args.includes("--manual-recovery") || args.includes("--recovery");
const reason = optionValue(args, "--reason") ?? null;
if (manualRecovery && (reason === null || reason.trim().length < 8)) throw new Error("web-probe sentinel publish-current manual recovery requires --reason with at least 8 characters");
sentinel = { kind: "publish", action: "publish-current", node, lane, sentinelId, dryRun: dryRun || !confirm, confirm, wait: args.includes("--wait"), timeoutSeconds, rerun: args.includes("--rerun"), manualRecovery, reason, sourceOverride };
if (sentinelActionRaw === "pac-publish-current" && (manualRecovery || args.includes("--rerun"))) {
throw new Error("web-probe sentinel pac-publish-current does not accept recovery or rerun options");
}
sentinel = {
kind: "publish",
action: "publish-current",
node,
lane,
sentinelId,
dryRun: dryRun || !confirm,
confirm,
wait: args.includes("--wait"),
timeoutSeconds,
rerun: args.includes("--rerun"),
manualRecovery,
reason,
internalExecution: sentinelActionRaw === "pac-publish-current" ? "pac-controller" : null,
sourceOverride,
};
} else if (sentinelActionRaw === "maintenance") {
const maintenanceAction = args[1];
if (maintenanceAction !== "status" && maintenanceAction !== "start" && maintenanceAction !== "stop") {
@@ -891,10 +891,6 @@ sys.exit(0 if payload["ok"] else 1)
PY
}
run_mirror_webhook_test() {
printf '{"ok":false,"mutation":false,"mode":"manual-source-delivery-test-disabled","error":"only real GitHub PR merge deliveries may enter the durable inbox","valuesPrinted":false}\n'
exit 2
}
case "$UNIDESK_GITEA_ACTION" in
apply) run_apply ;;
status) run_status ;;
+37 -66
View File
@@ -19,6 +19,7 @@ import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy"
import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle, validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy";
import type { GiteaWebhookIngressRetry } from "./platform-infra-gitea-caddy";
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
import { PAC_AUTOMATIC_DELIVERY_REFERENCE } from "./cicd-delivery-authority";
const configFile = rootPath("config", "platform-infra", "gitea.yaml");
const configLabel = "config/platform-infra/gitea.yaml";
@@ -319,7 +320,7 @@ interface MirrorRemoteParams {
export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action = "plan"] = args;
if (action === "help" || action === "--help") return giteaHelp();
if (action === "help" || action === "--help") return giteaHelp(args[1]);
if (action === "plan") {
const options = parseCommonOptions(args.slice(1));
const result = plan(options);
@@ -345,17 +346,31 @@ export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args:
};
}
export function giteaHelp(): Record<string, unknown> {
export function giteaHelp(scope?: string): Record<string, unknown> {
if (scope === "platform-bootstrap") {
return {
command: "platform-infra gitea help platform-bootstrap",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --confirm",
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target <NODE> --confirm",
"bun scripts/cli.ts platform-infra gitea mirror webhook apply --target <NODE> --confirm",
],
boundary: "这些命令只用于平台首次引导或独立平台维护,不能补齐 PR merge 后的 source delivery。",
};
}
return {
command: "platform-infra gitea status|validate|mirror status|mirror webhook status",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra gitea status --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea validate --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror status --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea status --target <NODE> [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea validate --target <NODE> [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror status --target <NODE> [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target <NODE> [--full|--raw]",
],
boundary: "PR merge is the only delivery trigger; these default entries are read-only observation and validation, never post-merge repair actions.",
scopedHelp: ["bun scripts/cli.ts platform-infra gitea help platform-bootstrap"],
boundary: "PR merge 是唯一 delivery 触发;默认入口只读观察和校验,不能充当合并后的恢复动作。",
};
}
@@ -830,7 +845,7 @@ function plan(options: CommonOptions): Record<string, unknown> {
objects: manifestObjectSummary(manifest),
},
policy,
next: nextCommands(target.id),
next: giteaReadOnlyNextCommands(target.id),
};
}
@@ -859,7 +874,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
secrets: { frpc: frpcSecretSummary(frpcSecret), webhookSync: secrets === undefined ? { enabled: false } : webhookSecretSummary(gitea, target, secrets) },
pk01Caddy: caddy,
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id),
next: giteaReadOnlyNextCommands(target.id),
};
}
@@ -877,10 +892,7 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise<Re
config: configSummary(gitea, target),
summary,
remote: options.raw ? parsed : options.full ? parsed : summary ?? compactCapture(result, { full: true }),
next: {
apply: `bun scripts/cli.ts platform-infra gitea apply --target ${target.id} --confirm`,
validate: `bun scripts/cli.ts platform-infra gitea validate --target ${target.id}`,
},
next: giteaReadOnlyNextCommands(target.id),
};
}
@@ -950,11 +962,6 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom
const result = await mirrorWebhookStatus(config, options);
return options.full || options.raw ? result : renderMirrorWebhookStatus(result);
}
if (action === "test") {
const options = parseMirrorOptions(args.slice(1));
const result = await mirrorWebhookTest(config, options);
return options.full || options.raw ? result : renderMirrorWebhookTest(result);
}
return {
ok: false,
error: "unsupported-platform-infra-gitea-mirror-webhook-command",
@@ -974,7 +981,7 @@ function mirrorPlan(options: CommonOptions): Record<string, unknown> {
responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary),
repositories: repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)),
credentials: credentialSummaries(gitea),
next: mirrorSetupNextCommands(target.id),
next: mirrorReadOnlyNextCommands(target.id),
};
}
@@ -1109,21 +1116,6 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorOptions
};
}
async function mirrorWebhookTest(config: UniDeskConfig, options: MirrorOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
return {
ok: false,
action: "platform-infra-gitea-mirror-webhook-test",
mutation: false,
mode: "manual-source-delivery-test-disabled",
target: targetSummary(target),
webhook: webhookSyncSummary(gitea, target),
error: "manual webhook source-delivery tests are disabled; only real GitHub PR merge deliveries may enter the durable inbox",
next: mirrorReadOnlyNextCommands(target.id),
};
}
function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string {
const app = gitea.app;
const image = `${app.image.repository}:${app.image.tag}`;
@@ -2064,22 +2056,13 @@ function remoteRepoSpec(repo: GiteaMirrorRepository): Record<string, unknown> {
};
}
function mirrorSetupNextCommands(targetId: string): Record<string, string> {
return {
bootstrap: `bun scripts/cli.ts platform-infra gitea mirror bootstrap --target ${targetId} --confirm`,
status: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
webhookApply: `bun scripts/cli.ts platform-infra gitea mirror webhook apply --target ${targetId} --confirm`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
full: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId} --full`,
};
}
function mirrorReadOnlyNextCommands(targetId: string): Record<string, string> {
return {
status: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
statusFull: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId} --full`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
webhookStatusFull: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId} --full`,
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
}
@@ -2278,8 +2261,10 @@ function renderMirrorPlan(result: Record<string, unknown>): RenderedCliResult {
...(responsibilities.length === 0 ? ["-"] : table(["NAME", "CURRENT", "TARGET", "DISPOSITION"], responsibilities)),
"",
"NEXT",
` bootstrap: ${stringValue(next.bootstrap)}`,
` status: ${stringValue(next.status)}`,
` full: ${stringValue(next.statusFull)}`,
` webhook-status: ${stringValue(next.webhookStatus)}`,
` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`,
"",
"Disclosure: credentials are summarized by presence/fingerprint only.",
]);
@@ -2403,22 +2388,6 @@ function renderMirrorWebhookStatus(result: Record<string, unknown>): RenderedCli
]);
}
function renderMirrorWebhookTest(result: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), boolText(repo.pingOk), boolText(repo.testOk), boolText(repo.sourceBundleReady), stringValue(repo.sourceCommit ?? repo.branchCommit).slice(0, 12), stringValue(repo.snapshotCommit).slice(0, 12), compactTail(stringValue(repo.error))]);
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook test", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK TEST",
...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]),
"",
"TEST",
...(repos.length === 0 ? ["-"] : table(["KEY", "GH_PING", "POST_OK", "SNAPSHOT_READY", "SOURCE", "SNAPSHOT", "ERROR"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.webhookStatus)}`,
]);
}
function renderPlan(result: Record<string, unknown>): RenderedCliResult {
const config = record(result.config);
const target = record(config.target);
@@ -2441,10 +2410,11 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
]),
"",
"NEXT",
` dry-run: ${stringValue(next.dryRun)}`,
` apply: ${stringValue(next.apply)}`,
` status: ${stringValue(next.status)}`,
` validate: ${stringValue(next.validate)}`,
` mirror-status: ${stringValue(next.mirrorStatus)}`,
` webhook-status: ${stringValue(next.webhookStatus)}`,
` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`,
"",
"Boundary: Gitea is internal ClusterIP source authority for GH-1560; CI trigger authority is Pipelines-as-Code, not Gitea Actions/act_runner.",
"Disclosure: Secret values are not printed; this stage does not create runner credentials.",
@@ -2604,12 +2574,13 @@ function parseCommonOptions(args: string[]): CommonOptions {
return { targetId, full, raw };
}
function nextCommands(targetId: string): Record<string, string> {
function giteaReadOnlyNextCommands(targetId: string): Record<string, string> {
return {
dryRun: `bun scripts/cli.ts platform-infra gitea apply --target ${targetId} --dry-run`,
apply: `bun scripts/cli.ts platform-infra gitea apply --target ${targetId} --confirm`,
status: `bun scripts/cli.ts platform-infra gitea status --target ${targetId}`,
validate: `bun scripts/cli.ts platform-infra gitea validate --target ${targetId}`,
mirrorStatus: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
}
@@ -261,6 +261,7 @@ pipeline_rows() {
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file"
node - "$payload_file" <<'NODE'
const fs = require('node:fs');
const { classifyPacPipelineRun, pipelineRunMatchesConsumer } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const input = fs.readFileSync(process.argv[2], 'utf8') || '{"items":[]}';
const data = input ? JSON.parse(input) : { items: [] };
function firstString(...values) {
@@ -311,17 +312,19 @@ function durationSeconds(item) {
}
const prefix = process.env.UNIDESK_PAC_PIPELINE_RUN_PREFIX;
const pipeline = process.env.UNIDESK_PAC_PIPELINE_NAME;
const consumer = {
id: process.env.UNIDESK_PAC_CONSUMER_ID,
repository: process.env.UNIDESK_PAC_REPOSITORY_NAME,
pipelineRunPrefix: prefix,
pipeline,
};
const rows = (data.items || [])
.filter((item) => {
const labels = item.metadata?.labels || {};
const name = item.metadata?.name || '';
return name.startsWith(prefix)
|| labels['tekton.dev/pipeline'] === pipeline
|| labels['tekton.dev/pipelineName'] === pipeline;
})
.sort((a, b) => Date.parse(b.metadata?.creationTimestamp || 0) - Date.parse(a.metadata?.creationTimestamp || 0))
.filter((item) => pipelineRunMatchesConsumer(consumer, item))
.map((item) => ({ item, classification: classifyPacPipelineRun(consumer, item) }))
.filter(({ classification }) => classification.deliveryClass === 'outer-pac-event' && classification.deliveryAuthorityEligible === true)
.sort((a, b) => Date.parse(b.item.metadata?.creationTimestamp || 0) - Date.parse(a.item.metadata?.creationTimestamp || 0))
.slice(0, 8)
.map((item) => {
.map(({ item, classification }) => {
const c = cond(item);
const params = mapParams(item.spec?.params);
return {
@@ -333,6 +336,9 @@ const rows = (data.items || [])
completionTime: item.status?.completionTime || null,
durationSeconds: durationSeconds(item),
sourceCommit: extractCommit(item, params),
deliveryClass: classification.deliveryClass,
deliveryAuthorityEligible: classification.deliveryAuthorityEligible,
classification,
};
});
process.stdout.write(JSON.stringify(rows));
@@ -344,7 +350,7 @@ history_rows() {
node <<'NODE'
const fs = require('node:fs');
const cp = require('node:child_process');
const { extractPacArtifactEvidence, parsePacLogRecords, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const { classifyPacPipelineRun, extractPacArtifactEvidence, parsePacLogRecords, pipelineRunMatchesConsumer, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const limit = Math.max(1, Math.min(50, Number.parseInt(process.env.UNIDESK_PAC_HISTORY_LIMIT || '5', 10) || 5));
const detailId = process.env.UNIDESK_PAC_HISTORY_ID || '';
const consumers = JSON.parse(Buffer.from(process.env.UNIDESK_PAC_HISTORY_CONSUMERS_B64 || 'W10=', 'base64').toString('utf8'));
@@ -612,6 +618,7 @@ function rowFor(consumer, item, taskRuns) {
...evidence.sourceObservation,
sourceMatched: sourceCommit !== null && evidence.sourceObservation.sourceCommit === sourceCommit,
};
const classification = classifyPacPipelineRun(consumer, item);
return {
id: item.metadata.name,
consumer: consumer.id,
@@ -634,6 +641,13 @@ function rowFor(consumer, item, taskRuns) {
branch: extractBranch(item, params),
eventType: firstString(labels['pipelinesascode.tekton.dev/event-type'], annotations['pipelinesascode.tekton.dev/event-type']),
sender: firstString(labels['pipelinesascode.tekton.dev/sender'], annotations['pipelinesascode.tekton.dev/sender']),
deliveryClass: classification.deliveryClass,
deliveryAuthorityEligible: classification.deliveryAuthorityEligible,
deliveryOwner: classification.deliveryOwner,
executionOwner: classification.executionOwner,
parentPipelineRun: classification.parentPipelineRun,
parentRelation: classification.parentRelation,
classification,
envReuse: {
status: evidence.status,
buildSkippedCount: evidence.buildSkippedCount,
@@ -645,19 +659,15 @@ function rowFor(consumer, item, taskRuns) {
artifact: evidence.artifact,
collector: evidence.collector,
taskRuns: tasks,
source: 'gitea-pac-tekton-live',
source: classification.deliveryClass === 'outer-pac-event'
? 'gitea-pac-event-observed'
: classification.deliveryClass === 'inner-deterministic-publish'
? 'tekton-deterministic-publish-observed'
: 'tekton-unclassified-observed',
valuesPrinted: false,
};
}
function pipelineRunMatchesConsumer(consumer, item) {
const labels = item.metadata?.labels || {};
const name = item.metadata?.name || '';
return name.startsWith(consumer.pipelineRunPrefix)
|| labels['tekton.dev/pipeline'] === consumer.pipeline
|| labels['tekton.dev/pipelineName'] === consumer.pipeline;
}
const rows = [];
const consumerSummaries = [];
for (const consumer of consumers) {
@@ -671,7 +681,18 @@ for (const consumer of consumers) {
matches = matches.sort((a, b) => Date.parse(b.metadata?.creationTimestamp || 0) - Date.parse(a.metadata?.creationTimestamp || 0));
if (!detailId) matches = matches.slice(0, limit);
for (const item of matches) rows.push(rowFor(consumer, item, taskRuns));
consumerSummaries.push({ id: consumer.id, namespace: consumer.namespace, repository: consumer.repository, pipelineRunPrefix: consumer.pipelineRunPrefix, totalPipelineRuns: (pipelineRuns.items || []).length, matched: matches.length });
const classified = matches.map((item) => classifyPacPipelineRun(consumer, item));
consumerSummaries.push({
id: consumer.id,
namespace: consumer.namespace,
repository: consumer.repository,
pipelineRunPrefix: consumer.pipelineRunPrefix,
totalPipelineRuns: (pipelineRuns.items || []).length,
matched: matches.length,
outerPacEvents: classified.filter((item) => item.deliveryClass === 'outer-pac-event').length,
innerDeterministicPublishes: classified.filter((item) => item.deliveryClass === 'inner-deterministic-publish').length,
unknown: classified.filter((item) => item.deliveryClass === 'unknown').length,
});
}
rows.sort((a, b) => Date.parse(b.triggeredAt || 0) - Date.parse(a.triggeredAt || 0));
process.stdout.write(JSON.stringify({ rows, consumers: consumerSummaries, errors: kubectlJsonErrors }));
@@ -1246,6 +1267,7 @@ function breakAt(code, stage, reason) {
}
if (historyErrors.length > 0) breakAt('target-read-failed', 'target-read', historyErrors[0]?.error || historyErrors[0]?.stderr || 'target-side history read failed');
if (Object.keys(row).length === 0) breakAt('pipeline-run-not-found', 'pipeline-run', `PipelineRun ${process.env.UNIDESK_PAC_HISTORY_ID || '-'} was not found for the selected consumer`);
if (Object.keys(row).length > 0 && row.deliveryAuthorityEligible !== true) breakAt('pipeline-run-not-delivery-authority', 'pipeline-run', `PipelineRun is classified as ${row.deliveryClass || 'unknown'}; only an outer PaC event can drive delivery closeout`);
if (Object.keys(row).length > 0 && row.status !== 'True') breakAt('pipeline-run-not-succeeded', 'pipeline-run', `PipelineRun terminal status is ${row.status || row.reason || 'unknown'}`);
if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'g14-ci-plan structured evidence is missing');
if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'g14-ci-plan source commit does not match the selected PipelineRun');
@@ -1269,6 +1291,11 @@ const realRun = {
reason: row.reason || null,
sourceCommit: row.commit || null,
sourceMatched: sourceObservation.sourceMatched === true,
deliveryClass: row.deliveryClass || 'unknown',
deliveryAuthorityEligible: row.deliveryAuthorityEligible === true,
deliveryOwner: row.deliveryOwner || null,
executionOwner: row.executionOwner || null,
parentRelation: row.parentRelation || null,
valuesPrinted: false,
},
mode: sourceObservation.mode || null,
@@ -1316,19 +1343,10 @@ if (!out.ok) process.exitCode = 1;
NODE
}
webhook_test_action() {
hooks=$(hook_summary)
hook_id=$(printf '%s' "$hooks" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(String(a[0]?.id||""))')
test -n "$hook_id"
gitea_api POST "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$hook_id/tests" '{}' >/dev/null
printf '{"ok":true,"mutation":true,"webhookTestSent":true,"hookId":"%s","valuesPrinted":false}\n' "$(json_string "$hook_id")"
}
case "$UNIDESK_PAC_ACTION" in
apply) apply_action ;;
status) status_action ;;
history) history_action ;;
debug-step) debug_step_action ;;
webhook-test) webhook_test_action ;;
*) printf '{"ok":false,"error":"unsupported-action","valuesPrinted":false}\n'; exit 2 ;;
esac
+186 -306
View File
@@ -3,9 +3,6 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
import { dirname, join } from "node:path";
import type { UniDeskConfig } from "./config";
import { rootPath } from "./config";
import { parseNodeScopedDelegatedOptions } from "./hwlab-node/plan";
import { nodeRuntimeEnsureGitMirrorFlushed } from "./hwlab-node/status";
import { runGitMirrorJob as runAgentRunGitMirrorJob } from "./agentrun/rest-bridge";
import type { RenderedCliResult } from "./output";
import {
capture,
@@ -17,6 +14,7 @@ import {
sha256Fingerprint,
} from "./platform-infra-ops-library";
import { materializeYamlComposition } from "./yaml-composition";
import { pacNodeReadOnlyNext, pacReadOnlyNext, resolveCicdDeliveryAuthority } from "./cicd-delivery-authority";
import {
canonicalizePacPipelineSpec,
pacSourceArtifactSafeError,
@@ -78,6 +76,13 @@ interface PacConfig {
controllerServicePort: number;
waitTimeoutSeconds: number;
};
capabilities: {
sentinelInternalPublish: {
enabled: boolean;
admissionProvenance: "unavailable";
trackingIssue: string;
};
};
closeout: {
gitOpsMirrorFlush: {
enabled: boolean;
@@ -167,6 +172,17 @@ interface HistoryOptions extends CommonOptions {
detailId: string | null;
}
export interface PacHistoryConsumerIdentity {
readonly id: string;
readonly node: string;
readonly pipelineRunPrefix: string;
}
export interface PacHistoryConsumerSelection {
readonly selectedConsumerIds: readonly string[];
readonly nextConsumerId: string | null;
}
interface CloseoutOptions extends CommonOptions {
sourceCommit: string | null;
wait: boolean;
@@ -178,10 +194,6 @@ interface ApplyOptions extends CommonOptions {
wait: boolean;
}
interface WebhookTestOptions extends CommonOptions {
confirm: boolean;
}
interface SecretMaterial {
adminUsername: string;
adminPassword: string;
@@ -193,7 +205,7 @@ interface SecretMaterial {
export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action = "plan"] = args;
if (action === "help" || action === "--help") return help();
if (action === "help" || action === "--help") return help(args[1] ?? null);
if (action === "plan") {
const options = parseCommonOptions(args.slice(1));
const result = plan(options);
@@ -224,11 +236,6 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
const result = await debugStep(config, options);
return options.full || options.raw ? result : options.json ? result : renderDebugStep(result);
}
if (action === "webhook-test") {
const options = parseWebhookTestOptions(args.slice(1));
const result = await webhookTest(config, options);
return options.json || options.full || options.raw ? result : renderWebhookTest(result);
}
if (action === "source-artifact") {
if (args.length === 1 || args.slice(1).includes("--help") || args.slice(1).includes("-h")) return sourceArtifactHelp();
const structuredError = args.includes("--json") || args.includes("--full");
@@ -275,7 +282,7 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
return sourceArtifactValidationError(safeError);
}
}
return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help() };
return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help(null) };
}
function sourceArtifactHelp(): RenderedCliResult {
@@ -307,31 +314,57 @@ function sourceArtifactValidationError(error: ReturnType<typeof pacSourceArtifac
};
}
function help(): Record<string, unknown> {
function help(scope: string | null): Record<string, unknown> {
if (scope === "platform-bootstrap") {
return {
command: "platform-infra pipelines-as-code help platform-bootstrap",
scope: "explicit-initial-platform-bootstrap",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --confirm",
],
boundary: "仅用于首次安装或显式平台 bootstrap;不得在 source PR 合并后用于交付、恢复或补跑。",
mutation: "explicit-confirm-required",
};
}
if (scope === "compatibility-diagnostics") {
return {
command: "platform-infra pipelines-as-code help compatibility-diagnostics",
scope: "explicit-readonly-or-connectivity-diagnostics",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target <NODE> --consumer <consumer> --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target <NODE> --consumer <consumer> --full",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target <NODE> --consumer <consumer> --limit 10 --full",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target <NODE> --consumer <consumer> --json",
],
boundary: "本 scope 只允许读取现有 PaC/Tekton/Argo 证据;不得 POST hook test、伪造 push、交付、恢复或补跑。",
};
}
return {
command: "platform-infra pipelines-as-code plan|apply|status|closeout|history|debug-step|source-artifact",
command: "platform-infra pipelines-as-code plan|status|history|debug-step|source-artifact",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --dry-run",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer <consumer> --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id <pipelinerun>",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 [--consumer <consumer>] [--id <pipelinerun>] [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree",
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact write --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runtime --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree --source-commit <full-sha>",
],
diagnostics: "webhook-test exists only for bounded connectivity diagnosis and must not be used as delivery evidence.",
boundary: "Sole CI trigger path for GH-1552/GH-1607: GitHub PR merge -> GitHub webhook bridge -> Gitea mirror/snapshot -> Pipelines-as-Code -> Tekton -> GitOps/Argo/k8s runtime.",
maintenanceHelp: "bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap",
compatibilityHelp: "bun scripts/cli.ts platform-infra pipelines-as-code help compatibility-diagnostics",
boundary: "唯一正式触发链:GitHub PR merge -> GitHub webhook bridge -> Gitea mirror/snapshot -> Pipelines-as-Code -> Tekton -> GitOps/Argo/k8s runtime。",
};
}
function readPacConfig(): PacConfig {
const root = materializeYamlComposition(readYamlRecord<Record<string, unknown>>(configFile, "platform-infra-pipelines-as-code"), { label: configLabel }).value;
const release = y.objectField(root, "release", "");
const capabilities = y.objectField(root, "capabilities", "");
const sentinelInternalPublish = y.objectField(capabilities, "sentinelInternalPublish", "capabilities");
const closeout = y.objectField(root, "closeout", "");
const gitOpsMirrorFlush = y.objectField(closeout, "gitOpsMirrorFlush", "closeout");
const gitea = y.objectField(root, "gitea", "");
@@ -369,6 +402,13 @@ function readPacConfig(): PacConfig {
controllerServicePort: y.portField(release, "controllerServicePort", "release"),
waitTimeoutSeconds: positiveInteger(release, "waitTimeoutSeconds", "release"),
},
capabilities: {
sentinelInternalPublish: {
enabled: y.booleanField(sentinelInternalPublish, "enabled", "capabilities.sentinelInternalPublish"),
admissionProvenance: y.enumField(sentinelInternalPublish, "admissionProvenance", "capabilities.sentinelInternalPublish", ["unavailable"] as const),
trackingIssue: urlField(sentinelInternalPublish, "trackingIssue", "capabilities.sentinelInternalPublish"),
},
},
closeout: {
gitOpsMirrorFlush: {
enabled: y.booleanField(gitOpsMirrorFlush, "enabled", "closeout.gitOpsMirrorFlush"),
@@ -712,8 +752,7 @@ function plan(options: CommonOptions): Record<string, unknown> {
config: configSummary(pac, consumer, repository),
release: pac.release,
repository: repositorySummary(repository),
consumer,
closeout: closeoutDeclaration(pac, consumer),
consumer: consumerObservationSummary(consumer),
secrets,
policy: policyChecks(repository),
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
@@ -738,7 +777,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
config: compactConfigSummary(pac, consumer, repository),
release: { version: pac.release.version, manifestUrl: pac.release.manifestUrl },
repository: repositorySummary(repository),
consumer,
consumer: consumerObservationSummary(consumer),
secrets: secretSummaries(pac),
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
@@ -754,14 +793,15 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise<Re
const result = await capture(config, target.route, ["sh"], remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, ""));
const parsed = parseJsonOutput(result.stdout);
const summary = parsed === null ? null : statusSummary(parsed);
const deliveryAuthority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane });
return {
ok: result.exitCode === 0 && summary?.ready === true,
ok: result.exitCode === 0 && summary?.ready === true && deliveryAuthority.kind === "pac-pr-merge",
action: "platform-infra-pipelines-as-code-status",
mutation: false,
target: targetSummary(target),
config: compactConfigSummary(pac, consumer, repository),
consumer,
closeout: closeoutDeclaration(pac, consumer),
consumer: consumerObservationSummary(consumer),
deliveryAuthority,
coverage: consumerCoverage(pac, target.id),
summary,
remote: parsed === null ? compactCapture(result, { full: true }) : options.raw ? parsed : undefined,
@@ -827,12 +867,7 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC
elapsedMs: Date.now() - startedAt,
valuesPrinted: false,
},
next: {
status: `bun scripts/cli.ts cicd status --node ${nodeId}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${target.id} --limit 10`,
closeout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${target.id} --wait`,
valuesPrinted: false,
},
next: pacNodeReadOnlyNext(target.id),
details: options.full || options.raw ? statuses : undefined,
valuesPrinted: false,
};
@@ -859,20 +894,20 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis
let observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics);
let sourceMatched = options.sourceCommit === null || observedSourceCommit === options.sourceCommit;
const ciReady = closeoutCiReady(current, options.sourceCommit);
printCloseoutProgress(target, consumer, "gitops-mirror-flush", "started", startedAt, {
required: pac.closeout.gitOpsMirrorFlush.enabled && consumer.closeoutGitOpsMirrorFlush,
ciReady,
sourceCommit: observedSourceCommit,
});
let gitOpsMirrorFlush = await runCloseoutGitOpsMirrorFlush(config, pac, consumer, summary, latest, ciReady, observedSourceCommit);
printCloseoutProgress(target, consumer, "gitops-mirror-flush", record(gitOpsMirrorFlush).ok === false ? "failed" : "completed", startedAt, {
mode: record(gitOpsMirrorFlush).mode,
executed: record(gitOpsMirrorFlush).executed === true,
required: record(gitOpsMirrorFlush).required === true,
});
const gitOpsMirrorFlushReady = record(gitOpsMirrorFlush).ok !== false;
let gitOpsMirrorFlush: Record<string, unknown> = {
ok: true,
enabled: false,
configured: consumer.closeoutGitOpsMirrorFlush,
required: false,
applicable: false,
mode: "automatic-chain-owned-observation",
executed: false,
reason: "PaC migrated delivery owns GitOps publication and mirror persistence; compatibility closeout is read-only.",
configSource: configLabel,
valuesPrinted: false,
};
const runtimeStartedAt = Date.now();
if (ciReady && gitOpsMirrorFlushReady) {
if (ciReady) {
current = await observeCloseoutStatus(config, options, target, consumer, "runtime", 1, startedAt);
runtimeAttempts += 1;
while (options.wait && !closeoutReady(current, options.sourceCommit) && Date.now() - runtimeStartedAt < waitMs) {
@@ -892,7 +927,7 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis
afterSummary: summary,
valuesPrinted: false,
};
const ready = baseReady && gitOpsMirrorFlushReady;
const ready = baseReady;
printCloseoutProgress(target, consumer, "closeout", ready ? "completed" : "blocked", startedAt, {
ready,
sourceMatched,
@@ -903,7 +938,7 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis
return {
ok: ready,
action: "platform-infra-pipelines-as-code-closeout",
mutation: record(gitOpsMirrorFlush).executed === true,
mutation: false,
target: targetSummary(target),
consumer,
sourceCommit: options.sourceCommit,
@@ -962,13 +997,8 @@ function printCloseoutProgress(target: PacTarget, consumer: PacConsumer, stage:
async function history(config: UniDeskConfig, options: HistoryOptions): Promise<Record<string, unknown>> {
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId);
const targetConsumers = pac.consumers.filter((consumer) => consumer.node.toLowerCase() === target.id.toLowerCase());
const inferredDetailConsumers = options.detailId === null
? []
: targetConsumers.filter((consumer) => options.detailId?.startsWith(consumer.pipelineRunPrefix));
const selectedConsumers = options.consumerId === null
? inferredDetailConsumers.length === 1 ? inferredDetailConsumers : targetConsumers
: [resolveConsumer(pac, options.consumerId)];
const selection = resolvePacHistoryConsumerSelection(pac.consumers, target.id, options.consumerId, options.detailId);
const selectedConsumers = selection.selectedConsumerIds.map((id) => resolveConsumer(pac, id));
const firstConsumer = selectedConsumers[0];
if (firstConsumer === undefined) throw new Error("no Pipelines-as-Code consumers are configured");
const firstRepository = resolveRepository(pac, firstConsumer.repositoryRef);
@@ -1003,7 +1033,46 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise<
valuesPrinted: false,
},
remote: parsed === null || options.raw ? remote : undefined,
next: nextCommands(target.id, options.consumerId ?? pac.defaults.consumerId, pac.defaults.consumerId),
next: selection.nextConsumerId === null
? pacNodeReadOnlyNext(target.id)
: pacReadOnlyNext(target.id, selection.nextConsumerId),
};
}
export function resolvePacHistoryConsumerSelection(
consumers: readonly PacHistoryConsumerIdentity[],
targetId: string,
requestedConsumerId: string | null,
detailId: string | null,
): PacHistoryConsumerSelection {
const targetConsumers = consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase());
if (targetConsumers.length === 0) throw new Error(`no Pipelines-as-Code consumers are configured for target ${targetId}`);
const requestedConsumer = requestedConsumerId === null
? null
: consumers.find((consumer) => consumer.id.toLowerCase() === requestedConsumerId.toLowerCase()) ?? null;
if (requestedConsumerId !== null && requestedConsumer === null) {
throw new Error(`unknown Pipelines-as-Code consumer ${requestedConsumerId}`);
}
if (requestedConsumer !== null && requestedConsumer.node.toLowerCase() !== targetId.toLowerCase()) {
throw new Error(`Pipelines-as-Code consumer ${requestedConsumer.id} belongs to ${requestedConsumer.node}, not target ${targetId}`);
}
if (detailId !== null) {
const matches = targetConsumers.filter((consumer) => detailId.startsWith(consumer.pipelineRunPrefix));
if (matches.length !== 1) {
throw new Error(`PipelineRun ${detailId} must match exactly one consumer on target ${targetId}; matched ${matches.length}`);
}
const matched = matches[0] as PacHistoryConsumerIdentity;
if (requestedConsumer !== null && requestedConsumer.id.toLowerCase() !== matched.id.toLowerCase()) {
throw new Error(`PipelineRun ${detailId} belongs to ${matched.id}, not requested consumer ${requestedConsumer.id}`);
}
return { selectedConsumerIds: [matched.id], nextConsumerId: matched.id };
}
if (requestedConsumer !== null) {
return { selectedConsumerIds: [requestedConsumer.id], nextConsumerId: requestedConsumer.id };
}
return {
selectedConsumerIds: targetConsumers.map((consumer) => consumer.id),
nextConsumerId: targetConsumers.length === 1 ? (targetConsumers[0] as PacHistoryConsumerIdentity).id : null,
};
}
@@ -1030,26 +1099,6 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis
};
}
async function webhookTest(config: UniDeskConfig, options: WebhookTestOptions): Promise<Record<string, unknown>> {
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId);
const consumer = resolveConsumer(pac, options.consumerId);
const repository = resolveRepository(pac, consumer.repositoryRef);
if (!options.confirm) return { ok: false, action: "platform-infra-pipelines-as-code-webhook-test", mutation: false, mode: "missing-confirm", error: "webhook-test requires --confirm" };
const secrets = ensureSecrets(pac, false);
const result = await capture(config, target.route, ["sh"], remoteScript("webhook-test", pac, target, repository, consumer, { ...options, dryRun: false, wait: false }, secrets, ""));
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-pipelines-as-code-webhook-test",
mutation: true,
target: targetSummary(target),
consumer,
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
};
}
async function fetchReleaseManifest(pac: PacConfig): Promise<string> {
const response = await fetch(pac.release.manifestUrl);
if (!response.ok) throw new Error(`failed to fetch ${pac.release.manifestUrl}: HTTP ${response.status}`);
@@ -1058,7 +1107,7 @@ async function fetchReleaseManifest(pac: PacConfig): Promise<string> {
return text;
}
function remoteScript(action: "apply" | "status" | "history" | "debug-step" | "webhook-test", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | WebhookTestOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string {
function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string {
const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`;
const env: Record<string, string> = {
UNIDESK_PAC_ACTION: action,
@@ -1385,11 +1434,11 @@ function configSummary(pac: PacConfig, consumer: PacConsumer, repository: PacRep
path: configLabel,
metadata: pac.metadata,
release: pac.release,
closeout: pac.closeout,
capabilities: capabilitySummary(pac),
gitea: { configRef: pac.gitea.configRef, internalBaseUrl: pac.gitea.internalBaseUrl, webhookBranch: pac.gitea.webhook.branch },
display: pac.display,
repositories: pac.repositories.map(repositorySummary),
consumers: pac.consumers,
consumers: pac.consumers.map(consumerObservationSummary),
repository: repositorySummary(repository),
consumer,
valuesPrinted: false,
@@ -1400,7 +1449,7 @@ function compactConfigSummary(pac: PacConfig, consumer: PacConsumer, repository:
return {
path: configLabel,
releaseVersion: pac.release.version,
closeout: pac.closeout,
capabilities: capabilitySummary(pac),
repository: repository.name,
providerType: repository.providerType,
sourceUrl: repository.cloneUrl,
@@ -1412,15 +1461,13 @@ function compactConfigSummary(pac: PacConfig, consumer: PacConsumer, repository:
};
}
function closeoutDeclaration(pac: PacConfig, consumer: PacConsumer): Record<string, unknown> {
const config = pac.closeout.gitOpsMirrorFlush;
function capabilitySummary(pac: PacConfig): Record<string, unknown> {
return {
enabled: config.enabled,
configured: consumer.closeoutGitOpsMirrorFlush,
required: config.enabled && consumer.closeoutGitOpsMirrorFlush,
lane: consumer.closeoutGitOpsMirrorLane ?? consumer.lane,
waitTimeoutSeconds: config.waitTimeoutSeconds,
maxAttempts: config.maxAttempts,
sentinelInternalPublish: {
...pac.capabilities.sentinelInternalPublish,
configRef: `${configLabel}#capabilities.sentinelInternalPublish`,
valuesPrinted: false,
},
valuesPrinted: false,
};
}
@@ -1441,34 +1488,38 @@ function repositorySummary(repository: PacRepository): Record<string, unknown> {
};
}
function consumerObservationSummary(consumer: PacConsumer): Record<string, unknown> {
return {
id: consumer.id,
node: consumer.node,
lane: consumer.lane,
namespace: consumer.namespace,
pipeline: consumer.pipeline,
pipelineRunPrefix: consumer.pipelineRunPrefix,
argoNamespace: consumer.argoNamespace,
argoApplication: consumer.argoApplication,
repositoryRef: consumer.repositoryRef,
valuesPrinted: false,
};
}
function consumerCoverage(pac: PacConfig, targetId: string): Record<string, unknown>[] {
return pac.consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase()).map((consumer) => {
const repository = resolveRepository(pac, consumer.repositoryRef);
const suffix = consumerSuffix(consumer.id, pac.defaults.consumerId);
return {
consumer: consumer.id,
source: `${repository.owner}/${repository.repo}@${repository.params.source_branch ?? "-"}`,
namespace: consumer.namespace,
pipeline: consumer.pipeline,
argoApplication: consumer.argoApplication,
statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}${suffix}`,
historyCommand: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId}${suffix}`,
statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumer.id}`,
historyCommand: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --consumer ${consumer.id}`,
};
});
}
function consumerSuffix(consumerId: string, defaultConsumerId: string): string {
return consumerId === defaultConsumerId ? "" : ` --consumer ${consumerId}`;
}
function nextCommands(targetId: string, consumerId: string, defaultConsumerId: string): Record<string, string> {
const suffix = consumerSuffix(consumerId, defaultConsumerId);
return {
apply: `bun scripts/cli.ts platform-infra pipelines-as-code apply --target ${targetId}${suffix} --confirm`,
closeout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${targetId}${suffix} --wait`,
status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}${suffix}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId}${suffix}`,
};
function nextCommands(targetId: string, consumerId: string, _defaultConsumerId: string): Record<string, unknown> {
return pacReadOnlyNext(targetId, consumerId);
}
function compactPlanJson(result: Record<string, unknown>): Record<string, unknown> {
@@ -1483,7 +1534,7 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
path: config.path,
metadata: config.metadata,
release: config.release,
closeout: config.closeout,
capabilities: config.capabilities,
gitea: config.gitea,
display: config.display,
valuesPrinted: false,
@@ -1500,7 +1551,6 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
argoApplication: consumer.argoApplication,
repositoryRef: consumer.repositoryRef,
},
closeout: result.closeout,
secrets: result.secrets,
policy: result.policy,
next: result.next,
@@ -1523,7 +1573,8 @@ function compactStatusJson(result: Record<string, unknown>): Record<string, unkn
pipeline: consumer.pipeline,
argoApplication: consumer.argoApplication,
},
closeout: result.closeout,
deliveryAuthority: result.deliveryAuthority,
capabilities: record(result.config).capabilities,
summary: compactStatusSummary(record(result.summary)),
next: result.next,
valuesPrinted: false,
@@ -1711,7 +1762,7 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
const config = record(result.config);
const repository = record(result.repository);
const consumer = record(result.consumer);
const closeout = record(result.closeout);
const internalPublish = record(record(config.capabilities).sentinelInternalPublish);
const secrets = arrayRecords(result.secrets);
const policy = arrayRecords(result.policy);
const lines = [
@@ -1726,25 +1777,23 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
["cd", "Argo", stringValue(consumer.argoApplication)],
]),
"",
"CLOSEOUT",
...table(["GLOBAL_ENABLED", "CONSUMER_CONFIGURED", "REQUIRED", "LANE", "WAIT_S", "MAX_ATTEMPTS"], [[
boolText(closeout.enabled),
boolText(closeout.configured),
boolText(closeout.required),
stringValue(closeout.lane),
stringValue(closeout.waitTimeoutSeconds),
stringValue(closeout.maxAttempts),
]]),
"",
"SECRETS",
...table(["ID", "PRESENT", "FINGERPRINT", "VALUES"], secrets.map((item) => [stringValue(item.id), boolText(item.present), stringValue(item.fingerprint), "false"])),
"",
"POLICY",
...table(["NAME", "OK", "DETAIL"], policy.map((item) => [stringValue(item.name), boolText(item.ok), stringValue(item.detail)])),
"",
"NEXT",
` apply: ${stringValue(record(result.next).apply)}`,
"CAPABILITIES",
...table(["NAME", "ENABLED", "PROVENANCE", "TRACKING"], [[
"sentinelInternalPublish",
boolText(internalPublish.enabled),
stringValue(internalPublish.admissionProvenance),
stringValue(internalPublish.trackingIssue),
]]),
"",
"READ-ONLY NEXT",
` status: ${stringValue(record(result.next).status)}`,
` history: ${stringValue(record(result.next).history)}`,
];
return rendered(result, "platform-infra pipelines-as-code plan", lines);
}
@@ -1770,7 +1819,8 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
function renderStatus(result: Record<string, unknown>): RenderedCliResult {
const summary = record(result.summary);
const consumer = record(result.consumer);
const closeout = record(result.closeout);
const deliveryAuthority = record(result.deliveryAuthority);
const internalPublish = record(record(record(result.config).capabilities).sentinelInternalPublish);
const coverage = arrayRecords(result.coverage);
const latest = record(summary.latestPipelineRun);
const taskRuns = arrayRecords(summary.taskRuns);
@@ -1788,18 +1838,9 @@ function renderStatus(result: Record<string, unknown>): RenderedCliResult {
...table(["CONSUMER", "SOURCE", "NAMESPACE", "PIPELINE", "ARGO_APP"], coverage.map((item) => [stringValue(item.consumer), stringValue(item.source), stringValue(item.namespace), stringValue(item.pipeline), stringValue(item.argoApplication)])),
"",
]),
...table(["CONSUMER", "READY", "CRD", "CONTROLLER", "WEBHOOKS", "REPO_URL"], [[stringValue(consumer.id), boolText(summary.ready), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), short(stringValue(repository.url), 56)]]),
...table(["CONSUMER", "READY", "AUTHORITY", "CRD", "CONTROLLER", "WEBHOOKS", "REPO_URL"], [[stringValue(consumer.id), boolText(summary.ready), stringValue(deliveryAuthority.kind), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), short(stringValue(repository.url), 56)]]),
` repository-condition: ${compactLine(stringValue(summary.repositoryCondition))}`,
"",
"CLOSEOUT",
...table(["GLOBAL_ENABLED", "CONSUMER_CONFIGURED", "REQUIRED", "LANE", "WAIT_S", "MAX_ATTEMPTS"], [[
boolText(closeout.enabled),
boolText(closeout.configured),
boolText(closeout.required),
stringValue(closeout.lane),
stringValue(closeout.waitTimeoutSeconds),
stringValue(closeout.maxAttempts),
]]),
` sentinel-internal-publish: enabled=${boolText(internalPublish.enabled)} provenance=${stringValue(internalPublish.admissionProvenance)} tracking=${stringValue(internalPublish.trackingIssue)}`,
"",
"GITEA HOOKS",
...(webhooks.length === 0 ? ["-"] : table(["HOOK", "ACTIVE", "EVENTS", "URL"], webhooks.map((item) => [stringValue(item.id), boolText(item.active), Array.isArray(item.events) ? item.events.join(",") : stringValue(item.events), short(stringValue(item.url), 56)]))),
@@ -1852,9 +1893,9 @@ function renderStatus(result: Record<string, unknown>): RenderedCliResult {
` pipeline-run: ${latest.name === undefined ? "-" : `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${stringValue(record(result.target).id)} --id ${stringValue(latest.name)}`}`,
"",
"NEXT",
` closeout: ${stringValue(record(result.next).closeout)}`,
` full: ${stringValue(record(result.next).status)} --full`,
` all-history: ${stringValue(record(result.next).history).replace(/ --consumer [^ ]+/u, "")} --limit 10`,
` fix-automatic-delivery: ${stringValue(record(record(result.next).fixAutomaticDelivery).reference)}`,
];
return rendered(result, "platform-infra pipelines-as-code status", lines);
}
@@ -1905,7 +1946,7 @@ function renderCloseout(result: Record<string, unknown>): RenderedCliResult {
"CICD DIAGNOSIS",
...table(["OK", "CODE", "PHASE", "HINT"], [[boolText(diagnostics.ok !== false), stringValue(diagnostics.code), stringValue(diagnostics.phase), compactLine(stringValue(diagnostics.hint))]]),
"",
"GITOPS MIRROR FLUSH",
"AUTOMATIC DELIVERY OBSERVATION",
...table(["ENABLED", "REQUIRED", "OK", "MODE", "EXECUTED", "PENDING", "IN_SYNC"], [[
boolText(gitOpsMirrorFlush.enabled),
boolText(gitOpsMirrorFlush.required),
@@ -1945,10 +1986,11 @@ function renderHistory(result: Record<string, unknown>): RenderedCliResult {
]),
"",
"TRIGGERS",
...(rows.length === 0 ? ["-"] : table([timeHeader, "CONSUMER", "REPO", "STATUS", "DUR_S", "COMMIT", "ENV_REUSE", "ID"], rows.map((row) => [
...(rows.length === 0 ? ["-"] : table([timeHeader, "CONSUMER", "CLASS", "OWNER", "STATUS", "DUR_S", "COMMIT", "ENV_REUSE", "ID"], rows.map((row) => [
stringValue(row.displayTime) === "-" ? isoShort(stringValue(row.triggeredAt)) : stringValue(row.displayTime),
stringValue(row.consumer),
stringValue(row.repo),
stringValue(row.deliveryClass),
stringValue(row.deliveryOwner ?? row.executionOwner),
statusText(row),
stringValue(row.durationSeconds),
short(stringValue(row.commit)),
@@ -1964,18 +2006,6 @@ function renderHistory(result: Record<string, unknown>): RenderedCliResult {
return rendered(result, "platform-infra pipelines-as-code history", lines.filter((line): line is string => line !== null));
}
function renderWebhookTest(result: Record<string, unknown>): RenderedCliResult {
const remote = record(result.remote);
const lines = [
"PLATFORM-INFRA PIPELINES-AS-CODE WEBHOOK TEST",
...table(["OK", "MUTATION", "HOOK", "SENT"], [[boolText(result.ok), boolText(result.mutation), stringValue(remote.hookId), boolText(remote.webhookTestSent)]]),
"",
"NEXT",
` status: ${stringValue(record(result.next).status)}`,
];
return rendered(result, "platform-infra pipelines-as-code webhook-test", lines);
}
function renderDebugStep(result: Record<string, unknown>): RenderedCliResult {
const checks = arrayRecords(result.checks);
const realRun = record(result.realRun);
@@ -1998,8 +2028,10 @@ function renderDebugStep(result: Record<string, unknown>): RenderedCliResult {
...(Object.keys(realRun).length === 0 ? [] : [
"",
"REAL PIPELINERUN EVIDENCE",
...table(["FOUND", "STATUS", "SOURCE", "MODE", "VALID", "FIRST_BREAK"], [[
...table(["FOUND", "CLASS", "AUTHORITY", "STATUS", "SOURCE", "MODE", "VALID", "FIRST_BREAK"], [[
boolText(realRun.found),
stringValue(pipelineRun.deliveryClass),
boolText(pipelineRun.deliveryAuthorityEligible),
stringValue(pipelineRun.status ?? pipelineRun.reason),
short(stringValue(pipelineRun.sourceCommit), 16),
stringValue(realRun.mode),
@@ -2064,23 +2096,6 @@ function parseApplyOptions(args: string[]): ApplyOptions {
return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait };
}
function parseWebhookTestOptions(args: string[]): WebhookTestOptions {
const commonArgs: string[] = [];
let confirm = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--confirm") confirm = true;
else {
commonArgs.push(arg);
if (arg === "--target" || arg === "--node" || arg === "--consumer") {
commonArgs.push(args[index + 1] ?? "");
index += 1;
}
}
}
return { ...parseCommonOptions(commonArgs), confirm };
}
function parseCloseoutOptions(args: string[]): CloseoutOptions {
const commonArgs: string[] = [];
let sourceCommit: string | null = null;
@@ -2250,146 +2265,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function runCloseoutGitOpsMirrorFlush(config: UniDeskConfig, pac: PacConfig, consumer: PacConsumer, summary: Record<string, unknown>, latest: Record<string, unknown>, baseReady: boolean, observedSourceCommit: string): Promise<Record<string, unknown>> {
const cfg = pac.closeout.gitOpsMirrorFlush;
const configSource = `${configLabel}.closeout.gitOpsMirrorFlush`;
const required = cfg.enabled && consumer.closeoutGitOpsMirrorFlush;
if (baseReady && stringValue(record(summary.diagnostics).code) === "pac-ready-no-runtime-change") {
return {
ok: true,
enabled: cfg.enabled,
required,
applicable: false,
mode: "retained-no-runtime-change",
executed: false,
reason: "the successful source observation produced no GitOps change, so there is no new revision to flush",
configSource,
valuesPrinted: false,
};
}
if (!required) {
return {
ok: true,
enabled: cfg.enabled,
required: false,
mode: cfg.enabled ? "consumer-not-enabled" : "disabled",
executed: false,
configSource,
valuesPrinted: false,
};
}
if (!baseReady) {
return {
ok: true,
enabled: true,
required: true,
mode: "waiting-for-pac-ready",
executed: false,
configSource,
valuesPrinted: false,
};
}
const pipelineRun = stringValue(latest.name);
if (observedSourceCommit === "-" || pipelineRun === "-") {
return {
ok: false,
enabled: true,
required: true,
mode: "missing-closeout-context",
executed: false,
configSource,
degradedReason: "pac-closeout-gitops-mirror-context-missing",
valuesPrinted: false,
};
}
if (consumer.id.startsWith("agentrun-")) {
const progressLines: string[] = [];
const flush = await captureStderrLines(progressLines, () => runAgentRunGitMirrorJob(config, "flush", {
node: consumer.node,
lane: consumer.lane,
confirm: true,
dryRun: false,
wait: true,
timeoutSeconds: cfg.waitTimeoutSeconds,
}));
return {
ok: flush.ok === true,
enabled: true,
required: true,
mode: stringValue(flush.mode, flush.ok === true ? "flushed" : "failed"),
executed: flush.ok === true,
sourceCommit: observedSourceCommit,
pipelineRun,
configSource,
waitTimeoutSeconds: cfg.waitTimeoutSeconds,
maxAttempts: cfg.maxAttempts,
beforeSummary: record(record(flush.status).summary),
afterSummary: record(record(flush.status).summary),
jobName: flush.jobName ?? null,
degradedReason: flush.ok === true ? undefined : "pac-closeout-agentrun-gitops-mirror-flush-failed",
next: record(flush.next),
flush,
progressLines,
valuesPrinted: false,
};
}
const deadlineMs = Date.now() + (cfg.waitTimeoutSeconds * 1000);
const gitOpsMirrorLane = consumer.closeoutGitOpsMirrorLane ?? consumer.lane;
const scoped = parseNodeScopedDelegatedOptions("git-mirror", [
"flush",
"--node",
consumer.node,
"--lane",
gitOpsMirrorLane,
"--confirm",
"--wait",
"--timeout-seconds",
String(cfg.waitTimeoutSeconds),
]);
const progressLines: string[] = [];
const flush = await captureStderrLines(progressLines, () => nodeRuntimeEnsureGitMirrorFlushed(scoped, "post", observedSourceCommit, pipelineRun, null, {
deadlineMs,
maxAttempts: cfg.maxAttempts,
}));
return {
ok: flush.ok === true,
enabled: true,
required: true,
mode: stringValue(flush.mode, flush.ok === true ? "flushed" : "failed"),
executed: flush.executed === true,
sourceCommit: observedSourceCommit,
pipelineRun,
configSource,
waitTimeoutSeconds: cfg.waitTimeoutSeconds,
maxAttempts: cfg.maxAttempts,
beforeSummary: flush.beforeSummary ?? null,
afterSummary: flush.afterSummary ?? null,
jobName: flush.jobName ?? null,
degradedReason: flush.degradedReason ?? undefined,
next: flush.next ?? undefined,
flush,
progressLines,
valuesPrinted: false,
};
}
async function captureStderrLines<T>(lines: string[], callback: () => T | Promise<T>): Promise<T> {
const stderr = process.stderr as typeof process.stderr & { write: (...args: unknown[]) => boolean };
const originalWrite = stderr.write.bind(process.stderr);
stderr.write = ((chunk: unknown, ...args: unknown[]): boolean => {
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
lines.push(text);
originalWrite(text);
const callbackArg = args.find((arg) => typeof arg === "function") as (() => void) | undefined;
callbackArg?.();
return true;
}) as typeof stderr.write;
try {
return await callback();
} finally {
stderr.write = originalWrite as typeof stderr.write;
}
}
function pipelineRunGate(latest: Record<string, unknown>): Record<string, unknown> {
const name = stringValue(latest.name);
@@ -2567,6 +2442,11 @@ function renderHistoryDetail(row: Record<string, unknown>): string[] {
"DETAIL",
...table(["FIELD", "VALUE"], [
["id", stringValue(row.id ?? row.pipelineRun)],
["deliveryClass", stringValue(row.deliveryClass)],
["deliveryAuthorityEligible", stringValue(row.deliveryAuthorityEligible)],
["deliveryOwner", stringValue(row.deliveryOwner)],
["executionOwner", stringValue(row.executionOwner)],
["parentRelation", stringValue(row.parentRelation)],
["pipeline", stringValue(row.pipeline)],
["branch", stringValue(row.branch)],
["commit", stringValue(row.commit)],
+7 -6
View File
@@ -462,13 +462,14 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra gitea validate --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror status --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01",
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer <consumer> --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer <consumer> [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer <consumer> --limit 10 [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id <pipelinerun> [--json|--full]",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer <consumer> [--id <pipelinerun>] [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01 --consumer <consumer> [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer <consumer> --limit 10 [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --id <pipelinerun> [--json|--full]",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target <NODE> --consumer <consumer> [--id <pipelinerun>] [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap",
"bun scripts/cli.ts platform-infra pipelines-as-code help compatibility-diagnostics",
],
description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, Web Terminal, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the target-scoped Kafka event bus, internal Gitea source authority, and Gitea + Pipelines-as-Code closeout for JD01 migrated consumers. Public services use PK01 Caddy/FRP or YAML-declared PK01 Caddy upstreams rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, Web Terminal, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the target-scoped Kafka event bus, internal Gitea source authority, and read-only Gitea + Pipelines-as-Code observation for migrated consumers. Public services use PK01 Caddy/FRP or YAML-declared PK01 Caddy upstreams rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
target,
codexPool: {
usage: [
@@ -0,0 +1,93 @@
// Responsibility: expose the YAML-owned sentinel internal publish capability without claiming provenance.
import { rootPath } from "./config";
import type { CicdDeliveryAuthority } from "./cicd-delivery-authority";
import type { WebProbeSentinelOptions } from "./hwlab-node-web-sentinel-cicd-shared";
import { readYamlRecord } from "./platform-infra-ops-library";
import { materializeYamlComposition } from "./yaml-composition";
const configPath = "config/platform-infra/pipelines-as-code.yaml";
const configRef = `${configPath}#capabilities.sentinelInternalPublish`;
export interface SentinelPacInternalPublishCapability {
readonly enabled: boolean;
readonly admissionProvenance: "unavailable";
readonly trackingIssue: string;
readonly configRef: string;
readonly valuesPrinted: false;
}
export type SentinelPacInternalPublishAuthorization = {
readonly allowed: false;
readonly mode: "sentinel-pac-internal-publish-blocked";
readonly reason:
| "operator-entrypoint"
| "delivery-authority-not-pac"
| "capability-disabled"
| "admission-provenance-unavailable";
readonly capability: SentinelPacInternalPublishCapability;
readonly valuesPrinted: false;
};
export function readSentinelPacInternalPublishCapability(): SentinelPacInternalPublishCapability {
const raw = readYamlRecord<Record<string, unknown>>(rootPath(...configPath.split("/")), "platform-infra-pipelines-as-code");
const composed = materializeYamlComposition(raw, { label: configPath }).value;
const capabilities = record(composed.capabilities, `${configPath}#capabilities`);
const capability = record(capabilities.sentinelInternalPublish, configRef);
const enabled = booleanField(capability.enabled, `${configRef}.enabled`);
const admissionProvenance = stringField(capability.admissionProvenance, `${configRef}.admissionProvenance`);
if (admissionProvenance !== "unavailable") {
throw new Error(`${configRef}.admissionProvenance must remain unavailable until the admission contract is implemented`);
}
const trackingIssue = stringField(capability.trackingIssue, `${configRef}.trackingIssue`);
if (!/^https:\/\/github\.com\/pikasTech\/unidesk\/issues\/\d+$/u.test(trackingIssue)) {
throw new Error(`${configRef}.trackingIssue must reference the owning GitHub issue`);
}
return {
enabled,
admissionProvenance,
trackingIssue,
configRef,
valuesPrinted: false,
};
}
export function authorizeSentinelPacInternalPublish(
options: WebProbeSentinelOptions,
authority: CicdDeliveryAuthority,
capability = readSentinelPacInternalPublishCapability(),
): SentinelPacInternalPublishAuthorization {
if (options.kind !== "publish" || options.internalExecution !== "pac-controller") {
return denied("operator-entrypoint", capability);
}
if (authority.kind !== "pac-pr-merge") return denied("delivery-authority-not-pac", capability);
if (!capability.enabled) return denied("capability-disabled", capability);
return denied("admission-provenance-unavailable", capability);
}
function denied(
reason: SentinelPacInternalPublishAuthorization["reason"],
capability: SentinelPacInternalPublishCapability,
): SentinelPacInternalPublishAuthorization {
return {
allowed: false,
mode: "sentinel-pac-internal-publish-blocked",
reason,
capability,
valuesPrinted: false,
};
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function booleanField(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`);
return value;
}
function stringField(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} must be a non-empty string`);
return value.trim();
}