5 changed files with 563 additions and 43 deletions
|
|
@ -38,6 +38,15 @@ const BindingsSchema = z
|
|||
// it per environment keeps each one's expectation explicit and
|
||||
// survives a future split into per-environment tokens.
|
||||
team: TeamSchema,
|
||||
// The named Coolify instance this environment lives on
|
||||
// (<state>/.coolify/<name>.env). Optional: with no binding and no
|
||||
// --instance, cast reads <state>/.coolify.env exactly as it always
|
||||
// has. Binding it here is what lets `--env prod` select the right
|
||||
// control plane with no flag and no file edit — the connection
|
||||
// target stops being implicit in a file's current contents.
|
||||
// An explicit --instance still wins, so a one-off read against a
|
||||
// legacy box needs no change to this file either.
|
||||
instance: z.string().optional(),
|
||||
s3_destination: z.string().optional(),
|
||||
// Var-name patterns this environment refuses outright (see
|
||||
// assertEnvVarPolicy). Operator-owned guard: prod typically bans
|
||||
|
|
|
|||
98
src/cli.ts
98
src/cli.ts
|
|
@ -5,7 +5,12 @@ import { parseArgs } from "node:util";
|
|||
import { parse as parseYaml } from "yaml";
|
||||
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
|
||||
import { githubAppNameFor, loadBindings } from "./bindings.js";
|
||||
import { loadCoolifyEnv } from "./config.js";
|
||||
import {
|
||||
type CoolifyInstance,
|
||||
assertWritable,
|
||||
formatInstance,
|
||||
loadInstance,
|
||||
} from "./config.js";
|
||||
import { CoolifyClient, HttpError } from "./coolify.js";
|
||||
import {
|
||||
type Live,
|
||||
|
|
@ -33,6 +38,13 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--proj
|
|||
the token belongs to that environment's declared team.
|
||||
\`cast team\` alone (no --env) reports the token's team
|
||||
without needing a binding — use it to fill environments.yaml.
|
||||
--instance <name>
|
||||
the Coolify to talk to: <state>/.coolify/<name>.env, instead
|
||||
of <state>/.coolify.env. Bind one per environment in
|
||||
environments.yaml (\`instance: <name>\`) and --env selects it
|
||||
with no flag; an explicit --instance still wins. An instance
|
||||
may declare COOLIFY_READ_ONLY=true, and then no command that
|
||||
writes will run against it.
|
||||
--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
|
||||
|
|
@ -46,6 +58,28 @@ function stateDirFrom(flag: string | undefined): string {
|
|||
return flag ?? process.env.CAST_STATE ?? ".";
|
||||
}
|
||||
|
||||
// Resolve which Coolify to talk to, announce it, and open a client on it.
|
||||
//
|
||||
// Precedence: --instance > the environment's `instance:` binding > the
|
||||
// default .coolify.env. Every command that reaches a live Coolify goes through
|
||||
// here, so every one of them SAYS which Coolify it is about to touch, right
|
||||
// next to the team assert. The connection target used to be implicit in
|
||||
// .coolify.env's current contents — retargeting meant hand-editing a live
|
||||
// credential file and putting it back afterwards, and the failure mode of
|
||||
// getting it wrong is running `apply` against production.
|
||||
function openCoolify(
|
||||
stateDir: string,
|
||||
flag: string | undefined,
|
||||
binding?: { instance?: string },
|
||||
): { instance: CoolifyInstance; client: CoolifyClient } {
|
||||
const instance = loadInstance(stateDir, flag ?? binding?.instance);
|
||||
console.log(formatInstance(instance));
|
||||
return {
|
||||
instance,
|
||||
client: new CoolifyClient(instance.baseUrl, instance.token),
|
||||
};
|
||||
}
|
||||
|
||||
// Live Coolify objects use their own field vocabulary; computeDiff compares
|
||||
// by the DESIRED vocabulary, so each live resource must be projected onto it
|
||||
// or every run reports spurious drift (breaks idempotency, criterion 2).
|
||||
|
|
@ -306,6 +340,7 @@ async function main(): Promise<number> {
|
|||
path: { type: "string" },
|
||||
state: { type: "string" },
|
||||
project: { type: "string" },
|
||||
instance: { type: "string" },
|
||||
"hostname-overlay": { type: "string" },
|
||||
full: { type: "boolean", default: false },
|
||||
},
|
||||
|
|
@ -350,8 +385,12 @@ async function main(): Promise<number> {
|
|||
parseYaml(readFileSync(values["hostname-overlay"], "utf8")),
|
||||
);
|
||||
}
|
||||
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
|
||||
const client = new CoolifyClient(baseUrl, token);
|
||||
const { instance, client } = openCoolify(
|
||||
stateDir,
|
||||
values.instance,
|
||||
binding,
|
||||
);
|
||||
if (command === "apply") assertWritable(instance, "apply");
|
||||
// Fail-closed, before the first live read — not merely before the first
|
||||
// write. A wrong-team token makes fetchLive come back empty (the API
|
||||
// resolves what it cannot see to null), so an unasserted `diff` would
|
||||
|
|
@ -438,6 +477,7 @@ async function main(): Promise<number> {
|
|||
user: { type: "string" },
|
||||
port: { type: "string" },
|
||||
state: { type: "string" },
|
||||
instance: { type: "string" },
|
||||
},
|
||||
});
|
||||
// --env is required: a server is registered under the token's team and
|
||||
|
|
@ -457,8 +497,12 @@ async function main(): Promise<number> {
|
|||
console.error(`environment ${values.env} not in environments.yaml`);
|
||||
return 2;
|
||||
}
|
||||
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
|
||||
const client = new CoolifyClient(baseUrl, token);
|
||||
const { instance, client } = openCoolify(
|
||||
stateDir,
|
||||
values.instance,
|
||||
binding,
|
||||
);
|
||||
assertWritable(instance, "server add");
|
||||
const team = await assertTeam(client, binding.team, values.env);
|
||||
console.log(`team ${formatTeam(team)} ✓`);
|
||||
await serverAdd(client, {
|
||||
|
|
@ -474,7 +518,11 @@ async function main(): Promise<number> {
|
|||
const { values } = parseArgs({
|
||||
args: rest,
|
||||
allowPositionals: true,
|
||||
options: { state: { type: "string" }, env: { type: "string" } },
|
||||
options: {
|
||||
state: { type: "string" },
|
||||
env: { type: "string" },
|
||||
instance: { type: "string" },
|
||||
},
|
||||
});
|
||||
// smoke writes: it POSTs two env vars onto the live smoke_target app and
|
||||
// deletes them again. That is a mutation, so it takes the assert like any
|
||||
|
|
@ -485,14 +533,18 @@ async function main(): Promise<number> {
|
|||
return 2;
|
||||
}
|
||||
const stateDir = stateDirFrom(values.state);
|
||||
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
|
||||
const client = new CoolifyClient(baseUrl, token);
|
||||
const bindings = loadBindings(join(stateDir, "environments.yaml"));
|
||||
const binding = bindings.environments[values.env];
|
||||
if (!binding) {
|
||||
console.error(`environment ${values.env} not in environments.yaml`);
|
||||
return 2;
|
||||
}
|
||||
const { instance, client } = openCoolify(
|
||||
stateDir,
|
||||
values.instance,
|
||||
binding,
|
||||
);
|
||||
assertWritable(instance, "smoke");
|
||||
const team = await assertTeam(client, binding.team, values.env);
|
||||
console.log(`team ${formatTeam(team)} ✓`);
|
||||
if (!bindings.smoke_target) {
|
||||
|
|
@ -518,11 +570,27 @@ async function main(): Promise<number> {
|
|||
const { values } = parseArgs({
|
||||
args: rest,
|
||||
allowPositionals: true,
|
||||
options: { state: { type: "string" }, env: { type: "string" } },
|
||||
options: {
|
||||
state: { type: "string" },
|
||||
env: { type: "string" },
|
||||
instance: { type: "string" },
|
||||
},
|
||||
});
|
||||
const stateDir = stateDirFrom(values.state);
|
||||
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
|
||||
const client = new CoolifyClient(baseUrl, token);
|
||||
// Bindings first, but only when --env was given: an environment's
|
||||
// `instance:` binding is what selects the Coolify to ask. Without --env
|
||||
// there is no binding to read (and deliberately so — see below), so the
|
||||
// flag or the default file decides.
|
||||
const binding = values.env
|
||||
? loadBindings(join(stateDir, "environments.yaml")).environments[
|
||||
values.env
|
||||
]
|
||||
: undefined;
|
||||
if (values.env && !binding) {
|
||||
console.error(`environment ${values.env} not in environments.yaml`);
|
||||
return 2;
|
||||
}
|
||||
const { client } = openCoolify(stateDir, values.instance, binding);
|
||||
// Read-only, and the one command that deliberately does NOT require a
|
||||
// team binding: it is how you discover the values to write into
|
||||
// environments.yaml in the first place. Asserting here would be circular.
|
||||
|
|
@ -530,13 +598,7 @@ async function main(): Promise<number> {
|
|||
// "will apply refuse?" — ask the question without touching anything.
|
||||
const actual = await client.currentTeam();
|
||||
console.log(`token's team: ${formatTeam(actual)}`);
|
||||
if (!values.env) return 0;
|
||||
const binding = loadBindings(join(stateDir, "environments.yaml"))
|
||||
.environments[values.env];
|
||||
if (!binding) {
|
||||
console.error(`environment ${values.env} not in environments.yaml`);
|
||||
return 2;
|
||||
}
|
||||
if (!values.env || !binding) return 0;
|
||||
await assertTeam(client, binding.team, values.env);
|
||||
console.log(`matches the team ${values.env} expects ✓`);
|
||||
return 0;
|
||||
|
|
|
|||
117
src/config.ts
117
src/config.ts
|
|
@ -1,11 +1,28 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Coolify's base URL + API token, read from <state>/.coolify.env — the one
|
||||
// file in the state repo that is never committed (it is a live credential).
|
||||
export function loadCoolifyEnv(path: string): {
|
||||
// Which Coolify cast is about to talk to. The single most consequential input
|
||||
// to any command — so it is a value that gets resolved, named, and printed,
|
||||
// not an implicit property of whatever `.coolify.env` happens to contain right
|
||||
// now.
|
||||
export type CoolifyInstance = {
|
||||
// "default" for <state>/.coolify.env, else the --instance name.
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
} {
|
||||
// Declared by the instance, not inferred from the token: an instance
|
||||
// configured for inspection must not be writable even if its token would
|
||||
// permit the writes. See assertWritable.
|
||||
readOnly: boolean;
|
||||
// The file it came from, so every message can name it.
|
||||
file: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_INSTANCE = "default";
|
||||
const DEFAULT_FILE = ".coolify.env";
|
||||
const INSTANCE_DIR = ".coolify";
|
||||
|
||||
function parseEnvFile(path: string): Record<string, string> {
|
||||
const vars: Record<string, string> = {};
|
||||
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
||||
const line = raw.trim();
|
||||
|
|
@ -14,6 +31,17 @@ export function loadCoolifyEnv(path: string): {
|
|||
if (eq === -1) continue;
|
||||
vars[line.slice(0, eq)] = line.slice(eq + 1).replace(/^"|"$/g, "");
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
// Coolify's base URL + API token, read from <state>/.coolify.env — the one
|
||||
// file in the state repo that is never committed (it is a live credential).
|
||||
export function loadCoolifyEnv(path: string): {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
readOnly: boolean;
|
||||
} {
|
||||
const vars = parseEnvFile(path);
|
||||
const baseUrl = vars.COOLIFY_BASE_URL;
|
||||
const token = vars.COOLIFY_ACCESS_TOKEN;
|
||||
if (!baseUrl || !token) {
|
||||
|
|
@ -21,5 +49,82 @@ export function loadCoolifyEnv(path: string): {
|
|||
`${path}: COOLIFY_BASE_URL and COOLIFY_ACCESS_TOKEN are required`,
|
||||
);
|
||||
}
|
||||
return { baseUrl, token };
|
||||
return { baseUrl, token, readOnly: vars.COOLIFY_READ_ONLY === "true" };
|
||||
}
|
||||
|
||||
// The named instances configured in a state dir: <state>/.coolify/<name>.env.
|
||||
export function knownInstances(stateDir: string): string[] {
|
||||
const dir = join(stateDir, INSTANCE_DIR);
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".env"))
|
||||
.map((f) => f.slice(0, -".env".length))
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function instanceFile(stateDir: string, name?: string): string {
|
||||
return name === undefined
|
||||
? join(stateDir, DEFAULT_FILE)
|
||||
: join(stateDir, INSTANCE_DIR, `${name}.env`);
|
||||
}
|
||||
|
||||
// Refuse, don't guess — the same position `diff` takes on an absent target
|
||||
// (#12). An unknown instance name is not a reason to fall back to the default
|
||||
// one: "the instance I asked for isn't there, so I used a different one" is
|
||||
// how a diff meant for a legacy box gets run against production.
|
||||
export function loadInstance(stateDir: string, name?: string): CoolifyInstance {
|
||||
const file = instanceFile(stateDir, name);
|
||||
if (name !== undefined && !existsSync(file)) {
|
||||
const known = knownInstances(stateDir);
|
||||
throw new Error(
|
||||
[
|
||||
`no Coolify instance named "${name}"`,
|
||||
"",
|
||||
` looked for: ${file}`,
|
||||
` configured: ${known.join(", ") || "(none)"}`,
|
||||
"",
|
||||
"A named instance is an env file holding COOLIFY_BASE_URL +",
|
||||
"COOLIFY_ACCESS_TOKEN (and optionally COOLIFY_READ_ONLY=true). Create the",
|
||||
"file above, or pass one of the names that exist. With no --instance, cast",
|
||||
`reads ${join(stateDir, DEFAULT_FILE)}.`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
const { baseUrl, token, readOnly } = loadCoolifyEnv(file);
|
||||
return {
|
||||
name: name ?? DEFAULT_INSTANCE,
|
||||
baseUrl,
|
||||
token,
|
||||
readOnly,
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
// A read-only instance is one the operator declared for inspection. The token
|
||||
// it holds may well be able to write — that is exactly the point: this turns
|
||||
// "I pointed the wrong token at the wrong box" from a live incident into an
|
||||
// exit code, before the first mutating call rather than after it.
|
||||
export function assertWritable(instance: CoolifyInstance, verb: string): void {
|
||||
if (!instance.readOnly) return;
|
||||
throw new Error(
|
||||
[
|
||||
`refusing to ${verb}: Coolify instance "${instance.name}" is read-only`,
|
||||
"",
|
||||
` declared by: COOLIFY_READ_ONLY=true in ${instance.file}`,
|
||||
` base url: ${instance.baseUrl}`,
|
||||
"",
|
||||
`\`${verb}\` writes. An instance configured for inspection cannot be written`,
|
||||
"to, even if its token would permit it. Run `cast diff` or `cast team`",
|
||||
"against this instance, or pass an --instance that is not read-only.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// What cast prints before it touches a Coolify, on every command that reaches
|
||||
// one. The instance is the input most likely to be wrong and least likely to
|
||||
// be noticed — so it gets said out loud, next to the team assert.
|
||||
export function formatInstance(instance: CoolifyInstance): string {
|
||||
return `instance ${instance.name} → ${instance.baseUrl}${
|
||||
instance.readOnly ? " (read-only)" : ""
|
||||
}`;
|
||||
}
|
||||
|
|
|
|||
239
test/cli.test.ts
239
test/cli.test.ts
|
|
@ -1,22 +1,107 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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";
|
||||
|
||||
function runCli(args: string[]): { code: number; output: string } {
|
||||
try {
|
||||
const output = execFileSync("node", ["dist/cli.js", ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
// 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"],
|
||||
});
|
||||
return { code: 0, output };
|
||||
} catch (e) {
|
||||
const err = e as { status: number; stderr: string; stdout: string };
|
||||
return { code: err.status, output: `${err.stdout}${err.stderr}` };
|
||||
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}`] : []),
|
||||
"github_apps:",
|
||||
" incubator: hdb-coolify",
|
||||
"smoke_target: core",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("infra cli", () => {
|
||||
it("refuses apply --path with --env prod, exit non-zero", () => {
|
||||
const r = runCli([
|
||||
it("refuses apply --path with --env prod, exit non-zero", async () => {
|
||||
const r = await runCli([
|
||||
"apply",
|
||||
"acme/widget",
|
||||
"--env",
|
||||
|
|
@ -27,8 +112,8 @@ describe("infra cli", () => {
|
|||
expect(r.code).not.toBe(0);
|
||||
expect(r.output).toMatch(/--path.*prod/);
|
||||
});
|
||||
it("prints usage on unknown command", () => {
|
||||
const r = runCli(["frobnicate"]);
|
||||
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);
|
||||
});
|
||||
|
|
@ -36,8 +121,8 @@ describe("infra cli", () => {
|
|||
// 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", () => {
|
||||
const r = runCli([
|
||||
it("refuses server add without --env, exit non-zero", async () => {
|
||||
const r = await runCli([
|
||||
"server",
|
||||
"add",
|
||||
"prod-box",
|
||||
|
|
@ -49,9 +134,125 @@ describe("infra cli", () => {
|
|||
expect(r.code).not.toBe(0);
|
||||
expect(r.output).toMatch(/--env/);
|
||||
});
|
||||
it("refuses smoke without --env, exit non-zero", () => {
|
||||
const r = runCli(["smoke"]);
|
||||
it("refuses smoke without --env, exit non-zero", async () => {
|
||||
const r = await runCli(["smoke"]);
|
||||
expect(r.code).not.toBe(0);
|
||||
expect(r.output).toMatch(/--env/);
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
"--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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
143
test/config.test.ts
Normal file
143
test/config.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertWritable,
|
||||
formatInstance,
|
||||
knownInstances,
|
||||
loadInstance,
|
||||
} from "../src/config.js";
|
||||
|
||||
// A state dir with a default .coolify.env and any number of named instances.
|
||||
function stateDir(
|
||||
named: Record<string, string> = {},
|
||||
defaultEnv?: string,
|
||||
): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
if (defaultEnv !== undefined) {
|
||||
writeFileSync(join(dir, ".coolify.env"), defaultEnv);
|
||||
}
|
||||
if (Object.keys(named).length > 0) {
|
||||
mkdirSync(join(dir, ".coolify"));
|
||||
for (const [name, body] of Object.entries(named)) {
|
||||
writeFileSync(join(dir, ".coolify", `${name}.env`), body);
|
||||
}
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const OK =
|
||||
'COOLIFY_BASE_URL="https://cp.example.com"\nCOOLIFY_ACCESS_TOKEN="t"\n';
|
||||
|
||||
describe("loadInstance", () => {
|
||||
// The whole point of #14 is that adding this must change nothing for anyone
|
||||
// who does not use it.
|
||||
it("reads .coolify.env when no instance is named — unchanged behavior", () => {
|
||||
const dir = stateDir({}, OK);
|
||||
const inst = loadInstance(dir);
|
||||
expect(inst).toMatchObject({
|
||||
name: "default",
|
||||
baseUrl: "https://cp.example.com",
|
||||
token: "t",
|
||||
readOnly: false,
|
||||
file: join(dir, ".coolify.env"),
|
||||
});
|
||||
});
|
||||
|
||||
it("reads .coolify/<name>.env for a named instance", () => {
|
||||
const dir = stateDir(
|
||||
{
|
||||
legacy:
|
||||
'COOLIFY_BASE_URL="https://old.example.com"\nCOOLIFY_ACCESS_TOKEN="lt"\n',
|
||||
},
|
||||
OK,
|
||||
);
|
||||
expect(loadInstance(dir, "legacy")).toMatchObject({
|
||||
name: "legacy",
|
||||
baseUrl: "https://old.example.com",
|
||||
token: "lt",
|
||||
});
|
||||
});
|
||||
|
||||
// Refuse, don't guess (#12's position on an absent target, applied to the
|
||||
// connection target). Falling back to the default instance here is how a
|
||||
// diff meant for a legacy box gets run against production.
|
||||
it("refuses an unknown instance and names the ones that exist", () => {
|
||||
const dir = stateDir({ "prod-cp": OK, "staging-cp": OK }, OK);
|
||||
expect(() => loadInstance(dir, "legacy")).toThrow(
|
||||
/no Coolify instance named "legacy"/,
|
||||
);
|
||||
expect(() => loadInstance(dir, "legacy")).toThrow(/prod-cp, staging-cp/);
|
||||
});
|
||||
|
||||
it("says so plainly when no named instances exist at all", () => {
|
||||
const dir = stateDir({}, OK);
|
||||
expect(() => loadInstance(dir, "legacy")).toThrow(/\(none\)/);
|
||||
});
|
||||
|
||||
it("never silently falls back to the default instance", () => {
|
||||
const dir = stateDir({}, OK);
|
||||
expect(() => loadInstance(dir, "legacy")).toThrow();
|
||||
});
|
||||
|
||||
it("reads COOLIFY_READ_ONLY off an instance", () => {
|
||||
const dir = stateDir({ legacy: `${OK}COOLIFY_READ_ONLY=true\n` });
|
||||
expect(loadInstance(dir, "legacy").readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("still requires base url and token", () => {
|
||||
const dir = stateDir({ broken: 'COOLIFY_BASE_URL="https://x"\n' });
|
||||
expect(() => loadInstance(dir, "broken")).toThrow(
|
||||
/COOLIFY_BASE_URL and COOLIFY_ACCESS_TOKEN are required/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("knownInstances", () => {
|
||||
it("lists named instances, sorted, and is empty when there are none", () => {
|
||||
expect(knownInstances(stateDir({ b: OK, a: OK }, OK))).toEqual(["a", "b"]);
|
||||
expect(knownInstances(stateDir({}, OK))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertWritable", () => {
|
||||
const inst = (readOnly: boolean) => ({
|
||||
name: "legacy",
|
||||
baseUrl: "https://old.example.com",
|
||||
token: "t",
|
||||
readOnly,
|
||||
file: "/s/.coolify/legacy.env",
|
||||
});
|
||||
|
||||
// "I pointed the wrong token at the wrong box" becomes an exit code rather
|
||||
// than a live incident — and it holds even when the TOKEN would permit the
|
||||
// write. That is the point: the declaration is the guard, not the scope.
|
||||
it("refuses a write against a read-only instance, naming the declaration", () => {
|
||||
expect(() => assertWritable(inst(true), "apply")).toThrow(
|
||||
/refusing to apply.*read-only/s,
|
||||
);
|
||||
expect(() => assertWritable(inst(true), "apply")).toThrow(
|
||||
/COOLIFY_READ_ONLY=true in \/s\/\.coolify\/legacy\.env/,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows writes against a normal instance", () => {
|
||||
expect(() => assertWritable(inst(false), "apply")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInstance", () => {
|
||||
it("names the instance and its base url, and flags read-only", () => {
|
||||
const base = {
|
||||
name: "legacy",
|
||||
baseUrl: "https://old.example.com",
|
||||
token: "t",
|
||||
file: "f",
|
||||
};
|
||||
expect(formatInstance({ ...base, readOnly: false })).toBe(
|
||||
"instance legacy → https://old.example.com",
|
||||
);
|
||||
expect(formatInstance({ ...base, readOnly: true })).toMatch(/read-only/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue