384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
import { afterEach, expect, test } from "bun:test";
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { YAML } from "bun";
|
|
import {
|
|
addAppToCompose,
|
|
createCompose,
|
|
type AppOptions,
|
|
} from "../../lib/scaffold";
|
|
import { resolveAppInput } from "../../command/add";
|
|
import { readCompose } from "../../lib/yaml";
|
|
import type { ScaffoldQuestion } from "../../lib/scaffold-prompts";
|
|
import { listDockerfileTemplates } from "../../lib/scaffold-templates";
|
|
|
|
function snippetValues(
|
|
question: ScaffoldQuestion,
|
|
overrides: Record<string, string> = {},
|
|
) {
|
|
return {
|
|
values: {
|
|
...Object.fromEntries(
|
|
(question.fields ?? []).map((field) => [
|
|
field.name,
|
|
field.initial ?? "",
|
|
]),
|
|
),
|
|
...overrides,
|
|
},
|
|
result: "services:\n malicious:\n image: ignored-preview",
|
|
};
|
|
}
|
|
|
|
const roots: string[] = [];
|
|
afterEach(async () => {
|
|
for (const path of roots.splice(0))
|
|
await rm(path, { recursive: true, force: true });
|
|
});
|
|
async function root() {
|
|
const path = await mkdtemp(join(tmpdir(), "kuber-add-test-"));
|
|
roots.push(path);
|
|
return path;
|
|
}
|
|
|
|
test("add preserves compose metadata, existing services and comments while adding claims/resources", async () => {
|
|
const cwd = await root();
|
|
const original =
|
|
"name: shop\nx-owner: team\nservices:\n api:\n image: example/api:1 # keep this comment\nx-hook: untouched\n";
|
|
await writeFile(join(cwd, "compose.yml"), original);
|
|
await addAppToCompose(cwd, {
|
|
name: "worker",
|
|
path: ".",
|
|
source: { kind: "image", image: "example/worker:2" },
|
|
postgres: "user/db",
|
|
s3: "key/bucket",
|
|
cpu: "0.5",
|
|
memory: "512m",
|
|
replicas: 2,
|
|
});
|
|
const updated = await readFile(join(cwd, "compose.yml"), "utf8");
|
|
expect(updated).toContain("image: example/api:1 # keep this comment");
|
|
expect(updated).toContain("x-hook: untouched");
|
|
const compose = await readCompose(join(cwd, "compose.yml"));
|
|
expect(compose.services?.worker).toMatchObject({
|
|
image: "example/worker:2",
|
|
volumes: ["postgresql:user/db", "s3:key/bucket"],
|
|
deploy: {
|
|
replicas: 2,
|
|
resources: { limits: { cpus: "0.5", memory: "512m" } },
|
|
},
|
|
});
|
|
expect(compose.services?.api?.image).toBe("example/api:1");
|
|
await expect(
|
|
addAppToCompose(cwd, {
|
|
name: "api",
|
|
path: ".",
|
|
source: { kind: "image", image: "x" },
|
|
}),
|
|
).rejects.toThrow("already exists");
|
|
});
|
|
|
|
test("add inserts into an inline empty services mapping and preserves surrounding content", async () => {
|
|
const cwd = await root();
|
|
const original =
|
|
"# before marker\nname: demo\nservices: {}\n# after marker\nx-hook: untouched\n";
|
|
await writeFile(join(cwd, "compose.yml"), original);
|
|
|
|
await addAppToCompose(cwd, {
|
|
name: "web",
|
|
path: ".",
|
|
source: { kind: "image", image: "example/web:1" },
|
|
});
|
|
|
|
const updated = await readFile(join(cwd, "compose.yml"), "utf8");
|
|
expect(updated).toContain("# before marker\nname: demo\nservices:\n");
|
|
expect(updated).toContain("# after marker\nx-hook: untouched\n");
|
|
expect(
|
|
(await readCompose(join(cwd, "compose.yml"))).services?.web?.image,
|
|
).toBe("example/web:1");
|
|
});
|
|
|
|
test("unsupported inline services mappings fail without changing the compose file", async () => {
|
|
const cwd = await root();
|
|
const original =
|
|
"# before marker\nname: demo\nservices: {existing: {image: busybox}}\n# after marker\n";
|
|
await writeFile(join(cwd, "compose.yml"), original);
|
|
|
|
await expect(
|
|
addAppToCompose(cwd, {
|
|
name: "web",
|
|
path: ".",
|
|
source: { kind: "image", image: "example/web:1" },
|
|
}),
|
|
).rejects.toThrow("Cannot safely insert a service");
|
|
|
|
expect(await readFile(join(cwd, "compose.yml"), "utf8")).toBe(original);
|
|
});
|
|
|
|
test("template creates paired files exclusively; collision rolls back and preserves compose", async () => {
|
|
const cwd = await root();
|
|
await mkdir(join(cwd, "app"));
|
|
await createCompose(cwd, "sample", {
|
|
name: "starter",
|
|
path: ".",
|
|
source: { kind: "image", image: "nginx:stable" },
|
|
});
|
|
const original = await readFile(join(cwd, "compose.yml"), "utf8");
|
|
const app: AppOptions = {
|
|
name: "app",
|
|
path: "app",
|
|
source: { kind: "template", template: "bun-service" },
|
|
};
|
|
await writeFile(join(cwd, "app", ".dockerignore"), "custom\n");
|
|
await expect(addAppToCompose(cwd, app)).rejects.toThrow();
|
|
expect(await readFile(join(cwd, "compose.yml"), "utf8")).toBe(original);
|
|
expect(await Bun.file(join(cwd, "app", "Dockerfile")).exists()).toBe(false);
|
|
expect(await readFile(join(cwd, "app", ".dockerignore"), "utf8")).toBe(
|
|
"custom\n",
|
|
);
|
|
await rm(join(cwd, "app", ".dockerignore"));
|
|
await addAppToCompose(cwd, app);
|
|
expect(await Bun.file(join(cwd, "app", "Dockerfile")).exists()).toBe(true);
|
|
expect(await Bun.file(join(cwd, "app", ".dockerignore")).exists()).toBe(true);
|
|
});
|
|
|
|
test("auto selects subproject and emits Buildpacks mode with path", async () => {
|
|
const cwd = await root();
|
|
await mkdir(join(cwd, "web"));
|
|
await writeFile(join(cwd, "web", "package.json"), "{}");
|
|
const app = await resolveAppInput(cwd, { nonInteractive: true });
|
|
expect(app).toMatchObject({ path: "web", source: { kind: "auto" } });
|
|
await createCompose(cwd, "sample", app);
|
|
const compose = YAML.parse(
|
|
await readFile(join(cwd, "compose.yml"), "utf8"),
|
|
) as { services: Record<string, Record<string, unknown>> };
|
|
expect(compose.services[app.name]!.build).toBe("auto");
|
|
expect(compose.services[app.name]!["x-kuber-build-context"]).toBe("web");
|
|
expect(
|
|
(await readCompose(join(cwd, "compose.yml"))).services?.[app.name]?.build,
|
|
).toBe("auto");
|
|
expect(await Bun.file(join(cwd, "web", "Dockerfile")).exists()).toBe(false);
|
|
});
|
|
|
|
test("rejects path traversal and symlink outside the project", async () => {
|
|
const cwd = await root();
|
|
await createCompose(cwd, "sample", {
|
|
name: "main",
|
|
path: ".",
|
|
source: { kind: "image", image: "nginx" },
|
|
});
|
|
await expect(
|
|
addAppToCompose(cwd, {
|
|
name: "bad",
|
|
path: "../elsewhere",
|
|
source: { kind: "auto" },
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
test("interactive image flow selects source before path and omits blank fields from generated YAML", async () => {
|
|
const cwd = await root();
|
|
const questions: ScaffoldQuestion[] = [];
|
|
const app = await resolveAppInput(cwd, {}, async (question) => {
|
|
questions.push(question);
|
|
if (question.name === "source") {
|
|
expect(question.initial).toBe(0);
|
|
return "image";
|
|
}
|
|
if (question.name === "image") {
|
|
expect(question.initial).toBe("nginx:stable");
|
|
return "example/web:1";
|
|
}
|
|
if (question.name === "path") return ".";
|
|
expect(question.type).toBe("snippet");
|
|
expect(question.template).not.toContain("managedBy");
|
|
expect(question.template).toContain('image: "example/web:1"');
|
|
expect(question.fields?.some((field) => field.name === "image")).toBe(
|
|
false,
|
|
);
|
|
return snippetValues(question, {
|
|
name: "web",
|
|
postgres: "",
|
|
s3: "",
|
|
cpu: "",
|
|
memory: "",
|
|
});
|
|
});
|
|
expect(questions.map(({ type }) => type)).toEqual([
|
|
"select",
|
|
"input",
|
|
"autocomplete",
|
|
"snippet",
|
|
]);
|
|
await createCompose(cwd, "demo", app);
|
|
const compose = await readCompose(join(cwd, "compose.yml"));
|
|
expect(compose.services?.web).toEqual({
|
|
image: "example/web:1",
|
|
deploy: { replicas: 1 },
|
|
});
|
|
expect(compose.services?.malicious).toBeUndefined();
|
|
});
|
|
|
|
test("auto flow accepts CNB URI and valid custom directory while keeping the root context rule", async () => {
|
|
const cwd = await root();
|
|
await mkdir(join(cwd, "web"));
|
|
await mkdir(join(cwd, "custom"));
|
|
await writeFile(join(cwd, "web", "package.json"), "{}");
|
|
const names: string[] = [];
|
|
const app = await resolveAppInput(cwd, {}, async (question) => {
|
|
names.push(question.name);
|
|
if (question.name === "source") {
|
|
expect(question.initial).toBe(1);
|
|
return "auto";
|
|
}
|
|
if (question.name === "cnb") {
|
|
expect(question.validate?.("https://example.com/buildpack.tgz")).not.toBe(
|
|
true,
|
|
);
|
|
expect(question.validate?.("")).toBe(true);
|
|
return "https://github.com/example/buildpack/releases/download/v1/web.cnb";
|
|
}
|
|
if (question.name === "path") {
|
|
expect(question.choices?.map(({ name }) => name)).toEqual(["web", "."]);
|
|
expect(await question.validate?.("../outside")).not.toBe(true);
|
|
expect(await question.validate?.("custom")).toBe(true);
|
|
expect(
|
|
await question.suggest?.("custom", question.choices!),
|
|
).toContainEqual({ name: "custom", message: "custom" });
|
|
return "custom";
|
|
}
|
|
return snippetValues(question, { name: "custom" });
|
|
});
|
|
expect(names).toEqual(["source", "cnb", "path", "compose"]);
|
|
await createCompose(cwd, "demo", app);
|
|
expect(
|
|
(await readCompose(join(cwd, "compose.yml"))).services?.custom,
|
|
).toMatchObject({
|
|
build:
|
|
"auto:https://github.com/example/buildpack/releases/download/v1/web.cnb",
|
|
"x-kuber-build-context": "custom",
|
|
});
|
|
const rootApp = await resolveAppInput(cwd, {
|
|
source: "auto",
|
|
path: ".",
|
|
name: "root",
|
|
nonInteractive: true,
|
|
cnb: "https://github.com/example/buildpack/releases/download/v1/root.cnb",
|
|
});
|
|
await addAppToCompose(cwd, rootApp);
|
|
expect((await readCompose(join(cwd, "compose.yml"))).services?.root).toEqual({
|
|
build:
|
|
"auto:https://github.com/example/buildpack/releases/download/v1/root.cnb",
|
|
});
|
|
});
|
|
|
|
test("template branch exposes all eight paired templates and locks explicitly flagged fields", async () => {
|
|
const cwd = await root();
|
|
const names: string[] = [];
|
|
const app = await resolveAppInput(
|
|
cwd,
|
|
{ path: ".", name: "fixed", cpu: "0.5" },
|
|
async (question) => {
|
|
names.push(question.name);
|
|
if (question.name === "source") return "template";
|
|
if (question.name === "template") {
|
|
expect(question.choices?.map(({ name }) => name)).toEqual(
|
|
listDockerfileTemplates().map(({ id }) => id),
|
|
);
|
|
expect(question.choices).toHaveLength(8);
|
|
return "bun-service";
|
|
}
|
|
expect(
|
|
question.fields?.some(({ name }) => name === "name" || name === "cpu"),
|
|
).toBe(false);
|
|
return snippetValues(question, {
|
|
name: "ignored",
|
|
cpu: "99",
|
|
postgres: "user/db",
|
|
s3: "key/bucket",
|
|
memory: "512m",
|
|
replicas: "2",
|
|
});
|
|
},
|
|
);
|
|
expect(names).toEqual(["source", "template", "compose"]);
|
|
expect(app).toMatchObject({
|
|
name: "fixed",
|
|
cpu: "0.5",
|
|
source: { kind: "template", template: "bun-service" },
|
|
postgres: "user/db",
|
|
s3: "key/bucket",
|
|
memory: "512m",
|
|
replicas: 2,
|
|
});
|
|
});
|
|
|
|
test("add preview contains literal existing names; collision is corrected in Snippet and existing content is preserved", async () => {
|
|
const cwd = await root();
|
|
const original =
|
|
"name: demo\nservices:\n existing:\n image: private:1 # retain\n app:\n image: private:2\nx-owner: same\n";
|
|
const file = join(cwd, "compose.yml");
|
|
await writeFile(file, original);
|
|
const app = await resolveAppInput(
|
|
cwd,
|
|
{ source: "image", image: "nginx", path: "." },
|
|
async (question) => {
|
|
expect(question.type).toBe("snippet");
|
|
expect(question.template).toContain(
|
|
"services:\n existing: ...\n app: ...\n ${name}:",
|
|
);
|
|
expect(question.template).not.toContain("private:1");
|
|
const name = question.fields?.find(({ name }) => name === "name");
|
|
expect(name?.validate?.("app")).toBe("Service app already exists");
|
|
expect(name?.validate?.("worker")).toBe(true);
|
|
expect(
|
|
question.fields
|
|
?.find(({ name }) => name === "replicas")
|
|
?.validate?.("0"),
|
|
).toContain("positive integer");
|
|
return snippetValues(question, { name: "worker" });
|
|
},
|
|
);
|
|
await addAppToCompose(cwd, app);
|
|
const updated = await readFile(file, "utf8");
|
|
expect(updated).toContain(
|
|
" existing:\n image: private:1 # retain\n app:\n image: private:2\n",
|
|
);
|
|
expect(updated).not.toContain("...");
|
|
expect((await readCompose(file)).services?.worker?.image).toBe("nginx");
|
|
await expect(
|
|
resolveAppInput(
|
|
cwd,
|
|
{ source: "image", image: "nginx", path: "." },
|
|
async (question) => snippetValues(question, { name: "app" }),
|
|
),
|
|
).rejects.toThrow("already exists");
|
|
expect(await readFile(file, "utf8")).toBe(updated);
|
|
});
|
|
|
|
test("cancelled add and a service appearing after the preview do not overwrite Compose", async () => {
|
|
const cwd = await root();
|
|
const file = await createCompose(cwd, "demo", {
|
|
name: "original",
|
|
path: ".",
|
|
source: { kind: "image", image: "nginx" },
|
|
});
|
|
const original = await readFile(file, "utf8");
|
|
await expect(
|
|
resolveAppInput(cwd, {}, async () => {
|
|
throw new Error("cancelled");
|
|
}),
|
|
).rejects.toThrow("cancelled");
|
|
expect(await readFile(file, "utf8")).toBe(original);
|
|
const app = await resolveAppInput(
|
|
cwd,
|
|
{ source: "image", image: "nginx", path: "." },
|
|
async (question) => snippetValues(question, { name: "worker" }),
|
|
);
|
|
await addAppToCompose(cwd, app);
|
|
const updated = await readFile(file, "utf8");
|
|
await expect(addAppToCompose(cwd, app)).rejects.toThrow("already exists");
|
|
expect(await readFile(file, "utf8")).toBe(updated);
|
|
});
|