fix(sentinel): add YAML shortest-path validation

This commit is contained in:
Codex
2026-07-12 17:28:17 +02:00
parent e5c763f5a7
commit 5a99f4efd7
11 changed files with 153 additions and 15 deletions
+59 -10
View File
@@ -168,7 +168,16 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options:
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));
const localCommit = options.kind === "validate" && options.localOnly
? runCommand(["git", "rev-parse", "HEAD"], repoRoot, { timeoutMs: 5_000 }).stdout.trim()
: null;
const localSourceOverride = localCommit === null ? null : {
commit: localCommit,
stageRef: null,
mirrorCommit: localCommit,
sourceAuthority: "gitea-snapshot" as const,
};
const state = loadSentinelCicdState(spec, options.sentinelId, options.timeoutSeconds, sentinelSourceResolveMode(options), localSourceOverride ?? sentinelSourceOverrideFromOptions(options));
if (options.kind === "image") return runSentinelImage(state, options);
if (options.kind === "control-plane") return runSentinelControlPlane(state, options);
if (options.kind === "publish") return runSentinelPublishCurrent(state, options);
@@ -229,13 +238,17 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
if (!options.wait) return renderAsyncSentinelJob(state, "control-plane", options.action, options.timeoutSeconds);
return runSentinelControlPlaneConfirmed(state, options);
}
const observed = options.action === "status" ? collectSentinelObservedStatus(state, options.timeoutSeconds) : null;
const observed = options.action === "status"
? options.wait
? waitForSentinelObservedStatus(state, options.timeoutSeconds)
: collectSentinelObservedStatus(state, options.timeoutSeconds)
: null;
const observedReady = options.action !== "status" || sentinelObservedReady(record(observed));
const observedWarnings = options.action === "status" ? sentinelObservedWarnings(record(observed)) : [];
const pipelineRun = sentinelPipelineRunName(state, options.rerun);
const statusDiagnosis = options.action === "status" && !observedReady ? sentinelObservedStatusDiagnosis(state, observed, pipelineRun) : null;
const result = {
ok: state.configReady && state.sourceHead.ok && observedReady,
ok: state.configReady && state.sourceHead.ok && (options.action === "status" || observedReady),
command,
node: state.spec.nodeId,
lane: state.spec.lane,
@@ -283,17 +296,18 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
cicd: state.cicd,
}),
observed,
deliveryStatus: options.action === "status" ? sentinelDeliveryStatus(state, record(observed), pipelineRun) : null,
statusDiagnosis,
drillDown: controlPlaneDrillDown(state, pipelineRun, null),
warnings: mergeWarnings(observedWarnings, record(statusDiagnosis).warning),
blocker: observedReady
blocker: options.action === "status" || observedReady
? null
: record(statusDiagnosis).blocker ?? { code: "sentinel-control-plane-observed-not-ready", reason: "one or more source, registry, GitOps, Argo, runtime or cadence checks did not pass", valuesRedacted: true },
recoveryNext: record(statusDiagnosis).recoveryNext ?? null,
next: controlPlaneNext(state, options.action),
valuesRedacted: true,
};
return rendered(result.ok, command, renderControlPlaneResult(result));
return rendered(result.ok, command, renderControlPlaneResult(result), options.action === "status" ? record(result.deliveryStatus) : undefined);
}
function runSentinelPublishCurrent(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "publish" }>): RenderedCliResult {
@@ -1924,7 +1938,7 @@ function collectSentinelObservedStatus(state: SentinelCicdState, timeoutSeconds:
gitops,
argo: probeArgoApplication(state, timeoutSeconds, effectiveExpectation.gitopsRevision),
runtime: probeRuntimeObjects(state, timeoutSeconds, effectiveExpectation.runtimeImage),
cadence: probeCadenceCronJob(state, timeoutSeconds),
cadence: probeCadenceCronJob(state, timeoutSeconds, nonEmptyString(gitops.schedule)),
};
}
@@ -1934,7 +1948,8 @@ function waitForSentinelObservedStatus(state: SentinelCicdState, timeoutSeconds:
let observed = collectSentinelObservedStatus(state, timeoutSeconds, expectation, includeGitMirror);
let polls = 1;
while (!sentinelObservedReady(observed) && Date.now() - startedAt < timeoutMs) {
runCommand(["sleep", "2"], repoRoot, { timeoutMs: 3_000 });
const pollSeconds = numberAtNullable(state.cicd, "confirmWait.pollSeconds") ?? 2;
runCommand(["sleep", String(pollSeconds)], repoRoot, { timeoutMs: (pollSeconds + 1) * 1_000 });
observed = collectSentinelObservedStatus(state, timeoutSeconds, expectation, includeGitMirror);
polls += 1;
}
@@ -1964,6 +1979,35 @@ function sentinelObservedReady(value: Record<string, unknown> | SentinelObserved
&& record(observed.cadence).ok === true;
}
export function sentinelDeliveryStatus(state: SentinelCicdState, observed: Record<string, unknown>, pipelineRun: string): Record<string, unknown> {
const gitops = record(observed.gitops);
const runtimeDeployment = record(record(observed.runtime).probe).deployment;
const cadenceProbe = record(record(observed.cadence).probe);
const argo = record(observed.argo);
const desiredSourceCommit = nonEmptyString(gitops.sourceCommit) ?? state.sourceHead.commit;
const runtimeSourceCommit = nonEmptyString(record(runtimeDeployment).sourceCommit);
const desiredSchedule = nonEmptyString(gitops.schedule);
const runtimeSchedule = nonEmptyString(cadenceProbe.schedule);
const converging = desiredSourceCommit !== runtimeSourceCommit || desiredSchedule !== runtimeSchedule || !sentinelObservedReady(observed);
return {
desiredSourceCommit,
runtimeSourceCommit,
pipelineRun,
gitopsRevision: gitops.revision ?? null,
argoSyncStatus: argo.syncStatus ?? null,
argoHealthStatus: argo.healthStatus ?? null,
runtimeReady: record(observed.runtime).ok === true,
desiredSchedule,
runtimeSchedule,
converging,
warning: converging ? "GitOps 期望状态尚未完全收敛;这是只读、非阻塞 warning。" : null,
blocking: false,
outcome: converging ? "converging" : "converged",
mutation: false,
valuesRedacted: true,
};
}
function sentinelObservedWarnings(value: Record<string, unknown> | SentinelObservedStatus | null): string[] {
const observed = record(value);
const argo = record(observed.argo);
@@ -2253,6 +2297,8 @@ function probeGitopsRuntimeManifest(state: SentinelCicdState, timeoutSeconds: nu
const revision = /^[0-9a-f]{40}$/iu.test(revisionLine?.trim() ?? "") ? revisionLine.trim() : null;
const manifest = manifestLines.join("\n");
const image = nonEmptyString(manifest.match(/image:\s*([^,\s}\]]+)/u)?.[1]);
const sourceCommit = nonEmptyString(manifest.match(/unidesk\.ai\/source-commit:\s*["']?([0-9a-f]{40})/iu)?.[1]);
const schedule = nonEmptyString(manifest.match(/\bschedule:\s*["']?([^,"'\r\n#]+)/u)?.[1]?.trim());
const imageMatchesRepository = image !== null && image.startsWith(`${state.image.repository}@sha256:`);
const compact = compactCommand(result);
return {
@@ -2261,6 +2307,8 @@ function probeGitopsRuntimeManifest(state: SentinelCicdState, timeoutSeconds: nu
branch,
manifestPath,
image,
sourceCommit,
schedule,
imageMatchesRepository,
result: { ...compact, stdoutPreview: `${revision ?? "-"} ${image ?? "-"}` },
valuesRedacted: true,
@@ -2301,9 +2349,10 @@ function probeRuntimeObjects(state: SentinelCicdState, timeoutSeconds: number, e
"const ready = Number(dep?.status?.readyReplicas ?? 0);",
"const updated = Number(dep?.status?.updatedReplicas ?? 0);",
"const image = dep?.spec?.template?.spec?.containers?.[0]?.image ?? null;",
"const sourceCommit = dep?.spec?.template?.metadata?.annotations?.['unidesk.ai/source-commit'] ?? dep?.metadata?.annotations?.['unidesk.ai/source-commit'] ?? null;",
"const imageMatches = expectedImage === null || image === expectedImage;",
"const payload = {",
" deployment: { present: deploymentPresent, desiredReplicas: desired, readyReplicas: ready, updatedReplicas: updated, image, expectedImage, imageMatches },",
" deployment: { present: deploymentPresent, desiredReplicas: desired, readyReplicas: ready, updatedReplicas: updated, image, expectedImage, imageMatches, sourceCommit },",
" service: { present: rc('svc') === 0 },",
" pvc: { present: rc('pvc') === 0, phase: json('pvc')?.status?.phase ?? null },",
" configMap: { present: rc('cm') === 0 },",
@@ -2320,7 +2369,7 @@ function probeRuntimeObjects(state: SentinelCicdState, timeoutSeconds: number, e
return { ok: result.exitCode === 0 && probe?.ok === true, probe, stdoutRecovery: probeResolution.diagnostics, result: compactCommand(result) };
}
function probeCadenceCronJob(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> {
function probeCadenceCronJob(state: SentinelCicdState, timeoutSeconds: number, desiredSchedule: string | null = null): Record<string, unknown> {
const expected = state.manifests.find((item) => item.kind === "CronJob") ?? null;
if (expected === null) {
return { ok: true, skipped: true, reason: "targetValidation.cadenceScheduler.disabled", valuesRedacted: true };
@@ -2329,7 +2378,7 @@ function probeCadenceCronJob(state: SentinelCicdState, timeoutSeconds: number):
const spec = record(expected.spec);
const namespace = stringAt(metadata, "namespace");
const name = stringAt(metadata, "name");
const expectedSchedule = stringAt(spec, "schedule");
const expectedSchedule = desiredSchedule ?? stringAt(spec, "schedule");
const script = [
"set +e",
`namespace=${shellQuote(namespace)}`,