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
@@ -34,6 +34,9 @@ export async function setupTestDb(): Promise<string> {
db.updateGlobalSetting('auth_password_hash', passwordHash);
db.updateGlobalSetting('auth_jwt_secret', TEST_JWT_SECRET);
// Also seed the users table (RBAC login reads from here)
db.addUser({ username: TEST_USERNAME, password_hash: passwordHash, role: 'admin' });
return tmpDir;
}
+604 -24
View File
@@ -195,7 +195,7 @@ app.use(nodeContextMiddleware);
declare global {
namespace Express {
interface Request {
user?: { username: string };
user?: { username: string; role: 'admin' | 'viewer' };
nodeId: number;
}
}
@@ -228,9 +228,9 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret');
const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string };
// Accept both user sessions and node proxy tokens
req.user = { username: decoded.username || 'node-proxy' };
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string };
// Accept both user sessions and node proxy tokens. Default role to 'admin' for backward compat with pre-RBAC tokens.
req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as 'admin' | 'viewer') || 'admin' };
next();
} catch (err) {
console.error('[Auth] Token validation failed:', (err as Error).message);
@@ -314,8 +314,11 @@ app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response)
dbSvc.updateGlobalSetting('admin_email', admin_email.trim());
}
// Create admin user in users table
dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' });
// Issue JWT and log user in
const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' });
const token = jwt.sign({ username, role: 'admin' }, jwtSecret, { expiresIn: '24h' });
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
res.json({ success: true, message: 'Setup completed successfully' });
} catch (error) {
@@ -334,16 +337,16 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response)
}
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const storedUsername = settings.auth_username;
const storedHash = settings.auth_password_hash;
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(username);
if (storedUsername && storedHash && username === storedUsername) {
const isValid = await bcrypt.compare(password, storedHash);
if (user) {
const isValid = await bcrypt.compare(password, user.password_hash);
if (isValid) {
const settings = db.getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('JWT secret missing from DB');
const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' });
const token = jwt.sign({ username: user.username, role: user.role }, jwtSecret, { expiresIn: '24h' });
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
res.json({ success: true, message: 'Login successful' });
return;
@@ -357,7 +360,7 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response)
}
});
// Update password endpoint
// Update password endpoint — any authenticated user can change their own password
app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
const { oldPassword, newPassword } = req.body;
@@ -371,21 +374,22 @@ app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response
}
const dbSvc = DatabaseService.getInstance();
const settings = dbSvc.getGlobalSettings();
const storedHash = settings.auth_password_hash;
const user = dbSvc.getUserByUsername(req.user!.username);
if (!storedHash) {
res.status(400).json({ error: 'Auth not configured properly' });
if (!user) {
res.status(400).json({ error: 'User not found' });
return;
}
const isValid = await bcrypt.compare(oldPassword, storedHash);
const isValid = await bcrypt.compare(oldPassword, user.password_hash);
if (!isValid) {
res.status(401).json({ error: 'Invalid old password' });
return;
}
const newHash = await bcrypt.hash(newPassword, 10);
dbSvc.updateUser(user.id, { password_hash: newHash });
// Keep global_settings in sync for backward compat
dbSvc.updateGlobalSetting('auth_password_hash', newHash);
res.json({ success: true, message: 'Password updated successfully' });
} catch (error) {
@@ -410,6 +414,7 @@ app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void =
// Generate a long-lived node proxy token for Sencho-to-Sencho authentication
app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
@@ -445,6 +450,14 @@ const requirePro = (_req: Request, res: Response): boolean => {
return true;
};
const requireAdmin = (req: Request, res: Response): boolean => {
if (req.user?.role !== 'admin') {
res.status(403).json({ error: 'Admin access required.', code: 'ADMIN_REQUIRED' });
return false;
}
return true;
};
app.get('/api/license', (_req: Request, res: Response): void => {
try {
const info = LicenseService.getInstance().getLicenseInfo();
@@ -456,6 +469,7 @@ app.get('/api/license', (_req: Request, res: Response): void => {
});
app.post('/api/license/activate', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const { license_key } = req.body;
if (!license_key || typeof license_key !== 'string') {
@@ -475,6 +489,7 @@ app.post('/api/license/activate', async (req: Request, res: Response): Promise<v
});
app.post('/api/license/deactivate', async (_req: Request, res: Response): Promise<void> => {
if (!requireAdmin(_req, res)) return;
try {
const result = await LicenseService.getInstance().deactivate();
if (result.success) {
@@ -751,6 +766,354 @@ async function fetchRemoteNodeOverview(node: Node): Promise<FleetNodeOverview> {
}
}
// ─── Fleet Snapshots (Pro) ───
interface SnapshotNodeData {
nodeId: number;
nodeName: string;
stacks: Array<{
stackName: string;
files: Array<{ filename: string; content: string }>;
}>;
}
async function captureLocalNodeFiles(node: Node): Promise<SnapshotNodeData> {
const fsService = FileSystemService.getInstance(node.id);
const stackNames = await fsService.getStacks();
const stacks: SnapshotNodeData['stacks'] = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeContent = await fsService.getStackContent(stackName);
files.push({ filename: 'compose.yaml', content: composeContent });
} catch {
// Stack has no compose file — skip
continue;
}
try {
const envContent = await fsService.getEnvContent(stackName);
files.push({ filename: '.env', content: envContent });
} catch {
// No .env file — that's fine
}
stacks.push({ stackName, files });
}
return { nodeId: node.id, nodeName: node.name, stacks };
}
async function captureRemoteNodeFiles(node: Node): Promise<SnapshotNodeData> {
if (!node.api_url || !node.api_token) {
throw new Error('Remote node not configured');
}
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = { Authorization: `Bearer ${node.api_token}` };
const stacksRes = await fetch(`${baseUrl}/api/stacks`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node');
const stackNames = await stacksRes.json() as string[];
const stacks: SnapshotNodeData['stacks'] = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (composeRes.ok) {
const content = await composeRes.text();
files.push({ filename: 'compose.yaml', content });
}
} catch {
continue;
}
try {
const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (envRes.ok) {
const content = await envRes.text();
files.push({ filename: '.env', content });
}
} catch {
// No .env — skip
}
if (files.length > 0) {
stacks.push({ stackName, files });
}
}
return { nodeId: node.id, nodeName: node.name, stacks };
}
// Create fleet snapshot
app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePro(req, res)) return;
try {
const { description = '' } = req.body;
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const username = req.user?.username || 'admin';
const results = await Promise.allSettled(
nodes.map(async (node) => {
if (node.type === 'remote') {
return captureRemoteNodeFiles(node);
}
return captureLocalNodeFiles(node);
})
);
const capturedNodes: SnapshotNodeData[] = [];
const skippedNodes: Array<{ nodeId: number; nodeName: string; reason: string }> = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
capturedNodes.push(result.value);
} else {
console.error(`[Fleet Snapshot] Failed to capture node ${nodes[i].name}:`, result.reason);
skippedNodes.push({
nodeId: nodes[i].id,
nodeName: nodes[i].name,
reason: result.reason instanceof Error ? result.reason.message : 'Unknown error',
});
}
});
let totalStacks = 0;
const allFiles: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }> = [];
for (const nodeData of capturedNodes) {
totalStacks += nodeData.stacks.length;
for (const stack of nodeData.stacks) {
for (const file of stack.files) {
allFiles.push({
nodeId: nodeData.nodeId,
nodeName: nodeData.nodeName,
stackName: stack.stackName,
filename: file.filename,
content: file.content,
});
}
}
}
const snapshotId = db.createSnapshot(
description,
username,
capturedNodes.length,
totalStacks,
JSON.stringify(skippedNodes),
);
if (allFiles.length > 0) {
db.insertSnapshotFiles(snapshotId, allFiles);
}
const snapshot = db.getSnapshot(snapshotId);
res.status(201).json(snapshot);
} catch (error) {
console.error('[Fleet Snapshot] Create error:', error);
res.status(500).json({ error: 'Failed to create fleet snapshot' });
}
});
// List fleet snapshots
app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
if (!requirePro(req, res)) return;
try {
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const offset = parseInt(req.query.offset as string, 10) || 0;
const db = DatabaseService.getInstance();
const snapshots = db.getSnapshots(limit, offset);
const total = db.getSnapshotCount();
res.json({ snapshots, total });
} catch (error) {
console.error('[Fleet Snapshot] List error:', error);
res.status(500).json({ error: 'Failed to list fleet snapshots' });
}
});
// Get snapshot detail
app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise<void> => {
if (!requirePro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(id);
if (!snapshot) {
res.status(404).json({ error: 'Snapshot not found' });
return;
}
const files = db.getSnapshotFiles(id);
// Group files by node and stack
const nodesMap = new Map<number, { nodeId: number; nodeName: string; stacks: Map<string, Array<{ filename: string; content: string }>> }>();
for (const file of files) {
if (!nodesMap.has(file.node_id)) {
nodesMap.set(file.node_id, { nodeId: file.node_id, nodeName: file.node_name, stacks: new Map() });
}
const nodeEntry = nodesMap.get(file.node_id)!;
if (!nodeEntry.stacks.has(file.stack_name)) {
nodeEntry.stacks.set(file.stack_name, []);
}
nodeEntry.stacks.get(file.stack_name)!.push({ filename: file.filename, content: file.content });
}
const nodes = Array.from(nodesMap.values()).map(n => ({
nodeId: n.nodeId,
nodeName: n.nodeName,
stacks: Array.from(n.stacks.entries()).map(([stackName, stackFiles]) => ({
stackName,
files: stackFiles,
})),
}));
res.json({ ...snapshot, nodes });
} catch (error) {
console.error('[Fleet Snapshot] Detail error:', error);
res.status(500).json({ error: 'Failed to fetch snapshot details' });
}
});
// Restore a stack from snapshot
app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePro(req, res)) return;
try {
const snapshotId = parseInt(req.params.id as string, 10);
const { nodeId, stackName, redeploy = false } = req.body;
if (!nodeId || !stackName) {
res.status(400).json({ error: 'nodeId and stackName are required' });
return;
}
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(snapshotId);
if (!snapshot) {
res.status(404).json({ error: 'Snapshot not found' });
return;
}
const files = db.getSnapshotStackFiles(snapshotId, nodeId, stackName);
if (files.length === 0) {
res.status(404).json({ error: 'No files found for this stack in the snapshot' });
return;
}
const node = db.getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Target node no longer exists' });
return;
}
if (node.type === 'local') {
const fsService = FileSystemService.getInstance(node.id);
// Backup current files before restore
try {
await fsService.backupStackFiles(stackName);
} catch {
// Stack may not exist yet — that's ok
}
for (const file of files) {
if (file.filename === 'compose.yaml') {
await fsService.saveStackContent(stackName, file.content);
} else if (file.filename === '.env') {
await fsService.saveEnvContent(stackName, file.content);
}
}
if (redeploy) {
const composeService = ComposeService.getInstance(node.id);
await composeService.deployStack(stackName);
}
} else {
// Remote node
if (!node.api_url || !node.api_token) {
res.status(503).json({ error: 'Remote node not configured' });
return;
}
const baseUrl = node.api_url.replace(/\/$/, '');
const headers: Record<string, string> = {
Authorization: `Bearer ${node.api_token}`,
'Content-Type': 'application/json',
};
for (const file of files) {
if (file.filename === 'compose.yaml') {
const putRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
method: 'PUT',
headers,
body: JSON.stringify({ content: file.content }),
signal: AbortSignal.timeout(15000),
});
if (!putRes.ok) throw new Error('Failed to restore compose file on remote node');
} else if (file.filename === '.env') {
const putRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
method: 'PUT',
headers,
body: JSON.stringify({ content: file.content }),
signal: AbortSignal.timeout(15000),
});
if (!putRes.ok) throw new Error('Failed to restore env file on remote node');
}
}
if (redeploy) {
await fetch(`${baseUrl}/api/compose/${encodeURIComponent(stackName)}/up`, {
method: 'POST',
headers,
signal: AbortSignal.timeout(30000),
});
}
}
res.json({ message: 'Stack restored successfully', redeployed: redeploy });
} catch (error) {
console.error('[Fleet Snapshot] Restore error:', error);
res.status(500).json({ error: 'Failed to restore stack from snapshot' });
}
});
// Delete snapshot
app.delete('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(id);
if (!snapshot) {
res.status(404).json({ error: 'Snapshot not found' });
return;
}
db.deleteSnapshot(id);
res.json({ message: 'Snapshot deleted' });
} catch (error) {
console.error('[Fleet Snapshot] Delete error:', error);
res.status(500).json({ error: 'Failed to delete snapshot' });
}
});
// ─── Webhooks (Pro) ─── CRUD requires auth + Pro, trigger is public with HMAC ───
// Webhook CRUD (auth + Pro required)
@@ -767,6 +1130,7 @@ app.get('/api/webhooks', authMiddleware, async (_req: Request, res: Response): P
});
app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePro(req, res)) return;
try {
const { name, stack_name, action, enabled } = req.body;
@@ -795,6 +1159,7 @@ app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): P
});
app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
@@ -817,6 +1182,7 @@ app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response)
});
app.delete('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
@@ -879,7 +1245,8 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi
// Execute asynchronously — return 202 immediately
res.status(202).json({ message: 'Webhook accepted', action });
svc.execute(id, action, triggerSource).catch(err => {
const atomic = LicenseService.getInstance().getTier() === 'pro';
svc.execute(id, action, triggerSource, atomic).catch(err => {
console.error(`[Webhooks] Execution error for webhook ${id}:`, err);
});
} catch (error) {
@@ -888,6 +1255,150 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi
}
});
// --- User Management (local-only, admin + Pro gated for creation) ---
app.get('/api/users', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const users = DatabaseService.getInstance().getUsers();
res.json(users);
} catch (error) {
console.error('[Users] List error:', error);
res.status(500).json({ error: 'Failed to fetch users' });
}
});
app.post('/api/users', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePro(req, res)) return;
try {
const { username, password, role } = req.body;
if (!username || !password || !role) {
res.status(400).json({ error: 'Username, password, and role are required' });
return;
}
if (typeof username !== 'string' || username.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(username)) {
res.status(400).json({ error: 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)' });
return;
}
if (typeof password !== 'string' || password.length < 6) {
res.status(400).json({ error: 'Password must be at least 6 characters' });
return;
}
if (role !== 'admin' && role !== 'viewer') {
res.status(400).json({ error: 'Role must be "admin" or "viewer"' });
return;
}
const db = DatabaseService.getInstance();
const existing = db.getUserByUsername(username);
if (existing) {
res.status(409).json({ error: 'A user with this username already exists' });
return;
}
const passwordHash = await bcrypt.hash(password, 10);
const id = db.addUser({ username, password_hash: passwordHash, role });
res.status(201).json({ id, username, role });
} catch (error) {
console.error('[Users] Create error:', error);
res.status(500).json({ error: 'Failed to create user' });
}
});
app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
const user = db.getUser(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
const { username, password, role } = req.body;
const updates: Partial<{ username: string; password_hash: string; role: string }> = {};
if (username !== undefined) {
if (typeof username !== 'string' || username.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(username)) {
res.status(400).json({ error: 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)' });
return;
}
const existing = db.getUserByUsername(username);
if (existing && existing.id !== id) {
res.status(409).json({ error: 'A user with this username already exists' });
return;
}
updates.username = username;
}
if (role !== undefined) {
if (role !== 'admin' && role !== 'viewer') {
res.status(400).json({ error: 'Role must be "admin" or "viewer"' });
return;
}
// Prevent demoting yourself
if (user.username === req.user!.username && role !== user.role) {
res.status(400).json({ error: 'Cannot change your own role' });
return;
}
// Prevent removing the last admin
if (user.role === 'admin' && role === 'viewer' && db.getAdminCount() <= 1) {
res.status(400).json({ error: 'Cannot demote the only admin user' });
return;
}
updates.role = role;
}
if (password !== undefined) {
if (typeof password !== 'string' || password.length < 6) {
res.status(400).json({ error: 'Password must be at least 6 characters' });
return;
}
updates.password_hash = await bcrypt.hash(password, 10);
}
db.updateUser(id, updates);
res.json({ success: true });
} catch (error) {
console.error('[Users] Update error:', error);
res.status(500).json({ error: 'Failed to update user' });
}
});
app.delete('/api/users/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
const user = db.getUser(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
// Cannot delete yourself
if (user.username === req.user!.username) {
res.status(400).json({ error: 'Cannot delete your own account' });
return;
}
// Cannot delete the last admin
if (user.role === 'admin' && db.getAdminCount() <= 1) {
res.status(400).json({ error: 'Cannot delete the only admin user' });
return;
}
db.deleteUser(id);
res.json({ success: true });
} catch (error) {
console.error('[Users] Delete error:', error);
res.status(500).json({ error: 'Failed to delete user' });
}
});
// Remote Node HTTP Proxy - single global instance.
// Previously, createProxyMiddleware was called inside the request handler on every API
// call, spawning a new proxy instance (and http-proxy server) each time. This caused:
@@ -1264,6 +1775,7 @@ app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
});
app.put('/api/stacks/:stackName', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const stackName = req.params.stackName as string;
if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) {
@@ -1409,6 +1921,7 @@ app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
});
app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const stackName = req.params.stackName as string;
if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) {
@@ -1442,6 +1955,7 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
});
app.post('/api/stacks', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { stackName } = req.body;
if (!stackName || typeof stackName !== 'string') {
@@ -1462,6 +1976,7 @@ app.post('/api/stacks', async (req: Request, res: Response) => {
});
app.delete('/api/stacks/:name', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const stackName = req.params.name as string;
try {
// Stage 1: Tell Docker to clean up ghost networks/containers
@@ -1503,6 +2018,7 @@ app.get('/api/containers/:id/logs', async (req: Request, res: Response) => {
});
app.post('/api/containers/:id/start', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const id = req.params.id as string;
const dockerController = DockerController.getInstance(req.nodeId);
@@ -1514,6 +2030,7 @@ app.post('/api/containers/:id/start', async (req: Request, res: Response) => {
});
app.post('/api/containers/:id/stop', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const id = req.params.id as string;
const dockerController = DockerController.getInstance(req.nodeId);
@@ -1525,6 +2042,7 @@ app.post('/api/containers/:id/stop', async (req: Request, res: Response) => {
});
app.post('/api/containers/:id/restart', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const id = req.params.id as string;
const dockerController = DockerController.getInstance(req.nodeId);
@@ -1537,17 +2055,21 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => {
// End of legacy container routes
app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const stackName = req.params.stackName as string;
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined);
const atomic = LicenseService.getInstance().getTier() === 'pro';
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
res.json({ message: 'Deployed successfully' });
} catch (error: any) {
console.error('Failed to deploy stack:', error);
res.status(500).json({ error: error.message || 'Failed to deploy stack' });
const rolledBack = LicenseService.getInstance().getTier() === 'pro';
res.status(500).json({ error: error.message || 'Failed to deploy stack', rolledBack });
}
});
app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const stackName = req.params.stackName as string;
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined);
@@ -1558,6 +2080,7 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
});
app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const stackName = req.params.stackName as string;
const dockerController = DockerController.getInstance(req.nodeId);
@@ -1576,6 +2099,7 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) =
});
app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const stackName = req.params.stackName as string;
const dockerController = DockerController.getInstance(req.nodeId);
@@ -1594,6 +2118,7 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
});
app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const stackName = req.params.stackName as string;
const dockerController = DockerController.getInstance(req.nodeId);
@@ -1613,13 +2138,49 @@ app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) =>
// Update stack: pull images and recreate containers
app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const stackName = req.params.stackName as string;
// Await update completion
await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined);
const atomic = LicenseService.getInstance().getTier() === 'pro';
await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic);
res.json({ status: 'Update completed' });
} catch (error) {
res.status(500).json({ error: 'Failed to update' });
const rolledBack = LicenseService.getInstance().getTier() === 'pro';
res.status(500).json({ error: 'Failed to update', rolledBack });
}
});
// Manual rollback endpoint (Pro + Admin)
app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
if (!requirePro(req, res)) return;
try {
const stackName = req.params.stackName as string;
const fsSvc = FileSystemService.getInstance(req.nodeId);
const backupInfo = await fsSvc.getBackupInfo(stackName);
if (!backupInfo.exists) {
return res.status(404).json({ error: 'No backup available for this stack.' });
}
await fsSvc.restoreStackFiles(stackName);
// Re-deploy with restored files (non-atomic to avoid loops)
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, false);
res.json({ message: 'Stack rolled back successfully.' });
} catch (error: any) {
console.error('Rollback failed:', error);
res.status(500).json({ error: error.message || 'Rollback failed.' });
}
});
// Backup info endpoint (read-only)
app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
const fsSvc = FileSystemService.getInstance(req.nodeId);
const info = await fsSvc.getBackupInfo(stackName);
res.json(info);
} catch (error: any) {
console.error('Failed to get backup info:', error);
res.status(500).json({ error: error.message || 'Failed to get backup info.' });
}
});
@@ -1925,6 +2486,7 @@ app.get('/api/agents', async (req: Request, res: Response) => {
});
app.post('/api/agents', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const agent = req.body;
DatabaseService.getInstance().upsertAgent(agent);
@@ -1981,6 +2543,7 @@ app.get('/api/settings', async (req: Request, res: Response) => {
});
app.post('/api/settings', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { key, value } = req.body;
if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) {
@@ -2000,6 +2563,7 @@ app.post('/api/settings', async (req: Request, res: Response) => {
});
app.patch('/api/settings', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const parsed = SettingsPatchSchema.safeParse(req.body);
if (!parsed.success) {
@@ -2042,6 +2606,7 @@ const AlertCreateSchema = z.object({
});
app.post('/api/alerts', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const parsed = AlertCreateSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid alert data', details: parsed.error.flatten().fieldErrors });
@@ -2057,6 +2622,7 @@ app.post('/api/alerts', async (req: Request, res: Response) => {
});
app.delete('/api/alerts/:id', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
DatabaseService.getInstance().deleteStackAlert(id);
@@ -2104,6 +2670,7 @@ app.delete('/api/notifications', authMiddleware, async (req: Request, res: Respo
});
app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { type, url } = req.body;
await NotificationService.getInstance().testDispatch(type, url);
@@ -2119,6 +2686,7 @@ app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Re
// to receive a short-lived token. The remote's WS upgrade handler allows 'console_session'
// tokens through its isProxyToken guard, keeping the long-lived api_token off interactive paths.
app.post('/api/system/console-token', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
@@ -2149,6 +2717,7 @@ app.get('/api/system/orphans', async (req: Request, res: Response) => {
});
app.post('/api/system/prune/orphans', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { containerIds } = req.body;
if (!Array.isArray(containerIds)) {
@@ -2164,6 +2733,7 @@ app.post('/api/system/prune/orphans', async (req: Request, res: Response) => {
});
app.post('/api/system/prune/system', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { target, scope } = req.body as { target: string; scope?: string };
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
@@ -2249,6 +2819,7 @@ app.get('/api/system/networks', async (req: Request, res: Response) => {
});
app.post('/api/system/images/delete', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { id } = req.body;
if (!id) return res.status(400).json({ error: 'ID is required' });
@@ -2262,6 +2833,7 @@ app.post('/api/system/images/delete', async (req: Request, res: Response) => {
});
app.post('/api/system/volumes/delete', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { id } = req.body;
if (!id) return res.status(400).json({ error: 'ID is required' });
@@ -2275,6 +2847,7 @@ app.post('/api/system/volumes/delete', async (req: Request, res: Response) => {
});
app.post('/api/system/networks/delete', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { id } = req.body;
if (!id) return res.status(400).json({ error: 'ID is required' });
@@ -2299,11 +2872,13 @@ app.get('/api/templates', async (req: Request, res: Response) => {
});
app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
templateService.clearCache();
res.json({ success: true });
});
app.post('/api/templates/deploy', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { stackName, template, envVars } = req.body;
@@ -2336,7 +2911,8 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => {
// 4. Deploy the stack with atomic rollback
try {
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined);
const atomic = LicenseService.getInstance().getTier() === 'pro';
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
res.json({ success: true, message: 'Template deployed successfully' });
} catch (deployError: any) {
const rawError = deployError.message || String(deployError);
@@ -2387,6 +2963,7 @@ app.get('/api/image-updates', authMiddleware, (_req: Request, res: Response) =>
});
app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Response) => {
if (!requireAdmin(_req, res)) return;
try {
const triggered = ImageUpdateService.getInstance().triggerManualRefresh();
if (!triggered) {
@@ -2431,6 +3008,7 @@ app.get('/api/nodes/:id', async (req: Request, res: Response) => {
// Create a new node
app.post('/api/nodes', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { name, type, compose_dir, is_default, api_url, api_token } = req.body;
@@ -2471,6 +3049,7 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
// Update a node
app.put('/api/nodes/:id', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string);
const updates = req.body;
@@ -2496,6 +3075,7 @@ app.put('/api/nodes/:id', async (req: Request, res: Response) => {
// Delete a node
app.delete('/api/nodes/:id', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string);
DatabaseService.getInstance().deleteNode(id);
+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> {
+179
View File
@@ -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;
}
}
+66
View File
@@ -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 };
}
}
}
+3 -3
View File
@@ -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}`);