Skip to content

Commit 83f0eee

Browse files
feat(cli): maintenance-windows topic + monitors test --config (#31)
* feat(cli): add maintenance-windows topic (list/get/create/update/cancel) Maintenance windows are a state-only resource (intentionally not exposed in `devhelm.yml` or as a Terraform resource) used to suppress alerts during planned changes. Wires the existing `/api/v1/maintenance-windows` endpoints into five oclif commands and registers a topic description in package.json. The list command supports the server-supported `--status active|upcoming` filter and a `--monitor <id>` filter. Create accepts `--start`, `--end`, `--reason`, and either `--monitor <id>` or `--org-wide` (the API stores a single monitorId, with null meaning org-wide). Update fetches the current window and back-fills timestamps when the user provides a partial change, so `update <id> --reason "Rescheduled"` works without re-passing start/end. Cancel maps to DELETE with the same confirmation prompt as the generic delete factory. Adds shared `buildMaintenanceWindowBody` plus unit tests, and threads the two new request DTOs into `extract-descriptions.mjs` so flag descriptions track the OpenAPI spec. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): add monitors test --config <file> for pre-save config validation Today `devhelm monitors test <id>` runs a probe against an existing monitor. This adds a sibling entry point: `devhelm monitors test --config <file>` reads a YAML or JSON file, validates it against the generated `CreateMonitorRequest` Zod schema, and (when validation passes) dispatches the test subset to the existing `/api/v1/monitors/test` ad-hoc endpoint so the user sees a real probe result before deciding to save the monitor. The id arg is now optional so the same command supports both flows. Passing both an id and `--config` errors out — there's no scenario where a live test against an existing monitor and a dry-run of a proposed config make sense in one shot. Validation errors print with field paths so `name`, `managedBy`, and config-discriminator failures all surface a clear actionable message instead of bouncing off a server 400. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1f127bf commit 83f0eee

14 files changed

Lines changed: 801 additions & 7 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ Every resource supports `list`, `get`, `create`, `update`, and `delete` subcomma
8484
| Resource | Commands | API Path |
8585
|----------|----------|----------|
8686
| `monitors` | list, get, create, update, delete, pause, resume, test, results | `/api/v1/monitors` |
87+
| `monitors test --config <file>` | validate a YAML/JSON CreateMonitorRequest before saving | `/api/v1/monitors/test` |
88+
| `maintenance-windows` | list, get, create, update, cancel | `/api/v1/maintenance-windows` |
8789
| `incidents` | list, get, create, update, delete, resolve | `/api/v1/incidents` |
8890
| `alert-channels` | list, get, create, update, delete, test | `/api/v1/alert-channels` |
8991
| `notification-policies` | list, get, create, update, delete, test | `/api/v1/notification-policies` |

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@
7979
"incidents": {
8080
"description": "Create, inspect, and resolve incidents"
8181
},
82+
"maintenance-windows": {
83+
"description": "Schedule downtime windows that suppress alerts during planned changes"
84+
},
8285
"monitors": {
8386
"description": "Manage HTTP, TCP, DNS, ICMP, MCP, and heartbeat monitors"
8487
},

