Files
mesh-catalog/modules/minio/client.ts
T

353 lines
14 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}`);
}
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> = {},
): 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), stringToSign).toString("hex");
const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${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): Buffer {
const kDate = hmac(`AWS4${this.rootPassword}`, 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 -------------------------------------------------
/** A deterministic 20-char access key id from a consumer name, so removal needs no stored state:
* the provisioner recomputes the same id at teardown that it minted at creation. */
export function accessKeyFor(consumer: string): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const digest = createHash("sha256").update(consumer).digest();
let out = "";
for (let i = 0; i < 20; i++) out += chars[digest[i] % chars.length];
return out;
}
/** A DNS-safe bucket name derived from a consumer — the removable identity of its storage. */
export function bucketFor(consumer: string): string {
const name = consumer.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
return name.length >= 3 ? name : `mesh-${name}`;
}
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;
}