feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from <state>/.coolify.env, with no flag or env override: the connection target was implicit in a file's current contents. Retargeting cast meant hand-editing a live credential file — and putting it back afterwards. The failure mode of getting that wrong is running `apply` against production. That is not hypothetical during the prod migration (incubator D-193): the state repo's .coolify.env holds a write+deploy token for the NEW control plane, while the verification gate needs a --full diff against the legacy, hand-built box still serving live users. - Named instances: <state>/.coolify/<name>.env, each with its own base URL and token. --instance <name> on every verb that reaches Coolify. - environments.yaml may bind one per environment (`instance: prod-cp`), so --env selects the right control plane with no flag and no file edit at all. An explicit --instance still wins, so a one-off read against a legacy box needs no change to that file either. - Refuse, don't guess, on an unknown --instance — naming the instances that do exist, in the same spirit as the absent-target refusal (#12/D-237). Falling back to the default here is exactly how a diff meant for a legacy box gets run against production. - An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add then refuse it before their first call, even though the token itself would permit the writes. "I pointed the wrong token at the wrong box" becomes an exit code rather than a live incident. - Every command that reaches a Coolify now SAYS which one, next to the team assert. It is the most consequential input and the least visible one. With no --instance and no binding, behavior is byte-for-byte what it was. The CLI tests spawn cast against stub Coolifys that record what they were asked, so "which instance did it actually talk to" is answered from the wire rather than from cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
79834369b1
commit
e457261436
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
|
// it per environment keeps each one's expectation explicit and
|
||||||
// survives a future split into per-environment tokens.
|
// survives a future split into per-environment tokens.
|
||||||
team: TeamSchema,
|
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(),
|
s3_destination: z.string().optional(),
|
||||||
// Var-name patterns this environment refuses outright (see
|
// Var-name patterns this environment refuses outright (see
|
||||||
// assertEnvVarPolicy). Operator-owned guard: prod typically bans
|
// 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 { parse as parseYaml } from "yaml";
|
||||||
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
|
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
|
||||||
import { githubAppNameFor, loadBindings } from "./bindings.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 { CoolifyClient, HttpError } from "./coolify.js";
|
||||||
import {
|
import {
|
||||||
type Live,
|
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.
|
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.
|
||||||
|
--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>
|
--project <name>
|
||||||
the Coolify project to act on, when it is not named after the
|
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
|
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 ?? ".";
|
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
|
// Live Coolify objects use their own field vocabulary; computeDiff compares
|
||||||
// by the DESIRED vocabulary, so each live resource must be projected onto it
|
// by the DESIRED vocabulary, so each live resource must be projected onto it
|
||||||
// or every run reports spurious drift (breaks idempotency, criterion 2).
|
// or every run reports spurious drift (breaks idempotency, criterion 2).
|
||||||
|
|
@ -306,6 +340,7 @@ async function main(): Promise<number> {
|
||||||
path: { type: "string" },
|
path: { type: "string" },
|
||||||
state: { type: "string" },
|
state: { type: "string" },
|
||||||
project: { type: "string" },
|
project: { type: "string" },
|
||||||
|
instance: { type: "string" },
|
||||||
"hostname-overlay": { type: "string" },
|
"hostname-overlay": { type: "string" },
|
||||||
full: { type: "boolean", default: false },
|
full: { type: "boolean", default: false },
|
||||||
},
|
},
|
||||||
|
|
@ -350,8 +385,12 @@ async function main(): Promise<number> {
|
||||||
parseYaml(readFileSync(values["hostname-overlay"], "utf8")),
|
parseYaml(readFileSync(values["hostname-overlay"], "utf8")),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
|
const { instance, client } = openCoolify(
|
||||||
const client = new CoolifyClient(baseUrl, token);
|
stateDir,
|
||||||
|
values.instance,
|
||||||
|
binding,
|
||||||
|
);
|
||||||
|
if (command === "apply") assertWritable(instance, "apply");
|
||||||
// Fail-closed, before the first live read — not merely before the first
|
// Fail-closed, before the first live read — not merely before the first
|
||||||
// write. A wrong-team token makes fetchLive come back empty (the API
|
// write. A wrong-team token makes fetchLive come back empty (the API
|
||||||
// resolves what it cannot see to null), so an unasserted `diff` would
|
// resolves what it cannot see to null), so an unasserted `diff` would
|
||||||
|
|
@ -438,6 +477,7 @@ async function main(): Promise<number> {
|
||||||
user: { type: "string" },
|
user: { type: "string" },
|
||||||
port: { type: "string" },
|
port: { type: "string" },
|
||||||
state: { type: "string" },
|
state: { type: "string" },
|
||||||
|
instance: { type: "string" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// --env is required: a server is registered under the token's team and
|
// --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`);
|
console.error(`environment ${values.env} not in environments.yaml`);
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
|
const { instance, client } = openCoolify(
|
||||||
const client = new CoolifyClient(baseUrl, token);
|
stateDir,
|
||||||
|
values.instance,
|
||||||
|
binding,
|
||||||
|
);
|
||||||
|
assertWritable(instance, "server add");
|
||||||
const team = await assertTeam(client, binding.team, values.env);
|
const team = await assertTeam(client, binding.team, values.env);
|
||||||
console.log(`team ${formatTeam(team)} ✓`);
|
console.log(`team ${formatTeam(team)} ✓`);
|
||||||
await serverAdd(client, {
|
await serverAdd(client, {
|
||||||
|
|
@ -474,7 +518,11 @@ async function main(): Promise<number> {
|
||||||
const { values } = parseArgs({
|
const { values } = parseArgs({
|
||||||
args: rest,
|
args: rest,
|
||||||
allowPositionals: true,
|
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
|
// 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
|
// 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;
|
return 2;
|
||||||
}
|
}
|
||||||
const stateDir = stateDirFrom(values.state);
|
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 bindings = loadBindings(join(stateDir, "environments.yaml"));
|
||||||
const binding = bindings.environments[values.env];
|
const binding = bindings.environments[values.env];
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
console.error(`environment ${values.env} not in environments.yaml`);
|
console.error(`environment ${values.env} not in environments.yaml`);
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
const { instance, client } = openCoolify(
|
||||||
|
stateDir,
|
||||||
|
values.instance,
|
||||||
|
binding,
|
||||||
|
);
|
||||||
|
assertWritable(instance, "smoke");
|
||||||
const team = await assertTeam(client, binding.team, values.env);
|
const team = await assertTeam(client, binding.team, values.env);
|
||||||
console.log(`team ${formatTeam(team)} ✓`);
|
console.log(`team ${formatTeam(team)} ✓`);
|
||||||
if (!bindings.smoke_target) {
|
if (!bindings.smoke_target) {
|
||||||
|
|
@ -518,11 +570,27 @@ async function main(): Promise<number> {
|
||||||
const { values } = parseArgs({
|
const { values } = parseArgs({
|
||||||
args: rest,
|
args: rest,
|
||||||
allowPositionals: true,
|
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 stateDir = stateDirFrom(values.state);
|
||||||
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
|
// Bindings first, but only when --env was given: an environment's
|
||||||
const client = new CoolifyClient(baseUrl, token);
|
// `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
|
// Read-only, and the one command that deliberately does NOT require a
|
||||||
// team binding: it is how you discover the values to write into
|
// team binding: it is how you discover the values to write into
|
||||||
// environments.yaml in the first place. Asserting here would be circular.
|
// 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.
|
// "will apply refuse?" — ask the question without touching anything.
|
||||||
const actual = await client.currentTeam();
|
const actual = await client.currentTeam();
|
||||||
console.log(`token's team: ${formatTeam(actual)}`);
|
console.log(`token's team: ${formatTeam(actual)}`);
|
||||||
if (!values.env) return 0;
|
if (!values.env || !binding) 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;
|
|
||||||
}
|
|
||||||
await assertTeam(client, binding.team, values.env);
|
await assertTeam(client, binding.team, values.env);
|
||||||
console.log(`matches the team ${values.env} expects ✓`);
|
console.log(`matches the team ${values.env} expects ✓`);
|
||||||
return 0;
|
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
|
// Which Coolify cast is about to talk to. The single most consequential input
|
||||||
// file in the state repo that is never committed (it is a live credential).
|
// to any command — so it is a value that gets resolved, named, and printed,
|
||||||
export function loadCoolifyEnv(path: string): {
|
// 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;
|
baseUrl: string;
|
||||||
token: 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> = {};
|
const vars: Record<string, string> = {};
|
||||||
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
||||||
const line = raw.trim();
|
const line = raw.trim();
|
||||||
|
|
@ -14,6 +31,17 @@ export function loadCoolifyEnv(path: string): {
|
||||||
if (eq === -1) continue;
|
if (eq === -1) continue;
|
||||||
vars[line.slice(0, eq)] = line.slice(eq + 1).replace(/^"|"$/g, "");
|
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 baseUrl = vars.COOLIFY_BASE_URL;
|
||||||
const token = vars.COOLIFY_ACCESS_TOKEN;
|
const token = vars.COOLIFY_ACCESS_TOKEN;
|
||||||
if (!baseUrl || !token) {
|
if (!baseUrl || !token) {
|
||||||
|
|
@ -21,5 +49,82 @@ export function loadCoolifyEnv(path: string): {
|
||||||
`${path}: COOLIFY_BASE_URL and COOLIFY_ACCESS_TOKEN are required`,
|
`${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 { spawn } from "node:child_process";
|
||||||
import { describe, expect, it } from "vitest";
|
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 } {
|
// Spawned ASYNCHRONOUSLY, and that is load-bearing: the stub Coolify below
|
||||||
try {
|
// runs in THIS process, so a blocking execFileSync would hold the event loop
|
||||||
const output = execFileSync("node", ["dist/cli.js", ...args], {
|
// and the stub could never answer the CLI it just launched — the two would
|
||||||
encoding: "utf8",
|
// deadlock until the test timed out.
|
||||||
stdio: "pipe",
|
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 };
|
let output = "";
|
||||||
} catch (e) {
|
child.stdout.on("data", (d) => {
|
||||||
const err = e as { status: number; stderr: string; stdout: string };
|
output += String(d);
|
||||||
return { code: err.status, output: `${err.stdout}${err.stderr}` };
|
});
|
||||||
|
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", () => {
|
describe("infra cli", () => {
|
||||||
it("refuses apply --path with --env prod, exit non-zero", () => {
|
it("refuses apply --path with --env prod, exit non-zero", async () => {
|
||||||
const r = runCli([
|
const r = await runCli([
|
||||||
"apply",
|
"apply",
|
||||||
"acme/widget",
|
"acme/widget",
|
||||||
"--env",
|
"--env",
|
||||||
|
|
@ -27,8 +112,8 @@ describe("infra cli", () => {
|
||||||
expect(r.code).not.toBe(0);
|
expect(r.code).not.toBe(0);
|
||||||
expect(r.output).toMatch(/--path.*prod/);
|
expect(r.output).toMatch(/--path.*prod/);
|
||||||
});
|
});
|
||||||
it("prints usage on unknown command", () => {
|
it("prints usage on unknown command", async () => {
|
||||||
const r = runCli(["frobnicate"]);
|
const r = await runCli(["frobnicate"]);
|
||||||
expect(r.code).not.toBe(0);
|
expect(r.code).not.toBe(0);
|
||||||
expect(r.output).toMatch(/usage: cast (apply|diff)/i);
|
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
|
// 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
|
// team), and `smoke` writes env vars onto a live app. Neither may run without
|
||||||
// an environment to assert the token's team against.
|
// an environment to assert the token's team against.
|
||||||
it("refuses server add without --env, exit non-zero", () => {
|
it("refuses server add without --env, exit non-zero", async () => {
|
||||||
const r = runCli([
|
const r = await runCli([
|
||||||
"server",
|
"server",
|
||||||
"add",
|
"add",
|
||||||
"prod-box",
|
"prod-box",
|
||||||
|
|
@ -49,9 +134,125 @@ describe("infra cli", () => {
|
||||||
expect(r.code).not.toBe(0);
|
expect(r.code).not.toBe(0);
|
||||||
expect(r.output).toMatch(/--env/);
|
expect(r.output).toMatch(/--env/);
|
||||||
});
|
});
|
||||||
it("refuses smoke without --env, exit non-zero", () => {
|
it("refuses smoke without --env, exit non-zero", async () => {
|
||||||
const r = runCli(["smoke"]);
|
const r = await runCli(["smoke"]);
|
||||||
expect(r.code).not.toBe(0);
|
expect(r.code).not.toBe(0);
|
||||||
expect(r.output).toMatch(/--env/);
|
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