Expire the files of an execution's bucket after 7 days

The internal mode puts a lifecycle rule on each execution bucket it creates, so the buckets
empty by themselves. MINIO_MCP_INTERNAL_BUCKET_EXPIRE_DAYS sets the days (default 7, 0 for none).
A store that refuses the rule does not stop the session: a warning is logged instead.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-10-02 17:29:35 +02:00
parent deb3d81119
commit 36dd7851fb
6 changed files with 120 additions and 4 deletions

View File

@ -35,6 +35,10 @@ Internal mode:
- `MINIO_MCP_INTERNAL_BUCKET_PREFIX` (default `exec-`). Not optional: it cannot be set empty, and it
is up to 27 lowercase letters, digits or `-`, leaving room for a 36-character execution id.
- `MINIO_MCP_INTERNAL_BUCKET_EXPIRE_DAYS` (default `7`). The lifecycle rule put on an execution's
bucket when this server creates it: its objects are deleted that many days after they were written.
`0` sets no rule. The empty bucket itself stays. Needs the permission to set a bucket lifecycle; if
the store refuses, the session still opens and a warning is logged.
The connection is not configured here but sent per session, at `initialize`, by whoever opens it -
in the workflow manager, the `minio-mcp-internal` catalog entry, which is where the credentials

View File

