feat: RBAC, atomic deployments, and fleet-wide backups (Pro) (#181)

* feat: add RBAC viewer accounts, atomic deployments, and fleet-wide backups (Pro)

Introduces three Pro-tier features:

- RBAC: Multi-user system with admin/viewer roles, user management UI,
  automatic migration from single-admin credentials, viewer restrictions
  across the entire UI (read-only editor, hidden action buttons)

- Atomic Deployments: Pre-deploy file backup to .sencho-backup/, automatic
  rollback on health probe failure, manual rollback button, health probes
  added to stack updates, webhook-triggered deploys use atomic rollback

- Fleet-Wide Backups: Point-in-time snapshots of compose files across all
  nodes (local + remote), stored centrally in SQLite, per-stack restore
  with optional redeploy, graceful handling of offline nodes

* fix(settings): use correct ProGate prop name in UsersSection

* fix(settings): remove unused isPro prop from UsersSection

* fix(auth): fetch user info after login and setup so isAdmin is set correctly
This commit is contained in:
Anso
2026-03-26 12:51:30 -04:00
committed by GitHub
parent 72670ffb42
commit db73d7671a
21 changed files with 2466 additions and 292 deletions
+124 -40
View File
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
import path from 'path';
import WebSocket from 'ws';
import DockerController from './DockerController';
import { FileSystemService } from './FileSystemService';
import { LogFormatter } from './LogFormatter';
import { NodeRegistry } from './NodeRegistry';
@@ -77,43 +78,74 @@ export class ComposeService {
await this.execute('docker', ['compose', action], stackDir, ws);
}
async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const sendOutput = (data: string) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
};
try {
const dockerController = DockerController.getInstance(this.nodeId);
const legacyContainers = await dockerController.getContainersByStack(stackName);
if (legacyContainers && legacyContainers.length > 0) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(`=== Cleaning up existing containers for clean deployment ===\n`);
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
// Atomic: backup files before deploying
if (atomic) {
try {
const fsSvc = FileSystemService.getInstance(this.nodeId);
await fsSvc.backupStackFiles(stackName);
sendOutput('=== Backup created for atomic deployment ===\n');
} catch (e) {
console.warn(`Failed to backup stack files for ${stackName}:`, e);
}
} catch (e) {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
try {
try {
const dockerController = DockerController.getInstance(this.nodeId);
const legacyContainers = await dockerController.getContainersByStack(stackName);
if (legacyContainers && legacyContainers.length > 0) {
sendOutput(`=== Cleaning up existing containers for clean deployment ===\n`);
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
}
} catch (e) {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
// Post-Deploy Health Probe
await new Promise(resolve => setTimeout(resolve, 3000));
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
const dockerController = DockerController.getInstance(this.nodeId);
const containers = await dockerController.getDocker().listContainers({
all: true,
filters: { label: [`com.docker.compose.project=${stackName}`] }
});
// Post-Deploy Health Probe
await new Promise(resolve => setTimeout(resolve, 3000));
for (const containerInfo of containers) {
if (containerInfo.State === 'exited') {
const container = dockerController.getDocker().getContainer(containerInfo.Id);
const inspectData = await container.inspect();
const exitCode = inspectData.State.ExitCode;
const dockerController = DockerController.getInstance(this.nodeId);
const containers = await dockerController.getDocker().listContainers({
all: true,
filters: { label: [`com.docker.compose.project=${stackName}`] }
});
if (exitCode !== 0) {
const logs = await container.logs({ stdout: true, stderr: true, tail: 50 });
const logStr = logs.toString('utf-8');
throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`);
for (const containerInfo of containers) {
if (containerInfo.State === 'exited') {
const container = dockerController.getDocker().getContainer(containerInfo.Id);
const inspectData = await container.inspect();
const exitCode = inspectData.State.ExitCode;
if (exitCode !== 0) {
const logs = await container.logs({ stdout: true, stderr: true, tail: 50 });
const logStr = logs.toString('utf-8');
throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`);
}
}
}
} catch (deployError) {
// Atomic: auto-rollback on failure
if (atomic) {
sendOutput('\n=== Deployment failed — rolling back to previous version ===\n');
try {
const fsSvc = FileSystemService.getInstance(this.nodeId);
await fsSvc.restoreStackFiles(stackName);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
sendOutput('=== Rolled back successfully ===\n');
} catch (rollbackError) {
console.error(`Rollback failed for ${stackName}:`, rollbackError);
sendOutput('=== Rollback failed — manual intervention may be required ===\n');
}
}
throw deployError;
}
}
@@ -228,29 +260,81 @@ export class ComposeService {
startStream();
}
async updateStack(stackName: string, ws?: WebSocket): Promise<void> {
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const sendOutput = (data: string) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
};
try {
const dockerController = DockerController.getInstance(this.nodeId);
const legacyContainers = await dockerController.getContainersByStack(stackName);
if (legacyContainers && legacyContainers.length > 0) {
sendOutput(`=== Cleaning up existing containers for clean update ===\n`);
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
// Atomic: backup files before updating
if (atomic) {
try {
const fsSvc = FileSystemService.getInstance(this.nodeId);
await fsSvc.backupStackFiles(stackName);
sendOutput('=== Backup created for atomic update ===\n');
} catch (e) {
console.warn(`Failed to backup stack files for ${stackName}:`, e);
}
} catch (e) {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
sendOutput('=== Pulling latest images ===\n');
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
try {
try {
const dockerController = DockerController.getInstance(this.nodeId);
const legacyContainers = await dockerController.getContainersByStack(stackName);
if (legacyContainers && legacyContainers.length > 0) {
sendOutput(`=== Cleaning up existing containers for clean update ===\n`);
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
}
} catch (e) {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
sendOutput('=== Recreating containers ===\n');
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
sendOutput('=== Stack updated successfully ===\n');
sendOutput('=== Pulling latest images ===\n');
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
sendOutput('=== Recreating containers ===\n');
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
// Post-Update Health Probe
await new Promise(resolve => setTimeout(resolve, 3000));
const dockerController = DockerController.getInstance(this.nodeId);
const containers = await dockerController.getDocker().listContainers({
all: true,
filters: { label: [`com.docker.compose.project=${stackName}`] }
});
for (const containerInfo of containers) {
if (containerInfo.State === 'exited') {
const container = dockerController.getDocker().getContainer(containerInfo.Id);
const inspectData = await container.inspect();
const exitCode = inspectData.State.ExitCode;
if (exitCode !== 0) {
const logs = await container.logs({ stdout: true, stderr: true, tail: 50 });
const logStr = logs.toString('utf-8');
throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`);
}
}
}
sendOutput('=== Stack updated successfully ===\n');
} catch (updateError) {
// Atomic: auto-rollback on failure
if (atomic) {
sendOutput('\n=== Update failed — rolling back to previous version ===\n');
try {
const fsSvc = FileSystemService.getInstance(this.nodeId);
await fsSvc.restoreStackFiles(stackName);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
sendOutput('=== Rolled back successfully ===\n');
} catch (rollbackError) {
console.error(`Rollback failed for ${stackName}:`, rollbackError);
sendOutput('=== Rollback failed — manual intervention may be required ===\n');
}
}
throw updateError;
}
}
public async downStack(stackName: string): Promise<void> {