feat: add branch follower gate probes
This commit is contained in:
@@ -0,0 +1,388 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
|
||||||
|
const gate = requiredEnv("GATE");
|
||||||
|
const follower = requiredEnv("FOLLOWER_ID");
|
||||||
|
const repository = requiredEnv("REPOSITORY");
|
||||||
|
const repoPath = requiredEnv("REPO_PATH");
|
||||||
|
const sourceBranch = requiredEnv("SOURCE_BRANCH");
|
||||||
|
const snapshotPrefix = requiredEnv("SNAPSHOT_PREFIX").replace(/\/+$/u, "");
|
||||||
|
const selectedSource = process.env.SOURCE_COMMIT || "";
|
||||||
|
const gitopsBranch = process.env.GITOPS_BRANCH || "";
|
||||||
|
const tektonNamespace = process.env.TEKTON_NAMESPACE || "";
|
||||||
|
const pipelineRunPrefix = process.env.PIPELINE_RUN_PREFIX || "";
|
||||||
|
const argoNamespace = process.env.ARGO_NAMESPACE || "";
|
||||||
|
const argoApplication = process.env.ARGO_APPLICATION || "";
|
||||||
|
const runtimeNamespace = process.env.RUNTIME_NAMESPACE || "";
|
||||||
|
const workloads = parseWorkloads(process.env.WORKLOADS_B64 || "");
|
||||||
|
const healthUrl = process.env.HEALTH_URL || "";
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const branchCommit = rev(`refs/heads/${sourceBranch}`);
|
||||||
|
const sourceCommit = selectedSource || branchCommit || "";
|
||||||
|
const sourceStageRef = sourceCommit ? `${snapshotPrefix}/${sourceCommit}` : null;
|
||||||
|
const sourceSnapshot = sourceStageRef ? rev(sourceStageRef) : null;
|
||||||
|
const source = {
|
||||||
|
repository,
|
||||||
|
branch: sourceBranch,
|
||||||
|
sourceCommit: sourceCommit || null,
|
||||||
|
stageRef: sourceStageRef,
|
||||||
|
snapshotReady: Boolean(sourceCommit && sourceSnapshot === sourceCommit),
|
||||||
|
authority: "k8s-git-mirror-snapshot",
|
||||||
|
};
|
||||||
|
const gitMirror = gitMirrorSummary(sourceCommit);
|
||||||
|
|
||||||
|
let evidence;
|
||||||
|
if (gate === "reuse-plan") evidence = reusePlanEvidence(sourceCommit);
|
||||||
|
else if (gate === "ci-taskrun-plan") evidence = await ciTaskRunEvidence(sourceCommit);
|
||||||
|
else if (gate === "cd-rollout-plan") evidence = await cdRolloutEvidence(sourceCommit);
|
||||||
|
else if (gate === "post-deploy-health") evidence = await postDeployHealthEvidence(sourceCommit);
|
||||||
|
else fail(`unsupported gate ${gate}`);
|
||||||
|
|
||||||
|
const ok = errors.length === 0 && evidence?.ok === true;
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok,
|
||||||
|
gate,
|
||||||
|
follower,
|
||||||
|
source,
|
||||||
|
evidence,
|
||||||
|
errors: errors.slice(0, 6),
|
||||||
|
statusAuthority: "kubernetes-api-serviceaccount",
|
||||||
|
parsedDownstreamCliOutput: false,
|
||||||
|
bounded: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function reusePlanEvidence(commit) {
|
||||||
|
const reuse = readReuseConfig(commit);
|
||||||
|
return {
|
||||||
|
ok: source.snapshotReady && gitMirror.ok === true && reuse.present === true && reuse.serviceCount > 0,
|
||||||
|
gitMirror,
|
||||||
|
reuse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ciTaskRunEvidence(commit) {
|
||||||
|
if (!commit || !tektonNamespace || !pipelineRunPrefix) return notConfigured("tekton");
|
||||||
|
const pipelineRunName = `${pipelineRunPrefix}-${commit.slice(0, 12)}`;
|
||||||
|
const pipelineRun = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelineruns/${encodeURIComponent(pipelineRunName)}`, false);
|
||||||
|
const prStatus = pipelineRunStatus(pipelineRun);
|
||||||
|
const pipelineRef = str(pipelineRun?.spec?.pipelineRef?.name);
|
||||||
|
const pipeline = pipelineRef ? await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelines/${encodeURIComponent(pipelineRef)}`, false) : null;
|
||||||
|
const taskRuns = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/taskruns?labelSelector=${encodeURIComponent(`tekton.dev/pipelineRun=${pipelineRunName}`)}`, false);
|
||||||
|
const taskSummary = taskRunsSummary(taskRuns);
|
||||||
|
return {
|
||||||
|
ok: prStatus.succeeded === true && taskSummary.failedCount === 0 && taskSummary.activeCount === 0,
|
||||||
|
pipelineRun: prStatus,
|
||||||
|
pipeline: {
|
||||||
|
name: pipelineRef,
|
||||||
|
taskCount: Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks.length : null,
|
||||||
|
tasks: Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks.slice(0, 12).map((task) => ({ name: str(task?.name), runAfter: Array.isArray(task?.runAfter) ? task.runAfter.slice(0, 6) : [] })) : [],
|
||||||
|
},
|
||||||
|
taskRuns: taskSummary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cdRolloutEvidence(commit) {
|
||||||
|
const argo = await argoSummary();
|
||||||
|
const runtime = await runtimeSummary(commit);
|
||||||
|
return {
|
||||||
|
ok: gitMirror.githubInSync !== false && argo.ready === true && runtime.ready === true && runtime.aligned === true,
|
||||||
|
gitMirror,
|
||||||
|
argo,
|
||||||
|
runtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postDeployHealthEvidence(commit) {
|
||||||
|
const runtime = await runtimeSummary(commit);
|
||||||
|
const health = await healthSummary();
|
||||||
|
return {
|
||||||
|
ok: runtime.ready === true && runtime.aligned === true && health.ok === true,
|
||||||
|
runtime,
|
||||||
|
health,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readReuseConfig(commit) {
|
||||||
|
if (!commit || !sourceStageRef) return { present: false, reason: "source-commit-missing" };
|
||||||
|
try {
|
||||||
|
const text = execFileSync("git", [`--git-dir=${repoPath}`, "show", `${sourceStageRef}:gitops/reuse.ymal`], { encoding: "utf8", maxBuffer: 256 * 1024 });
|
||||||
|
const services = reuseServiceIds(text);
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
path: "gitops/reuse.ymal",
|
||||||
|
bytes: Buffer.byteLength(text, "utf8"),
|
||||||
|
sha256: createHash("sha256").update(text).digest("hex"),
|
||||||
|
serviceCount: services.length,
|
||||||
|
serviceIds: services.slice(0, 12),
|
||||||
|
runtimeReuseMentioned: /\bruntimeReuse\b/u.test(text),
|
||||||
|
envReuseMentioned: /\benvReuse\b/u.test(text),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { present: false, path: "gitops/reuse.ymal", reason: shortText(error?.message || String(error)) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reuseServiceIds(text) {
|
||||||
|
const ids = new Set([...text.matchAll(/(?:^|\n)\s*-\s*id:\s*([A-Za-z0-9_.-]+)/gu)].map((match) => match[1]).filter(Boolean));
|
||||||
|
const lines = text.split(/\r?\n/u);
|
||||||
|
let inServices = false;
|
||||||
|
let servicesIndent = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
const services = /^(\s*)services:\s*$/u.exec(line);
|
||||||
|
if (services) {
|
||||||
|
inServices = true;
|
||||||
|
servicesIndent = services[1].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inServices) continue;
|
||||||
|
const match = /^(\s*)([A-Za-z0-9_.-]+):\s*$/u.exec(line);
|
||||||
|
if (!match) continue;
|
||||||
|
const indent = match[1].length;
|
||||||
|
if (indent <= servicesIndent) {
|
||||||
|
inServices = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (indent === servicesIndent + 2) ids.add(match[2]);
|
||||||
|
}
|
||||||
|
return Array.from(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitMirrorSummary(commit) {
|
||||||
|
const localSource = rev(`refs/heads/${sourceBranch}`);
|
||||||
|
const githubSource = rev(`refs/mirror-stage/heads/${sourceBranch}`);
|
||||||
|
const localGitops = gitopsBranch ? rev(`refs/heads/${gitopsBranch}`) : null;
|
||||||
|
const githubGitops = gitopsBranch ? rev(`refs/mirror-stage/heads/${gitopsBranch}`) : null;
|
||||||
|
return {
|
||||||
|
ok: Boolean(localSource) && source.snapshotReady && (!gitopsBranch || !localGitops || localGitops === githubGitops),
|
||||||
|
localSource,
|
||||||
|
githubSource,
|
||||||
|
sourceStageRef,
|
||||||
|
sourceSnapshot,
|
||||||
|
sourceSnapshotReady: source.snapshotReady,
|
||||||
|
gitopsBranch: gitopsBranch || null,
|
||||||
|
localGitops,
|
||||||
|
githubGitops,
|
||||||
|
pendingFlush: gitopsBranch ? Boolean(localGitops && localGitops !== githubGitops) : null,
|
||||||
|
githubInSync: gitopsBranch ? Boolean(!localGitops || localGitops === githubGitops) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function argoSummary() {
|
||||||
|
if (!argoNamespace || !argoApplication) return { ready: null, reason: "argo-not-configured" };
|
||||||
|
const app = await getJson(`/apis/argoproj.io/v1alpha1/namespaces/${encodeURIComponent(argoNamespace)}/applications/${encodeURIComponent(argoApplication)}`, false);
|
||||||
|
const sync = app?.status?.sync || {};
|
||||||
|
const health = app?.status?.health || {};
|
||||||
|
const op = app?.status?.operationState || {};
|
||||||
|
return {
|
||||||
|
name: argoApplication,
|
||||||
|
namespace: argoNamespace,
|
||||||
|
syncStatus: str(sync.status),
|
||||||
|
healthStatus: str(health.status),
|
||||||
|
revision: str(sync.revision),
|
||||||
|
operationPhase: str(op.phase),
|
||||||
|
operationMessage: str(op.message),
|
||||||
|
ready: sync.status === "Synced" && health.status === "Healthy",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runtimeSummary(expected) {
|
||||||
|
if (!runtimeNamespace || workloads.length === 0) return { ready: null, aligned: null, reason: "runtime-not-configured" };
|
||||||
|
const rows = [];
|
||||||
|
for (const workload of workloads) {
|
||||||
|
const resource = workload.kind === "StatefulSet" ? "statefulsets" : "deployments";
|
||||||
|
const item = await getJson(`/apis/apps/v1/namespaces/${encodeURIComponent(runtimeNamespace)}/${resource}/${encodeURIComponent(workload.name)}`, false);
|
||||||
|
rows.push(workloadSummary(workload, item, expected));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
namespace: runtimeNamespace,
|
||||||
|
expectedSha: shortSha(expected),
|
||||||
|
ready: rows.length > 0 && rows.every((row) => row.ready === true),
|
||||||
|
aligned: rows.length > 0 && rows.every((row) => row.aligned === true),
|
||||||
|
workloads: rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function healthSummary() {
|
||||||
|
if (!healthUrl) return { ok: null, reason: "health-url-not-configured" };
|
||||||
|
const targets = [`${healthUrl.replace(/\/+$/u, "")}/health/readiness`, `${healthUrl.replace(/\/+$/u, "")}/health/live`];
|
||||||
|
const probes = [];
|
||||||
|
for (const url of targets) probes.push(await httpProbe(url));
|
||||||
|
return { ok: probes.every((probe) => probe.ok), probes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function workloadSummary(spec, item, expected) {
|
||||||
|
const status = item?.status || {};
|
||||||
|
const desired = item?.spec?.replicas ?? 1;
|
||||||
|
const readyCount = spec.kind === "StatefulSet" ? status.readyReplicas ?? 0 : status.availableReplicas ?? 0;
|
||||||
|
const updated = status.updatedReplicas ?? readyCount;
|
||||||
|
const commit = workloadCommit(spec, item);
|
||||||
|
return {
|
||||||
|
kind: spec.kind,
|
||||||
|
name: spec.name,
|
||||||
|
ready: readyCount >= desired && updated >= desired,
|
||||||
|
desired,
|
||||||
|
readyReplicas: readyCount,
|
||||||
|
updatedReplicas: updated,
|
||||||
|
sourceCommit: shortSha(commit),
|
||||||
|
aligned: Boolean(expected && commit && commit === expected),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workloadCommit(spec, item) {
|
||||||
|
const meta = item?.metadata || {};
|
||||||
|
const tmpl = item?.spec?.template || {};
|
||||||
|
const podMeta = tmpl.metadata || {};
|
||||||
|
for (const key of spec.sourceCommit.labels || []) if (sha(meta.labels?.[key])) return meta.labels[key];
|
||||||
|
for (const key of spec.sourceCommit.annotations || []) if (sha(meta.annotations?.[key])) return meta.annotations[key];
|
||||||
|
for (const key of spec.sourceCommit.podLabels || []) if (sha(podMeta.labels?.[key])) return podMeta.labels[key];
|
||||||
|
for (const key of spec.sourceCommit.podAnnotations || []) if (sha(podMeta.annotations?.[key])) return podMeta.annotations[key];
|
||||||
|
for (const envName of spec.sourceCommit.env || []) {
|
||||||
|
for (const container of tmpl.spec?.containers || []) {
|
||||||
|
for (const env of container.env || []) if (env?.name === envName && sha(env.value)) return env.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pipelineRunStatus(value) {
|
||||||
|
const condition = (value?.status?.conditions || []).find((item) => item?.type === "Succeeded") || null;
|
||||||
|
return {
|
||||||
|
name: str(value?.metadata?.name),
|
||||||
|
namespace: str(value?.metadata?.namespace),
|
||||||
|
pipelineRefName: str(value?.spec?.pipelineRef?.name),
|
||||||
|
present: value !== null,
|
||||||
|
succeeded: condition?.status === "True" ? true : condition?.status === "False" ? false : null,
|
||||||
|
reason: str(condition?.reason),
|
||||||
|
startTime: str(value?.status?.startTime),
|
||||||
|
completionTime: str(value?.status?.completionTime),
|
||||||
|
durationSeconds: durationSeconds(value?.status?.startTime, value?.status?.completionTime),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskRunsSummary(list) {
|
||||||
|
const items = Array.isArray(list?.items) ? list.items : [];
|
||||||
|
const rows = items.map((item) => {
|
||||||
|
const condition = (item?.status?.conditions || []).find((entry) => entry?.type === "Succeeded") || {};
|
||||||
|
return {
|
||||||
|
name: str(item?.metadata?.name),
|
||||||
|
taskName: str(item?.metadata?.labels?.["tekton.dev/pipelineTask"]) || str(item?.spec?.taskRef?.name),
|
||||||
|
status: str(condition.status) || "Unknown",
|
||||||
|
reason: str(condition.reason),
|
||||||
|
durationSeconds: durationSeconds(item?.status?.startTime, item?.status?.completionTime),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const failed = rows.filter((item) => item.status === "False");
|
||||||
|
const active = rows.filter((item) => item.status !== "True" && item.status !== "False");
|
||||||
|
const slow = rows.filter((item) => typeof item.durationSeconds === "number" && item.durationSeconds >= 60);
|
||||||
|
return {
|
||||||
|
count: rows.length,
|
||||||
|
failedCount: failed.length,
|
||||||
|
activeCount: active.length,
|
||||||
|
slowCount: slow.length,
|
||||||
|
failedItems: failed.slice(0, 5),
|
||||||
|
activeItems: active.slice(0, 5),
|
||||||
|
slowItems: slow.slice(0, 5),
|
||||||
|
items: rows.slice(0, 12),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson(path, required) {
|
||||||
|
const host = process.env.KUBERNETES_SERVICE_HOST;
|
||||||
|
const port = Number(process.env.KUBERNETES_SERVICE_PORT || "443");
|
||||||
|
const tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||||
|
const caPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt";
|
||||||
|
if (!host || !existsSync(tokenPath) || !existsSync(caPath)) fail("kubernetes serviceaccount is unavailable");
|
||||||
|
const token = readFileSync(tokenPath, "utf8").trim();
|
||||||
|
const ca = readFileSync(caPath);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const req = https.request({ host, port, path, method: "GET", ca, headers: { authorization: `Bearer ${token}` } }, (res) => {
|
||||||
|
let body = "";
|
||||||
|
res.setEncoding("utf8");
|
||||||
|
res.on("data", (chunk) => { body += chunk; });
|
||||||
|
res.on("end", () => {
|
||||||
|
if (res.statusCode === 404 && !required) return resolve(null);
|
||||||
|
if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) return reject(new Error(shortText(body || `kube api ${res.statusCode}`)));
|
||||||
|
try { resolve(JSON.parse(body)); } catch (error) { reject(error); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
req.end();
|
||||||
|
}).catch((error) => {
|
||||||
|
errors.push(`${path}: ${shortText(error?.message || String(error))}`);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function httpProbe(url) {
|
||||||
|
const client = url.startsWith("https:") ? https : http;
|
||||||
|
const started = Date.now();
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const req = client.get(url, { timeout: 5000 }, (res) => {
|
||||||
|
res.resume();
|
||||||
|
res.on("end", () => resolve({ url, ok: (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300, statusCode: res.statusCode || null, elapsedMs: Date.now() - started }));
|
||||||
|
});
|
||||||
|
req.on("timeout", () => { req.destroy(); resolve({ url, ok: false, statusCode: null, elapsedMs: Date.now() - started, reason: "timeout" }); });
|
||||||
|
req.on("error", (error) => resolve({ url, ok: false, statusCode: null, elapsedMs: Date.now() - started, reason: shortText(error?.message || String(error)) }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkloads(value) {
|
||||||
|
if (!value) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(Buffer.from(value, "base64").toString("utf8"));
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item === "object").slice(0, 8) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rev(ref) {
|
||||||
|
try {
|
||||||
|
const out = execFileSync("git", [`--git-dir=${repoPath}`, "rev-parse", "--verify", `${ref}^{commit}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||||
|
return sha(out) ? out : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationSeconds(start, finish) {
|
||||||
|
if (!start || !finish) return null;
|
||||||
|
const value = (Date.parse(finish) - Date.parse(start)) / 1000;
|
||||||
|
return Number.isFinite(value) && value >= 0 ? Math.round(value * 10) / 10 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notConfigured(name) {
|
||||||
|
return { ok: false, reason: `${name}-not-configured` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(value) {
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha(value) {
|
||||||
|
return typeof value === "string" && /^[0-9a-f]{40}$/iu.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortSha(value) {
|
||||||
|
return sha(value) ? value.slice(0, 12) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortText(value) {
|
||||||
|
const text = String(value || "").replace(/\s+/gu, " ").trim();
|
||||||
|
return text.length <= 300 ? text : text.slice(0, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredEnv(name) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) fail(`${name} is required`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -32,8 +32,9 @@ import { invalidRuntimeReuseConfig, missingRuntimeReuseConfig, parseRuntimeReuse
|
|||||||
import { prioritizedTaskRunItems } from "./cicd-taskruns";
|
import { prioritizedTaskRunItems } from "./cicd-taskruns";
|
||||||
import { runBranchFollowerTaskRunDrillDown } from "./cicd-taskrun-drilldown";
|
import { runBranchFollowerTaskRunDrillDown } from "./cicd-taskrun-drilldown";
|
||||||
import { runBranchFollowerJobDrillDown, runBranchFollowerRuntimeDrillDown } from "./cicd-job-runtime-drilldown";
|
import { runBranchFollowerJobDrillDown, runBranchFollowerRuntimeDrillDown } from "./cicd-job-runtime-drilldown";
|
||||||
|
import { runBranchFollowerGate } from "./cicd-gates";
|
||||||
import { attachReconcileTimeline, compactReconcileTimeline, finishReconcileStep, finishReconcileTimeline, startReconcileStep, startReconcileTimeline } from "./cicd-reconcile-timeline";
|
import { attachReconcileTimeline, compactReconcileTimeline, finishReconcileStep, finishReconcileTimeline, startReconcileStep, startReconcileTimeline } from "./cicd-reconcile-timeline";
|
||||||
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, StageTiming, TriggerResult } from "./cicd-types";
|
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerGate, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, StageTiming, TriggerResult } from "./cicd-types";
|
||||||
import {
|
import {
|
||||||
arrayField,
|
arrayField,
|
||||||
asRecord,
|
asRecord,
|
||||||
@@ -53,7 +54,7 @@ const SPEC_VERSION = "draft-2026-07-03-p0-branch-follower";
|
|||||||
|
|
||||||
export function cicdHelp(): unknown {
|
export function cicdHelp(): unknown {
|
||||||
return {
|
return {
|
||||||
command: "cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime",
|
command: "cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime|gate",
|
||||||
output: "text by default; use --json, --raw, or -o json|yaml for machine output",
|
output: "text by default; use --json, --raw, or -o json|yaml for machine output",
|
||||||
usage: [
|
usage: [
|
||||||
"bun scripts/cli.ts cicd branch-follower plan",
|
"bun scripts/cli.ts cicd branch-follower plan",
|
||||||
@@ -68,10 +69,9 @@ export function cicdHelp(): unknown {
|
|||||||
"bun scripts/cli.ts cicd branch-follower cleanup-state --follower web-probe-sentinel-master --confirm",
|
"bun scripts/cli.ts cicd branch-follower cleanup-state --follower web-probe-sentinel-master --confirm",
|
||||||
"bun scripts/cli.ts cicd branch-follower events --follower agentrun-jd01-v02",
|
"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 logs --follower web-probe-sentinel-master",
|
||||||
"bun scripts/cli.ts cicd branch-follower status --follower hwlab-jd01-v03 --taskrun runtime-ready --logs-tail 120 --json",
|
|
||||||
"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 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 job --follower agentrun-jd01-v02 --source-commit <sha> --job image-build --json",
|
||||||
"bun scripts/cli.ts cicd branch-follower runtime --follower agentrun-jd01-v02 --workload agentrun-mgr --source-commit <sha> --json",
|
"bun scripts/cli.ts cicd branch-follower gate --follower agentrun-jd01-v02 --gate reuse-plan --source-commit <sha> --json",
|
||||||
],
|
],
|
||||||
config: DEFAULT_CONFIG_PATH,
|
config: DEFAULT_CONFIG_PATH,
|
||||||
spec: `${SPEC_REF} ${SPEC_VERSION}`,
|
spec: `${SPEC_REF} ${SPEC_VERSION}`,
|
||||||
@@ -83,7 +83,7 @@ export async function runCicdCommand(_config: UniDeskConfig | null, args: string
|
|||||||
const top = args[0];
|
const top = args[0];
|
||||||
if (top === undefined || isHelpToken(top)) return renderMachine("cicd", cicdHelp(), "json");
|
if (top === undefined || isHelpToken(top)) return renderMachine("cicd", cicdHelp(), "json");
|
||||||
if (top !== "branch-follower") {
|
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");
|
throw new Error("cicd usage: cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime|gate");
|
||||||
}
|
}
|
||||||
const options = parseOptions(args.slice(1));
|
const options = parseOptions(args.slice(1));
|
||||||
const command = commandLabel(options);
|
const command = commandLabel(options);
|
||||||
@@ -112,6 +112,8 @@ export async function runCicdCommand(_config: UniDeskConfig | null, args: string
|
|||||||
return renderResult(command, await runJobDrillDown(registry, options), options);
|
return renderResult(command, await runJobDrillDown(registry, options), options);
|
||||||
case "runtime":
|
case "runtime":
|
||||||
return renderResult(command, await runRuntimeDrillDown(registry, options), options);
|
return renderResult(command, await runRuntimeDrillDown(registry, options), options);
|
||||||
|
case "gate":
|
||||||
|
return renderResult(command, await runGate(registry, options), options);
|
||||||
case "help":
|
case "help":
|
||||||
return renderMachine(command, cicdHelp(), "json");
|
return renderMachine(command, cicdHelp(), "json");
|
||||||
}
|
}
|
||||||
@@ -122,7 +124,7 @@ function parseOptions(args: string[]): ParsedOptions {
|
|||||||
if (actionToken === undefined || isHelpToken(actionToken)) {
|
if (actionToken === undefined || isHelpToken(actionToken)) {
|
||||||
return defaultOptions("help", args.slice(actionToken === undefined ? 0 : 1));
|
return defaultOptions("help", args.slice(actionToken === undefined ? 0 : 1));
|
||||||
}
|
}
|
||||||
if (!["plan", "apply", "status", "run-once", "debug-step", "cleanup-state", "events", "logs", "taskrun", "job", "runtime"].includes(actionToken)) {
|
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}`);
|
throw new Error(`cicd branch-follower unknown action: ${actionToken}`);
|
||||||
}
|
}
|
||||||
const action = actionToken as BranchFollowerAction;
|
const action = actionToken as BranchFollowerAction;
|
||||||
@@ -162,6 +164,8 @@ function parseOptions(args: string[]): ParsedOptions {
|
|||||||
options.recordState = true;
|
options.recordState = true;
|
||||||
} else if (arg === "--step") {
|
} else if (arg === "--step") {
|
||||||
options.debugStep = debugStepOption(valueOption(rest, ++index, arg));
|
options.debugStep = debugStepOption(valueOption(rest, ++index, arg));
|
||||||
|
} else if (arg === "--gate") {
|
||||||
|
options.gate = gateOption(valueOption(rest, ++index, arg));
|
||||||
} else if (arg === "--taskrun" || arg === "--task-run") {
|
} else if (arg === "--taskrun" || arg === "--task-run") {
|
||||||
options.taskRunName = simpleK8sObjectName(valueOption(rest, ++index, arg), arg);
|
options.taskRunName = simpleK8sObjectName(valueOption(rest, ++index, arg), arg);
|
||||||
} else if (arg === "--pipelinerun" || arg === "--pipeline-run") {
|
} else if (arg === "--pipelinerun" || arg === "--pipeline-run") {
|
||||||
@@ -219,6 +223,7 @@ function parseOptions(args: string[]): ParsedOptions {
|
|||||||
if ((options.action === "job" || options.action === "runtime" || options.jobName !== null || options.workloadName !== null) && options.followerId === null) {
|
if ((options.action === "job" || options.action === "runtime" || options.jobName !== null || options.workloadName !== null) && options.followerId === null) {
|
||||||
throw new Error(`${options.action} requires --follower <id>`);
|
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>");
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +232,11 @@ function debugStepOption(value: string): BranchFollowerDebugStep {
|
|||||||
throw new Error("--step must be state-read, controller-source, status-read, decide, or state-write");
|
throw new Error("--step must be state-read, controller-source, status-read, decide, or state-write");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gateOption(value: string): BranchFollowerGate {
|
||||||
|
if (value === "reuse-plan" || value === "ci-taskrun-plan" || value === "cd-rollout-plan" || value === "post-deploy-health") return value;
|
||||||
|
throw new Error("--gate must be reuse-plan, ci-taskrun-plan, cd-rollout-plan, or post-deploy-health");
|
||||||
|
}
|
||||||
|
|
||||||
function isInClusterRuntime(): boolean {
|
function isInClusterRuntime(): boolean {
|
||||||
return Boolean(process.env.KUBERNETES_SERVICE_HOST && process.env.KUBERNETES_SERVICE_PORT);
|
return Boolean(process.env.KUBERNETES_SERVICE_HOST && process.env.KUBERNETES_SERVICE_PORT);
|
||||||
}
|
}
|
||||||
@@ -247,6 +257,7 @@ function defaultOptions(action: BranchFollowerAction, _args: string[]): ParsedOp
|
|||||||
raw: false,
|
raw: false,
|
||||||
recordState: false,
|
recordState: false,
|
||||||
debugStep: null,
|
debugStep: null,
|
||||||
|
gate: null,
|
||||||
taskRunName: null,
|
taskRunName: null,
|
||||||
pipelineRunName: null,
|
pipelineRunName: null,
|
||||||
jobName: null,
|
jobName: null,
|
||||||
@@ -858,6 +869,13 @@ async function runTaskRunDrillDown(registry: BranchFollowerRegistry, options: Pa
|
|||||||
return runBranchFollowerTaskRunDrillDown(registry, follower, options, runKubeScript);
|
return runBranchFollowerTaskRunDrillDown(registry, follower, options, runKubeScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runGate(registry: BranchFollowerRegistry, options: ParsedOptions): Promise<Record<string, unknown>> {
|
||||||
|
if (options.followerId === null) throw new Error("gate requires --follower <id>");
|
||||||
|
const follower = registry.followers.find((item) => item.id === options.followerId);
|
||||||
|
if (follower === undefined) throw new Error(`unknown follower ${options.followerId}`);
|
||||||
|
return runBranchFollowerGate(registry, follower, options, runKubeScript);
|
||||||
|
}
|
||||||
|
|
||||||
async function runJobDrillDown(registry: BranchFollowerRegistry, options: ParsedOptions): Promise<Record<string, unknown>> {
|
async function runJobDrillDown(registry: BranchFollowerRegistry, options: ParsedOptions): Promise<Record<string, unknown>> {
|
||||||
if (options.followerId === null) throw new Error("job requires --follower <id>");
|
if (options.followerId === null) throw new Error("job requires --follower <id>");
|
||||||
const follower = registry.followers.find((item) => item.id === options.followerId);
|
const follower = registry.followers.find((item) => item.id === options.followerId);
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// SPEC: PJ2026-01060703 CI/CD branch follower independently executable gate probes.
|
||||||
|
// Responsibility: submit bounded target-side gate Jobs and return compact evidence.
|
||||||
|
import type { CommandResult } from "./command";
|
||||||
|
import { resolveAgentRunLaneTarget } from "./agentrun-lanes";
|
||||||
|
import { nativeCicdScriptLoadShell } from "./cicd-native-bundle";
|
||||||
|
import { waitForJobShell } from "./cicd-controller-render";
|
||||||
|
import type { BranchFollowerRegistry, FollowerSpec, ParsedOptions } from "./cicd-types";
|
||||||
|
import { shQuote, redactText } from "./platform-infra-ops-library";
|
||||||
|
|
||||||
|
type KubeScriptRunner = (registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number) => CommandResult;
|
||||||
|
|
||||||
|
export async function runBranchFollowerGate(registry: BranchFollowerRegistry, follower: FollowerSpec, options: ParsedOptions, runKubeScript: KubeScriptRunner): Promise<Record<string, unknown>> {
|
||||||
|
if (options.gate === null) throw new Error("gate requires --gate <reuse-plan|ci-taskrun-plan|cd-rollout-plan|post-deploy-health>");
|
||||||
|
if (options.inCluster) return { ok: false, action: "gate", gate: options.gate, follower: follower.id, degradedReason: "operator-entry-required" };
|
||||||
|
const timeoutSeconds = options.timeoutSeconds ?? follower.budgets.statusSeconds;
|
||||||
|
const jobName = `bf-gate-${safeName(follower.id)}-${safeName(options.gate)}-${Date.now().toString(36)}`.slice(0, 63);
|
||||||
|
const manifest = gateJobManifest(registry, follower, options, jobName, timeoutSeconds);
|
||||||
|
const manifestYaml = `${Bun.YAML.stringify(manifest).trim()}\n`;
|
||||||
|
const script = [
|
||||||
|
"set -eu",
|
||||||
|
"tmp=$(mktemp)",
|
||||||
|
"base64 -d >\"$tmp\" <<'UNIDESK_GATE_JOB'",
|
||||||
|
Buffer.from(manifestYaml, "utf8").toString("base64"),
|
||||||
|
"UNIDESK_GATE_JOB",
|
||||||
|
`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 startedAt = Date.now();
|
||||||
|
const command = runKubeScript(registry, options, script, "", (timeoutSeconds + registry.controller.budgets.reconcileTransportGraceSeconds) * 1000);
|
||||||
|
const parsed = command.exitCode === 0 ? parseFirstJsonObject(command.stdout) : null;
|
||||||
|
const ok = command.exitCode === 0 && parsed !== null && parsed.ok === true;
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
action: "gate",
|
||||||
|
gate: options.gate,
|
||||||
|
follower: follower.id,
|
||||||
|
target: { name: jobName, namespace: registry.controller.namespace, execution: "k8s-native-gate-job" },
|
||||||
|
result: parsed,
|
||||||
|
command: {
|
||||||
|
exitCode: command.exitCode,
|
||||||
|
timedOut: command.timedOut,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
parseError: parsed === null ? "stdout-json-parse-failed" : null,
|
||||||
|
stdoutTail: ok ? "" : redactText(tailText(command.stdout, 1600)),
|
||||||
|
stderrTail: ok ? "" : redactText(tailText(command.stderr, 1200)),
|
||||||
|
},
|
||||||
|
parsedDownstreamCliOutput: false,
|
||||||
|
next: { gate: `bun scripts/cli.ts cicd branch-follower gate --follower ${follower.id} --gate ${options.gate} --json` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function gateJobManifest(registry: BranchFollowerRegistry, follower: FollowerSpec, options: ParsedOptions, jobName: string, timeoutSeconds: number): Record<string, unknown> {
|
||||||
|
const labels = { ...registry.controller.labels, "app.kubernetes.io/component": "cicd-gate-job" };
|
||||||
|
const agentrun = follower.adapter === "agentrun-yaml-lane" ? resolveAgentRunLaneTarget({ node: follower.target.node, lane: follower.target.lane }).spec : null;
|
||||||
|
const gitopsBranch = agentrun?.gitops.branch ?? "";
|
||||||
|
const healthUrl = agentrun?.runtime.internalBaseUrl ?? "";
|
||||||
|
const workloads = (follower.nativeStatus.runtime?.workloads ?? []).map((item) => ({ kind: item.kind, name: item.name, sourceCommit: item.sourceCommit }));
|
||||||
|
const gateScript = [
|
||||||
|
"set -eu",
|
||||||
|
"tmpdir=$(mktemp -d)",
|
||||||
|
"cleanup() { rm -rf \"$tmpdir\"; }",
|
||||||
|
"trap cleanup EXIT INT TERM",
|
||||||
|
nativeCicdScriptLoadShell(["branch-follower-gate.mjs"]),
|
||||||
|
"/etc/unidesk-cicd-branch-follower/sync-source.sh \"$REPOSITORY\" \"$SOURCE_BRANCH\" \"$SNAPSHOT_PREFIX\" \"$REPO_PATH\" >/tmp/bf-gate-source-sync.json 2>/tmp/bf-gate-source-sync.err || true",
|
||||||
|
"node \"$tmpdir/branch-follower-gate.mjs\"",
|
||||||
|
].join("\n");
|
||||||
|
return {
|
||||||
|
apiVersion: "batch/v1",
|
||||||
|
kind: "Job",
|
||||||
|
metadata: { name: jobName, namespace: registry.controller.namespace, labels },
|
||||||
|
spec: {
|
||||||
|
backoffLimit: registry.controller.budgets.reconcileJobBackoffLimit,
|
||||||
|
ttlSecondsAfterFinished: registry.controller.budgets.reconcileJobTtlSeconds,
|
||||||
|
activeDeadlineSeconds: timeoutSeconds + registry.controller.budgets.reconcileJobDeadlineGraceSeconds,
|
||||||
|
template: {
|
||||||
|
metadata: { labels },
|
||||||
|
spec: {
|
||||||
|
restartPolicy: "Never",
|
||||||
|
serviceAccountName: registry.controller.serviceAccountName,
|
||||||
|
volumes: [
|
||||||
|
{ name: "registry", configMap: { name: registry.controller.configMapName, defaultMode: 0o755 } },
|
||||||
|
{ name: "git-mirror-cache", persistentVolumeClaim: { claimName: registry.controller.source.gitMirrorCachePvcName } },
|
||||||
|
{ name: "git-ssh", secret: { secretName: registry.controller.source.githubSsh.secretName, defaultMode: 0o400 } },
|
||||||
|
],
|
||||||
|
containers: [{
|
||||||
|
name: "gate",
|
||||||
|
image: registry.controller.image,
|
||||||
|
imagePullPolicy: "IfNotPresent",
|
||||||
|
command: ["/bin/sh", "-lc", gateScript],
|
||||||
|
env: [
|
||||||
|
{ name: "GATE", value: options.gate },
|
||||||
|
{ name: "FOLLOWER_ID", value: follower.id },
|
||||||
|
{ name: "REPOSITORY", value: follower.source.repository },
|
||||||
|
{ name: "SOURCE_BRANCH", value: follower.source.branch },
|
||||||
|
{ name: "SNAPSHOT_PREFIX", value: follower.source.snapshotPrefix },
|
||||||
|
{ name: "SOURCE_COMMIT", value: options.sourceCommit ?? "" },
|
||||||
|
{ name: "REPO_PATH", value: follower.nativeStatus.source.repoPath },
|
||||||
|
{ name: "GITOPS_BRANCH", value: gitopsBranch },
|
||||||
|
{ name: "TEKTON_NAMESPACE", value: follower.nativeStatus.tekton?.namespace ?? "" },
|
||||||
|
{ name: "PIPELINE_RUN_PREFIX", value: follower.nativeStatus.tekton?.pipelineRunPrefix ?? "" },
|
||||||
|
{ name: "ARGO_NAMESPACE", value: follower.nativeStatus.argo?.namespace ?? "" },
|
||||||
|
{ name: "ARGO_APPLICATION", value: follower.nativeStatus.argo?.application ?? "" },
|
||||||
|
{ name: "RUNTIME_NAMESPACE", value: follower.nativeStatus.runtime?.namespace ?? "" },
|
||||||
|
{ name: "WORKLOADS_B64", value: Buffer.from(JSON.stringify(workloads), "utf8").toString("base64") },
|
||||||
|
{ name: "HEALTH_URL", value: healthUrl },
|
||||||
|
{ name: "UNIDESK_CONTROLLER_GITHUB_SSH_PRIVATE_KEY", value: `/git-ssh/${registry.controller.source.githubSsh.privateKeySecretKey}` },
|
||||||
|
{ name: "UNIDESK_CONTROLLER_GITHUB_PROXY_HOST", value: registry.controller.source.githubSsh.proxyHost },
|
||||||
|
{ name: "UNIDESK_CONTROLLER_GITHUB_PROXY_PORT", value: String(registry.controller.source.githubSsh.proxyPort) },
|
||||||
|
],
|
||||||
|
volumeMounts: [
|
||||||
|
{ name: "registry", mountPath: "/etc/unidesk-cicd-branch-follower", readOnly: true },
|
||||||
|
{ name: "git-mirror-cache", mountPath: "/cache" },
|
||||||
|
{ name: "git-ssh", mountPath: "/git-ssh", readOnly: true },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFirstJsonObject(text: string): Record<string, unknown> | null {
|
||||||
|
const start = text.indexOf("{");
|
||||||
|
if (start < 0) return null;
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = start; index < text.length; index += 1) {
|
||||||
|
const char = text[index];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) escaped = false;
|
||||||
|
else if (char === "\\") escaped = true;
|
||||||
|
else if (char === "\"") inString = false;
|
||||||
|
} else if (char === "\"") inString = true;
|
||||||
|
else if (char === "{") depth += 1;
|
||||||
|
else if (char === "}" && --depth === 0) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text.slice(start, index + 1)) as unknown;
|
||||||
|
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeName(value: string): string {
|
||||||
|
return value.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/-+/gu, "-").replace(/^-|-$/gu, "").slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tailText(text: string, maxChars: number): string {
|
||||||
|
return text.length <= maxChars ? text : text.slice(text.length - maxChars);
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
// Responsibility: type contracts shared by branch follower entry, controller render, and native K8s helpers.
|
// Responsibility: type contracts shared by branch follower entry, controller render, and native K8s helpers.
|
||||||
|
|
||||||
export type OutputMode = "human" | "json" | "yaml";
|
export type OutputMode = "human" | "json" | "yaml";
|
||||||
export type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "debug-step" | "cleanup-state" | "events" | "logs" | "taskrun" | "job" | "runtime";
|
export type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "debug-step" | "cleanup-state" | "events" | "logs" | "taskrun" | "job" | "runtime" | "gate";
|
||||||
export type BranchFollowerDebugStep = "state-read" | "controller-source" | "status-read" | "decide" | "state-write";
|
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";
|
||||||
export type BranchFollowerPhase =
|
export type BranchFollowerPhase =
|
||||||
| "Observed"
|
| "Observed"
|
||||||
| "Noop"
|
| "Noop"
|
||||||
@@ -31,6 +32,7 @@ export interface ParsedOptions {
|
|||||||
raw: boolean;
|
raw: boolean;
|
||||||
recordState: boolean;
|
recordState: boolean;
|
||||||
debugStep: BranchFollowerDebugStep | null;
|
debugStep: BranchFollowerDebugStep | null;
|
||||||
|
gate: BranchFollowerGate | null;
|
||||||
taskRunName: string | null;
|
taskRunName: string | null;
|
||||||
pipelineRunName: string | null;
|
pipelineRunName: string | null;
|
||||||
jobName: string | null;
|
jobName: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user