@hasna/uptime 0.1.3 → 0.1.5
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/CHANGELOG.md +47 -0
- package/README.md +42 -3
- package/dist/api.js +626 -4
- package/dist/cli/index.js +1090 -4
- package/dist/cloud-plan.d.ts +113 -0
- package/dist/cloud-plan.d.ts.map +1 -0
- package/dist/cloud-plan.js +267 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +891 -4
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +664 -4
- package/dist/report.d.ts +2 -7
- package/dist/report.d.ts.map +1 -1
- package/dist/service.d.ts +41 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +577 -4
- package/dist/store.d.ts +22 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +427 -4
- package/dist/types.d.ts +92 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/aws-deployment-runbook.md +92 -0
- package/package.json +7 -2
package/dist/cli/index.js
CHANGED
|
@@ -3407,9 +3407,24 @@ function ensureUptimeHome() {
|
|
|
3407
3407
|
|
|
3408
3408
|
// src/store.ts
|
|
3409
3409
|
var SECRET_URL_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
|
|
3410
|
-
var REQUIRED_TABLES = [
|
|
3410
|
+
var REQUIRED_TABLES = [
|
|
3411
|
+
"schema_migrations",
|
|
3412
|
+
"monitors",
|
|
3413
|
+
"check_results",
|
|
3414
|
+
"incidents",
|
|
3415
|
+
"check_leases",
|
|
3416
|
+
"monitor_provenance",
|
|
3417
|
+
"import_batches",
|
|
3418
|
+
"probe_identities",
|
|
3419
|
+
"probe_check_jobs",
|
|
3420
|
+
"probe_submissions",
|
|
3421
|
+
"report_schedules",
|
|
3422
|
+
"report_runs",
|
|
3423
|
+
"audit_events"
|
|
3424
|
+
];
|
|
3411
3425
|
var PROBE_TABLES = new Set(["probe_identities", "probe_check_jobs", "probe_submissions"]);
|
|
3412
|
-
var
|
|
3426
|
+
var REPORT_AUDIT_TABLES = new Set(["report_schedules", "report_runs", "audit_events"]);
|
|
3427
|
+
var CURRENT_SCHEMA_VERSION = "3";
|
|
3413
3428
|
|
|
3414
3429
|
class StaleCheckResultError extends Error {
|
|
3415
3430
|
constructor(message) {
|
|
@@ -3569,6 +3584,44 @@ class UptimeStore {
|
|
|
3569
3584
|
acquired_at TEXT NOT NULL
|
|
3570
3585
|
)
|
|
3571
3586
|
`);
|
|
3587
|
+
this.db.run(`
|
|
3588
|
+
CREATE TABLE IF NOT EXISTS report_schedules (
|
|
3589
|
+
id TEXT PRIMARY KEY,
|
|
3590
|
+
name TEXT NOT NULL UNIQUE,
|
|
3591
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
3592
|
+
interval_seconds INTEGER NOT NULL,
|
|
3593
|
+
next_run_at TEXT NOT NULL,
|
|
3594
|
+
last_run_at TEXT,
|
|
3595
|
+
subject TEXT,
|
|
3596
|
+
channels_json TEXT NOT NULL,
|
|
3597
|
+
created_at TEXT NOT NULL,
|
|
3598
|
+
updated_at TEXT NOT NULL
|
|
3599
|
+
)
|
|
3600
|
+
`);
|
|
3601
|
+
this.db.run(`
|
|
3602
|
+
CREATE TABLE IF NOT EXISTS report_runs (
|
|
3603
|
+
id TEXT PRIMARY KEY,
|
|
3604
|
+
schedule_id TEXT REFERENCES report_schedules(id) ON DELETE SET NULL,
|
|
3605
|
+
status TEXT NOT NULL CHECK (status IN ('success', 'failed')),
|
|
3606
|
+
started_at TEXT NOT NULL,
|
|
3607
|
+
finished_at TEXT NOT NULL,
|
|
3608
|
+
deliveries_json TEXT NOT NULL,
|
|
3609
|
+
error TEXT,
|
|
3610
|
+
report_json TEXT
|
|
3611
|
+
)
|
|
3612
|
+
`);
|
|
3613
|
+
this.db.run(`
|
|
3614
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
3615
|
+
id TEXT PRIMARY KEY,
|
|
3616
|
+
action TEXT NOT NULL,
|
|
3617
|
+
resource_type TEXT,
|
|
3618
|
+
resource_id TEXT,
|
|
3619
|
+
message TEXT,
|
|
3620
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
3621
|
+
actor TEXT,
|
|
3622
|
+
created_at TEXT NOT NULL
|
|
3623
|
+
)
|
|
3624
|
+
`);
|
|
3572
3625
|
this.db.run(`
|
|
3573
3626
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
3574
3627
|
key TEXT PRIMARY KEY,
|
|
@@ -3586,6 +3639,10 @@ class UptimeStore {
|
|
|
3586
3639
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_probe_time ON probe_submissions(probe_id, submitted_at DESC)");
|
|
3587
3640
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_monitor_time ON probe_submissions(monitor_id, checked_at DESC)");
|
|
3588
3641
|
this.db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_probe_submissions_job ON probe_submissions(job_id) WHERE job_id IS NOT NULL AND job_id != ''");
|
|
3642
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_report_schedules_due ON report_schedules(enabled, next_run_at)");
|
|
3643
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_report_runs_schedule_time ON report_runs(schedule_id, started_at DESC)");
|
|
3644
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_audit_events_resource_time ON audit_events(resource_type, resource_id, created_at DESC)");
|
|
3645
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_audit_events_time ON audit_events(created_at DESC)");
|
|
3589
3646
|
}
|
|
3590
3647
|
backup(destinationPath) {
|
|
3591
3648
|
if (this.dbPath === ":memory:" && !destinationPath) {
|
|
@@ -3882,6 +3939,136 @@ class UptimeStore {
|
|
|
3882
3939
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(receipt.id, receipt.probeId, receipt.jobId, receipt.monitorId, receipt.checkResultId, receipt.nonce, receipt.checkedAt, receipt.submittedAt);
|
|
3883
3940
|
return receipt;
|
|
3884
3941
|
}
|
|
3942
|
+
createReportSchedule(input) {
|
|
3943
|
+
const normalized = normalizeReportScheduleInput(input);
|
|
3944
|
+
const now = new Date().toISOString();
|
|
3945
|
+
const schedule = {
|
|
3946
|
+
id: newId("rps"),
|
|
3947
|
+
name: normalized.name,
|
|
3948
|
+
enabled: normalized.enabled,
|
|
3949
|
+
intervalSeconds: normalized.intervalSeconds,
|
|
3950
|
+
nextRunAt: normalized.nextRunAt,
|
|
3951
|
+
lastRunAt: null,
|
|
3952
|
+
subject: normalized.subject,
|
|
3953
|
+
channels: normalized.channels,
|
|
3954
|
+
createdAt: now,
|
|
3955
|
+
updatedAt: now
|
|
3956
|
+
};
|
|
3957
|
+
this.db.query(`INSERT INTO report_schedules (
|
|
3958
|
+
id, name, enabled, interval_seconds, next_run_at, last_run_at,
|
|
3959
|
+
subject, channels_json, created_at, updated_at
|
|
3960
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(schedule.id, schedule.name, schedule.enabled ? 1 : 0, schedule.intervalSeconds, schedule.nextRunAt, schedule.lastRunAt, schedule.subject, JSON.stringify(schedule.channels), schedule.createdAt, schedule.updatedAt);
|
|
3961
|
+
return schedule;
|
|
3962
|
+
}
|
|
3963
|
+
listReportSchedules(options = {}) {
|
|
3964
|
+
const rows = options.includeDisabled ? this.db.query("SELECT * FROM report_schedules ORDER BY name ASC").all() : this.db.query("SELECT * FROM report_schedules WHERE enabled = 1 ORDER BY name ASC").all();
|
|
3965
|
+
return rows.map(reportScheduleFromRow);
|
|
3966
|
+
}
|
|
3967
|
+
listDueReportSchedules(nowIso = new Date().toISOString()) {
|
|
3968
|
+
assertIsoTimestamp(nowIso, "Report schedule due timestamp");
|
|
3969
|
+
const rows = this.db.query("SELECT * FROM report_schedules WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC, name ASC").all(nowIso);
|
|
3970
|
+
return rows.map(reportScheduleFromRow);
|
|
3971
|
+
}
|
|
3972
|
+
getReportSchedule(idOrName) {
|
|
3973
|
+
const row = this.db.query("SELECT * FROM report_schedules WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
3974
|
+
return row ? reportScheduleFromRow(row) : null;
|
|
3975
|
+
}
|
|
3976
|
+
updateReportSchedule(idOrName, input) {
|
|
3977
|
+
const current = this.getReportSchedule(idOrName);
|
|
3978
|
+
if (!current)
|
|
3979
|
+
throw new Error(`Report schedule not found: ${idOrName}`);
|
|
3980
|
+
const normalized = normalizeReportScheduleInput({
|
|
3981
|
+
name: input.name ?? current.name,
|
|
3982
|
+
intervalSeconds: input.intervalSeconds ?? current.intervalSeconds,
|
|
3983
|
+
nextRunAt: input.nextRunAt ?? current.nextRunAt,
|
|
3984
|
+
enabled: input.enabled ?? current.enabled,
|
|
3985
|
+
subject: input.subject === undefined ? current.subject : input.subject,
|
|
3986
|
+
channels: input.channels ?? current.channels
|
|
3987
|
+
});
|
|
3988
|
+
const updatedAt = new Date().toISOString();
|
|
3989
|
+
this.db.query(`UPDATE report_schedules SET
|
|
3990
|
+
name = ?, enabled = ?, interval_seconds = ?, next_run_at = ?,
|
|
3991
|
+
subject = ?, channels_json = ?, updated_at = ?
|
|
3992
|
+
WHERE id = ?`).run(normalized.name, normalized.enabled ? 1 : 0, normalized.intervalSeconds, normalized.nextRunAt, normalized.subject, JSON.stringify(normalized.channels), updatedAt, current.id);
|
|
3993
|
+
return this.getReportSchedule(current.id);
|
|
3994
|
+
}
|
|
3995
|
+
deleteReportSchedule(idOrName) {
|
|
3996
|
+
const current = this.getReportSchedule(idOrName);
|
|
3997
|
+
if (!current)
|
|
3998
|
+
return false;
|
|
3999
|
+
this.db.query("DELETE FROM report_schedules WHERE id = ?").run(current.id);
|
|
4000
|
+
return true;
|
|
4001
|
+
}
|
|
4002
|
+
recordReportRun(input) {
|
|
4003
|
+
const startedAt = input.startedAt ?? new Date().toISOString();
|
|
4004
|
+
const finishedAt = input.finishedAt ?? new Date().toISOString();
|
|
4005
|
+
assertIsoTimestamp(startedAt, "Report run startedAt");
|
|
4006
|
+
assertIsoTimestamp(finishedAt, "Report run finishedAt");
|
|
4007
|
+
if (input.status !== "success" && input.status !== "failed") {
|
|
4008
|
+
throw new Error("Report run status must be success or failed");
|
|
4009
|
+
}
|
|
4010
|
+
if (input.scheduleId && !this.getReportSchedule(input.scheduleId)) {
|
|
4011
|
+
throw new Error(`Report schedule not found: ${input.scheduleId}`);
|
|
4012
|
+
}
|
|
4013
|
+
const run = {
|
|
4014
|
+
id: newId("rpr"),
|
|
4015
|
+
scheduleId: input.scheduleId ?? null,
|
|
4016
|
+
status: input.status,
|
|
4017
|
+
startedAt,
|
|
4018
|
+
finishedAt,
|
|
4019
|
+
deliveries: normalizeReportDeliveries(input.deliveries ?? []),
|
|
4020
|
+
error: normalizeNullableRedactedText(input.error, "Report run error", 1000),
|
|
4021
|
+
reportJson: input.reportJson ?? null
|
|
4022
|
+
};
|
|
4023
|
+
this.db.query(`INSERT INTO report_runs (
|
|
4024
|
+
id, schedule_id, status, started_at, finished_at, deliveries_json,
|
|
4025
|
+
error, report_json
|
|
4026
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(run.id, run.scheduleId, run.status, run.startedAt, run.finishedAt, JSON.stringify(run.deliveries), run.error, run.reportJson ? JSON.stringify(run.reportJson) : null);
|
|
4027
|
+
if (run.scheduleId) {
|
|
4028
|
+
this.advanceReportSchedule(run.scheduleId, run.finishedAt);
|
|
4029
|
+
}
|
|
4030
|
+
return run;
|
|
4031
|
+
}
|
|
4032
|
+
listReportRuns(options = {}) {
|
|
4033
|
+
const limit = clampLimit(options.limit ?? 50);
|
|
4034
|
+
const rows = options.scheduleId ? this.db.query("SELECT * FROM report_runs WHERE schedule_id = ? ORDER BY started_at DESC, id DESC LIMIT ?").all(options.scheduleId, limit) : this.db.query("SELECT * FROM report_runs ORDER BY started_at DESC, id DESC LIMIT ?").all(limit);
|
|
4035
|
+
return rows.map(reportRunFromRow);
|
|
4036
|
+
}
|
|
4037
|
+
recordAuditEvent(input) {
|
|
4038
|
+
const action = normalizeAuditText(input.action, "Audit action", 160);
|
|
4039
|
+
const createdAt = input.createdAt ?? new Date().toISOString();
|
|
4040
|
+
assertIsoTimestamp(createdAt, "Audit event createdAt");
|
|
4041
|
+
const event = {
|
|
4042
|
+
id: newId("aud"),
|
|
4043
|
+
action,
|
|
4044
|
+
resourceType: normalizeNullableAuditText(input.resourceType, "Audit resourceType", 80),
|
|
4045
|
+
resourceId: normalizeNullableAuditText(input.resourceId, "Audit resourceId", 160),
|
|
4046
|
+
message: normalizeNullableAuditText(input.message, "Audit message", 500),
|
|
4047
|
+
metadata: normalizeAuditMetadata(input.metadata ?? {}),
|
|
4048
|
+
actor: normalizeNullableAuditText(input.actor, "Audit actor", 160),
|
|
4049
|
+
createdAt
|
|
4050
|
+
};
|
|
4051
|
+
this.db.query(`INSERT INTO audit_events (
|
|
4052
|
+
id, action, resource_type, resource_id, message, metadata_json, actor, created_at
|
|
4053
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(event.id, event.action, event.resourceType, event.resourceId, event.message, JSON.stringify(event.metadata), event.actor, event.createdAt);
|
|
4054
|
+
return event;
|
|
4055
|
+
}
|
|
4056
|
+
listAuditEvents(options = {}) {
|
|
4057
|
+
const clauses = [];
|
|
4058
|
+
const args = [];
|
|
4059
|
+
if (options.resourceType) {
|
|
4060
|
+
clauses.push("resource_type = ?");
|
|
4061
|
+
args.push(options.resourceType);
|
|
4062
|
+
}
|
|
4063
|
+
if (options.resourceId) {
|
|
4064
|
+
clauses.push("resource_id = ?");
|
|
4065
|
+
args.push(options.resourceId);
|
|
4066
|
+
}
|
|
4067
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
4068
|
+
args.push(clampLimit(options.limit ?? 50));
|
|
4069
|
+
const rows = this.db.query(`SELECT * FROM audit_events ${where} ORDER BY created_at DESC, id DESC LIMIT ?`).all(...args);
|
|
4070
|
+
return rows.map(auditEventFromRow);
|
|
4071
|
+
}
|
|
3885
4072
|
acquireCheckLease(monitorId, owner, ttlMs) {
|
|
3886
4073
|
const now = new Date;
|
|
3887
4074
|
const nowIso = now.toISOString();
|
|
@@ -4064,6 +4251,18 @@ class UptimeStore {
|
|
|
4064
4251
|
closeOpenIncident(monitorId, closedAt) {
|
|
4065
4252
|
this.db.query("UPDATE incidents SET status = 'closed', closed_at = ? WHERE monitor_id = ? AND status = 'open'").run(closedAt, monitorId);
|
|
4066
4253
|
}
|
|
4254
|
+
advanceReportSchedule(scheduleId, finishedAt) {
|
|
4255
|
+
const schedule = this.getReportSchedule(scheduleId);
|
|
4256
|
+
if (!schedule)
|
|
4257
|
+
throw new Error(`Report schedule not found: ${scheduleId}`);
|
|
4258
|
+
const finishedMs = Date.parse(finishedAt);
|
|
4259
|
+
let nextMs = Math.max(Date.parse(schedule.nextRunAt), finishedMs);
|
|
4260
|
+
do {
|
|
4261
|
+
nextMs += schedule.intervalSeconds * 1000;
|
|
4262
|
+
} while (nextMs <= finishedMs);
|
|
4263
|
+
const nextRunAt = new Date(nextMs).toISOString();
|
|
4264
|
+
this.db.query("UPDATE report_schedules SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE id = ?").run(finishedAt, nextRunAt, finishedAt, schedule.id);
|
|
4265
|
+
}
|
|
4067
4266
|
ensureColumn(table, name, definition) {
|
|
4068
4267
|
const columns = this.db.query(`PRAGMA table_info(${table})`).all();
|
|
4069
4268
|
if (!columns.some((column) => column.name === name)) {
|
|
@@ -4142,9 +4341,10 @@ function verifyBackupFile(backupPath) {
|
|
|
4142
4341
|
const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
|
|
4143
4342
|
const schemaVersion = missingTables.includes("schema_migrations") ? null : db.query("SELECT value FROM schema_migrations WHERE key = 'schema_version'").get()?.value ?? null;
|
|
4144
4343
|
const currentOk = missingTables.length === 0 && schemaVersion === CURRENT_SCHEMA_VERSION;
|
|
4145
|
-
const restorableV1 = schemaVersion === "1" && missingTables.every((table) => PROBE_TABLES.has(table));
|
|
4344
|
+
const restorableV1 = schemaVersion === "1" && missingTables.every((table) => PROBE_TABLES.has(table) || REPORT_AUDIT_TABLES.has(table));
|
|
4345
|
+
const restorableV2 = schemaVersion === "2" && missingTables.every((table) => REPORT_AUDIT_TABLES.has(table));
|
|
4146
4346
|
return {
|
|
4147
|
-
ok: integrity === "ok" && (currentOk || restorableV1),
|
|
4347
|
+
ok: integrity === "ok" && (currentOk || restorableV1 || restorableV2),
|
|
4148
4348
|
backupPath,
|
|
4149
4349
|
integrity,
|
|
4150
4350
|
schemaVersion,
|
|
@@ -4308,6 +4508,175 @@ function normalizeScheduleSlot(value) {
|
|
|
4308
4508
|
rejectControlCharacters2(slot, "Probe job scheduleSlot");
|
|
4309
4509
|
return slot;
|
|
4310
4510
|
}
|
|
4511
|
+
function normalizeReportScheduleInput(input) {
|
|
4512
|
+
const name = input.name?.trim();
|
|
4513
|
+
if (!name)
|
|
4514
|
+
throw new Error("Report schedule name is required");
|
|
4515
|
+
rejectControlCharacters2(name, "Report schedule name");
|
|
4516
|
+
const intervalSeconds = boundedInteger2(input.intervalSeconds, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS);
|
|
4517
|
+
const nextRunAt = input.nextRunAt ?? new Date().toISOString();
|
|
4518
|
+
assertIsoTimestamp(nextRunAt, "Report schedule nextRunAt");
|
|
4519
|
+
const enabled = normalizeEnabled(input.enabled);
|
|
4520
|
+
const subject = normalizeNullableBoundedText(input.subject, "Report schedule subject", 200);
|
|
4521
|
+
const channels = normalizeReportChannels(input.channels);
|
|
4522
|
+
return { name, intervalSeconds, nextRunAt, enabled, subject, channels };
|
|
4523
|
+
}
|
|
4524
|
+
function normalizeReportChannels(channels) {
|
|
4525
|
+
if (!channels || typeof channels !== "object")
|
|
4526
|
+
throw new Error("Report schedule channels are required");
|
|
4527
|
+
const normalized = {};
|
|
4528
|
+
if (channels.email !== undefined)
|
|
4529
|
+
normalized.email = normalizeChannelTarget(channels.email, "email", ["apiUrl", "from", "to", "subject", "providerId"]);
|
|
4530
|
+
if (channels.sms !== undefined)
|
|
4531
|
+
normalized.sms = normalizeChannelTarget(channels.sms, "sms", ["apiUrl", "from", "to"]);
|
|
4532
|
+
if (channels.logs !== undefined)
|
|
4533
|
+
normalized.logs = normalizeChannelTarget(channels.logs, "logs", ["apiUrl", "projectId", "environment", "service"]);
|
|
4534
|
+
if (!normalized.email && !normalized.sms && !normalized.logs) {
|
|
4535
|
+
throw new Error("Report schedule requires at least one channel");
|
|
4536
|
+
}
|
|
4537
|
+
return normalized;
|
|
4538
|
+
}
|
|
4539
|
+
function normalizeChannelTarget(value, channel, allowedKeys) {
|
|
4540
|
+
if (value === false || value == null)
|
|
4541
|
+
return false;
|
|
4542
|
+
if (value === true)
|
|
4543
|
+
return true;
|
|
4544
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
4545
|
+
throw new Error(`Report schedule ${channel} channel must be true or an object`);
|
|
4546
|
+
}
|
|
4547
|
+
const record = value;
|
|
4548
|
+
const normalized = {};
|
|
4549
|
+
for (const [key, rawValue] of Object.entries(record)) {
|
|
4550
|
+
if (!allowedKeys.includes(key)) {
|
|
4551
|
+
if (/key|token|secret|password|credential|auth/i.test(key)) {
|
|
4552
|
+
throw new Error("Report schedules must not persist API keys or tokens; use environment variables or cloud channel refs");
|
|
4553
|
+
}
|
|
4554
|
+
throw new Error(`Unsupported report schedule ${channel} channel field: ${key}`);
|
|
4555
|
+
}
|
|
4556
|
+
if (rawValue === undefined || rawValue === null || rawValue === "")
|
|
4557
|
+
continue;
|
|
4558
|
+
if (key === "apiUrl" && Array.isArray(rawValue)) {
|
|
4559
|
+
throw new Error(`Report schedule ${channel}.${key} must be a string`);
|
|
4560
|
+
}
|
|
4561
|
+
if (Array.isArray(rawValue)) {
|
|
4562
|
+
const items = rawValue.map((item) => normalizeBoundedText(String(item), `Report schedule ${channel}.${key}`, 300));
|
|
4563
|
+
if (items.length > 0)
|
|
4564
|
+
normalized[key] = items;
|
|
4565
|
+
} else if (typeof rawValue === "string" || typeof rawValue === "number") {
|
|
4566
|
+
normalized[key] = key === "apiUrl" ? normalizeHttpIntegrationUrl(String(rawValue)) : normalizeBoundedText(String(rawValue), `Report schedule ${channel}.${key}`, 500);
|
|
4567
|
+
} else {
|
|
4568
|
+
throw new Error(`Report schedule ${channel}.${key} must be a string or string array`);
|
|
4569
|
+
}
|
|
4570
|
+
}
|
|
4571
|
+
return Object.keys(normalized).length > 0 ? normalized : true;
|
|
4572
|
+
}
|
|
4573
|
+
function normalizeHttpIntegrationUrl(value) {
|
|
4574
|
+
const parsed = new URL(value.trim());
|
|
4575
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
4576
|
+
throw new Error("Report schedule integration API URL must use http or https");
|
|
4577
|
+
}
|
|
4578
|
+
if (parsed.username || parsed.password) {
|
|
4579
|
+
throw new Error("Report schedule integration API URL must not include credentials");
|
|
4580
|
+
}
|
|
4581
|
+
for (const key of parsed.searchParams.keys()) {
|
|
4582
|
+
if (SECRET_URL_PARAM_PATTERN.test(key)) {
|
|
4583
|
+
throw new Error("Report schedule integration API URL must not include secret query parameters");
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
parsed.hash = "";
|
|
4587
|
+
return parsed.toString();
|
|
4588
|
+
}
|
|
4589
|
+
function normalizeReportDeliveries(deliveries) {
|
|
4590
|
+
return deliveries.map((delivery) => {
|
|
4591
|
+
if (delivery.channel !== "email" && delivery.channel !== "sms" && delivery.channel !== "logs") {
|
|
4592
|
+
throw new Error("Report delivery channel must be email, sms, or logs");
|
|
4593
|
+
}
|
|
4594
|
+
return {
|
|
4595
|
+
channel: delivery.channel,
|
|
4596
|
+
ok: Boolean(delivery.ok),
|
|
4597
|
+
status: delivery.status,
|
|
4598
|
+
id: delivery.id === undefined ? undefined : normalizeRedactedText(String(delivery.id), "Report delivery id", 300),
|
|
4599
|
+
error: delivery.error === undefined ? undefined : normalizeRedactedText(String(delivery.error), "Report delivery error", 1000)
|
|
4600
|
+
};
|
|
4601
|
+
});
|
|
4602
|
+
}
|
|
4603
|
+
function normalizeAuditText(value, label, maxLength) {
|
|
4604
|
+
return normalizeBoundedText(value ?? "", label, maxLength);
|
|
4605
|
+
}
|
|
4606
|
+
function normalizeNullableAuditText(value, label, maxLength) {
|
|
4607
|
+
return normalizeNullableBoundedText(value, label, maxLength);
|
|
4608
|
+
}
|
|
4609
|
+
function normalizeNullableBoundedText(value, label, maxLength) {
|
|
4610
|
+
if (value == null)
|
|
4611
|
+
return null;
|
|
4612
|
+
const normalized = normalizeRedactedText(value, label, maxLength);
|
|
4613
|
+
return normalized || null;
|
|
4614
|
+
}
|
|
4615
|
+
function normalizeBoundedText(value, label, maxLength) {
|
|
4616
|
+
const normalized = value.trim();
|
|
4617
|
+
rejectControlCharacters2(normalized, label);
|
|
4618
|
+
if (normalized.length > maxLength)
|
|
4619
|
+
throw new Error(`${label} is too long`);
|
|
4620
|
+
return normalized;
|
|
4621
|
+
}
|
|
4622
|
+
function normalizeNullableRedactedText(value, label, maxLength) {
|
|
4623
|
+
if (value == null)
|
|
4624
|
+
return null;
|
|
4625
|
+
const normalized = normalizeRedactedText(value, label, maxLength);
|
|
4626
|
+
return normalized || null;
|
|
4627
|
+
}
|
|
4628
|
+
function normalizeRedactedText(value, label, maxLength) {
|
|
4629
|
+
return normalizeBoundedText(redactSecretString(value), label, maxLength);
|
|
4630
|
+
}
|
|
4631
|
+
function normalizeAuditMetadata(value) {
|
|
4632
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
4633
|
+
throw new Error("Audit metadata must be an object");
|
|
4634
|
+
}
|
|
4635
|
+
return redactAuditSecrets(JSON.parse(JSON.stringify(value)));
|
|
4636
|
+
}
|
|
4637
|
+
function redactAuditSecrets(value) {
|
|
4638
|
+
if (Array.isArray(value))
|
|
4639
|
+
return value.map(redactAuditSecrets);
|
|
4640
|
+
if (typeof value === "string")
|
|
4641
|
+
return redactSecretString(value);
|
|
4642
|
+
if (!value || typeof value !== "object")
|
|
4643
|
+
return value;
|
|
4644
|
+
const output = {};
|
|
4645
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
4646
|
+
output[key] = /key|token|secret|password|credential|auth/i.test(key) ? "[REDACTED]" : redactAuditSecrets(nested);
|
|
4647
|
+
}
|
|
4648
|
+
return output;
|
|
4649
|
+
}
|
|
4650
|
+
function redactSecretString(value) {
|
|
4651
|
+
let output = value.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
|
|
4652
|
+
output = output.replace(/https?:\/\/[^\s"'<>]+/gi, (match) => redactUrlString(match));
|
|
4653
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(output))
|
|
4654
|
+
return output;
|
|
4655
|
+
return redactUrlString(output);
|
|
4656
|
+
}
|
|
4657
|
+
function redactUrlString(value) {
|
|
4658
|
+
let trailing = "";
|
|
4659
|
+
let candidate = value;
|
|
4660
|
+
while (/[),.;\]]$/.test(candidate)) {
|
|
4661
|
+
trailing = `${candidate.slice(-1)}${trailing}`;
|
|
4662
|
+
candidate = candidate.slice(0, -1);
|
|
4663
|
+
}
|
|
4664
|
+
try {
|
|
4665
|
+
const parsed = new URL(candidate);
|
|
4666
|
+
if (parsed.username)
|
|
4667
|
+
parsed.username = "[REDACTED]";
|
|
4668
|
+
if (parsed.password)
|
|
4669
|
+
parsed.password = "[REDACTED]";
|
|
4670
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
4671
|
+
if (SECRET_URL_PARAM_PATTERN.test(key))
|
|
4672
|
+
parsed.searchParams.set(key, "[REDACTED]");
|
|
4673
|
+
}
|
|
4674
|
+
parsed.hash = "";
|
|
4675
|
+
return `${parsed.toString()}${trailing}`;
|
|
4676
|
+
} catch {
|
|
4677
|
+
return value;
|
|
4678
|
+
}
|
|
4679
|
+
}
|
|
4311
4680
|
function assertIsoTimestamp(value, label) {
|
|
4312
4681
|
if (!Number.isFinite(Date.parse(value))) {
|
|
4313
4682
|
throw new Error(`${label} must be an ISO timestamp`);
|
|
@@ -4407,12 +4776,66 @@ function probeCheckJobFromRow(row) {
|
|
|
4407
4776
|
updatedAt: row.updated_at
|
|
4408
4777
|
};
|
|
4409
4778
|
}
|
|
4779
|
+
function reportScheduleFromRow(row) {
|
|
4780
|
+
return {
|
|
4781
|
+
id: row.id,
|
|
4782
|
+
name: row.name,
|
|
4783
|
+
enabled: Boolean(row.enabled),
|
|
4784
|
+
intervalSeconds: row.interval_seconds,
|
|
4785
|
+
nextRunAt: row.next_run_at,
|
|
4786
|
+
lastRunAt: row.last_run_at,
|
|
4787
|
+
subject: row.subject,
|
|
4788
|
+
channels: parseReportChannels(row.channels_json),
|
|
4789
|
+
createdAt: row.created_at,
|
|
4790
|
+
updatedAt: row.updated_at
|
|
4791
|
+
};
|
|
4792
|
+
}
|
|
4793
|
+
function reportRunFromRow(row) {
|
|
4794
|
+
return {
|
|
4795
|
+
id: row.id,
|
|
4796
|
+
scheduleId: row.schedule_id,
|
|
4797
|
+
status: row.status,
|
|
4798
|
+
startedAt: row.started_at,
|
|
4799
|
+
finishedAt: row.finished_at,
|
|
4800
|
+
deliveries: parseReportDeliveries(row.deliveries_json),
|
|
4801
|
+
error: row.error,
|
|
4802
|
+
reportJson: parseRecord(row.report_json)
|
|
4803
|
+
};
|
|
4804
|
+
}
|
|
4805
|
+
function auditEventFromRow(row) {
|
|
4806
|
+
return {
|
|
4807
|
+
id: row.id,
|
|
4808
|
+
action: row.action,
|
|
4809
|
+
resourceType: row.resource_type,
|
|
4810
|
+
resourceId: row.resource_id,
|
|
4811
|
+
message: row.message,
|
|
4812
|
+
metadata: parseRecord(row.metadata_json) ?? {},
|
|
4813
|
+
actor: row.actor,
|
|
4814
|
+
createdAt: row.created_at
|
|
4815
|
+
};
|
|
4816
|
+
}
|
|
4410
4817
|
function parseEvidence(value) {
|
|
4411
4818
|
if (!value)
|
|
4412
4819
|
return null;
|
|
4413
4820
|
const parsed = parseJson(value);
|
|
4414
4821
|
return parsed && typeof parsed === "object" ? parsed : null;
|
|
4415
4822
|
}
|
|
4823
|
+
function parseReportChannels(value) {
|
|
4824
|
+
const parsed = parseJson(value);
|
|
4825
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
4826
|
+
return {};
|
|
4827
|
+
return parsed;
|
|
4828
|
+
}
|
|
4829
|
+
function parseReportDeliveries(value) {
|
|
4830
|
+
const parsed = parseJson(value);
|
|
4831
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
4832
|
+
}
|
|
4833
|
+
function parseRecord(value) {
|
|
4834
|
+
if (!value)
|
|
4835
|
+
return null;
|
|
4836
|
+
const parsed = parseJson(value);
|
|
4837
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
4838
|
+
}
|
|
4416
4839
|
function parseJson(value) {
|
|
4417
4840
|
try {
|
|
4418
4841
|
return JSON.parse(value);
|
|
@@ -4734,6 +5157,7 @@ class UptimeService {
|
|
|
4734
5157
|
checkRunner;
|
|
4735
5158
|
leaseOwner = `svc_${randomUUID3().replace(/-/g, "").slice(0, 18)}`;
|
|
4736
5159
|
inFlightChecks = new Set;
|
|
5160
|
+
inFlightReportSchedules = new Set;
|
|
4737
5161
|
constructor(options = {}) {
|
|
4738
5162
|
this.store = options.store ?? new UptimeStore({ mode: "local", ...options });
|
|
4739
5163
|
this.checkRunner = options.checkRunner ?? runMonitorCheck;
|
|
@@ -4827,6 +5251,115 @@ class UptimeService {
|
|
|
4827
5251
|
}
|
|
4828
5252
|
return sendUptimeReport(this.summary(), options);
|
|
4829
5253
|
}
|
|
5254
|
+
createReportSchedule(input) {
|
|
5255
|
+
const store = this.reportStore();
|
|
5256
|
+
const schedule = store.createReportSchedule(input);
|
|
5257
|
+
this.audit("report_schedule.create", "report_schedule", schedule.id, `Created report schedule ${schedule.name}`, {
|
|
5258
|
+
name: schedule.name,
|
|
5259
|
+
enabled: schedule.enabled,
|
|
5260
|
+
intervalSeconds: schedule.intervalSeconds,
|
|
5261
|
+
channels: enabledReportChannels(schedule)
|
|
5262
|
+
});
|
|
5263
|
+
return schedule;
|
|
5264
|
+
}
|
|
5265
|
+
listReportSchedules(options = {}) {
|
|
5266
|
+
return this.reportStore().listReportSchedules(options);
|
|
5267
|
+
}
|
|
5268
|
+
getReportSchedule(idOrName) {
|
|
5269
|
+
return this.reportStore().getReportSchedule(idOrName);
|
|
5270
|
+
}
|
|
5271
|
+
updateReportSchedule(idOrName, input) {
|
|
5272
|
+
const store = this.reportStore();
|
|
5273
|
+
const schedule = store.updateReportSchedule(idOrName, input);
|
|
5274
|
+
this.audit("report_schedule.update", "report_schedule", schedule.id, `Updated report schedule ${schedule.name}`, {
|
|
5275
|
+
name: schedule.name,
|
|
5276
|
+
enabled: schedule.enabled,
|
|
5277
|
+
intervalSeconds: schedule.intervalSeconds,
|
|
5278
|
+
channels: enabledReportChannels(schedule)
|
|
5279
|
+
});
|
|
5280
|
+
return schedule;
|
|
5281
|
+
}
|
|
5282
|
+
deleteReportSchedule(idOrName) {
|
|
5283
|
+
const store = this.reportStore();
|
|
5284
|
+
const schedule = store.getReportSchedule(idOrName);
|
|
5285
|
+
const deleted = store.deleteReportSchedule(idOrName);
|
|
5286
|
+
if (deleted && schedule) {
|
|
5287
|
+
this.audit("report_schedule.delete", "report_schedule", schedule.id, `Deleted report schedule ${schedule.name}`, {
|
|
5288
|
+
name: schedule.name
|
|
5289
|
+
});
|
|
5290
|
+
}
|
|
5291
|
+
return deleted;
|
|
5292
|
+
}
|
|
5293
|
+
listReportRuns(options = {}) {
|
|
5294
|
+
return this.reportStore().listReportRuns(options);
|
|
5295
|
+
}
|
|
5296
|
+
listAuditEvents(options = {}) {
|
|
5297
|
+
return this.reportStore().listAuditEvents(options);
|
|
5298
|
+
}
|
|
5299
|
+
recordAuditEvent(input) {
|
|
5300
|
+
return this.reportStore().recordAuditEvent(input);
|
|
5301
|
+
}
|
|
5302
|
+
async runReportSchedule(idOrName, options = {}) {
|
|
5303
|
+
const store = this.reportStore();
|
|
5304
|
+
const schedule = store.getReportSchedule(idOrName);
|
|
5305
|
+
if (!schedule)
|
|
5306
|
+
throw new Error(`Report schedule not found: ${idOrName}`);
|
|
5307
|
+
if (!schedule.enabled)
|
|
5308
|
+
throw new Error(`Report schedule is disabled: ${schedule.name}`);
|
|
5309
|
+
if (this.inFlightReportSchedules.has(schedule.id))
|
|
5310
|
+
throw new Error(`Report schedule already running: ${schedule.name}`);
|
|
5311
|
+
this.inFlightReportSchedules.add(schedule.id);
|
|
5312
|
+
try {
|
|
5313
|
+
const startedAt = new Date().toISOString();
|
|
5314
|
+
let deliveries = [];
|
|
5315
|
+
let error = null;
|
|
5316
|
+
let reportJson = null;
|
|
5317
|
+
try {
|
|
5318
|
+
const report = this.buildReport({ subject: schedule.subject ?? undefined });
|
|
5319
|
+
reportJson = report.json;
|
|
5320
|
+
deliveries = await this.sendReport({
|
|
5321
|
+
subject: schedule.subject ?? undefined,
|
|
5322
|
+
email: schedule.channels.email,
|
|
5323
|
+
sms: schedule.channels.sms,
|
|
5324
|
+
logs: schedule.channels.logs,
|
|
5325
|
+
fetchImpl: options.fetchImpl
|
|
5326
|
+
});
|
|
5327
|
+
const failed = deliveries.filter((delivery) => !delivery.ok);
|
|
5328
|
+
if (failed.length > 0) {
|
|
5329
|
+
error = failed.map((delivery) => `${delivery.channel}: ${delivery.error ?? delivery.status ?? "failed"}`).join("; ");
|
|
5330
|
+
}
|
|
5331
|
+
} catch (caught) {
|
|
5332
|
+
error = caught instanceof Error ? caught.message : String(caught);
|
|
5333
|
+
}
|
|
5334
|
+
const finishedAt = new Date().toISOString();
|
|
5335
|
+
const run = store.recordReportRun({
|
|
5336
|
+
scheduleId: schedule.id,
|
|
5337
|
+
status: error ? "failed" : "success",
|
|
5338
|
+
startedAt,
|
|
5339
|
+
finishedAt,
|
|
5340
|
+
deliveries,
|
|
5341
|
+
error,
|
|
5342
|
+
reportJson
|
|
5343
|
+
});
|
|
5344
|
+
this.audit("report_schedule.run", "report_schedule", schedule.id, `Ran report schedule ${schedule.name}`, {
|
|
5345
|
+
runId: run.id,
|
|
5346
|
+
status: run.status,
|
|
5347
|
+
deliveryChannels: run.deliveries.map((delivery) => ({ channel: delivery.channel, ok: delivery.ok }))
|
|
5348
|
+
});
|
|
5349
|
+
return run;
|
|
5350
|
+
} finally {
|
|
5351
|
+
this.inFlightReportSchedules.delete(schedule.id);
|
|
5352
|
+
}
|
|
5353
|
+
}
|
|
5354
|
+
async runDueReportSchedules(now = new Date, options = {}) {
|
|
5355
|
+
const store = this.reportStore();
|
|
5356
|
+
const schedules = store.listDueReportSchedules(now.toISOString());
|
|
5357
|
+
const runs = [];
|
|
5358
|
+
for (const schedule of schedules) {
|
|
5359
|
+
runs.push(await this.runReportSchedule(schedule.id, options));
|
|
5360
|
+
}
|
|
5361
|
+
return runs;
|
|
5362
|
+
}
|
|
4830
5363
|
async checkMonitor(idOrName) {
|
|
4831
5364
|
if (this.store.mode === "hosted")
|
|
4832
5365
|
throw new Error("hosted checks require check_jobs and probes");
|
|
@@ -4885,6 +5418,9 @@ class UptimeService {
|
|
|
4885
5418
|
this.runDueChecks().catch((error) => {
|
|
4886
5419
|
console.error(error instanceof Error ? error.message : String(error));
|
|
4887
5420
|
});
|
|
5421
|
+
this.runDueReportSchedules(new Date, { fetchImpl: options.reportFetchImpl }).catch((error) => {
|
|
5422
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
5423
|
+
});
|
|
4888
5424
|
}, tickMs);
|
|
4889
5425
|
return {
|
|
4890
5426
|
stop: () => clearInterval(timer)
|
|
@@ -4944,6 +5480,40 @@ class UptimeService {
|
|
|
4944
5480
|
}
|
|
4945
5481
|
return store;
|
|
4946
5482
|
}
|
|
5483
|
+
reportStore() {
|
|
5484
|
+
if (this.store.mode === "hosted") {
|
|
5485
|
+
throw new Error("hosted report schedules require cloud channel refs, workspace stores, and audit logging");
|
|
5486
|
+
}
|
|
5487
|
+
const store = this.store;
|
|
5488
|
+
const required = [
|
|
5489
|
+
"createReportSchedule",
|
|
5490
|
+
"listReportSchedules",
|
|
5491
|
+
"listDueReportSchedules",
|
|
5492
|
+
"getReportSchedule",
|
|
5493
|
+
"updateReportSchedule",
|
|
5494
|
+
"deleteReportSchedule",
|
|
5495
|
+
"recordReportRun",
|
|
5496
|
+
"listReportRuns",
|
|
5497
|
+
"recordAuditEvent",
|
|
5498
|
+
"listAuditEvents"
|
|
5499
|
+
];
|
|
5500
|
+
for (const method of required) {
|
|
5501
|
+
if (typeof store[method] !== "function") {
|
|
5502
|
+
throw new Error("report scheduling requires a report-capable store");
|
|
5503
|
+
}
|
|
5504
|
+
}
|
|
5505
|
+
return store;
|
|
5506
|
+
}
|
|
5507
|
+
audit(action, resourceType, resourceId, message, metadata) {
|
|
5508
|
+
this.reportStore().recordAuditEvent({
|
|
5509
|
+
action,
|
|
5510
|
+
resourceType,
|
|
5511
|
+
resourceId,
|
|
5512
|
+
message,
|
|
5513
|
+
metadata,
|
|
5514
|
+
actor: "local"
|
|
5515
|
+
});
|
|
5516
|
+
}
|
|
4947
5517
|
submitProbeResultInTransaction(input) {
|
|
4948
5518
|
const store = this.probeStore();
|
|
4949
5519
|
const probe = store.getProbeIdentity(input.probeId);
|
|
@@ -5033,6 +5603,9 @@ class MonitorCheckBusyError extends Error {
|
|
|
5033
5603
|
this.name = "MonitorCheckBusyError";
|
|
5034
5604
|
}
|
|
5035
5605
|
}
|
|
5606
|
+
function enabledReportChannels(schedule) {
|
|
5607
|
+
return ["email", "sms", "logs"].filter((channel) => Boolean(schedule.channels[channel]));
|
|
5608
|
+
}
|
|
5036
5609
|
function validateProbeSubmission(input) {
|
|
5037
5610
|
if (!input.jobId.trim())
|
|
5038
5611
|
throw new Error("Probe submission jobId is required");
|
|
@@ -5583,9 +6156,54 @@ async function handleApiRoute(service, request, url, apiPath, options, hosted) {
|
|
|
5583
6156
|
const input = await jsonBody(request);
|
|
5584
6157
|
return json(await service.sendReport({ ...input, fetchImpl: options.fetchImpl }));
|
|
5585
6158
|
}
|
|
6159
|
+
if (hosted && (apiPath.startsWith("/api/report-schedules") || apiPath.startsWith("/api/report-runs") || apiPath.startsWith("/api/audit-events"))) {
|
|
6160
|
+
throw new ApiError("hosted report schedules require cloud channel refs, workspace stores, and audit logging", 501);
|
|
6161
|
+
}
|
|
5586
6162
|
if (hosted && apiPath.startsWith("/api/probes")) {
|
|
5587
6163
|
throw new ApiError("hosted probe APIs require cloud check_jobs, workspace stores, and audit logging", 501);
|
|
5588
6164
|
}
|
|
6165
|
+
if (request.method === "GET" && apiPath === "/api/report-schedules") {
|
|
6166
|
+
return json(service.listReportSchedules({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
|
|
6167
|
+
}
|
|
6168
|
+
if (request.method === "POST" && apiPath === "/api/report-schedules") {
|
|
6169
|
+
return json(service.createReportSchedule(await jsonBody(request)), 201);
|
|
6170
|
+
}
|
|
6171
|
+
if (request.method === "POST" && apiPath === "/api/report-schedules/run-due") {
|
|
6172
|
+
const input = await jsonBody(request);
|
|
6173
|
+
const now = input.now ? new Date(input.now) : new Date;
|
|
6174
|
+
return json(await service.runDueReportSchedules(now, { fetchImpl: options.fetchImpl }));
|
|
6175
|
+
}
|
|
6176
|
+
const reportScheduleRunMatch = apiPath.match(/^\/api\/report-schedules\/([^/]+)\/run$/);
|
|
6177
|
+
if (request.method === "POST" && reportScheduleRunMatch) {
|
|
6178
|
+
return json(await service.runReportSchedule(decodeURIComponent(reportScheduleRunMatch[1]), { fetchImpl: options.fetchImpl }));
|
|
6179
|
+
}
|
|
6180
|
+
const reportScheduleMatch = apiPath.match(/^\/api\/report-schedules\/([^/]+)$/);
|
|
6181
|
+
if (reportScheduleMatch) {
|
|
6182
|
+
const id = decodeURIComponent(reportScheduleMatch[1]);
|
|
6183
|
+
if (request.method === "GET") {
|
|
6184
|
+
const schedule = service.getReportSchedule(id);
|
|
6185
|
+
return schedule ? json(schedule) : json({ error: "not found" }, 404);
|
|
6186
|
+
}
|
|
6187
|
+
if (request.method === "PATCH") {
|
|
6188
|
+
return json(service.updateReportSchedule(id, await jsonBody(request)));
|
|
6189
|
+
}
|
|
6190
|
+
if (request.method === "DELETE") {
|
|
6191
|
+
return json({ deleted: service.deleteReportSchedule(id) });
|
|
6192
|
+
}
|
|
6193
|
+
}
|
|
6194
|
+
if (request.method === "GET" && apiPath === "/api/report-runs") {
|
|
6195
|
+
return json(service.listReportRuns({
|
|
6196
|
+
scheduleId: url.searchParams.get("scheduleId") ?? undefined,
|
|
6197
|
+
limit: numericParam(url, "limit", 50)
|
|
6198
|
+
}));
|
|
6199
|
+
}
|
|
6200
|
+
if (request.method === "GET" && apiPath === "/api/audit-events") {
|
|
6201
|
+
return json(service.listAuditEvents({
|
|
6202
|
+
resourceType: url.searchParams.get("resourceType") ?? undefined,
|
|
6203
|
+
resourceId: url.searchParams.get("resourceId") ?? undefined,
|
|
6204
|
+
limit: numericParam(url, "limit", 50)
|
|
6205
|
+
}));
|
|
6206
|
+
}
|
|
5589
6207
|
if (request.method === "GET" && apiPath === "/api/monitors") {
|
|
5590
6208
|
return json(service.listMonitors({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
|
|
5591
6209
|
}
|
|
@@ -5681,6 +6299,10 @@ async function handleApiRoute(service, request, url, apiPath, options, hosted) {
|
|
|
5681
6299
|
function hostedScopeFor(method, apiPath) {
|
|
5682
6300
|
if (method === "POST" && apiPath === "/api/report")
|
|
5683
6301
|
return "uptime:report";
|
|
6302
|
+
if (apiPath.startsWith("/api/report-schedules") || apiPath.startsWith("/api/report-runs"))
|
|
6303
|
+
return method === "GET" ? "uptime:read" : "uptime:report";
|
|
6304
|
+
if (apiPath.startsWith("/api/audit-events"))
|
|
6305
|
+
return method === "GET" ? "uptime:read" : "uptime:admin";
|
|
5684
6306
|
if (apiPath.startsWith("/api/probes"))
|
|
5685
6307
|
return method === "GET" ? "uptime:read" : "uptime:probe";
|
|
5686
6308
|
if (method === "POST" && (apiPath === "/api/check-all" || /\/check$/.test(apiPath)))
|
|
@@ -5765,6 +6387,268 @@ class ApiError extends Error {
|
|
|
5765
6387
|
}
|
|
5766
6388
|
}
|
|
5767
6389
|
|
|
6390
|
+
// src/cloud-plan.ts
|
|
6391
|
+
var DEFAULT_ACCOUNT = "hasna-xyz-infra";
|
|
6392
|
+
var DEFAULT_REGION = "us-east-1";
|
|
6393
|
+
var DEFAULT_STAGE = "prod";
|
|
6394
|
+
var DEFAULT_PREFIX = "open-uptime";
|
|
6395
|
+
var DEFAULT_HOSTNAME = "uptime.hasna.xyz";
|
|
6396
|
+
var DEFAULT_WORKSPACE_ID = "wks_2tyysw05cwap";
|
|
6397
|
+
var DEFAULT_VPC_ID = "vpc-04c7f7abc1d3c3f56";
|
|
6398
|
+
var DEFAULT_RDS = "hasna-xyz-infra-apps-prod-postgres";
|
|
6399
|
+
function buildAwsDeploymentPlan(options = {}) {
|
|
6400
|
+
const region = clean(options.region, DEFAULT_REGION);
|
|
6401
|
+
const stage = clean(options.stage, DEFAULT_STAGE);
|
|
6402
|
+
const prefix = clean(options.servicePrefix, DEFAULT_PREFIX);
|
|
6403
|
+
const accountName = clean(options.accountName, DEFAULT_ACCOUNT);
|
|
6404
|
+
const hostname = clean(options.hostname, DEFAULT_HOSTNAME);
|
|
6405
|
+
const workspaceId = clean(options.workspaceId, DEFAULT_WORKSPACE_ID);
|
|
6406
|
+
const ecrRepository = clean(options.ecrRepository, `hasna/opensource/${prefix}`);
|
|
6407
|
+
const image = clean(options.image, `<account-id>.dkr.ecr.${region}.amazonaws.com/${ecrRepository}:<git-sha>`);
|
|
6408
|
+
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
6409
|
+
const cluster = `${prefix}-${stage}`;
|
|
6410
|
+
const secrets = {
|
|
6411
|
+
database: clean(options.databaseSecretName, `hasna/xyz/opensource/uptime/${stage}/rds`),
|
|
6412
|
+
appEnv: clean(options.appEnvSecretName, `hasna/xyz/opensource/uptime/${stage}/app/env`),
|
|
6413
|
+
hostedToken: clean(options.hostedTokenSecretName, `hasna/xyz/opensource/uptime/${stage}/hosted-token`),
|
|
6414
|
+
publicProbe: clean(options.publicProbeSecretName, `hasna/xyz/opensource/uptime/${stage}/probe/public`),
|
|
6415
|
+
privateProbe: clean(options.privateProbeSecretName, `hasna/xyz/opensource/uptime/${stage}/probe/private`),
|
|
6416
|
+
reporting: clean(options.reportingSecretName, `hasna/xyz/opensource/uptime/${stage}/reporting`)
|
|
6417
|
+
};
|
|
6418
|
+
const services = [
|
|
6419
|
+
servicePlan(prefix, stage, "web", 2, image, workspaceId, secrets, {
|
|
6420
|
+
HASNA_UPTIME_MODE: "hosted",
|
|
6421
|
+
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
6422
|
+
HASNA_UPTIME_HOSTNAME: hostname
|
|
6423
|
+
}),
|
|
6424
|
+
servicePlan(prefix, stage, "scheduler", 1, image, workspaceId, secrets, {
|
|
6425
|
+
HASNA_UPTIME_MODE: "hosted",
|
|
6426
|
+
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
6427
|
+
HASNA_UPTIME_COMPONENT: "scheduler"
|
|
6428
|
+
}),
|
|
6429
|
+
servicePlan(prefix, stage, "public-probe", 1, image, workspaceId, secrets, {
|
|
6430
|
+
HASNA_UPTIME_MODE: "hosted",
|
|
6431
|
+
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
6432
|
+
HASNA_UPTIME_COMPONENT: "public-probe",
|
|
6433
|
+
HASNA_UPTIME_PROBE_LOCATION: region
|
|
6434
|
+
}),
|
|
6435
|
+
servicePlan(prefix, stage, "reporter", 1, image, workspaceId, secrets, {
|
|
6436
|
+
HASNA_UPTIME_MODE: "hosted",
|
|
6437
|
+
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
6438
|
+
HASNA_UPTIME_COMPONENT: "reporter"
|
|
6439
|
+
}),
|
|
6440
|
+
servicePlan(prefix, stage, "migration", 0, image, workspaceId, secrets, {
|
|
6441
|
+
HASNA_UPTIME_MODE: "hosted",
|
|
6442
|
+
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
6443
|
+
HASNA_UPTIME_COMPONENT: "migration"
|
|
6444
|
+
})
|
|
6445
|
+
];
|
|
6446
|
+
return {
|
|
6447
|
+
kind: "open-uptime.aws-deployment-plan",
|
|
6448
|
+
version: 1,
|
|
6449
|
+
generatedAt: new Date().toISOString(),
|
|
6450
|
+
status: "blocked",
|
|
6451
|
+
canApply: false,
|
|
6452
|
+
accountName,
|
|
6453
|
+
region,
|
|
6454
|
+
stage,
|
|
6455
|
+
servicePrefix: prefix,
|
|
6456
|
+
hostname,
|
|
6457
|
+
workspaceId,
|
|
6458
|
+
mode: "hosted",
|
|
6459
|
+
resources: {
|
|
6460
|
+
ecrRepository,
|
|
6461
|
+
ecsCluster: cluster,
|
|
6462
|
+
services,
|
|
6463
|
+
vpcId: clean(options.vpcId, DEFAULT_VPC_ID),
|
|
6464
|
+
rdsInstanceId: clean(options.rdsInstanceId, DEFAULT_RDS),
|
|
6465
|
+
evidenceBucket,
|
|
6466
|
+
loadBalancer: `${prefix}-${stage}-alb`,
|
|
6467
|
+
targetGroups: [`${prefix}-${stage}-web-tg`],
|
|
6468
|
+
securityGroups: [
|
|
6469
|
+
`${prefix}-${stage}-alb-sg`,
|
|
6470
|
+
`${prefix}-${stage}-web-sg`,
|
|
6471
|
+
`${prefix}-${stage}-scheduler-sg`,
|
|
6472
|
+
`${prefix}-${stage}-public-probe-sg`,
|
|
6473
|
+
`${prefix}-${stage}-rds-client-sg`
|
|
6474
|
+
],
|
|
6475
|
+
secrets,
|
|
6476
|
+
logGroups: services.map((service) => service.logGroup),
|
|
6477
|
+
alarms: [
|
|
6478
|
+
`${prefix}-${stage}-web-5xx`,
|
|
6479
|
+
`${prefix}-${stage}-scheduler-stalled`,
|
|
6480
|
+
`${prefix}-${stage}-probe-stale`,
|
|
6481
|
+
`${prefix}-${stage}-report-delivery-failures`
|
|
6482
|
+
]
|
|
6483
|
+
},
|
|
6484
|
+
image: {
|
|
6485
|
+
repository: ecrRepository,
|
|
6486
|
+
uri: image,
|
|
6487
|
+
buildCommand: "BLOCKED: add a reviewed Dockerfile/container build target before running docker build",
|
|
6488
|
+
pushCommands: [
|
|
6489
|
+
"BLOCKED: push only from approved CI/CD after the ECR repository and image digest policy exist",
|
|
6490
|
+
"BLOCKED: deploy services by immutable image digest, not by mutable tags"
|
|
6491
|
+
]
|
|
6492
|
+
},
|
|
6493
|
+
runbook: {
|
|
6494
|
+
preflight: [
|
|
6495
|
+
`aws sts get-caller-identity --profile ${accountName}`,
|
|
6496
|
+
`aws rds describe-db-instances --db-instance-identifier ${clean(options.rdsInstanceId, DEFAULT_RDS)} --region ${region}`,
|
|
6497
|
+
`aws ec2 describe-vpcs --vpc-ids ${clean(options.vpcId, DEFAULT_VPC_ID)} --region ${region}`,
|
|
6498
|
+
"Confirm the infra repository and Terraform/CloudFormation owner before live mutation."
|
|
6499
|
+
],
|
|
6500
|
+
provision: [
|
|
6501
|
+
`Infra PR must declare or update ECR repository ${ecrRepository}.`,
|
|
6502
|
+
`Infra PR must declare hardened S3 evidence bucket ${evidenceBucket} with KMS, versioning, lifecycle, and public access block.`,
|
|
6503
|
+
`Infra PR must declare ECS/Fargate cluster ${cluster}, ALB, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs.`,
|
|
6504
|
+
"Only apply the infra plan from the approved infrastructure repository after review evidence is attached."
|
|
6505
|
+
],
|
|
6506
|
+
deploy: [
|
|
6507
|
+
"Build and publish the image only after the Dockerfile/container target is reviewed.",
|
|
6508
|
+
"Run the migration task with the migrator role before web/scheduler/probe services.",
|
|
6509
|
+
`Register task definitions for ${services.map((service) => service.name).join(", ")} using valueFrom secrets.`,
|
|
6510
|
+
`Update ECS services in cluster ${cluster} one component at a time through the approved deploy pipeline.`,
|
|
6511
|
+
`Create Route53/edge record for ${hostname} only after ALB health checks pass and auth denial smokes succeed.`
|
|
6512
|
+
],
|
|
6513
|
+
rollback: [
|
|
6514
|
+
"Keep previous task definition ARNs before each service update.",
|
|
6515
|
+
"Rollback through the approved deploy pipeline to the previously recorded task definition ARNs.",
|
|
6516
|
+
"Disable scheduler/reporter services before data rollback.",
|
|
6517
|
+
"Restore RDS snapshot only after explicit operator approval and audit record."
|
|
6518
|
+
],
|
|
6519
|
+
spark01: [
|
|
6520
|
+
"Create a private probe identity with a caller-managed public key.",
|
|
6521
|
+
"Install @hasna/uptime on Spark01 and write the generated env file with mode 0600.",
|
|
6522
|
+
"Run the private probe against the hosted /api/v1 probe endpoint once it exists."
|
|
6523
|
+
]
|
|
6524
|
+
},
|
|
6525
|
+
blockers: [
|
|
6526
|
+
"The hasna-xyz-infra infrastructure owner repository was not found in this workspace.",
|
|
6527
|
+
"The repo has no reviewed Dockerfile/container build target for image build and publish automation.",
|
|
6528
|
+
"Hosted Postgres storage adapter and migrations are not implemented.",
|
|
6529
|
+
"Hosted production auth/RBAC must replace broad static hosted-token operation before exposure.",
|
|
6530
|
+
"Public probe execution still needs DNS, redirect, and rebinding SSRF enforcement plus cloud check-job leases.",
|
|
6531
|
+
"Spark01 hosted probe enrollment, claim, submit, heartbeat, revocation, and rotation are not cloud-backed yet."
|
|
6532
|
+
],
|
|
6533
|
+
requiredEvidence: [
|
|
6534
|
+
"Infrastructure PR/synth/plan from the approved infra repository.",
|
|
6535
|
+
"Container build smoke and immutable image digest.",
|
|
6536
|
+
"ECS task definitions using secrets.valueFrom only.",
|
|
6537
|
+
"ALB/TLS/DNS/auth denial smokes.",
|
|
6538
|
+
"RDS TLS, backups/PITR, scoped roles, and migration dry-run evidence.",
|
|
6539
|
+
"S3 bucket KMS, versioning, lifecycle, and public-access-block evidence.",
|
|
6540
|
+
"Spark01 private-probe registration, key-file mode, heartbeat, and revocation evidence."
|
|
6541
|
+
],
|
|
6542
|
+
safety: {
|
|
6543
|
+
liveAwsMutation: false,
|
|
6544
|
+
plaintextSecrets: false,
|
|
6545
|
+
hostedLocalSqliteAllowed: false,
|
|
6546
|
+
notes: [
|
|
6547
|
+
"This plan generator does not call AWS.",
|
|
6548
|
+
"Hosted runtime must use Postgres; SQLite remains local/dev fallback only.",
|
|
6549
|
+
"Secrets are represented as secret names/refs and must be injected with valueFrom.",
|
|
6550
|
+
"Actual deploy belongs in the deploy_release_operate_final goal node after infra review."
|
|
6551
|
+
]
|
|
6552
|
+
}
|
|
6553
|
+
};
|
|
6554
|
+
}
|
|
6555
|
+
function buildSpark01CloudConfig(options = {}) {
|
|
6556
|
+
const apiUrl = clean(options.apiUrl, `https://${DEFAULT_HOSTNAME}/api/v1`);
|
|
6557
|
+
const workspaceId = clean(options.workspaceId, DEFAULT_WORKSPACE_ID);
|
|
6558
|
+
const machineId = clean(options.machineId, "spark01");
|
|
6559
|
+
const privateKeyFile = clean(options.probePrivateKeyFile, "~/.hasna/uptime/probes/spark01.key.pem");
|
|
6560
|
+
const probeId = options.probeId?.trim();
|
|
6561
|
+
const blockers = [
|
|
6562
|
+
...probeId ? [] : ["Cloud-registered private probe id is required before writing a sourceable env file."],
|
|
6563
|
+
"Hosted probe claim and submit routes still fail closed until cloud check_jobs and workspace stores are implemented.",
|
|
6564
|
+
"Spark01 enrollment, heartbeat, revocation, rotation, and bounded offline lease handling are not implemented yet."
|
|
6565
|
+
];
|
|
6566
|
+
const env3 = {
|
|
6567
|
+
HASNA_UPTIME_MODE: "hosted",
|
|
6568
|
+
HASNA_UPTIME_API_URL: apiUrl,
|
|
6569
|
+
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
6570
|
+
HASNA_UPTIME_MACHINE_ID: machineId,
|
|
6571
|
+
HASNA_UPTIME_PRIVATE_PROBE_KEY_FILE: privateKeyFile,
|
|
6572
|
+
HASNA_UPTIME_PROBE_CLASS: "private",
|
|
6573
|
+
HASNA_UPTIME_LOG_LEVEL: clean(options.logLevel, "info")
|
|
6574
|
+
};
|
|
6575
|
+
if (probeId)
|
|
6576
|
+
env3.HASNA_UPTIME_PRIVATE_PROBE_ID = probeId;
|
|
6577
|
+
return {
|
|
6578
|
+
kind: "open-uptime.spark01-cloud-config",
|
|
6579
|
+
version: 1,
|
|
6580
|
+
generatedAt: new Date().toISOString(),
|
|
6581
|
+
status: "blocked",
|
|
6582
|
+
canStart: false,
|
|
6583
|
+
machineId,
|
|
6584
|
+
mode: "private-probe",
|
|
6585
|
+
env: env3,
|
|
6586
|
+
files: [
|
|
6587
|
+
{
|
|
6588
|
+
path: privateKeyFile,
|
|
6589
|
+
mode: "0600",
|
|
6590
|
+
purpose: "Ed25519 private key generated on Spark01; never paste into cloud config."
|
|
6591
|
+
},
|
|
6592
|
+
{
|
|
6593
|
+
path: "~/.hasna/uptime/cloud.env",
|
|
6594
|
+
mode: "0600",
|
|
6595
|
+
purpose: "Non-secret cloud/probe runtime environment; token values stay in the machine secret store."
|
|
6596
|
+
}
|
|
6597
|
+
],
|
|
6598
|
+
commands: [
|
|
6599
|
+
"bun install -g @hasna/uptime@latest",
|
|
6600
|
+
"Generate the Spark01 private key locally and register only its public key with the hosted control plane once registration exists.",
|
|
6601
|
+
"Write ~/.hasna/uptime/cloud.env from this plan, then source it for the private probe service.",
|
|
6602
|
+
"Start the private probe worker only after hosted /api/v1 probe claim/submit routes are backed by cloud jobs."
|
|
6603
|
+
],
|
|
6604
|
+
blockers,
|
|
6605
|
+
safety: {
|
|
6606
|
+
privateKeyInline: false,
|
|
6607
|
+
tokenInline: false,
|
|
6608
|
+
notes: [
|
|
6609
|
+
"This config is cloud-primary: Spark01 submits to hosted API state instead of local SQLite.",
|
|
6610
|
+
"The private key file path is referenced, not embedded.",
|
|
6611
|
+
"Hosted token or probe auth material must come from the machine secret store, not this generated config."
|
|
6612
|
+
]
|
|
6613
|
+
}
|
|
6614
|
+
};
|
|
6615
|
+
}
|
|
6616
|
+
function renderSpark01Env(config) {
|
|
6617
|
+
const required = ["HASNA_UPTIME_PRIVATE_PROBE_ID"];
|
|
6618
|
+
const missing = required.filter((key) => !config.env[key]);
|
|
6619
|
+
if (missing.length > 0) {
|
|
6620
|
+
throw new Error(`Spark01 env output requires ${missing.join(", ")}`);
|
|
6621
|
+
}
|
|
6622
|
+
return Object.entries(config.env).map(([key, value]) => `${key}=${shellEscape(value)}`).join(`
|
|
6623
|
+
`);
|
|
6624
|
+
}
|
|
6625
|
+
function servicePlan(prefix, stage, role, desiredCount, image, workspaceId, secrets, environment) {
|
|
6626
|
+
const name = `${prefix}-${stage}-${role}`;
|
|
6627
|
+
return {
|
|
6628
|
+
name,
|
|
6629
|
+
role,
|
|
6630
|
+
desiredCount,
|
|
6631
|
+
taskRole: `${name}-task-role`,
|
|
6632
|
+
executionRole: `${prefix}-${stage}-execution-role`,
|
|
6633
|
+
logGroup: `/ecs/${name}`,
|
|
6634
|
+
healthCommand: role === "web" ? "GET /health" : undefined,
|
|
6635
|
+
environment: {
|
|
6636
|
+
HASNA_UPTIME_IMAGE: image,
|
|
6637
|
+
...environment
|
|
6638
|
+
},
|
|
6639
|
+
secrets: role === "public-probe" ? { DATABASE_URL: secrets.database, PROBE_CONFIG: secrets.publicProbe } : role === "reporter" ? { DATABASE_URL: secrets.database, REPORTING_CONFIG: secrets.reporting } : { DATABASE_URL: secrets.database, APP_ENV: secrets.appEnv }
|
|
6640
|
+
};
|
|
6641
|
+
}
|
|
6642
|
+
function clean(value, fallback) {
|
|
6643
|
+
const normalized = value?.trim();
|
|
6644
|
+
return normalized || fallback;
|
|
6645
|
+
}
|
|
6646
|
+
function shellEscape(value) {
|
|
6647
|
+
if (/^[A-Za-z0-9_./:@~-]+$/.test(value))
|
|
6648
|
+
return value;
|
|
6649
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
6650
|
+
}
|
|
6651
|
+
|
|
5768
6652
|
// src/cli/index.ts
|
|
5769
6653
|
var program2 = new Command;
|
|
5770
6654
|
program2.name("uptime").description("Local-first uptime and downtime monitoring").version(packageVersion()).option("-j, --json", "print JSON");
|
|
@@ -5985,6 +6869,132 @@ program2.command("report").description("Build or send an uptime report through M
|
|
|
5985
6869
|
fail(error);
|
|
5986
6870
|
}
|
|
5987
6871
|
});
|
|
6872
|
+
var reportSchedules = program2.command("report-schedules").alias("schedules").description("Manage scheduled uptime reports");
|
|
6873
|
+
reportSchedules.command("create <name>").description("Create a scheduled uptime report").requiredOption("--interval <seconds>", "report interval in seconds", parseInteger).option("--next-run-at <iso>", "first due timestamp", new Date().toISOString()).option("--subject <subject>", "report subject").option("--email <to>", "email recipients; Mailery send key is read from env at run time").option("--from <email>", "Mailery from address").option("--mailery-url <url>", "Mailery API URL").option("--sms <phone>", "SMS recipients").option("--sms-from <phone>", "Telephony from phone number").option("--telephony-url <url>", "Telephony API URL").option("--logs", "write scheduled report runs to Open Logs").option("--logs-url <url>", "Open Logs API URL").option("--logs-project <id>", "Open Logs project id").option("--disabled", "create the schedule disabled").option("-j, --json", "print JSON").action((name, opts) => {
|
|
6874
|
+
try {
|
|
6875
|
+
const svc = service();
|
|
6876
|
+
const schedule = svc.createReportSchedule({
|
|
6877
|
+
name,
|
|
6878
|
+
intervalSeconds: opts.interval,
|
|
6879
|
+
nextRunAt: opts.nextRunAt,
|
|
6880
|
+
enabled: opts.disabled ? false : true,
|
|
6881
|
+
subject: opts.subject,
|
|
6882
|
+
channels: buildReportScheduleChannels(opts)
|
|
6883
|
+
});
|
|
6884
|
+
svc.close();
|
|
6885
|
+
print(schedule, `Created report schedule ${schedule.name}`, opts);
|
|
6886
|
+
} catch (error) {
|
|
6887
|
+
fail(error);
|
|
6888
|
+
}
|
|
6889
|
+
});
|
|
6890
|
+
reportSchedules.command("list").description("List scheduled uptime reports").option("--all", "include disabled schedules").option("-j, --json", "print JSON").action((opts) => {
|
|
6891
|
+
try {
|
|
6892
|
+
const svc = service();
|
|
6893
|
+
const schedules = svc.listReportSchedules({ includeDisabled: opts.all });
|
|
6894
|
+
svc.close();
|
|
6895
|
+
print(schedules, renderReportSchedules(schedules), opts);
|
|
6896
|
+
} catch (error) {
|
|
6897
|
+
fail(error);
|
|
6898
|
+
}
|
|
6899
|
+
});
|
|
6900
|
+
reportSchedules.command("run <id-or-name>").description("Run one scheduled report now and record a run").option("-j, --json", "print JSON").action(async (idOrName, opts) => {
|
|
6901
|
+
try {
|
|
6902
|
+
const svc = service();
|
|
6903
|
+
const run = await svc.runReportSchedule(idOrName);
|
|
6904
|
+
svc.close();
|
|
6905
|
+
print(run, renderReportRuns([run]), opts);
|
|
6906
|
+
if (run.status === "failed")
|
|
6907
|
+
process.exit(1);
|
|
6908
|
+
} catch (error) {
|
|
6909
|
+
fail(error);
|
|
6910
|
+
}
|
|
6911
|
+
});
|
|
6912
|
+
reportSchedules.command("run-due").description("Run all due scheduled reports and record runs").option("--now <iso>", "due timestamp", new Date().toISOString()).option("-j, --json", "print JSON").action(async (opts) => {
|
|
6913
|
+
try {
|
|
6914
|
+
const svc = service();
|
|
6915
|
+
const runs = await svc.runDueReportSchedules(new Date(opts.now));
|
|
6916
|
+
svc.close();
|
|
6917
|
+
print(runs, renderReportRuns(runs), opts);
|
|
6918
|
+
if (runs.some((run) => run.status === "failed"))
|
|
6919
|
+
process.exit(1);
|
|
6920
|
+
} catch (error) {
|
|
6921
|
+
fail(error);
|
|
6922
|
+
}
|
|
6923
|
+
});
|
|
6924
|
+
reportSchedules.command("delete <id-or-name>").alias("rm").description("Delete a scheduled uptime report").option("-j, --json", "print JSON").action((idOrName, opts) => {
|
|
6925
|
+
try {
|
|
6926
|
+
const svc = service();
|
|
6927
|
+
const deleted = svc.deleteReportSchedule(idOrName);
|
|
6928
|
+
svc.close();
|
|
6929
|
+
print({ deleted }, deleted ? `Deleted report schedule ${idOrName}` : `Not found: ${idOrName}`, opts);
|
|
6930
|
+
} catch (error) {
|
|
6931
|
+
fail(error);
|
|
6932
|
+
}
|
|
6933
|
+
});
|
|
6934
|
+
reportSchedules.command("runs").description("List scheduled report runs").option("--schedule <id>", "filter by report schedule id").option("--limit <n>", "max rows", parseInteger, 20).option("-j, --json", "print JSON").action((opts) => {
|
|
6935
|
+
try {
|
|
6936
|
+
const svc = service();
|
|
6937
|
+
const runs = svc.listReportRuns({ scheduleId: opts.schedule, limit: opts.limit });
|
|
6938
|
+
svc.close();
|
|
6939
|
+
print(runs, renderReportRuns(runs), opts);
|
|
6940
|
+
} catch (error) {
|
|
6941
|
+
fail(error);
|
|
6942
|
+
}
|
|
6943
|
+
});
|
|
6944
|
+
program2.command("audit").description("List local audit events").option("--resource-type <type>", "filter by resource type").option("--resource-id <id>", "filter by resource id").option("--limit <n>", "max rows", parseInteger, 20).option("-j, --json", "print JSON").action((opts) => {
|
|
6945
|
+
try {
|
|
6946
|
+
const svc = service();
|
|
6947
|
+
const events = svc.listAuditEvents({
|
|
6948
|
+
resourceType: opts.resourceType,
|
|
6949
|
+
resourceId: opts.resourceId,
|
|
6950
|
+
limit: opts.limit
|
|
6951
|
+
});
|
|
6952
|
+
svc.close();
|
|
6953
|
+
print(events, events.length ? events.map((event) => `${event.createdAt} ${event.action} ${sanitizeField(event.resourceType ?? "-")} ${sanitizeField(event.resourceId ?? "-")} ${sanitizeField(event.message ?? "")}`).join(`
|
|
6954
|
+
`) : "No audit events", opts);
|
|
6955
|
+
} catch (error) {
|
|
6956
|
+
fail(error);
|
|
6957
|
+
}
|
|
6958
|
+
});
|
|
6959
|
+
var cloud = program2.command("cloud").description("Generate dry-run cloud deployment and Spark01 configuration artifacts");
|
|
6960
|
+
cloud.command("plan").description("Generate a dry-run AWS deployment plan for hasna-xyz-infra").option("--account <name>", "AWS account/profile label", "hasna-xyz-infra").option("--region <region>", "AWS region", "us-east-1").option("--stage <stage>", "deployment stage", "prod").option("--hostname <hostname>", "hosted Open Uptime hostname", "uptime.hasna.xyz").option("--workspace-id <id>", "workspace id", "wks_2tyysw05cwap").option("--vpc-id <id>", "target VPC id").option("--rds-instance-id <id>", "existing RDS instance id").option("--ecr-repository <name>", "ECR repository name").option("--image <uri>", "container image URI").option("--evidence-bucket <name>", "S3 evidence bucket name").option("-j, --json", "print JSON").action((opts) => {
|
|
6961
|
+
try {
|
|
6962
|
+
const plan = buildAwsDeploymentPlan({
|
|
6963
|
+
accountName: opts.account,
|
|
6964
|
+
region: opts.region,
|
|
6965
|
+
stage: opts.stage,
|
|
6966
|
+
hostname: opts.hostname,
|
|
6967
|
+
workspaceId: opts.workspaceId,
|
|
6968
|
+
vpcId: opts.vpcId,
|
|
6969
|
+
rdsInstanceId: opts.rdsInstanceId,
|
|
6970
|
+
ecrRepository: opts.ecrRepository,
|
|
6971
|
+
image: opts.image,
|
|
6972
|
+
evidenceBucket: opts.evidenceBucket
|
|
6973
|
+
});
|
|
6974
|
+
print(plan, renderCloudPlan(plan), opts);
|
|
6975
|
+
} catch (error) {
|
|
6976
|
+
fail(error);
|
|
6977
|
+
}
|
|
6978
|
+
});
|
|
6979
|
+
cloud.command("spark01-config").description("Generate Spark01 cloud-primary private probe configuration").option("--api-url <url>", "hosted Open Uptime API URL", "https://uptime.hasna.xyz/api/v1").option("--workspace-id <id>", "workspace id", "wks_2tyysw05cwap").option("--probe-id <id>", "cloud registered private probe id").option("--private-key-file <path>", "Spark01 private probe key file", "~/.hasna/uptime/probes/spark01.key.pem").option("--machine-id <id>", "machine id", "spark01").option("--log-level <level>", "probe log level", "info").option("--env", "print shell env file instead of summary text").option("-j, --json", "print JSON").action((opts) => {
|
|
6980
|
+
try {
|
|
6981
|
+
const config = buildSpark01CloudConfig({
|
|
6982
|
+
apiUrl: opts.apiUrl,
|
|
6983
|
+
workspaceId: opts.workspaceId,
|
|
6984
|
+
probeId: opts.probeId,
|
|
6985
|
+
probePrivateKeyFile: opts.privateKeyFile,
|
|
6986
|
+
machineId: opts.machineId,
|
|
6987
|
+
logLevel: opts.logLevel
|
|
6988
|
+
});
|
|
6989
|
+
if (opts.env && !wantsJson(opts)) {
|
|
6990
|
+
console.log(renderSpark01Env(config));
|
|
6991
|
+
return;
|
|
6992
|
+
}
|
|
6993
|
+
print(config, renderSpark01Config(config), opts);
|
|
6994
|
+
} catch (error) {
|
|
6995
|
+
fail(error);
|
|
6996
|
+
}
|
|
6997
|
+
});
|
|
5988
6998
|
program2.command("results").description("List recent check results").option("--monitor <id>", "filter by monitor id").option("--limit <n>", "max rows", parseInteger, 20).option("-j, --json", "print JSON").action((opts) => {
|
|
5989
6999
|
try {
|
|
5990
7000
|
const svc = service();
|
|
@@ -6293,6 +7303,82 @@ function renderSummary(summary) {
|
|
|
6293
7303
|
return lines.join(`
|
|
6294
7304
|
`);
|
|
6295
7305
|
}
|
|
7306
|
+
function buildReportScheduleChannels(opts) {
|
|
7307
|
+
const channels = {};
|
|
7308
|
+
if (opts.email) {
|
|
7309
|
+
channels.email = {
|
|
7310
|
+
apiUrl: opts.maileryUrl,
|
|
7311
|
+
from: opts.from,
|
|
7312
|
+
to: splitList(opts.email)
|
|
7313
|
+
};
|
|
7314
|
+
}
|
|
7315
|
+
if (opts.sms) {
|
|
7316
|
+
channels.sms = {
|
|
7317
|
+
apiUrl: opts.telephonyUrl,
|
|
7318
|
+
from: opts.smsFrom,
|
|
7319
|
+
to: splitList(opts.sms)
|
|
7320
|
+
};
|
|
7321
|
+
}
|
|
7322
|
+
if (opts.logs) {
|
|
7323
|
+
channels.logs = {
|
|
7324
|
+
apiUrl: opts.logsUrl,
|
|
7325
|
+
projectId: opts.logsProject
|
|
7326
|
+
};
|
|
7327
|
+
}
|
|
7328
|
+
return channels;
|
|
7329
|
+
}
|
|
7330
|
+
function renderReportSchedules(schedules) {
|
|
7331
|
+
if (schedules.length === 0)
|
|
7332
|
+
return "No report schedules";
|
|
7333
|
+
return schedules.map((schedule) => {
|
|
7334
|
+
const status = schedule.enabled ? "enabled " : "disabled";
|
|
7335
|
+
const channels = ["email", "sms", "logs"].filter((channel) => Boolean(schedule.channels[channel])).join(",");
|
|
7336
|
+
return `${status} ${schedule.id} ${sanitizeField(schedule.name).padEnd(24)} every ${schedule.intervalSeconds}s next ${schedule.nextRunAt} ${channels}`;
|
|
7337
|
+
}).join(`
|
|
7338
|
+
`);
|
|
7339
|
+
}
|
|
7340
|
+
function renderReportRuns(runs) {
|
|
7341
|
+
if (runs.length === 0)
|
|
7342
|
+
return "No report runs";
|
|
7343
|
+
return runs.map((run) => {
|
|
7344
|
+
const status = run.status === "success" ? source_default.green("success") : source_default.red("failed");
|
|
7345
|
+
const deliveries = run.deliveries.map((delivery) => `${delivery.channel}:${delivery.ok ? "ok" : "failed"}`).join(",");
|
|
7346
|
+
return `${status.padEnd(12)} ${run.id} ${run.scheduleId ?? "-"} ${run.finishedAt} ${deliveries}${run.error ? ` ${sanitizeField(run.error)}` : ""}`;
|
|
7347
|
+
}).join(`
|
|
7348
|
+
`);
|
|
7349
|
+
}
|
|
7350
|
+
function renderCloudPlan(plan) {
|
|
7351
|
+
return [
|
|
7352
|
+
`${plan.servicePrefix} ${plan.stage} AWS plan (${plan.accountName}/${plan.region})`,
|
|
7353
|
+
`status: ${plan.status}`,
|
|
7354
|
+
`can apply: ${plan.canApply}`,
|
|
7355
|
+
`host: ${plan.hostname}`,
|
|
7356
|
+
`cluster: ${plan.resources.ecsCluster}`,
|
|
7357
|
+
`image: ${plan.image.uri}`,
|
|
7358
|
+
`vpc: ${plan.resources.vpcId}`,
|
|
7359
|
+
`rds: ${plan.resources.rdsInstanceId}`,
|
|
7360
|
+
`services: ${plan.resources.services.map((service2) => `${service2.name}:${service2.desiredCount}`).join(", ")}`,
|
|
7361
|
+
`evidence bucket: ${plan.resources.evidenceBucket}`,
|
|
7362
|
+
`blockers: ${plan.blockers.length}`,
|
|
7363
|
+
"live AWS mutation: false"
|
|
7364
|
+
].join(`
|
|
7365
|
+
`);
|
|
7366
|
+
}
|
|
7367
|
+
function renderSpark01Config(config) {
|
|
7368
|
+
return [
|
|
7369
|
+
`${config.machineId} ${config.mode} config`,
|
|
7370
|
+
`status: ${config.status}`,
|
|
7371
|
+
`can start: ${config.canStart}`,
|
|
7372
|
+
`api: ${config.env.HASNA_UPTIME_API_URL}`,
|
|
7373
|
+
`workspace: ${config.env.HASNA_UPTIME_WORKSPACE_ID}`,
|
|
7374
|
+
`probe: ${config.env.HASNA_UPTIME_PRIVATE_PROBE_ID ?? "<required>"}`,
|
|
7375
|
+
`key file: ${config.env.HASNA_UPTIME_PRIVATE_PROBE_KEY_FILE}`,
|
|
7376
|
+
`blockers: ${config.blockers.length}`,
|
|
7377
|
+
"private key inline: false",
|
|
7378
|
+
"token inline: false"
|
|
7379
|
+
].join(`
|
|
7380
|
+
`);
|
|
7381
|
+
}
|
|
6296
7382
|
function renderDeliveries(deliveries) {
|
|
6297
7383
|
if (deliveries.length === 0)
|
|
6298
7384
|
return "No report deliveries requested";
|