fix(maintenance): stop silent deletion of one-time maintenance windows
Maintenance windows could disappear seconds after creation when the API received a payload with end dates already in the past. The repository auto-set expiry = end for one-time windows (repeat = 0), and Mongo's TTL index on the expiry field then deleted the row on its next sweep. This was particularly visible with automation tools sending payloads in non-UTC timezones. ## Changes ### Validator hardening - Add .strict() to create + edit body schemas so unknown keys return 400 instead of being silently dropped. - Refine end > start on create. - Refine end > now for one-time create payloads (repeat = 0). - Refine end > start on edit when both are provided. - Remove the accepted expiry field from both schemas. ### Stop the auto-delete behaviour - Repository.create no longer copies end into expiry. - Schema's expiry field + TTL index removed. - Migration 0007 drops the live TTL index and unsets stale expiry values on existing rows so legacy data is no longer at risk. ### Client - Form submission no longer includes expiry.
This commit is contained in:
1 parent
a69f8a5ab5
commit
4db95cfa44
6 files changed
+86
-36
No files matched your search
@@ -85,10 +85,6 @@ const CreateMaintenanceWindowPage = () => {
|
||||
repeat,
|
||||
};
|
||||
|
||||
if (repeat === 0) {
|
||||
payload.expiry = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MaintenanceWindowModel } from "../models/MaintenanceWindow.js";
|
||||
import { logger } from "@/utils/logger.js";
|
||||
|
||||
/**
|
||||
* Drops the TTL index on MaintenanceWindow.expiry and unsets the expiry field.
|
||||
*
|
||||
* Earlier versions auto-set expiry = end for one-time maintenance windows. Combined with the
|
||||
* TTL index ({ expires: "0s" }), this caused Mongo to silently delete completed one-time
|
||||
* windows shortly after their end-date passed — including windows whose end-date was already
|
||||
* in the past at creation time (e.g. timezone-mismatched payloads from automation tools).
|
||||
* The fix stops writing expiry on create, and this migration removes the index and stale field
|
||||
* so legacy rows are no longer at risk of being deleted by the TTL monitor.
|
||||
*/
|
||||
export async function dropMaintenanceWindowExpiryIndex(): Promise<void> {
|
||||
const SERVICE_NAME = "Migration:DropMaintenanceWindowExpiryIndex";
|
||||
|
||||
try {
|
||||
const collection = MaintenanceWindowModel.collection;
|
||||
const indexes = await collection.indexes();
|
||||
const expiryIndex = indexes.find((idx) => idx.key && idx.key.expiry === 1);
|
||||
|
||||
if (expiryIndex?.name) {
|
||||
await collection.dropIndex(expiryIndex.name);
|
||||
logger.info({
|
||||
service: SERVICE_NAME,
|
||||
message: `Dropped TTL index ${expiryIndex.name} on MaintenanceWindow.expiry`,
|
||||
});
|
||||
} else {
|
||||
logger.info({
|
||||
service: SERVICE_NAME,
|
||||
message: "No TTL index found on MaintenanceWindow.expiry",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await collection.updateMany({ expiry: { $exists: true } }, { $unset: { expiry: "" } });
|
||||
logger.info({
|
||||
service: SERVICE_NAME,
|
||||
message: `Unset expiry on ${result.modifiedCount} maintenance windows`,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error({
|
||||
service: SERVICE_NAME,
|
||||
message: `Error dropping expiry index: ${errorMessage}`,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.
|
||||
import MigrationModel from "../models/Migration.js";
|
||||
import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js";
|
||||
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
|
||||
import { dropMaintenanceWindowExpiryIndex } from "./0007_dropMaintenanceWindowExpiryIndex.js";
|
||||
import type { ILogger } from "@/utils/logger.js";
|
||||
|
||||
type MigrationEntry = {
|
||||
@@ -19,6 +20,7 @@ const migrations: MigrationEntry[] = [
|
||||
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
|
||||
{ name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray },
|
||||
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
|
||||
{ name: "0007_dropMaintenanceWindowExpiryIndex", execute: dropMaintenanceWindowExpiryIndex },
|
||||
];
|
||||
|
||||
const runMigrations = async (logger?: ILogger) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorId"
|
||||
teamId: Types.ObjectId;
|
||||
start: Date;
|
||||
end: Date;
|
||||
expiry?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
@@ -50,10 +49,6 @@ const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
|
||||
end: {
|
||||
type: Date,
|
||||
},
|
||||
expiry: {
|
||||
type: Date,
|
||||
index: { expires: "0s" },
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
||||
@@ -45,11 +45,6 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
|
||||
|
||||
create = async (data: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
|
||||
const maintenanceWindow = new MaintenanceWindowModel(data);
|
||||
|
||||
// If the maintenance window is a one time window, set the expiry to the end date
|
||||
if (maintenanceWindow.repeat === 0) {
|
||||
maintenanceWindow.expiry = maintenanceWindow.end;
|
||||
}
|
||||
const result = await maintenanceWindow.save();
|
||||
return this.toEntity(result);
|
||||
};
|
||||
|
||||
@@ -7,17 +7,26 @@ const dateToString = z.coerce.date().transform((d) => d.toISOString());
|
||||
// Maintenance Window Validations
|
||||
//****************************************
|
||||
|
||||
export const createMaintenanceWindowBodyValidation = z.object({
|
||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
active: z.boolean().optional(),
|
||||
duration: z.number().min(1, "Duration is required"),
|
||||
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]),
|
||||
start: dateToString,
|
||||
end: dateToString,
|
||||
repeat: z.number().min(0, "Repeat must be a non-negative number"),
|
||||
expiry: dateToString.optional(),
|
||||
});
|
||||
export const createMaintenanceWindowBodyValidation = z
|
||||
.object({
|
||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
active: z.boolean().optional(),
|
||||
duration: z.number().min(1, "Duration is required"),
|
||||
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]),
|
||||
start: dateToString,
|
||||
end: dateToString,
|
||||
repeat: z.number().min(0, "Repeat must be a non-negative number"),
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => new Date(data.end).getTime() > new Date(data.start).getTime(), {
|
||||
message: "End must be after start",
|
||||
path: ["end"],
|
||||
})
|
||||
.refine((data) => data.repeat > 0 || new Date(data.end).getTime() > Date.now(), {
|
||||
message: "End must be in the future for one-time maintenance windows",
|
||||
path: ["end"],
|
||||
});
|
||||
|
||||
export const getMaintenanceWindowByIdParamValidation = z.object({
|
||||
id: z.string().min(1, "ID is required"),
|
||||
@@ -43,14 +52,19 @@ export const editMaintenanceWindowByIdParamValidation = z.object({
|
||||
id: z.string().min(1, "ID is required"),
|
||||
});
|
||||
|
||||
export const editMaintenanceByIdWindowBodyValidation = z.object({
|
||||
active: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
repeat: z.number().optional(),
|
||||
start: dateToString.optional(),
|
||||
end: dateToString.optional(),
|
||||
expiry: dateToString.optional(),
|
||||
monitors: z.array(z.string()).optional(),
|
||||
duration: z.number().optional(),
|
||||
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(),
|
||||
});
|
||||
export const editMaintenanceByIdWindowBodyValidation = z
|
||||
.object({
|
||||
active: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
repeat: z.number().optional(),
|
||||
start: dateToString.optional(),
|
||||
end: dateToString.optional(),
|
||||
monitors: z.array(z.string()).optional(),
|
||||
duration: z.number().optional(),
|
||||
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => !data.start || !data.end || new Date(data.end).getTime() > new Date(data.start).getTime(), {
|
||||
message: "End must be after start",
|
||||
path: ["end"],
|
||||
});
|
||||
Reference in new issue
Block a user