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:
parent
deb3d81119
commit
36dd7851fb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue