Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ new MaintenanceWindow('maintenance-window-1', {
repeatInterval: 1,
repeatUnit: 'MONTH',
repeatEndsAt: new Date(new Date().valueOf() + (2160 * 60 * 60 * 1000)), // ~three months from now
// The window starts now, so it is active when redeployed; the API rejects timezone changes on an
// active window, so this value must stay the same across deploys.
timezone: 'Europe/Berlin',
})
6 changes: 6 additions & 0 deletions packages/cli/src/ai-context/context.fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,12 @@
"repeatInterval": 1,
"repeatUnit": "WEEK",
"repeatEndsAt": "2025-08-01T00:00:00.000Z",
"timezone": "America/New_York",
"pauseAllChecks": false,
"silenceAlertsTags": [
"app:webshop"
],
"silenceAllAlerts": false,
"created_at": "2025-07-07T08:43:03.466Z",
"updated_at": "2025-07-07T08:43:03.465Z"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ new StatusPageV3AutomationRule('example-api-down-rule', {
## Maintenance Window

- Import the `MaintenanceWindow` construct from `checkly/constructs`.
- Maintenance windows are used to pause checks during maintenance periods so no alerts are sent.
- Checks are referenced by their tags in the `tags` property.
- Maintenance windows pause checks (`tags` or `pauseAllChecks`) and/or silence their alerts while the checks keep running (`silenceAlertsTags` or `silenceAllAlerts`).
- Set `timezone` (an IANA name such as `America/New_York`) so recurring windows keep their local time.

<!-- EXAMPLE: MAINTENANCE_WINDOW -->

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'

import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { MaintenanceWindowCodegen, type MaintenanceWindowResource } from '../maintenance-window-codegen.js'
import { Context } from '../internal/codegen/index.js'
import { Program } from '../../sourcegen/index.js'

describe('MaintenanceWindowCodegen', () => {
let rootDirectory: string

beforeEach(async () => {
rootDirectory = await mkdtemp(path.join(tmpdir(), 'maintenance-window-codegen-'))
})

afterEach(async () => {
await rm(rootDirectory, { recursive: true, force: true })
})

async function generate (resource: MaintenanceWindowResource): Promise<string> {
const program = new Program({
rootDirectory,
constructFileSuffix: '.check',
specFileSuffix: '.spec',
language: 'typescript',
})
const codegen = new MaintenanceWindowCodegen(program)
const context = new Context()

codegen.prepare('maintenance-window', resource, context)
codegen.gencode('maintenance-window', resource, context)
await program.realize()

const [filePath] = program.paths
if (filePath === undefined) {
throw new Error('Codegen did not register a generated file')
}
return readFile(filePath, 'utf8')
}

const baseResource: MaintenanceWindowResource = {
name: 'Weekly database maintenance',
tags: ['database'],
startsAt: '2030-01-01T09:00:00.000Z',
endsAt: '2030-01-01T10:00:00.000Z',
}

it('emits the timezone and the pause and silence scope', async () => {
const source = await generate({
...baseResource,
timezone: 'Europe/Berlin',
pauseAllChecks: true,
silenceAlertsTags: ['api'],
silenceAllAlerts: true,
})

expect(source).toContain(`timezone: 'Europe/Berlin'`)
expect(source).toContain(`pauseAllChecks: true`)
expect(source).toContain(`silenceAlertsTags: [`)
expect(source).toContain(`silenceAllAlerts: true`)
})

it('omits unset and default values', async () => {
const source = await generate({
...baseResource,
timezone: null,
pauseAllChecks: false,
silenceAlertsTags: [],
silenceAllAlerts: false,
})

expect(source).toContain(`tags: [`)
expect(source).not.toContain('timezone')
expect(source).not.toContain('pauseAllChecks')
expect(source).not.toContain('silenceAlertsTags')
expect(source).not.toContain('silenceAllAlerts')
})

it('omits an empty tag list', async () => {
const source = await generate({ ...baseResource, tags: [], pauseAllChecks: true })

expect(source).not.toContain('tags')
expect(source).toContain(`pauseAllChecks: true`)
})
})
119 changes: 119 additions & 0 deletions packages/cli/src/constructs/__tests__/maintenance-window.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it } from 'vitest'

import { Diagnostics, KNOWN_TIME_ZONES, MaintenanceWindow, MaintenanceWindowProps } from '../index.js'
import { Project } from '../project.js'
import { Session } from '../session.js'

const baseProps: MaintenanceWindowProps = {
name: 'Weekly database maintenance',
tags: ['database'],
startsAt: new Date('2030-01-01T09:00:00.000Z'),
endsAt: new Date('2030-01-01T10:00:00.000Z'),
repeatInterval: 1,
repeatUnit: 'WEEK',
}

async function validate (props: MaintenanceWindowProps): Promise<Diagnostics> {
const window = new MaintenanceWindow('maintenance-window', props)
const diagnostics = new Diagnostics()
await window.validate(diagnostics)
return diagnostics
}

describe('MaintenanceWindow', () => {
beforeEach(() => {
Session.project = new Project('project-id', {
name: 'Test Project',
repoUrl: 'https://github.com/checkly/checkly-cli',
})
})

describe('synthesize', () => {
it('sends a null timezone when none is set, so a removed timezone resets to UTC', () => {
const window = new MaintenanceWindow('maintenance-window', baseProps)
expect(window.synthesize()).toHaveProperty('timezone', null)
})

it('sends the configured timezone', () => {
const window = new MaintenanceWindow('maintenance-window', { ...baseProps, timezone: 'Europe/Berlin' })
expect(window.synthesize()).toHaveProperty('timezone', 'Europe/Berlin')
})

it('sends the pause and silence scope', () => {
const window = new MaintenanceWindow('maintenance-window', {
...baseProps,
tags: undefined,
pauseAllChecks: true,
silenceAlertsTags: ['api'],
silenceAllAlerts: false,
})
expect(window.synthesize()).toMatchObject({
pauseAllChecks: true,
silenceAlertsTags: ['api'],
silenceAllAlerts: false,
})
})
})

describe('validate', () => {
it.each(['America/New_York', 'UTC', 'Europe/Kyiv', 'Asia/Calcutta', 'Etc/GMT+0', 'Etc/GMT-0'])('accepts %s', async timezone => {
const diagnostics = await validate({ ...baseProps, timezone })
expect(diagnostics.isFatal()).toBe(false)
})

it.each(['+05:00', '-0300', 'Etc/GMT+5', 'Etc/GMT-14', 'Not/AZone'])('rejects %s', async timezone => {
const diagnostics = await validate({ ...baseProps, timezone })
expect(diagnostics.isFatal()).toBe(true)
expect(diagnostics.observations).toEqual(expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining('"timezone" must be a named IANA time zone'),
}),
]))
})

it('does not require a timezone', async () => {
const diagnostics = await validate(baseProps)
expect(diagnostics.isFatal()).toBe(false)
})

it('warns when the window neither pauses checks nor silences alerts', async () => {
const diagnostics = await validate({ ...baseProps, tags: undefined })
expect(diagnostics.isFatal()).toBe(false)
expect(diagnostics.observations).toEqual(expect.arrayContaining([
expect.objectContaining({ title: 'Maintenance window affects no checks' }),
]))
})

it.each<Partial<MaintenanceWindowProps>>([
{ tags: ['database'] },
{ tags: undefined, pauseAllChecks: true },
{ tags: undefined, silenceAlertsTags: ['api'] },
{ tags: undefined, silenceAllAlerts: true },
])('does not warn when a scope is set: %o', async scope => {
const diagnostics = await validate({ ...baseProps, ...scope })
expect(diagnostics.observations).toEqual([])
})
})