@ -31,6 +31,9 @@ export const PINNED_HEADERS = Object.freeze([INTERNAL_SCOPE_HEADER, ...Object.va
*/
export const DEFAULT_INTERNAL_BUCKET_PREFIX = "exec-";
/** How long an execution's files live, so its bucket empties by itself. 0 keeps them for ever. */
export const DEFAULT_INTERNAL_BUCKET_EXPIRE_DAYS = 7;
// Room for a 36-character execution id within S3's 63.
const MAX_BUCKET_PREFIX_LENGTH = 27;
const BUCKET_PREFIX = /^[a-z0-9][a-z0-9-]*$/;
@ -84,6 +87,7 @@ export class MinioConnectionRegistry {
defaultRegion = "us-east-1",
allowedEndpoints = [],
internalBucketPrefix = DEFAULT_INTERNAL_BUCKET_PREFIX,
internalBucketExpireDays = DEFAULT_INTERNAL_BUCKET_EXPIRE_DAYS,
serviceFactory
} = {}) {
if (!Number.isInteger(maxConnectionsPerSession) || maxConnectionsPerSession < 1) {
@ -94,6 +98,10 @@ export class MinioConnectionRegistry {
this.defaultRegion = defaultRegion;
this.allowedEndpoints = new Set(allowedEndpoints.map(normalizeEndpoint));
this.internalBucketPrefix = validBucketPrefix(internalBucketPrefix);
if (!Number.isInteger(internalBucketExpireDays) || internalBucketExpireDays < 0) {
throw new Error("internalBucketExpireDays must be a non-negative integer (0 keeps files for ever)");
}
this.internalBucketExpireDays = internalBucketExpireDays;
this.serviceFactory = serviceFactory ?? ((options) => new MinioService(options));
this.connections = new Map();
this.internalSessions = new Map();
@ -134,7 +142,8 @@ export class MinioConnectionRegistry {
accessKey: pinned[INTERNAL_CONNECTION_HEADERS.accessKey],
secretKey: pinned[INTERNAL_CONNECTION_HEADERS.secretKey],
region: pinned[INTERNAL_CONNECTION_HEADERS.region] || this.defaultRegion,
maxObjectBytes: this.maxObjectBytes
maxObjectBytes: this.maxObjectBytes,
expireDays: this.internalBucketExpireDays
});
try {
const ensured = await service.ensureBucket();

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
import { startStreamableHttpServer } from "./streamable-http.js";
import { DEFAULT_INTERNAL_BUCKET_PREFIX, MinioConnectionRegistry, PINNED_HEADERS, internalScopeOf } from "./connection-registry.js";
import { DEFAULT_INTERNAL_BUCKET_EXPIRE_DAYS, DEFAULT_INTERNAL_BUCKET_PREFIX, MinioConnectionRegistry, PINNED_HEADERS, internalScopeOf } from "./connection-registry.js";
import { INTERNAL_TOOL_DEFINITIONS, TOOL_DEFINITIONS } from "./tool-definitions.js";
function csv(value) { return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean); }
@ -10,6 +10,13 @@ function integer(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum) throw new Error(`Invalid positive integer: ${value}`);
return parsed;
}
// 0 is meaningful here (keep for ever), which integer() does not accept.
function expireDays(value) {
if (value === undefined || value === "") return DEFAULT_INTERNAL_BUCKET_EXPIRE_DAYS;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 36500) throw new Error(`Invalid MINIO_MCP_INTERNAL_BUCKET_EXPIRE_DAYS: ${value}`);
return parsed;
}
const maxObjectBytes = integer(process.env.MINIO_MCP_MAX_OBJECT_BYTES, 1024 * 1024, 32 * 1024 * 1024);
const registry = new MinioConnectionRegistry({
maxConnectionsPerSession: integer(process.env.MINIO_MCP_MAX_CONNECTIONS_PER_SESSION, 8, 64),
@ -18,7 +25,8 @@ const registry = new MinioConnectionRegistry({
allowedEndpoints: csv(process.env.MINIO_MCP_ALLOWED_ENDPOINTS),
// The internal storage's connection comes per session from the catalog; the prefix stays here.
// Read with ?? so an empty value reaches validation and stops the server, not falls back.
internalBucketPrefix: process.env.MINIO_MCP_INTERNAL_BUCKET_PREFIX ?? DEFAULT_INTERNAL_BUCKET_PREFIX
internalBucketPrefix: process.env.MINIO_MCP_INTERNAL_BUCKET_PREFIX ?? DEFAULT_INTERNAL_BUCKET_PREFIX,
internalBucketExpireDays: expireDays(process.env.MINIO_MCP_INTERNAL_BUCKET_EXPIRE_DAYS)
});
// Same tool names in both modes; what differs is the connection the operation runs against.

View File

@ -4,6 +4,7 @@ import {
HeadBucketCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutBucketLifecycleConfigurationCommand,
PutObjectCommand,
S3Client
} from "@aws-sdk/client-s3";
@ -125,7 +126,7 @@ export class MinioService {
* A {@code client} passed in is shared - the internal one, used by every execution's session - so
* {@link #destroy} leaves it open; a client this service built for itself it closes.
*/
constructor({ endpoint, bucket, accessKey, secretKey, region = "us-east-1", maxObjectBytes = 1024 * 1024, client }) {
constructor({ endpoint, bucket, accessKey, secretKey, region = "us-east-1", maxObjectBytes = 1024 * 1024, client, expireDays = 0 }) {
this.endpoint = normalizeEndpoint(endpoint);
if (typeof bucket !== "string" || bucket.trim() === "" || bucket.length > 255 || bucket.includes("/") || CONTROL_CHARACTERS.test(bucket)) {
throw new Error("bucket must be a valid non-empty bucket name");
@ -137,7 +138,10 @@ export class MinioService {
}
if (!Number.isInteger(maxObjectBytes) || maxObjectBytes < 1) throw new Error("maxObjectBytes must be a positive integer");
if (!Number.isInteger(expireDays) || expireDays < 0) throw new Error("expireDays must be a non-negative integer");
this.bucket = bucket;
this.expireDays = expireDays;
this.maxObjectBytes = maxObjectBytes;
this.ownsClient = !client;
this.client = client ?? createS3Client({ endpoint: this.endpoint, region, accessKey, secretKey });
@ -166,6 +170,7 @@ export class MinioService {
}
try {
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }));
await this.#expireObjects();
return { bucket: this.bucket, created: true };
} catch (error) {
if (error?.name === "BucketAlreadyOwnedByYou" || error?.name === "BucketAlreadyExists") {
@ -175,6 +180,25 @@ export class MinioService {
}
}
/**
* Objects of an execution's bucket expire after {@code expireDays}, so the bucket empties by itself.
* Not fatal when the store refuses: the execution's storage works without it, and failing the
* session over housekeeping would cost more than the leftover files.
*/
async #expireObjects() {
if (this.expireDays === 0) return;
try {
await this.client.send(new PutBucketLifecycleConfigurationCommand({
Bucket: this.bucket,
LifecycleConfiguration: {
Rules: [{ ID: "expire-execution-data", Status: "Enabled", Filter: { Prefix: "" }, Expiration: { Days: this.expireDays } }]
}
}));
} catch (error) {
console.warn(`Could not set the ${this.expireDays}-day expiry on bucket ${this.bucket}: ${error?.name ?? "error"}`);
}
}
async listObjects(args) {
assertPlainObject(args);
rejectUnknown(args, ["prefix", "delimiter", "maxKeys", "continuationToken"]);

View File

@ -187,6 +187,23 @@ test("keeps every internal bucket behind the prefix, whatever the scope names",
assert.equal(services[0].options.bucket, "exec-storage-nodes-bucket");
});
test("hands each internal bucket a 7-day expiry unless configured otherwise", async () => {
const { registry, services } = internalFixture();
await registry.openInternal(internalContext("mcp-a"));
assert.equal(services[0].options.expireDays, 7);
const custom = internalFixture({ internalBucketExpireDays: 30 });
await custom.registry.openInternal(internalContext("mcp-a"));
assert.equal(custom.services[0].options.expireDays, 30);
const forever = internalFixture({ internalBucketExpireDays: 0 });
await forever.registry.openInternal(internalContext("mcp-a"));
assert.equal(forever.services[0].options.expireDays, 0);
for (const internalBucketExpireDays of [-1, 1.5, "7"]) {
assert.throws(() => new MinioConnectionRegistry({ internalBucketExpireDays }), /internalBucketExpireDays/);
}
});
test("uses a configured bucket prefix and refuses an empty or invalid one", async () => {
const { registry, services } = internalFixture({ internalBucketPrefix: "hf-run-" });
await registry.openInternal(internalContext("mcp-a"));

View File

@ -5,6 +5,7 @@ import {
CreateBucketCommand,
GetObjectCommand,
HeadBucketCommand,
PutBucketLifecycleConfigurationCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand
@ -188,6 +189,59 @@ test("creates the bucket when it does not exist yet", async () => {
assert.equal(client.commands[1].input.Bucket, "agent-data");
});
function serviceWithExpiry(client, expireDays) {
return new MinioService({
endpoint: "https://minio.example.test", bucket: "agent-data", accessKey: "a", secretKey: "s", client, expireDays
});
}
test("sets an expiry on a bucket it creates", async () => {
const client = new FakeClient((command) => {
if (command instanceof HeadBucketCommand) throw s3Error("NotFound", 404);
return {};
});
assert.deepEqual(await serviceWithExpiry(client, 7).ensureBucket(), { bucket: "agent-data", created: true });
const lifecycle = client.commands[2];
assert.ok(lifecycle instanceof PutBucketLifecycleConfigurationCommand);
assert.equal(lifecycle.input.Bucket, "agent-data");
assert.equal(lifecycle.input.LifecycleConfiguration.Rules[0].Expiration.Days, 7);
assert.equal(lifecycle.input.LifecycleConfiguration.Rules[0].Status, "Enabled");
});
test("sets no expiry when the days are 0, nor on a bucket that already exists", async () => {
const none = new FakeClient((command) => {
if (command instanceof HeadBucketCommand) throw s3Error("NotFound", 404);
return {};
});
await serviceWithExpiry(none, 0).ensureBucket();
assert.equal(none.commands.length, 2);
const existing = new FakeClient(() => ({}));
await serviceWithExpiry(existing, 7).ensureBucket();
assert.equal(existing.commands.length, 1);
});
test("a store that refuses the expiry does not stop the bucket being had", async () => {
const client = new FakeClient((command) => {
if (command instanceof HeadBucketCommand) throw s3Error("NotFound", 404);
if (command instanceof PutBucketLifecycleConfigurationCommand) throw s3Error("AccessDenied", 403);
return {};
});
const warn = console.warn;
const warnings = [];
console.warn = (message) => warnings.push(message);
try {
assert.deepEqual(await serviceWithExpiry(client, 7).ensureBucket(), { bucket: "agent-data", created: true });
} finally {
console.warn = warn;
}
assert.match(warnings[0], /7-day expiry on bucket agent-data: AccessDenied/);
});
test("rejects a negative or fractional expiry", () => {
assert.throws(() => serviceWithExpiry(new FakeClient(() => ({})), -1), /expireDays/);
assert.throws(() => serviceWithExpiry(new FakeClient(() => ({})), 1.5), /expireDays/);
});
test("leaves an existing bucket alone", async () => {
const client = new FakeClient(() => ({}));
assert.deepEqual(await service(client).ensureBucket(), { bucket: "agent-data", created: false });