Two assertions in the full-mesh test encoded the old naming: the grant file read back from the provider, and the PostgreSQL role the real application logs in as. Both are named after the consumer now, and a consumer is a module on a machine. These are the two that matter most in this file — it is the only place where a real application authenticates against a real database with a password the mesh delivered and cannot read, so they are what would have caught the naming going wrong end to end.
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
/**
|
|
* The last step of a credential, against a real database.
|
|
*
|
|
* The mesh generates a password, seals it to the machine that must accept it, and discards the
|
|
* plaintext — so it cannot tell PostgreSQL to start accepting it. Something on that machine reads
|
|
* what the host wrote and makes it true. Everything up to that point is proven elsewhere; this is
|
|
* the step where a password either becomes a login or does not.
|
|
*
|
|
* Against a real PostgreSQL because there is no version of this worth asserting against a fake:
|
|
* what is under test is whether `create role ... password` and a connection agree, which is
|
|
* exactly what a fake would be told to agree about (novox/hq ADR 0017).
|
|
*/
|
|
|
|
import { test, after, before } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { loadScenario } from "../../src/declaration/parse.ts";
|
|
import { raise } from "../../src/lifecycle/raise.ts";
|
|
import { destroy, exec } from "../../src/lifecycle/operate.ts";
|
|
import { labIsUsable, destroyAll } from "./harness.ts";
|
|
import { incus } from "../../src/incus/client.ts";
|
|
import { machineName } from "../../src/lifecycle/names.ts";
|
|
|
|
const capability = await labIsUsable();
|
|
const provisioner = process.env["MESH_LAB_PROVISIONER"] ?? "";
|
|
const skip = !capability.usable
|
|
? `lab not usable: ${capability.why}`
|
|
: !provisioner
|
|
? "set MESH_LAB_PROVISIONER to a built provisioner (mesh-control: go build ./examples/postgres-provisioner)"
|
|
: false;
|
|
|
|
const SCENARIO = "a-provider";
|
|
const MACHINE = "anchor";
|
|
const GRANTS = "/var/lib/postgres/grants";
|
|
const SUPER = "postgres://postgres:super@127.0.0.1:5432/postgres?sslmode=disable";
|
|
|
|
let instanceId = "";
|
|
/** The postgres image, by digest, from the registry the scenario raised. */
|
|
let image = "";
|
|
|
|
function shellQuote(s: string): string {
|
|
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
}
|
|
|
|
/** Run something on the machine and return what it said, with its exit status. */
|
|
async function on(command: string): Promise<{ out: string; ok: boolean }> {
|
|
const { stdout } = await exec(instanceId, MACHINE, [
|
|
"sh", "-c", `${command} 2>&1; echo "__exit=$?"`,
|
|
]);
|
|
const marker = stdout.lastIndexOf("__exit=");
|
|
const status = Number(stdout.slice(marker + 7).trim());
|
|
return { out: stdout.slice(0, marker), ok: status === 0 };
|
|
}
|
|
|
|
/** The same, refusing to continue past a failure nobody would otherwise see. */
|
|
async function must(command: string): Promise<string> {
|
|
const { out, ok } = await on(command);
|
|
if (!ok) throw new Error(`${command}\n${out}`);
|
|
return out;
|
|
}
|
|
|
|
/** psql as the superuser, inside the database container. */
|
|
async function sql(query: string): Promise<string> {
|
|
return (await must(`docker exec mesh-db psql -U postgres -qAt -c ${shellQuote(query)}`)).trim();
|
|
}
|
|
|
|
/**
|
|
* Write what the host would have written from a declaration: the manifest of who asked, and one
|
|
* file per consumer holding its password alone.
|
|
*
|
|
* Written here rather than by running the host, because what is under test is the step *after*
|
|
* the host — and that the host writes these exact shapes is asserted in its own suite.
|
|
*/
|
|
async function meshWrote(
|
|
consumers: { node: string; module: string; name: string; password: string }[],
|
|
): Promise<void> {
|
|
const manifest = {
|
|
contributions: 1,
|
|
requirement: "postgres-database",
|
|
generated: "by the mesh",
|
|
given: consumers.map((c) => ({
|
|
from: c.module,
|
|
node: c.node,
|
|
secret: `${GRANTS}/${c.node}.${c.module}.secret`,
|
|
values: { name: c.name },
|
|
})),
|
|
};
|
|
await must(`mkdir -p ${GRANTS}`);
|
|
await must(`printf %s ${shellQuote(JSON.stringify(manifest))} > ${GRANTS}/mesh.json`);
|
|
// Every credential file rewritten from nothing, so a removed consumer's does not linger and
|
|
// make the revocation test pass for a reason that is not the one being tested.
|
|
await must(`find ${GRANTS} -name '*.secret' -delete`);
|
|
for (const c of consumers) {
|
|
await must(`printf %s ${shellQuote(c.password)} > ${GRANTS}/${c.node}.${c.module}.secret`);
|
|
await must(`chmod 600 ${GRANTS}/${c.node}.${c.module}.secret`);
|
|
}
|
|
}
|
|
|
|
/** The provisioner, as the module shipping PostgreSQL would run it. */
|
|
async function provision(): Promise<{ out: string; ok: boolean }> {
|
|
return on(
|
|
`GRANTS=${GRANTS} MESH_PROVISION_POSTGRES=${shellQuote(SUPER)} /usr/local/bin/mesh-provision-postgres`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Can this role log in with this password?
|
|
*
|
|
* Over the bridge, from a container of its own. `--network container:mesh-db` would share the
|
|
* database's namespace and put us back on its loopback, which is the very thing being avoided.
|
|
*
|
|
* The address comes from `.NetworkSettings.Networks.bridge.IPAddress` rather than the top-level
|
|
* `.NetworkSettings.IPAddress`, which docker 29 no longer populates — it templates to empty, psql
|
|
* silently falls back to a unix socket that is not there, and every login looks impossible.
|
|
*
|
|
* From a separate container, reaching the database over the bridge — **not** from inside it over
|
|
* loopback. PostgreSQL's default `pg_hba.conf` trusts `127.0.0.1`, so a check made from inside
|
|
* the container authenticates nothing and returns true for any password at all. Which is what the
|
|
* first version of this did: two tests passed without ever verifying a password, and only the
|
|
* rotation test noticed, by asserting that an old password had *stopped* working.
|
|
*/
|
|
async function canLogIn(role: string, password: string, database: string): Promise<boolean> {
|
|
return (await tryLogIn(role, password, database)).ok;
|
|
}
|
|
|
|
/** The same, keeping what the database said — so a failure says why rather than only that. */
|
|
async function tryLogIn(
|
|
role: string,
|
|
password: string,
|
|
database: string,
|
|
): Promise<{ ok: boolean; out: string }> {
|
|
const { out } = await on(
|
|
`docker run --rm -e PGPASSWORD=${shellQuote(password)} ${image} ` +
|
|
`psql -h "$(docker inspect -f '{{.NetworkSettings.Networks.bridge.IPAddress}}' mesh-db)" ` +
|
|
`-U ${role} -d ${database} -qAt -c 'select 1'`,
|
|
);
|
|
return { ok: out.trim() === "1", out };
|
|
}
|
|
|
|
before(async () => {
|
|
if (skip) return;
|
|
const scenario = loadScenario(`scenarios/${SCENARIO}.yml`);
|
|
const instance = await raise(scenario, {});
|
|
instanceId = instance.instanceId;
|
|
|
|
// From the registry the scenario raised, by digest. There is no route to a public registry from
|
|
// a documentation range, which is the point of the lab having its own.
|
|
const stocked = instance.images.find((r) => r.includes("postgres"));
|
|
assert.ok(stocked, `the scenario stocked no postgres image: ${instance.images.join(", ")}`);
|
|
image = stocked;
|
|
|
|
await must(
|
|
`docker run -d --name mesh-db -e POSTGRES_PASSWORD=super ` +
|
|
`-p 127.0.0.1:5432:5432 ${image}`,
|
|
);
|
|
let ready = false;
|
|
for (let i = 0; i < 90 && !ready; i++) {
|
|
({ ok: ready } = await on(`docker exec mesh-db pg_isready -U postgres`));
|
|
if (!ready) await new Promise((r) => setTimeout(r, 1000));
|
|
}
|
|
assert.ok(ready, "the database never became ready");
|
|
|
|
await incus([
|
|
"file", "push", provisioner,
|
|
`${machineName(instanceId, MACHINE)}/usr/local/bin/mesh-provision-postgres`,
|
|
"--mode", "0755",
|
|
], 180_000);
|
|
}, { timeout: 1_200_000 });
|
|
|
|
after(async () => {
|
|
if (instanceId) await destroy(instanceId);
|
|
await destroyAll(`${SCENARIO}-`);
|
|
}, { timeout: 600_000 });
|
|
|
|
test("a password the mesh generated becomes a login that works", { skip, timeout: 300_000 }, async () => {
|
|
await meshWrote([
|
|
{ node: "workstation", module: "meshboard", name: "meshboard", password: "first-password-aaa" },
|
|
]);
|
|
const { out, ok } = await provision();
|
|
assert.ok(ok, out);
|
|
|
|
assert.equal(await sql(`select rolcanlogin from pg_roles where rolname = 'mesh_workstation_meshboard'`), "t");
|
|
assert.equal(await sql(`select 1 from pg_database where datname = 'meshboard'`), "1");
|
|
const attempt = await tryLogIn("mesh_workstation_meshboard", "first-password-aaa", "meshboard");
|
|
assert.ok(attempt.ok, `the consumer cannot log in with the password the mesh gave it:\n${attempt.out}`);
|
|
});
|
|
|
|
test("running it again reaches the same state and says nothing", { skip, timeout: 300_000 }, async () => {
|
|
// It runs after every declaration and is never told what changed, so arriving at an already
|
|
// correct state is the ordinary case rather than an edge one.
|
|
const { out, ok } = await provision();
|
|
assert.ok(ok, out);
|
|
assert.equal(out.trim(), "", `it did work on a second run: ${out}`);
|
|
assert.ok(await canLogIn("mesh_workstation_meshboard", "first-password-aaa", "meshboard"));
|
|
});
|
|
|
|
test("rotating the password makes the new one work and the old one stop", { skip, timeout: 300_000 }, async () => {
|
|
// The failure this guards is a provisioner that only ever creates: the mesh replaces the file,
|
|
// the role exists, nothing happens, and a rotation reports success while changing nothing.
|
|
await meshWrote([
|
|
{ node: "workstation", module: "meshboard", name: "meshboard", password: "second-password-bbb" },
|
|
]);
|
|
const { out, ok } = await provision();
|
|
assert.ok(ok, out);
|
|
|
|
assert.ok(
|
|
await canLogIn("mesh_workstation_meshboard", "second-password-bbb", "meshboard"),
|
|
"the rotated password does not work",
|
|
);
|
|
assert.equal(
|
|
await canLogIn("mesh_workstation_meshboard", "first-password-aaa", "meshboard"),
|
|
false,
|
|
"the old password still works, so the rotation changed nothing",
|
|
);
|
|
});
|
|
|
|
test("a consumer that goes away loses its login", { skip, timeout: 300_000 }, async () => {
|
|
// The half usually missing. A consumer removed from the mesh otherwise keeps a working login
|
|
// for ever and nothing says so — the same rule the host follows about removing what it declared
|
|
// and no longer declares.
|
|
await meshWrote([]);
|
|
const { out, ok } = await provision();
|
|
assert.ok(ok, out);
|
|
assert.match(out, /revoked mesh_workstation_meshboard/);
|
|
|
|
assert.equal(await sql(`select rolcanlogin from pg_roles where rolname = 'mesh_workstation_meshboard'`), "f");
|
|
assert.equal(
|
|
await canLogIn("mesh_workstation_meshboard", "second-password-bbb", "meshboard"),
|
|
false,
|
|
"a consumer nobody asks for any more can still log in",
|
|
);
|
|
});
|
|
|
|
test("a role nobody here made is left alone", { skip, timeout: 300_000 }, async () => {
|
|
// A provisioner that removed every role it did not recognise could not safely be run on a
|
|
// database that predates it — which is every database anybody would want to adopt.
|
|
await sql(`create role someone_elses with login password 'theirs'`);
|
|
await sql(`create database theirs owner someone_elses`);
|
|
await meshWrote([]);
|
|
const { ok } = await provision();
|
|
assert.ok(ok);
|
|
assert.equal(await sql(`select rolcanlogin from pg_roles where rolname = 'someone_elses'`), "t");
|
|
assert.ok(await canLogIn("someone_elses", "theirs", "theirs"));
|
|
});
|
|
|
|
test("a manifest naming a credential that was never written is refused", { skip, timeout: 300_000 }, async () => {
|
|
// Rather than creating a role with no password — a login nothing can use, which nothing would
|
|
// report until something tried to connect.
|
|
await meshWrote([]);
|
|
await must(
|
|
`printf %s '{"contributions":1,"requirement":"postgres-database","given":[` +
|
|
`{"from":"meshboard","node":"ghost","secret":"${GRANTS}/ghost.meshboard.secret","values":{"name":"ghost"}}` +
|
|
`]}' > ${GRANTS}/mesh.json`,
|
|
);
|
|
const { out, ok } = await provision();
|
|
assert.equal(ok, false, "it carried on past a missing credential");
|
|
assert.match(out, /should be at .*ghost\.meshboard\.secret/);
|
|
assert.equal(await sql(`select count(*) from pg_roles where rolname = 'mesh_ghost_meshboard'`), "0");
|
|
});
|