fix(minio): support mc_path and harden sync against transient objects
This commit is contained in:
@@ -818,6 +818,8 @@ backup:
|
|||||||
bucket: flynn-backups
|
bucket: flynn-backups
|
||||||
prefix: flynn
|
prefix: flynn
|
||||||
secure: true
|
secure: true
|
||||||
|
# Optional absolute path to MinIO client binary if not on PATH
|
||||||
|
mc_path: /usr/local/bin/mc
|
||||||
```
|
```
|
||||||
|
|
||||||
## MinIO Share Tool
|
## MinIO Share Tool
|
||||||
@@ -1631,7 +1633,7 @@ src/
|
|||||||
|
|
||||||
## System Prompt
|
## System Prompt
|
||||||
|
|
||||||
Flynn assembles its system prompt from layered template files (`SOUL.md`, `AGENTS.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`) searched in configurable directories. The first match per file wins.
|
Flynn assembles its system prompt from layered template files (`SOUL.md`, `AGENTS.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `BOOTSTRAP.md`, `HEARTBEAT.md`) searched in configurable directories. The first match per file wins.
|
||||||
|
|
||||||
A **Runtime Context** section is automatically appended to every system prompt with the current date and time, so the model always knows when "now" is without needing a tool call.
|
A **Runtime Context** section is automatically appended to every system prompt with the current date and time, so the model always knows when "now" is without needing a tool call.
|
||||||
|
|
||||||
|
|||||||
+34
-1
@@ -5860,10 +5860,43 @@
|
|||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "docs-only change"
|
"test_status": "docs-only change"
|
||||||
|
},
|
||||||
|
"minio-mc-path-and-enoent-hardening": {
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-19",
|
||||||
|
"updated": "2026-02-19",
|
||||||
|
"summary": "Added backup-level MinIO CLI path override (`backup.minio.mc_path`) and wired it through MinIO backup upload + `minio.share`/`minio.ingest`/`minio.sync`. Added consistent ENOENT guidance so missing `mc` now returns actionable setup errors instead of raw spawn failures.",
|
||||||
|
"files_modified": [
|
||||||
|
"src/config/schema.ts",
|
||||||
|
"src/config/schema.test.ts",
|
||||||
|
"src/backup/run.ts",
|
||||||
|
"src/backup/run.test.ts",
|
||||||
|
"src/tools/builtin/minio-share.ts",
|
||||||
|
"src/tools/builtin/minio-share.test.ts",
|
||||||
|
"src/tools/builtin/minio-ingest.ts",
|
||||||
|
"src/tools/builtin/minio-ingest.test.ts",
|
||||||
|
"src/tools/builtin/minio-sync.ts",
|
||||||
|
"src/tools/builtin/minio-sync.test.ts",
|
||||||
|
"README.md",
|
||||||
|
"docs/plans/state.json"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm test:run src/config/schema.test.ts src/tools/builtin/minio-sync.test.ts src/tools/builtin/minio-ingest.test.ts src/tools/builtin/minio-share.test.ts src/backup/run.test.ts + pnpm typecheck passing"
|
||||||
|
},
|
||||||
|
"minio-sync-keep-marker-and-race-hardening": {
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-19",
|
||||||
|
"updated": "2026-02-19",
|
||||||
|
"summary": "Hardened `minio.sync` against noisy object listings and race conditions by skipping `.keep` marker objects and treating missing-object read errors (objects deleted after listing) as per-object skips instead of failing the entire sync task.",
|
||||||
|
"files_modified": [
|
||||||
|
"src/tools/builtin/minio-sync.ts",
|
||||||
|
"src/tools/builtin/minio-sync.test.ts",
|
||||||
|
"docs/plans/state.json"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm test:run src/tools/builtin/minio-sync.test.ts + pnpm typecheck passing"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1933,
|
"total_test_count": 1941,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
|
|||||||
@@ -18,4 +18,16 @@ describe('backup internals', () => {
|
|||||||
expect(backupInternals.buildObjectKey('/flynn/daily/', 'a.tar.gz')).toBe('flynn/daily/a.tar.gz');
|
expect(backupInternals.buildObjectKey('/flynn/daily/', 'a.tar.gz')).toBe('flynn/daily/a.tar.gz');
|
||||||
expect(backupInternals.buildObjectKey('', 'a.tar.gz')).toBe('a.tar.gz');
|
expect(backupInternals.buildObjectKey('', 'a.tar.gz')).toBe('a.tar.gz');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves custom mc path with fallback', () => {
|
||||||
|
expect(backupInternals.resolveMcPath('/usr/local/bin/mc')).toBe('/usr/local/bin/mc');
|
||||||
|
expect(backupInternals.resolveMcPath('')).toBe('mc');
|
||||||
|
expect(backupInternals.resolveMcPath(undefined)).toBe('mc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats missing mc binary errors with setup hint', () => {
|
||||||
|
const error = new Error('spawn mc ENOENT') as Error & { code?: string };
|
||||||
|
error.code = 'ENOENT';
|
||||||
|
expect(backupInternals.formatMinioCliError(error, '/custom/mc')).toContain('MinIO client binary not found: /custom/mc');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+26
-3
@@ -61,6 +61,21 @@ function buildObjectKey(prefix: string, fileName: string): string {
|
|||||||
return trimmed.length > 0 ? `${trimmed}/${fileName}` : fileName;
|
return trimmed.length > 0 ? `${trimmed}/${fileName}` : fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMcPath(pathValue: string | undefined): string {
|
||||||
|
const trimmed = pathValue?.trim();
|
||||||
|
return trimmed && trimmed.length > 0 ? trimmed : 'mc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinioCliError(error: unknown, mcPath: string): string {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'ENOENT') {
|
||||||
|
return `MinIO client binary not found: ${mcPath}. Install MinIO Client (mc) or set \`backup.minio.mc_path\` to the full binary path.`;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
function collectExistingEntries(opts: {
|
function collectExistingEntries(opts: {
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
backupConfig: BackupConfig;
|
backupConfig: BackupConfig;
|
||||||
@@ -137,12 +152,18 @@ export async function runBackupSnapshot(opts: BackupRunOptions): Promise<BackupR
|
|||||||
...process.env,
|
...process.env,
|
||||||
[`MC_HOST_${alias}`]: host,
|
[`MC_HOST_${alias}`]: host,
|
||||||
};
|
};
|
||||||
|
const mcPath = resolveMcPath(opts.backupConfig.minio.mc_path);
|
||||||
|
|
||||||
await execFileAsync('mc', ['mb', '--ignore-existing', `${alias}/${bucket}`], { env });
|
let remotePath = '';
|
||||||
|
try {
|
||||||
|
await execFileAsync(mcPath, ['mb', '--ignore-existing', `${alias}/${bucket}`], { env });
|
||||||
|
|
||||||
const objectKey = buildObjectKey(opts.backupConfig.minio.prefix, fileName);
|
const objectKey = buildObjectKey(opts.backupConfig.minio.prefix, fileName);
|
||||||
const remotePath = `${alias}/${bucket}/${objectKey}`;
|
remotePath = `${alias}/${bucket}/${objectKey}`;
|
||||||
await execFileAsync('mc', ['cp', archivePath, remotePath], { env });
|
await execFileAsync(mcPath, ['cp', archivePath, remotePath], { env });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatMinioCliError(error, mcPath));
|
||||||
|
}
|
||||||
|
|
||||||
auditLogger?.systemConfig('backup', 'upload', {
|
auditLogger?.systemConfig('backup', 'upload', {
|
||||||
archive_path: archivePath,
|
archive_path: archivePath,
|
||||||
@@ -164,5 +185,7 @@ export const backupInternals = {
|
|||||||
buildObjectKey,
|
buildObjectKey,
|
||||||
collectExistingEntries,
|
collectExistingEntries,
|
||||||
expandHomePath,
|
expandHomePath,
|
||||||
|
formatMinioCliError,
|
||||||
fileTimestamp,
|
fileTimestamp,
|
||||||
|
resolveMcPath,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ describe('configSchema — backup', () => {
|
|||||||
expect(result.backup.minio.enabled).toBe(false);
|
expect(result.backup.minio.enabled).toBe(false);
|
||||||
expect(result.backup.minio.prefix).toBe('flynn');
|
expect(result.backup.minio.prefix).toBe('flynn');
|
||||||
expect(result.backup.minio.secure).toBe(true);
|
expect(result.backup.minio.secure).toBe(true);
|
||||||
|
expect(result.backup.minio.mc_path).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts custom backup settings', () => {
|
it('accepts custom backup settings', () => {
|
||||||
@@ -307,6 +308,7 @@ describe('configSchema — backup', () => {
|
|||||||
bucket: 'flynn-backups',
|
bucket: 'flynn-backups',
|
||||||
prefix: 'daily',
|
prefix: 'daily',
|
||||||
secure: false,
|
secure: false,
|
||||||
|
mc_path: '/usr/local/bin/mc',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -325,6 +327,7 @@ describe('configSchema — backup', () => {
|
|||||||
expect(result.backup.minio.bucket).toBe('flynn-backups');
|
expect(result.backup.minio.bucket).toBe('flynn-backups');
|
||||||
expect(result.backup.minio.prefix).toBe('daily');
|
expect(result.backup.minio.prefix).toBe('daily');
|
||||||
expect(result.backup.minio.secure).toBe(false);
|
expect(result.backup.minio.secure).toBe(false);
|
||||||
|
expect(result.backup.minio.mc_path).toBe('/usr/local/bin/mc');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -940,6 +940,7 @@ const backupSchema = z.object({
|
|||||||
bucket: z.string().optional(),
|
bucket: z.string().optional(),
|
||||||
prefix: z.string().default('flynn'),
|
prefix: z.string().default('flynn'),
|
||||||
secure: z.boolean().default(true),
|
secure: z.boolean().default(true),
|
||||||
|
mc_path: z.string().optional(),
|
||||||
}).default({}),
|
}).default({}),
|
||||||
}).default({});
|
}).default({});
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,18 @@ describe('createMinioIngestTool', () => {
|
|||||||
stderr: '',
|
stderr: '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const tool = createMinioIngestTool(makeBackupConfig(), store, {
|
const tool = createMinioIngestTool(makeBackupConfig({
|
||||||
|
minio: {
|
||||||
|
enabled: true,
|
||||||
|
endpoint: 'localhost:9000',
|
||||||
|
access_key: 'minio-admin',
|
||||||
|
secret_key: 'minio-secret',
|
||||||
|
bucket: 'flynn-knowledge',
|
||||||
|
prefix: 'flynn',
|
||||||
|
secure: false,
|
||||||
|
mc_path: '/opt/bin/mc',
|
||||||
|
},
|
||||||
|
}), store, {
|
||||||
execRunner,
|
execRunner,
|
||||||
now: () => new Date('2026-02-16T15:00:00.000Z'),
|
now: () => new Date('2026-02-16T15:00:00.000Z'),
|
||||||
});
|
});
|
||||||
@@ -68,7 +79,7 @@ describe('createMinioIngestTool', () => {
|
|||||||
'append',
|
'append',
|
||||||
);
|
);
|
||||||
expect(execRunner).toHaveBeenCalledWith(
|
expect(execRunner).toHaveBeenCalledWith(
|
||||||
'mc',
|
'/opt/bin/mc',
|
||||||
['cat', 'flynningest/flynn-knowledge/knowledge/runbook.md'],
|
['cat', 'flynningest/flynn-knowledge/knowledge/runbook.md'],
|
||||||
expect.objectContaining({ env: expect.any(Object) }),
|
expect.objectContaining({ env: expect.any(Object) }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,20 +58,21 @@ function isExtractableBinaryObject(objectKey: string): boolean {
|
|||||||
|
|
||||||
async function readObjectText(
|
async function readObjectText(
|
||||||
runner: ExecRunner,
|
runner: ExecRunner,
|
||||||
|
mcPath: string,
|
||||||
remotePath: string,
|
remotePath: string,
|
||||||
objectKey: string,
|
objectKey: string,
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const ext = extname(objectKey).toLowerCase();
|
const ext = extname(objectKey).toLowerCase();
|
||||||
if (!isExtractableBinaryObject(objectKey)) {
|
if (!isExtractableBinaryObject(objectKey)) {
|
||||||
const { stdout } = await runner('mc', ['cat', remotePath], { env, maxBuffer: 20 * 1024 * 1024 });
|
const { stdout } = await runner(mcPath, ['cat', remotePath], { env, maxBuffer: 20 * 1024 * 1024 });
|
||||||
return toText(stdout);
|
return toText(stdout);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'flynn-minio-ingest-'));
|
const tempDir = mkdtempSync(join(tmpdir(), 'flynn-minio-ingest-'));
|
||||||
const localPath = join(tempDir, 'object.bin');
|
const localPath = join(tempDir, 'object.bin');
|
||||||
try {
|
try {
|
||||||
await runner('mc', ['cp', remotePath, localPath], { env, maxBuffer: 20 * 1024 * 1024 });
|
await runner(mcPath, ['cp', remotePath, localPath], { env, maxBuffer: 20 * 1024 * 1024 });
|
||||||
if (ext === '.pdf') {
|
if (ext === '.pdf') {
|
||||||
const { stdout } = await runner('pdftotext', ['-q', localPath, '-'], { maxBuffer: 20 * 1024 * 1024 });
|
const { stdout } = await runner('pdftotext', ['-q', localPath, '-'], { maxBuffer: 20 * 1024 * 1024 });
|
||||||
return toText(stdout);
|
return toText(stdout);
|
||||||
@@ -187,13 +188,14 @@ export function createMinioIngestTool(config: BackupConfig, store: MemoryStore,
|
|||||||
secure: minio.secure,
|
secure: minio.secure,
|
||||||
});
|
});
|
||||||
const env = { ...process.env, [`MC_HOST_${alias}`]: host };
|
const env = { ...process.env, [`MC_HOST_${alias}`]: host };
|
||||||
|
const mcPath = backupInternals.resolveMcPath(minio.mc_path);
|
||||||
const runner = deps?.execRunner ?? (async (file: string, cmdArgs: string[], options?: { env?: NodeJS.ProcessEnv; maxBuffer?: number }) => {
|
const runner = deps?.execRunner ?? (async (file: string, cmdArgs: string[], options?: { env?: NodeJS.ProcessEnv; maxBuffer?: number }) => {
|
||||||
return execFileAsync(file, cmdArgs, options);
|
return execFileAsync(file, cmdArgs, options);
|
||||||
});
|
});
|
||||||
const remotePath = `${alias}/${bucket}/${objectKey}`;
|
const remotePath = `${alias}/${bucket}/${objectKey}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await readObjectText(runner, remotePath, objectKey, env);
|
const text = await readObjectText(runner, mcPath, remotePath, objectKey, env);
|
||||||
|
|
||||||
if (!force && !isExtractableBinaryObject(objectKey) && !isLikelyText(text)) {
|
if (!force && !isExtractableBinaryObject(objectKey) && !isLikelyText(text)) {
|
||||||
return {
|
return {
|
||||||
@@ -227,7 +229,7 @@ export function createMinioIngestTool(config: BackupConfig, store: MemoryStore,
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
output: '',
|
output: '',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: backupInternals.formatMinioCliError(error, mcPath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -63,7 +63,18 @@ describe('createMinioShareTool', () => {
|
|||||||
return { stdout: '', stderr: '' };
|
return { stdout: '', stderr: '' };
|
||||||
});
|
});
|
||||||
|
|
||||||
const tool = createMinioShareTool(makeBackupConfig(), {
|
const tool = createMinioShareTool(makeBackupConfig({
|
||||||
|
minio: {
|
||||||
|
enabled: true,
|
||||||
|
endpoint: 'localhost:9000',
|
||||||
|
access_key: 'minio-admin',
|
||||||
|
secret_key: 'minio-secret',
|
||||||
|
bucket: 'flynn-shared',
|
||||||
|
prefix: 'flynn',
|
||||||
|
secure: false,
|
||||||
|
mc_path: '/opt/bin/mc',
|
||||||
|
},
|
||||||
|
}), {
|
||||||
execRunner,
|
execRunner,
|
||||||
now: () => new Date('2026-02-16T10:00:00.000Z'),
|
now: () => new Date('2026-02-16T10:00:00.000Z'),
|
||||||
});
|
});
|
||||||
@@ -72,6 +83,7 @@ describe('createMinioShareTool', () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.output).toContain('https://minio.local/share/abc');
|
expect(result.output).toContain('https://minio.local/share/abc');
|
||||||
expect(execRunner).toHaveBeenCalledTimes(3);
|
expect(execRunner).toHaveBeenCalledTimes(3);
|
||||||
|
expect(execRunner.mock.calls[0]?.[0]).toBe('/opt/bin/mc');
|
||||||
expect(execRunner.mock.calls[1]?.[1]).toContain('cp');
|
expect(execRunner.mock.calls[1]?.[1]).toContain('cp');
|
||||||
expect(execRunner.mock.calls[2]?.[1]).toContain('share');
|
expect(execRunner.mock.calls[2]?.[1]).toContain('share');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,14 +116,15 @@ export function createMinioShareTool(config: BackupConfig, deps?: MinioShareDeps
|
|||||||
secure: minio.secure,
|
secure: minio.secure,
|
||||||
});
|
});
|
||||||
const env = { ...process.env, [`MC_HOST_${alias}`]: host };
|
const env = { ...process.env, [`MC_HOST_${alias}`]: host };
|
||||||
|
const mcPath = backupInternals.resolveMcPath(minio.mc_path);
|
||||||
const runner = deps?.execRunner ?? (async (file: string, cmdArgs: string[], options?: { env?: NodeJS.ProcessEnv }) => {
|
const runner = deps?.execRunner ?? (async (file: string, cmdArgs: string[], options?: { env?: NodeJS.ProcessEnv }) => {
|
||||||
return execFileAsync(file, cmdArgs, options);
|
return execFileAsync(file, cmdArgs, options);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runner('mc', ['mb', '--ignore-existing', `${alias}/${minio.bucket}`], { env });
|
await runner(mcPath, ['mb', '--ignore-existing', `${alias}/${minio.bucket}`], { env });
|
||||||
await runner('mc', ['cp', localPath, remotePath], { env });
|
await runner(mcPath, ['cp', localPath, remotePath], { env });
|
||||||
const { stdout } = await runner('mc', ['share', 'download', '--json', '--expire', expires, remotePath], { env });
|
const { stdout } = await runner(mcPath, ['share', 'download', '--json', '--expire', expires, remotePath], { env });
|
||||||
const shareUrl = parseShareUrl(typeof stdout === 'string' ? stdout : stdout.toString('utf-8'));
|
const shareUrl = parseShareUrl(typeof stdout === 'string' ? stdout : stdout.toString('utf-8'));
|
||||||
if (!shareUrl) {
|
if (!shareUrl) {
|
||||||
return { success: false, output: '', error: 'Failed to parse MinIO share URL from mc output' };
|
return { success: false, output: '', error: 'Failed to parse MinIO share URL from mc output' };
|
||||||
@@ -137,7 +138,7 @@ export function createMinioShareTool(config: BackupConfig, deps?: MinioShareDeps
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
output: '',
|
output: '',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: backupInternals.formatMinioCliError(error, mcPath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ describe('minio sync internals', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores keep-marker objects from recursive listings', () => {
|
||||||
|
const stdout = [
|
||||||
|
'{"status":"success","type":"file","key":".keep"}',
|
||||||
|
'{"status":"success","type":"file","key":"reports/.keep"}',
|
||||||
|
'{"status":"success","type":"file","key":"reports/daily.md"}',
|
||||||
|
].join('\n');
|
||||||
|
expect(minioSyncInternals.parseListedObjectKeys(stdout)).toEqual([
|
||||||
|
'reports/daily.md',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes object paths into namespace-safe segments', () => {
|
it('normalizes object paths into namespace-safe segments', () => {
|
||||||
expect(minioSyncInternals.normalizeNamespaceSegment('knowledge/team runbook.v2.md')).toBe('knowledge/team_runbook_v2');
|
expect(minioSyncInternals.normalizeNamespaceSegment('knowledge/team runbook.v2.md')).toBe('knowledge/team_runbook_v2');
|
||||||
});
|
});
|
||||||
@@ -163,4 +174,89 @@ describe('createMinioSyncTool', () => {
|
|||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain('backup.minio.enabled=true');
|
expect(result.error).toContain('backup.minio.enabled=true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses configured backup.minio.mc_path and returns setup hint when missing', async () => {
|
||||||
|
const write = vi.fn();
|
||||||
|
const store = { write } as unknown as MemoryStore;
|
||||||
|
const execRunner = vi.fn(async () => {
|
||||||
|
const error = new Error('spawn /opt/minio/mc ENOENT') as Error & { code?: string };
|
||||||
|
error.code = 'ENOENT';
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createMinioSyncTool(makeBackupConfig({
|
||||||
|
minio: {
|
||||||
|
enabled: true,
|
||||||
|
endpoint: 'localhost:9000',
|
||||||
|
access_key: 'minio-admin',
|
||||||
|
secret_key: 'minio-secret',
|
||||||
|
bucket: 'flynn-knowledge',
|
||||||
|
prefix: 'flynn',
|
||||||
|
secure: false,
|
||||||
|
mc_path: '/opt/minio/mc',
|
||||||
|
},
|
||||||
|
}), store, { execRunner });
|
||||||
|
|
||||||
|
const result = await tool.execute({ prefix: 'knowledge/' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('MinIO client binary not found: /opt/minio/mc');
|
||||||
|
expect(result.error).toContain('backup.minio.mc_path');
|
||||||
|
expect(execRunner).toHaveBeenCalledWith(
|
||||||
|
'/opt/minio/mc',
|
||||||
|
['ls', '--json', '--recursive', 'flynnsync/flynn-knowledge/knowledge/'],
|
||||||
|
expect.objectContaining({ env: expect.any(Object) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips objects that disappear between ls and read', async () => {
|
||||||
|
const write = vi.fn();
|
||||||
|
const store = { write } as unknown as MemoryStore;
|
||||||
|
const execRunner = vi.fn(async (_file: string, args: string[]) => {
|
||||||
|
if (args[0] === 'ls') {
|
||||||
|
return {
|
||||||
|
stdout: [
|
||||||
|
'{"status":"success","type":"file","key":"reports/.keep"}',
|
||||||
|
'{"status":"success","type":"file","key":"reports/summary.md"}',
|
||||||
|
].join('\n'),
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (args[0] === 'cat' && args[1]?.endsWith('reports/summary.md')) {
|
||||||
|
return { stdout: 'OK summary', stderr: '' };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected call: ${args.join(' ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createMinioSyncTool(makeBackupConfig(), store, { execRunner });
|
||||||
|
const result = await tool.execute({ prefix: 'reports/' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.output).toContain('Scanned: 1 object(s)');
|
||||||
|
expect(result.output).toContain('Imported: 1');
|
||||||
|
expect(result.output).toContain('Skipped: 0');
|
||||||
|
expect(write).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats missing-object read errors as skip instead of task failure', async () => {
|
||||||
|
const write = vi.fn();
|
||||||
|
const store = { write } as unknown as MemoryStore;
|
||||||
|
const execRunner = vi.fn(async (_file: string, args: string[]) => {
|
||||||
|
if (args[0] === 'ls') {
|
||||||
|
return {
|
||||||
|
stdout: '{"status":"success","type":"file","key":"reports/missing.md"}',
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (args[0] === 'cat') {
|
||||||
|
throw new Error('mcli: <ERROR> Unable to read from flynnsync/flynn/reports/missing.md. Object does not exist.');
|
||||||
|
}
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createMinioSyncTool(makeBackupConfig(), store, { execRunner });
|
||||||
|
const result = await tool.execute({ prefix: 'reports/' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.output).toContain('Imported: 0');
|
||||||
|
expect(result.output).toContain('Skipped: 1');
|
||||||
|
expect(write).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function parseListedObjectKeys(stdout: string): string[] {
|
|||||||
if (!key) {continue;}
|
if (!key) {continue;}
|
||||||
if (type && type !== 'file') {continue;}
|
if (type && type !== 'file') {continue;}
|
||||||
if (key.endsWith('/')) {continue;}
|
if (key.endsWith('/')) {continue;}
|
||||||
|
if (key === '.keep' || key.endsWith('/.keep')) {continue;}
|
||||||
keys.push(key);
|
keys.push(key);
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
@@ -52,6 +53,15 @@ function parseListedObjectKeys(stdout: string): string[] {
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBenignMissingObjectError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const lowered = message.toLowerCase();
|
||||||
|
return lowered.includes('object does not exist')
|
||||||
|
|| lowered.includes('unable to read from')
|
||||||
|
|| lowered.includes('no such key')
|
||||||
|
|| lowered.includes('not found');
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNamespaceSegment(value: string): string {
|
function normalizeNamespaceSegment(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/\.[^.]+$/, '')
|
.replace(/\.[^.]+$/, '')
|
||||||
@@ -61,6 +71,7 @@ function normalizeNamespaceSegment(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const minioSyncInternals = {
|
export const minioSyncInternals = {
|
||||||
|
isBenignMissingObjectError,
|
||||||
parseListedObjectKeys,
|
parseListedObjectKeys,
|
||||||
normalizeNamespaceSegment,
|
normalizeNamespaceSegment,
|
||||||
};
|
};
|
||||||
@@ -137,13 +148,14 @@ export function createMinioSyncTool(config: BackupConfig, store: MemoryStore, de
|
|||||||
secure: minio.secure,
|
secure: minio.secure,
|
||||||
});
|
});
|
||||||
const env = { ...process.env, [`MC_HOST_${alias}`]: host };
|
const env = { ...process.env, [`MC_HOST_${alias}`]: host };
|
||||||
|
const mcPath = backupInternals.resolveMcPath(minio.mc_path);
|
||||||
const runner = deps?.execRunner ?? (async (file: string, cmdArgs: string[], options?: { env?: NodeJS.ProcessEnv; maxBuffer?: number }) => {
|
const runner = deps?.execRunner ?? (async (file: string, cmdArgs: string[], options?: { env?: NodeJS.ProcessEnv; maxBuffer?: number }) => {
|
||||||
return execFileAsync(file, cmdArgs, options);
|
return execFileAsync(file, cmdArgs, options);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const basePath = `${alias}/${bucket}/${prefix}`;
|
const basePath = `${alias}/${bucket}/${prefix}`;
|
||||||
const { stdout: listed } = await runner('mc', ['ls', '--json', '--recursive', basePath], {
|
const { stdout: listed } = await runner(mcPath, ['ls', '--json', '--recursive', basePath], {
|
||||||
env,
|
env,
|
||||||
maxBuffer: 20 * 1024 * 1024,
|
maxBuffer: 20 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
@@ -167,8 +179,17 @@ export function createMinioSyncTool(config: BackupConfig, store: MemoryStore, de
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
const remotePath = `${alias}/${bucket}/${key}`;
|
const remotePath = `${alias}/${bucket}/${key}`;
|
||||||
const text = await minioIngestInternals.readObjectText(runner, remotePath, key, env);
|
text = await minioIngestInternals.readObjectText(runner, mcPath, remotePath, key, env);
|
||||||
|
} catch (error) {
|
||||||
|
if (isBenignMissingObjectError(error)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
if (!force && !minioIngestInternals.isExtractableBinaryObject(key) && !minioIngestInternals.isLikelyText(text)) {
|
if (!force && !minioIngestInternals.isExtractableBinaryObject(key) && !minioIngestInternals.isLikelyText(text)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
@@ -207,7 +228,7 @@ export function createMinioSyncTool(config: BackupConfig, store: MemoryStore, de
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
output: '',
|
output: '',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: backupInternals.formatMinioCliError(error, mcPath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user