diff --git a/backend/src/__tests__/audit-log.test.ts b/backend/src/__tests__/audit-log.test.ts index a480fa37..0d3de7e4 100644 --- a/backend/src/__tests__/audit-log.test.ts +++ b/backend/src/__tests__/audit-log.test.ts @@ -65,6 +65,18 @@ describe('getAuditSummary()', () => { expect(getAuditSummary('POST', '/stacks/mystack/rollback')).toBe('Rolled back stack: mystack'); }); + it('resolves per-service restart summary (stack name as resource)', () => { + expect(getAuditSummary('POST', '/stacks/web/services/app/restart')).toBe('Restarted stack service: web'); + }); + + it('resolves per-service stop summary (stack name as resource)', () => { + expect(getAuditSummary('POST', '/stacks/web/services/app/stop')).toBe('Stopped stack service: web'); + }); + + it('resolves per-service start summary (stack name as resource)', () => { + expect(getAuditSummary('POST', '/stacks/web/services/app/start')).toBe('Started stack service: web'); + }); + it('decodes URL-encoded resource names', () => { expect(getAuditSummary('POST', '/stacks/my%20stack/deploy')).toBe('Deployed stack: my stack'); }); diff --git a/backend/src/__tests__/stack-service-actions.test.ts b/backend/src/__tests__/stack-service-actions.test.ts new file mode 100644 index 00000000..0bbd3fca --- /dev/null +++ b/backend/src/__tests__/stack-service-actions.test.ts @@ -0,0 +1,285 @@ +/** + * Integration tests for per-service lifecycle routes: + * POST /api/stacks/:stackName/services/:serviceName/restart + * POST /api/stacks/:stackName/services/:serviceName/stop + * POST /api/stacks/:stackName/services/:serviceName/start + * + * Verifies permission gating, name validation, container filtering, fan-out + * to the correct DockerController method, 404 paths, and error propagation. + * + * DockerController is mocked at the service layer so no real Docker daemon + * is required. All other external dependencies are stubbed in kind. + */ +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +// ── Hoisted mocks (must come before importing the app) ────────────────────── + +const { + mockGetContainersByStack, + mockRestartContainer, + mockStopContainer, + mockStartContainer, +} = vi.hoisted(() => ({ + mockGetContainersByStack: vi.fn(), + mockRestartContainer: vi.fn(), + mockStopContainer: vi.fn(), + mockStartContainer: vi.fn(), +})); + +vi.mock('../services/DockerController', async () => { + const actual = await vi.importActual( + '../services/DockerController', + ); + return { + ...actual, + default: { + ...actual.default, + getInstance: () => ({ + getContainersByStack: mockGetContainersByStack, + restartContainer: mockRestartContainer, + stopContainer: mockStopContainer, + startContainer: mockStartContainer, + }), + }, + }; +}); + +vi.mock('../services/FileSystemService', () => ({ + FileSystemService: { + getInstance: () => ({ + getStacks: vi.fn().mockResolvedValue([]), + getBaseDir: () => '/tmp/compose', + readComposeFile: vi.fn().mockResolvedValue(''), + }), + }, +})); + +// ── Container fixture helpers ─────────────────────────────────────────────── + +interface ContainerFixture { + Id: string; + Service: string; + Names: string[]; + State: string; + Status: string; + Ports: { PrivatePort: number; PublicPort: number }[]; +} + +function makeContainer(id: string, service: string): ContainerFixture { + return { + Id: id, + Service: service, + Names: [`/${service}`], + State: 'running', + Status: 'Up 1 second', + Ports: [], + }; +} + +// ── Setup ─────────────────────────────────────────────────────────────────── + +let tmpDir: string; +let app: import('express').Express; +let authCookie: string; +let viewerCookie: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass', 1); + DatabaseService.getInstance().addUser({ username: 'svc-viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'svc-viewer', password: 'viewerpass' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + mockGetContainersByStack.mockReset(); + mockRestartContainer.mockReset(); + mockStopContainer.mockReset(); + mockStartContainer.mockReset(); + + // Default: no containers found (safe baseline for tests that set their own value) + mockGetContainersByStack.mockResolvedValue([]); + + // Default: operations resolve successfully + mockRestartContainer.mockResolvedValue(undefined); + mockStopContainer.mockResolvedValue(undefined); + mockStartContainer.mockResolvedValue(undefined); +}); + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('POST /api/stacks/:stackName/services/:serviceName/restart', () => { + it('happy path: restarts matched container, ignores other services', async () => { + const appContainer = makeContainer('container-app-1', 'app'); + const dbContainer = makeContainer('container-db-1', 'db'); + mockGetContainersByStack.mockResolvedValue([appContainer, dbContainer]); + + const res = await request(app) + .post('/api/stacks/web/services/app/restart') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.count).toBe(1); + + expect(mockRestartContainer).toHaveBeenCalledTimes(1); + expect(mockRestartContainer).toHaveBeenCalledWith('container-app-1'); + expect(mockRestartContainer).not.toHaveBeenCalledWith('container-db-1'); + }); +}); + +describe('POST /api/stacks/:stackName/services/:serviceName/stop', () => { + it('happy path: stops matched container only', async () => { + const appContainer = makeContainer('container-app-1', 'app'); + const dbContainer = makeContainer('container-db-1', 'db'); + mockGetContainersByStack.mockResolvedValue([appContainer, dbContainer]); + + const res = await request(app) + .post('/api/stacks/web/services/app/stop') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.count).toBe(1); + + expect(mockStopContainer).toHaveBeenCalledTimes(1); + expect(mockStopContainer).toHaveBeenCalledWith('container-app-1'); + expect(mockStopContainer).not.toHaveBeenCalledWith('container-db-1'); + }); +}); + +describe('POST /api/stacks/:stackName/services/:serviceName/start', () => { + it('happy path: starts matched container only', async () => { + const appContainer = makeContainer('container-app-1', 'app'); + const dbContainer = makeContainer('container-db-1', 'db'); + mockGetContainersByStack.mockResolvedValue([appContainer, dbContainer]); + + const res = await request(app) + .post('/api/stacks/web/services/app/start') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.count).toBe(1); + + expect(mockStartContainer).toHaveBeenCalledTimes(1); + expect(mockStartContainer).toHaveBeenCalledWith('container-app-1'); + expect(mockStartContainer).not.toHaveBeenCalledWith('container-db-1'); + }); +}); + +describe('multi-replica fan-out', () => { + it('restarts all replicas when multiple containers share the same service name', async () => { + const containers = [ + makeContainer('container-app-1', 'app'), + makeContainer('container-app-2', 'app'), + makeContainer('container-app-3', 'app'), + ]; + mockGetContainersByStack.mockResolvedValue(containers); + + const res = await request(app) + .post('/api/stacks/web/services/app/restart') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(3); + expect(mockRestartContainer).toHaveBeenCalledTimes(3); + expect(mockRestartContainer).toHaveBeenCalledWith('container-app-1'); + expect(mockRestartContainer).toHaveBeenCalledWith('container-app-2'); + expect(mockRestartContainer).toHaveBeenCalledWith('container-app-3'); + }); +}); + +describe('404 error cases', () => { + it('returns 404 when requested service is not in the stack', async () => { + mockGetContainersByStack.mockResolvedValue([ + makeContainer('container-app-1', 'app'), + makeContainer('container-db-1', 'db'), + ]); + + const res = await request(app) + .post('/api/stacks/web/services/nginx/restart') + .set('Cookie', authCookie); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Service 'nginx' not found in stack 'web'."); + expect(mockRestartContainer).not.toHaveBeenCalled(); + }); + + it('returns 404 when stack has no containers', async () => { + mockGetContainersByStack.mockResolvedValue([]); + + const res = await request(app) + .post('/api/stacks/web/services/app/restart') + .set('Cookie', authCookie); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('No containers found for this stack.'); + expect(mockRestartContainer).not.toHaveBeenCalled(); + }); +}); + +describe('400 validation errors', () => { + it('returns 400 for stack name containing invalid characters', async () => { + // Express decodes %2F but a literal ".." fails isValidStackName + const res = await request(app) + .post('/api/stacks/..invalid../services/app/restart') + .set('Cookie', authCookie); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Invalid stack name'); + }); + + it('returns 400 for invalid service name (starts with hyphen)', async () => { + const res = await request(app) + .post('/api/stacks/web/services/-invalid/restart') + .set('Cookie', authCookie); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Invalid service name'); + }); +}); + +describe('authentication', () => { + it('returns 401 when request has no auth cookie', async () => { + const res = await request(app).post('/api/stacks/web/services/app/restart'); + expect(res.status).toBe(401); + }); + + it('returns 403 for viewer role (no write permission)', async () => { + const res = await request(app) + .post('/api/stacks/web/services/app/restart') + .set('Cookie', viewerCookie); + + expect(res.status).toBe(403); + expect(mockRestartContainer).not.toHaveBeenCalled(); + }); +}); + +describe('Docker error propagation', () => { + it('returns 500 with the error message when restartContainer rejects', async () => { + mockGetContainersByStack.mockResolvedValue([makeContainer('container-app-1', 'app')]); + mockRestartContainer.mockRejectedValue(new Error('daemon error')); + + const res = await request(app) + .post('/api/stacks/web/services/app/restart') + .set('Cookie', authCookie); + + expect(res.status).toBe(500); + expect(res.body.error).toContain('daemon error'); + }); +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 28672465..1a96f6c7 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -13,7 +13,7 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { requirePermission } from '../middleware/permissions'; import { requirePaid, requireAdmin } from '../middleware/tierGates'; import { NotificationService } from '../services/NotificationService'; -import { isValidStackName, isPathWithinBase } from '../utils/validation'; +import { isValidStackName, isValidServiceName, isPathWithinBase } from '../utils/validation'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; import { sendGitSourceError } from '../utils/gitSourceHttp'; @@ -692,6 +692,65 @@ stacksRouter.post('/:stackName/start', async (req: Request, res: Response) => { } }); +type ServiceAction = 'start' | 'stop' | 'restart'; + +async function handleServiceAction( + req: Request, + res: Response, + action: ServiceAction, +): Promise { + const stackName = req.params.stackName as string; + const serviceName = req.params.serviceName as string; + if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + if (!isValidServiceName(serviceName)) { + res.status(400).json({ error: 'Invalid service name' }); + return; + } + try { + const dockerController = DockerController.getInstance(req.nodeId); + const all = await dockerController.getContainersByStack(stackName); + if (!all || all.length === 0) { + res.status(404).json({ error: 'No containers found for this stack.' }); + return; + } + const matching = all.filter(c => c.Service === serviceName); + if (matching.length === 0) { + res.status(404).json({ error: `Service '${serviceName}' not found in stack '${stackName}'.` }); + return; + } + const op = + action === 'start' + ? (id: string) => dockerController.startContainer(id) + : action === 'stop' + ? (id: string) => dockerController.stopContainer(id) + : (id: string) => dockerController.restartContainer(id); + await Promise.all(matching.map(c => op(c.Id))); + invalidateNodeCaches(req.nodeId); + console.log( + `[Stacks] Service ${action} completed: ${stackName}/${serviceName} (${matching.length} containers)`, + ); + res.json({ + success: true, + message: `Service ${action} completed via Engine API.`, + count: matching.length, + }); + } catch (error: unknown) { + console.error(`[Stacks] Service ${action} failed: ${stackName}/${serviceName}`, error); + res.status(500).json({ error: getErrorMessage(error, `Failed to ${action} service`) }); + } +} + +stacksRouter.post('/:stackName/services/:serviceName/restart', (req, res) => + handleServiceAction(req, res, 'restart')); +stacksRouter.post('/:stackName/services/:serviceName/stop', (req, res) => + handleServiceAction(req, res, 'stop')); +stacksRouter.post('/:stackName/services/:serviceName/start', (req, res) => + handleServiceAction(req, res, 'start')); + stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index 0fdc9ecc..74b0e5dd 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -18,6 +18,12 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { 'POST /stacks/*/start': 'Started stack', 'POST /stacks/*/stop': 'Stopped stack', 'POST /stacks/*/restart': 'Restarted stack', + + // Per-service lifecycle (resourceName = stack name; service name is in the path column) + 'POST /stacks/*/services/*/start': 'Started stack service', + 'POST /stacks/*/services/*/stop': 'Stopped stack service', + 'POST /stacks/*/services/*/restart': 'Restarted stack service', + 'POST /stacks/*/update': 'Updated stack images', 'POST /stacks/*/rollback': 'Rolled back stack', diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 217ede68..22242734 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -72,6 +72,13 @@ export function isValidDockerResourceId(id: string): boolean { return /^[a-f0-9]{12,64}$/i.test(id); } +/** + * Compose service name. Allows dots in addition to the stack-name set + * (Compose spec permits `my.service`). + */ +export const isValidServiceName = (name: string): boolean => + /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name); + /** * Asserts that a resolved file path stays within a given base directory. * Returns true if the path is safe, false if it escapes the base. diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 2cee46d5..2fbfa923 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -213,6 +213,20 @@ The stack header groups actions by frequency of use. The most common action is t Stack directories often contain root-owned files, either because Docker Compose creates them, or because the stacks were originally set up outside of Sencho with `sudo`. Sencho handles this automatically: if a normal deletion fails due to permissions, it uses Docker to clean up root-owned files. No manual intervention is needed in the standard setup. +## Controlling a single service + +Inside the stack detail view, each service row has a kebab menu (`⋮`) on the right side. Use it to act on that service without touching the rest of the stack. + +- **Restart service** — stops and starts all containers for that service. +- **Stop service** — stops all containers for that service. Only shown when the service is running. +- **Start service** — starts all containers for that service. Only shown when the service is stopped or exited. + +For services running multiple replicas, the action applies to every replica simultaneously. Sibling services are not affected. + + + The service name in the action must match the `services:` key in your `compose.yaml`. If no containers exist yet for that service, the action will fail with a "service not found" error. Make sure the service is defined in the compose file and has been deployed at least once. + + ## Stack context menu Right-click any stack in the sidebar to open the context menu. The menu adapts to the stack's current state; only relevant actions are shown. @@ -260,3 +274,9 @@ Click the **folder-search icon** next to the "Create Stack" button in the sideba Any subdirectory inside your `COMPOSE_DIR` that contains a `compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml` file is recognized as a valid stack. + +## Troubleshooting + +### Service action returns a "service not found" error + +The service name used in the action must match the `services:` key in the stack's `compose.yaml`. This error occurs when no running containers match that service name, either because the service was never deployed or because the compose file defines a different name. Verify the service key in your compose file and ensure the stack has been deployed at least once so containers exist for that service. diff --git a/docs/images/per-service-lifecycle/audit-log-entry.png b/docs/images/per-service-lifecycle/audit-log-entry.png new file mode 100644 index 00000000..9aca6d09 Binary files /dev/null and b/docs/images/per-service-lifecycle/audit-log-entry.png differ diff --git a/docs/images/per-service-lifecycle/restart-service-toast.png b/docs/images/per-service-lifecycle/restart-service-toast.png new file mode 100644 index 00000000..38e030b9 Binary files /dev/null and b/docs/images/per-service-lifecycle/restart-service-toast.png differ diff --git a/docs/images/per-service-lifecycle/service-action-menu.png b/docs/images/per-service-lifecycle/service-action-menu.png new file mode 100644 index 00000000..bbb7433a Binary files /dev/null and b/docs/images/per-service-lifecycle/service-action-menu.png differ diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 60fe540e..e2e0fa0f 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -76,6 +76,7 @@ import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; interface ContainerInfo { Id: string; Names: string[]; + Service?: string; State: string; Status?: string; Ports?: { PrivatePort: number, PublicPort: number }[]; @@ -1471,6 +1472,27 @@ export default function EditorLayout() { } }; + const serviceAction = async (action: 'start' | 'stop' | 'restart', serviceName: string) => { + if (!selectedFile) return; + const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); + try { + const r = await apiFetch(`/stacks/${stackName}/services/${encodeURIComponent(serviceName)}/${action}`, { + method: 'POST', + }); + if (!r.ok) throw new Error((await r.text()) || `${action} failed`); + const label = action === 'restart' ? 'restarted' : action === 'stop' ? 'stopped' : 'started'; + toast.success(`Service "${serviceName}" ${label}`); + const cr = await apiFetch(`/stacks/${stackName}/containers`); + const conts = await cr.json(); + setContainers(Array.isArray(conts) ? conts : []); + } catch (e) { + console.error(`Failed to ${action} service "${serviceName}":`, e); + toast.error((e as Error).message || `Failed to ${action} service "${serviceName}"`); + } finally { + refreshStacks(true); + } + }; + const updateStack = async (e?: React.MouseEvent) => { e?.preventDefault(); e?.stopPropagation(); @@ -2483,19 +2505,19 @@ export default function EditorLayout() { } const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container'; - const isRunning = container.State === 'running'; + const isActive = container.State === 'running' || container.State === 'paused'; const health = container.healthStatus; - const uptime = isRunning ? extractUptime(container.Status) : null; + const uptime = isActive ? extractUptime(container.Status) : null; const hcLabel = healthcheckLabel(health); const stats = containerStats[container?.Id]; const history = stats?.history; - const badgeClass = health === 'unhealthy' || !isRunning + const badgeClass = health === 'unhealthy' || !isActive ? 'bg-destructive text-destructive-foreground' : health === 'starting' ? 'bg-warning text-warning-foreground' : 'bg-success text-success-foreground'; - const badgeGlyph = health === 'unhealthy' || !isRunning ? '✗' : health === 'starting' ? '…' : '✓'; + const badgeGlyph = health === 'unhealthy' || !isActive ? '✗' : health === 'starting' ? '…' : '✓'; const sparkStroke = health === 'unhealthy' ? 'var(--destructive)' : health === 'starting' ? 'var(--warning)' : 'var(--chart-1)'; return ( @@ -2537,7 +2559,7 @@ export default function EditorLayout() { variant="ghost" className="h-7 w-7 rounded-md" onClick={() => openLogViewer(container?.Id, containerName)} - disabled={!isRunning} + disabled={!isActive} aria-label="View logs" > @@ -2548,15 +2570,45 @@ export default function EditorLayout() { variant="ghost" className="h-7 w-7 rounded-md" onClick={() => openBashModal(container?.Id, containerName)} - disabled={!isRunning} + disabled={!isActive} aria-label="Open bash shell" > )} + {container.Service && ( + + + + + + {isActive ? ( + <> + serviceAction('restart', container.Service!)}> + Restart service + + serviceAction('stop', container.Service!)}> + Stop service + + + ) : ( + serviceAction('start', container.Service!)}> + Start service + + )} + + + )} - {isRunning ? ( + {isActive ? (