Merge pull request #1773 from pikasTech/fix/1764-pac-manual-repair-hints
fix: 收敛 PaC 已迁移链路的手工修复提示
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 已迁移到 PaC;GitHub PR merge 是唯一交付触发,CLI 不执行人工推进。"
|
||||
: "无法从 owning YAML 唯一确认 delivery authority;CLI 已 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
@@ -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-边界",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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-边界",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 分钟");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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-边界",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 runtime;Gitea Actions POC 与 branch-follower 仅保留只读历史观察。",
|
||||
},
|
||||
subcommands: [
|
||||
cicdNodeStatusHelp(),
|
||||
|
||||
+19
-2
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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` },
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user