feat(stacks): per-service start/stop/restart lifecycle actions (#778)

* feat(stacks): add per-service start/stop/restart lifecycle routes

Adds POST /:stackName/services/:serviceName/{start,stop,restart} routes
that operate on containers belonging to a single Compose service, using
the same Engine API pattern as the existing stack-level lifecycle routes.
Includes isValidServiceName validator and audit-summary entries for the
three new paths.

* test(stacks): add per-service action route tests

* test(stacks): fix test quality issues in service action tests

* feat(stacks): add per-service lifecycle menu to container cards

* fix(stacks): handle paused container state in service action menu

* docs(stacks): add per-service lifecycle actions documentation

* docs(stacks): add validation screenshots for per-service lifecycle actions
This commit is contained in:
Anso
2026-04-25 17:26:04 -04:00
committed by GitHub
parent abee078741
commit 6986b927e3
10 changed files with 449 additions and 8 deletions
+12
View File
@@ -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');
});
@@ -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<typeof import('../services/DockerController')>(
'../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');
});
});
+60 -1
View File
@@ -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<void> {
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)) {
+6
View File
@@ -18,6 +18,12 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'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',
+7
View File
@@ -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.