mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix: harden auto-heal policies (#1042)
* fix: harden auto-heal policies * fix: resolve auto-heal lint failure
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let AutoHealService: typeof import('../services/AutoHealService').AutoHealService;
|
||||
let DockerController: typeof import('../services/DockerController').default;
|
||||
let DockerEventManager: typeof import('../services/DockerEventManager').DockerEventManager;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let NotificationService: typeof import('../services/NotificationService').NotificationService;
|
||||
|
||||
function makePolicy(
|
||||
db: import('../services/DatabaseService').DatabaseService,
|
||||
overrides: Partial<import('../services/DatabaseService').AutoHealPolicy> = {},
|
||||
) {
|
||||
const nodeId = db.getDefaultNode()?.id ?? 1;
|
||||
const now = Date.now();
|
||||
return db.addAutoHealPolicy({
|
||||
node_id: nodeId,
|
||||
proxy_entitled_until: 0,
|
||||
stack_name: 'heal-stack',
|
||||
service_name: null,
|
||||
unhealthy_duration_mins: 1,
|
||||
cooldown_mins: 5,
|
||||
max_restarts_per_hour: 3,
|
||||
auto_disable_after_failures: 2,
|
||||
enabled: 1,
|
||||
consecutive_failures: 0,
|
||||
last_fired_at: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function resetAutoHealSingleton() {
|
||||
(AutoHealService as unknown as { instance?: unknown }).instance = undefined;
|
||||
return AutoHealService.getInstance();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ AutoHealService } = await import('../services/AutoHealService'));
|
||||
({ default: DockerController } = await import('../services/DockerController'));
|
||||
({ DockerEventManager } = await import('../services/DockerEventManager'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
({ NotificationService } = await import('../services/NotificationService'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
const db = DatabaseService.getInstance();
|
||||
db.getDb().prepare('DELETE FROM auto_heal_history').run();
|
||||
db.getDb().prepare('DELETE FROM auto_heal_policies').run();
|
||||
vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('AutoHealService.evaluate', () => {
|
||||
it('does not evaluate existing policies on Community tier', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
makePolicy(db);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const dockerSpy = vi.spyOn(DockerController, 'getInstance');
|
||||
|
||||
await resetAutoHealSingleton().evaluate();
|
||||
|
||||
expect(dockerSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('evaluates trusted proxy-entitled policies on a Community runtime node', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
makePolicy(db, { proxy_entitled_until: Date.now() + 60_000 });
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const getRunningContainers = vi.fn().mockResolvedValue([]);
|
||||
const getInstance = vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getRunningContainers,
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
|
||||
await resetAutoHealSingleton().evaluate();
|
||||
|
||||
expect(getInstance).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('heals a currently unhealthy container after the observed threshold elapses without event state', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = makePolicy(db);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
const restartContainer = vi.fn().mockResolvedValue(undefined);
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getRunningContainers: vi.fn().mockResolvedValue([{
|
||||
Id: 'container-1',
|
||||
Names: ['/heal-stack-web-1'],
|
||||
Labels: {
|
||||
'com.docker.compose.project': 'heal-stack',
|
||||
'com.docker.compose.service': 'web',
|
||||
},
|
||||
State: 'running',
|
||||
Status: 'Up 5 minutes (unhealthy)',
|
||||
}]),
|
||||
restartContainer,
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
vi.spyOn(DockerEventManager.getInstance(), 'getService').mockReturnValue({
|
||||
getContainerState: () => undefined,
|
||||
} as unknown as ReturnType<ReturnType<typeof DockerEventManager.getInstance>['getService']>);
|
||||
|
||||
const service = resetAutoHealSingleton();
|
||||
(service as unknown as { observedUnhealthySince: Map<string, number> }).observedUnhealthySince
|
||||
.set(`1:container-1`, Date.now() - 2 * 60_000);
|
||||
|
||||
await service.evaluate();
|
||||
|
||||
expect(restartContainer).toHaveBeenCalledWith('container-1');
|
||||
const history = db.getAutoHealHistory(policy.id!);
|
||||
expect(history[0]).toMatchObject({ action: 'restarted', success: 1 });
|
||||
});
|
||||
|
||||
it('records Docker unavailable history once per throttle window when listing containers fails', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = makePolicy(db);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getRunningContainers: vi.fn().mockRejectedValue(new Error('permission denied')),
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
|
||||
const service = resetAutoHealSingleton();
|
||||
await service.evaluate();
|
||||
await service.evaluate();
|
||||
|
||||
const history = db.getAutoHealHistory(policy.id!);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]).toMatchObject({
|
||||
action: 'docker_unavailable',
|
||||
success: 0,
|
||||
error: 'permission denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('evaluates only policies scoped to each local node', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const secondNodeId = db.addNode({
|
||||
name: 'second-local',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
makePolicy(db, { node_id: secondNodeId, stack_name: 'second-stack' });
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
const getRunningContainers = vi.fn().mockResolvedValue([]);
|
||||
const getInstance = vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getRunningContainers,
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
|
||||
await resetAutoHealSingleton().evaluate();
|
||||
|
||||
expect(getInstance).toHaveBeenCalledTimes(1);
|
||||
expect(getInstance).toHaveBeenCalledWith(secondNodeId);
|
||||
});
|
||||
|
||||
it('keeps restart rate-limit state isolated by node while pruning', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const secondNodeId = db.addNode({
|
||||
name: 'rate-limit-second-local',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
const policy = makePolicy(db, { node_id: secondNodeId, stack_name: 'second-rate-stack' });
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getRunningContainers: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
|
||||
const service = resetAutoHealSingleton();
|
||||
const restartTimestamps = (service as unknown as { restartTimestamps: Map<string, number[]> }).restartTimestamps;
|
||||
const nodeOneKey = '1:node-one-container';
|
||||
restartTimestamps.set(nodeOneKey, [Date.now()]);
|
||||
|
||||
await (service as unknown as {
|
||||
evaluateForNode: (nodeId: number, policies: import('../services/DatabaseService').AutoHealPolicy[]) => Promise<void>;
|
||||
}).evaluateForNode(secondNodeId, [policy]);
|
||||
|
||||
expect(restartTimestamps.has(nodeOneKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('prunes observed health and history throttle entries for removed containers and deleted policies', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = makePolicy(db, { stack_name: 'cleanup-stack' });
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getRunningContainers: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
|
||||
const service = resetAutoHealSingleton();
|
||||
const internals = service as unknown as {
|
||||
observedUnhealthySince: Map<string, number>;
|
||||
historyTimestamps: Map<string, number>;
|
||||
evaluateForNode: (nodeId: number, policies: import('../services/DatabaseService').AutoHealPolicy[]) => Promise<void>;
|
||||
};
|
||||
internals.observedUnhealthySince.set('1:removed-container', Date.now());
|
||||
internals.historyTimestamps.set(`1:${policy.id}:removed-container:skipped_cooldown`, Date.now());
|
||||
db.deleteAutoHealPolicy(policy.id!);
|
||||
|
||||
await internals.evaluateForNode(1, []);
|
||||
|
||||
expect(internals.observedUnhealthySince.has('1:removed-container')).toBe(false);
|
||||
expect(internals.historyTimestamps.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not create duplicate timers when start is called twice', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const service = resetAutoHealSingleton();
|
||||
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
|
||||
const setIntervalSpy = vi.spyOn(global, 'setInterval');
|
||||
|
||||
service.start();
|
||||
service.start();
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
|
||||
service.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,8 @@ describe('AutoHealService.shouldHeal', () => {
|
||||
|
||||
const basePolicy = {
|
||||
id: 1,
|
||||
node_id: 1,
|
||||
proxy_entitled_until: 0,
|
||||
stack_name: 'mystack',
|
||||
service_name: null,
|
||||
unhealthy_duration_mins: 5,
|
||||
|
||||
@@ -13,6 +13,8 @@ let db: any;
|
||||
const makePolicy = (overrides: Record<string, unknown> = {}) => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
node_id: 1,
|
||||
proxy_entitled_until: 0,
|
||||
stack_name: 'teststack',
|
||||
service_name: null,
|
||||
unhealthy_duration_mins: 5,
|
||||
@@ -59,6 +61,8 @@ describe('DatabaseService - auto-heal policy CRUD', () => {
|
||||
const fetched = db.getAutoHealPolicy(created.id);
|
||||
expect(fetched).toBeDefined();
|
||||
expect(fetched.stack_name).toBe('roundtrip-stack');
|
||||
expect(fetched.node_id).toBe(1);
|
||||
expect(fetched.proxy_entitled_until).toBe(0);
|
||||
expect(fetched.service_name).toBe('web');
|
||||
expect(fetched.unhealthy_duration_mins).toBe(3);
|
||||
expect(fetched.cooldown_mins).toBe(15);
|
||||
@@ -94,6 +98,51 @@ describe('DatabaseService - auto-heal policy CRUD', () => {
|
||||
expect(yPolicies.every((p: any) => p.stack_name === 'filter-stack-y')).toBe(true);
|
||||
});
|
||||
|
||||
it('getAutoHealPolicies with nodeId filter returns only matching rows', () => {
|
||||
db.addAutoHealPolicy(makePolicy({ stack_name: 'node-filter-a', node_id: 1 }));
|
||||
db.addAutoHealPolicy(makePolicy({ stack_name: 'node-filter-b', node_id: 2 }));
|
||||
|
||||
const nodeOnePolicies = db.getAutoHealPolicies(undefined, 1);
|
||||
const nodeTwoPolicies = db.getAutoHealPolicies(undefined, 2);
|
||||
|
||||
expect(nodeOnePolicies.every((p: any) => p.node_id === 1)).toBe(true);
|
||||
expect(nodeTwoPolicies.every((p: any) => p.node_id === 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('auto-heal node migration does not rewrite already-scoped node 1 policies', () => {
|
||||
const created = db.addAutoHealPolicy(makePolicy({ stack_name: 'migration-node-one', node_id: 1 }));
|
||||
db.addNode({
|
||||
name: 'new-default-node',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: true,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
|
||||
(db as any).migrateAutoHealNodeId();
|
||||
|
||||
expect(db.getAutoHealPolicy(created.id).node_id).toBe(1);
|
||||
});
|
||||
|
||||
it('auto-heal node migration resumes backfill when the completion marker is missing', () => {
|
||||
db.updateGlobalSetting('migration_auto_heal_node_scope_v1', '');
|
||||
const created = db.addAutoHealPolicy(makePolicy({ stack_name: 'migration-partial', node_id: 1 }));
|
||||
const newDefaultId = db.addNode({
|
||||
name: 'partial-new-default-node',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: true,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
|
||||
(db as any).migrateAutoHealNodeId();
|
||||
|
||||
expect(db.getAutoHealPolicy(created.id).node_id).toBe(newDefaultId);
|
||||
expect(db.getGlobalSettings().migration_auto_heal_node_scope_v1).toBe('1');
|
||||
});
|
||||
|
||||
it('updateAutoHealPolicy partial update changes only specified fields', () => {
|
||||
const created = db.addAutoHealPolicy(makePolicy({ stack_name: 'update-stack', cooldown_mins: 10 }));
|
||||
|
||||
@@ -212,6 +261,32 @@ describe('DatabaseService - auto-heal history', () => {
|
||||
expect(limited[1].reason).toBe('entry-8');
|
||||
expect(limited[2].reason).toBe('entry-7');
|
||||
});
|
||||
|
||||
it('recordAutoHealHistory prunes old rows beyond the retained window', () => {
|
||||
const policy = db.addAutoHealPolicy(makePolicy({ stack_name: 'history-prune-stack' }));
|
||||
const policyId: number = policy.id;
|
||||
const baseTs = Date.now();
|
||||
|
||||
for (let i = 0; i < 505; i++) {
|
||||
db.recordAutoHealHistory({
|
||||
policy_id: policyId,
|
||||
stack_name: 'history-prune-stack',
|
||||
service_name: null,
|
||||
container_name: 'history-prune-stack-web-1',
|
||||
container_id: 'hist-prune',
|
||||
action: 'skipped_cooldown',
|
||||
reason: `entry-${i}`,
|
||||
success: 0,
|
||||
error: null,
|
||||
timestamp: baseTs + i,
|
||||
});
|
||||
}
|
||||
|
||||
const retained = db.getAutoHealHistory(policyId, 600);
|
||||
expect(retained.length).toBe(500);
|
||||
expect(retained[0].reason).toBe('entry-504');
|
||||
expect(retained[499].reason).toBe('entry-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseService - consecutive failure counters', () => {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
|
||||
function userToken(username: string): string {
|
||||
const user = DatabaseService.getInstance().getUserByUsername(username);
|
||||
if (!user) throw new Error(`missing test user ${username}`);
|
||||
return jwt.sign({ username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' });
|
||||
}
|
||||
|
||||
function createApiToken(scope: 'read-only' | 'deploy-only' | 'full-admin'): string {
|
||||
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '5m' });
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const admin = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME);
|
||||
if (!admin?.id) throw new Error('missing seeded admin');
|
||||
DatabaseService.getInstance().addApiToken({
|
||||
token_hash: tokenHash,
|
||||
name: `auto-heal-${scope}-${Date.now()}`,
|
||||
scope,
|
||||
user_id: admin.id,
|
||||
created_at: Date.now(),
|
||||
expires_at: null,
|
||||
});
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
function makePolicy(nodeId: number, stackName = 'route-stack') {
|
||||
const now = Date.now();
|
||||
return DatabaseService.getInstance().addAutoHealPolicy({
|
||||
node_id: nodeId,
|
||||
proxy_entitled_until: 0,
|
||||
stack_name: stackName,
|
||||
service_name: null,
|
||||
unhealthy_duration_mins: 5,
|
||||
cooldown_mins: 5,
|
||||
max_restarts_per_hour: 3,
|
||||
auto_disable_after_failures: 5,
|
||||
enabled: 1,
|
||||
consecutive_failures: 0,
|
||||
last_fired_at: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
|
||||
const viewerHash = await bcrypt.hash('password123', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'route-viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
DatabaseService.getInstance().getDb().prepare('DELETE FROM auto_heal_history').run();
|
||||
DatabaseService.getInstance().getDb().prepare('DELETE FROM auto_heal_policies').run();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('/api/auto-heal routes', () => {
|
||||
it('rejects Community tier access', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auto-heal/policies')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('marks trusted proxy-created policies with a lease on a Community runtime node', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const proxyToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '5m' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auto-heal/policies')
|
||||
.set('Authorization', `Bearer ${proxyToken}`)
|
||||
.set(PROXY_TIER_HEADER, 'paid')
|
||||
.set(PROXY_VARIANT_HEADER, 'admiral')
|
||||
.send({
|
||||
stack_name: 'proxy-runtime-stack',
|
||||
unhealthy_duration_mins: 5,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.proxy_entitled_until).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('allows admins to create node-scoped policies on paid tier', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-heal/policies')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`)
|
||||
.send({
|
||||
stack_name: 'route-stack',
|
||||
service_name: null,
|
||||
unhealthy_duration_mins: 5,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.node_id).toBe(DatabaseService.getInstance().getDefaultNode()?.id);
|
||||
expect(res.body.proxy_entitled_until).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects non-admin policy mutation', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-heal/policies')
|
||||
.set('Authorization', `Bearer ${userToken('route-viewer')}`)
|
||||
.send({
|
||||
stack_name: 'route-stack',
|
||||
unhealthy_duration_mins: 5,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('lists only policies for the active node', async () => {
|
||||
const defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1;
|
||||
const secondNodeId = DatabaseService.getInstance().addNode({
|
||||
name: 'route-second-local',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
makePolicy(defaultNodeId, 'same-stack');
|
||||
makePolicy(secondNodeId, 'same-stack');
|
||||
|
||||
const defaultRes = await request(app)
|
||||
.get('/api/auto-heal/policies?stackName=same-stack')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
const secondRes = await request(app)
|
||||
.get('/api/auto-heal/policies?stackName=same-stack')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`)
|
||||
.set('x-node-id', String(secondNodeId));
|
||||
|
||||
expect(defaultRes.status).toBe(200);
|
||||
expect(defaultRes.body).toHaveLength(1);
|
||||
expect(defaultRes.body[0].node_id).toBe(defaultNodeId);
|
||||
expect(secondRes.status).toBe(200);
|
||||
expect(secondRes.body).toHaveLength(1);
|
||||
expect(secondRes.body[0].node_id).toBe(secondNodeId);
|
||||
});
|
||||
|
||||
it('rejects history access for a policy owned by a different node', async () => {
|
||||
const secondNodeId = DatabaseService.getInstance().addNode({
|
||||
name: 'history-second-local',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
const policy = makePolicy(secondNodeId, 'history-stack');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/auto-heal/policies/${policy.id}/history`)
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('persists enabled toggles through the patch route', async () => {
|
||||
const defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1;
|
||||
const policy = makePolicy(defaultNodeId, 'toggle-stack');
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/auto-heal/policies/${policy.id}`)
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`)
|
||||
.send({ enabled: 0 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(0);
|
||||
expect(DatabaseService.getInstance().getAutoHealPolicy(policy.id!)?.enabled).toBe(0);
|
||||
});
|
||||
|
||||
it('allows read-only API tokens to list but not mutate policies', async () => {
|
||||
const token = createApiToken('read-only');
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/auto-heal/policies')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
const postRes = await request(app)
|
||||
.post('/api/auto-heal/policies')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ stack_name: 'api-token-stack', unhealthy_duration_mins: 5 });
|
||||
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(postRes.status).toBe(403);
|
||||
expect(postRes.body.code).toBe('SCOPE_DENIED');
|
||||
});
|
||||
|
||||
it('returns 503 for disconnected remote auto-heal requests before local routes run', async () => {
|
||||
const remoteNodeId = DatabaseService.getInstance().addNode({
|
||||
name: 'disconnected-remote',
|
||||
type: 'remote',
|
||||
compose_dir: '',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auto-heal/policies')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`)
|
||||
.set('x-node-id', String(remoteNodeId));
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toContain('has no API URL or token');
|
||||
});
|
||||
});
|
||||
@@ -14,15 +14,36 @@ const AutoHealPolicyCreateSchema = z.object({
|
||||
max_restarts_per_hour: z.coerce.number().int().min(1).max(60).default(3),
|
||||
auto_disable_after_failures: z.coerce.number().int().min(1).max(100).default(5),
|
||||
});
|
||||
const AutoHealPolicyUpdateSchema = AutoHealPolicyCreateSchema.partial().omit({ stack_name: true });
|
||||
const AutoHealPolicyUpdateSchema = AutoHealPolicyCreateSchema
|
||||
.partial()
|
||||
.omit({ stack_name: true })
|
||||
.extend({
|
||||
enabled: z.coerce.number().int().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
export const autoHealRouter = Router();
|
||||
|
||||
const PROXY_ENTITLEMENT_LEASE_MS = 5 * 60_000;
|
||||
|
||||
function proxyEntitlementUntil(req: Request): number {
|
||||
return req.proxyTier === 'paid' ? Date.now() + PROXY_ENTITLEMENT_LEASE_MS : 0;
|
||||
}
|
||||
|
||||
autoHealRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = typeof req.query.stackName === 'string' ? req.query.stackName : undefined;
|
||||
try {
|
||||
res.json(DatabaseService.getInstance().getAutoHealPolicies(stackName));
|
||||
const db = DatabaseService.getInstance();
|
||||
const policies = db.getAutoHealPolicies(stackName, req.nodeId);
|
||||
const leaseUntil = proxyEntitlementUntil(req);
|
||||
if (leaseUntil > 0) {
|
||||
for (const policy of policies) {
|
||||
if (policy.id !== undefined) db.updateAutoHealPolicy(policy.id, { proxy_entitled_until: leaseUntil });
|
||||
}
|
||||
res.json(db.getAutoHealPolicies(stackName, req.nodeId));
|
||||
return;
|
||||
}
|
||||
res.json(policies);
|
||||
} catch (err) {
|
||||
console.error('[AutoHeal] Failed to list policies:', getErrorMessage(err, 'unknown'));
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -41,6 +62,8 @@ autoHealRouter.post('/policies', authMiddleware, (req: Request, res: Response):
|
||||
const now = Date.now();
|
||||
try {
|
||||
const policy = DatabaseService.getInstance().addAutoHealPolicy({
|
||||
node_id: req.nodeId,
|
||||
proxy_entitled_until: proxyEntitlementUntil(req),
|
||||
stack_name,
|
||||
service_name: service_name ?? null,
|
||||
unhealthy_duration_mins,
|
||||
@@ -72,8 +95,9 @@ autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Respon
|
||||
}
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
if (!db.getAutoHealPolicy(id)) { res.status(404).json({ error: 'Policy not found' }); return; }
|
||||
db.updateAutoHealPolicy(id, parsed.data);
|
||||
const policy = db.getAutoHealPolicy(id);
|
||||
if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; }
|
||||
db.updateAutoHealPolicy(id, { ...parsed.data, proxy_entitled_until: Math.max(policy.proxy_entitled_until, proxyEntitlementUntil(req)) });
|
||||
res.json(db.getAutoHealPolicy(id));
|
||||
} catch (err) {
|
||||
console.error('[AutoHeal] Failed to update policy:', getErrorMessage(err, 'unknown'));
|
||||
@@ -88,7 +112,8 @@ autoHealRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Respo
|
||||
if (id === null) return;
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
if (!db.getAutoHealPolicy(id)) { res.status(404).json({ error: 'Policy not found' }); return; }
|
||||
const policy = db.getAutoHealPolicy(id);
|
||||
if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; }
|
||||
db.deleteAutoHealPolicy(id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
@@ -103,7 +128,10 @@ autoHealRouter.get('/policies/:id/history', authMiddleware, (req: Request, res:
|
||||
if (id === null) return;
|
||||
const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 100);
|
||||
try {
|
||||
res.json(DatabaseService.getInstance().getAutoHealHistory(id, limit));
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = db.getAutoHealPolicy(id);
|
||||
if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; }
|
||||
res.json(db.getAutoHealHistory(id, limit));
|
||||
} catch (err) {
|
||||
console.error('[AutoHeal] Failed to fetch history:', getErrorMessage(err, 'unknown'));
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
||||
@@ -65,7 +65,7 @@ export function buildLocalConfigurationStatus(
|
||||
const alertRules = db.getStackAlerts().length;
|
||||
const notifRoutes = db.getNotificationRoutes();
|
||||
|
||||
const healPolicies = db.getAutoHealPolicies();
|
||||
const healPolicies = db.getAutoHealPolicies(undefined, nodeId);
|
||||
const autoUpdateMap = db.getStackAutoUpdateSettingsForNode(nodeId);
|
||||
const autoUpdateEnabled = Object.values(autoUpdateMap).filter(Boolean).length;
|
||||
const autoUpdateTotal = Object.keys(autoUpdateMap).length;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DatabaseService, AutoHealPolicy, AutoHealHistoryEntry } from './Databas
|
||||
import DockerController from './DockerController';
|
||||
import { DockerEventManager } from './DockerEventManager';
|
||||
import { ContainerHealthSnapshot } from './DockerEventService';
|
||||
import { LicenseService } from './LicenseService';
|
||||
import { NotificationService } from './NotificationService';
|
||||
|
||||
// Dockerode listContainers shape (subset used here)
|
||||
@@ -10,11 +11,14 @@ type ContainerInfo = {
|
||||
Id: string;
|
||||
Names?: string[];
|
||||
Labels?: Record<string, string>;
|
||||
State?: string;
|
||||
Status?: string;
|
||||
};
|
||||
|
||||
const EVAL_INTERVAL_MS = 30_000;
|
||||
const INITIAL_DELAY_MS = 10_000;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 60_000; // 1 hour
|
||||
const HISTORY_THROTTLE_MS = 5 * 60_000;
|
||||
|
||||
export class AutoHealService {
|
||||
private static instance: AutoHealService;
|
||||
@@ -22,6 +26,8 @@ export class AutoHealService {
|
||||
private initialTimer: NodeJS.Timeout | null = null;
|
||||
private isProcessing = false;
|
||||
private restartTimestamps = new Map<string, number[]>();
|
||||
private observedUnhealthySince = new Map<string, number>();
|
||||
private historyTimestamps = new Map<string, number>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -33,6 +39,7 @@ export class AutoHealService {
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.initialTimer || this.intervalId) return;
|
||||
this.initialTimer = setTimeout(() => {
|
||||
void this.evaluate();
|
||||
this.intervalId = setInterval(() => void this.evaluate(), EVAL_INTERVAL_MS);
|
||||
@@ -54,13 +61,18 @@ export class AutoHealService {
|
||||
if (this.isProcessing) return;
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
const localPaid = LicenseService.getInstance().getTier() === 'paid';
|
||||
const db = DatabaseService.getInstance();
|
||||
const policies = db.getAutoHealPolicies().filter(p => p.enabled === 1);
|
||||
if (policies.length === 0) return;
|
||||
|
||||
// Evaluate only on local nodes (remote nodes self-monitor via their own instance)
|
||||
const nodes = db.getNodes().filter(n => n.type === 'local');
|
||||
const now = Date.now();
|
||||
for (const node of nodes) {
|
||||
const policies = db.getAutoHealPolicies(undefined, node.id).filter(p =>
|
||||
p.enabled === 1 && (localPaid || p.proxy_entitled_until > now)
|
||||
);
|
||||
this.pruneInactivePolicyHistory(node.id, policies);
|
||||
if (policies.length === 0) continue;
|
||||
await this.evaluateForNode(node.id, policies);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -75,27 +87,52 @@ export class AutoHealService {
|
||||
try {
|
||||
containers = await DockerController.getInstance(nodeId).getRunningContainers();
|
||||
} catch (err) {
|
||||
const now = Date.now();
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(
|
||||
`[AutoHeal] failed to list containers on node ${nodeId}:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
errorMsg,
|
||||
);
|
||||
for (const policy of policies) {
|
||||
if (policy.id === undefined) continue;
|
||||
this.recordThrottledHistory(policy, {
|
||||
policy_id: policy.id!,
|
||||
stack_name: policy.stack_name,
|
||||
service_name: policy.service_name,
|
||||
container_name: `node-${nodeId}`,
|
||||
container_id: `node-${nodeId}`,
|
||||
action: 'docker_unavailable',
|
||||
reason: 'Skipped: Docker daemon is unavailable for this node.',
|
||||
success: 0,
|
||||
error: errorMsg,
|
||||
timestamp: now,
|
||||
}, nodeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const eventSvc = DockerEventManager.getInstance().getService(nodeId);
|
||||
const now = Date.now();
|
||||
|
||||
// Prune stale entries for containers no longer running on this node
|
||||
const liveIds = new Set(containers.map(c => c.Id));
|
||||
for (const [cid, timestamps] of this.restartTimestamps.entries()) {
|
||||
const liveKeys = new Set(containers.map(c => this.containerKey(nodeId, c.Id)));
|
||||
for (const [key, timestamps] of this.restartTimestamps.entries()) {
|
||||
if (!key.startsWith(`${nodeId}:`)) continue;
|
||||
const containerId = key.slice(String(nodeId).length + 1);
|
||||
const recent = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
|
||||
if (recent.length === 0 || !liveIds.has(cid)) {
|
||||
this.restartTimestamps.delete(cid);
|
||||
if (recent.length === 0 || !liveIds.has(containerId)) {
|
||||
this.restartTimestamps.delete(key);
|
||||
} else {
|
||||
this.restartTimestamps.set(cid, recent);
|
||||
this.restartTimestamps.set(key, recent);
|
||||
}
|
||||
}
|
||||
for (const key of this.observedUnhealthySince.keys()) {
|
||||
if (key.startsWith(`${nodeId}:`) && !liveKeys.has(key)) {
|
||||
this.observedUnhealthySince.delete(key);
|
||||
}
|
||||
}
|
||||
this.pruneInactivePolicyHistory(nodeId, policies);
|
||||
|
||||
for (const policy of policies) {
|
||||
if (policy.id === undefined) {
|
||||
@@ -115,8 +152,8 @@ export class AutoHealService {
|
||||
const containerName =
|
||||
container.Names?.[0]?.replace(/^\//, '') ?? container.Id.slice(0, 12);
|
||||
const serviceOverride = container.Labels?.['com.docker.compose.service'] ?? null;
|
||||
const state = eventSvc?.getContainerState(container.Id);
|
||||
const decision = this.shouldHeal(state, policy, container.Id, now);
|
||||
const state = this.getEffectiveState(nodeId, container, eventSvc?.getContainerState(container.Id), now);
|
||||
const decision = this.shouldHeal(state, policy, this.containerKey(nodeId, container.Id), now);
|
||||
|
||||
if (!decision.heal) {
|
||||
if (
|
||||
@@ -124,7 +161,7 @@ export class AutoHealService {
|
||||
decision.skipReason !== 'not_unhealthy' &&
|
||||
decision.skipReason !== 'duration_not_met'
|
||||
) {
|
||||
db.recordAutoHealHistory({
|
||||
this.recordThrottledHistory(policy, {
|
||||
policy_id: policy.id!,
|
||||
stack_name: policy.stack_name,
|
||||
service_name: policy.service_name ?? serviceOverride,
|
||||
@@ -135,7 +172,7 @@ export class AutoHealService {
|
||||
success: 0,
|
||||
error: null,
|
||||
timestamp: now,
|
||||
});
|
||||
}, nodeId, container.Id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -151,6 +188,51 @@ export class AutoHealService {
|
||||
}
|
||||
}
|
||||
|
||||
private getEffectiveState(
|
||||
nodeId: number,
|
||||
container: ContainerInfo,
|
||||
eventState: ContainerHealthSnapshot | undefined,
|
||||
now: number,
|
||||
): ContainerHealthSnapshot | undefined {
|
||||
const key = this.containerKey(nodeId, container.Id);
|
||||
const statusText = `${container.State ?? ''} ${container.Status ?? ''}`.toLowerCase();
|
||||
const dockerHealth = statusText.includes('unhealthy')
|
||||
? 'unhealthy'
|
||||
: statusText.includes('healthy')
|
||||
? 'healthy'
|
||||
: statusText.includes('starting')
|
||||
? 'starting'
|
||||
: undefined;
|
||||
|
||||
if (dockerHealth === 'unhealthy') {
|
||||
const unhealthySince = eventState?.healthStatus === 'unhealthy' && eventState.unhealthySince
|
||||
? eventState.unhealthySince
|
||||
: this.observedUnhealthySince.get(key) ?? now;
|
||||
this.observedUnhealthySince.set(key, unhealthySince);
|
||||
return {
|
||||
id: container.Id,
|
||||
name: eventState?.name ?? container.Names?.[0]?.replace(/^\//, ''),
|
||||
stackName: eventState?.stackName ?? container.Labels?.['com.docker.compose.project'],
|
||||
healthStatus: 'unhealthy',
|
||||
unhealthySince,
|
||||
lastKillAt: eventState?.lastKillAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (dockerHealth === 'healthy' || dockerHealth === 'starting') {
|
||||
this.observedUnhealthySince.delete(key);
|
||||
return {
|
||||
id: container.Id,
|
||||
name: eventState?.name ?? container.Names?.[0]?.replace(/^\//, ''),
|
||||
stackName: eventState?.stackName ?? container.Labels?.['com.docker.compose.project'],
|
||||
healthStatus: dockerHealth,
|
||||
lastKillAt: eventState?.lastKillAt,
|
||||
};
|
||||
}
|
||||
|
||||
return eventState;
|
||||
}
|
||||
|
||||
private shouldHeal(
|
||||
state: ContainerHealthSnapshot | undefined,
|
||||
policy: AutoHealPolicy,
|
||||
@@ -213,10 +295,11 @@ export class AutoHealService {
|
||||
db.resetConsecutiveFailures(policy.id!);
|
||||
db.updateAutoHealPolicy(policy.id!, { last_fired_at: now });
|
||||
|
||||
const timestamps = this.restartTimestamps.get(containerId) ?? [];
|
||||
const restartKey = this.containerKey(nodeId, containerId);
|
||||
const timestamps = this.restartTimestamps.get(restartKey) ?? [];
|
||||
timestamps.push(now);
|
||||
this.restartTimestamps.set(
|
||||
containerId,
|
||||
restartKey,
|
||||
timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS),
|
||||
);
|
||||
|
||||
@@ -232,7 +315,7 @@ export class AutoHealService {
|
||||
timestamp: now,
|
||||
username: 'system',
|
||||
method: 'POST',
|
||||
path: '/api/auto-heal/execute',
|
||||
path: '/system/auto-heal',
|
||||
status_code: 200,
|
||||
node_id: nodeId,
|
||||
ip_address: '127.0.0.1',
|
||||
@@ -306,6 +389,34 @@ export class AutoHealService {
|
||||
.catch(e => console.error('[AutoHeal] notification dispatch failed:', e));
|
||||
}
|
||||
|
||||
private recordThrottledHistory(
|
||||
policy: AutoHealPolicy,
|
||||
entry: Omit<AutoHealHistoryEntry, 'id'>,
|
||||
nodeId: number,
|
||||
containerId = entry.container_id,
|
||||
): void {
|
||||
if (policy.id === undefined) return;
|
||||
const key = `${nodeId}:${policy.id}:${containerId}:${entry.action}`;
|
||||
const lastRecorded = this.historyTimestamps.get(key) ?? 0;
|
||||
if (entry.timestamp - lastRecorded < HISTORY_THROTTLE_MS) return;
|
||||
this.historyTimestamps.set(key, entry.timestamp);
|
||||
DatabaseService.getInstance().recordAutoHealHistory(entry);
|
||||
}
|
||||
|
||||
private pruneInactivePolicyHistory(nodeId: number, policies: AutoHealPolicy[]): void {
|
||||
const activePolicyIds = new Set(policies.map(p => p.id).filter((id): id is number => id !== undefined));
|
||||
for (const key of this.historyTimestamps.keys()) {
|
||||
const [keyNodeId, policyId] = key.split(':');
|
||||
if (keyNodeId === String(nodeId) && !activePolicyIds.has(Number(policyId))) {
|
||||
this.historyTimestamps.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private containerKey(nodeId: number, containerId: string): string {
|
||||
return `${nodeId}:${containerId}`;
|
||||
}
|
||||
|
||||
private skipReasonText(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'skipped_user_action':
|
||||
|
||||
@@ -31,6 +31,8 @@ export type NodeMode = 'proxy' | 'pilot_agent';
|
||||
|
||||
export interface AutoHealPolicy {
|
||||
id?: number;
|
||||
node_id: number;
|
||||
proxy_entitled_until: number;
|
||||
stack_name: string;
|
||||
service_name: string | null;
|
||||
unhealthy_duration_mins: number;
|
||||
@@ -51,7 +53,7 @@ export interface AutoHealHistoryEntry {
|
||||
service_name: string | null;
|
||||
container_name: string;
|
||||
container_id: string;
|
||||
action: 'restarted' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit' | 'failed' | 'policy_auto_disabled';
|
||||
action: 'restarted' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit' | 'failed' | 'policy_auto_disabled' | 'docker_unavailable';
|
||||
reason: string;
|
||||
success: number;
|
||||
error: string | null;
|
||||
@@ -650,6 +652,7 @@ export class DatabaseService {
|
||||
this.migrateAddNodeLastContact();
|
||||
this.migrateAddNodeCordonFields();
|
||||
this.migrateAddBlueprintPinnedNode();
|
||||
this.migrateAutoHealNodeId();
|
||||
|
||||
// Reset the cache once at end of constructor in case any migration
|
||||
// populated it via getGlobalSettings() and a subsequent migration
|
||||
@@ -1074,6 +1077,8 @@ export class DatabaseService {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auto_heal_policies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL DEFAULT 1,
|
||||
proxy_entitled_until INTEGER NOT NULL DEFAULT 0,
|
||||
stack_name TEXT NOT NULL,
|
||||
service_name TEXT,
|
||||
unhealthy_duration_mins INTEGER NOT NULL,
|
||||
@@ -1372,11 +1377,14 @@ export class DatabaseService {
|
||||
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notification_routes_node_priority ON notification_routes(node_id, enabled, priority)').run();
|
||||
}
|
||||
|
||||
private tryAddColumn(table: string, col: string, def: string): void {
|
||||
private tryAddColumn(table: string, col: string, def: string): boolean {
|
||||
try {
|
||||
this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run();
|
||||
} catch {
|
||||
/* column already present */
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||
if (!message.includes('duplicate column name')) throw err;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1552,6 +1560,24 @@ export class DatabaseService {
|
||||
this.tryAddColumn('blueprints', 'pinned_node_id', 'INTEGER');
|
||||
}
|
||||
|
||||
private migrateAutoHealNodeId(): void {
|
||||
const markerKey = 'migration_auto_heal_node_scope_v1';
|
||||
const markerDone = this.getGlobalSettings()[markerKey] === '1';
|
||||
this.tryAddColumn('auto_heal_policies', 'node_id', 'INTEGER NOT NULL DEFAULT 1');
|
||||
this.tryAddColumn('auto_heal_policies', 'proxy_entitled_until', 'INTEGER NOT NULL DEFAULT 0');
|
||||
if (!markerDone) {
|
||||
const defaultNode = this.getDefaultNode();
|
||||
if (defaultNode?.id) {
|
||||
this.db.transaction(() => {
|
||||
this.db.prepare('UPDATE auto_heal_policies SET node_id = ? WHERE node_id IS NULL OR node_id = 1').run(defaultNode.id);
|
||||
this.updateGlobalSetting(markerKey, '1');
|
||||
})();
|
||||
} else {
|
||||
this.updateGlobalSetting(markerKey, '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sencho Mesh ---
|
||||
|
||||
public listMeshStacks(nodeId?: number): Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }> {
|
||||
@@ -1786,10 +1812,16 @@ export class DatabaseService {
|
||||
|
||||
// --- Auto-Heal Policies ---
|
||||
|
||||
public getAutoHealPolicies(stackName?: string): AutoHealPolicy[] {
|
||||
public getAutoHealPolicies(stackName?: string, nodeId?: number): AutoHealPolicy[] {
|
||||
if (stackName && nodeId !== undefined) {
|
||||
return this.db.prepare('SELECT * FROM auto_heal_policies WHERE stack_name = ? AND node_id = ?').all(stackName, nodeId) as AutoHealPolicy[];
|
||||
}
|
||||
if (stackName) {
|
||||
return this.db.prepare('SELECT * FROM auto_heal_policies WHERE stack_name = ?').all(stackName) as AutoHealPolicy[];
|
||||
}
|
||||
if (nodeId !== undefined) {
|
||||
return this.db.prepare('SELECT * FROM auto_heal_policies WHERE node_id = ?').all(nodeId) as AutoHealPolicy[];
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM auto_heal_policies').all() as AutoHealPolicy[];
|
||||
}
|
||||
|
||||
@@ -1799,9 +1831,11 @@ export class DatabaseService {
|
||||
|
||||
public addAutoHealPolicy(policy: Omit<AutoHealPolicy, 'id'>): AutoHealPolicy {
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO auto_heal_policies (stack_name, service_name, unhealthy_duration_mins, cooldown_mins, max_restarts_per_hour, auto_disable_after_failures, enabled, consecutive_failures, last_fired_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO auto_heal_policies (node_id, proxy_entitled_until, stack_name, service_name, unhealthy_duration_mins, cooldown_mins, max_restarts_per_hour, auto_disable_after_failures, enabled, consecutive_failures, last_fired_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(
|
||||
policy.node_id,
|
||||
policy.proxy_entitled_until,
|
||||
policy.stack_name,
|
||||
policy.service_name ?? null,
|
||||
policy.unhealthy_duration_mins,
|
||||
@@ -1821,7 +1855,7 @@ export class DatabaseService {
|
||||
const ALLOWED_KEYS = new Set([
|
||||
'service_name', 'unhealthy_duration_mins', 'cooldown_mins',
|
||||
'max_restarts_per_hour', 'auto_disable_after_failures',
|
||||
'enabled', 'consecutive_failures', 'last_fired_at',
|
||||
'enabled', 'consecutive_failures', 'last_fired_at', 'proxy_entitled_until',
|
||||
]);
|
||||
const entries = Object.entries(patch).filter(([k, v]) => ALLOWED_KEYS.has(k) && v !== undefined);
|
||||
if (entries.length === 0) return;
|
||||
@@ -1852,6 +1886,7 @@ export class DatabaseService {
|
||||
entry.error ?? null,
|
||||
entry.timestamp
|
||||
);
|
||||
this.pruneAutoHealHistory(entry.policy_id);
|
||||
}
|
||||
|
||||
public getAutoHealHistory(policyId: number, limit = 50): AutoHealHistoryEntry[] {
|
||||
@@ -1860,6 +1895,21 @@ export class DatabaseService {
|
||||
).all(policyId, limit) as AutoHealHistoryEntry[];
|
||||
}
|
||||
|
||||
public pruneAutoHealHistory(policyId: number, maxRows = 500, maxAgeMs = 30 * 24 * 60 * 60_000): void {
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
this.db.prepare('DELETE FROM auto_heal_history WHERE policy_id = ? AND timestamp < ?').run(policyId, cutoff);
|
||||
this.db.prepare(`
|
||||
DELETE FROM auto_heal_history
|
||||
WHERE policy_id = ?
|
||||
AND id NOT IN (
|
||||
SELECT id FROM auto_heal_history
|
||||
WHERE policy_id = ?
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`).run(policyId, policyId, maxRows);
|
||||
}
|
||||
|
||||
public incrementConsecutiveFailures(policyId: number): void {
|
||||
this.db.prepare('UPDATE auto_heal_policies SET consecutive_failures = consecutive_failures + 1, updated_at = ? WHERE id = ?').run(Date.now(), policyId);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ interface StackAlert {
|
||||
|
||||
interface AutoHealPolicy {
|
||||
id?: number;
|
||||
node_id: number;
|
||||
proxy_entitled_until: number;
|
||||
stack_name: string;
|
||||
service_name: string | null;
|
||||
unhealthy_duration_mins: number;
|
||||
@@ -55,7 +57,7 @@ interface AutoHealHistoryEntry {
|
||||
service_name: string | null;
|
||||
container_name: string;
|
||||
container_id: string;
|
||||
action: 'restarted' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit' | 'failed' | 'policy_auto_disabled';
|
||||
action: 'restarted' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit' | 'failed' | 'policy_auto_disabled' | 'docker_unavailable';
|
||||
reason: string;
|
||||
success: number;
|
||||
error: string | null;
|
||||
@@ -122,6 +124,7 @@ function actionLabel(action: AutoHealHistoryEntry['action']): string {
|
||||
case 'skipped_rate_limit': return 'Skipped (rate limit)';
|
||||
case 'failed': return 'Failed';
|
||||
case 'policy_auto_disabled': return 'Auto-disabled';
|
||||
case 'docker_unavailable': return 'Docker unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user