4 changed files with 366 additions and 19 deletions
|
|
@ -46,6 +46,9 @@ const BindingsSchema = z
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
),
|
),
|
||||||
|
// Keyed by the repo the App clones for. Prefer the FULL `<org>/<repo>`
|
||||||
|
// slug; a bare `<repo>` key still resolves (see githubAppNameFor) so
|
||||||
|
// existing state files keep working.
|
||||||
github_apps: z.record(z.string()),
|
github_apps: z.record(z.string()),
|
||||||
smoke_target: z.string().optional(),
|
smoke_target: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
@ -53,6 +56,39 @@ const BindingsSchema = z
|
||||||
|
|
||||||
export type Bindings = z.infer<typeof BindingsSchema>;
|
export type Bindings = z.infer<typeof BindingsSchema>;
|
||||||
|
|
||||||
|
// Resolve the Coolify GitHub App name for a repo, full slug first.
|
||||||
|
//
|
||||||
|
// The short name alone is not a key: `<repo>` is only unique *within* an org,
|
||||||
|
// so `heavy-duty/incubator` and `acme/incubator` collapse onto one entry and
|
||||||
|
// whichever App is bound there gets used to clone BOTH — silently, because a
|
||||||
|
// wrong-but-existing App resolves to a real uuid and the create succeeds. The
|
||||||
|
// full slug is the thing that actually identifies a repo, so it wins.
|
||||||
|
//
|
||||||
|
// The bare-`<repo>` fallback is kept deliberately: it is what every state file
|
||||||
|
// written before this used, and dropping it would break them for no gain. A
|
||||||
|
// short key is unambiguous right up until a second org shows up, which is
|
||||||
|
// precisely when the full-slug key it falls back from starts winning instead.
|
||||||
|
export function githubAppNameFor(bindings: Bindings, orgRepo: string): string {
|
||||||
|
const repoShort = orgRepo.split("/")[1] ?? orgRepo;
|
||||||
|
const name = bindings.github_apps[orgRepo] ?? bindings.github_apps[repoShort];
|
||||||
|
if (!name) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`no GitHub App bound for ${orgRepo}`,
|
||||||
|
"",
|
||||||
|
` looked for: github_apps["${orgRepo}"], then github_apps["${repoShort}"]`,
|
||||||
|
` bound repos: ${Object.keys(bindings.github_apps).join(", ") || "(none)"}`,
|
||||||
|
"",
|
||||||
|
"Add it to environments.yaml, keyed by the full slug:",
|
||||||
|
"",
|
||||||
|
" github_apps:",
|
||||||
|
` ${orgRepo}: <the App's name in Coolify>`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadBindings(
|
export function loadBindings(
|
||||||
path: string,
|
path: string,
|
||||||
opts: { overrideText?: string } = {},
|
opts: { overrideText?: string } = {},
|
||||||
|
|
|
||||||
143
src/cli.ts
143
src/cli.ts
|
|
@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
import { parse as parseYaml } from "yaml";
|
import { parse as parseYaml } from "yaml";
|
||||||
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
|
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
|
||||||
import { loadBindings } from "./bindings.js";
|
import { githubAppNameFor, loadBindings } from "./bindings.js";
|
||||||
import { loadCoolifyEnv } from "./config.js";
|
import { loadCoolifyEnv } from "./config.js";
|
||||||
import { CoolifyClient, HttpError } from "./coolify.js";
|
import { CoolifyClient, HttpError } from "./coolify.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,8 +20,8 @@ import { serverAdd } from "./server.js";
|
||||||
import { smoke } from "./smoke.js";
|
import { smoke } from "./smoke.js";
|
||||||
import { assertTeam, formatTeam } from "./team.js";
|
import { assertTeam, formatTeam } from "./team.js";
|
||||||
|
|
||||||
const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--hostname-overlay <file>]
|
const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--hostname-overlay <file>]
|
||||||
cast diff <org>/<repo> --env <env> [--full]
|
cast diff <org>/<repo> --env <env> [--full] [--project <name>]
|
||||||
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
|
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
|
||||||
cast smoke --env <env>
|
cast smoke --env <env>
|
||||||
cast team [--env <env>]
|
cast team [--env <env>]
|
||||||
|
|
@ -32,7 +32,13 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--host
|
||||||
Coolify takes one, because every one of them first asserts
|
Coolify takes one, because every one of them first asserts
|
||||||
the token belongs to that environment's declared team.
|
the token belongs to that environment's declared team.
|
||||||
\`cast team\` alone (no --env) reports the token's team
|
\`cast team\` alone (no --env) reports the token's team
|
||||||
without needing a binding — use it to fill environments.yaml.`;
|
without needing a binding — use it to fill environments.yaml.
|
||||||
|
--project <name>
|
||||||
|
the Coolify project to act on, when it is not named after the
|
||||||
|
repo (the default). A project built by hand in the UI is called
|
||||||
|
whatever someone typed; \`diff\` refuses rather than reporting an
|
||||||
|
absent project as an empty one, and this is how you point it at
|
||||||
|
the real name.`;
|
||||||
|
|
||||||
// cast is stateless: every instance-scoped input is read from the state
|
// cast is stateless: every instance-scoped input is read from the state
|
||||||
// directory it is pointed at, never from a location the tool itself knows.
|
// directory it is pointed at, never from a location the tool itself knows.
|
||||||
|
|
@ -142,20 +148,53 @@ export function projectLiveFields(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLive(
|
// The live side of a diff/apply is either "here are the resources" or "the
|
||||||
|
// thing I was told to look at does not exist" — and those two must NOT collapse
|
||||||
|
// into the same value.
|
||||||
|
//
|
||||||
|
// They used to: both returned []. That is right for `apply` (a first apply
|
||||||
|
// legitimately creates the project and its environment) and quietly wrong for
|
||||||
|
// `diff`, because computeDiff(desired, []) means "every desired resource is
|
||||||
|
// missing" — rendered as a confident full-create plan. So a diff pointed at a
|
||||||
|
// project name that does not exist reports a CLEAN-LOOKING plan that verified
|
||||||
|
// nothing at all. Same shape of lie as the wrong-team token in team.ts: an
|
||||||
|
// unverifiable read that answers "absent" and invites a create.
|
||||||
|
//
|
||||||
|
// Keeping the distinction in the type is what lets each caller take its own
|
||||||
|
// (opposite, and both correct) position on absence.
|
||||||
|
export type LiveLookup =
|
||||||
|
| { found: true; live: Live[] }
|
||||||
|
| {
|
||||||
|
found: false;
|
||||||
|
missing: "project";
|
||||||
|
project: string;
|
||||||
|
available: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
found: false;
|
||||||
|
missing: "environment";
|
||||||
|
project: string;
|
||||||
|
environment: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchLive(
|
||||||
client: CoolifyClient,
|
client: CoolifyClient,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
envName: string,
|
envName: string,
|
||||||
): Promise<Live[]> {
|
): Promise<LiveLookup> {
|
||||||
// List resources inside <projectName>/<envName>; tolerate a missing
|
|
||||||
// project (first apply creates it) or a missing environment (first apply
|
|
||||||
// into a project that doesn't have this environment yet) by returning [].
|
|
||||||
const projects = (await client.get("/projects")) as Array<{
|
const projects = (await client.get("/projects")) as Array<{
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
}>;
|
}>;
|
||||||
const project = projects.find((p) => p.name === projectName);
|
const project = projects.find((p) => p.name === projectName);
|
||||||
if (!project) return [];
|
if (!project) {
|
||||||
|
return {
|
||||||
|
found: false,
|
||||||
|
missing: "project",
|
||||||
|
project: projectName,
|
||||||
|
available: projects.map((p) => p.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
// GET /projects/{uuid}/{environment_name_or_uuid} eager-loads exactly
|
// GET /projects/{uuid}/{environment_name_or_uuid} eager-loads exactly
|
||||||
// these relations (app/Http/Controllers/Api/ProjectController.php
|
// these relations (app/Http/Controllers/Api/ProjectController.php
|
||||||
// @environment_details, coollabsio/coolify v4.1.2): applications,
|
// @environment_details, coollabsio/coolify v4.1.2): applications,
|
||||||
|
|
@ -181,7 +220,14 @@ async function fetchLive(
|
||||||
redis?: Array<Record<string, unknown>>;
|
redis?: Array<Record<string, unknown>>;
|
||||||
services?: Array<Record<string, unknown>>;
|
services?: Array<Record<string, unknown>>;
|
||||||
} | null;
|
} | null;
|
||||||
if (!env) return [];
|
if (!env) {
|
||||||
|
return {
|
||||||
|
found: false,
|
||||||
|
missing: "environment",
|
||||||
|
project: projectName,
|
||||||
|
environment: envName,
|
||||||
|
};
|
||||||
|
}
|
||||||
const map = (
|
const map = (
|
||||||
kind: ResourceKind,
|
kind: ResourceKind,
|
||||||
items: Array<Record<string, unknown>> = [],
|
items: Array<Record<string, unknown>> = [],
|
||||||
|
|
@ -193,12 +239,56 @@ async function fetchLive(
|
||||||
fields: projectLiveFields(kind, i),
|
fields: projectLiveFields(kind, i),
|
||||||
env: undefined, // populated per-resource below only in full mode by caller
|
env: undefined, // populated per-resource below only in full mode by caller
|
||||||
}));
|
}));
|
||||||
return [
|
return {
|
||||||
|
found: true,
|
||||||
|
live: [
|
||||||
...map("application", env.applications),
|
...map("application", env.applications),
|
||||||
...map("database", env.postgresqls),
|
...map("database", env.postgresqls),
|
||||||
...map("database", env.redis),
|
...map("database", env.redis),
|
||||||
...map("service", env.services),
|
...map("service", env.services),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Why `diff` refuses instead of reporting an empty live side: see LiveLookup.
|
||||||
|
// The message has one job — make it impossible to read "absent" as "empty" —
|
||||||
|
// so it names what was looked for, where the name came from, and what actually
|
||||||
|
// exists next to it.
|
||||||
|
export function renderAbsentTarget(
|
||||||
|
lookup: Extract<LiveLookup, { found: false }>,
|
||||||
|
ctx: { orgRepo: string; overridden: boolean },
|
||||||
|
): string {
|
||||||
|
const origin = ctx.overridden
|
||||||
|
? "--project"
|
||||||
|
: `derived from the repo slug ${ctx.orgRepo}`;
|
||||||
|
const head =
|
||||||
|
lookup.missing === "project"
|
||||||
|
? [
|
||||||
|
`refusing to diff: no project named "${lookup.project}" exists in this team`,
|
||||||
|
"",
|
||||||
|
` looked for: project "${lookup.project}" (${origin})`,
|
||||||
|
` exists here: ${lookup.available.join(", ") || "(no projects at all)"}`,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
`refusing to diff: project "${lookup.project}" has no environment "${lookup.environment}"`,
|
||||||
|
"",
|
||||||
|
` looked for: environment "${lookup.environment}" in project "${lookup.project}"`,
|
||||||
|
" note: cast names environments after --env, so a project built by",
|
||||||
|
" hand in the Coolify UI may well use a different name for the",
|
||||||
|
" same tier (Coolify's own default is `production`).",
|
||||||
];
|
];
|
||||||
|
return [
|
||||||
|
...head,
|
||||||
|
"",
|
||||||
|
"An absent target reads back exactly like an empty one, so continuing would diff",
|
||||||
|
'it as "nothing exists — create everything": a clean-looking report that verified',
|
||||||
|
"nothing. `apply` may create a target; `diff` may only ever describe one that is",
|
||||||
|
"already there.",
|
||||||
|
"",
|
||||||
|
lookup.missing === "project"
|
||||||
|
? "Pass --project <name> if this instance names it differently."
|
||||||
|
: "Re-run with --env naming the environment as it exists here.",
|
||||||
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
|
|
@ -215,6 +305,7 @@ async function main(): Promise<number> {
|
||||||
env: { type: "string" },
|
env: { type: "string" },
|
||||||
path: { type: "string" },
|
path: { type: "string" },
|
||||||
state: { type: "string" },
|
state: { type: "string" },
|
||||||
|
project: { type: "string" },
|
||||||
"hostname-overlay": { type: "string" },
|
"hostname-overlay": { type: "string" },
|
||||||
full: { type: "boolean", default: false },
|
full: { type: "boolean", default: false },
|
||||||
},
|
},
|
||||||
|
|
@ -227,6 +318,12 @@ async function main(): Promise<number> {
|
||||||
}
|
}
|
||||||
const stateDir = stateDirFrom(values.state);
|
const stateDir = stateDirFrom(values.state);
|
||||||
const repoShort = orgRepo.split("/")[1];
|
const repoShort = orgRepo.split("/")[1];
|
||||||
|
// The Coolify project name and the secrets-file key are different things
|
||||||
|
// that happen to default to the same string. Only the former is a name
|
||||||
|
// some other system chose: a project built by hand in the UI is called
|
||||||
|
// whatever someone typed. --project overrides that one, and nothing else —
|
||||||
|
// secrets stay keyed by the repo (a state-repo convention we own).
|
||||||
|
const projectName = values.project ?? repoShort;
|
||||||
const checkout = resolveCheckout(orgRepo, {
|
const checkout = resolveCheckout(orgRepo, {
|
||||||
env: envName,
|
env: envName,
|
||||||
path: values.path,
|
path: values.path,
|
||||||
|
|
@ -264,7 +361,23 @@ async function main(): Promise<number> {
|
||||||
const team = await assertTeam(client, binding.team, envName);
|
const team = await assertTeam(client, binding.team, envName);
|
||||||
console.log(`team ${formatTeam(team)} ✓`);
|
console.log(`team ${formatTeam(team)} ✓`);
|
||||||
const mode = command === "apply" || values.full ? "full" : "structural";
|
const mode = command === "apply" || values.full ? "full" : "structural";
|
||||||
const live = await fetchLive(client, repoShort, envName);
|
const lookup = await fetchLive(client, projectName, envName);
|
||||||
|
// apply and diff take opposite (and both correct) positions on absence:
|
||||||
|
// apply is *allowed* to be the thing that brings a project into existence,
|
||||||
|
// so [] is a legitimate starting point. diff is only ever a claim about
|
||||||
|
// something that already exists — for it, absence is not an empty diff, it
|
||||||
|
// is the absence of anything to diff against, and reporting a full-create
|
||||||
|
// plan would launder that into a pass. See LiveLookup.
|
||||||
|
if (!lookup.found && command === "diff") {
|
||||||
|
console.error(
|
||||||
|
renderAbsentTarget(lookup, {
|
||||||
|
orgRepo,
|
||||||
|
overridden: values.project !== undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
const live = lookup.found ? lookup.live : [];
|
||||||
if (mode === "full") {
|
if (mode === "full") {
|
||||||
for (const l of live) {
|
for (const l of live) {
|
||||||
const envs = (await client
|
const envs = (await client
|
||||||
|
|
@ -296,10 +409,10 @@ async function main(): Promise<number> {
|
||||||
if (command === "diff") return report.clean ? 0 : 1;
|
if (command === "diff") return report.clean ? 0 : 1;
|
||||||
const serverUuid = await client.serverUuid(binding.server);
|
const serverUuid = await client.serverUuid(binding.server);
|
||||||
const githubAppUuid = await client.githubAppUuid(
|
const githubAppUuid = await client.githubAppUuid(
|
||||||
bindings.github_apps[repoShort],
|
githubAppNameFor(bindings, orgRepo),
|
||||||
);
|
);
|
||||||
const exec = buildExecutor(client, {
|
const exec = buildExecutor(client, {
|
||||||
projectName: repoShort,
|
projectName,
|
||||||
envName,
|
envName,
|
||||||
serverUuid,
|
serverUuid,
|
||||||
githubAppUuid,
|
githubAppUuid,
|
||||||
|
|
|
||||||
62
test/bindings.test.ts
Normal file
62
test/bindings.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { type Bindings, githubAppNameFor } from "../src/bindings.js";
|
||||||
|
|
||||||
|
function bindings(github_apps: Record<string, string>): Bindings {
|
||||||
|
return {
|
||||||
|
environments: {
|
||||||
|
prod: { server: "prod-box", team: { id: 0, name: "Root Team" } },
|
||||||
|
},
|
||||||
|
github_apps,
|
||||||
|
} as Bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("githubAppNameFor", () => {
|
||||||
|
it("resolves the full <org>/<repo> slug", () => {
|
||||||
|
const b = bindings({ "heavy-duty/incubator": "hdb-coolify" });
|
||||||
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every state file written before full-slug keying uses the bare repo name.
|
||||||
|
// Dropping that would break them for no gain, so it stays as a fallback.
|
||||||
|
it("still resolves a legacy bare <repo> key", () => {
|
||||||
|
const b = bindings({ incubator: "hdb-coolify" });
|
||||||
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole point of the issue. A short name is unique only *within* an org,
|
||||||
|
// so two orgs' same-named repos collapse onto one key — and the loser gets
|
||||||
|
// cloned by the winner's App, silently, because a wrong-but-existing App
|
||||||
|
// still resolves to a real uuid and the create succeeds.
|
||||||
|
it("keeps two orgs' same-named repos on separate Apps", () => {
|
||||||
|
const b = bindings({
|
||||||
|
"heavy-duty/incubator": "hdb-coolify",
|
||||||
|
"acme/incubator": "acme-coolify",
|
||||||
|
});
|
||||||
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
||||||
|
expect(githubAppNameFor(b, "acme/incubator")).toBe("acme-coolify");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Precedence matters in exactly the case that motivated the fix: a state file
|
||||||
|
// mid-migration carries both a legacy short key and a new full-slug one. The
|
||||||
|
// slug is the thing that actually identifies a repo, so it must win.
|
||||||
|
it("prefers the full slug over a colliding bare key", () => {
|
||||||
|
const b = bindings({
|
||||||
|
incubator: "legacy-app",
|
||||||
|
"heavy-duty/incubator": "hdb-coolify",
|
||||||
|
});
|
||||||
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an unbound repo, naming both keys it tried", () => {
|
||||||
|
const b = bindings({ "heavy-duty/other": "other-app" });
|
||||||
|
const err = githubAppNameFor.bind(
|
||||||
|
null,
|
||||||
|
b,
|
||||||
|
"heavy-duty/incubator",
|
||||||
|
) as () => string;
|
||||||
|
expect(err).toThrow(/no GitHub App bound for heavy-duty\/incubator/);
|
||||||
|
expect(err).toThrow(/github_apps\["heavy-duty\/incubator"\]/);
|
||||||
|
expect(err).toThrow(/github_apps\["incubator"\]/);
|
||||||
|
expect(err).toThrow(/heavy-duty\/other/);
|
||||||
|
});
|
||||||
|
});
|
||||||
136
test/live-lookup.test.ts
Normal file
136
test/live-lookup.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { fetchLive, renderAbsentTarget } from "../src/cli.js";
|
||||||
|
import { CoolifyClient } from "../src/coolify.js";
|
||||||
|
|
||||||
|
// A Coolify that answers GET /projects with `projects`, and
|
||||||
|
// GET /projects/{uuid}/{env} with whatever `envByProject` holds for it
|
||||||
|
// (undefined → 404, which is how Coolify says "no such environment").
|
||||||
|
function coolify(
|
||||||
|
projects: Array<{ uuid: string; name: string }>,
|
||||||
|
envByProject: Record<string, unknown> = {},
|
||||||
|
): CoolifyClient {
|
||||||
|
const fetchImpl = vi.fn(async (url: string | URL) => {
|
||||||
|
const path = new URL(String(url)).pathname.replace("/api/v1", "");
|
||||||
|
if (path === "/projects") {
|
||||||
|
return new Response(JSON.stringify(projects), { status: 200 });
|
||||||
|
}
|
||||||
|
const m = path.match(/^\/projects\/([^/]+)\/(.+)$/);
|
||||||
|
if (m) {
|
||||||
|
const body = envByProject[`${m[1]}/${m[2]}`];
|
||||||
|
return body === undefined
|
||||||
|
? new Response(JSON.stringify({ message: "Not found." }), {
|
||||||
|
status: 404,
|
||||||
|
})
|
||||||
|
: new Response(JSON.stringify(body), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response("{}", { status: 500 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
return new CoolifyClient("https://coolify.test", "tok", fetchImpl);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("fetchLive", () => {
|
||||||
|
it("returns the live resources when project and environment both exist", async () => {
|
||||||
|
const client = coolify([{ uuid: "p1", name: "incubator" }], {
|
||||||
|
"p1/prod": {
|
||||||
|
applications: [{ name: "core", uuid: "a1" }],
|
||||||
|
postgresqls: [{ name: "db", uuid: "d1" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await fetchLive(client, "incubator", "prod");
|
||||||
|
expect(r.found).toBe(true);
|
||||||
|
if (!r.found) throw new Error("unreachable");
|
||||||
|
expect(r.live.map((l) => l.name).sort()).toEqual(["core", "db"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bug this whole change exists for: an absent project used to come back
|
||||||
|
// as [], which computeDiff reads as "every desired resource is missing" and
|
||||||
|
// renders as a confident full-create plan. Absence must be its own answer,
|
||||||
|
// distinguishable from an empty-but-real environment.
|
||||||
|
it("reports an ABSENT project as absent, not as empty", async () => {
|
||||||
|
const client = coolify([
|
||||||
|
{ uuid: "p1", name: "incubator-prod" },
|
||||||
|
{ uuid: "p2", name: "umami" },
|
||||||
|
]);
|
||||||
|
const r = await fetchLive(client, "incubator", "prod");
|
||||||
|
expect(r).toEqual({
|
||||||
|
found: false,
|
||||||
|
missing: "project",
|
||||||
|
project: "incubator",
|
||||||
|
available: ["incubator-prod", "umami"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same lie, a different road: the project is real but the environment name
|
||||||
|
// is not. A hand-built project is very often `production`, not `prod`.
|
||||||
|
it("reports an ABSENT environment as absent, not as empty", async () => {
|
||||||
|
const client = coolify([{ uuid: "p1", name: "incubator" }], {
|
||||||
|
"p1/production": { applications: [] },
|
||||||
|
});
|
||||||
|
const r = await fetchLive(client, "incubator", "prod");
|
||||||
|
expect(r).toEqual({
|
||||||
|
found: false,
|
||||||
|
missing: "environment",
|
||||||
|
project: "incubator",
|
||||||
|
environment: "prod",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The distinction has to be real in BOTH directions, or the gate would just
|
||||||
|
// trade a false pass for a false alarm: a project whose environment exists
|
||||||
|
// and is genuinely empty is `found`, with zero resources.
|
||||||
|
it("distinguishes a real-but-empty environment from an absent one", async () => {
|
||||||
|
const client = coolify([{ uuid: "p1", name: "incubator" }], {
|
||||||
|
"p1/prod": { applications: [], postgresqls: [], services: [] },
|
||||||
|
});
|
||||||
|
const r = await fetchLive(client, "incubator", "prod");
|
||||||
|
expect(r).toEqual({ found: true, live: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderAbsentTarget", () => {
|
||||||
|
it("names what it looked for, where the name came from, and what exists", () => {
|
||||||
|
const msg = renderAbsentTarget(
|
||||||
|
{
|
||||||
|
found: false,
|
||||||
|
missing: "project",
|
||||||
|
project: "incubator",
|
||||||
|
available: ["incubator-prod", "umami"],
|
||||||
|
},
|
||||||
|
{ orgRepo: "heavy-duty/incubator", overridden: false },
|
||||||
|
);
|
||||||
|
expect(msg).toMatch(/no project named "incubator"/);
|
||||||
|
expect(msg).toMatch(/derived from the repo slug heavy-duty\/incubator/);
|
||||||
|
expect(msg).toMatch(/incubator-prod, umami/);
|
||||||
|
expect(msg).toMatch(/--project <name>/);
|
||||||
|
// The reader must not be able to walk away thinking a clean diff was a pass.
|
||||||
|
expect(msg).toMatch(/verified\s+nothing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the name came from --project when it was overridden", () => {
|
||||||
|
const msg = renderAbsentTarget(
|
||||||
|
{
|
||||||
|
found: false,
|
||||||
|
missing: "project",
|
||||||
|
project: "typo",
|
||||||
|
available: ["incubator"],
|
||||||
|
},
|
||||||
|
{ orgRepo: "heavy-duty/incubator", overridden: true },
|
||||||
|
);
|
||||||
|
expect(msg).toMatch(/\(--project\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("points at the UI-naming gotcha when the environment is what is missing", () => {
|
||||||
|
const msg = renderAbsentTarget(
|
||||||
|
{
|
||||||
|
found: false,
|
||||||
|
missing: "environment",
|
||||||
|
project: "incubator",
|
||||||
|
environment: "prod",
|
||||||
|
},
|
||||||
|
{ orgRepo: "heavy-duty/incubator", overridden: false },
|
||||||
|
);
|
||||||
|
expect(msg).toMatch(/has no environment "prod"/);
|
||||||
|
expect(msg).toMatch(/production/);
|
||||||
|
expect(msg).toMatch(/--env/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue