313 lines
10 KiB
TypeScript
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" },
|
|
],
|
|
},
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|