describe('KNOWN_TIME_ZONES', () => {
// validate() trusts listed names without consulting Intl, so the list itself must be well formed.
// Whether the running Node recognizes each name is not asserted: older Node releases bundle older
// tzdata and legitimately lack recently added zones.
it('is a sorted, duplicate-free list of named zones', () => {
expect([...KNOWN_TIME_ZONES].sort()).toEqual(KNOWN_TIME_ZONES)
expect(new Set(KNOWN_TIME_ZONES).size).toBe(KNOWN_TIME_ZONES.length)
expect(KNOWN_TIME_ZONES).toContain('UTC')
const malformed = KNOWN_TIME_ZONES.filter(timeZone =>
timeZone !== 'UTC' && !/^[A-Z][A-Za-z]+(\/[A-Z][A-Za-z0-9_+-]*){1,2}$/.test(timeZone))
expect(malformed).toEqual([])
})

it('contains no names that validate() would reject', async () => {
const diagnostics = new Diagnostics()
for (const timezone of KNOWN_TIME_ZONES) {
await new MaintenanceWindow(`maintenance-window-${timezone}`, { ...baseProps, timezone }).validate(diagnostics)
}
expect(diagnostics.isFatal()).toBe(false)
})
})
})
1 change: 1 addition & 0 deletions packages/cli/src/constructs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from './http-request.js'
export * from './query-param.js'
export * from './frequency.js'
export * from './maintenance-window.js'
export { KNOWN_TIME_ZONES, type KnownTimeZone, type TimeZone } from './time-zone.js'
export * from './alert-channel.js'
export * from './private-location.js'
export * from './private-location-check-assignment.js'
Expand Down
39 changes: 34 additions & 5 deletions packages/cli/src/constructs/maintenance-window-codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export interface MaintenanceWindowResource {
repeatInterval?: number | null
repeatUnit?: string
repeatEndsAt?: string
timezone?: string | null
pauseAllChecks?: boolean
silenceAlertsTags?: Array<string>
silenceAllAlerts?: boolean
}

const construct = 'MaintenanceWindow'
Expand All @@ -33,11 +37,13 @@ export class MaintenanceWindowCodegen extends Codegen<MaintenanceWindowResource>
builder.object(builder => {
builder.string('name', resource.name)

builder.array('tags', builder => {
for (const tag of resource.tags) {
builder.string(tag)
}
})
if (resource.tags?.length) {
builder.array('tags', builder => {
for (const tag of resource.tags) {
builder.string(tag)
}
})
}

builder.expr('startsAt', ident('Date'), builder => {
builder.new(builder => {
Expand Down Expand Up @@ -67,6 +73,29 @@ export class MaintenanceWindowCodegen extends Codegen<MaintenanceWindowResource>
})
})
}

if (resource.timezone) {
builder.string('timezone', resource.timezone)
}

// The default value for pauseAllChecks is false, only include if true.
if (resource.pauseAllChecks !== undefined && resource.pauseAllChecks) {
builder.boolean('pauseAllChecks', resource.pauseAllChecks)
}

if (resource.silenceAlertsTags?.length) {
const silenceAlertsTags = resource.silenceAlertsTags
builder.array('silenceAlertsTags', builder => {
for (const tag of silenceAlertsTags) {
builder.string(tag)
}
})
}

// The default value for silenceAllAlerts is false, only include if true.
if (resource.silenceAllAlerts !== undefined && resource.silenceAllAlerts) {
builder.boolean('silenceAllAlerts', resource.silenceAllAlerts)
}
})
})
}))
Expand Down
Loading
Loading