@celilo/cli 0.13.3 → 0.14.1
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/CELILO_CORE_MODULES.md +3 -0
- package/CELILO_SUBSYSTEMS.md +71 -2
- package/docs/ALERTING.md +298 -0
- package/docs/INDEX.md +103 -0
- package/drizzle/0016_trusted_sources.sql +10 -0
- package/drizzle/0017_alerting.sql +127 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -2
- package/schemas/system_config.json +9 -0
- package/src/capabilities/well-known.ts +11 -1
- package/src/cli/commands/alerts-act.ts +107 -0
- package/src/cli/commands/alerts-list.ts +62 -0
- package/src/cli/commands/alerts-poll.ts +129 -0
- package/src/cli/commands/alerts-sweep.ts +156 -0
- package/src/cli/commands/module-list.ts +50 -3
- package/src/cli/commands/module-show.ts +11 -3
- package/src/cli/commands/monitor.ts +178 -0
- package/src/cli/commands/notify-config.ts +453 -0
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-update.ts +1 -0
- package/src/cli/completion.ts +26 -0
- package/src/cli/generate-zsh-completion.ts +2 -0
- package/src/cli/index.ts +58 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/schema.ts +371 -2
- package/src/hooks/capability-loader.ts +158 -46
- package/src/hooks/capability-map-coverage.test.ts +101 -0
- package/src/manifest/schema.ts +77 -4
- package/src/services/alerting/ack.test.ts +212 -0
- package/src/services/alerting/ack.ts +119 -0
- package/src/services/alerting/builtin-monitors.test.ts +132 -0
- package/src/services/alerting/builtin-monitors.ts +84 -0
- package/src/services/alerting/builtin-source.ts +82 -0
- package/src/services/alerting/coverage-source.ts +38 -0
- package/src/services/alerting/deferral.test.ts +161 -0
- package/src/services/alerting/delivery-loop.test.ts +396 -0
- package/src/services/alerting/deploy-hooks.test.ts +125 -0
- package/src/services/alerting/deploy-hooks.ts +111 -0
- package/src/services/alerting/escalation.test.ts +207 -0
- package/src/services/alerting/escalation.ts +151 -0
- package/src/services/alerting/format.test.ts +193 -0
- package/src/services/alerting/format.ts +150 -0
- package/src/services/alerting/health-coverage.ts +81 -0
- package/src/services/alerting/inbound-poller.test.ts +298 -0
- package/src/services/alerting/inbound-poller.ts +236 -0
- package/src/services/alerting/inbound.test.ts +201 -0
- package/src/services/alerting/inbound.ts +112 -0
- package/src/services/alerting/interview-responder.test.ts +169 -0
- package/src/services/alerting/interview-responder.ts +158 -0
- package/src/services/alerting/keys.test.ts +155 -0
- package/src/services/alerting/keys.ts +190 -0
- package/src/services/alerting/monitors.ts +185 -0
- package/src/services/alerting/notification-responder.test.ts +290 -0
- package/src/services/alerting/notification-responder.ts +260 -0
- package/src/services/alerting/notifier.ts +219 -0
- package/src/services/alerting/people.ts +178 -0
- package/src/services/alerting/quiet-hours.test.ts +140 -0
- package/src/services/alerting/quiet-hours.ts +99 -0
- package/src/services/alerting/reconcile.test.ts +190 -0
- package/src/services/alerting/reconcile.ts +166 -0
- package/src/services/alerting/run-monitor.test.ts +185 -0
- package/src/services/alerting/run-monitor.ts +177 -0
- package/src/services/alerting/store.test.ts +222 -0
- package/src/services/alerting/store.ts +289 -0
- package/src/services/alerting/suppression.test.ts +228 -0
- package/src/services/alerting/suppression.ts +142 -0
- package/src/services/alerting/sweep-runner.test.ts +229 -0
- package/src/services/alerting/sweep-runner.ts +204 -0
- package/src/services/alerting/sweep.test.ts +61 -0
- package/src/services/alerting/sweep.ts +41 -0
- package/src/services/alerting/tokens.test.ts +152 -0
- package/src/services/alerting/tokens.ts +119 -0
- package/src/services/alerting/transport-loader.ts +48 -0
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/trusted-sources.test.ts +137 -0
- package/src/services/audit/trusted-sources.ts +124 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/firewall-reach.ts +83 -0
- package/src/services/health-runner.test.ts +50 -0
- package/src/services/health-runner.ts +116 -82
- package/src/services/machine-pool.ts +2 -1
- package/src/services/module-deploy.ts +17 -0
- package/src/services/system-config-validator.test.ts +31 -1
- package/src/services/trusted-sources.test.ts +221 -0
- package/src/services/trusted-sources.ts +159 -0
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/templates/generator.ts +6 -29
package/src/db/schema.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm';
|
|
2
2
|
import {
|
|
3
|
+
index,
|
|
3
4
|
integer,
|
|
4
5
|
primaryKey,
|
|
5
6
|
sqliteTable,
|
|
@@ -197,7 +198,7 @@ export const ipAllocations = sqliteTable('ip_allocations', {
|
|
|
197
198
|
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
198
199
|
vmid: integer('vmid').notNull().unique(),
|
|
199
200
|
containerIp: text('container_ip').notNull().unique(), // CIDR format (e.g., "10.0.10.10/24")
|
|
200
|
-
zone: text('zone').$type<
|
|
201
|
+
zone: text('zone').$type<AllocatableZone>().notNull(),
|
|
201
202
|
allocatedAt: integer('allocated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
202
203
|
});
|
|
203
204
|
|
|
@@ -210,7 +211,7 @@ export const ipReservations = sqliteTable('ip_reservations', {
|
|
|
210
211
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
211
212
|
ipStart: text('ip_start').notNull(), // Single IP or range start
|
|
212
213
|
ipEnd: text('ip_end'), // NULL for single IP, end IP for range
|
|
213
|
-
zone: text('zone').$type<
|
|
214
|
+
zone: text('zone').$type<AllocatableZone>().notNull(),
|
|
214
215
|
reason: text('reason').notNull(),
|
|
215
216
|
reservedAt: integer('reserved_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
216
217
|
});
|
|
@@ -274,6 +275,17 @@ export const NETWORK_ZONES = [
|
|
|
274
275
|
*/
|
|
275
276
|
export type NetworkZone = (typeof NETWORK_ZONES)[number];
|
|
276
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Zones an IP allocation or reservation can name: every NetworkZone except
|
|
280
|
+
* `external`, whose systems are addressed by the provider, not by our IPAM.
|
|
281
|
+
*
|
|
282
|
+
* Derived rather than hand-written for the same reason as NetworkZone above —
|
|
283
|
+
* the previous hand-written union was copied into two column definitions and a
|
|
284
|
+
* cast in machine-pool.ts, and the cast had already drifted (it was missing
|
|
285
|
+
* `secure-mgmt`, and its comment claimed the only difference was `external`).
|
|
286
|
+
*/
|
|
287
|
+
export type AllocatableZone = Exclude<NetworkZone, 'external'>;
|
|
288
|
+
|
|
277
289
|
/**
|
|
278
290
|
* Container services table
|
|
279
291
|
* Stores container service providers (Proxmox, Digital Ocean, etc.)
|
|
@@ -503,6 +515,39 @@ export const portForwards = sqliteTable(
|
|
|
503
515
|
}),
|
|
504
516
|
);
|
|
505
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Trusted-source registry — the desired-state store for "this subnet may reach
|
|
520
|
+
* every managed zone", the sibling of `port_forwards` and for the same reason: a
|
|
521
|
+
* rule that lives only in the applied ruleset belongs to no module, so the next
|
|
522
|
+
* converge correctly removes it. The admin VPN sat in exactly that position and
|
|
523
|
+
* needed a shell script re-adding its rules every second.
|
|
524
|
+
*
|
|
525
|
+
* Distinct from a port forward: that publishes one backend on specific ports;
|
|
526
|
+
* this is a whole origin subnet permitted to initiate into the segmented tiers.
|
|
527
|
+
*/
|
|
528
|
+
export const trustedSources = sqliteTable(
|
|
529
|
+
'trusted_sources',
|
|
530
|
+
{
|
|
531
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
532
|
+
/** The firewall host that renders this trust (config.firewallIp). */
|
|
533
|
+
firewallIp: text('firewall_ip').notNull(),
|
|
534
|
+
/** Subnet CIDR permitted to reach every managed zone. */
|
|
535
|
+
subnet: text('subnet').notNull(),
|
|
536
|
+
description: text('description').notNull().default(''),
|
|
537
|
+
/** Module that registered it — reach into every tier must never be anonymous. */
|
|
538
|
+
registeredBy: text('registered_by').notNull(),
|
|
539
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
540
|
+
},
|
|
541
|
+
(table) => ({
|
|
542
|
+
// One row per (firewall, subnet) — the store delete-then-inserts on this
|
|
543
|
+
// tuple so re-registering the same subnet is an idempotent upsert.
|
|
544
|
+
trustedSourceUnique: uniqueIndex('trusted_sources_unique_idx').on(
|
|
545
|
+
table.firewallIp,
|
|
546
|
+
table.subnet,
|
|
547
|
+
),
|
|
548
|
+
}),
|
|
549
|
+
);
|
|
550
|
+
|
|
506
551
|
/**
|
|
507
552
|
* DNS registration ledger — one row per (provider, fqdn) the framework
|
|
508
553
|
* has successfully registered via dns_registrar.registerHost. Written
|
|
@@ -742,6 +787,312 @@ export const apiPrincipals = sqliteTable('api_principals', {
|
|
|
742
787
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
743
788
|
});
|
|
744
789
|
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// Alerting (openspec/changes/add-alerting)
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Alert severity. Only `critical` pages.
|
|
796
|
+
*
|
|
797
|
+
* A check item's `warn` status always yields `warning`; a `fail` yields the
|
|
798
|
+
* monitor's configured severity. A monitor deliberately configured as
|
|
799
|
+
* `warning` therefore never pages even when its checks fail — the operator's
|
|
800
|
+
* "record this but don't wake me" knob. See design D6.
|
|
801
|
+
*/
|
|
802
|
+
export type AlertSeverity = 'warning' | 'critical';
|
|
803
|
+
|
|
804
|
+
/** What a monitor runs. `module_hook` = a module's health_check hook. */
|
|
805
|
+
export type MonitorKind = 'module_hook' | 'builtin_check';
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Whether a monitor run executed at all. The load-bearing distinction: a run
|
|
809
|
+
* that could not execute produces an empty failing set for reasons that say
|
|
810
|
+
* nothing about the underlying checks, so it must never resolve anything.
|
|
811
|
+
* See design D5.
|
|
812
|
+
*/
|
|
813
|
+
export type MonitorRunOutcome = 'success' | 'error';
|
|
814
|
+
|
|
815
|
+
export type AlertState = 'pending' | 'firing' | 'acked' | 'suppressed' | 'resolved';
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* People celilo can reach. Deliberately independent of any transport or
|
|
819
|
+
* module — a person exists before any notification module is deployed.
|
|
820
|
+
*/
|
|
821
|
+
export const people = sqliteTable('people', {
|
|
822
|
+
id: text('id').primaryKey(), // UUID
|
|
823
|
+
/** User-facing kebab-case name (e.g. "peter"). Never a UUID in CLI output. */
|
|
824
|
+
name: text('name').notNull().unique(),
|
|
825
|
+
/** IANA timezone (e.g. "America/Los_Angeles") — quiet hours are local to it. */
|
|
826
|
+
timezone: text('timezone').notNull(),
|
|
827
|
+
/** Quiet-hours window as local "HH:MM"; both null = always reachable. */
|
|
828
|
+
quietHoursStart: text('quiet_hours_start'),
|
|
829
|
+
quietHoursEnd: text('quiet_hours_end'),
|
|
830
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
831
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* A person's address on a transport, plus the policy for using it.
|
|
836
|
+
*
|
|
837
|
+
* Addresses live HERE, never in the transport module's config — otherwise
|
|
838
|
+
* adding a recipient would require redeploying the module. signal-cli holds
|
|
839
|
+
* one credential (its own registration); recipients are addresses, not
|
|
840
|
+
* credentials. See design D8.
|
|
841
|
+
*/
|
|
842
|
+
export const routes = sqliteTable(
|
|
843
|
+
'routes',
|
|
844
|
+
{
|
|
845
|
+
id: text('id').primaryKey(), // UUID
|
|
846
|
+
personId: text('person_id')
|
|
847
|
+
.notNull()
|
|
848
|
+
.references(() => people.id, { onDelete: 'cascade' }),
|
|
849
|
+
/** Module providing the `notification` capability. */
|
|
850
|
+
transportModuleId: text('transport_module_id')
|
|
851
|
+
.notNull()
|
|
852
|
+
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
853
|
+
/** Transport-specific address (phone number, email, topic). */
|
|
854
|
+
address: text('address').notNull(),
|
|
855
|
+
/** Alerts below this severity are not delivered here. */
|
|
856
|
+
severityFloor: text('severity_floor').$type<AlertSeverity>().notNull().default('warning'),
|
|
857
|
+
/**
|
|
858
|
+
* Whether replies can arrive over this route — set from whether the
|
|
859
|
+
* transport implements `receive`. A route that cannot ack never stops
|
|
860
|
+
* escalation (spec: *Unidirectional transport cannot acknowledge*).
|
|
861
|
+
*/
|
|
862
|
+
canAck: integer('can_ack', { mode: 'boolean' }).notNull().default(false),
|
|
863
|
+
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
|
864
|
+
verifiedAt: integer('verified_at', { mode: 'timestamp' }),
|
|
865
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
866
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
867
|
+
},
|
|
868
|
+
(table) => ({
|
|
869
|
+
uniqueAddress: unique().on(table.personId, table.transportModuleId, table.address),
|
|
870
|
+
}),
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Named, reusable escalation policy. Steps reference ROUTES rather than
|
|
875
|
+
* people — "page Peter" is ambiguous about which transport to use.
|
|
876
|
+
*/
|
|
877
|
+
export const escalationPolicies = sqliteTable('escalation_policies', {
|
|
878
|
+
id: text('id').primaryKey(), // UUID
|
|
879
|
+
/** User-facing kebab-case name (e.g. "default", "critical"). */
|
|
880
|
+
name: text('name').notNull().unique(),
|
|
881
|
+
/**
|
|
882
|
+
* Reserved escape hatch (design D13): quiet hours otherwise defer ALL
|
|
883
|
+
* severities. Unused in MVP — the column exists so enabling it later is not
|
|
884
|
+
* a migration.
|
|
885
|
+
*/
|
|
886
|
+
bypassQuietHours: integer('bypass_quiet_hours', { mode: 'boolean' }).notNull().default(false),
|
|
887
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
888
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
/** One ordered step of an escalation policy. Delay is from escalation start. */
|
|
892
|
+
export const escalationSteps = sqliteTable(
|
|
893
|
+
'escalation_steps',
|
|
894
|
+
{
|
|
895
|
+
policyId: text('policy_id')
|
|
896
|
+
.notNull()
|
|
897
|
+
.references(() => escalationPolicies.id, { onDelete: 'cascade' }),
|
|
898
|
+
/** 0-based order within the policy. */
|
|
899
|
+
stepIndex: integer('step_index').notNull(),
|
|
900
|
+
routeId: text('route_id')
|
|
901
|
+
.notNull()
|
|
902
|
+
.references(() => routes.id, { onDelete: 'cascade' }),
|
|
903
|
+
/** Minutes after escalation begins. Step 0 is normally 0. */
|
|
904
|
+
delayMinutes: integer('delay_minutes').notNull(),
|
|
905
|
+
},
|
|
906
|
+
(table) => ({
|
|
907
|
+
pk: primaryKey({ columns: [table.policyId, table.stepIndex] }),
|
|
908
|
+
}),
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* What gets checked, how often, and how failures are routed.
|
|
913
|
+
*
|
|
914
|
+
* The module manifest's `hooks.health_check.interval` is only a SUGGESTION;
|
|
915
|
+
* this row is the effective schedule and an operator edit survives module
|
|
916
|
+
* upgrades. See design D3.
|
|
917
|
+
*/
|
|
918
|
+
export const monitors = sqliteTable(
|
|
919
|
+
'monitors',
|
|
920
|
+
{
|
|
921
|
+
id: text('id').primaryKey(), // UUID
|
|
922
|
+
kind: text('kind').$type<MonitorKind>().notNull(),
|
|
923
|
+
/** Module id for `module_hook`; audit check name for `builtin_check`. */
|
|
924
|
+
target: text('target').notNull(),
|
|
925
|
+
intervalMinutes: integer('interval_minutes').notNull(),
|
|
926
|
+
/** Severity applied to `fail` items. Only `critical` pages. */
|
|
927
|
+
severity: text('severity').$type<AlertSeverity>().notNull().default('critical'),
|
|
928
|
+
/**
|
|
929
|
+
* False for monitors watching the alerting system itself — a cascading
|
|
930
|
+
* failure must not silence the component reporting it (design S4).
|
|
931
|
+
*/
|
|
932
|
+
suppressible: integer('suppressible', { mode: 'boolean' }).notNull().default(true),
|
|
933
|
+
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
|
934
|
+
escalationPolicyId: text('escalation_policy_id').references(() => escalationPolicies.id, {
|
|
935
|
+
onDelete: 'set null',
|
|
936
|
+
}),
|
|
937
|
+
lastRunAt: integer('last_run_at', { mode: 'timestamp' }),
|
|
938
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
939
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
940
|
+
},
|
|
941
|
+
(table) => ({
|
|
942
|
+
uniqueTarget: unique().on(table.kind, table.target),
|
|
943
|
+
}),
|
|
944
|
+
);
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Record of each monitor execution. Exists so reconciliation can branch on
|
|
948
|
+
* whether the last run actually ran (design D5) — without a persisted
|
|
949
|
+
* outcome there is nothing to distinguish "found nothing wrong" from
|
|
950
|
+
* "couldn't look".
|
|
951
|
+
*/
|
|
952
|
+
export const monitorRuns = sqliteTable('monitor_runs', {
|
|
953
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
954
|
+
monitorId: text('monitor_id')
|
|
955
|
+
.notNull()
|
|
956
|
+
.references(() => monitors.id, { onDelete: 'cascade' }),
|
|
957
|
+
ranAt: integer('ran_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
958
|
+
outcome: text('outcome').$type<MonitorRunOutcome>().notNull(),
|
|
959
|
+
/** Populated when outcome is `error` (ssh failure, timeout, hook threw). */
|
|
960
|
+
errorMessage: text('error_message'),
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* A deliberate, time-boxed suppression source. Today only deploys create
|
|
965
|
+
* these — a deploy is the same suppression mechanism as a machine-down alert,
|
|
966
|
+
* with a window as the source instead of an ancestor alert (design D7).
|
|
967
|
+
*/
|
|
968
|
+
export const suppressionWindows = sqliteTable('suppression_windows', {
|
|
969
|
+
id: text('id').primaryKey(), // UUID
|
|
970
|
+
source: text('source').$type<'deploy'>().notNull(),
|
|
971
|
+
scopeModuleId: text('scope_module_id')
|
|
972
|
+
.notNull()
|
|
973
|
+
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
974
|
+
startedAt: integer('started_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
975
|
+
/** Null while open. Set on deploy completion, including on failure. */
|
|
976
|
+
endsAt: integer('ends_at', { mode: 'timestamp' }),
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* The alert lifecycle. One row per alert key per firing episode: a resolve
|
|
981
|
+
* followed by a re-fire mints a new row, so history is preserved.
|
|
982
|
+
*
|
|
983
|
+
* `acked`, `suppressed`, and `silenced` are deliberately THREE separate
|
|
984
|
+
* concerns (design S5) — collapsing any two is how alerting systems become
|
|
985
|
+
* untrustworthy.
|
|
986
|
+
*/
|
|
987
|
+
export const alerts = sqliteTable(
|
|
988
|
+
'alerts',
|
|
989
|
+
{
|
|
990
|
+
id: text('id').primaryKey(), // UUID
|
|
991
|
+
/** Stable key, e.g. `module:caddy/check:cert-validity`. See design D4. */
|
|
992
|
+
key: text('key').notNull(),
|
|
993
|
+
/**
|
|
994
|
+
* Mirrors `key` while the alert is live; set to NULL on resolve. Backs the
|
|
995
|
+
* "one live alert per key" unique index below — SQLite treats NULLs in a
|
|
996
|
+
* unique index as distinct, so any number of resolved rows may share a key
|
|
997
|
+
* while at most one live row may hold it. Written only by the
|
|
998
|
+
* reconciliation code that sets `state`; the two must move together.
|
|
999
|
+
*/
|
|
1000
|
+
activeKey: text('active_key'),
|
|
1001
|
+
monitorId: text('monitor_id')
|
|
1002
|
+
.notNull()
|
|
1003
|
+
.references(() => monitors.id, { onDelete: 'cascade' }),
|
|
1004
|
+
state: text('state').$type<AlertState>().notNull().default('pending'),
|
|
1005
|
+
severity: text('severity').$type<AlertSeverity>().notNull(),
|
|
1006
|
+
firstFiredAt: integer('first_fired_at', { mode: 'timestamp' })
|
|
1007
|
+
.notNull()
|
|
1008
|
+
.default(sql`(unixepoch())`),
|
|
1009
|
+
lastSeenAt: integer('last_seen_at', { mode: 'timestamp' })
|
|
1010
|
+
.notNull()
|
|
1011
|
+
.default(sql`(unixepoch())`),
|
|
1012
|
+
/**
|
|
1013
|
+
* Notification is withheld until this instant, so an ancestor firing
|
|
1014
|
+
* moments later can establish suppression first, and so a failure that
|
|
1015
|
+
* clears immediately never pages (design S1).
|
|
1016
|
+
*/
|
|
1017
|
+
graceUntil: integer('grace_until', { mode: 'timestamp' }).notNull(),
|
|
1018
|
+
/** Set when an ancestor ALERT is suppressing this one. */
|
|
1019
|
+
suppressedByAlertId: text('suppressed_by_alert_id'),
|
|
1020
|
+
/** Set when a suppression WINDOW (e.g. a deploy) is suppressing this one. */
|
|
1021
|
+
suppressedByWindowId: text('suppressed_by_window_id').references(() => suppressionWindows.id, {
|
|
1022
|
+
onDelete: 'set null',
|
|
1023
|
+
}),
|
|
1024
|
+
/** Escalation clock origin after suppression lifts — NOT firstFiredAt (S3). */
|
|
1025
|
+
unsuppressedAt: integer('unsuppressed_at', { mode: 'timestamp' }),
|
|
1026
|
+
/**
|
|
1027
|
+
* True between un-suppression and the next SUCCESSFUL run. While set, the
|
|
1028
|
+
* alert does not notify: if it recovered along with its ancestor it
|
|
1029
|
+
* resolves quietly instead of paging (design S2).
|
|
1030
|
+
*/
|
|
1031
|
+
awaitingConfirmation: integer('awaiting_confirmation', { mode: 'boolean' })
|
|
1032
|
+
.notNull()
|
|
1033
|
+
.default(false),
|
|
1034
|
+
ackedBy: text('acked_by').references(() => people.id, { onDelete: 'set null' }),
|
|
1035
|
+
ackedAt: integer('acked_at', { mode: 'timestamp' }),
|
|
1036
|
+
/** Deliberate operator silence — distinct from suppression (S5). */
|
|
1037
|
+
silencedUntil: integer('silenced_until', { mode: 'timestamp' }),
|
|
1038
|
+
escalationStep: integer('escalation_step').notNull().default(0),
|
|
1039
|
+
nextEscalationAt: integer('next_escalation_at', { mode: 'timestamp' }),
|
|
1040
|
+
/**
|
|
1041
|
+
* Quiet hours defer DELIVERY, never the escalation clock (D13). When a step
|
|
1042
|
+
* falls due inside someone's window the step is still taken — the step
|
|
1043
|
+
* index advances and the next one is scheduled — and only the message
|
|
1044
|
+
* waits, held here until the window ends.
|
|
1045
|
+
*/
|
|
1046
|
+
deferredUntil: integer('deferred_until', { mode: 'timestamp' }),
|
|
1047
|
+
/** Route the deferred message is owed to. */
|
|
1048
|
+
deferredRouteId: text('deferred_route_id').references(() => routes.id, {
|
|
1049
|
+
onDelete: 'set null',
|
|
1050
|
+
}),
|
|
1051
|
+
escalationPolicyId: text('escalation_policy_id').references(() => escalationPolicies.id, {
|
|
1052
|
+
onDelete: 'set null',
|
|
1053
|
+
}),
|
|
1054
|
+
message: text('message').notNull(),
|
|
1055
|
+
details: text('details'),
|
|
1056
|
+
resolvedAt: integer('resolved_at', { mode: 'timestamp' }),
|
|
1057
|
+
},
|
|
1058
|
+
(table) => ({
|
|
1059
|
+
/**
|
|
1060
|
+
* One live alert per key. Resolved rows are exempt so history accumulates:
|
|
1061
|
+
* `activeKey` mirrors `key` while live and is NULL once resolved, and
|
|
1062
|
+
* SQLite treats NULLs in a unique index as distinct.
|
|
1063
|
+
*/
|
|
1064
|
+
liveKey: uniqueIndex('alerts_live_key_idx').on(table.activeKey),
|
|
1065
|
+
keyLookup: index('alerts_key_idx').on(table.key),
|
|
1066
|
+
}),
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* One outbound message, and the token that authorises a reply to it.
|
|
1071
|
+
*
|
|
1072
|
+
* Per DELIVERY, not per alert: the token identifies WHO replied, which is
|
|
1073
|
+
* what "an ack from the secondary is broadcast to everyone paged" needs, and
|
|
1074
|
+
* doubles as the audit trail (design D10).
|
|
1075
|
+
*/
|
|
1076
|
+
export const notificationDeliveries = sqliteTable('notification_deliveries', {
|
|
1077
|
+
id: text('id').primaryKey(), // UUID
|
|
1078
|
+
/** Short operator-typable token, unique across live deliveries. */
|
|
1079
|
+
token: text('token').notNull().unique(),
|
|
1080
|
+
kind: text('kind').$type<'alert' | 'interview'>().notNull(),
|
|
1081
|
+
/**
|
|
1082
|
+
* `alerts.id` when kind is `alert`; a BUS event id when kind is
|
|
1083
|
+
* `interview`. Deliberately not a foreign key — the event bus is a separate
|
|
1084
|
+
* SQLite database, so no FK can span it.
|
|
1085
|
+
*/
|
|
1086
|
+
targetId: text('target_id').notNull(),
|
|
1087
|
+
routeId: text('route_id')
|
|
1088
|
+
.notNull()
|
|
1089
|
+
.references(() => routes.id, { onDelete: 'cascade' }),
|
|
1090
|
+
sentAt: integer('sent_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
1091
|
+
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
|
|
1092
|
+
/** Set when a reply consumed this token. */
|
|
1093
|
+
consumedAt: integer('consumed_at', { mode: 'timestamp' }),
|
|
1094
|
+
});
|
|
1095
|
+
|
|
745
1096
|
/**
|
|
746
1097
|
* Type exports for use in application code
|
|
747
1098
|
*/
|
|
@@ -785,3 +1136,21 @@ export type AspectApproval = typeof aspectApprovals.$inferSelect;
|
|
|
785
1136
|
export type NewAspectApproval = typeof aspectApprovals.$inferInsert;
|
|
786
1137
|
export type ApiPrincipal = typeof apiPrincipals.$inferSelect;
|
|
787
1138
|
export type NewApiPrincipal = typeof apiPrincipals.$inferInsert;
|
|
1139
|
+
export type Person = typeof people.$inferSelect;
|
|
1140
|
+
export type NewPerson = typeof people.$inferInsert;
|
|
1141
|
+
export type Route = typeof routes.$inferSelect;
|
|
1142
|
+
export type NewRoute = typeof routes.$inferInsert;
|
|
1143
|
+
export type EscalationPolicy = typeof escalationPolicies.$inferSelect;
|
|
1144
|
+
export type NewEscalationPolicy = typeof escalationPolicies.$inferInsert;
|
|
1145
|
+
export type EscalationStep = typeof escalationSteps.$inferSelect;
|
|
1146
|
+
export type NewEscalationStep = typeof escalationSteps.$inferInsert;
|
|
1147
|
+
export type Monitor = typeof monitors.$inferSelect;
|
|
1148
|
+
export type NewMonitor = typeof monitors.$inferInsert;
|
|
1149
|
+
export type MonitorRun = typeof monitorRuns.$inferSelect;
|
|
1150
|
+
export type NewMonitorRun = typeof monitorRuns.$inferInsert;
|
|
1151
|
+
export type SuppressionWindow = typeof suppressionWindows.$inferSelect;
|
|
1152
|
+
export type NewSuppressionWindow = typeof suppressionWindows.$inferInsert;
|
|
1153
|
+
export type Alert = typeof alerts.$inferSelect;
|
|
1154
|
+
export type NewAlert = typeof alerts.$inferInsert;
|
|
1155
|
+
export type NotificationDelivery = typeof notificationDeliveries.$inferSelect;
|
|
1156
|
+
export type NewNotificationDelivery = typeof notificationDeliveries.$inferInsert;
|