Files
mesh-catalog/modules/minio/provisioner/index.ts
T
jschoubben 1fb7ca3d72 A withdrawn consumer keeps its data, in every provider that holds some (hq issue 241)
mssql disables the login, mongodb takes the user's roles, minio revokes the key and keeps the bucket,
mailu disables the mailbox, gitea prohibits the login instead of purging the user and their
repositories, umami keeps the website. Each provider's create already enables what this locks.
2026-10-05 00:34:40 +02:00

92 lines
4.5 KiB
TypeScript

// minio's provisioner — the adapter that makes minio a provider of the mesh `s3-bucket` interface
// (the name in module.json's `provides`). The reconcile loop, the contributions file, and reading
// the mesh's minted secret are the sdk harness's; this writes only the per-service half: how minio
// creates and removes a consumer's bucket and its scoped access key (novox/hq ADR 0039/0040/0048).
//
// The `s3-bucket` interface: a consumer connects to an S3 endpoint with an access key confined to
// its own bucket. It depends on `s3-bucket`, not on minio, so any S3-compatible provider could serve
// it.
//
// **The access key and its secret are the mesh's, not the provisioner's (ADR 0048).** The mesh
// derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio
// creates the service account under exactly that access key with exactly that secret — a credential
// the provisioner invented is one the consumer could never present.
//
// **The bucket name is the mesh's too (ADR 0201).** It used to be computed here, from the login,
// and every consumer transcribed the same rule into its own definition by hand — two copies of
// one rule with nothing comparing them, and one of three was wrong for months. Now the rule is a
// line of this module's manifest (`serves.s3-bucket.bucket: ${consumer:as:dns}`), the mesh fills
// it per consumer, and the same filled value reaches this provisioner and the consumer's own
// configuration. There is no second computation to disagree with.
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { MinioClient } from "../client.js";
const minio = MinioClient.fromEnv();
runProvisioner("s3-bucket", {
async create(p: Provision): Promise<void> {
const bucket = bucketNamed(p.derived);
const accessKeyId = p.as;
if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket);
// Re-mint the scoped key idempotently: drop any prior one under this id, then add it back with
// the mesh's secret.
try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ }
await minio.createAccessKey(bucket, accessKeyId, p.password);
await announce("bucket.created", {
bucket,
consumer: p.consumer ?? "",
accessKey: accessKeyId,
endpoint: minio.baseUrl,
});
},
async remove(p: { as: string; derived: Readonly<Record<string, unknown>> }): Promise<void> {
const bucket = bucketNamed(p.derived);
// Revoking the key is what cuts the consumer's access, and the bucket is kept, empty or not
// (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). A bucket is removed by a person, never by this loop.
try { await minio.removeAccessKey(p.as); } catch { /* already gone */ }
console.error(`[minio] ${p.as} withdrawn: access key revoked, bucket ${bucket} kept`);
await announce("bucket.removed", { bucket, accessKey: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return minio.canReachAs(bucketNamed(p.derived), p.as, p.password);
},
});
/** The bucket the mesh derived for this consumer.
*
* Absent means this module is running against a control plane that does not fill `${consumer:…}`
* yet, or a manifest whose `serves` block lost the line. Both are the same mistake from here —
* nobody said which bucket — and both are said rather than guessed: a provisioner that fell back
* to deriving one would restore the second rule and hide the fault behind a bucket that happens
* to be right. */
function bucketNamed(derived: Readonly<Record<string, unknown>>): string {
const bucket = derived.bucket;
if (typeof bucket !== "string" || bucket === "") {
throw new Error(
"the mesh did not say which bucket this consumer gets: minio's manifest must serve " +
"`bucket` under s3-bucket (novox/hq ADR 0201)",
);
}
return bucket;
}
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
* bucket that was made. */
async function announce(type: string, body: unknown): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[minio] could not emit ${type}: ${err}`);
}
}