`smoke` found the application it WRITES to by name against GET /applications — every app the token can see, across every project and every environment on the instance — and took the first name match. So `smoke_target: core` did not name an application; it named whichever `core` Coolify happened to list first. One instance carrying prod and staging is enough for `cast smoke --env staging` to POST its canary vars onto prod's `core`, and on the failure path leave them there. It now resolves the target through fetchLive(project, environment) — the same lookup every read-side verb makes — and takes the coordinates that lookup needs: --project and --environment, with diff/capture/inventory's semantics and defaults. An application that is not in that project + environment is not an empty result, it is the absence of anything to write to, so smoke refuses: naming what it looked for, where the name came from, and what is actually there (including when the name belongs to a service or a database, which would 404 on the /envs endpoint smoke writes to). The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped `smoke_target` is dropped: it named an app from a key with no project to scope to, so it could not be fixed, only carried. It is still declared in the schema — refused with a migration message rather than a strict-mode "unrecognized key", because loadBindings runs for every verb and an unmigrated state file must not take `diff` and `apply` down with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
271 lines
9.6 KiB
TypeScript
271 lines
9.6 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
import { createServer } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
// Spawned ASYNCHRONOUSLY, and that is load-bearing: the stub Coolify below
|
|
// runs in THIS process, so a blocking execFileSync would hold the event loop
|
|
// and the stub could never answer the CLI it just launched — the two would
|
|
// deadlock until the test timed out.
|
|
function runCli(args: string[]): Promise<{ code: number; output: string }> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn("node", ["dist/cli.js", ...args], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let output = "";
|
|
child.stdout.on("data", (d) => {
|
|
output += String(d);
|
|
});
|
|
child.stderr.on("data", (d) => {
|
|
output += String(d);
|
|
});
|
|
child.on("close", (code) => resolve({ code: code ?? 0, output }));
|
|
});
|
|
}
|
|
|
|
// A Coolify that answers the two calls these paths make, and RECORDS what it
|
|
// was asked. The recording is the point: "which instance did cast actually
|
|
// talk to" is the question #14 exists to make answerable, so the tests below
|
|
// answer it from the wire rather than from cast's own console output.
|
|
type Stub = { url: string; hits: string[]; close: () => Promise<void> };
|
|
const stubs: Stub[] = [];
|
|
|
|
async function stubCoolify(): Promise<Stub> {
|
|
const hits: string[] = [];
|
|
const server = createServer((req, res) => {
|
|
hits.push(req.url ?? "");
|
|
const body =
|
|
req.url === "/api/v1/teams/current"
|
|
? JSON.stringify({ id: 0, name: "Root Team" })
|
|
: "[]";
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(body);
|
|
});
|
|
await new Promise<void>((r) => {
|
|
server.listen(0, "127.0.0.1", r);
|
|
});
|
|
const stub: Stub = {
|
|
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
|
hits,
|
|
close: () =>
|
|
new Promise<void>((r) => {
|
|
server.close(() => r());
|
|
}),
|
|
};
|
|
stubs.push(stub);
|
|
return stub;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(stubs.splice(0).map((s) => s.close()));
|
|
});
|
|
|
|
const coolifyEnv = (url: string, readOnly = false) =>
|
|
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n${
|
|
readOnly ? "COOLIFY_READ_ONLY=true\n" : ""
|
|
}`;
|
|
|
|
// A state dir: the default instance, plus any named ones, plus bindings.
|
|
function stateWith(opts: {
|
|
default?: string;
|
|
named?: Record<string, string>;
|
|
boundInstance?: string;
|
|
}): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "cast-cli-"));
|
|
if (opts.default) writeFileSync(join(dir, ".coolify.env"), opts.default);
|
|
if (opts.named) {
|
|
mkdirSync(join(dir, ".coolify"));
|
|
for (const [name, body] of Object.entries(opts.named)) {
|
|
writeFileSync(join(dir, ".coolify", `${name}.env`), body);
|
|
}
|
|
}
|
|
writeFileSync(
|
|
join(dir, "environments.yaml"),
|
|
[
|
|
"environments:",
|
|
" prod:",
|
|
" server: prod-box",
|
|
" team: { id: 0, name: Root Team }",
|
|
...(opts.boundInstance ? [` instance: ${opts.boundInstance}`] : []),
|
|
" projects:",
|
|
" heavy-duty/incubator:",
|
|
" smoke_target: core",
|
|
"github_apps:",
|
|
" incubator: hdb-coolify",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
return dir;
|
|
}
|
|
|
|
describe("infra cli", () => {
|
|
it("refuses apply --path with --env prod, exit non-zero", async () => {
|
|
const r = await runCli([
|
|
"apply",
|
|
"acme/widget",
|
|
"--env",
|
|
"prod",
|
|
"--path",
|
|
"/tmp/x",
|
|
]);
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/--path.*prod/);
|
|
});
|
|
it("prints usage on unknown command", async () => {
|
|
const r = await runCli(["frobnicate"]);
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/usage: cast (apply|diff)/i);
|
|
});
|
|
// Both of these reach a live Coolify and mutate — `server add` registers a
|
|
// server into the token's team (permanently: a server belongs to exactly one
|
|
// team), and `smoke` writes env vars onto a live app. Neither may run without
|
|
// an environment to assert the token's team against.
|
|
it("refuses server add without --env, exit non-zero", async () => {
|
|
const r = await runCli([
|
|
"server",
|
|
"add",
|
|
"prod-box",
|
|
"--ip",
|
|
"10.0.0.1",
|
|
"--key",
|
|
"/tmp/k",
|
|
]);
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/--env/);
|
|
});
|
|
it("refuses smoke without --env, exit non-zero", async () => {
|
|
const r = await runCli(["smoke", "heavy-duty/incubator"]);
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/--env/);
|
|
});
|
|
// The repo is the PROJECT, and the project is half of the only scope in which
|
|
// `smoke_target: core` names anything (#29). `cast smoke --env prod` used to
|
|
// run — resolving the name against every application on the instance — which
|
|
// is precisely the invocation that could write prod's `core` while smoking
|
|
// staging. It is now a usage error, before any state is even read.
|
|
it("refuses smoke without the <org>/<repo> positional, exit non-zero", async () => {
|
|
const r = await runCli(["smoke", "--env", "prod"]);
|
|
expect(r.code).toBe(2);
|
|
expect(r.output).toMatch(/cast smoke\s+<org>\/<repo>/);
|
|
});
|
|
});
|
|
|
|
describe("--instance (multiple Coolify instances)", () => {
|
|
// The acceptance criterion from #14, end to end: a command against a named
|
|
// instance reaches THAT Coolify, with no edit to .coolify.env — which still
|
|
// sits there, untouched, pointing somewhere else entirely.
|
|
it("talks to the named instance, leaving .coolify.env untouched", async () => {
|
|
const [main, legacy] = [await stubCoolify(), await stubCoolify()];
|
|
const dir = stateWith({
|
|
default: coolifyEnv(main.url),
|
|
named: { legacy: coolifyEnv(legacy.url) },
|
|
});
|
|
const r = await runCli(["team", "--state", dir, "--instance", "legacy"]);
|
|
expect(r.code).toBe(0);
|
|
expect(r.output).toContain(`instance legacy → ${legacy.url}`);
|
|
// The wire is the witness, not the log line.
|
|
expect(legacy.hits).toContain("/api/v1/teams/current");
|
|
expect(main.hits).toEqual([]);
|
|
});
|
|
|
|
it("uses .coolify.env when no instance is named — unchanged behavior", async () => {
|
|
const [main, legacy] = [await stubCoolify(), await stubCoolify()];
|
|
const dir = stateWith({
|
|
default: coolifyEnv(main.url),
|
|
named: { legacy: coolifyEnv(legacy.url) },
|
|
});
|
|
const r = await runCli(["team", "--state", dir]);
|
|
expect(r.code).toBe(0);
|
|
expect(r.output).toContain(`instance default → ${main.url}`);
|
|
expect(main.hits).toContain("/api/v1/teams/current");
|
|
expect(legacy.hits).toEqual([]);
|
|
});
|
|
|
|
// environments.yaml binds the instance, so --env prod selects the right
|
|
// control plane with no flag and no file edit at all.
|
|
it("honors an environment's instance binding with no flag", async () => {
|
|
const [main, prodCp] = [await stubCoolify(), await stubCoolify()];
|
|
const dir = stateWith({
|
|
default: coolifyEnv(main.url),
|
|
named: { "prod-cp": coolifyEnv(prodCp.url) },
|
|
boundInstance: "prod-cp",
|
|
});
|
|
const r = await runCli(["team", "--state", dir, "--env", "prod"]);
|
|
expect(r.code).toBe(0);
|
|
expect(r.output).toContain(`instance prod-cp → ${prodCp.url}`);
|
|
expect(prodCp.hits).toContain("/api/v1/teams/current");
|
|
expect(main.hits).toEqual([]);
|
|
});
|
|
|
|
it("lets an explicit --instance beat the environment's binding", async () => {
|
|
const [prodCp, legacy] = [await stubCoolify(), await stubCoolify()];
|
|
const dir = stateWith({
|
|
named: {
|
|
"prod-cp": coolifyEnv(prodCp.url),
|
|
legacy: coolifyEnv(legacy.url),
|
|
},
|
|
boundInstance: "prod-cp",
|
|
});
|
|
const r = await runCli([
|
|
"team",
|
|
"--state",
|
|
dir,
|
|
"--env",
|
|
"prod",
|
|
"--instance",
|
|
"legacy",
|
|
]);
|
|
expect(r.code).toBe(0);
|
|
expect(legacy.hits).toContain("/api/v1/teams/current");
|
|
expect(prodCp.hits).toEqual([]);
|
|
});
|
|
|
|
it("refuses an unknown instance, names the known ones, and touches nothing", async () => {
|
|
const main = await stubCoolify();
|
|
const dir = stateWith({
|
|
default: coolifyEnv(main.url),
|
|
named: { legacy: coolifyEnv(main.url) },
|
|
});
|
|
const r = await runCli(["team", "--state", dir, "--instance", "typo"]);
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/no Coolify instance named "typo"/);
|
|
expect(r.output).toMatch(/configured:\s+legacy/);
|
|
// Refuse, don't guess: it must not have fallen back to the default.
|
|
expect(main.hits).toEqual([]);
|
|
});
|
|
|
|
// The nice-to-have that turns "I pointed the wrong token at the wrong box"
|
|
// into an exit code: the refusal lands BEFORE the first call, so a read-only
|
|
// instance never even gets asked.
|
|
it("refuses a writing verb against a read-only instance, before any call", async () => {
|
|
const legacy = await stubCoolify();
|
|
const dir = stateWith({
|
|
named: { legacy: coolifyEnv(legacy.url, true) },
|
|
});
|
|
const r = await runCli([
|
|
"smoke",
|
|
"heavy-duty/incubator",
|
|
"--state",
|
|
dir,
|
|
"--env",
|
|
"prod",
|
|
"--instance",
|
|
"legacy",
|
|
]);
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/refusing to smoke.*read-only/s);
|
|
expect(legacy.hits).toEqual([]);
|
|
});
|
|
|
|
it("still allows a read-only instance to be read", async () => {
|
|
const legacy = await stubCoolify();
|
|
const dir = stateWith({ named: { legacy: coolifyEnv(legacy.url, true) } });
|
|
const r = await runCli(["team", "--state", dir, "--instance", "legacy"]);
|
|
expect(r.code).toBe(0);
|
|
expect(r.output).toMatch(/read-only/);
|
|
expect(legacy.hits).toContain("/api/v1/teams/current");
|
|
});
|
|
});
|