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
+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)) {