scripts/extract-descriptions.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const TARGET_SCHEMAS = [
2929
'CreateResourceGroupRequest', 'UpdateResourceGroupRequest',
3030
'CreateWebhookEndpointRequest', 'UpdateWebhookEndpointRequest',
3131
'CreateApiKeyRequest', 'UpdateApiKeyRequest',
32+
'CreateMaintenanceWindowRequest', 'UpdateMaintenanceWindowRequest',
3233
'ResolveIncidentRequest', 'MonitorTestRequest',
3334
'AcquireDeployLockRequest',
3435
'HttpMonitorConfig', 'TcpMonitorConfig', 'DnsMonitorConfig',
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {Command, Flags} from '@oclif/core'
2+
import type {ZodType} from 'zod'
3+
import {globalFlags, buildClient} from '../../lib/base-command.js'
4+
import {apiDelete, apiGetSingle} from '../../lib/api-client.js'
5+
import {DevhelmAuthError, DevhelmNotFoundError, EXIT_CODES} from '../../lib/errors.js'
6+
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
7+
import type {components} from '../../lib/api.generated.js'
8+
import {uuidArg} from '../../lib/validators.js'
9+
10+
type MaintenanceWindowDto = components['schemas']['MaintenanceWindowDto']
11+
12+
export default class MaintenanceWindowsCancel extends Command {
13+
static description = 'Cancel a maintenance window (deletes scheduled or active windows)'
14+
static examples = [
15+
'<%= config.bin %> maintenance-windows cancel <id>',
16+
'<%= config.bin %> maintenance-windows cancel <id> --yes',
17+
]
18+
static args = {id: uuidArg({description: 'Maintenance window ID', required: true})}
19+
static flags = {
20+
...globalFlags,
21+
yes: Flags.boolean({
22+
char: 'y',
23+
description: 'Skip the interactive confirmation prompt',
24+
default: false,
25+
}),
26+
}
27+
28+
async run() {
29+
const {args, flags} = await this.parse(MaintenanceWindowsCancel)
30+
const client = buildClient(flags)
31+
const path = `/api/v1/maintenance-windows/${args.id}`
32+
33+
if (!flags.yes) {
34+
if (!process.stdin.isTTY) {
35+
// Mirrors the safety check in the generic `delete` factory: in
36+
// CI / piped invocations we refuse to silently confirm.
37+
this.error(
38+
`Refusing to cancel maintenance window '${args.id}' in non-interactive mode without --yes (or -y).`,
39+
{exit: EXIT_CODES.VALIDATION},
40+
)
41+
}
42+
const confirmed = await promptForCancel(client, path, args.id)
43+
if (!confirmed) {
44+
this.log('Cancelled.')
45+
return
46+
}
47+
}
48+
49+
await apiDelete(client, path)
50+
this.log(`Maintenance window '${args.id}' cancelled.`)
51+
}
52+
}
53+
54+
async function promptForCancel(
55+
client: ReturnType<typeof buildClient>,
56+
path: string,
57+
id: string,
58+
): Promise<boolean> {
59+
let label = `'${id}'`
60+
try {
61+
const value = await apiGetSingle<MaintenanceWindowDto>(
62+
client,
63+
path,
64+
apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>,
65+
)
66+
const reason = value.reason ?? '(no reason)'
67+
label = `'${reason}' (${id}, ${value.startsAt}${value.endsAt})`
68+
} catch (err) {
69+
// Surface auth/not-found before the destructive action; swallow
70+
// anything else so we still prompt with the bare id rather than
71+
// blocking a cancel that would otherwise succeed.
72+
if (err instanceof DevhelmAuthError || err instanceof DevhelmNotFoundError) throw err
73+
}
74+
75+
const {createInterface} = await import('node:readline')
76+
const rl = createInterface({input: process.stdin, output: process.stderr})
77+
const answer = await new Promise<string>((resolve) => {
78+
rl.question(`Cancel maintenance window ${label}? [y/N] `, resolve)
79+
})
80+
rl.close()
81+
const normalized = answer.trim().toLowerCase()
82+
return normalized === 'y' || normalized === 'yes'
83+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {Command, Flags} from '@oclif/core'
2+
import type {ZodType} from 'zod'
3+
import {globalFlags, buildClient, display} from '../../lib/base-command.js'
4+
import {apiPostSingle} from '../../lib/api-client.js'
5+
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
6+
import type {components} from '../../lib/api.generated.js'
7+
import {fieldDescriptions} from '../../lib/descriptions.generated.js'
8+
import {parse as parseSchema} from '../../lib/response-validation.js'
9+
import {uuidFlag} from '../../lib/validators.js'
10+
import {buildMaintenanceWindowBody} from '../../lib/maintenance-windows.js'
11+
12+
type MaintenanceWindowDto = components['schemas']['MaintenanceWindowDto']
13+
14+
const desc = (field: string, fallback?: string) =>
15+
fieldDescriptions['CreateMaintenanceWindowRequest']?.[field] ?? fallback ?? field
16+
17+
export default class MaintenanceWindowsCreate extends Command {
18+
static description = 'Schedule a new maintenance window'
19+
static examples = [
20+
'<%= config.bin %> maintenance-windows create --start 2026-06-01T14:00:00Z --end 2026-06-01T14:30:00Z --reason "Deploy" --monitor <uuid>',
21+
'<%= config.bin %> maintenance-windows create --start 2026-06-01T14:00:00Z --end 2026-06-01T14:30:00Z --reason "Org-wide outage" --org-wide',
22+
]
23+
static flags = {
24+
...globalFlags,
25+
start: Flags.string({description: desc('startsAt'), required: true}),
26+
end: Flags.string({description: desc('endsAt'), required: true}),
27+
reason: Flags.string({description: desc('reason')}),
28+
monitor: uuidFlag({description: 'Monitor ID this window applies to'}),
29+
'org-wide': Flags.boolean({
30+
description: 'Apply this window to every monitor in the org (mutually exclusive with --monitor)',
31+
default: false,
32+
exclusive: ['monitor'],
33+
}),
34+
'repeat-rule': Flags.string({description: desc('repeatRule')}),
35+
'suppress-alerts': Flags.boolean({
36+
description: desc('suppressAlerts'),
37+
allowNo: true,
38+
}),
39+
}
40+
41+
async run() {
42+
const {flags} = await this.parse(MaintenanceWindowsCreate)
43+
const client = buildClient(flags)
44+
45+
if (!flags.monitor && !flags['org-wide']) {
46+
this.error('Pass --monitor <uuid> or --org-wide to scope the window.', {exit: 2})
47+
}
48+
49+
const built = buildMaintenanceWindowBody({
50+
start: flags.start,
51+
end: flags.end,
52+
reason: flags.reason,
53+
monitor: flags.monitor,
54+
orgWide: flags['org-wide'],
55+
repeatRule: flags['repeat-rule'],
56+
suppressAlerts: flags['suppress-alerts'],
57+
}, 'create')
58+
59+
const body = parseSchema(
60+
apiSchemas.CreateMaintenanceWindowRequest,
61+
built,
62+
'maintenance-window.create body invalid',
63+
)
64+
65+
const created = await apiPostSingle<MaintenanceWindowDto>(
66+
client,
67+
'/api/v1/maintenance-windows',
68+
apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>,
69+
body,
70+
)
71+
display(this, created, flags.output)
72+
}
73+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {Command} from '@oclif/core'
2+
import type {ZodType} from 'zod'
3+
import {globalFlags, buildClient, display} from '../../lib/base-command.js'
4+
import {apiGetSingle} from '../../lib/api-client.js'
5+
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
6+
import type {components} from '../../lib/api.generated.js'
7+
import {uuidArg} from '../../lib/validators.js'
8+
9+
type MaintenanceWindowDto = components['schemas']['MaintenanceWindowDto']
10+
11+
export default class MaintenanceWindowsGet extends Command {
12+
static description = 'Get a maintenance window by id'
13+
static examples = ['<%= config.bin %> maintenance-windows get <id>']
14+
static args = {id: uuidArg({description: 'Maintenance window ID', required: true})}
15+
static flags = {...globalFlags}
16+
17+
async run() {
18+
const {args, flags} = await this.parse(MaintenanceWindowsGet)
19+
const client = buildClient(flags)
20+
const value = await apiGetSingle<MaintenanceWindowDto>(
21+
client,
22+
`/api/v1/maintenance-windows/${args.id}`,
23+
apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>,
24+
)
25+
display(this, value, flags.output)
26+
}
27+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {Command, Flags} from '@oclif/core'
2+
import type {ZodType} from 'zod'
3+
import {globalFlags, buildClient, display} from '../../lib/base-command.js'
4+
import {apiGetPage} from '../../lib/api-client.js'
5+
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
6+
import type {components} from '../../lib/api.generated.js'
7+
import {uuidFlag} from '../../lib/validators.js'
8+
9+
type MaintenanceWindowDto = components['schemas']['MaintenanceWindowDto']
10+
11+
// API-supported filter values for `GET /api/v1/maintenance-windows?filter=...`.
12+
// The server understands `active` (in-progress now) and `upcoming` (future).
13+
// Past windows are not server-filterable today — pass no filter and inspect
14+
// the timestamps locally if you need them.
15+
const STATUS_OPTIONS = ['active', 'upcoming'] as const
16+
17+
export default class MaintenanceWindowsList extends Command {
18+
static description = 'List all maintenance windows'
19+
static examples = [
20+
'<%= config.bin %> maintenance-windows list',
21+
'<%= config.bin %> maintenance-windows list --status active',
22+
'<%= config.bin %> maintenance-windows list --monitor <uuid>',
23+
]
24+
static flags = {
25+
...globalFlags,
26+
'page-size': Flags.integer({description: 'Number of items per API request (1–200)', default: 200}),
27+
status: Flags.string({
28+
description: 'Filter by lifecycle state (server-supported: active, upcoming)',
29+
options: [...STATUS_OPTIONS],
30+
}),
31+
monitor: uuidFlag({description: 'Only show windows attached to this monitor ID'}),
32+
}
33+
34+
async run() {
35+
const {flags} = await this.parse(MaintenanceWindowsList)
36+
const client = buildClient(flags)
37+
const schema = apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>
38+
39+
// Roll our own pagination loop because the shared
40+
// `fetchPaginatedValidated` helper accepts only page/size — this
41+
// endpoint also takes `monitorId` and `filter` query params, and
42+
// appending them to the path string would race against openapi-fetch's
43+
// own query serialiser.
44+
const items: MaintenanceWindowDto[] = []
45+
let page = 0
46+
while (true) {
47+
const resp = await apiGetPage<MaintenanceWindowDto>(
48+
client,
49+
'/api/v1/maintenance-windows',
50+
schema,
51+
{
52+
query: {
53+
page,
54+
size: flags['page-size'],
55+
...(flags.status ? {filter: flags.status} : {}),
56+
...(flags.monitor ? {monitorId: flags.monitor} : {}),
57+
},
58+
},
59+
)
60+
items.push(...resp.data)
61+
if (!resp.hasNext) break
62+
page++
63+
}
64+
65+
display(this, items, flags.output, [
66+
{header: 'ID', get: (r: MaintenanceWindowDto) => r.id ?? ''},
67+
{header: 'MONITOR', get: (r: MaintenanceWindowDto) => r.monitorId ?? '(org-wide)'},
68+
{header: 'STARTS', get: (r: MaintenanceWindowDto) => r.startsAt ?? ''},
69+
{header: 'ENDS', get: (r: MaintenanceWindowDto) => r.endsAt ?? ''},
70+
{header: 'STATUS', get: (r: MaintenanceWindowDto) => computeStatus(r)},
71+
{header: 'SUPPRESS', get: (r: MaintenanceWindowDto) => String(r.suppressAlerts ?? '')},
72+
{header: 'REASON', get: (r: MaintenanceWindowDto) => r.reason ?? ''},
73+
])
74+
}
75+
}
76+
77+
// Best-effort lifecycle label derived from the timestamps. The server only
78+
// distinguishes `active` and `upcoming`; we surface `past` so the table
79+
// is still readable when no filter was applied.
80+
function computeStatus(window: MaintenanceWindowDto): string {
81+
const now = Date.now()
82+
const start = Date.parse(window.startsAt ?? '')
83+
const end = Date.parse(window.endsAt ?? '')
84+
if (Number.isFinite(end) && end < now) return 'past'
85+
if (Number.isFinite(start) && start > now) return 'upcoming'
86+
return 'active'
87+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {Command, Flags} from '@oclif/core'
2+
import type {ZodType} from 'zod'
3+
import {globalFlags, buildClient, display} from '../../lib/base-command.js'
4+
import {apiGetSingle, apiPutSingle} from '../../lib/api-client.js'
5+
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
6+
import type {components} from '../../lib/api.generated.js'
7+
import {fieldDescriptions} from '../../lib/descriptions.generated.js'
8+
import {parse as parseSchema} from '../../lib/response-validation.js'
9+
import {uuidArg, uuidFlag} from '../../lib/validators.js'
10+
import {buildMaintenanceWindowBody} from '../../lib/maintenance-windows.js'
11+
12+
type MaintenanceWindowDto = components['schemas']['MaintenanceWindowDto']
13+
14+
const desc = (field: string, fallback?: string) =>
15+
fieldDescriptions['UpdateMaintenanceWindowRequest']?.[field] ?? fallback ?? field
16+
17+
export default class MaintenanceWindowsUpdate extends Command {
18+
static description = 'Update a maintenance window'
19+
static examples = [
20+
'<%= config.bin %> maintenance-windows update <id> --reason "Rescheduled deploy"',
21+
'<%= config.bin %> maintenance-windows update <id> --start 2026-06-01T15:00:00Z --end 2026-06-01T15:30:00Z',
22+
'<%= config.bin %> maintenance-windows update <id> --monitor <uuid>',
23+
]
24+
static args = {id: uuidArg({description: 'Maintenance window ID', required: true})}
25+
static flags = {
26+
...globalFlags,
27+
start: Flags.string({description: desc('startsAt')}),
28+
end: Flags.string({description: desc('endsAt')}),
29+
reason: Flags.string({description: desc('reason') + ' (pass an empty string to clear)'}),
30+
monitor: uuidFlag({description: 'Reassign the window to a different monitor'}),
31+
'org-wide': Flags.boolean({
32+
description: 'Convert the window to org-wide (mutually exclusive with --monitor)',
33+
default: false,
34+
exclusive: ['monitor'],
35+
}),
36+
'repeat-rule': Flags.string({description: desc('repeatRule') + ' (empty string clears)'}),
37+
'suppress-alerts': Flags.boolean({description: desc('suppressAlerts'), allowNo: true}),
38+
}
39+
40+
async run() {
41+
const {args, flags} = await this.parse(MaintenanceWindowsUpdate)
42+
const client = buildClient(flags)
43+
const path = `/api/v1/maintenance-windows/${args.id}`
44+
45+
// The server's update DTO requires `startsAt` and `endsAt` (they're
46+
// not nullish on UpdateMaintenanceWindowRequest), but partial updates
47+
// are still useful — e.g. `--reason "Rescheduled"` alone. Fetch the
48+
// current window and back-fill any timestamp the user didn't pass so
49+
// the round-trip stays semantically a partial update.
50+
const existing = await apiGetSingle<MaintenanceWindowDto>(
51+
client,
52+
path,
53+
apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>,
54+
)
55+
56+
const built = buildMaintenanceWindowBody({
57+
start: flags.start ?? existing.startsAt,
58+
end: flags.end ?? existing.endsAt,
59+
reason: flags.reason,
60+
monitor: flags.monitor,
61+
orgWide: flags['org-wide'],
62+
repeatRule: flags['repeat-rule'],
63+
suppressAlerts: flags['suppress-alerts'],
64+
}, 'update')
65+
66+
const body = parseSchema(
67+
apiSchemas.UpdateMaintenanceWindowRequest,
68+
built,
69+
'maintenance-window.update body invalid',
70+
)
71+
72+
const updated = await apiPutSingle<MaintenanceWindowDto>(
73+
client,
74+
path,
75+
apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>,
76+
body,
77+
)
78+
display(this, updated, flags.output)
79+
}
80+
}

0 commit comments

Comments
 (0)