fix: flush follower mirror from thin cicd route
This commit is contained in:
@@ -44,7 +44,7 @@ Follower-scoped commands such as `status --follower`, `events --follower`, `logs
|
||||
|
||||
Multi-follower status summaries should omit per-follower `command.payload`/native drill-down payloads entirely; those belong to follower-scoped `events`/`logs`/`debug-step` queries. Default all-follower status must remain parseable below the transport stdout limit.
|
||||
|
||||
`scripts/src/cicd.ts` should stay a thin branch-follower route/orchestration entry, not a catch-all implementation file. Rendering, debug steps, controller manifests, native K8s helpers, adapter-specific trigger/status logic and large data compactors must live in responsibility-specific modules before `cicd.ts` approaches the 3000-line hard split point.
|
||||
`scripts/src/cicd.ts` must stay a thin top-level CI/CD route entry. Branch-follower implementation belongs in `scripts/src/cicd-branch-follower.ts` and responsibility-specific modules; rendering, debug steps, controller manifests, native K8s helpers, adapter-specific trigger/status logic and large data compactors must be split before any implementation file approaches the 3000-line hard split point.
|
||||
|
||||
`status-read`, `events`, `logs` and debug summaries must expose compact closeout gate details when a follower is not aligned: git-mirror readiness, Tekton PipelineRun condition, Argo sync/health, runtime target sha/readiness and short errors. Repeating only phase/observed/target/message is a visibility defect and must be fixed before further rollout tuning.
|
||||
|
||||
@@ -56,6 +56,8 @@ When Argo exposes operation start/finish timestamps, stage timing rows should re
|
||||
|
||||
The automatic controller loop is non-blocking, so closeout acceleration cannot live only in the user-facing `--wait` path. Once a triggered PipelineRun has succeeded and required runtime/GitOps gates are not aligned, the in-cluster controller path should perform the same bounded target-side Argo refresh used by wait closeout; otherwise convergence depends on Argo's background poll interval and can exceed the 120s budget even when Tekton finished quickly.
|
||||
|
||||
The same rule applies to git-mirror post-flush. If native status shows runtime/Argo are aligned but GitOps mirror is still pending flush, the automatic controller loop must run the bounded target-side git-mirror flush instead of leaving a follower in `ClosingOut` until a manual wait/closeout path is used.
|
||||
|
||||
Stage timing rows must not label optional gates as `not-ready` when they are not part of that follower's closeout contract. For sentinel-like followers without a GitOps branch flush gate, git-mirror source snapshot readiness should render as source-ready/ready, while missing GitOps `githubInSync` remains `-`/not-applicable instead of a failure-looking state.
|
||||
|
||||
## Source Authority
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
||||
// SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower.
|
||||
// Responsibility: compact native Kubernetes status summaries for branch-follower closeout.
|
||||
import { resolveAgentRunLaneTarget } from "./agentrun-lanes";
|
||||
import type { FollowerSpec, NativeStatusSpec, NativeWorkloadSpec } from "./cicd-types";
|
||||
import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes";
|
||||
|
||||
export function pipelineRunSucceeded(pipelineRun: Record<string, unknown> | null): boolean | null {
|
||||
if (pipelineRun === null) return null;
|
||||
const conditions = Array.isArray(asOptionalRecord(pipelineRun.status)?.conditions) ? asOptionalRecord(pipelineRun.status)?.conditions : [];
|
||||
for (const condition of conditions as unknown[]) {
|
||||
const record = asOptionalRecord(condition);
|
||||
if (record?.type !== "Succeeded") continue;
|
||||
if (record.status === "True") return true;
|
||||
if (record.status === "False") return false;
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function argoApplicationReady(application: Record<string, unknown> | null): boolean {
|
||||
if (application === null) return false;
|
||||
const status = asOptionalRecord(application.status);
|
||||
const sync = asOptionalRecord(status?.sync);
|
||||
const health = asOptionalRecord(status?.health);
|
||||
return sync?.status === "Synced" && health?.status === "Healthy";
|
||||
}
|
||||
|
||||
export function runtimeWorkloadsReady(runtime: NativeStatusSpec["runtime"], workloads: Record<string, unknown>[]): boolean {
|
||||
if (runtime === null) return true;
|
||||
if (workloads.length < runtime.workloads.length) return false;
|
||||
return runtime.workloads.every((spec, index) => workloadReady(spec, workloads[index] ?? null));
|
||||
}
|
||||
|
||||
export function runtimeTargetShaFromWorkloads(runtime: NativeStatusSpec["runtime"], workloads: Record<string, unknown>[]): string | null {
|
||||
if (runtime === null) return null;
|
||||
const commits: string[] = [];
|
||||
runtime.workloads.forEach((spec, index) => {
|
||||
const commit = workloadSourceCommit(spec, workloads[index] ?? null);
|
||||
if (commit !== null) commits.push(commit);
|
||||
});
|
||||
const unique = Array.from(new Set(commits));
|
||||
return unique.length === 1 ? unique[0] ?? null : null;
|
||||
}
|
||||
|
||||
export function nativePipelineRunSummary(pipelineRun: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (pipelineRun === null) return null;
|
||||
const metadata = asOptionalRecord(pipelineRun.metadata);
|
||||
const status = asOptionalRecord(pipelineRun.status);
|
||||
const condition = latestCondition(status, "Succeeded");
|
||||
return {
|
||||
name: stringOrNull(metadata?.name),
|
||||
namespace: stringOrNull(metadata?.namespace),
|
||||
succeeded: pipelineRunSucceeded(pipelineRun),
|
||||
reason: stringOrNull(condition?.reason),
|
||||
startTime: stringOrNull(status?.startTime),
|
||||
completionTime: stringOrNull(status?.completionTime),
|
||||
durationSeconds: numberOrNull(status?.durationSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
export function nativeArgoSummary(application: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (application === null) return null;
|
||||
const metadata = asOptionalRecord(application.metadata);
|
||||
const status = asOptionalRecord(application.status);
|
||||
const sync = asOptionalRecord(status?.sync);
|
||||
const health = asOptionalRecord(status?.health);
|
||||
const operationState = asOptionalRecord(status?.operationState);
|
||||
return {
|
||||
name: stringOrNull(metadata?.name),
|
||||
namespace: stringOrNull(metadata?.namespace),
|
||||
syncStatus: stringOrNull(sync?.status),
|
||||
healthStatus: stringOrNull(health?.status),
|
||||
healthMessage: stringOrNull(health?.message),
|
||||
revision: stringOrNull(sync?.revision),
|
||||
operationPhase: stringOrNull(operationState?.phase),
|
||||
operationMessage: stringOrNull(operationState?.message),
|
||||
operationStartedAt: stringOrNull(operationState?.startedAt),
|
||||
operationFinishedAt: stringOrNull(operationState?.finishedAt),
|
||||
operationDurationSeconds: numberOrNull(operationState?.durationSeconds),
|
||||
conditions: Array.isArray(status?.conditions) ? status.conditions.slice(0, 5) : [],
|
||||
nonReadyResources: Array.isArray(status?.nonReadyResources) ? status.nonReadyResources.slice(0, 5) : [],
|
||||
ready: argoApplicationReady(application),
|
||||
};
|
||||
}
|
||||
|
||||
export function nativeRuntimeSummary(runtime: NativeStatusSpec["runtime"], workloads: Record<string, unknown>[], expectedSha: string | null): Record<string, unknown> | null {
|
||||
if (runtime === null) return null;
|
||||
const targetSha = runtimeTargetShaFromWorkloads(runtime, workloads);
|
||||
return {
|
||||
namespace: runtime.namespace,
|
||||
ready: runtimeWorkloadsReady(runtime, workloads),
|
||||
targetSha,
|
||||
expectedSha,
|
||||
aligned: expectedSha !== null && targetSha !== null ? targetSha === expectedSha : null,
|
||||
workloads: runtime.workloads.map((spec, index) => {
|
||||
const workload = workloads[index] ?? null;
|
||||
const sourceCommit = workloadSourceCommit(spec, workload);
|
||||
return {
|
||||
kind: spec.kind,
|
||||
name: spec.name,
|
||||
ready: workloadReady(spec, workload),
|
||||
sourceCommit,
|
||||
aligned: expectedSha !== null && sourceCommit !== null ? sourceCommit === expectedSha : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function nativeGitMirrorRequired(follower: FollowerSpec): boolean {
|
||||
return nativeGitMirrorGitopsBranch(follower) !== null
|
||||
&& (follower.closeoutChecks.includes("gitMirrorPostFlush") || follower.closeoutChecks.includes("gitops"));
|
||||
}
|
||||
|
||||
export function nativeGitMirrorReady(gitMirror: Record<string, unknown> | null): boolean {
|
||||
if (gitMirror === null) return false;
|
||||
if (gitMirror.ok === false) return false;
|
||||
if (gitMirror.sourceSnapshotReady === false) return false;
|
||||
if (gitMirror.pendingFlush === true) return false;
|
||||
if (gitMirror.githubInSync === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function nativeGitMirrorSummary(gitMirror: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (gitMirror === null) return null;
|
||||
return {
|
||||
ok: gitMirror.ok === true,
|
||||
repository: stringOrNull(gitMirror.repository),
|
||||
sourceBranch: stringOrNull(gitMirror.sourceBranch),
|
||||
gitopsBranch: stringOrNull(gitMirror.gitopsBranch),
|
||||
localSource: stringOrNull(gitMirror.localSource),
|
||||
githubSource: stringOrNull(gitMirror.githubSource),
|
||||
sourceStageRef: stringOrNull(gitMirror.sourceStageRef),
|
||||
sourceSnapshotReady: gitMirror.sourceSnapshotReady === true,
|
||||
localGitops: stringOrNull(gitMirror.localGitops),
|
||||
githubGitops: stringOrNull(gitMirror.githubGitops),
|
||||
pendingFlush: gitMirror.pendingFlush === true,
|
||||
githubInSync: gitMirror.githubInSync === true,
|
||||
statusAuthority: stringOrNull(gitMirror.statusAuthority) ?? "k8s-git-mirror-cache",
|
||||
};
|
||||
}
|
||||
|
||||
function nativeGitMirrorGitopsBranch(follower: FollowerSpec): string | null {
|
||||
if (follower.adapter === "hwlab-node-runtime") {
|
||||
return hwlabRuntimeLaneSpecForNode(follower.target.lane, follower.target.node).gitopsBranch;
|
||||
}
|
||||
if (follower.adapter === "agentrun-yaml-lane") {
|
||||
return resolveAgentRunLaneTarget({ node: follower.target.node, lane: follower.target.lane }).spec.gitops.branch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function workloadReady(spec: NativeWorkloadSpec, workload: Record<string, unknown> | null): boolean {
|
||||
if (workload === null) return false;
|
||||
const status = asOptionalRecord(workload.status);
|
||||
if (spec.kind === "Deployment") {
|
||||
const desired = numberOrNull(asOptionalRecord(workload.spec)?.replicas) ?? 1;
|
||||
const available = numberOrNull(status?.availableReplicas) ?? 0;
|
||||
const updated = numberOrNull(status?.updatedReplicas) ?? 0;
|
||||
return available >= desired && updated >= desired;
|
||||
}
|
||||
const desired = numberOrNull(asOptionalRecord(workload.spec)?.replicas) ?? 1;
|
||||
const ready = numberOrNull(status?.readyReplicas) ?? 0;
|
||||
return ready >= desired;
|
||||
}
|
||||
|
||||
function workloadSourceCommit(spec: NativeWorkloadSpec, workload: Record<string, unknown> | null): string | null {
|
||||
if (workload === null) return null;
|
||||
const metadata = asOptionalRecord(workload.metadata);
|
||||
const labels = asOptionalRecord(metadata?.labels);
|
||||
const annotations = asOptionalRecord(metadata?.annotations);
|
||||
const template = asOptionalRecord(asOptionalRecord(workload.spec)?.template);
|
||||
const podMetadata = asOptionalRecord(template?.metadata);
|
||||
const podLabels = asOptionalRecord(podMetadata?.labels);
|
||||
const podAnnotations = asOptionalRecord(podMetadata?.annotations);
|
||||
for (const key of spec.sourceCommit.labels) {
|
||||
const value = shaOrNull(labels?.[key]);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
for (const key of spec.sourceCommit.annotations) {
|
||||
const value = shaOrNull(annotations?.[key]);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
for (const key of spec.sourceCommit.podLabels) {
|
||||
const value = shaOrNull(podLabels?.[key]);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
for (const key of spec.sourceCommit.podAnnotations) {
|
||||
const value = shaOrNull(podAnnotations?.[key]);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
const containers = Array.isArray(asOptionalRecord(template?.spec)?.containers) ? asOptionalRecord(template?.spec)?.containers : [];
|
||||
for (const envName of spec.sourceCommit.env) {
|
||||
for (const container of containers as unknown[]) {
|
||||
const env = Array.isArray(asOptionalRecord(container)?.env) ? asOptionalRecord(container)?.env : [];
|
||||
for (const entry of env as unknown[]) {
|
||||
const record = asOptionalRecord(entry);
|
||||
if (record?.name !== envName) continue;
|
||||
const value = shaOrNull(record.value);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function latestCondition(status: Record<string, unknown> | null, type: string): Record<string, unknown> | null {
|
||||
const conditions = Array.isArray(status?.conditions) ? status.conditions : [];
|
||||
for (const condition of conditions as unknown[]) {
|
||||
const record = asOptionalRecord(condition);
|
||||
if (record?.type === type) return record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shaOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && /^[0-9a-f]{40}$/iu.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function asOptionalRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
+6
-2762
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user