105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
|
|
/**
|
||
|
|
* S3-compatible backup transport — pushes the bundle to any S3 API endpoint
|
||
|
|
* (AWS S3, Backblaze B2, Wasabi, Cloudflare R2, MinIO). Reuses the `minio`
|
||
|
|
* client the storage backend already depends on, so no new SDK.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import path from 'node:path';
|
||
|
|
|
||
|
|
import { Client as MinioClient } from 'minio';
|
||
|
|
|
||
|
|
import {
|
||
|
|
BACKUP_NAME_PREFIX,
|
||
|
|
sortBundlesNewestFirst,
|
||
|
|
type BackupTransport,
|
||
|
|
type S3DestConfig,
|
||
|
|
} from './types';
|
||
|
|
|
||
|
|
/** Split a configured endpoint (host or URL) into minio's endPoint/port/useSSL. */
|
||
|
|
export function parseS3Endpoint(
|
||
|
|
endpoint: string,
|
||
|
|
cfg: { useSSL?: boolean; port?: number },
|
||
|
|
): { endPoint: string; port?: number; useSSL: boolean } {
|
||
|
|
let host = endpoint.trim();
|
||
|
|
let useSSL = cfg.useSSL ?? true;
|
||
|
|
let port = cfg.port;
|
||
|
|
const m = /^(https?):\/\/([^/:]+)(?::(\d+))?/i.exec(host);
|
||
|
|
if (m) {
|
||
|
|
useSSL = m[1]!.toLowerCase() === 'https';
|
||
|
|
host = m[2]!;
|
||
|
|
if (m[3]) port = Number(m[3]);
|
||
|
|
} else {
|
||
|
|
host = host.replace(/\/.*$/, '');
|
||
|
|
}
|
||
|
|
return { endPoint: host, ...(port ? { port } : {}), useSSL };
|
||
|
|
}
|
||
|
|
|
||
|
|
export class S3Transport implements BackupTransport {
|
||
|
|
private readonly prefix: string;
|
||
|
|
|
||
|
|
constructor(private readonly cfg: S3DestConfig) {
|
||
|
|
// Normalise prefix to "" or "dir/".
|
||
|
|
const p = (cfg.prefix ?? '').replace(/^\/+|\/+$/g, '');
|
||
|
|
this.prefix = p ? `${p}/` : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
private client(): MinioClient {
|
||
|
|
const { endPoint, port, useSSL } = parseS3Endpoint(this.cfg.endpoint, {
|
||
|
|
useSSL: this.cfg.useSSL,
|
||
|
|
port: this.cfg.port,
|
||
|
|
});
|
||
|
|
return new MinioClient({
|
||
|
|
endPoint,
|
||
|
|
...(port ? { port } : {}),
|
||
|
|
useSSL,
|
||
|
|
accessKey: this.cfg.accessKey,
|
||
|
|
secretKey: this.cfg.secretKey,
|
||
|
|
...(this.cfg.region ? { region: this.cfg.region } : {}),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async test(): Promise<void> {
|
||
|
|
const exists = await this.client().bucketExists(this.cfg.bucket);
|
||
|
|
if (!exists) throw new Error(`Bucket not found or not accessible: ${this.cfg.bucket}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
async push(localPath: string, remoteName: string): Promise<{ remoteRef: string; bytes: number }> {
|
||
|
|
const key = `${this.prefix}${remoteName}`;
|
||
|
|
await this.client().fPutObject(this.cfg.bucket, key, localPath, {
|
||
|
|
'Content-Type': 'application/x-tar',
|
||
|
|
});
|
||
|
|
const { stat } = await import('node:fs/promises');
|
||
|
|
const s = await stat(localPath);
|
||
|
|
return { remoteRef: `s3://${this.cfg.bucket}/${key}`, bytes: s.size };
|
||
|
|
}
|
||
|
|
|
||
|
|
async prune(retentionCount: number | null): Promise<{ deleted: number }> {
|
||
|
|
if (retentionCount === null || retentionCount < 0) return { deleted: 0 };
|
||
|
|
const client = this.client();
|
||
|
|
const names = await this.listBundleKeys(client);
|
||
|
|
const sorted = sortBundlesNewestFirst(names.map((k) => path.posix.basename(k)));
|
||
|
|
const keepBasenames = new Set(sorted.slice(0, retentionCount));
|
||
|
|
const toDelete = names.filter(
|
||
|
|
(k) =>
|
||
|
|
path.posix.basename(k).startsWith(BACKUP_NAME_PREFIX) &&
|
||
|
|
!keepBasenames.has(path.posix.basename(k)),
|
||
|
|
);
|
||
|
|
for (const key of toDelete) await client.removeObject(this.cfg.bucket, key);
|
||
|
|
return { deleted: toDelete.length };
|
||
|
|
}
|
||
|
|
|
||
|
|
private listBundleKeys(client: MinioClient): Promise<string[]> {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const keys: string[] = [];
|
||
|
|
const stream = client.listObjectsV2(this.cfg.bucket, this.prefix, true);
|
||
|
|
stream.on('data', (obj) => {
|
||
|
|
if (obj.name && path.posix.basename(obj.name).startsWith(BACKUP_NAME_PREFIX)) {
|
||
|
|
keys.push(obj.name);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
stream.on('error', reject);
|
||
|
|
stream.on('end', () => resolve(keys));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|