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.
+20
View File
@@ -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.
</Note>
## 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.
<Note>
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.
</Note>
## 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
<Tip>
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.
</Tip>
## 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

+59 -7
View File
@@ -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"
>
<ScrollText className="h-3.5 w-3.5" strokeWidth={1.5} />
@@ -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"
>
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
)}
{container.Service && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md"
aria-label="Service actions"
>
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isActive ? (
<>
<DropdownMenuItem onSelect={() => serviceAction('restart', container.Service!)}>
Restart service
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => serviceAction('stop', container.Service!)}>
Stop service
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onSelect={() => serviceAction('start', container.Service!)}>
Start service
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{isRunning ? (
{isActive ? (
<div className="mt-2 grid grid-cols-3 gap-2">
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">