316 lines
16 KiB
TypeScript
316 lines
16 KiB
TypeScript
// SPEC: PJ2026-01060703 CI/CD branch follower controller render helpers.
|
|
// Responsibility: Kubernetes controller/reconcile Job manifests and controller bootstrap scripts.
|
|
import { createHash } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { rootPath } from "./config";
|
|
import { shQuote } from "./platform-infra-ops-library";
|
|
import type { BranchFollowerDebugStep, BranchFollowerRegistry, ParsedOptions } from "./cicd-types";
|
|
|
|
const SPEC_REF = "PJ2026-01060703";
|
|
|
|
export function renderControllerReconcileJob(registry: BranchFollowerRegistry, options: ParsedOptions, jobName: string, mode: { dryRun: boolean; wait?: boolean; recordState: boolean }, timeoutSeconds: number): Record<string, unknown> {
|
|
const labels = { ...registry.controller.labels, "app.kubernetes.io/component": "cicd-reconcile-job" };
|
|
const commandArgs = [
|
|
"bun",
|
|
"scripts/cli.ts",
|
|
"cicd",
|
|
"branch-follower",
|
|
"run-once",
|
|
...(options.followerId === null ? ["--all"] : ["--follower", options.followerId]),
|
|
mode.dryRun ? "--dry-run" : "--confirm",
|
|
"--wait",
|
|
"--in-cluster",
|
|
"--config",
|
|
"config/cicd-branch-followers.yaml",
|
|
"--timeout-seconds",
|
|
String(timeoutSeconds),
|
|
...(mode.recordState ? ["--record-state"] : []),
|
|
];
|
|
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 } },
|
|
{ name: "work", emptyDir: {} },
|
|
],
|
|
containers: [
|
|
{
|
|
name: "reconcile",
|
|
image: registry.controller.image,
|
|
imagePullPolicy: "IfNotPresent",
|
|
command: ["/bin/sh", "/etc/unidesk-cicd-branch-follower/controller-one-shot.sh"],
|
|
args: commandArgs,
|
|
env: [
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_BRANCH", value: registry.controller.source.branch },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_REPOSITORY", value: registry.controller.source.repository },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX", value: registry.controller.source.sourceSnapshot.stageRefPrefix.replaceAll("{branch}", registry.controller.source.branch) },
|
|
{ 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 },
|
|
{ name: "work", mountPath: "/work" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function renderControllerDebugJob(registry: BranchFollowerRegistry, options: ParsedOptions, jobName: string, step: BranchFollowerDebugStep, timeoutSeconds: number): Record<string, unknown> {
|
|
if (options.followerId === null) throw new Error("debug-step target job requires --follower <id>");
|
|
const labels = { ...registry.controller.labels, "app.kubernetes.io/component": "cicd-debug-job" };
|
|
const commandArgs = [
|
|
"bun",
|
|
"scripts/cli.ts",
|
|
"cicd",
|
|
"branch-follower",
|
|
"debug-step",
|
|
"--follower",
|
|
options.followerId,
|
|
"--step",
|
|
step,
|
|
options.confirm ? "--confirm" : "--dry-run",
|
|
"--in-cluster",
|
|
"--config",
|
|
"config/cicd-branch-followers.yaml",
|
|
"--timeout-seconds",
|
|
String(timeoutSeconds),
|
|
"--json",
|
|
];
|
|
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 } },
|
|
{ name: "work", emptyDir: {} },
|
|
],
|
|
containers: [
|
|
{
|
|
name: "debug",
|
|
image: registry.controller.image,
|
|
imagePullPolicy: "IfNotPresent",
|
|
command: ["/bin/sh", "/etc/unidesk-cicd-branch-follower/controller-one-shot.sh"],
|
|
args: commandArgs,
|
|
env: [
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_BRANCH", value: registry.controller.source.branch },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_REPOSITORY", value: registry.controller.source.repository },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX", value: registry.controller.source.sourceSnapshot.stageRefPrefix.replaceAll("{branch}", registry.controller.source.branch) },
|
|
{ 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 },
|
|
{ name: "work", mountPath: "/work" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function waitForJobShell(namespace: string, jobName: string, timeoutSeconds: number): string {
|
|
return [
|
|
`NAMESPACE=${shQuote(namespace)}`,
|
|
`JOB_NAME=${shQuote(jobName)}`,
|
|
`TIMEOUT_SECONDS=${shQuote(String(timeoutSeconds))}`,
|
|
"export NAMESPACE JOB_NAME TIMEOUT_SECONDS",
|
|
nativeCicdScript("wait-job.sh"),
|
|
].join("\n");
|
|
}
|
|
|
|
export function renderControllerManifests(registry: BranchFollowerRegistry): Record<string, unknown>[] {
|
|
const labels = registry.controller.labels;
|
|
const selector = labels;
|
|
const controllerConfigData = {
|
|
"cicd-branch-followers.yaml": registry.rawText,
|
|
"sync-source.sh": nativeCicdScript("sync-source.sh"),
|
|
"controller-one-shot.sh": nativeCicdScript("controller-one-shot.sh"),
|
|
"controller-loop.sh": nativeCicdScript("controller-loop.sh"),
|
|
"hwlab-node-control-plane-refresh.mjs": nativeCicdScript("hwlab-node-control-plane-refresh.mjs"),
|
|
"github-proxy-connect.mjs": nativeCicdScript("github-proxy-connect.mjs"),
|
|
"git-ssh-proxy.sh": nativeCicdScript("git-ssh-proxy.sh"),
|
|
"runtime-gitops-observability.mjs": nativeHwlabScript("runtime-gitops-observability.mjs"),
|
|
"runtime-gitops-postprocess.mjs": nativeHwlabScript("runtime-gitops-postprocess.mjs"),
|
|
"runtime-gitops-verify.mjs": nativeHwlabScript("runtime-gitops-verify.mjs"),
|
|
};
|
|
const controllerConfigSha = sha256(JSON.stringify(controllerConfigData));
|
|
return [
|
|
{
|
|
apiVersion: "v1",
|
|
kind: "Namespace",
|
|
metadata: { name: registry.controller.namespace, labels },
|
|
},
|
|
{
|
|
apiVersion: "v1",
|
|
kind: "ServiceAccount",
|
|
metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels },
|
|
},
|
|
{
|
|
apiVersion: "rbac.authorization.k8s.io/v1",
|
|
kind: "Role",
|
|
metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels },
|
|
rules: [
|
|
{ apiGroups: [""], resources: ["configmaps", "pods", "events"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
|
{ apiGroups: ["apps"], resources: ["deployments"], verbs: ["get", "list", "watch"] },
|
|
{ apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] },
|
|
{ apiGroups: ["coordination.k8s.io"], resources: ["leases"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
|
],
|
|
},
|
|
{
|
|
apiVersion: "rbac.authorization.k8s.io/v1",
|
|
kind: "RoleBinding",
|
|
metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels },
|
|
subjects: [{ kind: "ServiceAccount", name: registry.controller.serviceAccountName, namespace: registry.controller.namespace }],
|
|
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: registry.controller.serviceAccountName },
|
|
},
|
|
{
|
|
apiVersion: "rbac.authorization.k8s.io/v1",
|
|
kind: "ClusterRole",
|
|
metadata: { name: registry.controller.serviceAccountName, labels },
|
|
rules: [
|
|
{ apiGroups: [""], resources: ["pods", "pods/log", "events"], verbs: ["get", "list", "watch"] },
|
|
{ apiGroups: [""], resources: ["configmaps"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
|
{ apiGroups: [""], resources: ["pods/exec"], verbs: ["create"] },
|
|
{ apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch", "create", "delete"] },
|
|
{ apiGroups: ["apps"], resources: ["deployments", "statefulsets"], verbs: ["get", "list", "watch"] },
|
|
{ apiGroups: ["tekton.dev"], resources: ["pipelineruns"], verbs: ["get", "list", "watch", "create", "patch", "delete"] },
|
|
{ apiGroups: ["tekton.dev"], resources: ["pipelines"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
|
{ apiGroups: ["tekton.dev"], resources: ["taskruns"], verbs: ["get", "list", "watch"] },
|
|
{ apiGroups: ["argoproj.io"], resources: ["applications"], verbs: ["get", "list", "watch", "patch"] },
|
|
],
|
|
},
|
|
{
|
|
apiVersion: "rbac.authorization.k8s.io/v1",
|
|
kind: "ClusterRoleBinding",
|
|
metadata: { name: registry.controller.serviceAccountName, labels },
|
|
subjects: [{ kind: "ServiceAccount", name: registry.controller.serviceAccountName, namespace: registry.controller.namespace }],
|
|
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "ClusterRole", name: registry.controller.serviceAccountName },
|
|
},
|
|
{
|
|
apiVersion: "v1",
|
|
kind: "ConfigMap",
|
|
metadata: { name: registry.controller.configMapName, namespace: registry.controller.namespace, labels },
|
|
data: controllerConfigData,
|
|
},
|
|
{
|
|
apiVersion: "v1",
|
|
kind: "ConfigMap",
|
|
metadata: { name: registry.controller.stateConfigMapName, namespace: registry.controller.namespace, labels },
|
|
data: {
|
|
_createdAt: new Date().toISOString(),
|
|
_specRef: SPEC_REF,
|
|
_registrySha256: registry.rawSha256,
|
|
_controllerConfigSha256: controllerConfigSha,
|
|
},
|
|
},
|
|
{
|
|
apiVersion: "coordination.k8s.io/v1",
|
|
kind: "Lease",
|
|
metadata: { name: registry.controller.leaseName, namespace: registry.controller.namespace, labels },
|
|
spec: { holderIdentity: "unidesk-cicd-branch-follower", leaseDurationSeconds: registry.controller.loop.leaseDurationSeconds },
|
|
},
|
|
{
|
|
apiVersion: "apps/v1",
|
|
kind: "Deployment",
|
|
metadata: { name: registry.controller.deploymentName, namespace: registry.controller.namespace, labels },
|
|
spec: {
|
|
replicas: 1,
|
|
selector: { matchLabels: selector },
|
|
template: {
|
|
metadata: {
|
|
labels: selector,
|
|
annotations: {
|
|
"unidesk.pikapython.com/spec-ref": SPEC_REF,
|
|
"unidesk.pikapython.com/registry-sha256": registry.rawSha256,
|
|
"unidesk.pikapython.com/controller-config-sha256": controllerConfigSha,
|
|
"unidesk.pikapython.com/host-worktree-authority": "false",
|
|
},
|
|
},
|
|
spec: {
|
|
serviceAccountName: registry.controller.serviceAccountName,
|
|
terminationGracePeriodSeconds: registry.controller.loop.terminationGracePeriodSeconds,
|
|
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 } },
|
|
{ name: "work", emptyDir: {} },
|
|
],
|
|
containers: [
|
|
{
|
|
name: "controller",
|
|
image: registry.controller.image,
|
|
imagePullPolicy: "IfNotPresent",
|
|
command: ["/bin/sh", "/etc/unidesk-cicd-branch-follower/controller-loop.sh"],
|
|
env: [
|
|
{ name: "UNIDESK_CICD_BRANCH_FOLLOWER_INTERVAL_SECONDS", value: String(registry.controller.loop.intervalSeconds) },
|
|
{ name: "UNIDESK_CICD_BRANCH_FOLLOWER_TIMEOUT_SECONDS", value: String(registry.controller.loop.reconcileTimeoutSeconds) },
|
|
{ name: "UNIDESK_CONTROLLER_GIT_MIRROR_READ_URL", value: registry.controller.source.gitMirrorReadUrl },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_BRANCH", value: registry.controller.source.branch },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_REPOSITORY", value: registry.controller.source.repository },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX", value: registry.controller.source.sourceSnapshot.stageRefPrefix.replaceAll("{branch}", registry.controller.source.branch) },
|
|
{ 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 },
|
|
{ name: "work", mountPath: "/work" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function nativeCicdScript(name: string): string {
|
|
return readFileSync(rootPath("scripts/native/cicd", name), "utf8");
|
|
}
|
|
|
|
function nativeHwlabScript(name: string): string {
|
|
return readFileSync(rootPath("scripts/native/hwlab", name), "utf8");
|
|
}
|
|
|
|
function sha256(value: string): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|