feat(scheduler): schedule container restart, stop, and start (#1526)

* feat(scheduler): schedule container restart, stop, and start

Add container as a scheduled-task target type so operators can automate lifecycle actions against standalone containers by node and name, with matching UI pickers, validation, execution on local and remote nodes, and tests.

* fix(scheduler): stack service matching and container picker hygiene

Backfill Service on smartFallback containers so per-service stack restarts work when container_name is set. Match services by compose label and container name in stack routes and scheduled restarts. Exclude Sencho from GET /api/containers lists. Hide the Restart Stack service picker when a stack has only one service.

* test(scheduler): scope service checkbox assertion to Services block

The create dialog also has a Delete after run checkbox. Count checkboxes only inside the Services section so CI does not include unrelated form controls.

* fix(scheduler): narrow closest() result to HTMLElement in schedule test

The service-checkbox assertion passed an Element from closest() into
within(), which requires an HTMLElement, failing tsc -b in the frontend
build and Docker build stages. Use the closest<HTMLElement>() type
argument so the value type-checks without an unsafe cast.

* fix(scheduler): hide Sencho container on remote node picker lists

Remote container lists are proxied from peer Sencho instances, so id-only self filtering missed peers on older builds. Await SelfIdentity init, match ImageID, and drop official saelix/sencho images. Apply the same heuristic in the scheduled-operations UI and when the hub fetches remote containers for scheduled runs.

* test(monitor): add missing DatabaseService mocks for scan history cleanup

* test(scheduler): add missing markStaleScansAsFailed mock

SchedulerService.tick() calls db.markStaleScansAsFailed() to sweep stale
vulnerability scans. The scheduler-service test was missing this method in
its DatabaseService mock, causing TypeError failures during test initialization.

Added mockMarkStaleScansAsFailed to hoisted mocks and DatabaseService mock
object, returning safe default of 0 scans marked as failed.

* test(compose): add missing FileSystemService mocks for getStackContent/getEnvContent

* test(containers-route): mock SelfIdentityService to prevent initialize() crash

The excludeSelfContainers() helper calls SelfIdentityService.initialize(), which tries to access DockerController. Without a proper SelfIdentityService mock, the initialize() call fails silently, causing a 500 error on GET /api/containers.

Added SelfIdentityService mock with initialize(), isOwnContainer(), and isOwnImage() methods to prevent the crash.
This commit is contained in:
Anso
2026-07-02 22:31:29 -04:00
committed by GitHub
parent b65daf6845
commit 10fb93dcb1
29 changed files with 932 additions and 68 deletions
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import {
containerBelongsToComposeService,
filterContainersByComposeService,
} from '../helpers/composeServiceMatch';
describe('composeServiceMatch', () => {
it('matches by Service field from docker compose ps', () => {
const c = { Id: 'abc', Service: 'mariadb', Names: ['/mariadb'] };
expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true);
expect(containerBelongsToComposeService(c, 'phpmyadmin')).toBe(false);
});
it('matches by com.docker.compose.service label', () => {
const c = {
Id: 'abc',
Names: ['/custom-name'],
Labels: { 'com.docker.compose.service': 'mariadb' },
};
expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true);
});
it('matches when container name equals service (container_name in compose)', () => {
const c = { Id: 'abc', Service: '', Names: ['/mariadb'] };
expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true);
});
it('filterContainersByComposeService returns all replicas', () => {
const containers = [
{ Id: '1', Service: 'app', Names: ['/web-app-1'] },
{ Id: '2', Service: 'app', Names: ['/web-app-2'] },
{ Id: '3', Names: ['/db'] },
];
expect(filterContainersByComposeService(containers, 'app')).toHaveLength(2);
});
});
@@ -19,6 +19,7 @@ const {
mockGetComposeFilename, mockGetOverrideFilename, mockEnsureStackOverride,
mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync,
mockGetGlobalSettings, mockPruneDanglingImages, mockGetBindMounts,
mockGetStackContent, mockGetEnvContent,
} = vi.hoisted(() => ({
mockSpawn: vi.fn(),
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
@@ -40,6 +41,8 @@ const {
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
mockPruneDanglingImages: vi.fn().mockResolvedValue({ reclaimedBytes: 0 }),
mockGetBindMounts: vi.fn().mockResolvedValue(null),
mockGetStackContent: vi.fn().mockResolvedValue(''),
mockGetEnvContent: vi.fn().mockResolvedValue(''),
}));
vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));
@@ -109,6 +112,8 @@ vi.mock('../services/FileSystemService', () => ({
restoreStackFiles: mockRestoreStackFiles,
getComposeFilename: mockGetComposeFilename,
getOverrideFilename: mockGetOverrideFilename,
getStackContent: mockGetStackContent,
getEnvContent: mockGetEnvContent,
}),
},
}));
@@ -16,6 +16,7 @@ let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let DockerController: typeof import('../services/DockerController').default;
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
let SelfIdentityService: typeof import('../services/SelfIdentityService').default;
let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS;
const VIEWER = 'container-read-viewer';
@@ -28,7 +29,7 @@ function viewerToken(): string {
}
/**
* Replace the DockerController / FileSystemService singletons with stubs so the
* Replace the DockerController / FileSystemService / SelfIdentityService singletons with stubs so the
* handlers run without a Docker daemon. The logs stub ends the response itself
* (the real streamContainerLogs flushes SSE headers and streams), so a request
* that clears the guard resolves instead of hanging. Returns the spies so a test
@@ -46,6 +47,11 @@ function stubDockerAndFs(): { docker: ReturnType<typeof vi.spyOn>; fs: ReturnTyp
const fs = vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
getStacks: vi.fn().mockResolvedValue([]),
} as unknown as ReturnType<typeof FileSystemService.getInstance>);
vi.spyOn(SelfIdentityService, 'getInstance').mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
isOwnContainer: vi.fn().mockReturnValue(false),
isOwnImage: vi.fn().mockReturnValue(false),
} as unknown as ReturnType<typeof SelfIdentityService.getInstance>);
return { docker, fs };
}
@@ -54,6 +60,7 @@ beforeAll(async () => {
({ DatabaseService } = await import('../services/DatabaseService'));
({ default: DockerController } = await import('../services/DockerController'));
({ FileSystemService } = await import('../services/FileSystemService'));
({ default: SelfIdentityService } = await import('../services/SelfIdentityService'));
({ ROLE_PERMISSIONS } = await import('../middleware/permissions'));
({ app } = await import('../index'));
@@ -0,0 +1,92 @@
/**
* GET /api/containers must omit Sencho's own container from picker lists.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let DockerController: typeof import('../services/DockerController').default;
let SelfIdentityServiceMod: typeof import('../services/SelfIdentityService').default;
const VIEWER = 'container-self-filter-viewer';
function viewerToken(): string {
const user = DatabaseService.getInstance().getUserByUsername(VIEWER)!;
return jwt.sign({ username: VIEWER, role: 'viewer', tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' });
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ default: DockerController } = await import('../services/DockerController'));
({ default: SelfIdentityServiceMod } = await import('../services/SelfIdentityService'));
({ app } = await import('../index'));
const hash = await bcrypt.hash('password123', 1);
DatabaseService.getInstance().addUser({ username: VIEWER, password_hash: hash, role: 'viewer' });
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('GET /api/containers self-filter', () => {
beforeEach(() => {
vi.spyOn(SelfIdentityServiceMod, 'getInstance').mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
isOwnContainer: (idOrName: string) => idOrName === 'sencho' || idOrName.startsWith('sencho-id'),
isOwnImage: vi.fn().mockReturnValue(false),
} as unknown as ReturnType<typeof SelfIdentityServiceMod.getInstance>);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getAllContainers: vi.fn().mockResolvedValue([
{ Id: 'sencho-id-full', Names: ['/sencho'], State: 'running', Status: 'Up 1 day' },
{ Id: 'other-id', Names: ['/mariadb'], State: 'running', Status: 'Up 1 day' },
]),
getRunningContainers: vi.fn().mockResolvedValue([]),
} as unknown as ReturnType<typeof DockerController.getInstance>);
});
it('excludes Sencho from all=true container list', async () => {
const res = await request(app)
.get('/api/containers?all=true')
.set('Authorization', `Bearer ${viewerToken()}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].Names).toEqual(['/mariadb']);
});
it('excludes official Sencho images when SelfIdentity does not match by id', async () => {
vi.spyOn(SelfIdentityServiceMod, 'getInstance').mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
isOwnContainer: vi.fn().mockReturnValue(false),
isOwnImage: vi.fn().mockReturnValue(false),
} as unknown as ReturnType<typeof SelfIdentityServiceMod.getInstance>);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getAllContainers: vi.fn().mockResolvedValue([
{ Id: 'remote-sencho', Names: ['/sencho'], Image: 'saelix/sencho:latest', State: 'running' },
{ Id: 'other-id', Names: ['/mariadb'], State: 'running' },
]),
getRunningContainers: vi.fn().mockResolvedValue([]),
} as unknown as ReturnType<typeof DockerController.getInstance>);
const res = await request(app)
.get('/api/containers?all=true')
.set('Authorization', `Bearer ${viewerToken()}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].Names).toEqual(['/mariadb']);
});
});
@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest';
import { isPublishedSenchoImage } from '../helpers/excludeSelfContainers';
describe('isPublishedSenchoImage', () => {
it('matches Docker Hub and GHCR release paths', () => {
expect(isPublishedSenchoImage('saelix/sencho:latest')).toBe(true);
expect(isPublishedSenchoImage('ghcr.io/studio-saelix/sencho:0.93.1')).toBe(true);
expect(isPublishedSenchoImage('ghcr.io/studio-saelix/sencho-dev:dev')).toBe(true);
expect(isPublishedSenchoImage('lscr.io/linuxserver/mariadb:latest')).toBe(false);
});
});
@@ -10,6 +10,7 @@ import { installArcstatsFsMock, arcstatsBody, DEFAULT_ARC_PATH, type ArcstatsFsM
const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContainerMetric,
mockCleanupOldMetrics, mockCleanupOldNotifications, mockCleanupOldAuditLogs,
mockUpdateStackAlertLastFired, mockGetSystemState, mockSetSystemState,
mockPruneScanHistoryPerImage, mockDeleteScansByImageRef,
mockGetRunningContainers, mockGetAllContainers, mockGetContainerStatsStream,
mockGetContainerRestartCount, mockGetDiskUsage, mockGetImages, mockGetStacks,
mockDispatchAlert,
@@ -30,6 +31,8 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
mockUpdateStackAlertLastFired: vi.fn(),
mockGetSystemState: vi.fn().mockReturnValue(null),
mockSetSystemState: vi.fn(),
mockPruneScanHistoryPerImage: vi.fn().mockReturnValue(0),
mockDeleteScansByImageRef: vi.fn().mockReturnValue(0),
mockGetRunningContainers: vi.fn().mockResolvedValue([]),
mockGetAllContainers: vi.fn().mockResolvedValue([]),
mockGetContainerStatsStream: vi.fn().mockResolvedValue('{}'),
@@ -64,6 +67,8 @@ vi.mock('../services/DatabaseService', () => ({
updateStackAlertLastFired: mockUpdateStackAlertLastFired,
getSystemState: mockGetSystemState,
setSystemState: mockSetSystemState,
pruneScanHistoryPerImage: mockPruneScanHistoryPerImage,
deleteScansByImageRef: mockDeleteScansByImageRef,
}),
},
}));
@@ -19,7 +19,7 @@ const EXPECTED_ACTIONS: BackendScheduledAction[] = [
'auto_backup', 'auto_stop', 'auto_down', 'auto_start',
];
const ALL_TARGET_TYPES: TargetType[] = ['stack', 'fleet', 'system'];
const ALL_TARGET_TYPES: TargetType[] = ['stack', 'fleet', 'system', 'container'];
describe('scheduledActionRegistry', () => {
it('exposes exactly the known backend actions, in order', () => {
@@ -46,27 +46,27 @@ describe('scheduledActionRegistry', () => {
describe('validateActionTarget', () => {
const validPairs: Record<BackendScheduledAction, TargetType[]> = {
restart: ['stack'],
restart: ['stack', 'container'],
snapshot: ['fleet'],
prune: ['system'],
update: ['stack', 'fleet'],
scan: ['system'],
auto_backup: ['stack'],
auto_stop: ['stack'],
auto_stop: ['stack', 'container'],
auto_down: ['stack'],
auto_start: ['stack'],
auto_start: ['stack', 'container'],
};
const mismatchMessage: Record<BackendScheduledAction, string> = {
restart: 'Restart action requires target_type "stack".',
restart: 'Restart action requires target_type "stack" or "container".',
snapshot: 'Snapshot action requires target_type "fleet".',
prune: 'Prune action requires target_type "system".',
update: 'Update action requires target_type "stack" or "fleet".',
scan: 'Scan action requires target_type "system".',
auto_backup: 'auto_backup action requires target_type "stack".',
auto_stop: 'auto_stop action requires target_type "stack".',
auto_stop: 'auto_stop action requires target_type "stack" or "container".',
auto_down: 'auto_down action requires target_type "stack".',
auto_start: 'auto_start action requires target_type "stack".',
auto_start: 'auto_start action requires target_type "stack" or "container".',
};
for (const action of EXPECTED_ACTIONS) {
@@ -594,6 +594,64 @@ describe('POST /api/scheduled-tasks - new lifecycle actions', () => {
});
});
describe('POST /api/scheduled-tasks - container lifecycle', () => {
it('creates a container restart schedule', async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.set('Cookie', adminCookie)
.send({
name: 'daily-watchtower-restart',
target_type: 'container',
target_id: 'watchtower',
node_id: 1,
action: 'restart',
cron_expression: '0 3 * * *',
enabled: true,
});
expect(res.status).toBe(201);
expect(res.body.target_type).toBe('container');
expect(res.body.target_id).toBe('watchtower');
expect(res.body.action).toBe('restart');
});
it('rejects invalid container names', async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.set('Cookie', adminCookie)
.send({
name: 'bad-container',
target_type: 'container',
target_id: '../escape',
node_id: 1,
action: 'restart',
cron_expression: '0 3 * * *',
enabled: true,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/valid container name/);
});
for (const action of ['auto_stop', 'auto_start'] as const) {
it(`creates container ${action} schedule`, async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.set('Cookie', adminCookie)
.send({
name: `ctr-${action}`,
target_type: 'container',
target_id: 'sidecar',
node_id: 1,
action,
cron_expression: '0 4 * * *',
enabled: true,
});
expect(res.status).toBe(201);
expect(res.body.target_type).toBe('container');
expect(res.body.action).toBe(action);
});
}
});
describe('POST /api/scheduled-tasks - available on the Community tier', () => {
beforeEach(() => {
tierSpy.mockReturnValue('community');
+110 -2
View File
@@ -12,9 +12,10 @@ const {
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
mockGetGlobalSettings, mockGetStackDossier,
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
mockMarkStaleRunsAsFailed, mockDeleteOldScans,
mockMarkStaleRunsAsFailed, mockMarkStaleScansAsFailed, mockDeleteOldScans,
mockGetTier, mockGetProxyHeaders,
mockGetContainersByStack, mockRestartContainer, mockPruneSystem,
mockGetContainersByStack, mockRestartContainer, mockFindContainerByName,
mockStartContainer, mockStopContainer, mockPruneSystem,
mockUpdateStack,
mockGetStacks, mockGetStackContent, mockGetEnvContent,
mockCheckImage,
@@ -43,11 +44,15 @@ const {
mockInsertSnapshotFiles: vi.fn(),
mockClearStackUpdateStatus: vi.fn(),
mockMarkStaleRunsAsFailed: vi.fn().mockReturnValue(0),
mockMarkStaleScansAsFailed: vi.fn().mockReturnValue(0),
mockDeleteOldScans: vi.fn().mockReturnValue(0),
mockGetTier: vi.fn().mockReturnValue('paid'),
mockGetProxyHeaders: vi.fn().mockReturnValue({ tier: 'paid' }),
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
mockRestartContainer: vi.fn().mockResolvedValue(undefined),
mockFindContainerByName: vi.fn().mockResolvedValue(null),
mockStartContainer: vi.fn().mockResolvedValue(undefined),
mockStopContainer: vi.fn().mockResolvedValue(undefined),
mockPruneSystem: vi.fn().mockResolvedValue({ success: true, reclaimedBytes: 0 }),
mockUpdateStack: vi.fn().mockResolvedValue(undefined),
mockGetStacks: vi.fn().mockResolvedValue([]),
@@ -89,6 +94,7 @@ vi.mock('../services/DatabaseService', () => ({
insertSnapshotFiles: mockInsertSnapshotFiles,
clearStackUpdateStatus: mockClearStackUpdateStatus,
markStaleRunsAsFailed: mockMarkStaleRunsAsFailed,
markStaleScansAsFailed: mockMarkStaleScansAsFailed,
deleteOldScans: mockDeleteOldScans,
deleteScheduledTask: mockDeleteScheduledTask,
getMatchingPolicy: mockGetMatchingPolicy,
@@ -117,6 +123,9 @@ vi.mock('../services/DockerController', () => ({
getInstance: () => ({
getContainersByStack: mockGetContainersByStack,
restartContainer: mockRestartContainer,
findContainerByName: mockFindContainerByName,
startContainer: mockStartContainer,
stopContainer: mockStopContainer,
pruneSystem: mockPruneSystem,
}),
},
@@ -530,6 +539,105 @@ describe('SchedulerService - executeRestart', () => {
expect.objectContaining({ status: 'failure', error: expect.stringContaining('No containers') })
);
});
it('restarts a standalone container by name', async () => {
mockGetScheduledTask.mockReturnValue({
id: 63,
name: 'restart-ctr',
action: 'restart',
target_type: 'container',
cron_expression: '0 3 * * *',
enabled: true,
target_id: 'watchtower',
node_id: 1,
created_by: 'admin',
last_status: null,
});
mockFindContainerByName.mockResolvedValue({
id: 'abc123deadbeef',
name: 'watchtower',
state: 'running',
image: 'containrrr/watchtower',
stackProject: null,
});
const svc = SchedulerService.getInstance();
await svc.triggerTask(63);
expect(mockFindContainerByName).toHaveBeenCalledWith('watchtower');
expect(mockRestartContainer).toHaveBeenCalledWith('abc123deadbeef');
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
expect.any(Number),
expect.objectContaining({
status: 'success',
output: expect.stringContaining('watchtower'),
}),
);
});
it('records failure when container name is missing', async () => {
mockGetScheduledTask.mockReturnValue({
id: 64,
name: 'restart-missing',
action: 'restart',
target_type: 'container',
cron_expression: '0 3 * * *',
enabled: true,
target_id: 'gone-container',
node_id: 1,
created_by: 'admin',
last_status: null,
});
mockFindContainerByName.mockResolvedValue(null);
const svc = SchedulerService.getInstance();
await svc.triggerTask(64);
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
expect.any(Number),
expect.objectContaining({
status: 'failure',
error: expect.stringContaining('gone-container'),
}),
);
});
it('resolves a new container ID when the name matches after recreation', async () => {
mockGetScheduledTask.mockReturnValue({
id: 65,
name: 'restart-recreated',
action: 'restart',
target_type: 'container',
cron_expression: '0 3 * * *',
enabled: true,
target_id: 'sidecar',
node_id: 1,
created_by: 'admin',
last_status: null,
});
mockFindContainerByName
.mockResolvedValueOnce({
id: 'old-id-111',
name: 'sidecar',
state: 'running',
image: 'sidecar:latest',
stackProject: null,
})
.mockResolvedValueOnce({
id: 'new-id-222',
name: 'sidecar',
state: 'running',
image: 'sidecar:latest',
stackProject: null,
});
const svc = SchedulerService.getInstance();
await svc.triggerTask(65);
await svc.triggerTask(65);
expect(mockRestartContainer).toHaveBeenNthCalledWith(1, 'old-id-111');
expect(mockRestartContainer).toHaveBeenNthCalledWith(2, 'new-id-222');
});
});
// ── executePrune ───────────────────────────────────────────────────────
@@ -140,6 +140,28 @@ describe('POST /api/stacks/:stackName/services/:serviceName/restart', () => {
expect(mockRestartContainer).toHaveBeenCalledWith('container-app-1');
expect(mockRestartContainer).not.toHaveBeenCalledWith('container-db-1');
});
it('matches smartFallback containers when Service is empty but container name equals service', async () => {
mockGetContainersByStack.mockResolvedValue([
{
Id: 'container-mariadb-1',
Service: '',
Names: ['/mariadb'],
State: 'running',
Status: 'Up 12 days',
Ports: [],
},
makeContainer('container-phpmyadmin-1', 'phpmyadmin'),
]);
const res = await request(app)
.post('/api/stacks/db-compose/services/mariadb/restart')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
expect(mockRestartContainer).toHaveBeenCalledWith('container-mariadb-1');
});
});
describe('POST /api/stacks/:stackName/services/:serviceName/stop', () => {
@@ -0,0 +1,29 @@
/**
* Match stack containers to a compose service name. `docker compose ps` sets
* `Service`, but smartFallback containers only had `Names` until Service was
* backfilled; these matchers also accept the compose service label and a
* container name equal to the service (common with `container_name:`).
*/
export interface ComposeServiceContainer {
Id: string;
Service?: string;
Names?: string[];
Labels?: Record<string, string>;
}
export function containerBelongsToComposeService(
container: ComposeServiceContainer,
serviceName: string,
): boolean {
if (container.Service === serviceName) return true;
if (container.Labels?.['com.docker.compose.service'] === serviceName) return true;
const containerName = container.Names?.[0]?.replace(/^\//, '');
return containerName === serviceName;
}
export function filterContainersByComposeService<T extends ComposeServiceContainer>(
containers: T[],
serviceName: string,
): T[] {
return containers.filter(c => containerBelongsToComposeService(c, serviceName));
}
@@ -0,0 +1,38 @@
import SelfIdentityService from '../services/SelfIdentityService';
export interface DockerContainerListRow {
Id: string;
Names?: string[];
Image?: string;
ImageID?: string;
Labels?: Record<string, string>;
}
/** Official Sencho release images (Docker Hub + GHCR). Used when SelfIdentity is unavailable on a peer. */
export function isPublishedSenchoImage(image: string): boolean {
const lower = image.toLowerCase();
return /(?:^|\/)saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower)
|| /studio-saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower);
}
function isLikelySenchoManagementContainer(c: DockerContainerListRow): boolean {
const name = c.Names?.[0]?.replace(/^\//, '').toLowerCase() ?? '';
if (name === 'sencho' || name === 'sencho-agent') return true;
if (c.Image && isPublishedSenchoImage(c.Image)) return true;
return false;
}
/** Drop the running Sencho instance from container picker lists. */
export async function excludeSelfContainers<T extends DockerContainerListRow>(containers: T[]): Promise<T[]> {
const self = SelfIdentityService.getInstance();
await self.initialize();
return containers.filter(c => {
const name = c.Names?.[0]?.replace(/^\//, '') ?? '';
if (self.isOwnContainer(c.Id)) return false;
if (name && self.isOwnContainer(name)) return false;
if (c.ImageID && self.isOwnImage(c.ImageID)) return false;
if (isLikelySenchoManagementContainer(c)) return false;
return true;
});
}
+6 -2
View File
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from 'express';
import DockerController from '../services/DockerController';
import { FileSystemService } from '../services/FileSystemService';
import { excludeSelfContainers } from '../helpers/excludeSelfContainers';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
@@ -11,8 +12,11 @@ containersRouter.get('/', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getRunningContainers();
res.json(containers);
const all = req.query.all === 'true' || req.query.all === '1';
const containers = all
? await dockerController.getAllContainers()
: await dockerController.getRunningContainers();
res.json(await excludeSelfContainers(containers));
} catch (error) {
res.status(500).json({ error: 'Failed to fetch containers' });
}
+32 -6
View File
@@ -17,7 +17,7 @@ import { escapeCsvField } from '../utils/csv';
import { getErrorMessage } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
import { isValidStackName } from '../utils/validation';
import { isValidStackName, isValidContainerName } from '../utils/validation';
// Frontend listeners filter on scope === 'scheduled-tasks'. Wrapped so a
// broken subscriber socket cannot turn a successful mutation into a 500.
@@ -78,12 +78,30 @@ function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId:
return null;
}
function validateContainerTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null {
if (targetType !== 'container') return null;
if (typeof targetId !== 'string' || !targetId.trim() || nodeId === null || nodeId === undefined) {
return 'Container operations require target_id and node_id.';
}
if (targetId !== targetId.trim() || !isValidContainerName(targetId)) {
return 'Container target_id must be a valid container name.';
}
if (parsePositiveNodeId(nodeId) === null) {
return 'Container operations require a valid node_id.';
}
return null;
}
/**
* Shared guard for non-stack actions that require a node. Stack actions use
* validateStackTarget because they also require target_id.
*/
function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null {
if (targetType === 'stack') return null;
if (targetType === 'stack' || targetType === 'container') return null;
const def = getScheduledActionDefinition(action);
if (!def?.requiresNode) return null;
@@ -226,7 +244,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
res.status(400).json({ error: 'Name is required' }); return;
}
if (!(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) {
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return;
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, system, or container.' }); return;
}
if (!(VALID_ACTIONS as readonly string[]).includes(action)) {
res.status(400).json({ error: INVALID_ACTION_MESSAGE }); return;
@@ -239,6 +257,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
const stackTargetErr = validateStackTarget(target_type, target_id, node_id);
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
const containerTargetErr = validateContainerTarget(target_type, target_id, node_id);
if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; }
const optionalErr = validateOptionalFields(action, target_type, prune_targets, target_services, prune_label_filter);
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
@@ -260,7 +280,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
const nextRun = (enabled === false)
? null
: (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression));
const normalizedTargetId = target_type === 'stack' ? target_id : null;
const normalizedTargetId =
target_type === 'stack' || target_type === 'container' ? target_id : null;
const normalizedNodeId = actionRequiresNode(action) ? parsePositiveNodeId(node_id) : null;
const id = DatabaseService.getInstance().createScheduledTask({
@@ -330,7 +351,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
const finalAction = (action ?? existing.action) as BackendScheduledAction;
const finalTargetType = (target_type ?? existing.target_type) as TargetType;
const finalTargetId = finalTargetType === 'stack'
const finalTargetId = finalTargetType === 'stack' || finalTargetType === 'container'
? (target_id !== undefined ? target_id : existing.target_id)
: null;
const finalNodeId = actionRequiresNode(finalAction)
@@ -345,6 +366,9 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId);
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
const containerTargetErr = validateContainerTarget(finalTargetType, finalTargetId, finalNodeId);
if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; }
const optionalErr = validateOptionalFields(finalAction, finalTargetType, prune_targets, target_services, prune_label_filter);
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
@@ -364,7 +388,9 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
updates.name = name.trim();
}
if (target_type !== undefined) updates.target_type = finalTargetType;
if (target_id !== undefined || finalTargetType !== 'stack') updates.target_id = finalTargetId || null;
if (target_id !== undefined || (finalTargetType !== 'stack' && finalTargetType !== 'container')) {
updates.target_id = finalTargetId || null;
}
if (node_id !== undefined || !actionRequiresNode(finalAction)) {
updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null;
}
+2 -1
View File
@@ -44,6 +44,7 @@ import { sanitizeForLog } from '../utils/safeLog';
import { sendGitSourceError } from '../utils/gitSourceHttp';
import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, describePolicyBlock } from '../helpers/policyGate';
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
import { filterContainersByComposeService } from '../helpers/composeServiceMatch';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection';
import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/envFileResolution';
@@ -1646,7 +1647,7 @@ async function handleServiceAction(
res.status(404).json({ error: 'No containers found for this stack.' });
return;
}
const matching = all.filter(c => c.Service === serviceName);
const matching = filterContainersByComposeService(all, serviceName);
if (matching.length === 0) {
res.status(404).json({ error: `Service '${serviceName}' not found in stack '${stackName}'.` });
return;
+1 -1
View File
@@ -537,7 +537,7 @@ export interface ApiToken {
export interface ScheduledTask {
id: number;
name: string;
target_type: 'stack' | 'fleet' | 'system';
target_type: 'stack' | 'fleet' | 'system' | 'container';
target_id: string | null;
node_id: number | null;
action: BackendScheduledAction;
+36 -1
View File
@@ -863,6 +863,31 @@ class DockerController {
return this.validateApiData<any[]>(containers);
}
/** Resolve a container by its durable name (not ephemeral ID). */
public async findContainerByName(name: string): Promise<{
id: string;
name: string;
state: string;
image: string;
stackProject: string | null;
} | null> {
const normalized = name.replace(/^\//, '');
const containers = await this.getAllContainers();
for (const c of containers) {
const containerName = c.Names?.[0]?.replace(/^\//, '');
if (containerName === normalized) {
return {
id: c.Id,
name: containerName,
state: c.State ?? 'unknown',
image: c.Image ?? '',
stackProject: c.Labels?.['com.docker.compose.project'] ?? null,
};
}
}
return null;
}
/**
* Builds topology data with 2 Docker API calls instead of N+1.
* Fetches all networks + all containers in parallel, then maps
@@ -1488,10 +1513,13 @@ class DockerController {
// 2. Extract expected container names with legacy prefix support
const expectedNames: string[] = [];
const nameToService = new Map<string, string>();
for (const [serviceName, serviceConfig] of Object.entries(parsedYaml.services)) {
const config = serviceConfig as any;
const config = serviceConfig as { container_name?: string };
nameToService.set(serviceName, serviceName);
if (config.container_name) {
expectedNames.push(config.container_name);
nameToService.set(config.container_name, serviceName);
} else {
// Standard v2 naming
expectedNames.push(serviceName);
@@ -1516,6 +1544,11 @@ class DockerController {
// 5. Map to the frontend interface
return fallbackContainers.map(c => {
const strippedName = c.Names?.[0]?.replace(/^\//, '') ?? '';
const labelService = c.Labels?.['com.docker.compose.service'];
const service = (typeof labelService === 'string' && labelService.length > 0
? labelService
: nameToService.get(strippedName)) ?? '';
let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = [];
if (c.Ports && Array.isArray(c.Ports)) {
Ports = c.Ports
@@ -1525,8 +1558,10 @@ class DockerController {
return {
Id: c.Id,
Names: c.Names,
Service: service,
State: c.State,
Status: c.Status,
Labels: c.Labels,
Ports
};
});
+132 -1
View File
@@ -21,6 +21,8 @@ import type { ScanAllNodeImagesResult } from './TrivyService';
import TrivyInstaller from './TrivyInstaller';
import { CloudBackupService } from './CloudBackupService';
import { buildSystemPolicyGateOptions } from '../helpers/policyGate';
import { filterContainersByComposeService } from '../helpers/composeServiceMatch';
import { excludeSelfContainers } from '../helpers/excludeSelfContainers';
import { enforcePolicyPreDeploy } from './PolicyEnforcement';
import { summarizeBlockReasons } from '../utils/policy-risk';
@@ -424,6 +426,9 @@ export class SchedulerService {
}
private async executeRestart(task: ScheduledTask): Promise<string> {
if (task.target_type === 'container') {
return this.executeContainerRestart(task);
}
if (!task.target_id || task.node_id == null) {
throw new Error('Stack restart requires target_id and node_id');
}
@@ -439,7 +444,7 @@ export class SchedulerService {
let filtered = containers;
if (task.target_services) {
const serviceNames: string[] = JSON.parse(task.target_services);
filtered = containers.filter(c => c.Service && serviceNames.includes(c.Service));
filtered = serviceNames.flatMap(svc => filterContainersByComposeService(containers, svc));
if (filtered.length === 0) {
throw new Error(`No containers found matching services [${serviceNames.join(', ')}] in stack "${task.target_id}"`);
}
@@ -507,6 +512,9 @@ export class SchedulerService {
}
private async executeAutoStop(task: ScheduledTask): Promise<string> {
if (task.target_type === 'container') {
return this.executeContainerStop(task);
}
this.assertStackTarget(task, 'Auto-stop');
if (this.isRemoteNode(task.node_id)) {
await this.postToRemoteStack(task.node_id, `${encodeURIComponent(task.target_id)}/stop`);
@@ -537,6 +545,9 @@ export class SchedulerService {
}
private async executeAutoStart(task: ScheduledTask): Promise<string> {
if (task.target_type === 'container') {
return this.executeContainerStart(task);
}
this.assertStackTarget(task, 'Auto-start');
// Remote auto-start proxies to the remote's own deploy route, which runs
// that node's scan-policy gate against the images it actually holds. The
@@ -802,6 +813,126 @@ export class SchedulerService {
* `routeSuffix` is the path under `/api/stacks/`; the caller URL-encodes each
* segment.
*/
private containerNotFoundMessage(containerName: string, nodeId: number): string {
const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId);
return `Container "${containerName}" not found on node "${nodeName}". It may have been renamed or removed.`;
}
private async resolveContainerId(task: ScheduledTask): Promise<{ id: string; name: string }> {
if (!task.target_id || task.node_id == null) {
throw new Error('Container operations require target_id and node_id');
}
const name = task.target_id;
if (this.isRemoteNode(task.node_id)) {
const containers = await this.getRemoteContainers(task.node_id);
const match = containers.find(
c => c.Names?.[0]?.replace(/^\//, '') === name,
);
if (!match) throw new Error(this.containerNotFoundMessage(name, task.node_id));
return { id: match.Id, name };
}
const found = await DockerController.getInstance(task.node_id).findContainerByName(name);
if (!found) throw new Error(this.containerNotFoundMessage(name, task.node_id));
return { id: found.id, name: found.name };
}
private async executeContainerRestart(task: ScheduledTask): Promise<string> {
const { id, name } = await this.resolveContainerId(task);
const nodeId = task.node_id!;
if (this.isRemoteNode(nodeId)) {
await this.postToRemoteContainer(nodeId, id, 'restart');
} else {
await DockerController.getInstance(nodeId).restartContainer(id);
}
const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId);
return `Restarted container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`;
}
private async executeContainerStop(task: ScheduledTask): Promise<string> {
const { id, name } = await this.resolveContainerId(task);
const nodeId = task.node_id!;
if (this.isRemoteNode(nodeId)) {
await this.postToRemoteContainer(nodeId, id, 'stop');
} else {
await DockerController.getInstance(nodeId).stopContainer(id);
}
const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId);
return `Stopped container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`;
}
private async executeContainerStart(task: ScheduledTask): Promise<string> {
const { id, name } = await this.resolveContainerId(task);
const nodeId = task.node_id!;
if (this.isRemoteNode(nodeId)) {
await this.postToRemoteContainer(nodeId, id, 'start');
} else {
await DockerController.getInstance(nodeId).startContainer(id);
}
const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId);
return `Started container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`;
}
private async getRemoteContainers(nodeId: number): Promise<Array<{
Id: string;
Names?: string[];
State?: string;
Image?: string;
}>> {
const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId);
if (!proxyTarget) {
throw new Error('Remote node is not configured or missing API credentials');
}
const baseUrl = proxyTarget.apiUrl.replace(/\/$/, '');
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
const response = await fetch(`${baseUrl}/api/containers?all=true`, {
headers: {
'Authorization': `Bearer ${proxyTarget.apiToken}`,
[PROXY_TIER_HEADER]: proxyHeaders.tier,
},
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) {
const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
throw new Error((body as { error?: string }).error || `Remote node returned ${response.status}`);
}
return excludeSelfContainers(await response.json() as Array<{
Id: string;
Names?: string[];
State?: string;
Image?: string;
ImageID?: string;
}>);
}
private async postToRemoteContainer(
nodeId: number,
containerId: string,
action: 'start' | 'stop' | 'restart',
): Promise<void> {
const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId);
if (!proxyTarget) {
throw new Error('Remote node is not configured or missing API credentials');
}
const baseUrl = proxyTarget.apiUrl.replace(/\/$/, '');
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
const response = await fetch(
`${baseUrl}/api/containers/${encodeURIComponent(containerId)}/${action}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${proxyTarget.apiToken}`,
[PROXY_TIER_HEADER]: proxyHeaders.tier,
},
signal: AbortSignal.timeout(300_000),
},
);
if (!response.ok) {
const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
throw new Error((body as { error?: string }).error || `Remote node returned ${response.status}`);
}
}
private async postToRemoteStack(nodeId: number, routeSuffix: string): Promise<void> {
const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId);
if (!proxyTarget) {
@@ -10,7 +10,7 @@
* each side.
*/
export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system', 'container'] as const;
export type TargetType = typeof VALID_TARGET_TYPES[number];
export interface BackendScheduledActionDefinition {
@@ -26,15 +26,15 @@ export interface BackendScheduledActionDefinition {
* in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ...").
*/
export const BACKEND_SCHEDULED_ACTIONS = [
{ id: 'restart', targetTypes: ['stack'], requiresNode: true },
{ id: 'restart', targetTypes: ['stack', 'container'], requiresNode: true },
{ id: 'snapshot', targetTypes: ['fleet'], requiresNode: false },
{ id: 'prune', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' },
{ id: 'update', targetTypes: ['stack', 'fleet'], requiresNode: true },
{ id: 'scan', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' },
{ id: 'auto_backup', targetTypes: ['stack'], requiresNode: true },
{ id: 'auto_stop', targetTypes: ['stack'], requiresNode: true },
{ id: 'auto_stop', targetTypes: ['stack', 'container'], requiresNode: true },
{ id: 'auto_down', targetTypes: ['stack'], requiresNode: true },
{ id: 'auto_start', targetTypes: ['stack'], requiresNode: true },
{ id: 'auto_start', targetTypes: ['stack', 'container'], requiresNode: true },
] as const satisfies readonly BackendScheduledActionDefinition[];
export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id'];
@@ -58,15 +58,15 @@ const ACTION_BY_ID = new Map<BackendScheduledAction, BackendScheduledActionDefin
* the API contract, so it is kept explicit rather than templated.
*/
const TARGET_MISMATCH_MESSAGE: Record<BackendScheduledAction, string> = {
restart: 'Restart action requires target_type "stack".',
restart: 'Restart action requires target_type "stack" or "container".',
snapshot: 'Snapshot action requires target_type "fleet".',
prune: 'Prune action requires target_type "system".',
update: 'Update action requires target_type "stack" or "fleet".',
scan: 'Scan action requires target_type "system".',
auto_backup: 'auto_backup action requires target_type "stack".',
auto_stop: 'auto_stop action requires target_type "stack".',
auto_stop: 'auto_stop action requires target_type "stack" or "container".',
auto_down: 'auto_down action requires target_type "stack".',
auto_start: 'auto_start action requires target_type "stack".',
auto_start: 'auto_start action requires target_type "stack" or "container".',
};
/**
+4
View File
@@ -8,6 +8,10 @@ import { sanitizeForLog } from './safeLog';
export const isValidStackName = (name: string): boolean =>
/^[a-zA-Z0-9_-]+$/.test(name);
/** Docker container name (no path separators). Used for scheduled container targets. */
export const isValidContainerName = (name: string): boolean =>
!name.includes('..') && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/.test(name);
/**
* Validates that a remote node API URL is a safe, well-formed HTTP/HTTPS URL.
* Rejects loopback addresses to prevent SSRF against local services.