// 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 { 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 { if (options.followerId === null) throw new Error("debug-step target job requires --follower "); 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[] { 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"); }