mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
feat: add service-scoped stack alert rules (#1681)
* feat: add service-scoped stack alert rules
Stack alerts can target one Compose service or all services. Breach timers
are per container and cooldowns are per service so a healthy sibling no
longer clears another container's timer or silences a different service.
* fix: gate remote scoped alert creates without losing the body
Remote hops skip JSON parsing so the proxy stream stays pipeable, which
left service_name invisible to the capability gate. Buffer POST /alerts
bodies for inspection, fail closed when the remote lacks the capability,
and rewrite the buffered bytes on forward. Restore alert-panel alt text
to match the unchanged screenshot.
* fix: bound remote alert body buffer and reject encoded JSON
Cap proxied POST /alerts buffering at the local 100KB JSON limit with
structured 413 cleanup, reject non-identity Content-Encoding with 415 so
compressed scoped bodies cannot bypass the mixed-version gate, and cover
oversized, chunked, and gzip regressions.
* fix: harden service-scoped alert delete, cooldown, and proxy gates
Reject non-digit alert ids, dual-write last_fired_at for rollback safety,
gate cooldown on persisted notification history, fail-fast oversized proxy
bodies with 413, and clarify Not in compose UI semantics.
* test: expect dispatchAlert persisted result in crash-safety cases
Update notification-routing assertions for the new { persisted } return
shape so CI matches the cooldown-gating contract.
This commit is contained in:
@@ -57,8 +57,8 @@ describe('GET /api/alerts', () => {
|
||||
it('filters alerts by stackName query param', async () => {
|
||||
// Seed two alerts for different stacks
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addStackAlert({ stack_name: 'web', metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 });
|
||||
db.addStackAlert({ stack_name: 'api', metric: 'memory_percent', operator: '>', threshold: 90, duration_mins: 5, cooldown_mins: 60 });
|
||||
db.addStackAlert({ stack_name: 'web', service_name: null, metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 });
|
||||
db.addStackAlert({ stack_name: 'api', service_name: null, metric: 'memory_percent', operator: '>', threshold: 90, duration_mins: 5, cooldown_mins: 60 });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/alerts?stackName=web')
|
||||
@@ -105,10 +105,101 @@ describe('POST /api/alerts', () => {
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.stack_name).toBe('new-stack');
|
||||
expect(res.body.service_name).toBeNull();
|
||||
expect(res.body.metric).toBe('memory_percent');
|
||||
expect(res.body.threshold).toBe(85);
|
||||
});
|
||||
|
||||
it('persists a valid dotted service_name', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/alerts')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
stack_name: 'svc-stack',
|
||||
service_name: 'api.web',
|
||||
metric: 'cpu_percent',
|
||||
operator: '>',
|
||||
threshold: 80,
|
||||
duration_mins: 5,
|
||||
cooldown_mins: 60,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.service_name).toBe('api.web');
|
||||
});
|
||||
|
||||
it('normalizes empty service_name to null', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/alerts')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
stack_name: 'empty-svc',
|
||||
service_name: '',
|
||||
metric: 'cpu_percent',
|
||||
operator: '>',
|
||||
threshold: 80,
|
||||
duration_mins: 5,
|
||||
cooldown_mins: 60,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.service_name).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects whitespace-only service_name', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/alerts')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
stack_name: 'bad-svc',
|
||||
service_name: ' ',
|
||||
metric: 'cpu_percent',
|
||||
operator: '>',
|
||||
threshold: 80,
|
||||
duration_mins: 5,
|
||||
cooldown_mins: 60,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects reserved _unlabeled service_name', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/alerts')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
stack_name: 'bad-svc',
|
||||
service_name: '_unlabeled',
|
||||
metric: 'cpu_percent',
|
||||
operator: '>',
|
||||
threshold: 80,
|
||||
duration_mins: 5,
|
||||
cooldown_mins: 60,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects service_name when the capability is disabled', async () => {
|
||||
const { disableCapability, enableCapability, SERVICE_SCOPED_STACK_ALERT_CAPABILITY } =
|
||||
await import('../services/CapabilityRegistry');
|
||||
disableCapability(SERVICE_SCOPED_STACK_ALERT_CAPABILITY);
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/alerts')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
stack_name: 'cap-stack',
|
||||
service_name: 'api',
|
||||
metric: 'cpu_percent',
|
||||
operator: '>',
|
||||
threshold: 80,
|
||||
duration_mins: 5,
|
||||
cooldown_mins: 60,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('capability_unavailable');
|
||||
} finally {
|
||||
enableCapability(SERVICE_SCOPED_STACK_ALERT_CAPABILITY);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates required fields and returns 400 for missing data', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/alerts')
|
||||
@@ -202,6 +293,7 @@ describe('DELETE /api/alerts/:id', () => {
|
||||
// Create an alert to delete
|
||||
const created = DatabaseService.getInstance().addStackAlert({
|
||||
stack_name: 'delete-me',
|
||||
service_name: null,
|
||||
metric: 'cpu_percent',
|
||||
operator: '>',
|
||||
threshold: 90,
|
||||
@@ -216,6 +308,58 @@ describe('DELETE /api/alerts/:id', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects leading-junk ids like 1abc without deleting alert 1', async () => {
|
||||
const created = DatabaseService.getInstance().addStackAlert({
|
||||
stack_name: 'strict-id-junk',
|
||||
service_name: null,
|
||||
metric: 'cpu_percent',
|
||||
operator: '>',
|
||||
threshold: 90,
|
||||
duration_mins: 0,
|
||||
cooldown_mins: 0,
|
||||
});
|
||||
expect(created.id).toBeDefined();
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/alerts/${created.id}abc`)
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Invalid alert id');
|
||||
expect(DatabaseService.getInstance().getStackAlerts('strict-id-junk').some((a) => a.id === created.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects fractional ids like 2.5 without deleting alert 2', async () => {
|
||||
const first = DatabaseService.getInstance().addStackAlert({
|
||||
stack_name: 'strict-id-fraction',
|
||||
service_name: null,
|
||||
metric: 'cpu_percent',
|
||||
operator: '>',
|
||||
threshold: 80,
|
||||
duration_mins: 0,
|
||||
cooldown_mins: 0,
|
||||
});
|
||||
const second = DatabaseService.getInstance().addStackAlert({
|
||||
stack_name: 'strict-id-fraction',
|
||||
service_name: null,
|
||||
metric: 'memory_percent',
|
||||
operator: '>',
|
||||
threshold: 80,
|
||||
duration_mins: 0,
|
||||
cooldown_mins: 0,
|
||||
});
|
||||
expect(second.id).toBeDefined();
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/alerts/${second.id}.5`)
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Invalid alert id');
|
||||
const remaining = DatabaseService.getInstance().getStackAlerts('strict-id-fraction').map((a) => a.id);
|
||||
expect(remaining).toEqual(expect.arrayContaining([first.id, second.id]));
|
||||
});
|
||||
});
|
||||
|
||||
// --- POST /api/notifications/test ---
|
||||
|
||||
Reference in New Issue
Block a user