@mirante/alarms 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -0
- package/runtime.proto +94 -0
- package/src/index.d.ts +106 -0
- package/src/index.js +255 -0
- package/test/index.test.js +162 -0
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mirante/alarms",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"default": "./src/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./runtime.proto": "./runtime.proto"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"prepare": "node -e \"var fs=require('fs');var src='../../../proto/alarms/v1/alarms.proto';if(fs.existsSync(src))fs.copyFileSync(src,'runtime.proto')\"",
|
|
15
|
+
"test": "node --test"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@grpc/grpc-js": "^1.13.4",
|
|
19
|
+
"@grpc/proto-loader": "^0.7.15"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/runtime.proto
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
syntax = "proto3";
|
|
2
|
+
|
|
3
|
+
package alarms.v1;
|
|
4
|
+
|
|
5
|
+
option go_package = "github.com/g0ulartleo/mirante/proto/alarms/v1;alarmsv1";
|
|
6
|
+
|
|
7
|
+
import "google/protobuf/struct.proto";
|
|
8
|
+
|
|
9
|
+
service AlarmRuntime {
|
|
10
|
+
rpc ListAlarms(ListAlarmsRequest) returns (ListAlarmsResponse);
|
|
11
|
+
rpc GetAlarm(GetAlarmRequest) returns (GetAlarmResponse);
|
|
12
|
+
rpc RunAlarm(RunAlarmRequest) returns (RunAlarmResponse);
|
|
13
|
+
rpc Health(HealthRequest) returns (HealthResponse);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
message HealthRequest {}
|
|
17
|
+
|
|
18
|
+
message HealthResponse {
|
|
19
|
+
string status = 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
message ListAlarmsRequest {}
|
|
23
|
+
|
|
24
|
+
message ListAlarmsResponse {
|
|
25
|
+
repeated Alarm alarms = 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
message GetAlarmRequest {
|
|
29
|
+
string alarm_id = 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
message GetAlarmResponse {
|
|
33
|
+
Alarm alarm = 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
message RunAlarmRequest {
|
|
37
|
+
string alarm_id = 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
message RunAlarmResponse {
|
|
41
|
+
SignalStatus status = 1;
|
|
42
|
+
string message = 2;
|
|
43
|
+
RuntimeError error = 3;
|
|
44
|
+
repeated google.protobuf.Struct details = 4;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
enum SignalStatus {
|
|
48
|
+
SIGNAL_STATUS_UNSPECIFIED = 0;
|
|
49
|
+
SIGNAL_STATUS_HEALTHY = 1;
|
|
50
|
+
SIGNAL_STATUS_UNHEALTHY = 2;
|
|
51
|
+
SIGNAL_STATUS_UNKNOWN = 3;
|
|
52
|
+
SIGNAL_STATUS_WARNING = 4;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
enum RuntimeErrorCode {
|
|
56
|
+
RUNTIME_ERROR_CODE_UNSPECIFIED = 0;
|
|
57
|
+
RUNTIME_ERROR_CODE_UNSUPPORTED = 1;
|
|
58
|
+
RUNTIME_ERROR_CODE_RUNALARM_FAILED = 2;
|
|
59
|
+
RUNTIME_ERROR_CODE_INTERNAL = 3;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
message RuntimeError {
|
|
63
|
+
RuntimeErrorCode code = 1;
|
|
64
|
+
string message = 2;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
message Alarm {
|
|
68
|
+
string id = 1;
|
|
69
|
+
string name = 2;
|
|
70
|
+
string description = 3;
|
|
71
|
+
string how_to_fix = 4;
|
|
72
|
+
repeated string path = 5;
|
|
73
|
+
string cron = 6;
|
|
74
|
+
string interval = 7;
|
|
75
|
+
AlarmNotifications notifications = 8;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
message AlarmNotifications {
|
|
79
|
+
map<string, NotificationChannels> channels = 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
message NotificationChannels {
|
|
83
|
+
repeated SlackWebhookNotification slack_webhooks = 1;
|
|
84
|
+
repeated EmailNotification emails = 2;
|
|
85
|
+
bool notify_missing_signals = 3;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
message SlackWebhookNotification {
|
|
89
|
+
string url = 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
message EmailNotification {
|
|
93
|
+
repeated string to = 1;
|
|
94
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const protoPath: string;
|
|
2
|
+
|
|
3
|
+
export const SignalStatus: {
|
|
4
|
+
readonly UNSPECIFIED: 'SIGNAL_STATUS_UNSPECIFIED';
|
|
5
|
+
readonly HEALTHY: 'SIGNAL_STATUS_HEALTHY';
|
|
6
|
+
readonly UNHEALTHY: 'SIGNAL_STATUS_UNHEALTHY';
|
|
7
|
+
readonly UNKNOWN: 'SIGNAL_STATUS_UNKNOWN';
|
|
8
|
+
readonly WARNING: 'SIGNAL_STATUS_WARNING';
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const RuntimeErrorCode: {
|
|
12
|
+
readonly UNSPECIFIED: 'RUNTIME_ERROR_CODE_UNSPECIFIED';
|
|
13
|
+
readonly UNSUPPORTED: 'RUNTIME_ERROR_CODE_UNSUPPORTED';
|
|
14
|
+
readonly RUNALARM_FAILED: 'RUNTIME_ERROR_CODE_RUNALARM_FAILED';
|
|
15
|
+
readonly INTERNAL: 'RUNTIME_ERROR_CODE_INTERNAL';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type SignalStatusValue = typeof SignalStatus[keyof typeof SignalStatus];
|
|
19
|
+
export type RuntimeErrorCodeValue = typeof RuntimeErrorCode[keyof typeof RuntimeErrorCode];
|
|
20
|
+
|
|
21
|
+
export interface Signal {
|
|
22
|
+
status: SignalStatusValue;
|
|
23
|
+
message: string;
|
|
24
|
+
details?: SignalDetail[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function healthy(message: string, options?: { details?: SignalDetail[] }): Signal;
|
|
28
|
+
export function warning(message: string, options?: { details?: SignalDetail[] }): Signal;
|
|
29
|
+
export function unhealthy(message: string, options?: { details?: SignalDetail[] }): Signal;
|
|
30
|
+
export function unknown(message: string, options?: { details?: SignalDetail[] }): Signal;
|
|
31
|
+
|
|
32
|
+
export interface AlarmDefinition {
|
|
33
|
+
id: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
description: string;
|
|
36
|
+
howToFix?: string;
|
|
37
|
+
cron?: string;
|
|
38
|
+
interval?: string;
|
|
39
|
+
notifications?: {
|
|
40
|
+
critical?: {
|
|
41
|
+
slackWebhooks?: string[];
|
|
42
|
+
emails?: string[][];
|
|
43
|
+
notifyMissingSignals?: boolean;
|
|
44
|
+
};
|
|
45
|
+
warnings?: {
|
|
46
|
+
slackWebhooks?: string[];
|
|
47
|
+
emails?: string[][];
|
|
48
|
+
notifyMissingSignals?: boolean;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
run(): Signal | Promise<Signal>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Alarm {
|
|
55
|
+
id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
description: string;
|
|
58
|
+
howToFix?: string;
|
|
59
|
+
path?: string[];
|
|
60
|
+
cron?: string;
|
|
61
|
+
interval?: string;
|
|
62
|
+
notifications?: AlarmNotifications;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AlarmNotifications {
|
|
66
|
+
channels: Record<string, NotificationChannels>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface NotificationChannels {
|
|
70
|
+
slackWebhooks?: SlackWebhookNotification[];
|
|
71
|
+
emails?: EmailNotification[];
|
|
72
|
+
notifyMissingSignals?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SlackWebhookNotification {
|
|
76
|
+
url: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface EmailNotification {
|
|
80
|
+
to: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface RunAlarmResponse {
|
|
84
|
+
status: SignalStatusValue;
|
|
85
|
+
message: string;
|
|
86
|
+
error?: RuntimeError;
|
|
87
|
+
details?: SignalDetail[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface RuntimeError {
|
|
91
|
+
code: RuntimeErrorCodeValue;
|
|
92
|
+
message: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type SignalDetail = Record<string, unknown>;
|
|
96
|
+
|
|
97
|
+
export function loadAlarms(options: { alarmsDir: string }): Promise<AlarmDefinition[]>;
|
|
98
|
+
export function createRuntimeService(alarms: AlarmDefinition[]): Record<string, unknown>;
|
|
99
|
+
export function serveRuntime(options: {
|
|
100
|
+
alarmsDir: string;
|
|
101
|
+
addr?: string;
|
|
102
|
+
credentials?: object;
|
|
103
|
+
grpcOptions?: Record<string, number>;
|
|
104
|
+
}): Promise<unknown>;
|
|
105
|
+
export function toProtoAlarm(alarm: AlarmDefinition): Promise<Alarm>;
|
|
106
|
+
export function normalizeDetails(details: SignalDetail[]): SignalDetail[];
|
package/src/index.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
|
|
10
|
+
export const protoPath = path.join(packageRoot, 'runtime.proto');
|
|
11
|
+
|
|
12
|
+
export const SignalStatus = Object.freeze({
|
|
13
|
+
UNSPECIFIED: 'SIGNAL_STATUS_UNSPECIFIED',
|
|
14
|
+
HEALTHY: 'SIGNAL_STATUS_HEALTHY',
|
|
15
|
+
UNHEALTHY: 'SIGNAL_STATUS_UNHEALTHY',
|
|
16
|
+
UNKNOWN: 'SIGNAL_STATUS_UNKNOWN',
|
|
17
|
+
WARNING: 'SIGNAL_STATUS_WARNING',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const RuntimeErrorCode = Object.freeze({
|
|
21
|
+
UNSPECIFIED: 'RUNTIME_ERROR_CODE_UNSPECIFIED',
|
|
22
|
+
UNSUPPORTED: 'RUNTIME_ERROR_CODE_UNSUPPORTED',
|
|
23
|
+
RUNALARM_FAILED: 'RUNTIME_ERROR_CODE_RUNALARM_FAILED',
|
|
24
|
+
INTERNAL: 'RUNTIME_ERROR_CODE_INTERNAL',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function healthy(message, options = {}) {
|
|
28
|
+
return {
|
|
29
|
+
status: SignalStatus.HEALTHY,
|
|
30
|
+
message: String(message ?? ''),
|
|
31
|
+
details: normalizeDetails(options.details ?? []),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function warning(message, options = {}) {
|
|
36
|
+
return {
|
|
37
|
+
status: SignalStatus.WARNING,
|
|
38
|
+
message: String(message ?? ''),
|
|
39
|
+
details: normalizeDetails(options.details ?? []),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function unhealthy(message, options = {}) {
|
|
44
|
+
return {
|
|
45
|
+
status: SignalStatus.UNHEALTHY,
|
|
46
|
+
message: String(message ?? ''),
|
|
47
|
+
details: normalizeDetails(options.details ?? []),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function unknown(message, options = {}) {
|
|
52
|
+
return {
|
|
53
|
+
status: SignalStatus.UNKNOWN,
|
|
54
|
+
message: String(message ?? ''),
|
|
55
|
+
details: normalizeDetails(options.details ?? []),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function loadAlarms({ alarmsDir }) {
|
|
60
|
+
if (!alarmsDir) {
|
|
61
|
+
throw new Error('alarmsDir is required');
|
|
62
|
+
}
|
|
63
|
+
if (!fs.existsSync(alarmsDir)) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const alarms = [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
|
|
70
|
+
async function walk(dir, relPath) {
|
|
71
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
72
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
73
|
+
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const fullPath = path.join(dir, entry.name);
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
await walk(fullPath, [...relPath, entry.name]);
|
|
78
|
+
} else if (entry.isFile() && /\.(js|mjs|ts)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
|
|
79
|
+
const moduleURL = pathToFileURL(fullPath).href;
|
|
80
|
+
const mod = await import(moduleURL);
|
|
81
|
+
for (const value of Object.values(mod)) {
|
|
82
|
+
if (typeof value !== 'object' || value === null) continue;
|
|
83
|
+
if (typeof value.id !== 'string') continue;
|
|
84
|
+
if (typeof value.run !== 'function') continue;
|
|
85
|
+
if (!value.cron && !value.interval) continue;
|
|
86
|
+
|
|
87
|
+
validateAlarm(value);
|
|
88
|
+
if (seen.has(value.id)) {
|
|
89
|
+
throw new Error(`duplicate alarm id ${JSON.stringify(value.id)}`);
|
|
90
|
+
}
|
|
91
|
+
seen.add(value.id);
|
|
92
|
+
value.path = relPath;
|
|
93
|
+
alarms.push(value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await walk(alarmsDir, []);
|
|
100
|
+
return alarms;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createRuntimeService(alarms) {
|
|
104
|
+
const byID = new Map(alarms.map((alarm) => [alarm.id, alarm]));
|
|
105
|
+
return {
|
|
106
|
+
ListAlarms: async (call, callback) => {
|
|
107
|
+
try {
|
|
108
|
+
callback(null, { alarms: await Promise.all(alarms.map(toProtoAlarm)) });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
callback(error);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
GetAlarm: async (call, callback) => {
|
|
114
|
+
try {
|
|
115
|
+
const alarmID = call.request?.alarmId;
|
|
116
|
+
const alarm = byID.get(alarmID);
|
|
117
|
+
if (!alarm) {
|
|
118
|
+
callback({ code: 5, message: `alarm ${JSON.stringify(alarmID)} not found` });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
callback(null, { alarm: await toProtoAlarm(alarm) });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
callback(error);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
RunAlarm: async (call, callback) => {
|
|
127
|
+
const alarmID = call.request?.alarmId;
|
|
128
|
+
const alarm = byID.get(alarmID);
|
|
129
|
+
if (!alarm) {
|
|
130
|
+
callback(null, errorResponse(
|
|
131
|
+
RuntimeErrorCode.UNSUPPORTED,
|
|
132
|
+
`alarm ${JSON.stringify(alarmID)} not found`,
|
|
133
|
+
));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const signal = await alarm.run();
|
|
139
|
+
if (!signal || signal.status == null) {
|
|
140
|
+
callback(null, errorResponse(
|
|
141
|
+
RuntimeErrorCode.RUNALARM_FAILED,
|
|
142
|
+
`alarm ${JSON.stringify(alarmID)} did not return a valid signal`,
|
|
143
|
+
));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
callback(null, signal);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
callback(null, errorResponse(
|
|
149
|
+
RuntimeErrorCode.RUNALARM_FAILED,
|
|
150
|
+
`run failed: ${error.message}`,
|
|
151
|
+
));
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
Health: async (_call, callback) => {
|
|
155
|
+
callback(null, { status: 'SERVING' });
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function serveRuntime({ alarmsDir, addr = '127.0.0.1:50051', credentials, grpcOptions = {} }) {
|
|
161
|
+
const grpc = require('@grpc/grpc-js');
|
|
162
|
+
const protoLoader = require('@grpc/proto-loader');
|
|
163
|
+
const packageDefinition = protoLoader.loadSync(protoPath, {
|
|
164
|
+
longs: String,
|
|
165
|
+
enums: String,
|
|
166
|
+
defaults: true,
|
|
167
|
+
oneofs: true,
|
|
168
|
+
});
|
|
169
|
+
const proto = grpc.loadPackageDefinition(packageDefinition).alarmruntime.v1;
|
|
170
|
+
const alarms = await loadAlarms({ alarmsDir });
|
|
171
|
+
const server = new grpc.Server(grpcOptions);
|
|
172
|
+
server.addService(proto.AlarmRuntime.service, createRuntimeService(alarms));
|
|
173
|
+
const creds = credentials ?? grpc.ServerCredentials.createInsecure();
|
|
174
|
+
await new Promise((resolve, reject) => {
|
|
175
|
+
server.bindAsync(addr, creds, (error) => {
|
|
176
|
+
if (error) {
|
|
177
|
+
reject(error);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
resolve();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
return server;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const KNOWN_SEVERITIES = new Set(['critical', 'warnings']);
|
|
187
|
+
|
|
188
|
+
export async function toProtoAlarm(alarm) {
|
|
189
|
+
validateAlarm(alarm);
|
|
190
|
+
const raw = alarm.notifications ?? {};
|
|
191
|
+
const channels = {};
|
|
192
|
+
for (const [severity, ch] of Object.entries(raw)) {
|
|
193
|
+
if (!KNOWN_SEVERITIES.has(severity)) {
|
|
194
|
+
console.warn(`unknown notification severity ${JSON.stringify(severity)}, skipping`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
channels[severity] = {
|
|
198
|
+
slackWebhooks: (ch.slackWebhooks ?? []).filter(Boolean).map((url) => ({ url })),
|
|
199
|
+
emails: (ch.emails ?? []).map((to) => ({ to: normalizeArray(to).filter(Boolean) })),
|
|
200
|
+
notifyMissingSignals: Boolean(ch.notifyMissingSignals),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
id: alarm.id,
|
|
205
|
+
name: alarm.name || '',
|
|
206
|
+
description: alarm.description,
|
|
207
|
+
howToFix: alarm.howToFix || '',
|
|
208
|
+
path: alarm.path ?? [],
|
|
209
|
+
cron: alarm.cron || '',
|
|
210
|
+
interval: alarm.interval || '',
|
|
211
|
+
notifications: { channels },
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function normalizeDetails(details) {
|
|
216
|
+
return normalizeArray(details).map((detail) => {
|
|
217
|
+
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) {
|
|
218
|
+
throw new Error('detail must be a plain object');
|
|
219
|
+
}
|
|
220
|
+
return detail;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function validateAlarm(alarm) {
|
|
225
|
+
if (typeof alarm.id !== 'string' || !alarm.id) {
|
|
226
|
+
throw new Error('alarm id is required');
|
|
227
|
+
}
|
|
228
|
+
if (!alarm.description) {
|
|
229
|
+
throw new Error(`alarm ${JSON.stringify(alarm.id)} description is required`);
|
|
230
|
+
}
|
|
231
|
+
if (!alarm.cron && !alarm.interval) {
|
|
232
|
+
throw new Error(`alarm ${JSON.stringify(alarm.id)} cron or interval is required`);
|
|
233
|
+
}
|
|
234
|
+
if (alarm.cron && alarm.interval) {
|
|
235
|
+
throw new Error(`alarm ${JSON.stringify(alarm.id)} cron and interval cannot both be set`);
|
|
236
|
+
}
|
|
237
|
+
if (typeof alarm.run !== 'function') {
|
|
238
|
+
throw new Error(`alarm ${JSON.stringify(alarm.id)} must have a run method`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function errorResponse(code, message) {
|
|
243
|
+
return {
|
|
244
|
+
status: SignalStatus.UNKNOWN,
|
|
245
|
+
message,
|
|
246
|
+
error: { code, message },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function normalizeArray(value) {
|
|
251
|
+
if (value === undefined || value === null) {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
return Array.isArray(value) ? value : [value];
|
|
255
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
6
|
+
import { protoPath, RuntimeErrorCode, SignalStatus, loadAlarms } from '../src/index.js';
|
|
7
|
+
|
|
8
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'alarms-test-'));
|
|
9
|
+
|
|
10
|
+
function writeAlarm(dir, relFile, alarmObj) {
|
|
11
|
+
const name = relFile.replace(/\.ts$/, '.mjs');
|
|
12
|
+
const fullPath = path.join(dir, name);
|
|
13
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
14
|
+
const { run, ...props } = alarmObj;
|
|
15
|
+
const propsJson = JSON.stringify(props, null, 2);
|
|
16
|
+
const jsCode = `export const alarm = Object.assign(${propsJson}, { run: ${run} });\n`;
|
|
17
|
+
fs.writeFileSync(fullPath, jsCode);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('mirante-alarms-js', () => {
|
|
21
|
+
it('exports a runtime proto path', () => {
|
|
22
|
+
assert.equal(fs.existsSync(protoPath), true);
|
|
23
|
+
assert.match(fs.readFileSync(protoPath, 'utf8'), /service AlarmRuntime/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('exports shared enum constants', () => {
|
|
27
|
+
assert.equal(SignalStatus.WARNING, 'SIGNAL_STATUS_WARNING');
|
|
28
|
+
assert.equal(RuntimeErrorCode.UNSUPPORTED, 'RUNTIME_ERROR_CODE_UNSUPPORTED');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('loadAlarms', () => {
|
|
33
|
+
let dir;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
dir = fs.mkdtempSync(path.join(tmpDir, 'load-'));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns empty array when dir does not exist', async () => {
|
|
44
|
+
const alarms = await loadAlarms({ alarmsDir: '/nonexistent/path' });
|
|
45
|
+
assert.deepEqual(alarms, []);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('loads alarms from root of alarmsDir', async () => {
|
|
49
|
+
writeAlarm(dir, 'check-http.ts', {
|
|
50
|
+
id: 'check-http',
|
|
51
|
+
description: 'Checks HTTP',
|
|
52
|
+
interval: '1m',
|
|
53
|
+
run: 'function(){}',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const alarms = await loadAlarms({ alarmsDir: dir });
|
|
57
|
+
assert.equal(alarms.length, 1);
|
|
58
|
+
assert.deepEqual(alarms[0].path, []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('derives path from single subdirectory', async () => {
|
|
62
|
+
writeAlarm(dir, 'db/connection-pool.ts', {
|
|
63
|
+
id: 'db-conn-pool',
|
|
64
|
+
description: 'Checks connection pool',
|
|
65
|
+
interval: '1m',
|
|
66
|
+
run: 'function(){}',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const alarms = await loadAlarms({ alarmsDir: dir });
|
|
70
|
+
assert.equal(alarms.length, 1);
|
|
71
|
+
assert.deepEqual(alarms[0].path, ['db']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('derives path from nested subdirectories', async () => {
|
|
75
|
+
writeAlarm(dir, 'aws/production/ec2/cpu.ts', {
|
|
76
|
+
id: 'aws-prod-ec2-cpu',
|
|
77
|
+
description: 'Checks CPU',
|
|
78
|
+
interval: '1m',
|
|
79
|
+
run: 'function(){}',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const alarms = await loadAlarms({ alarmsDir: dir });
|
|
83
|
+
assert.equal(alarms.length, 1);
|
|
84
|
+
assert.deepEqual(alarms[0].path, ['aws', 'production', 'ec2']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('loads alarms from multiple directories and assigns correct paths', async () => {
|
|
88
|
+
writeAlarm(dir, 'root-alarm.ts', {
|
|
89
|
+
id: 'root-alarm',
|
|
90
|
+
description: 'Root level',
|
|
91
|
+
interval: '1m',
|
|
92
|
+
run: 'function(){}',
|
|
93
|
+
});
|
|
94
|
+
writeAlarm(dir, 'db/mysql/connections.ts', {
|
|
95
|
+
id: 'mysql-conns',
|
|
96
|
+
description: 'MySQL connections',
|
|
97
|
+
interval: '1m',
|
|
98
|
+
run: 'function(){}',
|
|
99
|
+
});
|
|
100
|
+
writeAlarm(dir, 'db/redis/memory.ts', {
|
|
101
|
+
id: 'redis-mem',
|
|
102
|
+
description: 'Redis memory',
|
|
103
|
+
interval: '1m',
|
|
104
|
+
run: 'function(){}',
|
|
105
|
+
});
|
|
106
|
+
writeAlarm(dir, 'infra/dns.ts', {
|
|
107
|
+
id: 'dns-check',
|
|
108
|
+
description: 'DNS check',
|
|
109
|
+
interval: '1m',
|
|
110
|
+
run: 'function(){}',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const alarms = await loadAlarms({ alarmsDir: dir });
|
|
114
|
+
assert.equal(alarms.length, 4);
|
|
115
|
+
|
|
116
|
+
const byId = Object.fromEntries(alarms.map(a => [a.id, a]));
|
|
117
|
+
assert.deepEqual(byId['root-alarm'].path, []);
|
|
118
|
+
assert.deepEqual(byId['mysql-conns'].path, ['db', 'mysql']);
|
|
119
|
+
assert.deepEqual(byId['redis-mem'].path, ['db', 'redis']);
|
|
120
|
+
assert.deepEqual(byId['dns-check'].path, ['infra']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('overrides alarm path if alarm defines one', async () => {
|
|
124
|
+
writeAlarm(dir, 'custom-path.ts', {
|
|
125
|
+
id: 'custom-path',
|
|
126
|
+
description: 'Has path but should be overridden',
|
|
127
|
+
interval: '1m',
|
|
128
|
+
run: 'function(){}',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const alarms = await loadAlarms({ alarmsDir: dir });
|
|
132
|
+
assert.equal(alarms.length, 1);
|
|
133
|
+
assert.deepEqual(alarms[0].path, []);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('fails on duplicate alarm ids across directories', async () => {
|
|
137
|
+
writeAlarm(dir, 'dup-a.ts', {
|
|
138
|
+
id: 'dup',
|
|
139
|
+
description: 'Duplicate A',
|
|
140
|
+
interval: '1m',
|
|
141
|
+
run: 'function(){}',
|
|
142
|
+
});
|
|
143
|
+
writeAlarm(dir, 'dup-b.ts', {
|
|
144
|
+
id: 'dup',
|
|
145
|
+
description: 'Duplicate B',
|
|
146
|
+
interval: '1m',
|
|
147
|
+
run: 'function(){}',
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await assert.rejects(
|
|
151
|
+
() => loadAlarms({ alarmsDir: dir }),
|
|
152
|
+
/duplicate alarm id/,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('throws if alarmsDir is not provided', async () => {
|
|
157
|
+
await assert.rejects(
|
|
158
|
+
() => loadAlarms({}),
|
|
159
|
+
/alarmsDir is required/,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|