Files
kuber/server/buildpacks-job.ts
T
2026-10-06 15:31:51 +00:00

313 lines
10 KiB
TypeScript

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<string>();
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" },
],
},
},
]
: []),
],
},
},
},
};
}