mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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:
@@ -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> {
|
||||
|
||||
@@ -59,6 +59,15 @@ export interface WebhookExecution {
|
||||
executed_at: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: 'admin' | 'viewer';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface NotificationHistory {
|
||||
id?: number;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
@@ -67,6 +76,26 @@ export interface NotificationHistory {
|
||||
is_read: boolean;
|
||||
}
|
||||
|
||||
export interface FleetSnapshot {
|
||||
id: number;
|
||||
description: string;
|
||||
created_by: string;
|
||||
node_count: number;
|
||||
stack_count: number;
|
||||
skipped_nodes: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface FleetSnapshotFile {
|
||||
id: number;
|
||||
snapshot_id: number;
|
||||
node_id: number;
|
||||
node_name: string;
|
||||
stack_name: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class DatabaseService {
|
||||
private static instance: DatabaseService;
|
||||
private db: Database.Database;
|
||||
@@ -83,6 +112,7 @@ export class DatabaseService {
|
||||
|
||||
this.initSchema();
|
||||
this.migrateJsonConfig(dataDir);
|
||||
this.migrateAdminToUsersTable();
|
||||
}
|
||||
|
||||
public static getInstance(): DatabaseService {
|
||||
@@ -188,6 +218,38 @@ export class DatabaseService {
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_executions_webhook ON webhook_executions(webhook_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fleet_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL,
|
||||
node_count INTEGER NOT NULL,
|
||||
stack_count INTEGER NOT NULL,
|
||||
skipped_nodes TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fleet_snapshot_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
node_name TEXT NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
FOREIGN KEY(snapshot_id) REFERENCES fleet_snapshots(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshot_files_snapshot ON fleet_snapshot_files(snapshot_id);
|
||||
`);
|
||||
|
||||
// Apply migrations safely (ignore if columns already exist)
|
||||
@@ -231,6 +293,22 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private migrateAdminToUsersTable(): void {
|
||||
const userCount = (this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number })?.count || 0;
|
||||
if (userCount > 0) return;
|
||||
|
||||
const settings = this.getGlobalSettings();
|
||||
const username = settings.auth_username;
|
||||
const passwordHash = settings.auth_password_hash;
|
||||
if (!username || !passwordHash) return;
|
||||
|
||||
const now = Date.now();
|
||||
this.db.prepare(
|
||||
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(username, passwordHash, 'admin', now, now);
|
||||
console.log(`Migrated admin user "${username}" to users table.`);
|
||||
}
|
||||
|
||||
private migrateJsonConfig(dataDir: string) {
|
||||
const configPath = path.join(dataDir, 'sencho.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
@@ -593,4 +671,105 @@ export class DatabaseService {
|
||||
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
public getUsers(): Omit<User, 'password_hash'>[] {
|
||||
return this.db.prepare('SELECT id, username, role, created_at, updated_at FROM users ORDER BY created_at ASC').all() as Omit<User, 'password_hash'>[];
|
||||
}
|
||||
|
||||
public getUser(id: number): User | undefined {
|
||||
return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
}
|
||||
|
||||
public getUserByUsername(username: string): User | undefined {
|
||||
return this.db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined;
|
||||
}
|
||||
|
||||
public addUser(user: { username: string; password_hash: string; role: 'admin' | 'viewer' }): number {
|
||||
const now = Date.now();
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(user.username, user.password_hash, user.role, now, now);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
public updateUser(id: number, updates: Partial<{ username: string; password_hash: string; role: string }>): void {
|
||||
const fields: string[] = [];
|
||||
const values: (string | number)[] = [];
|
||||
|
||||
if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); }
|
||||
if (updates.password_hash !== undefined) { fields.push('password_hash = ?'); values.push(updates.password_hash); }
|
||||
if (updates.role !== undefined) { fields.push('role = ?'); values.push(updates.role); }
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
values.push(id);
|
||||
this.db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
}
|
||||
|
||||
public deleteUser(id: number): void {
|
||||
this.db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
public getUserCount(): number {
|
||||
return (this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number })?.count || 0;
|
||||
}
|
||||
|
||||
public getAdminCount(): number {
|
||||
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number })?.count || 0;
|
||||
}
|
||||
|
||||
// --- Fleet Snapshots ---
|
||||
|
||||
public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string): number {
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO fleet_snapshots (description, created_by, node_count, stack_count, skipped_nodes, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(description, createdBy, nodeCount, stackCount, skippedNodes, Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
public insertSnapshotFiles(snapshotId: number, files: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>): void {
|
||||
const insert = this.db.prepare(
|
||||
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const insertMany = this.db.transaction((rows: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>) => {
|
||||
for (const row of rows) {
|
||||
insert.run(snapshotId, row.nodeId, row.nodeName, row.stackName, row.filename, row.content);
|
||||
}
|
||||
});
|
||||
insertMany(files);
|
||||
}
|
||||
|
||||
public getSnapshots(limit = 50, offset = 0): FleetSnapshot[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM fleet_snapshots ORDER BY created_at DESC LIMIT ? OFFSET ?'
|
||||
).all(limit, offset) as FleetSnapshot[];
|
||||
}
|
||||
|
||||
public getSnapshot(id: number): FleetSnapshot | undefined {
|
||||
return this.db.prepare('SELECT * FROM fleet_snapshots WHERE id = ?').get(id) as FleetSnapshot | undefined;
|
||||
}
|
||||
|
||||
public getSnapshotFiles(snapshotId: number): FleetSnapshotFile[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM fleet_snapshot_files WHERE snapshot_id = ? ORDER BY node_name, stack_name'
|
||||
).all(snapshotId) as FleetSnapshotFile[];
|
||||
}
|
||||
|
||||
public getSnapshotStackFiles(snapshotId: number, nodeId: number, stackName: string): FleetSnapshotFile[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM fleet_snapshot_files WHERE snapshot_id = ? AND node_id = ? AND stack_name = ?'
|
||||
).all(snapshotId, nodeId, stackName) as FleetSnapshotFile[];
|
||||
}
|
||||
|
||||
public deleteSnapshot(id: number): void {
|
||||
this.db.prepare('DELETE FROM fleet_snapshots WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
public getSnapshotCount(): number {
|
||||
return (this.db.prepare('SELECT COUNT(*) as count FROM fleet_snapshots').get() as { count: number })?.count || 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,4 +236,70 @@ export class FileSystemService {
|
||||
console.error('Migration error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup stack files (compose.yaml + .env) to .sencho-backup/ within the stack dir.
|
||||
*/
|
||||
async backupStackFiles(stackName: string): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const backupDir = path.join(stackDir, '.sencho-backup');
|
||||
await fsPromises.mkdir(backupDir, { recursive: true });
|
||||
|
||||
// Copy compose file
|
||||
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||
for (const file of composeFiles) {
|
||||
const src = path.join(stackDir, file);
|
||||
try {
|
||||
await fsPromises.access(src);
|
||||
await fsPromises.copyFile(src, path.join(backupDir, file));
|
||||
} catch {
|
||||
// File doesn't exist, skip
|
||||
}
|
||||
}
|
||||
|
||||
// Copy .env if it exists
|
||||
const envSrc = path.join(stackDir, '.env');
|
||||
try {
|
||||
await fsPromises.access(envSrc);
|
||||
await fsPromises.copyFile(envSrc, path.join(backupDir, '.env'));
|
||||
} catch {
|
||||
// No .env to backup
|
||||
}
|
||||
|
||||
// Write timestamp marker
|
||||
await fsPromises.writeFile(path.join(backupDir, '.timestamp'), Date.now().toString(), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore stack files from .sencho-backup/ back to the stack dir.
|
||||
*/
|
||||
async restoreStackFiles(stackName: string): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const backupDir = path.join(stackDir, '.sencho-backup');
|
||||
|
||||
const items = await fsPromises.readdir(backupDir);
|
||||
for (const item of items) {
|
||||
if (item === '.timestamp') continue;
|
||||
await fsPromises.copyFile(path.join(backupDir, item), path.join(stackDir, item));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup info for a stack.
|
||||
*/
|
||||
async getBackupInfo(stackName: string): Promise<{ exists: boolean; timestamp: number | null }> {
|
||||
const backupDir = path.join(this.baseDir, stackName, '.sencho-backup');
|
||||
try {
|
||||
await fsPromises.access(backupDir);
|
||||
const tsFile = path.join(backupDir, '.timestamp');
|
||||
try {
|
||||
const ts = await fsPromises.readFile(tsFile, 'utf-8');
|
||||
return { exists: true, timestamp: parseInt(ts, 10) || null };
|
||||
} catch {
|
||||
return { exists: true, timestamp: null };
|
||||
}
|
||||
} catch {
|
||||
return { exists: false, timestamp: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class WebhookService {
|
||||
);
|
||||
}
|
||||
|
||||
public async execute(webhookId: number, action: string, triggerSource: string | null): Promise<{ success: boolean; error?: string; duration_ms: number }> {
|
||||
public async execute(webhookId: number, action: string, triggerSource: string | null, atomic?: boolean): Promise<{ success: boolean; error?: string; duration_ms: number }> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const webhook = db.getWebhook(webhookId);
|
||||
if (!webhook) throw new Error('Webhook not found');
|
||||
@@ -62,7 +62,7 @@ export class WebhookService {
|
||||
const compose = ComposeService.getInstance(defaultNodeId);
|
||||
switch (action) {
|
||||
case 'deploy':
|
||||
await compose.deployStack(webhook.stack_name);
|
||||
await compose.deployStack(webhook.stack_name, undefined, atomic);
|
||||
break;
|
||||
case 'restart':
|
||||
await compose.runCommand(webhook.stack_name, 'restart');
|
||||
@@ -74,7 +74,7 @@ export class WebhookService {
|
||||
await compose.runCommand(webhook.stack_name, 'start');
|
||||
break;
|
||||
case 'pull':
|
||||
await compose.updateStack(webhook.stack_name);
|
||||
await compose.updateStack(webhook.stack_name, undefined, atomic);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
|
||||
Reference in New Issue
Block a user