feat(cloud-backup): mirror fleet snapshots to S3-compatible storage (#782)

* feat(cloud-backup): mirror fleet snapshots to S3-compatible storage

Add an Admiral-tier Cloud Backup feature that replicates every fleet
snapshot to off-site storage, with two provider modes that share the
same `@aws-sdk/client-s3` code path:

- Sencho Cloud Backup: zero-config, 500 MB allowance backed by
  Cloudflare R2, provisioned via the sencho.io worker against the
  user's Lemon Squeezy license.
- Custom S3 (BYOB): any S3-compatible bucket (AWS, MinIO, Backblaze
  B2, Wasabi, R2 with own keys), with credentials encrypted via
  `CryptoService` before storage.

API-triggered snapshots upload fire-and-forget so the UI returns
immediately; scheduled snapshots block on the upload so the task's
success/failure reflects cloud durability. Object keys include the
instance_id segment to prevent collisions when the same Admiral
license is activated on multiple Sencho instances.

* fix(cloud-backup): drop ES2022-only Error cause arg breaking ES2020 build

The backend tsconfig pins lib to ES2020. The two-argument
`Error(message, { cause })` form requires ES2022, so tsc rejected it
with TS2554. Revert to single-argument throw to match the
convention used elsewhere in the backend services.
This commit is contained in:
Anso
2026-04-26 15:42:21 -04:00
committed by GitHub
parent 801a098a5b
commit 03f91cd5bb
20 changed files with 2805 additions and 401 deletions
+1016 -393
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -28,6 +28,7 @@
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@types/node": "^25.3.0", "@types/node": "^25.3.0",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@types/tar-stream": "^3.1.4",
"@types/yaml": "^1.9.6", "@types/yaml": "^1.9.6",
"eslint": "^10.1.0", "eslint": "^10.1.0",
"nodemon": "^3.1.13", "nodemon": "^3.1.13",
@@ -39,6 +40,7 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-ecr": "^3.1019.0", "@aws-sdk/client-ecr": "^3.1019.0",
"@aws-sdk/client-s3": "^3.1037.0",
"@types/compression": "^1.8.1", "@types/compression": "^1.8.1",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/dockerode": "^4.0.1", "@types/dockerode": "^4.0.1",
@@ -69,6 +71,7 @@
"otplib": "^12.0.1", "otplib": "^12.0.1",
"semver": "^7.7.4", "semver": "^7.7.4",
"systeminformation": "^5.31.1", "systeminformation": "^5.31.1",
"tar-stream": "^3.1.8",
"ws": "^8.19.0", "ws": "^8.19.0",
"yaml": "^2.8.2", "yaml": "^2.8.2",
"zod": "^4.3.6" "zod": "^4.3.6"
@@ -0,0 +1,193 @@
/**
* Tests for /api/cloud-backup routes — tier gating (community/skipper/admiral),
* admin gating, config CRUD round-trip with secret encryption, audit logging.
* The S3 SDK is mocked at the module level so no network calls happen.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
const sentSpy = vi.fn();
vi.mock('@aws-sdk/client-s3', () => {
class S3Client { async send(cmd: { name: string; input: Record<string, unknown> }) { return sentSpy(cmd); } }
class PutObjectCommand { name = 'PutObjectCommand'; constructor(public input: Record<string, unknown>) {} }
class GetObjectCommand { name = 'GetObjectCommand'; constructor(public input: Record<string, unknown>) {} }
class ListObjectsV2Command { name = 'ListObjectsV2Command'; constructor(public input: Record<string, unknown>) {} }
class DeleteObjectCommand { name = 'DeleteObjectCommand'; constructor(public input: Record<string, unknown>) {} }
class HeadBucketCommand { name = 'HeadBucketCommand'; constructor(public input: Record<string, unknown>) {} }
return { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand, HeadBucketCommand };
});
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let authCookie: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ LicenseService } = await import('../services/LicenseService'));
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
sentSpy.mockReset();
const db = DatabaseService.getInstance();
for (const k of [
'cloud_backup_provider',
'cloud_backup_endpoint',
'cloud_backup_region',
'cloud_backup_bucket',
'cloud_backup_access_key',
'cloud_backup_secret_key',
'cloud_backup_path_prefix',
'cloud_backup_auto_upload',
]) {
db.updateGlobalSetting(k, '');
}
});
describe('Cloud backup tier gating', () => {
it('rejects community tier with PAID_REQUIRED', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('rejects skipper tier with ADMIRAL_REQUIRED', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValueOnce('skipper');
const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
});
it('admiral tier reaches the handler', async () => {
const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('provider', 'disabled');
});
});
describe('Cloud backup config CRUD', () => {
it('redacts secret_key on read; persists encrypted ciphertext', async () => {
const putRes = await request(app)
.put('/api/cloud-backup/config')
.set('Cookie', authCookie)
.send({
provider: 'custom',
custom: {
endpoint: 'https://s3.example.com',
region: 'us-east-1',
bucket: 'b',
access_key: 'AKIA1234',
secret_key: 'super-secret',
path_prefix: 'sencho/',
auto_upload: true,
},
});
expect(putRes.status).toBe(204);
const stored = DatabaseService.getInstance().getGlobalSettings().cloud_backup_secret_key;
expect(stored.startsWith('enc:')).toBe(true);
expect(stored.includes('super-secret')).toBe(false);
const getRes = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
expect(getRes.status).toBe(200);
expect(getRes.body.provider).toBe('custom');
expect(getRes.body.custom.secret_key).toBe('***');
expect(getRes.body.custom.bucket).toBe('b');
});
it('preserves saved secret when client sends "***"', async () => {
const db = DatabaseService.getInstance();
await request(app)
.put('/api/cloud-backup/config')
.set('Cookie', authCookie)
.send({
provider: 'custom',
custom: { endpoint: 'https://e', region: 'r', bucket: 'b', access_key: 'a', secret_key: 'first-secret', path_prefix: 's/', auto_upload: false },
});
const firstStored = db.getGlobalSettings().cloud_backup_secret_key;
await request(app)
.put('/api/cloud-backup/config')
.set('Cookie', authCookie)
.send({
provider: 'custom',
custom: { endpoint: 'https://e', region: 'r2', bucket: 'b', access_key: 'a', secret_key: '***', path_prefix: 's/', auto_upload: true },
});
const secondStored = db.getGlobalSettings().cloud_backup_secret_key;
expect(secondStored).toBe(firstStored);
expect(db.getGlobalSettings().cloud_backup_region).toBe('r2');
expect(db.getGlobalSettings().cloud_backup_auto_upload).toBe('1');
});
it('rejects invalid provider value', async () => {
const res = await request(app)
.put('/api/cloud-backup/config')
.set('Cookie', authCookie)
.send({ provider: 'bogus' });
expect(res.status).toBe(400);
});
it('rejects custom config missing required fields', async () => {
const res = await request(app)
.put('/api/cloud-backup/config')
.set('Cookie', authCookie)
.send({ provider: 'custom', custom: { endpoint: '', bucket: '', access_key: '' } });
expect(res.status).toBe(400);
});
});
describe('Cloud backup audit log', () => {
it('writes audit row with the cloud-backup summary on PUT /config', async () => {
await request(app)
.put('/api/cloud-backup/config')
.set('Cookie', authCookie)
.send({
provider: 'custom',
custom: { endpoint: 'https://s3.example.com', region: 'r', bucket: 'b', access_key: 'a', secret_key: 's', path_prefix: 'p/', auto_upload: false },
});
const { entries } = DatabaseService.getInstance().getAuditLogs({ limit: 50 });
const cloudEntry = entries.find(e => e.path.includes('/cloud-backup/config') && e.method === 'PUT');
expect(cloudEntry).toBeDefined();
expect(cloudEntry!.summary).toBe('Updated cloud backup config');
});
});
describe('Cloud backup test endpoint', () => {
it('reports failure when no provider is configured', async () => {
const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie).send({});
expect(res.status).toBe(200);
expect(res.body.success).toBe(false);
});
it('reports success when HeadBucketCommand resolves', async () => {
const db = DatabaseService.getInstance();
const { CryptoService } = await import('../services/CryptoService');
db.updateGlobalSetting('cloud_backup_provider', 'custom');
db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com');
db.updateGlobalSetting('cloud_backup_region', 'us-east-1');
db.updateGlobalSetting('cloud_backup_bucket', 'b');
db.updateGlobalSetting('cloud_backup_access_key', 'a');
db.updateGlobalSetting('cloud_backup_secret_key', CryptoService.getInstance().encrypt('s'));
sentSpy.mockResolvedValueOnce({});
const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie).send({});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
@@ -0,0 +1,242 @@
/**
* Tests for CloudBackupService — provider resolution, encryption round-trip,
* archive format, and S3 client invocation. The S3 SDK is mocked at the
* module level so no network calls happen.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import * as zlib from 'zlib';
import * as tar from 'tar-stream';
import { Readable } from 'stream';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
const sentSpy = vi.fn();
const s3ClientCtorSpy = vi.fn();
vi.mock('@aws-sdk/client-s3', () => {
class S3Client {
constructor(opts: unknown) { s3ClientCtorSpy(opts); }
async send(cmd: { name: string; input: Record<string, unknown> }) { return sentSpy(cmd); }
}
class PutObjectCommand { name = 'PutObjectCommand'; constructor(public input: Record<string, unknown>) {} }
class GetObjectCommand { name = 'GetObjectCommand'; constructor(public input: Record<string, unknown>) {} }
class ListObjectsV2Command { name = 'ListObjectsV2Command'; constructor(public input: Record<string, unknown>) {} }
class DeleteObjectCommand { name = 'DeleteObjectCommand'; constructor(public input: Record<string, unknown>) {} }
class HeadBucketCommand { name = 'HeadBucketCommand'; constructor(public input: Record<string, unknown>) {} }
return { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand, HeadBucketCommand };
});
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let CryptoService: typeof import('../services/CryptoService').CryptoService;
let CloudBackupService: typeof import('../services/CloudBackupService').CloudBackupService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ CryptoService } = await import('../services/CryptoService'));
({ CloudBackupService } = await import('../services/CloudBackupService'));
});
afterAll(() => {
CloudBackupService.getInstance().stop();
cleanupTestDb(tmpDir);
});
beforeEach(() => {
sentSpy.mockReset();
s3ClientCtorSpy.mockReset();
const db = DatabaseService.getInstance();
// Reset all cloud-backup-related settings between tests.
for (const k of [
'cloud_backup_provider',
'cloud_backup_endpoint',
'cloud_backup_region',
'cloud_backup_bucket',
'cloud_backup_access_key',
'cloud_backup_secret_key',
'cloud_backup_path_prefix',
'cloud_backup_auto_upload',
]) {
db.updateGlobalSetting(k, '');
}
for (const k of [
'sencho_cloud_backup_endpoint',
'sencho_cloud_backup_bucket',
'sencho_cloud_backup_access_key',
'sencho_cloud_backup_secret_key',
'sencho_cloud_backup_path_prefix',
'sencho_cloud_backup_quota_bytes',
'sencho_cloud_backup_provisioned_at',
]) {
db.setSystemState(k, '');
}
db.setSystemState('instance_id', 'test-instance-id');
});
describe('CloudBackupService — provider resolution', () => {
it('returns "disabled" when no provider is set', () => {
expect(CloudBackupService.getInstance().getProvider()).toBe('disabled');
expect(CloudBackupService.getInstance().isEnabled()).toBe(false);
expect(CloudBackupService.getInstance().getResolvedConfig()).toBeNull();
});
it('returns null config for custom provider when fields are missing', () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('cloud_backup_provider', 'custom');
db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com');
// bucket, access_key, secret_key still missing
expect(CloudBackupService.getInstance().getResolvedConfig()).toBeNull();
});
it('decrypts custom secret_key on read', () => {
const db = DatabaseService.getInstance();
const crypto = CryptoService.getInstance();
db.updateGlobalSetting('cloud_backup_provider', 'custom');
db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com');
db.updateGlobalSetting('cloud_backup_region', 'us-east-1');
db.updateGlobalSetting('cloud_backup_bucket', 'my-bucket');
db.updateGlobalSetting('cloud_backup_access_key', 'AKIA1234');
db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt('plaintext-secret'));
db.updateGlobalSetting('cloud_backup_auto_upload', '1');
const cfg = CloudBackupService.getInstance().getResolvedConfig();
expect(cfg).not.toBeNull();
expect(cfg!.provider).toBe('custom');
expect(cfg!.secretKey).toBe('plaintext-secret');
expect(cfg!.autoUpload).toBe(true);
});
it('resolves sencho provider from system_state and forces auto_upload on', () => {
const db = DatabaseService.getInstance();
const crypto = CryptoService.getInstance();
db.updateGlobalSetting('cloud_backup_provider', 'sencho');
db.setSystemState('sencho_cloud_backup_endpoint', 'https://r2.example.com');
db.setSystemState('sencho_cloud_backup_bucket', 'sencho-cloud-backups');
db.setSystemState('sencho_cloud_backup_access_key', 'R2-ACCESS');
db.setSystemState('sencho_cloud_backup_secret_key', crypto.encrypt('R2-SECRET'));
db.setSystemState('sencho_cloud_backup_path_prefix', 'tenants/123/');
db.setSystemState('sencho_cloud_backup_quota_bytes', '524288000');
const cfg = CloudBackupService.getInstance().getResolvedConfig();
expect(cfg!.provider).toBe('sencho');
expect(cfg!.region).toBe('auto');
expect(cfg!.secretKey).toBe('R2-SECRET');
expect(cfg!.autoUpload).toBe(true);
expect(cfg!.quotaBytes).toBe(524_288_000);
});
});
describe('CloudBackupService — uploadSnapshot', () => {
function seedCustomProvider() {
const db = DatabaseService.getInstance();
const crypto = CryptoService.getInstance();
db.updateGlobalSetting('cloud_backup_provider', 'custom');
db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com');
db.updateGlobalSetting('cloud_backup_region', 'us-east-1');
db.updateGlobalSetting('cloud_backup_bucket', 'my-bucket');
db.updateGlobalSetting('cloud_backup_access_key', 'AKIA1234');
db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt('test-secret'));
db.updateGlobalSetting('cloud_backup_path_prefix', 'sencho/');
db.updateGlobalSetting('cloud_backup_auto_upload', '1');
}
it('uploads a snapshot with correct object key and gzipped tar archive', async () => {
seedCustomProvider();
const db = DatabaseService.getInstance();
const snapshotId = db.createSnapshot('Test backup', 'admin', 1, 1, '[]');
db.insertSnapshotFiles(snapshotId, [
{ nodeId: 1, nodeName: 'gateway', stackName: 'web', filename: 'compose.yaml', content: 'services: {}\n' },
{ nodeId: 1, nodeName: 'gateway', stackName: 'web', filename: '.env', content: 'KEY=value\n' },
]);
sentSpy.mockResolvedValue({});
await CloudBackupService.getInstance().uploadSnapshot(snapshotId);
expect(s3ClientCtorSpy).toHaveBeenCalledWith(expect.objectContaining({
endpoint: 'https://s3.example.com',
region: 'us-east-1',
forcePathStyle: true,
credentials: { accessKeyId: 'AKIA1234', secretAccessKey: 'test-secret' },
}));
const putCall = sentSpy.mock.calls.find(c => c[0].name === 'PutObjectCommand');
expect(putCall).toBeDefined();
const input = putCall![0].input as { Bucket: string; Key: string; Body: Buffer; ContentType: string };
expect(input.Bucket).toBe('my-bucket');
expect(input.Key).toContain('sencho/instances/test-instance-id/snapshots/');
expect(input.Key).toMatch(/\.tar\.gz$/);
expect(input.ContentType).toBe('application/gzip');
expect(Buffer.isBuffer(input.Body)).toBe(true);
expect(input.Body.byteLength).toBeGreaterThan(0);
const decompressed = zlib.gunzipSync(input.Body);
const entries: Array<{ name: string; content: string }> = await new Promise((resolve, reject) => {
const extract = tar.extract();
const list: Array<{ name: string; content: string }> = [];
extract.on('entry', (header, stream, next) => {
const chunks: Buffer[] = [];
stream.on('data', (c: Buffer) => chunks.push(c));
stream.on('end', () => { list.push({ name: header.name, content: Buffer.concat(chunks).toString('utf-8') }); next(); });
stream.resume();
});
extract.on('finish', () => resolve(list));
extract.on('error', reject);
Readable.from(decompressed).pipe(extract);
});
const meta = entries.find(e => e.name === 'metadata.json');
expect(meta).toBeDefined();
const parsed = JSON.parse(meta!.content);
expect(parsed.id).toBe(snapshotId);
expect(parsed.instance_id).toBe('test-instance-id');
expect(parsed.archive_version).toBe(1);
expect(entries.find(e => e.name === 'nodes/1_gateway/web/compose.yaml')).toBeDefined();
expect(entries.find(e => e.name === 'nodes/1_gateway/web/.env')).toBeDefined();
expect(CloudBackupService.getInstance().getUploadStatus(snapshotId).status).toBe('success');
});
it('records failure status when upload throws', async () => {
seedCustomProvider();
const db = DatabaseService.getInstance();
const snapshotId = db.createSnapshot('Failing', 'admin', 0, 0, '[]');
sentSpy.mockRejectedValueOnce(new Error('AccessDenied: bad creds'));
await expect(CloudBackupService.getInstance().uploadSnapshot(snapshotId)).rejects.toThrow(/bad creds/);
const status = CloudBackupService.getInstance().getUploadStatus(snapshotId);
expect(status.status).toBe('failed');
expect(status.error).toContain('bad creds');
});
it('throws when no provider is configured', async () => {
await expect(CloudBackupService.getInstance().uploadSnapshot(999)).rejects.toThrow(/not configured/i);
});
});
describe('CloudBackupService — listCloudSnapshots', () => {
it('parses snapshot ID from object key and sorts by lastModified desc', async () => {
const db = DatabaseService.getInstance();
const crypto = CryptoService.getInstance();
db.updateGlobalSetting('cloud_backup_provider', 'custom');
db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com');
db.updateGlobalSetting('cloud_backup_region', 'us-east-1');
db.updateGlobalSetting('cloud_backup_bucket', 'b');
db.updateGlobalSetting('cloud_backup_access_key', 'a');
db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt('s'));
db.updateGlobalSetting('cloud_backup_path_prefix', 'sencho/');
sentSpy.mockResolvedValueOnce({
Contents: [
{ Key: 'sencho/instances/test-instance-id/snapshots/3_2026-01-01_a.tar.gz', Size: 100, LastModified: new Date('2026-01-01T00:00:00Z') },
{ Key: 'sencho/instances/test-instance-id/snapshots/7_2026-04-01_b.tar.gz', Size: 200, LastModified: new Date('2026-04-01T00:00:00Z') },
],
});
const list = await CloudBackupService.getInstance().listCloudSnapshots();
expect(list).toHaveLength(2);
expect(list[0].snapshotId).toBe(7);
expect(list[1].snapshotId).toBe(3);
});
});
@@ -145,6 +145,16 @@ vi.mock('../services/NotificationService', () => ({
}, },
})); }));
vi.mock('../services/CloudBackupService', () => ({
CloudBackupService: {
getInstance: () => ({
isEnabled: () => false,
isAutoUploadOn: () => false,
uploadSnapshot: vi.fn().mockResolvedValue(undefined),
}),
},
}));
vi.mock('../services/NodeRegistry', () => ({ vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: { NodeRegistry: {
getInstance: () => ({ getInstance: () => ({
+2
View File
@@ -18,6 +18,7 @@ import { webhooksRouter } from './routes/webhooks';
import { usersRouter } from './routes/users'; import { usersRouter } from './routes/users';
import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources'; import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources';
import { fleetRouter } from './routes/fleet'; import { fleetRouter } from './routes/fleet';
import { cloudBackupRouter } from './routes/cloudBackup';
import { permissionsRouter } from './routes/permissions'; import { permissionsRouter } from './routes/permissions';
import { convertRouter } from './routes/convert'; import { convertRouter } from './routes/convert';
import { alertsRouter } from './routes/alerts'; import { alertsRouter } from './routes/alerts';
@@ -91,6 +92,7 @@ app.use('/api/stacks', stackLabelsRouter);
app.use('/api/api-tokens', apiTokensRouter); app.use('/api/api-tokens', apiTokensRouter);
app.use('/api/audit-log', auditLogRouter); app.use('/api/audit-log', auditLogRouter);
app.use('/api/fleet', fleetRouter); app.use('/api/fleet', fleetRouter);
app.use('/api/cloud-backup', cloudBackupRouter);
app.use('/api/webhooks', webhooksRouter); app.use('/api/webhooks', webhooksRouter);
app.use('/api/users', usersRouter); app.use('/api/users', usersRouter);
app.use('/api/git-sources', gitSourcesRouter); app.use('/api/git-sources', gitSourcesRouter);
+243
View File
@@ -0,0 +1,243 @@
import { Router, type Request, type Response } from 'express';
import { CloudBackupService } from '../services/CloudBackupService';
import { DatabaseService } from '../services/DatabaseService';
import { CryptoService } from '../services/CryptoService';
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { getErrorMessage } from '../utils/errors';
const SCOPE_MESSAGE = 'API tokens cannot manage cloud backup configuration.';
const SECRET_REDACTED = '***';
const VALID_PROVIDERS = new Set(['disabled', 'sencho', 'custom']);
function parseSnapshotIdParam(req: Request, res: Response): number | null {
const raw = req.params.id as string | undefined;
const parsed = parseInt(raw ?? '', 10);
if (isNaN(parsed) || parsed <= 0) {
res.status(400).json({ error: 'Invalid snapshot ID' });
return null;
}
return parsed;
}
function decodeObjectKey(req: Request, res: Response): string | null {
const raw = req.params.keyB64 as string | undefined;
if (!raw) {
res.status(400).json({ error: 'Missing object key' });
return null;
}
try {
const decoded = Buffer.from(raw, 'base64url').toString('utf-8');
if (!decoded || decoded.includes('..') || decoded.startsWith('/')) {
res.status(400).json({ error: 'Invalid object key' });
return null;
}
return decoded;
} catch {
res.status(400).json({ error: 'Invalid object key encoding' });
return null;
}
}
export const cloudBackupRouter = Router();
cloudBackupRouter.get('/config', (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmiral(req, res)) return;
try {
const db = DatabaseService.getInstance();
const settings = db.getGlobalSettings();
const provider = CloudBackupService.getInstance().getProvider();
const senchoProvisioned = !!db.getSystemState('sencho_cloud_backup_provisioned_at');
res.json({
provider,
sencho_provisioned: senchoProvisioned,
sencho_provisioned_at: db.getSystemState('sencho_cloud_backup_provisioned_at'),
custom: {
endpoint: settings.cloud_backup_endpoint || '',
region: settings.cloud_backup_region || '',
bucket: settings.cloud_backup_bucket || '',
access_key: settings.cloud_backup_access_key || '',
secret_key: settings.cloud_backup_secret_key ? SECRET_REDACTED : '',
path_prefix: settings.cloud_backup_path_prefix || 'sencho/',
auto_upload: settings.cloud_backup_auto_upload === '1',
},
});
} catch (error) {
console.error('[CloudBackup] config get error:', error);
res.status(500).json({ error: 'Failed to load cloud backup config' });
}
});
cloudBackupRouter.put('/config', (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const body = req.body ?? {};
const provider = body.provider as string | undefined;
if (!provider || !VALID_PROVIDERS.has(provider)) {
res.status(400).json({ error: 'provider must be one of: disabled, sencho, custom' });
return;
}
const db = DatabaseService.getInstance();
const crypto = CryptoService.getInstance();
db.updateGlobalSetting('cloud_backup_provider', provider);
if (provider === 'custom') {
const c = body.custom ?? {};
const endpoint = typeof c.endpoint === 'string' ? c.endpoint.trim() : '';
const region = typeof c.region === 'string' ? c.region.trim() : '';
const bucket = typeof c.bucket === 'string' ? c.bucket.trim() : '';
const accessKey = typeof c.access_key === 'string' ? c.access_key.trim() : '';
const pathPrefix = typeof c.path_prefix === 'string' ? c.path_prefix.trim() : 'sencho/';
const autoUpload = c.auto_upload === true || c.auto_upload === '1' ? '1' : '0';
if (!endpoint || !bucket || !accessKey) {
res.status(400).json({ error: 'endpoint, bucket, and access_key are required for custom S3.' });
return;
}
if (!/^https?:\/\//i.test(endpoint)) {
res.status(400).json({ error: 'endpoint must start with http:// or https://' });
return;
}
db.updateGlobalSetting('cloud_backup_endpoint', endpoint);
db.updateGlobalSetting('cloud_backup_region', region);
db.updateGlobalSetting('cloud_backup_bucket', bucket);
db.updateGlobalSetting('cloud_backup_access_key', accessKey);
db.updateGlobalSetting('cloud_backup_path_prefix', pathPrefix);
db.updateGlobalSetting('cloud_backup_auto_upload', autoUpload);
const incomingSecret = typeof c.secret_key === 'string' ? c.secret_key : '';
if (incomingSecret && incomingSecret !== SECRET_REDACTED) {
db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt(incomingSecret));
}
}
res.status(204).send();
} catch (error) {
console.error('[CloudBackup] config update error:', error);
res.status(500).json({ error: 'Failed to save cloud backup config' });
}
});
cloudBackupRouter.post('/test', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const result = await CloudBackupService.getInstance().testConnection();
res.json(result);
} catch (error) {
console.error('[CloudBackup] test error:', error);
res.status(500).json({ success: false, error: getErrorMessage(error, 'Connection test failed') });
}
});
cloudBackupRouter.post('/provision', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const result = await CloudBackupService.getInstance().provisionSenchoCloudBackup();
if (!result.success) {
res.status(400).json({ error: result.error || 'Provisioning failed' });
return;
}
res.json({ success: true, quota_bytes: result.quotaBytes });
} catch (error) {
console.error('[CloudBackup] provision error:', error);
res.status(500).json({ error: 'Failed to provision Sencho Cloud Backup' });
}
});
cloudBackupRouter.get('/usage', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmiral(req, res)) return;
try {
const svc = CloudBackupService.getInstance();
if (svc.getProvider() !== 'sencho') {
res.status(400).json({ error: 'Usage is only available for Sencho Cloud Backup' });
return;
}
const usage = await svc.getSenchoCloudBackupUsage();
res.json(usage);
} catch (error) {
console.error('[CloudBackup] usage error:', error);
res.status(502).json({ error: getErrorMessage(error, 'Failed to fetch usage') });
}
});
cloudBackupRouter.get('/snapshots', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmiral(req, res)) return;
try {
const entries = await CloudBackupService.getInstance().listCloudSnapshots();
res.json(entries);
} catch (error) {
console.error('[CloudBackup] list error:', error);
res.status(502).json({ error: getErrorMessage(error, 'Failed to list cloud snapshots') });
}
});
cloudBackupRouter.post('/upload/:id', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
const id = parseSnapshotIdParam(req, res);
if (id == null) return;
try {
const db = DatabaseService.getInstance();
if (!db.getSnapshot(id)) {
res.status(404).json({ error: 'Snapshot not found' });
return;
}
await CloudBackupService.getInstance().uploadSnapshot(id);
res.status(202).json({ status: 'success', snapshot_id: id });
} catch (error) {
console.error('[CloudBackup] upload error:', error);
res.status(502).json({ error: getErrorMessage(error, 'Cloud upload failed') });
}
});
cloudBackupRouter.get('/status/:id', (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmiral(req, res)) return;
const id = parseSnapshotIdParam(req, res);
if (id == null) return;
res.json(CloudBackupService.getInstance().getUploadStatus(id));
});
cloudBackupRouter.get('/object/:keyB64/download', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmiral(req, res)) return;
const objectKey = decodeObjectKey(req, res);
if (!objectKey) return;
try {
const buffer = await CloudBackupService.getInstance().downloadSnapshot(objectKey);
const filename = objectKey.split('/').pop() || 'snapshot.tar.gz';
res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', String(buffer.byteLength));
res.end(buffer);
} catch (error) {
console.error('[CloudBackup] download error:', error);
res.status(502).json({ error: getErrorMessage(error, 'Failed to download cloud snapshot') });
}
});
cloudBackupRouter.delete('/object/:keyB64', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
const objectKey = decodeObjectKey(req, res);
if (!objectKey) return;
try {
await CloudBackupService.getInstance().deleteCloudSnapshot(objectKey);
res.status(204).send();
} catch (error) {
console.error('[CloudBackup] delete error:', error);
res.status(502).json({ error: getErrorMessage(error, 'Failed to delete cloud snapshot') });
}
});
+13
View File
@@ -20,6 +20,8 @@ import { getLatestVersion } from '../utils/version-check';
import { isValidStackName } from '../utils/validation'; import { isValidStackName } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug'; import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
import { CloudBackupService } from '../services/CloudBackupService';
import { NotificationService } from '../services/NotificationService';
const updateTracker = FleetUpdateTrackerService.getInstance(); const updateTracker = FleetUpdateTrackerService.getInstance();
const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -819,6 +821,17 @@ fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Respons
db.insertSnapshotFiles(snapshotId, allFiles); db.insertSnapshotFiles(snapshotId, allFiles);
} }
const cloudSvc = CloudBackupService.getInstance();
if (cloudSvc.isEnabled() && cloudSvc.isAutoUploadOn()) {
void cloudSvc.uploadSnapshot(snapshotId).catch(uploadErr => {
const message = uploadErr instanceof Error ? uploadErr.message : String(uploadErr);
console.error('[Fleet Snapshot] Cloud upload failed:', message);
void NotificationService.getInstance()
.dispatchAlert('error', 'system', `Cloud backup upload failed for snapshot ${snapshotId}: ${message}`)
.catch(() => { /* notification dispatch is best-effort */ });
});
}
console.log('[Fleet] Snapshot created:', capturedNodes.length, 'nodes,', totalStacks, 'stacks'); console.log('[Fleet] Snapshot created:', capturedNodes.length, 'nodes,', totalStacks, 'stacks');
if (isDebugEnabled()) { if (isDebugEnabled()) {
console.debug(`[Fleet:debug] Snapshot ${snapshotId} capture completed in ${Date.now() - captureStart}ms, ${allFiles.length} file(s) stored`); console.debug(`[Fleet:debug] Snapshot ${snapshotId} capture completed in ${Date.now() - captureStart}ms, ${allFiles.length} file(s) stored`);
+400
View File
@@ -0,0 +1,400 @@
/**
* CloudBackupService — off-site replication for fleet snapshots.
*
* Two providers share the same S3-compatible code path:
* - 'sencho' : managed Sencho Cloud Backup. Credentials provisioned by
* sencho.io/api/cloud-backup/provision and stored in system_state.
* - 'custom' : bring-your-own-bucket. Credentials user-configured in global_settings.
*
* Routes call this service after a snapshot is persisted to SQLite. The service
* reads snapshot rows from DatabaseService, packs them into a tar.gz archive,
* and uploads via @aws-sdk/client-s3.
*/
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
DeleteObjectCommand,
HeadBucketCommand,
} from '@aws-sdk/client-s3';
import { Readable } from 'stream';
import * as zlib from 'zlib';
import * as tar from 'tar-stream';
import axios from 'axios';
import { DatabaseService, type FleetSnapshotFile } from './DatabaseService';
import { CryptoService } from './CryptoService';
import { LicenseService } from './LicenseService';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
export type CloudProvider = 'disabled' | 'sencho' | 'custom';
export type UploadStatus = 'idle' | 'uploading' | 'success' | 'failed';
export interface ResolvedCloudConfig {
provider: 'sencho' | 'custom';
endpoint: string;
region: string;
bucket: string;
accessKey: string;
secretKey: string;
pathPrefix: string;
autoUpload: boolean;
quotaBytes?: number;
}
export interface CloudSnapshotEntry {
objectKey: string;
sizeBytes: number;
lastModified: string | null;
snapshotId: number | null;
}
export interface ProvisionResult {
success: boolean;
quotaBytes?: number;
error?: string;
}
export interface UploadStatusEntry {
status: UploadStatus;
objectKey?: string;
error?: string;
updatedAt: number;
}
const SENCHO_CLOUD_BACKUP_API_DEFAULT = 'https://sencho.io';
const PROVIDER_KEY = 'cloud_backup_provider';
const SUCCESS_STATUS_TTL_MS = 5 * 60 * 1000;
export class CloudBackupService {
private static instance: CloudBackupService;
private uploadStatus = new Map<number, UploadStatusEntry>();
private statusGcTimer: ReturnType<typeof setInterval> | null = null;
private constructor() {
this.statusGcTimer = setInterval(() => this.gcUploadStatus(), 60 * 1000);
if (typeof this.statusGcTimer.unref === 'function') this.statusGcTimer.unref();
}
public static getInstance(): CloudBackupService {
if (!CloudBackupService.instance) {
CloudBackupService.instance = new CloudBackupService();
}
return CloudBackupService.instance;
}
public stop(): void {
if (this.statusGcTimer) {
clearInterval(this.statusGcTimer);
this.statusGcTimer = null;
}
}
// ─── Configuration ─────────────────────────────────────────────────────────
public getProvider(): CloudProvider {
const value = DatabaseService.getInstance().getGlobalSettings()[PROVIDER_KEY];
if (value === 'sencho' || value === 'custom') return value;
return 'disabled';
}
public isEnabled(): boolean {
return this.getResolvedConfig() !== null;
}
public isAutoUploadOn(): boolean {
const cfg = this.getResolvedConfig();
return cfg?.autoUpload === true;
}
public getResolvedConfig(): ResolvedCloudConfig | null {
const provider = this.getProvider();
if (provider === 'disabled') return null;
const db = DatabaseService.getInstance();
const crypto = CryptoService.getInstance();
if (provider === 'sencho') {
const endpoint = db.getSystemState('sencho_cloud_backup_endpoint');
const bucket = db.getSystemState('sencho_cloud_backup_bucket');
const accessKey = db.getSystemState('sencho_cloud_backup_access_key');
const secretRaw = db.getSystemState('sencho_cloud_backup_secret_key');
const pathPrefix = db.getSystemState('sencho_cloud_backup_path_prefix') || '';
const quotaRaw = db.getSystemState('sencho_cloud_backup_quota_bytes');
if (!endpoint || !bucket || !accessKey || !secretRaw) return null;
return {
provider: 'sencho',
endpoint,
region: 'auto',
bucket,
accessKey,
secretKey: crypto.decrypt(secretRaw),
pathPrefix,
autoUpload: true,
quotaBytes: quotaRaw ? parseInt(quotaRaw, 10) : undefined,
};
}
const settings = db.getGlobalSettings();
const endpoint = settings.cloud_backup_endpoint;
const region = settings.cloud_backup_region;
const bucket = settings.cloud_backup_bucket;
const accessKey = settings.cloud_backup_access_key;
const secretRaw = settings.cloud_backup_secret_key;
const pathPrefix = settings.cloud_backup_path_prefix || 'sencho/';
if (!endpoint || !bucket || !accessKey || !secretRaw) return null;
return {
provider: 'custom',
endpoint,
region: region || 'us-east-1',
bucket,
accessKey,
secretKey: crypto.decrypt(secretRaw),
pathPrefix,
autoUpload: settings.cloud_backup_auto_upload === '1',
};
}
// ─── Sencho Cloud Backup lifecycle ─────────────────────────────────────────
public async provisionSenchoCloudBackup(): Promise<ProvisionResult> {
const db = DatabaseService.getInstance();
const crypto = CryptoService.getInstance();
const licenseKey = db.getSystemState('license_key');
if (!licenseKey) return { success: false, error: 'No license key found. Activate an Admiral license first.' };
const variant = LicenseService.getInstance().getVariant();
if (variant !== 'admiral') return { success: false, error: 'Sencho Cloud Backup requires the Admiral tier.' };
const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT;
try {
const res = await axios.post(`${apiBase}/api/cloud-backup/provision`, { license_key: licenseKey }, { timeout: 15000 });
const data = res.data as {
endpoint: string;
region?: string;
bucket: string;
access_key: string;
secret_key: string;
path_prefix: string;
quota_bytes: number;
};
db.setSystemState('sencho_cloud_backup_endpoint', data.endpoint);
db.setSystemState('sencho_cloud_backup_bucket', data.bucket);
db.setSystemState('sencho_cloud_backup_access_key', data.access_key);
db.setSystemState('sencho_cloud_backup_secret_key', crypto.encrypt(data.secret_key));
db.setSystemState('sencho_cloud_backup_path_prefix', data.path_prefix);
db.setSystemState('sencho_cloud_backup_quota_bytes', String(data.quota_bytes));
db.setSystemState('sencho_cloud_backup_provisioned_at', new Date().toISOString());
db.updateGlobalSetting(PROVIDER_KEY, 'sencho');
return { success: true, quotaBytes: data.quota_bytes };
} catch (err) {
const responseError = (err as { response?: { data?: { error?: string } } }).response?.data?.error;
return { success: false, error: responseError || getErrorMessage(err, 'Failed to provision Sencho Cloud Backup.') };
}
}
public async refreshSenchoCloudBackupCredentials(): Promise<void> {
const result = await this.provisionSenchoCloudBackup();
if (!result.success) throw new Error(result.error || 'Failed to refresh Sencho Cloud Backup credentials.');
}
public async getSenchoCloudBackupUsage(): Promise<{ used_bytes: number; quota_bytes: number; object_count: number }> {
const db = DatabaseService.getInstance();
const licenseKey = db.getSystemState('license_key');
if (!licenseKey) throw new Error('No license key found.');
const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT;
const res = await axios.post(`${apiBase}/api/cloud-backup/usage`, { license_key: licenseKey }, { timeout: 15000 });
const data = res.data as { used_bytes: number; quota_bytes: number; object_count: number };
return data;
}
// ─── S3 operations ─────────────────────────────────────────────────────────
public async testConnection(): Promise<{ success: boolean; error?: string }> {
const cfg = this.getResolvedConfig();
if (!cfg) return { success: false, error: 'No cloud backup configuration is active.' };
try {
const client = this.buildS3Client(cfg);
await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
return { success: true };
} catch (err) {
return { success: false, error: getErrorMessage(err, 'Connection test failed.') };
}
}
public async uploadSnapshot(snapshotId: number): Promise<void> {
const cfg = this.getResolvedConfig();
if (!cfg) throw new Error('Cloud backup is not configured.');
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(snapshotId);
if (!snapshot) throw new Error(`Snapshot ${snapshotId} not found.`);
const files = db.getSnapshotFiles(snapshotId);
const objectKey = this.buildObjectKey(cfg, snapshot.id, snapshot.description, snapshot.created_at);
this.setStatus(snapshotId, { status: 'uploading', objectKey, updatedAt: Date.now() });
try {
const archive = await this.buildArchive(snapshot, files);
const client = this.buildS3Client(cfg);
await client.send(new PutObjectCommand({
Bucket: cfg.bucket,
Key: objectKey,
Body: archive,
ContentType: 'application/gzip',
}));
this.setStatus(snapshotId, { status: 'success', objectKey, updatedAt: Date.now() });
if (isDebugEnabled()) {
console.log(`[CloudBackup:debug] Uploaded snapshot ${snapshotId} (${archive.byteLength} bytes) to ${cfg.bucket}/${objectKey}`);
}
} catch (err) {
const message = getErrorMessage(err, 'Cloud upload failed.');
this.setStatus(snapshotId, { status: 'failed', objectKey, error: message, updatedAt: Date.now() });
throw new Error(message);
}
}
public async downloadSnapshot(objectKey: string): Promise<Buffer> {
const cfg = this.getResolvedConfig();
if (!cfg) throw new Error('Cloud backup is not configured.');
const client = this.buildS3Client(cfg);
const result = await client.send(new GetObjectCommand({ Bucket: cfg.bucket, Key: objectKey }));
const body = result.Body as Readable | undefined;
if (!body) throw new Error('Empty response body.');
return await streamToBuffer(body);
}
public async listCloudSnapshots(): Promise<CloudSnapshotEntry[]> {
const cfg = this.getResolvedConfig();
if (!cfg) return [];
const client = this.buildS3Client(cfg);
const prefix = `${cfg.pathPrefix}instances/${this.getInstanceId()}/snapshots/`;
const result = await client.send(new ListObjectsV2Command({
Bucket: cfg.bucket,
Prefix: prefix,
MaxKeys: 1000,
}));
const objects = result.Contents || [];
return objects
.filter(o => !!o.Key)
.map(o => {
const key = o.Key as string;
const basename = key.split('/').pop() || key;
const idMatch = basename.match(/^(\d+)_/);
return {
objectKey: key,
sizeBytes: o.Size ?? 0,
lastModified: o.LastModified ? o.LastModified.toISOString() : null,
snapshotId: idMatch ? parseInt(idMatch[1], 10) : null,
};
})
.sort((a, b) => (b.lastModified || '').localeCompare(a.lastModified || ''));
}
public async deleteCloudSnapshot(objectKey: string): Promise<void> {
const cfg = this.getResolvedConfig();
if (!cfg) throw new Error('Cloud backup is not configured.');
const client = this.buildS3Client(cfg);
await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: objectKey }));
}
// ─── Status tracking ───────────────────────────────────────────────────────
public getUploadStatus(snapshotId: number): UploadStatusEntry {
return this.uploadStatus.get(snapshotId) || { status: 'idle', updatedAt: 0 };
}
private setStatus(snapshotId: number, entry: UploadStatusEntry): void {
this.uploadStatus.set(snapshotId, entry);
}
private gcUploadStatus(): void {
const now = Date.now();
for (const [id, entry] of this.uploadStatus.entries()) {
if (entry.status === 'success' && now - entry.updatedAt > SUCCESS_STATUS_TTL_MS) {
this.uploadStatus.delete(id);
}
}
}
// ─── Internals ─────────────────────────────────────────────────────────────
private buildS3Client(cfg: ResolvedCloudConfig): S3Client {
return new S3Client({
endpoint: cfg.endpoint,
region: cfg.region,
credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey },
forcePathStyle: true,
});
}
private getInstanceId(): string {
return DatabaseService.getInstance().getSystemState('instance_id') || 'unknown';
}
private buildObjectKey(cfg: ResolvedCloudConfig, snapshotId: number, description: string, createdAt: number): string {
const ts = new Date(createdAt).toISOString().replace(/[:.]/g, '-');
const slug = description.slice(0, 40).replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'snapshot';
return `${cfg.pathPrefix}instances/${this.getInstanceId()}/snapshots/${snapshotId}_${ts}_${slug}.tar.gz`;
}
private async buildArchive(
snapshot: { id: number; description: string; created_by: string; node_count: number; stack_count: number; skipped_nodes: string; created_at: number },
files: FleetSnapshotFile[],
): Promise<Buffer> {
const pack = tar.pack();
const metadata = {
id: snapshot.id,
description: snapshot.description,
created_by: snapshot.created_by,
created_at: snapshot.created_at,
node_count: snapshot.node_count,
stack_count: snapshot.stack_count,
skipped_nodes: safeParseJson(snapshot.skipped_nodes, []),
instance_id: this.getInstanceId(),
archive_version: 1,
};
pack.entry({ name: 'metadata.json' }, JSON.stringify(metadata, null, 2));
for (const file of files) {
const safeNodeName = sanitizePathSegment(file.node_name);
const safeStackName = sanitizePathSegment(file.stack_name);
const safeFilename = sanitizePathSegment(file.filename);
const entryPath = `nodes/${file.node_id}_${safeNodeName}/${safeStackName}/${safeFilename}`;
pack.entry({ name: entryPath }, file.content);
}
pack.finalize();
const gzip = zlib.createGzip();
const chunks: Buffer[] = [];
return await new Promise<Buffer>((resolve, reject) => {
gzip.on('data', (chunk: Buffer) => chunks.push(chunk));
gzip.on('end', () => resolve(Buffer.concat(chunks)));
gzip.on('error', reject);
pack.on('error', reject);
pack.pipe(gzip);
});
}
}
// ─── Helpers ───────────────────────────────────────────────────────────────────
function streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
function sanitizePathSegment(input: string): string {
// Preserve dotfiles like `.env` while neutralising traversal (`..`) and path separators.
const cleaned = input.replace(/\.\./g, '_').replace(/[\\/]/g, '_');
if (/^\.+$/.test(cleaned)) return '_';
return cleaned;
}
function safeParseJson<T>(value: string | null | undefined, fallback: T): T {
if (!value) return fallback;
try { return JSON.parse(value) as T; } catch { return fallback; }
}
+18 -3
View File
@@ -15,6 +15,7 @@ import { NotificationService } from './NotificationService';
import TrivyService from './TrivyService'; import TrivyService from './TrivyService';
import type { ScanAllNodeImagesResult } from './TrivyService'; import type { ScanAllNodeImagesResult } from './TrivyService';
import TrivyInstaller from './TrivyInstaller'; import TrivyInstaller from './TrivyInstaller';
import { CloudBackupService } from './CloudBackupService';
const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000; const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000;
@@ -501,11 +502,25 @@ export class SchedulerService {
db.insertSnapshotFiles(snapshotId, allFiles); db.insertSnapshotFiles(snapshotId, allFiles);
} }
if (isDebugEnabled()) { let cloudUploadNote = '';
console.debug(`[SchedulerService:debug] Snapshot task ${task.id}: captured ${capturedNodes.length} node(s), ${totalStacks} stack(s), ${allFiles.length} file(s), skipped ${skippedNodes.length}`); const cloudSvc = CloudBackupService.getInstance();
if (cloudSvc.isEnabled() && cloudSvc.isAutoUploadOn()) {
try {
await cloudSvc.uploadSnapshot(snapshotId);
cloudUploadNote = ', cloud upload OK';
} catch (err) {
const message = getErrorMessage(err, 'Cloud upload failed');
console.error('[SchedulerService] Cloud upload failed:', message);
this.safeDispatch('warning', 'system', `Cloud backup failed for scheduled snapshot ${snapshotId}: ${message}`);
cloudUploadNote = ', cloud upload FAILED';
}
} }
return `Fleet snapshot created (id=${snapshotId}, ${capturedNodes.length} node(s), ${totalStacks} stack(s)${skippedNodes.length > 0 ? `, ${skippedNodes.length} skipped` : ''})`; if (isDebugEnabled()) {
console.debug(`[SchedulerService:debug] Snapshot task ${task.id}: captured ${capturedNodes.length} node(s), ${totalStacks} stack(s), ${allFiles.length} file(s), skipped ${skippedNodes.length}${cloudUploadNote}`);
}
return `Fleet snapshot created (id=${snapshotId}, ${capturedNodes.length} node(s), ${totalStacks} stack(s)${skippedNodes.length > 0 ? `, ${skippedNodes.length} skipped` : ''}${cloudUploadNote})`;
} }
private async executePrune(task: ScheduledTask): Promise<string> { private async executePrune(task: ScheduledTask): Promise<string> {
+7
View File
@@ -86,6 +86,13 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'POST /fleet/nodes/*/update': 'Triggered fleet node update', 'POST /fleet/nodes/*/update': 'Triggered fleet node update',
'POST /fleet/update-all': 'Triggered fleet-wide update', 'POST /fleet/update-all': 'Triggered fleet-wide update',
// Cloud backup
'PUT /cloud-backup/config': 'Updated cloud backup config',
'POST /cloud-backup/test': 'Tested cloud backup connection',
'POST /cloud-backup/provision': 'Provisioned Sencho Cloud Backup',
'POST /cloud-backup/upload': 'Uploaded snapshot to cloud',
'DELETE /cloud-backup/object': 'Deleted cloud snapshot',
// SSO // SSO
'PUT /sso/config': 'Updated SSO configuration', 'PUT /sso/config': 'Updated SSO configuration',
'DELETE /sso/config': 'Deleted SSO configuration', 'DELETE /sso/config': 'Deleted SSO configuration',
+53
View File
@@ -82,6 +82,59 @@ Sencho writes the snapshot's files back to the target node:
Admins can delete snapshots from the list view by clicking the trash icon on the right side of each row. A confirmation dialog asks you to confirm before the snapshot is permanently removed. Deleting a snapshot removes all captured file data from the database. This action cannot be undone. Admins can delete snapshots from the list view by clicking the trash icon on the right side of each row. A confirmation dialog asks you to confirm before the snapshot is permanently removed. Deleting a snapshot removes all captured file data from the database. This action cannot be undone.
## Cloud Backup
<Note>
Cloud Backup requires an Admiral license. Configure it in **Settings → Cloud Backup**.
</Note>
Cloud Backup mirrors every fleet snapshot to off-site storage so your snapshots survive local disk failure. Two storage modes are supported.
### Sencho Cloud Backup (included)
A managed 500 MB allowance backed by Cloudflare R2, included with every Admiral license. Open **Settings → Cloud Backup**, choose **Sencho Cloud Backup**, and click **Activate**. Sencho exchanges your license key for scoped storage credentials and starts replicating new snapshots automatically. The settings panel shows your storage usage and lets you reprovision credentials if needed.
### Custom S3 (BYOB)
Bring any S3-compatible bucket: AWS S3, MinIO, Backblaze B2, Wasabi, or your own Cloudflare R2 token. Choose **Custom S3** in the storage-mode dropdown and fill in:
- **Endpoint URL** (e.g. `https://s3.us-east-1.amazonaws.com`, `https://my-minio.example.com:9000`)
- **Region** (e.g. `us-east-1`, or `auto` for R2)
- **Bucket** name
- **Path Prefix** (default `sencho/`)
- **Access Key ID** and **Secret Access Key**
- **Auto-upload** toggle
Click **Test** to verify connectivity, then **Save**. Secret keys are encrypted at rest. Sencho only sends them to your configured endpoint.
### Manual upload vs auto-upload
When auto-upload is on, every fleet snapshot is replicated as soon as it is created. Manual snapshots from the **Fleet → Snapshots** view upload asynchronously so the UI returns immediately; scheduled snapshots block on the upload so the task's success status reflects cloud durability.
To upload a single snapshot on demand, open the **Snapshots** tab in Fleet View. Each row that hasn't been mirrored yet shows a cloud-upload action next to the **View** button. Once a snapshot is in the cloud, a small cloud icon appears next to its description.
### Browsing and downloading cloud snapshots
The **Cloud Snapshots** panel in **Settings → Cloud Backup** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack.
### Restoring from a cloud snapshot
For in-place rollback, use the **Restore** action on the snapshot detail view as described above; the local copy is the source of truth for live restore. Cloud snapshots cover the disaster-recovery case where the local disk is gone: download the archive, extract it, and bring up a fresh Sencho instance pointed at the recovered files.
### Cloud Backup troubleshooting
#### Bad credentials
If **Test** reports an authentication error, double-check the Access Key ID, Secret Access Key, and bucket. Some providers require you to enable S3-compatible API access on the bucket separately. For MinIO, verify the user has read/write permission on the target bucket.
#### Over quota (Sencho Cloud Backup)
Sencho Cloud Backup has a 500 MB allowance per license. When you hit the cap, new uploads fail with a quota error. Delete older cloud snapshots from the **Cloud Snapshots** panel to free space. The local copies are unaffected.
#### Network timeout or 5xx error
Transient errors surface as a notification. Retry by clicking the cloud-upload action on the snapshot row, or wait for the next scheduled snapshot which will retry on its own. Persistent failures usually indicate an endpoint outage; verify the storage provider is reachable from your Sencho host.
## Access control ## Access control
| Action | Admin | Node Admin | Deployer | Auditor | Viewer | | Action | Admin | Node Admin | Deployer | Auditor | Viewer |
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+67 -5
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { import {
Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2, Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2,
Eye, ChevronDown, ChevronLeft, ChevronRight, Plus, Loader2, RotateCcw, Eye, ChevronDown, ChevronLeft, ChevronRight, Plus, Loader2, RotateCcw,
Cloud, CloudUpload,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -20,6 +21,7 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { toast } from '@/components/ui/toast-store'; import { toast } from '@/components/ui/toast-store';
// --- Types --- // --- Types ---
@@ -66,6 +68,8 @@ const PAGE_SIZE = 10;
export default function FleetSnapshots() { export default function FleetSnapshots() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { license, isPaid } = useLicense();
const isAdmiral = isPaid && license?.variant === 'admiral';
const [snapshots, setSnapshots] = useState<FleetSnapshot[]>([]); const [snapshots, setSnapshots] = useState<FleetSnapshot[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -81,6 +85,8 @@ export default function FleetSnapshots() {
const [restoringStack, setRestoringStack] = useState<string | null>(null); const [restoringStack, setRestoringStack] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null); const [deletingId, setDeletingId] = useState<number | null>(null);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [cloudSnapshotIds, setCloudSnapshotIds] = useState<Set<number>>(new Set());
const [uploadingId, setUploadingId] = useState<number | null>(null);
const totalPages = Math.max(1, Math.ceil(snapshots.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(snapshots.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1); const safePage = Math.min(page, totalPages - 1);
@@ -111,6 +117,37 @@ export default function FleetSnapshots() {
fetchSnapshots(); fetchSnapshots();
}, [fetchSnapshots]); }, [fetchSnapshots]);
const fetchCloudSnapshots = useCallback(async () => {
if (!isAdmiral) return;
try {
const res = await apiFetch('/cloud-backup/snapshots', { localOnly: true });
if (!res.ok) return;
const data = await res.json() as Array<{ snapshotId: number | null }>;
setCloudSnapshotIds(new Set(data.map(d => d.snapshotId).filter((id): id is number => id != null)));
} catch {
// best-effort; cloud indicators stay hidden on failure
}
}, [isAdmiral]);
useEffect(() => {
fetchCloudSnapshots();
}, [fetchCloudSnapshots]);
const handleCloudUpload = async (id: number) => {
setUploadingId(id);
try {
const res = await apiFetch(`/cloud-backup/upload/${id}`, { method: 'POST', localOnly: true });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || `Upload failed (${res.status})`);
toast.success('Snapshot uploaded to cloud.');
await fetchCloudSnapshots();
} catch (err) {
toast.error((err as Error)?.message || 'Cloud upload failed.');
} finally {
setUploadingId(null);
}
};
const handleCreate = async () => { const handleCreate = async () => {
setCreating(true); setCreating(true);
const loadingId = toast.loading('Creating fleet snapshot...'); const loadingId = toast.loading('Creating fleet snapshot...');
@@ -533,11 +570,20 @@ export default function FleetSnapshots() {
{new Date(snapshot.created_at).toLocaleString()} {new Date(snapshot.created_at).toLocaleString()}
</TableCell> </TableCell>
<TableCell className="text-sm max-w-[300px] truncate"> <TableCell className="text-sm max-w-[300px] truncate">
{snapshot.description ? ( <div className="flex items-center gap-1.5 min-w-0">
snapshot.description {snapshot.description ? (
) : ( <span className="truncate">{snapshot.description}</span>
<span className="italic text-muted-foreground">No description</span> ) : (
)} <span className="italic text-muted-foreground">No description</span>
)}
{cloudSnapshotIds.has(snapshot.id) && (
<Cloud
className="w-3.5 h-3.5 text-success shrink-0"
strokeWidth={1.5}
aria-label="Uploaded to cloud"
/>
)}
</div>
</TableCell> </TableCell>
<TableCell className="text-xs font-mono tabular-nums text-muted-foreground whitespace-nowrap"> <TableCell className="text-xs font-mono tabular-nums text-muted-foreground whitespace-nowrap">
{snapshot.node_count} node{snapshot.node_count !== 1 ? 's' : ''} {snapshot.node_count} node{snapshot.node_count !== 1 ? 's' : ''}
@@ -568,6 +614,22 @@ export default function FleetSnapshots() {
<Eye className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} /> <Eye className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
View View
</Button> </Button>
{isAdmin && isAdmiral && !cloudSnapshotIds.has(snapshot.id) && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
title="Upload to cloud"
disabled={uploadingId === snapshot.id}
onClick={() => handleCloudUpload(snapshot.id)}
>
{uploadingId === snapshot.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
) : (
<CloudUpload className="w-3.5 h-3.5" strokeWidth={1.5} />
)}
</Button>
)}
{isAdmin && ( {isAdmin && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
@@ -39,6 +39,7 @@ import {
NotificationRoutingSection, NotificationRoutingSection,
WebhooksSection, WebhooksSection,
SecuritySection, SecuritySection,
CloudBackupSection,
DeveloperSection, DeveloperSection,
AppStoreSection, AppStoreSection,
SupportSection, SupportSection,
@@ -318,6 +319,7 @@ export function SettingsModal({ isOpen, onClose, initialSection, onLabelsChanged
case 'notification-routing': return <NotificationRoutingSection />; case 'notification-routing': return <NotificationRoutingSection />;
case 'webhooks': return <WebhooksSection isPaid={isPaid} />; case 'webhooks': return <WebhooksSection isPaid={isPaid} />;
case 'security': return <SecuritySection isPaid={isPaid} />; case 'security': return <SecuritySection isPaid={isPaid} />;
case 'cloud-backup': return <CloudBackupSection />;
case 'developer': case 'developer':
return ( return (
<DeveloperSection <DeveloperSection
@@ -0,0 +1,523 @@
import { useEffect, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Combobox } from '@/components/ui/combobox';
import { TogglePill } from '@/components/ui/toggle-pill';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import { AdmiralGate } from '@/components/AdmiralGate';
import { Cloud, CloudOff, RefreshCw, CheckCircle2, AlertCircle, Loader2, Trash2, Download } from 'lucide-react';
type Provider = 'disabled' | 'sencho' | 'custom';
interface CustomConfig {
endpoint: string;
region: string;
bucket: string;
access_key: string;
secret_key: string;
path_prefix: string;
auto_upload: boolean;
}
interface ConfigResponse {
provider: Provider;
sencho_provisioned: boolean;
sencho_provisioned_at: string | null;
custom: CustomConfig;
}
interface UsageResponse {
used_bytes: number;
quota_bytes: number;
object_count: number;
}
interface CloudSnapshotEntry {
objectKey: string;
sizeBytes: number;
lastModified: string | null;
snapshotId: number | null;
}
const EMPTY_CUSTOM: CustomConfig = {
endpoint: '',
region: '',
bucket: '',
access_key: '',
secret_key: '',
path_prefix: 'sencho/',
auto_upload: false,
};
const PROVIDER_OPTIONS = [
{ value: 'disabled', label: 'Disabled' },
{ value: 'sencho', label: 'Sencho Cloud Backup (included)' },
{ value: 'custom', label: 'Custom S3 (BYOB)' },
];
const PANEL_CLASS = 'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3';
export function CloudBackupSection() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [provider, setProvider] = useState<Provider>('disabled');
const [senchoProvisioned, setSenchoProvisioned] = useState(false);
const [custom, setCustom] = useState<CustomConfig>(EMPTY_CUSTOM);
const [originalSecretSaved, setOriginalSecretSaved] = useState(false);
const [usage, setUsage] = useState<UsageResponse | null>(null);
const [snapshots, setSnapshots] = useState<CloudSnapshotEntry[]>([]);
const [testing, setTesting] = useState(false);
const [provisioning, setProvisioning] = useState(false);
const [deleteKey, setDeleteKey] = useState<string | null>(null);
const loadConfig = useCallback(async () => {
try {
const res = await apiFetch('/cloud-backup/config');
if (!res.ok) throw new Error(`Failed to load config (${res.status})`);
const data: ConfigResponse = await res.json();
setProvider(data.provider);
setSenchoProvisioned(data.sencho_provisioned);
setCustom({ ...data.custom, secret_key: '' });
setOriginalSecretSaved(!!data.custom.secret_key);
} catch (err) {
toast.error((err as Error)?.message || 'Failed to load cloud backup config.');
} finally {
setLoading(false);
}
}, []);
const loadUsage = useCallback(async () => {
try {
const res = await apiFetch('/cloud-backup/usage');
if (res.ok) setUsage(await res.json());
} catch {
// Usage is informational; failures shouldn't surface as toasts.
}
}, []);
const loadSnapshots = useCallback(async () => {
try {
const res = await apiFetch('/cloud-backup/snapshots');
if (res.ok) setSnapshots(await res.json());
} catch {
// Best-effort; the panel renders empty when listing fails.
}
}, []);
useEffect(() => {
loadConfig();
}, [loadConfig]);
useEffect(() => {
if (provider === 'sencho' && senchoProvisioned) loadUsage();
}, [provider, senchoProvisioned, loadUsage]);
useEffect(() => {
if (provider !== 'disabled') loadSnapshots();
else setSnapshots([]);
}, [provider, loadSnapshots]);
const handleProviderChange = async (next: string) => {
const nextProvider = next as Provider;
setProvider(nextProvider);
if (nextProvider === 'sencho' && !senchoProvisioned) return;
setSaving(true);
try {
const body = nextProvider === 'custom' ? { provider: nextProvider, custom: { ...custom, secret_key: '' } } : { provider: nextProvider };
const res = await apiFetch('/cloud-backup/config', {
method: 'PUT',
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to save provider');
}
toast.success('Cloud backup provider updated.');
} catch (err) {
toast.error((err as Error)?.message || 'Failed to update provider.');
} finally {
setSaving(false);
}
};
const handleSaveCustom = async () => {
setSaving(true);
try {
const payload = {
provider: 'custom',
custom: {
endpoint: custom.endpoint,
region: custom.region,
bucket: custom.bucket,
access_key: custom.access_key,
secret_key: custom.secret_key || (originalSecretSaved ? '***' : ''),
path_prefix: custom.path_prefix,
auto_upload: custom.auto_upload,
},
};
const res = await apiFetch('/cloud-backup/config', {
method: 'PUT',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to save configuration');
}
toast.success('Custom S3 configuration saved.');
setCustom(c => ({ ...c, secret_key: '' }));
setOriginalSecretSaved(true);
loadSnapshots();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to save configuration.');
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setTesting(true);
try {
const res = await apiFetch('/cloud-backup/test', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (data.success) toast.success('Connection successful.');
else toast.error(data.error || 'Connection test failed.');
} catch (err) {
toast.error((err as Error)?.message || 'Connection test failed.');
} finally {
setTesting(false);
}
};
const handleProvision = async () => {
setProvisioning(true);
try {
const res = await apiFetch('/cloud-backup/provision', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.error) throw new Error(data?.error || 'Provisioning failed.');
toast.success('Sencho Cloud Backup activated.');
setSenchoProvisioned(true);
await Promise.all([loadConfig(), loadUsage(), loadSnapshots()]);
} catch (err) {
toast.error((err as Error)?.message || 'Provisioning failed.');
} finally {
setProvisioning(false);
}
};
const handleAutoUploadToggle = async (next: boolean) => {
setCustom(c => ({ ...c, auto_upload: next }));
try {
const res = await apiFetch('/cloud-backup/config', {
method: 'PUT',
body: JSON.stringify({
provider: 'custom',
custom: { ...custom, auto_upload: next, secret_key: originalSecretSaved ? '***' : '' },
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to update auto-upload');
}
} catch (err) {
toast.error((err as Error)?.message || 'Failed to update auto-upload.');
setCustom(c => ({ ...c, auto_upload: !next }));
}
};
const confirmDelete = async () => {
if (!deleteKey) return;
try {
const encoded = btoa(deleteKey).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const res = await apiFetch(`/cloud-backup/object/${encoded}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to delete cloud snapshot');
}
toast.success('Cloud snapshot deleted.');
loadSnapshots();
if (provider === 'sencho') loadUsage();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to delete cloud snapshot.');
} finally {
setDeleteKey(null);
}
};
if (loading) {
return (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
</div>
);
}
const usagePercent = usage && usage.quota_bytes > 0 ? Math.min(100, Math.round((usage.used_bytes / usage.quota_bytes) * 100)) : 0;
const usageColor = usagePercent >= 90 ? 'var(--destructive)' : usagePercent >= 80 ? 'var(--warning)' : 'var(--brand)';
return (
<AdmiralGate featureName="Cloud Backup">
<div className="space-y-6">
<div className={PANEL_CLASS}>
<Label className="text-sm">Storage Mode</Label>
<Combobox
options={PROVIDER_OPTIONS}
value={provider}
onValueChange={handleProviderChange}
disabled={saving}
/>
<p className="text-xs text-muted-foreground">
Choose where fleet snapshots are replicated. Sencho Cloud Backup is included with the Admiral tier.
</p>
</div>
{provider === 'sencho' && !senchoProvisioned && (
<div className={PANEL_CLASS}>
<div className="flex items-center gap-2">
<Cloud className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<span className="font-medium text-sm">Activate Sencho Cloud Backup</span>
</div>
<p className="text-xs text-muted-foreground">
Activates a 500 MB allowance backed by Cloudflare R2, scoped to this Admiral license.
</p>
<Button size="sm" onClick={handleProvision} disabled={provisioning}>
{provisioning ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : <Cloud className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />}
Activate
</Button>
</div>
)}
{provider === 'sencho' && senchoProvisioned && (
<div className={PANEL_CLASS}>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-success" strokeWidth={1.5} />
<span className="font-medium text-sm">Sencho Cloud Backup</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={handleTest} disabled={testing}>
{testing ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : null}
Test
</Button>
<Button size="sm" variant="ghost" onClick={handleProvision} disabled={provisioning}>
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
Reprovision
</Button>
</div>
</div>
{usage && (
<div className="rounded-lg border border-glass-border px-3 py-2.5 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Storage used</span>
<span className="text-stat-subtitle font-mono text-xs">
{formatBytes(usage.used_bytes)} / {formatBytes(usage.quota_bytes)} ({usage.object_count} objects)
</span>
</div>
<div className="h-1.5 rounded-full bg-muted/60 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${usagePercent}%`,
backgroundColor: usageColor,
boxShadow: `0 0 8px ${usageColor}`,
}}
/>
</div>
</div>
)}
<div className="flex items-start gap-2 rounded-lg border border-glass-border bg-muted/30 px-3 py-2.5">
<Cloud className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} />
<p className="text-xs text-muted-foreground">
Auto-upload is on for Sencho Cloud Backup. Every fleet snapshot is replicated within seconds.
</p>
</div>
</div>
)}
{provider === 'custom' && (
<div className={PANEL_CLASS}>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Cloud className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<span className="font-medium text-sm">Custom S3 Configuration</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={handleTest} disabled={testing || saving}>
{testing ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : null}
Test
</Button>
<Button size="sm" onClick={handleSaveCustom} disabled={saving}>
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : null}
Save
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Endpoint URL</Label>
<Input
placeholder="https://s3.us-east-1.amazonaws.com"
value={custom.endpoint}
onChange={e => setCustom({ ...custom, endpoint: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Region</Label>
<Input
placeholder="us-east-1"
value={custom.region}
onChange={e => setCustom({ ...custom, region: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Bucket</Label>
<Input
placeholder="my-sencho-backups"
value={custom.bucket}
onChange={e => setCustom({ ...custom, bucket: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Path Prefix</Label>
<Input
placeholder="sencho/"
value={custom.path_prefix}
onChange={e => setCustom({ ...custom, path_prefix: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Access Key ID</Label>
<Input
placeholder="AKIA..."
value={custom.access_key}
onChange={e => setCustom({ ...custom, access_key: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Secret Access Key</Label>
<Input
type="password"
placeholder={originalSecretSaved && !custom.secret_key ? '•••• saved ••••' : ''}
value={custom.secret_key}
onChange={e => setCustom({ ...custom, secret_key: e.target.value })}
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Auto-upload</Label>
<p className="text-xs text-muted-foreground">Automatically upload every fleet snapshot to this bucket.</p>
</div>
<TogglePill checked={custom.auto_upload} onChange={handleAutoUploadToggle} />
</div>
</div>
)}
{provider !== 'disabled' && (
<div className={PANEL_CLASS}>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Cloud Snapshots</span>
<Button size="sm" variant="ghost" onClick={loadSnapshots}>
<RefreshCw className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
{snapshots.length === 0 ? (
<div className="flex items-start gap-2 text-xs text-muted-foreground py-2">
<CloudOff className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
No cloud snapshots yet. The next fleet snapshot will appear here.
</div>
) : (
<ul className="space-y-1.5">
{snapshots.map(s => (
<li key={s.objectKey} className="flex items-center justify-between gap-2 rounded-md border border-glass-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-mono truncate">{s.objectKey.split('/').pop()}</div>
<div className="text-[10px] text-stat-subtitle font-mono">
{formatBytes(s.sizeBytes)} {s.lastModified ? `· ${new Date(s.lastModified).toLocaleString()}` : ''}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={async () => {
const encoded = btoa(s.objectKey).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
try {
const res = await apiFetch(`/cloud-backup/object/${encoded}/download`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || `Download failed (${res.status})`);
}
const blob = await res.blob();
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = s.objectKey.split('/').pop() || 'snapshot.tar.gz';
link.click();
URL.revokeObjectURL(link.href);
} catch (err) {
toast.error((err as Error)?.message || 'Download failed.');
}
}}
title="Download"
>
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => setDeleteKey(s.objectKey)}
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
)}
<AlertDialog open={!!deleteKey} onOpenChange={open => !open && setDeleteKey(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-destructive" strokeWidth={1.5} />
Delete cloud snapshot?
</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes the archive from your bucket. The local SQLite copy is unaffected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</AdmiralGate>
);
}
@@ -6,6 +6,7 @@ export { SystemSection } from './SystemSection';
export { NotificationsSection } from './NotificationsSection'; export { NotificationsSection } from './NotificationsSection';
export { WebhooksSection } from './WebhooksSection'; export { WebhooksSection } from './WebhooksSection';
export { SecuritySection } from './SecuritySection'; export { SecuritySection } from './SecuritySection';
export { CloudBackupSection } from './CloudBackupSection';
export { DeveloperSection } from './DeveloperSection'; export { DeveloperSection } from './DeveloperSection';
export { AppStoreSection } from './AppStoreSection'; export { AppStoreSection } from './AppStoreSection';
export { SupportSection } from './SupportSection'; export { SupportSection } from './SupportSection';
@@ -114,6 +114,17 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
adminOnly: true, adminOnly: true,
hiddenOnRemote: true, hiddenOnRemote: true,
}, },
{
id: 'cloud-backup',
group: 'system',
label: 'Cloud Backup',
description: 'Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage.',
keywords: ['cloud', 'backup', 'snapshot', 's3', 'r2', 'minio', 'storage', 'offsite'],
tier: 'admiral',
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{ {
id: 'nodes', id: 'nodes',
group: 'system', group: 'system',
@@ -37,6 +37,7 @@ export type SectionId =
| 'notifications' | 'notifications'
| 'webhooks' | 'webhooks'
| 'security' | 'security'
| 'cloud-backup'
| 'developer' | 'developer'
| 'nodes' | 'nodes'
| 'appstore' | 'appstore'