Files
cloudly/ts/manager.backup/classes.replicationtarget.ts
jkunz f40ef6b7c0 chore: update cloudly dependency stack
Align Cloudly with the current typedserver, smartconfig, smartstate, and Docker tooling releases so builds and Docker output stay compatible with the upgraded stack.
2026-05-08 13:56:20 +00:00

198 lines
7.2 KiB
TypeScript

import * as plugins from '../plugins.js';
type TArchiveObject = {
path: string;
size: number;
sha256: string;
};
type TTargetType = 's3' | 'smb';
export interface IBackupTargetWriter {
targetType: TTargetType;
hasObject(pathArg: string, objectArg: TArchiveObject): Promise<boolean>;
putObject(pathArg: string, objectArg: TArchiveObject, contentsArg: Buffer): Promise<void>;
readObject(pathArg: string): Promise<Buffer>;
}
const requiredEnv = (nameArg: string) => {
const value = process.env[nameArg];
if (!value) {
throw new Error(`Missing required backup target env ${nameArg}`);
}
return value;
};
const normalizeRemotePath = (pathArg: string) => {
const normalized = plugins.path.posix
.normalize(String(pathArg || '').replace(/\\/g, '/').trim())
.replace(/^\/+/, '');
if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
throw new Error(`Invalid backup target path ${pathArg}`);
}
return normalized;
};
const getBufferSha256 = (contentsArg: Buffer) => {
return plugins.crypto.createHash('sha256').update(contentsArg).digest('hex');
};
const assertObjectMatches = (objectArg: TArchiveObject, contentsArg: Buffer, labelArg: string) => {
const sha256 = getBufferSha256(contentsArg);
if (contentsArg.length !== objectArg.size || sha256 !== objectArg.sha256) {
throw new Error(`Backup target checksum mismatch for ${labelArg}`);
}
};
const objectMatches = (objectArg: TArchiveObject, contentsArg: Buffer) => {
return contentsArg.length === objectArg.size && getBufferSha256(contentsArg) === objectArg.sha256;
};
class S3BackupTargetWriter implements IBackupTargetWriter {
public targetType: TTargetType = 's3';
private bucketPromise?: Promise<any>;
private async getBucket() {
if (!this.bucketPromise) {
this.bucketPromise = (async () => {
const smartBucket = new plugins.smartbucket.SmartBucket({
endpoint: requiredEnv('CLOUDLY_BACKUP_S3_ENDPOINT'),
accessKey: requiredEnv('CLOUDLY_BACKUP_S3_ACCESS_KEY'),
accessSecret: requiredEnv('CLOUDLY_BACKUP_S3_SECRET_KEY'),
region: process.env.CLOUDLY_BACKUP_S3_REGION || 'us-east-1',
...(process.env.CLOUDLY_BACKUP_S3_PORT
? { port: Number(process.env.CLOUDLY_BACKUP_S3_PORT) }
: {}),
...(process.env.CLOUDLY_BACKUP_S3_USE_SSL
? { useSsl: process.env.CLOUDLY_BACKUP_S3_USE_SSL !== 'false' }
: {}),
} as any);
const bucketName = requiredEnv('CLOUDLY_BACKUP_S3_BUCKET');
if (await smartBucket.bucketExists(bucketName)) {
return await smartBucket.getBucketByName(bucketName);
}
return await smartBucket.createBucket(bucketName);
})();
}
return await this.bucketPromise;
}
public async hasObject(pathArg: string, objectArg: TArchiveObject) {
try {
return objectMatches(objectArg, await this.readObject(pathArg));
} catch {
return false;
}
}
public async putObject(pathArg: string, objectArg: TArchiveObject, contentsArg: Buffer) {
const targetPath = normalizeRemotePath(pathArg);
assertObjectMatches(objectArg, contentsArg, targetPath);
const bucket = await this.getBucket();
const tempPath = `${targetPath}.upload-${Date.now()}-${plugins.smartunique.shortId()}.tmp`;
try {
await bucket.fastPut({ path: tempPath, contents: contentsArg, overwrite: true });
assertObjectMatches(objectArg, await bucket.fastGet({ path: tempPath }), tempPath);
await bucket.fastMove({ sourcePath: tempPath, destinationPath: targetPath, overwrite: true });
assertObjectMatches(objectArg, await bucket.fastGet({ path: targetPath }), targetPath);
} finally {
await bucket.fastRemove({ path: tempPath }).catch(() => {});
}
}
public async readObject(pathArg: string) {
const bucket = await this.getBucket();
return await bucket.fastGet({ path: normalizeRemotePath(pathArg) });
}
}
class SmbBackupTargetWriter implements IBackupTargetWriter {
public targetType: TTargetType = 'smb';
private clientPromise?: Promise<plugins.smartsamba.SambaClient>;
private async getClient() {
if (!this.clientPromise) {
this.clientPromise = (async () => {
const client = new plugins.smartsamba.SambaClient({
host: requiredEnv('CLOUDLY_BACKUP_SMB_HOST'),
...(process.env.CLOUDLY_BACKUP_SMB_PORT
? { port: Number(process.env.CLOUDLY_BACKUP_SMB_PORT) }
: {}),
auth: {
...(process.env.CLOUDLY_BACKUP_SMB_USERNAME
? { username: process.env.CLOUDLY_BACKUP_SMB_USERNAME }
: {}),
...(process.env.CLOUDLY_BACKUP_SMB_PASSWORD
? { password: process.env.CLOUDLY_BACKUP_SMB_PASSWORD }
: {}),
...(process.env.CLOUDLY_BACKUP_SMB_DOMAIN
? { domain: process.env.CLOUDLY_BACKUP_SMB_DOMAIN }
: {}),
},
});
await client.start();
return client;
})();
}
return await this.clientPromise;
}
private getShare() {
return requiredEnv('CLOUDLY_BACKUP_SMB_SHARE');
}
private async ensureParentDirectory(pathArg: string) {
const client = await this.getClient();
const parent = plugins.path.posix.dirname(pathArg);
if (!parent || parent === '.') {
return;
}
const parts = parent.split('/').filter(Boolean);
let current = '';
for (const part of parts) {
current = current ? `${current}/${part}` : part;
await client.createDirectory(this.getShare(), current).catch(() => {});
}
}
public async hasObject(pathArg: string, objectArg: TArchiveObject) {
try {
return objectMatches(objectArg, await this.readObject(pathArg));
} catch {
return false;
}
}
public async putObject(pathArg: string, objectArg: TArchiveObject, contentsArg: Buffer) {
const targetPath = normalizeRemotePath(pathArg);
assertObjectMatches(objectArg, contentsArg, targetPath);
const client = await this.getClient();
const share = this.getShare();
const tempPath = `${targetPath}.upload-${Date.now()}-${plugins.smartunique.shortId()}.tmp`;
await this.ensureParentDirectory(targetPath);
try {
await client.writeFile(share, tempPath, contentsArg);
assertObjectMatches(objectArg, await client.readFile(share, tempPath), tempPath);
await client.deleteFile(share, targetPath).catch(() => {});
await client.rename(share, tempPath, targetPath);
assertObjectMatches(objectArg, await client.readFile(share, targetPath), targetPath);
} finally {
await client.deleteFile(share, tempPath).catch(() => {});
}
}
public async readObject(pathArg: string) {
return await (await this.getClient()).readFile(this.getShare(), normalizeRemotePath(pathArg));
}
}
export const createBackupTargetWriterFromEnv = (): IBackupTargetWriter => {
const targetType = process.env.CLOUDLY_BACKUP_TARGET_TYPE as TTargetType | undefined;
if (targetType === 's3') {
return new S3BackupTargetWriter();
}
if (targetType === 'smb') {
return new SmbBackupTargetWriter();
}
throw new Error('No remote backup target configured. Set CLOUDLY_BACKUP_TARGET_TYPE to s3 or smb.');
};