mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(stacks): harden stack management with security, validation, and logging (#520)
* fix(stacks): harden stack management with security fixes, validation alignment, and logging Validate WebSocket stack names with isValidStackName() to close a path-traversal gap on the /api/stacks/:stackName/logs WS endpoint. Align POST /api/stacks to use the canonical validator (allows underscores). Replace error: any catch blocks with error: unknown + type narrowing. Add cache invalidation to PUT /api/stacks/:stackName/env. Rename DELETE param from :name to :stackName for consistency. Add standard [Stacks] lifecycle logs and diagnostic [Stacks:debug] logs gated behind the Developer Mode toggle (with 5s TTL cache). Extract shared isDebugEnabled() and getErrorMessage() utilities. Frontend: roll back optimistic status on API failure, guard unsaved changes when switching stacks, pre-check duplicate names in App Store. * docs(settings): update Developer Mode description to mention debug diagnostics
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Unit tests for stack management hardening:
|
||||
* - WebSocket stack name validation
|
||||
* - Stack creation validation alignment
|
||||
* - Error type narrowing (unknown vs any)
|
||||
* - Cache invalidation on env write
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
|
||||
// ─── WebSocket Stack Name Validation ────────────────────────────────────────
|
||||
// The WS handler at /api/stacks/:stackName/logs uses a regex to extract the
|
||||
// stack name, then must validate it with isValidStackName() before passing
|
||||
// it to streamLogs(). These tests verify the validation catches traversal
|
||||
// attempts that the regex alone would allow.
|
||||
|
||||
describe('WebSocket stack name validation', () => {
|
||||
it('rejects traversal patterns the URL regex would pass', () => {
|
||||
// The WS regex is /^\/api\/stacks\/([^/]+)\/logs$/ which only blocks '/'
|
||||
// After decodeURIComponent, these should still be caught by isValidStackName
|
||||
expect(isValidStackName('..')).toBe(false);
|
||||
expect(isValidStackName('..%2f')).toBe(false);
|
||||
expect(isValidStackName('foo bar')).toBe(false);
|
||||
expect(isValidStackName('foo;ls')).toBe(false);
|
||||
expect(isValidStackName('')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts valid stack names that would appear in WS paths', () => {
|
||||
expect(isValidStackName('nginx')).toBe(true);
|
||||
expect(isValidStackName('my-app')).toBe(true);
|
||||
expect(isValidStackName('my_app')).toBe(true);
|
||||
expect(isValidStackName('stack123')).toBe(true);
|
||||
});
|
||||
|
||||
it('validates URL-decoded stack names correctly', () => {
|
||||
// Simulate what happens after decodeURIComponent
|
||||
const encoded = '%2e%2e'; // '..' URL-encoded
|
||||
const decoded = decodeURIComponent(encoded);
|
||||
expect(isValidStackName(decoded)).toBe(false);
|
||||
|
||||
const encodedSlash = 'foo%2fbar'; // 'foo/bar' URL-encoded
|
||||
const decodedSlash = decodeURIComponent(encodedSlash);
|
||||
expect(isValidStackName(decodedSlash)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stack Creation Validation Alignment ────────────────────────────────────
|
||||
// POST /api/stacks should use isValidStackName() (which allows underscores)
|
||||
// instead of the old inline regex /^[a-zA-Z0-9-]+$/ (which rejected underscores).
|
||||
|
||||
describe('stack creation validation alignment', () => {
|
||||
it('allows underscores in stack names (aligned with isValidStackName)', () => {
|
||||
expect(isValidStackName('my_stack')).toBe(true);
|
||||
expect(isValidStackName('test_app_v2')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows hyphens in stack names', () => {
|
||||
expect(isValidStackName('my-stack')).toBe(true);
|
||||
expect(isValidStackName('test-app-v2')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows mixed hyphens and underscores', () => {
|
||||
expect(isValidStackName('my-stack_v2')).toBe(true);
|
||||
expect(isValidStackName('test_app-prod')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects names with dots (not valid for Docker compose project names)', () => {
|
||||
expect(isValidStackName('my.stack')).toBe(false);
|
||||
expect(isValidStackName('.env')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Type Narrowing ──────────────────────────────────────────────────
|
||||
// Catch blocks should use `error: unknown` with instanceof narrowing, not `error: any`.
|
||||
// These tests verify the narrowing pattern works correctly.
|
||||
|
||||
describe('error type narrowing pattern', () => {
|
||||
it('extracts message from Error instances', () => {
|
||||
const error: unknown = new Error('Deploy failed: port conflict');
|
||||
const message = error instanceof Error ? error.message : 'Fallback message';
|
||||
expect(message).toBe('Deploy failed: port conflict');
|
||||
});
|
||||
|
||||
it('uses fallback for non-Error values', () => {
|
||||
const error: unknown = 'string error';
|
||||
const message = error instanceof Error ? error.message : 'Fallback message';
|
||||
expect(message).toBe('Fallback message');
|
||||
});
|
||||
|
||||
it('uses fallback for null/undefined', () => {
|
||||
const nullErr: unknown = null;
|
||||
const message1 = nullErr instanceof Error ? nullErr.message : 'Fallback';
|
||||
expect(message1).toBe('Fallback');
|
||||
|
||||
const undefErr: unknown = undefined;
|
||||
const message2 = undefErr instanceof Error ? undefErr.message : 'Fallback';
|
||||
expect(message2).toBe('Fallback');
|
||||
});
|
||||
|
||||
it('handles objects without message property', () => {
|
||||
const error: unknown = { code: 'ENOENT' };
|
||||
const message = error instanceof Error ? error.message : 'Fallback';
|
||||
expect(message).toBe('Fallback');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stack Name Edge Cases ──────────────────────────────────────────────────
|
||||
|
||||
describe('stack name edge cases for all endpoints', () => {
|
||||
it('rejects names that could cause shell injection', () => {
|
||||
expect(isValidStackName('$(whoami)')).toBe(false);
|
||||
expect(isValidStackName('`id`')).toBe(false);
|
||||
expect(isValidStackName('stack;rm -rf')).toBe(false);
|
||||
expect(isValidStackName('stack&&echo')).toBe(false);
|
||||
expect(isValidStackName('stack|cat')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects null bytes and control characters', () => {
|
||||
expect(isValidStackName('stack\x00')).toBe(false);
|
||||
expect(isValidStackName('stack\n')).toBe(false);
|
||||
expect(isValidStackName('stack\t')).toBe(false);
|
||||
expect(isValidStackName('stack\r')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects Windows path separators', () => {
|
||||
expect(isValidStackName('stack\\name')).toBe(false);
|
||||
expect(isValidStackName('C:\\stacks')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts single character names', () => {
|
||||
expect(isValidStackName('a')).toBe(true);
|
||||
expect(isValidStackName('1')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts numeric-only names', () => {
|
||||
expect(isValidStackName('123')).toBe(true);
|
||||
expect(isValidStackName('42')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,30 @@ describe('isValidStackName', () => {
|
||||
expect(isValidStackName('foo;rm -rf /')).toBe(false);
|
||||
expect(isValidStackName('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects URL-encoded traversal attempts', () => {
|
||||
// These would already be decoded by decodeURIComponent before validation,
|
||||
// but the raw encoded forms should also fail
|
||||
expect(isValidStackName('%2e%2e%2f')).toBe(false);
|
||||
expect(isValidStackName('foo%2fbar')).toBe(false);
|
||||
expect(isValidStackName('..%2fetc')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unicode characters', () => {
|
||||
expect(isValidStackName('stäck')).toBe(false);
|
||||
expect(isValidStackName('stack\u0000')).toBe(false);
|
||||
expect(isValidStackName('\u202Estack')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles very long names', () => {
|
||||
// The regex itself has no length limit, but 255 chars should still pass
|
||||
const longValid = 'a'.repeat(255);
|
||||
expect(isValidStackName(longValid)).toBe(true);
|
||||
|
||||
// Names with only valid chars remain valid regardless of length
|
||||
const veryLong = 'stack-' + 'x'.repeat(1000);
|
||||
expect(isValidStackName(veryLong)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isValidRemoteUrl ────────────────────────────────────────────────────────
|
||||
|
||||
+75
-28
@@ -56,6 +56,9 @@ function invalidateNodeCaches(nodeId: number): void {
|
||||
cache.invalidate(`stack-statuses:${nodeId}`);
|
||||
cache.invalidate('project-name-map');
|
||||
}
|
||||
|
||||
import { isDebugEnabled } from './utils/debug';
|
||||
import { getErrorMessage } from './utils/errors';
|
||||
import SelfUpdateService from './services/SelfUpdateService';
|
||||
import semver from 'semver';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
@@ -3018,10 +3021,19 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
// would accumulate listeners and allocate memory for every connection.
|
||||
logsWss.close();
|
||||
const stackName = decodeURIComponent(logsMatch[1]);
|
||||
if (!isValidStackName(stackName)) {
|
||||
ws.send('Error: Invalid stack name\r\n');
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isDebugEnabled()) console.debug('[Stacks:debug] WS log stream opened', { stackName, nodeId });
|
||||
ws.on('close', () => {
|
||||
if (isDebugEnabled()) console.debug('[Stacks:debug] WS log stream closed', { stackName, nodeId });
|
||||
});
|
||||
ComposeService.getInstance(nodeId).streamLogs(stackName, ws);
|
||||
} catch (error) {
|
||||
console.error('Failed to stream logs:', error);
|
||||
console.error('[Stacks] Failed to stream logs:', error);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`Error streaming logs: ${(error as Error).message}\n`);
|
||||
}
|
||||
@@ -3406,6 +3418,7 @@ app.put('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
}
|
||||
await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Compose file saved: ${stackName}`);
|
||||
res.json({ message: 'Stack saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to save stack:', error);
|
||||
@@ -3569,9 +3582,12 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
|
||||
const fsService = FileSystemService.getInstance(req.nodeId);
|
||||
await fsService.writeFile(envPath, content, 'utf-8');
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
const envFileName = path.basename(envPath);
|
||||
console.log(`[Stacks] Env file saved: ${stackName}/${envFileName}`);
|
||||
res.json({ message: 'Env file saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to save env file:', error);
|
||||
console.error('[Stacks] Failed to save env file:', error);
|
||||
res.status(500).json({ error: 'Failed to save env file' });
|
||||
}
|
||||
});
|
||||
@@ -3583,14 +3599,16 @@ app.post('/api/stacks', async (req: Request, res: Response) => {
|
||||
if (!stackName || typeof stackName !== 'string') {
|
||||
return res.status(400).json({ error: 'Stack name is required and must be a string' });
|
||||
}
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(stackName)) {
|
||||
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters and hyphens' });
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' });
|
||||
}
|
||||
await FileSystemService.getInstance(req.nodeId).createStack(stackName);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Stack created: ${stackName}`);
|
||||
res.json({ message: 'Stack created successfully', name: stackName });
|
||||
} catch (error: any) {
|
||||
if (error.message && error.message.includes('already exists')) {
|
||||
} catch (error: unknown) {
|
||||
const message = getErrorMessage(error, '');
|
||||
if (message.includes('already exists')) {
|
||||
return res.status(409).json({ error: 'Stack already exists' });
|
||||
}
|
||||
console.error('Failed to create stack:', error);
|
||||
@@ -3598,8 +3616,8 @@ app.post('/api/stacks', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/stacks/:name', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.name as string;
|
||||
app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:delete', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
@@ -3619,9 +3637,12 @@ app.delete('/api/stacks/:name', async (req: Request, res: Response) => {
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Stack deleted: ${stackName}`);
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message || 'Failed to delete stack' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Failed to delete stack ${stackName}:`, error);
|
||||
const message = getErrorMessage(error, 'Failed to delete stack');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3713,14 +3734,21 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) =>
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const debug = isDebugEnabled();
|
||||
const atomic = LicenseService.getInstance().getTier() === 'paid';
|
||||
if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId });
|
||||
const t0 = Date.now();
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Deploy completed: ${stackName}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
|
||||
res.json({ message: 'Deployed successfully' });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to deploy stack:', error);
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Deploy failed: ${stackName}`, error);
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
res.status(500).json({ error: error.message || 'Failed to deploy stack', rolledBack });
|
||||
if (rolledBack) console.warn(`[Stacks] Deploy failed, rolled back: ${stackName}`);
|
||||
const message = getErrorMessage(error, 'Failed to deploy stack');
|
||||
res.status(500).json({ error: message, rolledBack });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3733,8 +3761,10 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Down completed: ${stackName}`);
|
||||
res.json({ status: 'Command started' });
|
||||
} catch (error) {
|
||||
console.error(`[Stacks] Down failed: ${stackName}`, error);
|
||||
res.status(500).json({ error: 'Failed to start command' });
|
||||
}
|
||||
});
|
||||
@@ -3755,10 +3785,12 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) =
|
||||
|
||||
await Promise.all(containers.map(c => dockerController.restartContainer(c.Id)));
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Restart completed: ${stackName} (${containers.length} containers)`);
|
||||
res.json({ success: true, message: 'Restart completed via Engine API.' });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to restart containers:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to restart containers' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Restart failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Failed to restart containers');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3778,10 +3810,12 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
|
||||
|
||||
await Promise.all(containers.map(c => dockerController.stopContainer(c.Id)));
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Stop completed: ${stackName} (${containers.length} containers)`);
|
||||
res.json({ success: true, message: 'Stop completed via Engine API.' });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to stop containers:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to stop containers' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Stop failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Failed to stop containers');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3801,10 +3835,12 @@ app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) =>
|
||||
|
||||
await Promise.all(containers.map(c => dockerController.startContainer(c.Id)));
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Start completed: ${stackName} (${containers.length} containers)`);
|
||||
res.json({ success: true, message: 'Start completed via Engine API.' });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to start containers:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to start containers' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Start failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Failed to start containers');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3816,13 +3852,20 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) =>
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const debug = isDebugEnabled();
|
||||
const atomic = LicenseService.getInstance().getTier() === 'paid';
|
||||
if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId });
|
||||
const t0 = Date.now();
|
||||
await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic);
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Update completed: ${stackName}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
|
||||
res.json({ status: 'Update completed' });
|
||||
} catch (error) {
|
||||
console.error(`[Stacks] Update failed: ${stackName}`, error);
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
if (rolledBack) console.warn(`[Stacks] Update failed, rolled back: ${stackName}`);
|
||||
res.status(500).json({ error: 'Failed to update', rolledBack });
|
||||
}
|
||||
});
|
||||
@@ -3841,14 +3884,17 @@ app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response)
|
||||
if (!backupInfo.exists) {
|
||||
return res.status(404).json({ error: 'No backup available for this stack.' });
|
||||
}
|
||||
console.log(`[Stacks] Rollback initiated: ${stackName}`);
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
// Re-deploy with restored files (non-atomic to avoid loops)
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, false);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Rollback completed: ${stackName}`);
|
||||
res.json({ message: 'Stack rolled back successfully.' });
|
||||
} catch (error: any) {
|
||||
console.error('Rollback failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Rollback failed.' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Rollback failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Rollback failed.');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3862,9 +3908,10 @@ app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) =>
|
||||
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
||||
const info = await fsSvc.getBackupInfo(stackName);
|
||||
res.json(info);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to get backup info:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get backup info.' });
|
||||
const message = getErrorMessage(error, 'Failed to get backup info.');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6095,7 +6142,7 @@ app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Respons
|
||||
res.json(meta);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch node meta:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch node metadata';
|
||||
const message = getErrorMessage(error, 'Failed to fetch node metadata');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { LogFormatter } from './LogFormatter';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { RegistryService } from './RegistryService';
|
||||
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
/**
|
||||
* ComposeService - local docker compose CLI execution.
|
||||
*
|
||||
@@ -113,6 +115,9 @@ export class ComposeService {
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
if (debug) console.debug('[ComposeService:debug] deployStack', { stackName, stackDir, atomic });
|
||||
const sendOutput = (data: string) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
};
|
||||
@@ -166,6 +171,7 @@ export class ComposeService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (debug) console.debug(`[ComposeService:debug] deployStack completed in ${Date.now() - t0}ms`, { stackName });
|
||||
} catch (deployError) {
|
||||
// Atomic: auto-rollback on failure
|
||||
if (atomic) {
|
||||
@@ -299,6 +305,9 @@ export class ComposeService {
|
||||
|
||||
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
if (debug) console.debug('[ComposeService:debug] updateStack', { stackName, stackDir, atomic });
|
||||
const sendOutput = (data: string) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
};
|
||||
@@ -358,6 +367,7 @@ export class ComposeService {
|
||||
}
|
||||
|
||||
sendOutput('=== Stack updated successfully ===\n');
|
||||
if (debug) console.debug(`[ComposeService:debug] updateStack completed in ${Date.now() - t0}ms`, { stackName });
|
||||
} catch (updateError) {
|
||||
// Atomic: auto-rollback on failure
|
||||
if (atomic) {
|
||||
|
||||
@@ -12,6 +12,8 @@ function getBackupBaseDir(): string {
|
||||
return path.join(dataDir, 'backups');
|
||||
}
|
||||
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
/**
|
||||
* FileSystemService - local-only file I/O for compose stack management.
|
||||
*
|
||||
@@ -52,6 +54,7 @@ export class FileSystemService {
|
||||
const filePath = path.join(stackDir, file);
|
||||
try {
|
||||
await fsPromises.access(filePath);
|
||||
if (isDebugEnabled()) console.debug('[FileSystemService:debug] Resolved compose file', { stackName, file });
|
||||
return filePath;
|
||||
} catch {
|
||||
// continue
|
||||
@@ -249,6 +252,8 @@ export class FileSystemService {
|
||||
* holds sencho.db and encryption.key.
|
||||
*/
|
||||
async backupStackFiles(stackName: string): Promise<void> {
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const backupDir = path.join(getBackupBaseDir(), stackName);
|
||||
await fsPromises.mkdir(backupDir, { recursive: true });
|
||||
@@ -282,9 +287,12 @@ export class FileSystemService {
|
||||
|
||||
// Write timestamp marker
|
||||
await fsPromises.writeFile(path.join(backupDir, '.timestamp'), Date.now().toString(), 'utf-8');
|
||||
if (debug) console.debug(`[FileSystemService:debug] Backup completed in ${Date.now() - t0}ms`, { stackName });
|
||||
}
|
||||
|
||||
async restoreStackFiles(stackName: string): Promise<void> {
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const backupDir = path.join(getBackupBaseDir(), stackName);
|
||||
|
||||
@@ -293,6 +301,7 @@ export class FileSystemService {
|
||||
if (item === '.timestamp') continue;
|
||||
await fsPromises.copyFile(path.join(backupDir, item), path.join(stackDir, item));
|
||||
}
|
||||
if (debug) console.debug(`[FileSystemService:debug] Restore completed in ${Date.now() - t0}ms`, { stackName, files: items.filter(i => i !== '.timestamp') });
|
||||
}
|
||||
|
||||
async getBackupInfo(stackName: string): Promise<{ exists: boolean; timestamp: number | null }> {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared diagnostic logging gate.
|
||||
*
|
||||
* Reads `developer_mode` from the global settings, cached for a short
|
||||
* window so hot paths (per-request, per-log-line) do not hit SQLite
|
||||
* on every call.
|
||||
*/
|
||||
|
||||
let cachedValue = false;
|
||||
let cacheExpiry = 0;
|
||||
const CACHE_TTL_MS = 5_000;
|
||||
|
||||
export function isDebugEnabled(): boolean {
|
||||
const now = Date.now();
|
||||
if (now < cacheExpiry) return cachedValue;
|
||||
|
||||
try {
|
||||
// Dynamic require avoids circular-dependency issues when this
|
||||
// utility is imported from services that DatabaseService itself
|
||||
// depends on, and prevents SQLite side effects during tests.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { DatabaseService } = require('../services/DatabaseService');
|
||||
cachedValue = DatabaseService.getInstance().getGlobalSettings().developer_mode === '1';
|
||||
} catch {
|
||||
cachedValue = false;
|
||||
}
|
||||
|
||||
cacheExpiry = now + CACHE_TTL_MS;
|
||||
return cachedValue;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Safely extract a message from an unknown caught value.
|
||||
*
|
||||
* Replaces the repeated `error instanceof Error ? error.message : '...'`
|
||||
* pattern throughout the codebase.
|
||||
*/
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
@@ -215,7 +215,7 @@ Advanced settings for log streaming behavior and data retention. Most users can
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Developer Mode** | Off | When on, the [Global Observability](/features/global-observability) view switches from polling to real-time SSE streaming |
|
||||
| **Developer Mode** | Off | Enables real-time SSE streaming for [Global Observability](/features/global-observability), and activates debug diagnostics (timing, cache state, file resolution) in the server logs |
|
||||
| **Standard Log Polling Rate** | 5s | Polling interval in standard mode. Options: `1s`, `3s`, `5s`, `10s`. Disabled when Developer Mode is on. |
|
||||
|
||||
### Data Retention
|
||||
|
||||
@@ -138,6 +138,19 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
toast.error("Stack name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-check for duplicate stack name
|
||||
try {
|
||||
const checkRes = await apiFetch('/stacks');
|
||||
if (checkRes.ok) {
|
||||
const existingStacks: string[] = await checkRes.json();
|
||||
if (existingStacks.includes(stackName.trim())) {
|
||||
toast.error(`A stack named "${stackName.trim()}" already exists. Choose a different name.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch { /* proceed to deploy; backend will catch duplicates */ }
|
||||
|
||||
setIsDeploying(true);
|
||||
|
||||
const modifiedTemplate = { ...selectedTemplate };
|
||||
|
||||
@@ -122,6 +122,7 @@ export default function EditorLayout() {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [newStackName, setNewStackName] = useState('');
|
||||
const [stackToDelete, setStackToDelete] = useState<string | null>(null);
|
||||
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [stackActions, setStackActions] = useState<Record<string, StackAction>>({});
|
||||
const stackActionsRef = useRef<Record<string, StackAction>>({});
|
||||
@@ -802,8 +803,16 @@ export default function EditorLayout() {
|
||||
};
|
||||
}, [containers]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const hasUnsavedChanges = () =>
|
||||
content !== originalContent || envContent !== originalEnvContent;
|
||||
|
||||
const loadFile = async (filename: string) => {
|
||||
if (!filename) return;
|
||||
// Guard: if there are unsaved changes and we're switching to a different stack, confirm first
|
||||
if (selectedFile && filename !== selectedFile && hasUnsavedChanges()) {
|
||||
setPendingUnsavedLoad(filename);
|
||||
return;
|
||||
}
|
||||
setIsFileLoading(true);
|
||||
setIsEditing(false); // Reset to view mode when loading a new file
|
||||
try {
|
||||
@@ -985,6 +994,7 @@ export default function EditorLayout() {
|
||||
const stackFile = selectedFile;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
setStackAction(stackFile, 'deploy');
|
||||
const previousStatus = stackStatuses[stackFile];
|
||||
setOptimisticStatus(stackFile, 'running');
|
||||
try {
|
||||
const response = await apiFetch(`/stacks/${stackName}/deploy`, {
|
||||
@@ -1010,6 +1020,7 @@ export default function EditorLayout() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy:', error);
|
||||
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
||||
const msg = (error as Error).message || 'Failed to deploy stack';
|
||||
toast.error(isPaid ? `${msg} - automatically rolled back to previous version.` : msg);
|
||||
} finally {
|
||||
@@ -1025,6 +1036,7 @@ export default function EditorLayout() {
|
||||
const stackFile = selectedFile;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
setStackAction(stackFile, 'stop');
|
||||
const previousStatus = stackStatuses[stackFile];
|
||||
setOptimisticStatus(stackFile, 'exited');
|
||||
try {
|
||||
const response = await apiFetch(`/stacks/${stackName}/stop`, {
|
||||
@@ -1043,6 +1055,7 @@ export default function EditorLayout() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to stop:', error);
|
||||
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
||||
toast.error((error as Error).message || 'Failed to stop stack');
|
||||
} finally {
|
||||
clearStackAction(stackFile);
|
||||
@@ -1057,6 +1070,7 @@ export default function EditorLayout() {
|
||||
const stackFile = selectedFile;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
setStackAction(stackFile, 'restart');
|
||||
const previousStatus = stackStatuses[stackFile];
|
||||
setOptimisticStatus(stackFile, 'running');
|
||||
try {
|
||||
const response = await apiFetch(`/stacks/${stackName}/restart`, {
|
||||
@@ -1075,6 +1089,7 @@ export default function EditorLayout() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restart:', error);
|
||||
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
||||
toast.error((error as Error).message || 'Failed to restart stack');
|
||||
} finally {
|
||||
clearStackAction(stackFile);
|
||||
@@ -1089,6 +1104,7 @@ export default function EditorLayout() {
|
||||
const stackFile = selectedFile;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
setStackAction(stackFile, 'update');
|
||||
const previousStatus = stackStatuses[stackFile];
|
||||
setOptimisticStatus(stackFile, 'running');
|
||||
try {
|
||||
const response = await apiFetch(`/stacks/${stackName}/update`, {
|
||||
@@ -1107,6 +1123,7 @@ export default function EditorLayout() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update:', error);
|
||||
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
||||
toast.error((error as Error).message || 'Failed to update stack');
|
||||
} finally {
|
||||
clearStackAction(stackFile);
|
||||
@@ -2286,6 +2303,28 @@ export default function EditorLayout() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={!!pendingUnsavedLoad} onOpenChange={(open) => { if (!open) setPendingUnsavedLoad(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have unsaved changes. Switching stacks will discard them. Continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setPendingUnsavedLoad(null)}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => {
|
||||
const target = pendingUnsavedLoad;
|
||||
// Reset content to original so the guard doesn't re-trigger
|
||||
setContent(originalContent);
|
||||
setEnvContent(originalEnvContent);
|
||||
setPendingUnsavedLoad(null);
|
||||
if (target) loadFile(target);
|
||||
}}>Discard Changes</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={bulkActionOpen} onOpenChange={setBulkActionOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics & Extended Logs</p>
|
||||
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics, Debug Diagnostics & Extended Logs</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="developer_mode"
|
||||
|
||||
Reference in New Issue
Block a user