Files
mesh-catalog/modules/minio/client.ts
T
jschoubben 159ed53103 Rebased onto main: ADR 0188 renumbered to 0201, and minio takes the sdk at 0.1.7
The bundles refactor took ADR 0188 on main, so minio's comments cite 0201.
The sdk is 0.1.7 after the same rebase, and minio needs the `derived` field
it carries.
2026-10-04 02:45:13 +02:00

356 lines
15 KiB
TypeScript

// The MinIO admin client — minio's own code, living in the module (novox/hq ADR 0039). Ported out
// of the shared hal sdk, where a change to MinIO's surface rebuilt everything; here it rebuilds only
// minio. This module's tools, its provisioner and its events entrypoint import it; nothing outside
// minio does.
//
// It speaks two planes with node built-ins only (never the `minio` npm package):
// - the S3 data plane over `fetch`, signed with AWS Signature V4 (node:crypto) — bucket and object
// operations, and presigned URLs;
// - the admin plane through the `mc` CLI (node:child_process) — scoped service accounts, whose
// creation the MinIO admin REST API guards behind an encrypted payload `fetch` cannot form.
// This mirrors hal's MinIOClient/MinIOAdmin split, folded into one client the module builds from env.
import { createHash, createHmac } from "node:crypto";
import { execFile } from "node:child_process";
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface MinioBucket {
name: string;
creationDate?: Date;
}
export interface MinioObject {
name: string;
size: number;
lastModified: Date;
etag?: string;
}
export interface MinioObjectStat {
size: number;
lastModified: Date;
etag?: string;
contentType?: string;
}
export interface MinioBucketInfo {
name: string;
exists: boolean;
region: string;
/** Sampled from the first page of a listing (up to 1000 keys) — a summary, not an audit. */
sampledObjects: number;
sampledBytes: number;
}
/** A scoped credential a consumer receives: an access key/secret pair confined to one bucket. */
export interface AccessKey {
accessKey: string;
secretKey: string;
}
interface MinioOptions {
endpoint: string;
rootUser: string;
rootPassword: string;
region: string;
mcBin: string;
mcConfigDir: string;
}
export class MinioClient {
readonly baseUrl: string;
readonly region: string;
private readonly rootUser: string;
private readonly rootPassword: string;
private readonly mcBin: string;
private readonly mcConfigDir: string;
private aliasReady = false;
constructor(opts: MinioOptions) {
this.baseUrl = opts.endpoint.replace(/\/$/, "");
this.region = opts.region;
this.rootUser = opts.rootUser;
this.rootPassword = opts.rootPassword;
this.mcBin = opts.mcBin;
this.mcConfigDir = opts.mcConfigDir;
}
/**
* Build from the module's resolved environment. The endpoint and root identity come from
* MESH_MINIO_*; the password may be given inline or as a mounted secret file (the manifest mounts
* root.secret), so the provisioner container needs nothing written by hand. Throws when
* unconfigured — an object store the module cannot reach is not a usable client.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): MinioClient {
const endpoint = env.MESH_MINIO_ENDPOINT;
const rootUser = env.MESH_MINIO_ROOT_USER;
const passwordFile = env.MESH_MINIO_ROOT_PASSWORD_FILE;
const rootPassword = env.MESH_MINIO_ROOT_PASSWORD ?? (passwordFile ? readFileSync(passwordFile, "utf8").trim() : undefined);
if (!endpoint || !rootUser || !rootPassword) {
throw new Error("minio is not configured — set MESH_MINIO_ENDPOINT, MESH_MINIO_ROOT_USER and MESH_MINIO_ROOT_PASSWORD");
}
return new MinioClient({
endpoint,
rootUser,
rootPassword,
region: env.MESH_MINIO_REGION ?? "us-east-1",
mcBin: env.MESH_MINIO_MC_BIN ?? "mc",
mcConfigDir: env.MESH_MINIO_MC_CONFIG ?? join(tmpdir(), ".mc-mesh"),
});
}
// --- S3 data plane (signed fetch) ---------------------------------------
async listBuckets(): Promise<MinioBucket[]> {
const { status, text } = await this.request("GET", "/");
if (status !== 200) throw new Error(`minio listBuckets: ${status} ${text}`);
const out: MinioBucket[] = [];
const re = /<Bucket>\s*<Name>([^<]+)<\/Name>\s*<CreationDate>([^<]*)<\/CreationDate>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
out.push({ name: m[1], creationDate: m[2] ? new Date(m[2]) : undefined });
}
return out;
}
async bucketExists(bucket: string): Promise<boolean> {
const { status } = await this.request("HEAD", `/${bucket}`);
if (status === 200) return true;
if (status === 404) return false;
throw new Error(`minio bucketExists ${bucket}: ${status}`);
}
/**
* Whether a consumer's access key, with exactly this secret, reaches its bucket: a HEAD of the
* bucket signed as the consumer, the way it signs. Read-only. `false` when the key is unknown, the
* secret wrong, access denied or the bucket gone; any other answer rejects (novox/hq issue 120).
*/
async canReachAs(bucket: string, accessKey: string, secretKey: string): Promise<boolean> {
const { status } = await this.request("HEAD", `/${bucket}`, {}, { accessKey, secretKey });
if (status === 200) return true;
if (status === 403 || status === 404) return false;
throw new Error(`minio HEAD ${bucket} as ${accessKey}: ${status}`);
}
async createBucket(bucket: string): Promise<void> {
const { status, text } = await this.request("PUT", `/${bucket}`);
// 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail.
if (status !== 200 && status !== 409) throw new Error(`minio createBucket ${bucket}: ${status} ${text}`);
}
async removeBucket(bucket: string): Promise<void> {
const { status, text } = await this.request("DELETE", `/${bucket}`);
// 204 removed; 404 already gone — removal is idempotent too.
if (status !== 204 && status !== 404) throw new Error(`minio removeBucket ${bucket}: ${status} ${text}`);
}
async listObjects(bucket: string, prefix = "", recursive = false, maxKeys = 100): Promise<MinioObject[]> {
const query: Record<string, string> = { "list-type": "2", "max-keys": String(maxKeys) };
if (prefix) query.prefix = prefix;
if (!recursive) query.delimiter = "/";
const { status, text } = await this.request("GET", `/${bucket}`, query);
if (status !== 200) throw new Error(`minio listObjects ${bucket}: ${status} ${text}`);
const out: MinioObject[] = [];
for (const block of text.split("<Contents>").slice(1)) {
const key = tag(block, "Key");
if (!key) continue;
out.push({
name: key,
size: Number(tag(block, "Size") ?? "0"),
lastModified: new Date(tag(block, "LastModified") ?? 0),
etag: tag(block, "ETag")?.replace(/&quot;|"/g, ""),
});
}
return out;
}
async statObject(bucket: string, object: string): Promise<MinioObjectStat> {
const { status, headers } = await this.request("HEAD", `/${bucket}/${object}`);
if (status !== 200) throw new Error(`minio statObject ${bucket}/${object}: ${status}`);
const lm = headers.get("last-modified");
return {
size: Number(headers.get("content-length") ?? "0"),
lastModified: lm ? new Date(lm) : new Date(0),
etag: headers.get("etag")?.replace(/"/g, "") ?? undefined,
contentType: headers.get("content-type") ?? undefined,
};
}
async bucketInfo(bucket: string): Promise<MinioBucketInfo> {
const exists = await this.bucketExists(bucket);
if (!exists) return { name: bucket, exists: false, region: this.region, sampledObjects: 0, sampledBytes: 0 };
const objects = await this.listObjects(bucket, "", true, 1000);
return {
name: bucket,
exists: true,
region: this.region,
sampledObjects: objects.length,
sampledBytes: objects.reduce((n, o) => n + o.size, 0),
};
}
/** A time-limited URL for GET (download) or PUT (upload) of one object — query-string SigV4. */
presignedUrl(method: "GET" | "PUT", bucket: string, object: string, expires = 86400): string {
const { amzDate, dateStamp } = this.stamp();
const scope = `${dateStamp}/${this.region}/s3/aws4_request`;
const host = new URL(this.baseUrl).host;
const params: Record<string, string> = {
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": `${this.rootUser}/${scope}`,
"X-Amz-Date": amzDate,
"X-Amz-Expires": String(expires),
"X-Amz-SignedHeaders": "host",
};
const path = uriEncode(`/${bucket}/${object}`, false);
const canonicalQuery = encodeQuery(params);
const canonicalRequest = [method, path, canonicalQuery, `host:${host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n");
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n");
const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex");
return `${this.baseUrl}${path}?${canonicalQuery}&X-Amz-Signature=${signature}`;
}
// --- admin plane (mc CLI) ------------------------------------------------
/**
* Create a service account scoped to one bucket, under a given access key and secret key, and
* return the pair. The secret key is the mesh's — the mesh mints one password per consumer and
* hands a copy to both ends (novox/hq ADR 0048), so minio sets that as the secret rather than
* generating one the consumer could never learn. The MinIO admin REST API encrypts this request
* with a key derived (Argon2) from the root secret, which node built-ins cannot reproduce — so, as
* hal did, the module drives the `mc` CLI, which the runtime image bundles.
*/
async createAccessKey(bucket: string, accessKey: string, secretKey: string): Promise<AccessKey> {
await this.ensureAlias();
const policyPath = join(this.mcConfigDir, `policy-${accessKey}.json`);
writeFileSync(policyPath, bucketPolicy(bucket), { mode: 0o600 });
try {
await this.mc(
"admin", "user", "svcacct", "add", "mesh", this.rootUser,
"--access-key", accessKey,
"--secret-key", secretKey,
"--policy", policyPath,
);
} finally {
try { unlinkSync(policyPath); } catch { /* best effort */ }
}
return { accessKey, secretKey };
}
async removeAccessKey(accessKey: string): Promise<void> {
await this.ensureAlias();
await this.mc("admin", "user", "svcacct", "rm", "mesh", accessKey);
}
private async ensureAlias(): Promise<void> {
if (this.aliasReady) return;
await this.mc("alias", "set", "mesh", this.baseUrl, this.rootUser, this.rootPassword);
this.aliasReady = true;
}
private async mc(...args: string[]): Promise<string> {
const { stdout } = await execFileAsync(this.mcBin, ["--config-dir", this.mcConfigDir, ...args], { timeout: 30_000 });
return stdout.trim();
}
// --- SigV4 request plumbing ---------------------------------------------
private async request(
method: string,
path: string,
query: Record<string, string> = {},
as: { accessKey: string; secretKey: string } = { accessKey: this.rootUser, secretKey: this.rootPassword },
): Promise<{ status: number; headers: Headers; text: string }> {
const { amzDate, dateStamp } = this.stamp();
const host = new URL(this.baseUrl).host;
const payloadHash = sha256hex(""); // no request bodies are sent on this client
const encodedPath = uriEncode(path, false);
const canonicalQuery = encodeQuery(query);
const signedHeaders = "host;x-amz-content-sha256;x-amz-date";
const canonicalHeaders = `host:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`;
const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n");
const scope = `${dateStamp}/${this.region}/s3/aws4_request`;
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n");
const signature = hmac(this.signingKey(dateStamp, as.secretKey), stringToSign).toString("hex");
const authorization = `AWS4-HMAC-SHA256 Credential=${as.accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`;
const res = await fetch(url, {
method,
// `host` is set by fetch from the URL; the wire header matches what we signed.
headers: { Authorization: authorization, "x-amz-content-sha256": payloadHash, "x-amz-date": amzDate },
});
const text = method === "HEAD" ? "" : await res.text();
return { status: res.status, headers: res.headers, text };
}
private signingKey(dateStamp: string, secretKey: string = this.rootPassword): Buffer {
const kDate = hmac(`AWS4${secretKey}`, dateStamp);
const kRegion = hmac(kDate, this.region);
const kService = hmac(kRegion, "s3");
return hmac(kService, "aws4_request");
}
private stamp(): { amzDate: string; dateStamp: string } {
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, "");
return { amzDate, dateStamp: amzDate.slice(0, 8) };
}
}
// --- module-scoped helpers -------------------------------------------------
// **Neither the access key nor the bucket is derived here any more.** `accessKeyFor` minted an id
// of its own until the mesh took that over (ADR 0048: the login is the mesh's, handed to both
// ends), and `bucketFor` derived the bucket until the mesh took that over too (ADR 0201: the rule
// is a line of this module's manifest, filled per consumer and delivered to both ends). Both
// survived with no callers, which is the state a rule comes back from; they are gone.
function bucketPolicy(bucket: string): string {
return JSON.stringify({
Version: "2012-10-17",
Statement: [{
Effect: "Allow",
Action: ["s3:*"],
Resource: [`arn:aws:s3:::${bucket}`, `arn:aws:s3:::${bucket}/*`],
}],
});
}
function tag(xml: string, name: string): string | undefined {
const m = new RegExp(`<${name}>([^<]*)</${name}>`).exec(xml);
return m ? m[1] : undefined;
}
function hmac(key: Buffer | string, data: string): Buffer {
return createHmac("sha256", key).update(data, "utf8").digest();
}
function sha256hex(data: string): string {
return createHash("sha256").update(data, "utf8").digest("hex");
}
/** AWS canonical query: each key/value URI-encoded (slash included), sorted by encoded key. */
function encodeQuery(params: Record<string, string>): string {
return Object.keys(params)
.map((k) => [uriEncode(k, true), uriEncode(params[k], true)] as const)
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([k, v]) => `${k}=${v}`)
.join("&");
}
/** RFC 3986 URI encoding, byte-correct via UTF-8. Path callers keep "/" literal; query callers don't. */
function uriEncode(str: string, encodeSlash: boolean): string {
let out = "";
for (const byte of Buffer.from(str, "utf8")) {
const c = String.fromCharCode(byte);
if (/[A-Za-z0-9_.~-]/.test(c)) out += c;
else if (c === "/" && !encodeSlash) out += c;
else out += "%" + byte.toString(16).toUpperCase().padStart(2, "0");
}
return out;
}