import type { BuildJobOptions, KubernetesJob } from "./build-job"; // The Heroku 26 builder is multi-architecture and ships lifecycle 0.21.22. export const DEFAULT_BUILDPACKS_IMAGE = "docker.io/heroku/builder:26@sha256:d71287feea697567159584a0e8fa9c1af8d8419b07829bf40dd793cc26c7f7ad"; export function validateBuildpacksImage(image: string): void { if (!/^[a-zA-Z0-9][a-zA-Z0-9._:/-]*@sha256:[a-f0-9]{64}$/.test(image)) throw new Error("Buildpacks builder image must be digest-pinned (sha256)"); } function safePath(path: string, name: string): string { const normalized = path === "." ? "" : path.replace(/^\.\//, ""); if ( !path || path.startsWith("/") || path.includes("\\") || path.includes("\0") || (normalized && normalized .split("/") .some((part) => !part || part === "." || part === "..")) ) throw new Error(`${name} must be a safe workspace-relative path`); return normalized; } function registry(image: string): string { const first = image.split("/")[0]!; return first.includes(".") || first.includes(":") || first === "localhost" ? first : "docker.io"; } export function createBuildpacksJob( options: BuildJobOptions & { buildpacksImage?: string; buildpackSubPath?: string; }, ): KubernetesJob { const builderImage = options.buildpacksImage ?? DEFAULT_BUILDPACKS_IMAGE; validateBuildpacksImage(builderImage); if (options.spec.builder !== "buildpacks") throw new Error("Buildpacks Job requires the buildpacks builder"); if ( options.spec.dockerfile !== undefined || options.spec.target !== undefined || options.spec.buildArgs.length ) throw new Error( "Buildpacks does not support dockerfile, target, or buildArgs", ); if ( !/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(options.name) || options.name.length > 63 ) throw new Error("Job name must be a valid DNS label"); const context = safePath(options.spec.context, "Build context"); const subPath = options.workspaceSubPath ? safePath(options.workspaceSubPath, "Workspace subPath") : undefined; if (!!options.spec.buildpackUri !== !!options.buildpackSubPath) throw new Error("Custom buildpack URI requires a staged package path"); const packagePath = options.buildpackSubPath ? safePath(options.buildpackSubPath, "Buildpack subPath") : undefined; const packageMounts = packagePath ? [ { name: "workspace", mountPath: "/package", subPath: packagePath, readOnly: true, }, ] : []; const customMounts = packagePath ? [ { name: "custom-buildpacks", mountPath: "/custom-buildpacks", readOnly: true, }, ...packageMounts, ] : []; const images = [ options.pushImage ?? options.spec.image, ...(options.pushImages ?? []), ]; if (images.some((image) => !image || /[,"\r\n\s]/.test(image))) throw new Error("Invalid Buildpacks output image name"); // The lifecycle's registry exporter rejects tags spanning registries. if (new Set(images.map(registry)).size > 1) throw new Error("Buildpacks output images must use the same registry"); if (!options.cacheImage || /[,"\r\n\s]/.test(options.cacheImage)) throw new Error("Invalid Buildpacks cache image name"); const insecureRegistries = new Set(); if (options.pushRegistryInsecure) images.forEach((image) => { insecureRegistries.add(registry(image)); }); if (options.cacheRegistryInsecure) insecureRegistries.add(registry(options.cacheImage)); const labels = { "app.kubernetes.io/name": "kuber-buildpacks", "app.kubernetes.io/managed-by": "kuber", "kuber.astrxl.dev/build": options.name, ...options.labels, }; const tolerations = [...(options.tolerations ?? [])]; const amd64Toleration = { key: "arch", operator: "Equal", value: "amd64", effect: "NoExecute", }; if ( options.spec.architecture === "amd64" && !tolerations.some( (value) => Object.keys(amd64Toleration).length === Object.keys(value).length && Object.entries(amd64Toleration).every( ([key, entry]) => value[key] === entry, ), ) ) tolerations.push(amd64Toleration); const env = [ // lifecycle 0.21.22 requires this input; 0.15 enables --insecure-registry. { name: "CNB_PLATFORM_API", value: "0.15" }, { name: "TMPDIR", value: "/tmp" }, ...(options.registrySecretName ? [{ name: "DOCKER_CONFIG", value: "/docker-config" }] : []), ]; const writableMounts = [ { name: "app", mountPath: "/cnb-app" }, { name: "layers", mountPath: "/layers" }, { name: "cache", mountPath: "/cache" }, { name: "platform", mountPath: "/platform" }, { name: "tmp", mountPath: "/tmp" }, ]; const authMounts = options.registrySecretName ? [{ name: "registry-auth", mountPath: "/docker-config", readOnly: true }] : []; return { apiVersion: "batch/v1", kind: "Job", metadata: { name: options.name, namespace: options.namespace, labels, annotations: { "kuber.astrxl.dev/workspace": options.spec.workspace }, }, spec: { backoffLimit: options.backoffLimit ?? 0, ttlSecondsAfterFinished: options.ttlSecondsAfterFinished ?? 3600, template: { metadata: { labels }, spec: { restartPolicy: "Never", ...(options.serviceAccountName ? { serviceAccountName: options.serviceAccountName } : {}), automountServiceAccountToken: false, nodeSelector: { ...options.nodeSelector, "kubernetes.io/arch": options.spec.architecture, }, ...(tolerations.length ? { tolerations } : {}), securityContext: { runAsNonRoot: true, runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000, seccompProfile: { type: "RuntimeDefault" }, }, ...(options.registrySecretName ? { imagePullSecrets: [{ name: options.registrySecretName }] } : {}), initContainers: [ ...(packagePath ? [ { name: "prepare-buildpacks", image: builderImage, imagePullPolicy: "IfNotPresent", command: ["/bin/sh", "-ec"], args: [ 'cp -R /cnb/buildpacks/. /custom-buildpacks/; for directory in /package/buildpacks/*; do rm -rf "/custom-buildpacks/${directory##*/}"; done; cp -R /package/buildpacks/. /custom-buildpacks/', ], securityContext: { allowPrivilegeEscalation: false, runAsNonRoot: true, runAsUser: 1000, runAsGroup: 1000, }, volumeMounts: [ ...packageMounts, { name: "custom-buildpacks", mountPath: "/custom-buildpacks", }, ], }, ] : []), { name: "copy-source", image: builderImage, imagePullPolicy: "IfNotPresent", command: ["/bin/sh", "-ec"], args: [ 'cp -R "$1/." /cnb-app/ && chmod -R u+rwX /cnb-app', "--", context ? `/workspace/${context}` : "/workspace", ], securityContext: { allowPrivilegeEscalation: false, runAsNonRoot: true, runAsUser: 1000, runAsGroup: 1000, }, volumeMounts: [ { name: "workspace", mountPath: "/workspace", readOnly: true, ...(subPath ? { subPath } : {}), }, { name: "app", mountPath: "/cnb-app" }, { name: "tmp", mountPath: "/tmp" }, ], }, ], containers: [ { // The existing Kubernetes observer selects this name for logs/status. name: "buildkit", image: builderImage, imagePullPolicy: "IfNotPresent", command: ["/cnb/lifecycle/creator"], args: [ "--app", "/cnb-app", "--layers", "/layers", "--platform", "/platform", ...(packagePath ? [ "--buildpacks", "/custom-buildpacks", "--order", "/package/order.toml", ] : []), "--cache-image", options.cacheImage, "--uid", "1000", "--gid", "1000", ...[...insecureRegistries].flatMap((host) => [ "--insecure-registry", host, ]), ...images.slice(1).flatMap((image) => ["--tag", image]), images[0], ], env, securityContext: { allowPrivilegeEscalation: false, runAsNonRoot: true, runAsUser: 1000, runAsGroup: 1000, }, volumeMounts: [...writableMounts, ...authMounts, ...customMounts], }, ], volumes: [ { name: "workspace", persistentVolumeClaim: { claimName: options.workspaceClaimName }, }, ...["app", "layers", "cache", "platform", "tmp"].map((name) => ({ name, emptyDir: {}, })), ...(packagePath ? [{ name: "custom-buildpacks", emptyDir: {} }] : []), ...(options.registrySecretName ? [ { name: "registry-auth", secret: { secretName: options.registrySecretName, items: [ { key: ".dockerconfigjson", path: "config.json" }, ], }, }, ] : []), ], }, }, }, }; }