audit-logger: the assigned-module manifest (ADR 0048) #2
@@ -0,0 +1,351 @@
|
||||
// The MinIO admin client — minio's own code, living in the module (novox/hq ADR 0044). 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, randomBytes } 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(/"|"/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 and return its credential. 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 provisioner image
|
||||
* bundles.
|
||||
*/
|
||||
async createAccessKey(bucket: string, accessKey: string): Promise<AccessKey> {
|
||||
await this.ensureAlias();
|
||||
const secretKey = randomBytes(20).toString("hex");
|
||||
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;
|
||||
}
|
||||
@@ -10,6 +10,10 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.minio.bucket.created",
|
||||
"module.minio.bucket.removed"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"port": 9000,
|
||||
@@ -31,7 +35,8 @@
|
||||
"s3-bucket": "/var/lib/minio/grants"
|
||||
},
|
||||
"own-secrets": {
|
||||
"root": "/var/lib/minio/root.secret"
|
||||
"root": "/var/lib/minio/root.secret",
|
||||
"broker": "/var/lib/minio/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
@@ -94,9 +99,9 @@
|
||||
"network": "minio",
|
||||
"env": {
|
||||
"GRANTS": "/var/lib/minio/grants",
|
||||
"MESH_OBJECTSTORE_URL": "http://minio:9000",
|
||||
"MESH_OBJECTSTORE_ROOT_USER": "meshroot",
|
||||
"MESH_OBJECTSTORE_ROOT_PASSWORD_FILE": "/run/secrets/root"
|
||||
"MESH_MINIO_ENDPOINT": "http://minio:9000",
|
||||
"MESH_MINIO_ROOT_USER": "meshroot",
|
||||
"MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root"
|
||||
},
|
||||
"volumes": [
|
||||
"/var/lib/minio/grants:/var/lib/minio/grants:ro",
|
||||
@@ -104,4 +109,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-minio",
|
||||
"version": "0.1.0",
|
||||
"description": "minio — S3-compatible object store. Its admin client, tools, provisioner and events live here (novox/hq ADR 0044).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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, sealing and grant-file handling 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 0044/0045).
|
||||
//
|
||||
// The `s3-bucket` interface: a consumer receives `{ endpoint, bucket, accessKey, secretKey, region }`
|
||||
// — an S3 endpoint and a credential confined to its own bucket. It depends on `s3-bucket`, not on
|
||||
// minio, so any S3-compatible provider could serve it.
|
||||
//
|
||||
// The bucket and access-key id are derived deterministically from the consumer's identity, because
|
||||
// the harness hands `remove` only that identity (no stored values) — so teardown recomputes exactly
|
||||
// what creation minted, with nothing to persist. The emits fire here, at the real provisioning
|
||||
// points (novox/hq ADR 0046/0047); the module's events entrypoint (../index.ts) consumes them.
|
||||
|
||||
import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { MinioClient, accessKeyFor, bucketFor } from "../client.js";
|
||||
|
||||
const minio = MinioClient.fromEnv();
|
||||
|
||||
runProvisioner("s3-bucket", {
|
||||
async create(grant: Grant): Promise<Credential> {
|
||||
const bucket = bucketFor(grant.consumer);
|
||||
const accessKeyId = accessKeyFor(grant.consumer);
|
||||
|
||||
if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket);
|
||||
// Re-mint the scoped key idempotently: drop any prior one under this id, then add fresh.
|
||||
try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ }
|
||||
const key = await minio.createAccessKey(bucket, accessKeyId);
|
||||
|
||||
await emit("module.minio.bucket.created", {
|
||||
bucket,
|
||||
consumer: grant.consumer,
|
||||
node: grant.node,
|
||||
accessKey: key.accessKey, // the secret is never put on the bus — only the credential file carries it
|
||||
endpoint: minio.baseUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
fields: {
|
||||
endpoint: minio.baseUrl,
|
||||
bucket,
|
||||
accessKey: key.accessKey,
|
||||
secretKey: key.secretKey,
|
||||
region: minio.region,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async remove(grant: Grant): Promise<void> {
|
||||
const bucket = bucketFor(grant.consumer);
|
||||
const accessKeyId = accessKeyFor(grant.consumer);
|
||||
|
||||
// Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if
|
||||
// empty; a bucket that still holds objects is left for an operator rather than erroring on every
|
||||
// reconcile tick — access is already gone, and silently deleting a consumer's data would be worse.
|
||||
try { await minio.removeAccessKey(accessKeyId); } catch { /* already gone */ }
|
||||
try {
|
||||
await minio.removeBucket(bucket);
|
||||
} catch (err) {
|
||||
console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`);
|
||||
}
|
||||
|
||||
await emit("module.minio.bucket.removed", { bucket, consumer: grant.consumer, node: grant.node });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// minio's tools — ported here from the shared sdk (novox/hq ADR 0044), importing minio's own client.
|
||||
// They return structured data; the mesh serves them through the sdk's tool harness. These are the
|
||||
// read/inspect operations useful to an operator; creating storage for a consumer is the provisioner's
|
||||
// job, not a tool's.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { MinioClient } from "../client.js";
|
||||
|
||||
export function getMinioTools(minio: MinioClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "minio_list_buckets",
|
||||
description: "List every S3 bucket in the object store, with creation dates.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const buckets = await minio.listBuckets();
|
||||
return {
|
||||
count: buckets.length,
|
||||
buckets: buckets.map((b) => ({ name: b.name, createdAt: b.creationDate?.toISOString() })),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minio_list_objects",
|
||||
description: "List objects in a bucket, optionally under a prefix.",
|
||||
input: {
|
||||
bucket: { type: "string", description: "the bucket name" },
|
||||
prefix: { type: "string", description: "only keys under this prefix (e.g. 'photos/')" },
|
||||
recursive: { type: "boolean", description: "descend into nested prefixes (default false)" },
|
||||
limit: { type: "number", description: "max keys to return (default 100)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const bucket = String(args.bucket);
|
||||
const objects = await minio.listObjects(
|
||||
bucket,
|
||||
args.prefix ? String(args.prefix) : "",
|
||||
Boolean(args.recursive),
|
||||
args.limit ? Number(args.limit) : 100,
|
||||
);
|
||||
return {
|
||||
bucket,
|
||||
count: objects.length,
|
||||
objects: objects.map((o) => ({ key: o.name, size: o.size, lastModified: o.lastModified.toISOString(), etag: o.etag })),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minio_bucket_info",
|
||||
description: "Summary of one bucket: whether it exists, its region, and a sampled object count and size.",
|
||||
input: { bucket: { type: "string", description: "the bucket name" } },
|
||||
run: async (args) => minio.bucketInfo(String(args.bucket)),
|
||||
},
|
||||
{
|
||||
name: "minio_presigned_url",
|
||||
description: "A time-limited URL to download (GET) or upload (PUT) one object without credentials.",
|
||||
input: {
|
||||
bucket: { type: "string", description: "the bucket name" },
|
||||
object: { type: "string", description: "the object key" },
|
||||
method: { type: "string", description: "'GET' to download (default) or 'PUT' to upload" },
|
||||
expires: { type: "number", description: "seconds until the URL expires (default 86400 = 24h)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const method = String(args.method ?? "GET").toUpperCase() === "PUT" ? "PUT" : "GET";
|
||||
const expires = args.expires ? Number(args.expires) : 86400;
|
||||
const url = minio.presignedUrl(method, String(args.bucket), String(args.object), expires);
|
||||
return { method, bucket: String(args.bucket), object: String(args.object), expiresInSeconds: expires, url };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when the object store is configured and reachable; without it minio
|
||||
// contributes none rather than failing the whole tool runtime.
|
||||
registerModuleTools("minio", (env) => {
|
||||
try {
|
||||
return getMinioTools(MinioClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"client.ts",
|
||||
"tools/index.ts",
|
||||
"provisioner/index.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user