466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { createBuildJob } from "../../server/build-job";
|
|
import {
|
|
createBuildpacksJob,
|
|
DEFAULT_BUILDPACKS_IMAGE,
|
|
} from "../../server/buildpacks-job";
|
|
import type { BuildSpec } from "../../shared/build-protocol";
|
|
|
|
function spec(architecture: "arm64" | "amd64"): BuildSpec {
|
|
return {
|
|
architecture,
|
|
image: "registry.example.com/kuber/demo-web:latest",
|
|
context: "apps/web",
|
|
dockerfile: "docker/Web.Dockerfile",
|
|
target: "production",
|
|
buildArgs: ["NODE_ENV=production"],
|
|
workspace: `sha256:${"a".repeat(64)}`,
|
|
};
|
|
}
|
|
|
|
describe("BuildKit Job generation", () => {
|
|
test("quotes multiple image names as one BuildKit image exporter", () => {
|
|
const job = createBuildJob({
|
|
name: "build-multi",
|
|
namespace: "default",
|
|
spec: spec("amd64"),
|
|
workspaceClaimName: "workspace",
|
|
cacheImage: "registry.example.com/cache/demo-web",
|
|
pushImage: "registry.example.com/kuber/demo-web:latest",
|
|
pushImages: ["registry.example.com/kuber/demo-worker:latest"],
|
|
});
|
|
expect((job.spec as any).template.spec.containers[0].args).toContain(
|
|
'--output=type=image,"name=registry.example.com/kuber/demo-web:latest,registry.example.com/kuber/demo-worker:latest",push=true',
|
|
);
|
|
});
|
|
test.each(["arm64", "amd64"] as const)(
|
|
"generates a rootless %s job",
|
|
(architecture) => {
|
|
const job = createBuildJob({
|
|
name: `build-${architecture}`,
|
|
namespace: "kuber-system",
|
|
spec: spec(architecture),
|
|
workspaceClaimName: "build-workspaces",
|
|
workspaceSubPath: "snapshot",
|
|
cacheImage: "registry.example.com/cache/demo-web",
|
|
registrySecretName: "registry-auth",
|
|
nodeSelector: { "kubernetes.io/arch": "wrong", pool: "builders" },
|
|
});
|
|
const jobSpec = job.spec as any;
|
|
const pod = jobSpec.template.spec;
|
|
const container = pod.containers[0];
|
|
expect(pod.nodeSelector["kubernetes.io/arch"]).toBe(architecture);
|
|
expect(pod.nodeSelector.pool).toBe("builders");
|
|
if (architecture === "amd64") {
|
|
expect(pod.tolerations).toEqual([
|
|
{
|
|
key: "arch",
|
|
operator: "Equal",
|
|
value: "amd64",
|
|
effect: "NoExecute",
|
|
},
|
|
]);
|
|
} else {
|
|
expect(pod.tolerations).toBeUndefined();
|
|
}
|
|
expect(pod.automountServiceAccountToken).toBe(false);
|
|
expect(pod.securityContext).toMatchObject({
|
|
runAsNonRoot: true,
|
|
runAsUser: 1000,
|
|
seccompProfile: { type: "Unconfined" },
|
|
});
|
|
expect(container.securityContext).toEqual({
|
|
runAsNonRoot: true,
|
|
runAsUser: 1000,
|
|
allowPrivilegeEscalation: true,
|
|
seccompProfile: { type: "Unconfined" },
|
|
appArmorProfile: { type: "Unconfined" },
|
|
});
|
|
expect(container.env).toContainEqual({
|
|
name: "BUILDKITD_FLAGS",
|
|
value: "--oci-worker-no-process-sandbox",
|
|
});
|
|
expect(container.args).toContain(`--opt=platform=linux/${architecture}`);
|
|
expect(container.args).toContain(
|
|
"--import-cache=type=registry,ref=registry.example.com/cache/demo-web",
|
|
);
|
|
expect(container.args).toContain(
|
|
"--export-cache=type=registry,ref=registry.example.com/cache/demo-web,mode=max",
|
|
);
|
|
expect(container.args).toContain(
|
|
"--output=type=image,name=registry.example.com/kuber/demo-web:latest,push=true",
|
|
);
|
|
expect(container.volumeMounts).toContainEqual({
|
|
name: "workspace",
|
|
mountPath: "/workspace",
|
|
readOnly: true,
|
|
subPath: "snapshot",
|
|
});
|
|
expect(pod.volumes).toContainEqual({
|
|
name: "registry-auth",
|
|
secret: {
|
|
secretName: "registry-auth",
|
|
items: [{ key: ".dockerconfigjson", path: "config.json" }],
|
|
},
|
|
});
|
|
expect(
|
|
jobSpec.template.metadata.annotations[
|
|
"container.apparmor.security.beta.kubernetes.io/buildkit"
|
|
],
|
|
).toBe("unconfined");
|
|
},
|
|
);
|
|
|
|
test("merges caller tolerations with the amd64 placement toleration", () => {
|
|
const amd64Toleration = {
|
|
key: "arch",
|
|
operator: "Equal",
|
|
value: "amd64",
|
|
effect: "NoExecute",
|
|
};
|
|
const customToleration = {
|
|
key: "workload",
|
|
operator: "Equal",
|
|
value: "build",
|
|
effect: "NoSchedule",
|
|
};
|
|
const job = createBuildJob({
|
|
name: "build-tolerations",
|
|
namespace: "default",
|
|
spec: spec("amd64"),
|
|
workspaceClaimName: "workspace",
|
|
cacheImage: "registry.example.com/cache/demo-web",
|
|
tolerations: [customToleration, amd64Toleration],
|
|
});
|
|
|
|
expect((job.spec as any).template.spec.tolerations).toEqual([
|
|
customToleration,
|
|
amd64Toleration,
|
|
]);
|
|
});
|
|
|
|
test("preserves caller tolerations for arm64 without adding architecture placement", () => {
|
|
const customToleration = {
|
|
key: "workload",
|
|
operator: "Equal",
|
|
value: "build",
|
|
effect: "NoSchedule",
|
|
};
|
|
const job = createBuildJob({
|
|
name: "build-arm64-tolerations",
|
|
namespace: "default",
|
|
spec: spec("arm64"),
|
|
workspaceClaimName: "workspace",
|
|
cacheImage: "registry.example.com/cache/demo-web",
|
|
tolerations: [customToleration],
|
|
});
|
|
|
|
expect((job.spec as any).template.spec.tolerations).toEqual([
|
|
customToleration,
|
|
]);
|
|
});
|
|
|
|
test("uses pushImage for output when set", () => {
|
|
const job = createBuildJob({
|
|
name: "build-push",
|
|
namespace: "default",
|
|
spec: spec("amd64"),
|
|
workspaceClaimName: "workspace",
|
|
cacheImage: "registry.example.com/cache/demo-web",
|
|
pushImage: "internal.registry:5000/kuber/demo-web:latest",
|
|
});
|
|
const container = (job.spec as any).template.spec.containers[0];
|
|
expect(container.args).toContain(
|
|
"--output=type=image,name=internal.registry:5000/kuber/demo-web:latest,push=true",
|
|
);
|
|
expect(container.args).not.toContainEqual(
|
|
expect.stringContaining("--output=type=image,name=registry.example.com"),
|
|
);
|
|
});
|
|
|
|
test("adds insecure flags when configured", () => {
|
|
const job = createBuildJob({
|
|
name: "build-insecure",
|
|
namespace: "default",
|
|
spec: spec("amd64"),
|
|
workspaceClaimName: "workspace",
|
|
cacheImage: "internal.registry:5000/cache/demo-web",
|
|
pushImage: "internal.registry:5000/kuber/demo-web:latest",
|
|
pushRegistryInsecure: true,
|
|
cacheRegistryInsecure: true,
|
|
});
|
|
const container = (job.spec as any).template.spec.containers[0];
|
|
expect(container.args).toContain(
|
|
"--import-cache=type=registry,ref=internal.registry:5000/cache/demo-web,registry.insecure=true",
|
|
);
|
|
expect(container.args).toContain(
|
|
"--export-cache=type=registry,ref=internal.registry:5000/cache/demo-web,mode=max,registry.insecure=true",
|
|
);
|
|
expect(container.args).toContain(
|
|
"--output=type=image,name=internal.registry:5000/kuber/demo-web:latest,push=true,registry.insecure=true",
|
|
);
|
|
});
|
|
|
|
test("no insecure flags when not configured", () => {
|
|
const job = createBuildJob({
|
|
name: "build-secure",
|
|
namespace: "default",
|
|
spec: spec("amd64"),
|
|
workspaceClaimName: "workspace",
|
|
cacheImage: "registry.example.com/cache/demo-web",
|
|
});
|
|
const container = (job.spec as any).template.spec.containers[0];
|
|
for (const arg of container.args) {
|
|
expect(arg).not.toContain("registry.insecure");
|
|
}
|
|
});
|
|
|
|
test("rejects traversal and invalid Kubernetes names", () => {
|
|
expect(() =>
|
|
createBuildJob({
|
|
name: "Invalid_Name",
|
|
namespace: "default",
|
|
spec: spec("arm64"),
|
|
workspaceClaimName: "workspace",
|
|
cacheImage: "cache",
|
|
}),
|
|
).toThrow("DNS label");
|
|
expect(() =>
|
|
createBuildJob({
|
|
name: "valid",
|
|
namespace: "default",
|
|
spec: { ...spec("arm64"), context: "../outside" },
|
|
workspaceClaimName: "workspace",
|
|
cacheImage: "cache",
|
|
}),
|
|
).toThrow("safe workspace-relative");
|
|
expect(() =>
|
|
createBuildJob({
|
|
name: "valid",
|
|
namespace: "default",
|
|
spec: spec("arm64"),
|
|
workspaceClaimName: "workspace",
|
|
workspaceSubPath: "../outside",
|
|
cacheImage: "cache",
|
|
}),
|
|
).toThrow("Workspace subPath");
|
|
});
|
|
});
|
|
|
|
describe("Buildpacks Job generation", () => {
|
|
test("mounts custom package separately, preserves builtins and passes explicit creator order", () => {
|
|
const uri =
|
|
"https://github.com/example/bun/releases/download/v1/buildpack.cnb";
|
|
const job = createBuildpacksJob({
|
|
...options("arm64"),
|
|
spec: { ...options("arm64").spec, buildpackUri: uri },
|
|
buildpackSubPath: "workspaces/job-buildpack",
|
|
});
|
|
const pod = (job.spec as any).template.spec;
|
|
const creator = pod.containers[0];
|
|
expect(creator.args).toContain("--buildpacks");
|
|
expect(creator.args).toContain("/custom-buildpacks");
|
|
expect(creator.args).toContain("--order");
|
|
expect(creator.args).toContain("/package/order.toml");
|
|
expect(creator.volumeMounts).toContainEqual({
|
|
name: "workspace",
|
|
mountPath: "/package",
|
|
subPath: "workspaces/job-buildpack",
|
|
readOnly: true,
|
|
});
|
|
expect(creator.volumeMounts).toContainEqual({
|
|
name: "custom-buildpacks",
|
|
mountPath: "/custom-buildpacks",
|
|
readOnly: true,
|
|
});
|
|
expect(pod.initContainers[0].args[0]).toContain("/cnb/buildpacks/.");
|
|
expect(pod.initContainers[1].name).toBe("copy-source");
|
|
expect(
|
|
pod.initContainers[1].volumeMounts.some(
|
|
(mount: any) => mount.mountPath === "/package",
|
|
),
|
|
).toBe(false);
|
|
expect(() =>
|
|
createBuildpacksJob({
|
|
...options(),
|
|
spec: { ...options().spec, buildpackUri: uri },
|
|
}),
|
|
).toThrow(/staged/);
|
|
expect(() =>
|
|
createBuildpacksJob({
|
|
...options(),
|
|
spec: { ...options().spec, buildpackUri: uri },
|
|
buildpackSubPath: "../outside",
|
|
}),
|
|
).toThrow(/safe/);
|
|
});
|
|
const options = (architecture: "amd64" | "arm64" = "amd64") => ({
|
|
name: "build-cnb",
|
|
namespace: "builds",
|
|
spec: {
|
|
...spec(architecture),
|
|
builder: "buildpacks" as const,
|
|
dockerfile: undefined,
|
|
target: undefined,
|
|
buildArgs: [],
|
|
},
|
|
workspaceClaimName: "workspaces",
|
|
workspaceSubPath: "snapshot",
|
|
cacheImage: `registry.example.com/cache/demo-web-cnb-${architecture}`,
|
|
registrySecretName: "registry-auth",
|
|
nodeSelector: { pool: "builders", "kubernetes.io/arch": "wrong" },
|
|
});
|
|
|
|
test.each(["amd64", "arm64"] as const)(
|
|
"uses pinned lifecycle and isolated writable directories on %s",
|
|
(architecture) => {
|
|
const job = createBuildpacksJob(options(architecture));
|
|
const pod = (job.spec as any).template.spec;
|
|
const init = pod.initContainers[0];
|
|
const creator = pod.containers[0];
|
|
expect(creator.name).toBe("buildkit");
|
|
expect(init.image).toBe(DEFAULT_BUILDPACKS_IMAGE);
|
|
expect(creator.image).toBe(DEFAULT_BUILDPACKS_IMAGE);
|
|
expect(init.command).toEqual(["/bin/sh", "-ec"]);
|
|
expect(init.args).toEqual([
|
|
'cp -R "$1/." /cnb-app/ && chmod -R u+rwX /cnb-app',
|
|
"--",
|
|
"/workspace/apps/web",
|
|
]);
|
|
expect(init.volumeMounts[0]).toEqual({
|
|
name: "workspace",
|
|
mountPath: "/workspace",
|
|
readOnly: true,
|
|
subPath: "snapshot",
|
|
});
|
|
expect(creator.command).toEqual(["/cnb/lifecycle/creator"]);
|
|
expect(creator.args).toEqual([
|
|
"--app",
|
|
"/cnb-app",
|
|
"--layers",
|
|
"/layers",
|
|
"--platform",
|
|
"/platform",
|
|
"--cache-image",
|
|
`registry.example.com/cache/demo-web-cnb-${architecture}`,
|
|
"--uid",
|
|
"1000",
|
|
"--gid",
|
|
"1000",
|
|
"registry.example.com/kuber/demo-web:latest",
|
|
]);
|
|
expect(creator.volumeMounts).not.toContainEqual(
|
|
expect.objectContaining({ name: "workspace" }),
|
|
);
|
|
for (const name of ["app", "layers", "cache", "platform", "tmp"])
|
|
expect(pod.volumes).toContainEqual({ name, emptyDir: {} });
|
|
expect(pod.securityContext).toMatchObject({
|
|
runAsUser: 1000,
|
|
runAsGroup: 1000,
|
|
fsGroup: 1000,
|
|
seccompProfile: { type: "RuntimeDefault" },
|
|
});
|
|
expect(creator.securityContext.allowPrivilegeEscalation).toBe(false);
|
|
expect(pod.automountServiceAccountToken).toBe(false);
|
|
expect(pod.nodeSelector).toEqual({
|
|
pool: "builders",
|
|
"kubernetes.io/arch": architecture,
|
|
});
|
|
expect(pod.tolerations?.length ?? 0).toBe(
|
|
architecture === "amd64" ? 1 : 0,
|
|
);
|
|
expect(creator.env).toContainEqual({
|
|
name: "CNB_PLATFORM_API",
|
|
value: "0.15",
|
|
});
|
|
expect(creator.env).toContainEqual({
|
|
name: "DOCKER_CONFIG",
|
|
value: "/docker-config",
|
|
});
|
|
expect(creator.volumeMounts).toContainEqual({
|
|
name: "registry-auth",
|
|
mountPath: "/docker-config",
|
|
readOnly: true,
|
|
});
|
|
expect(pod.volumes).toContainEqual({
|
|
name: "registry-auth",
|
|
secret: {
|
|
secretName: "registry-auth",
|
|
items: [{ key: ".dockerconfigjson", path: "config.json" }],
|
|
},
|
|
});
|
|
expect(pod.imagePullSecrets).toEqual([{ name: "registry-auth" }]);
|
|
},
|
|
);
|
|
|
|
test("exports additional tags and scopes insecure registry flags", () => {
|
|
const job = createBuildpacksJob({
|
|
...options(),
|
|
cacheImage: "cache.local:5000/demo-cnb-amd64",
|
|
pushImage: "internal.local:5000/web:latest",
|
|
pushImages: [
|
|
"internal.local:5000/worker:latest",
|
|
"internal.local:5000/other:latest",
|
|
],
|
|
pushRegistryInsecure: true,
|
|
cacheRegistryInsecure: true,
|
|
});
|
|
expect((job.spec as any).template.spec.containers[0].args).toEqual([
|
|
"--app",
|
|
"/cnb-app",
|
|
"--layers",
|
|
"/layers",
|
|
"--platform",
|
|
"/platform",
|
|
"--cache-image",
|
|
"cache.local:5000/demo-cnb-amd64",
|
|
"--uid",
|
|
"1000",
|
|
"--gid",
|
|
"1000",
|
|
"--insecure-registry",
|
|
"internal.local:5000",
|
|
"--insecure-registry",
|
|
"cache.local:5000",
|
|
"--tag",
|
|
"internal.local:5000/worker:latest",
|
|
"--tag",
|
|
"internal.local:5000/other:latest",
|
|
"internal.local:5000/web:latest",
|
|
]);
|
|
});
|
|
|
|
test("rejects unpinned builders and incompatible options", () => {
|
|
expect(() =>
|
|
createBuildpacksJob({
|
|
...options(),
|
|
pushImages: ["another.example.com/worker:latest"],
|
|
}),
|
|
).toThrow("same registry");
|
|
expect(() =>
|
|
createBuildpacksJob({
|
|
...options(),
|
|
buildpacksImage: "heroku/builder:26",
|
|
}),
|
|
).toThrow("digest-pinned");
|
|
expect(() =>
|
|
createBuildpacksJob({
|
|
...options(),
|
|
buildpacksImage: "heroku/builder@sha256:deadbeef",
|
|
}),
|
|
).toThrow("digest-pinned");
|
|
expect(() =>
|
|
createBuildpacksJob({
|
|
...options(),
|
|
spec: { ...options().spec, dockerfile: "Dockerfile" },
|
|
}),
|
|
).toThrow("does not support");
|
|
expect(() =>
|
|
createBuildpacksJob({
|
|
...options(),
|
|
spec: { ...options().spec, context: "../escape" },
|
|
}),
|
|
).toThrow("safe workspace-relative");
|
|
});
|
|
});
|