feat(automations): add per-host maintenance mode and recurring schedules (#1521)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:51:51 -05:00
1 parent fd91d4925a
commit 99cffc9ba6
26 files changed
+1813 -6

No files matched your search

@@ -0,0 +1,47 @@
import { expect, it, vi } from "vitest";
import { createScheduler } from "../../src/backend/scheduler.js";
it("discards sustained breaches during maintenance but still runs scheduled work", async () => {
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
const clearBreach = vi.fn(async () => {});
const repository = {
listDueSchedules: async () => [{ automationId: 2, intervalSeconds: 60 }],
markScheduleTicked: vi.fn(),
listOpenBreaches: async () => [
{
automationId: 1,
stateKey: "7:cpu.percent",
breachStartedAt: "2000-01-01T00:00:00Z",
},
],
findById: async () => ({
id: 1,
userId: "alice",
enabled: true,
definition: JSON.stringify({
trigger: { kind: "metric_threshold", forSeconds: 60 },
}),
}),
clearBreach,
failStaleRunningRuns: vi.fn(),
pruneRunsOlderThan: async () => 0,
};
const maintaining = vi.fn(async () => true);
const scheduler = createScheduler({
repository: repository as never,
engine: { run },
log: { debug() {}, info() {}, warn() {}, error() {} },
reconcile: [],
isMaintaining: maintaining,
});
await scheduler.tick();
expect(maintaining).toHaveBeenCalledWith("alice", 7);
expect(clearBreach).toHaveBeenCalledWith(1, "7:cpu.percent");
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
automationId: 2,
triggerType: "schedule",
}),
);
});
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import {
advanceMaintenance,
beginMaintenance,
nextOccurrence,
parsePlan,
plansOverlap,
} from "../../src/backend/maintenance-time.js";
import {
emptyMaintenance,
type MaintenancePlan,
} from "../../src/maintenance.js";
const at = (value: string) => Date.parse(value);
const plan = (overrides: Partial<MaintenancePlan> = {}): MaintenancePlan => ({
id: "one",
reason: "Upgrade",
start: "2026-01-31T21:00:00.000Z",
nextStart: "2026-01-31T21:00:00.000Z",
durationMinutes: 60,
recurrence: "once",
graceMinutes: 20,
notifyOverdue: true,
...overrides,
});
describe("maintenance calendar", () => {
it("keeps weekly UTC time across DST and skips missing monthly days", () => {
expect(
new Date(
nextOccurrence(
plan({ recurrence: "weekly" }),
at("2026-03-28T21:00Z"),
)!,
).toISOString(),
).toBe("2026-04-04T21:00:00.000Z");
expect(
new Date(
nextOccurrence(
plan({ recurrence: "monthly" }),
at("2026-01-31T21:00Z"),
)!,
).toISOString(),
).toBe("2026-03-31T21:00:00.000Z");
expect(nextOccurrence(plan(), at("2026-01-31T21:00Z"))).toBeNull();
});
it("rejects actual overlaps and accepts touching windows", () => {
expect(plansOverlap(plan(), plan({ start: "2026-01-31T21:59:00Z" }))).toBe(
true,
);
expect(plansOverlap(plan(), plan({ start: "2026-01-31T22:00:00Z" }))).toBe(
false,
);
expect(
plansOverlap(
plan({ recurrence: "monthly" }),
plan({ start: "2030-03-31T21:30:00Z" }),
),
).toBe(true);
expect(
plansOverlap(
plan({ recurrence: "weekly" }),
plan({ recurrence: "monthly", start: "2026-03-31T21:30:00Z" }),
),
).toBe(true);
expect(
plansOverlap(
plan({ recurrence: "weekly" }),
plan({ recurrence: "monthly", start: "2026-03-31T23:00:00Z" }),
),
).toBe(false);
});
it("does not end at ETM and merges a later nonoverlapping schedule", () => {
const state = emptyMaintenance();
state.plans = [
plan(),
plan({
id: "two",
reason: "Firmware",
start: "2026-01-31T22:30:00Z",
nextStart: "2026-01-31T22:30:00Z",
}),
];
advanceMaintenance(state, at("2026-01-31T22:15Z"));
expect(state.active?.estimatedEnd).toBe("2026-01-31T22:00:00.000Z");
advanceMaintenance(state, at("2026-01-31T22:45Z"));
expect(state.active?.startedAt).toBe("2026-01-31T21:00:00.000Z");
expect(state.active?.estimatedEnd).toBe("2026-01-31T23:30:00.000Z");
expect(state.active?.reasons).toEqual(["Upgrade", "Firmware"]);
advanceMaintenance(state, at("2026-02-02T00:00Z"));
expect(state.active).not.toBeNull();
});
it("catches up after restart and never re-enters a consumed one-time plan", () => {
const state = emptyMaintenance();
state.plans = [plan({ recurrence: "monthly" })];
advanceMaintenance(state, at("2026-05-01T00:00Z"));
expect(state.active?.startedAt).toBe("2026-03-31T21:00:00.000Z");
expect(state.plans[0].nextStart).toBe("2026-05-31T21:00:00.000Z");
state.active = null;
advanceMaintenance(state, at("2026-05-01T00:00Z"));
expect(state.active).toBeNull();
});
it("preserves a delivered reminder unless a later estimate extends maintenance", () => {
const state = emptyMaintenance();
beginMaintenance(state, plan(), at("2026-01-31T21:00Z"));
state.active!.notified = true;
beginMaintenance(
state,
plan({ durationMinutes: 10 }),
at("2026-01-31T21:05Z"),
);
expect(state.active!.notified).toBe(true);
beginMaintenance(state, plan(), at("2026-01-31T22:30Z"));
expect(state.active!.notified).toBe(false);
});
it.each([
{ reason: " " },
{ durationMinutes: 0 },
{ durationMinutes: 10081 },
{ graceMinutes: -1 },
{ recurrence: "daily" },
{ notifyOverdue: "true" },
{ start: "2020-01-01T00:00Z" },
{ start: "2026-01-31T21:00:01Z" },
])("rejects invalid input %j", (override) => {
expect(() =>
parsePlan(plan(override as never), at("2026-01-01T00:00Z")),
).toThrow();
});
});
@@ -0,0 +1,209 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { startServer as createServer, host, type TestServer } from "./helpers";
import { createMaintenanceRepository } from "../../src/backend/maintenance-repository.js";
import { createMaintenanceService } from "../../src/backend/maintenance-service.js";
function startServer(options: Parameters<typeof createServer>[0] = {}) {
return createServer({
...options,
before: (db) => {
db.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
options.before?.(db);
},
});
}
let server: TestServer | undefined;
afterEach(async () => {
vi.restoreAllMocks();
await server?.close();
server = undefined;
});
const input = {
action: "start",
reason: "OS upgrade",
durationMinutes: 60,
graceMinutes: 20,
notifyOverdue: true,
};
describe("host maintenance", () => {
it("scopes reads and edits to the host owner and enforces edit permission", async () => {
server = await startServer({ hosts: [host(1), host(2, "bob")] });
expect(
(await server.request("POST", "/maintenance/1", { body: input })).status,
).toBe(200);
expect(
(await server.request("GET", "/maintenance", { user: "bob" })).body,
).toEqual([]);
expect(
(
await server.request("POST", "/maintenance/1", {
user: "bob",
body: { action: "end" },
})
).status,
).toBe(404);
expect(
(await server.request("GET", "/maintenance/1", { user: "bob" })).status,
).toBe(404);
expect(
(await server.request("POST", "/maintenance/1x", { body: input })).status,
).toBe(400);
expect(
(await server.request("GET", "/maintenance/1", { user: null })).status,
).toBe(401);
await server.close();
server = await startServer({ permissions: ["automations.view"] });
expect(
(await server.request("POST", "/maintenance/1", { body: input })).status,
).toBe(403);
});
it("survives service restart, remains active beyond ETM, and reminds only once with fresh offline evidence", async () => {
server = await startServer();
let now = Date.parse("2026-01-01T12:00Z");
vi.spyOn(Date, "now").mockImplementation(() => now);
await server.request("POST", "/maintenance/1", { body: input });
const repository = await createMaintenanceRepository(server.db.database);
let service = createMaintenanceService(server.mock.ctx, repository);
now = Date.parse("2026-01-01T13:21Z");
server.mock.hostStatuses.set(1, {
status: "offline",
lastChecked: "2026-01-01T12:00Z",
});
await service.tick();
expect(server.mock.notifications).toHaveLength(0);
server.mock.hostStatuses.set(1, {
status: "reachable",
lastChecked: "2026-01-01T13:21Z",
});
await service.tick();
expect(server.mock.notifications).toHaveLength(0);
server.mock.hostStatuses.set(1, {
status: "offline",
lastChecked: "2026-01-01T13:21Z",
});
await service.tick();
expect(server.mock.notifications).toHaveLength(1);
expect(server.mock.notifications[0].actor).toBe("alice");
service = createMaintenanceService(server.mock.ctx, repository);
await service.tick();
expect(server.mock.notifications).toHaveLength(1);
expect((await service.read("alice", 1)).active).not.toBeNull();
await service.edit("alice", 1, "end", {});
expect(await service.isMaintaining("alice", 1)).toBe(false);
});
it("serializes concurrent starts and conflicting schedules without losing state", async () => {
server = await startServer();
const service = createMaintenanceService(
server.mock.ctx,
await createMaintenanceRepository(server.db.database),
);
const starts = await Promise.allSettled([
service.edit("alice", 1, "start", input),
service.edit("alice", 1, "start", input),
]);
expect(starts.map((r) => r.status).sort()).toEqual([
"fulfilled",
"rejected",
]);
const start = new Date(
Math.ceil((Date.now() + 86400000) / 60000) * 60000,
).toISOString();
const schedules = await Promise.allSettled([
service.edit("alice", 1, "schedule", {
...input,
start,
recurrence: "weekly",
}),
service.edit("alice", 1, "schedule", {
...input,
start,
recurrence: "monthly",
}),
]);
expect(schedules.map((r) => r.status).sort()).toEqual([
"fulfilled",
"rejected",
]);
expect((await service.read("alice", 1)).plans).toHaveLength(1);
});
it("starts a due schedule before accepting status events and handles deletion", async () => {
server = await startServer();
let now = Date.parse("2026-01-01T12:00Z");
vi.spyOn(Date, "now").mockImplementation(() => now);
const service = createMaintenanceService(
server.mock.ctx,
await createMaintenanceRepository(server.db.database),
);
await service.edit("alice", 1, "schedule", {
...input,
start: "2026-01-01T13:00:00Z",
recurrence: "once",
});
expect(await service.isMaintaining("alice", 1)).toBe(false);
now = Date.parse("2026-01-01T13:00Z");
expect(await service.isMaintaining("alice", 1)).toBe(true);
const state = await service.read("alice", 1);
await service.edit("alice", 1, "remove", { id: state.plans[0].id });
expect(await service.isMaintaining("alice", 1)).toBe(true);
await service.wipeUser("alice");
expect(await service.list("alice")).toEqual([]);
});
it("clears old dwell windows on start and end and cascades host deletion", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: {
name: "CPU",
definition: {
version: 1,
trigger: {
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 1 },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
forSeconds: 60,
cooldownMinutes: 0,
},
steps: [],
},
},
});
expect(created.status).toBe(201);
const { createAutomationRepository } =
await import("../../src/backend/repository.js");
const repository = await createAutomationRepository(
server.db.database,
async () => [],
);
const seed = () =>
repository.upsertTriggerState({
automationId: created.body.id,
stateKey: "1",
breachStartedAt: "2000-01-01T00:00:00Z",
});
await seed();
expect(
(await server.request("POST", "/maintenance/1", { body: input })).status,
).toBe(200);
expect(
(await repository.getTriggerState(created.body.id, "1"))?.breachStartedAt,
).toBeNull();
await seed();
expect(
(
await server.request("POST", "/maintenance/1", {
body: { action: "end" },
})
).status,
).toBe(200);
expect(
(await repository.getTriggerState(created.body.id, "1"))?.breachStartedAt,
).toBeNull();
server.db.sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run();
expect(
await (await createMaintenanceRepository(server.db.database)).list(),
).toEqual([]);
});
});
@@ -438,3 +438,132 @@ describe("watchedHosts", () => {
expect((await triggers.watchedHosts("metric_threshold")).size).toBe(0);
});
});
describe("maintenance suppression", () => {
it("updates the status baseline without firing or replaying maintenance edges", async () => {
let maintaining = false;
const guarded = createTriggers(
repository as never,
{ run },
log,
async () => maintaining,
);
addAutomation({
kind: "host_status",
hostSelector: { kind: "all" },
to: "offline",
cooldownMinutes: 0,
});
await guarded.onStatus({ hostId: 7, ownerUserId: "user-1", online: true });
maintaining = true;
await guarded.onStatus({ hostId: 7, ownerUserId: "user-1", online: false });
maintaining = false;
await guarded.onStatus({ hostId: 7, ownerUserId: "user-1", online: false });
expect(run).not.toHaveBeenCalled();
await guarded.onStatus({ hostId: 7, ownerUserId: "user-1", online: true });
await guarded.onStatus({ hostId: 7, ownerUserId: "user-1", online: false });
expect(run).toHaveBeenCalledTimes(1);
});
it("maintains health baselines and suppresses container events", async () => {
const guarded = createTriggers(
repository as never,
{ run },
log,
async () => true,
);
addAutomation({
kind: "health_check",
hostSelector: { kind: "all" },
to: "failing",
cooldownMinutes: 0,
});
addAutomation({
kind: "docker_event",
hostSelector: { kind: "all" },
event: "exited",
cooldownMinutes: 0,
});
await guarded.onHealthCheck({
hostId: 7,
userId: "user-1",
checkId: "http",
ok: true,
});
await guarded.onHealthCheck({
hostId: 7,
userId: "user-1",
checkId: "http",
ok: false,
});
await guarded.onDockerEvent({
hostId: 7,
ownerUserId: "user-1",
container: "app",
event: "exited",
});
expect(run).not.toHaveBeenCalled();
expect(triggerState.get("1:7:http")?.lastObservedState).toBe("failing");
});
it("clears a metric dwell window so maintenance time cannot count toward a later alert", async () => {
let maintaining = true;
const guarded = createTriggers(
repository as never,
{ run },
log,
async () => maintaining,
);
addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "all" },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
forSeconds: 60,
cooldownMinutes: 0,
});
triggerState.set("1:7", {
breachStartedAt: "2000-01-01T00:00:00Z",
});
const event = {
hostId: 7,
ownerUserId: "user-1",
metrics: { cpu: { percent: 99 } },
};
await guarded.onMetrics(event);
expect(triggerState.get("1:7")?.breachStartedAt).toBeNull();
maintaining = false;
await guarded.onMetrics(event);
expect(run).not.toHaveBeenCalled();
expect(triggerState.get("1:7")?.breachStartedAt).not.toBeNull();
});
it("suppresses tunnel disconnects but preserves security login events", async () => {
const guarded = createTriggers(
repository as never,
{ run },
log,
async () => true,
);
addAutomation({
kind: "internal_event",
event: "tunnel_disconnected",
cooldownMinutes: 0,
});
addAutomation({
kind: "internal_event",
event: "user_login",
cooldownMinutes: 0,
});
await guarded.onInternalEvent({
event: "tunnel_disconnected",
userId: "user-1",
hostId: 7,
});
expect(run).not.toHaveBeenCalled();
await guarded.onInternalEvent({
event: "user_login",
userId: "user-1",
hostId: 7,
});
expect(run).toHaveBeenCalledTimes(1);
});
});