Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Every resource supports `list`, `get`, `create`, `update`, and `delete` subcomma
| Resource | Commands | API Path |
|----------|----------|----------|
| `monitors` | list, get, create, update, delete, pause, resume, test, results | `/api/v1/monitors` |
| `monitors test --config <file>` | validate a YAML/JSON CreateMonitorRequest before saving | `/api/v1/monitors/test` |
| `maintenance-windows` | list, get, create, update, cancel | `/api/v1/maintenance-windows` |
| `incidents` | list, get, create, update, delete, resolve | `/api/v1/incidents` |
| `alert-channels` | list, get, create, update, delete, test | `/api/v1/alert-channels` |
| `notification-policies` | list, get, create, update, delete, test | `/api/v1/notification-policies` |
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
"incidents": {
"description": "Create, inspect, and resolve incidents"
},
"maintenance-windows": {
"description": "Schedule downtime windows that suppress alerts during planned changes"
},
"monitors": {
"description": "Manage HTTP, TCP, DNS, ICMP, MCP, and heartbeat monitors"
},
Expand Down
1 change: 1 addition & 0 deletions scripts/extract-descriptions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const TARGET_SCHEMAS = [
'CreateResourceGroupRequest', 'UpdateResourceGroupRequest',
'CreateWebhookEndpointRequest', 'UpdateWebhookEndpointRequest',
'CreateApiKeyRequest', 'UpdateApiKeyRequest',
'CreateMaintenanceWindowRequest', 'UpdateMaintenanceWindowRequest',
'ResolveIncidentRequest', 'MonitorTestRequest',
'AcquireDeployLockRequest',
'HttpMonitorConfig', 'TcpMonitorConfig', 'DnsMonitorConfig',
Expand Down
83 changes: 83 additions & 0 deletions src/commands/maintenance-windows/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {Command, Flags} from '@oclif/core'
import type {ZodType} from 'zod'
import {globalFlags, buildClient} from '../../lib/base-command.js'
import {apiDelete, apiGetSingle} from '../../lib/api-client.js'
import {DevhelmAuthError, DevhelmNotFoundError, EXIT_CODES} from '../../lib/errors.js'
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
import type {components} from '../../lib/api.generated.js'
import {uuidArg} from '../../lib/validators.js'

type MaintenanceWindowDto = components['schemas']['MaintenanceWindowDto']

export default class MaintenanceWindowsCancel extends Command {
static description = 'Cancel a maintenance window (deletes scheduled or active windows)'
static examples = [
'<%= config.bin %> maintenance-windows cancel <id>',
'<%= config.bin %> maintenance-windows cancel <id> --yes',
]
static args = {id: uuidArg({description: 'Maintenance window ID', required: true})}
static flags = {
...globalFlags,
yes: Flags.boolean({
char: 'y',
description: 'Skip the interactive confirmation prompt',
default: false,
}),
}

async run() {
const {args, flags} = await this.parse(MaintenanceWindowsCancel)
const client = buildClient(flags)
const path = `/api/v1/maintenance-windows/${args.id}`

if (!flags.yes) {
if (!process.stdin.isTTY) {
// Mirrors the safety check in the generic `delete` factory: in
// CI / piped invocations we refuse to silently confirm.
this.error(
`Refusing to cancel maintenance window '${args.id}' in non-interactive mode without --yes (or -y).`,
{exit: EXIT_CODES.VALIDATION},
)
}
const confirmed = await promptForCancel(client, path, args.id)
if (!confirmed) {
this.log('Cancelled.')
return
}
}

await apiDelete(client, path)
this.log(`Maintenance window '${args.id}' cancelled.`)
}
}

async function promptForCancel(
client: ReturnType<typeof buildClient>,
path: string,
id: string,
): Promise<boolean> {
let label = `'${id}'`
try {
const value = await apiGetSingle<MaintenanceWindowDto>(
client,
path,
apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>,
)
const reason = value.reason ?? '(no reason)'
label = `'${reason}' (${id}, ${value.startsAt} → ${value.endsAt})`
} catch (err) {
// Surface auth/not-found before the destructive action; swallow
// anything else so we still prompt with the bare id rather than
// blocking a cancel that would otherwise succeed.
if (err instanceof DevhelmAuthError || err instanceof DevhelmNotFoundError) throw err
}

const {createInterface} = await import('node:readline')
const rl = createInterface({input: process.stdin, output: process.stderr})
const answer = await new Promise<string>((resolve) => {
rl.question(`Cancel maintenance window ${label}? [y/N] `, resolve)
})
rl.close()
const normalized = answer.trim().toLowerCase()
return normalized === 'y' || normalized === 'yes'
}
73 changes: 73 additions & 0 deletions src/commands/maintenance-windows/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {Command, Flags} from '@oclif/core'
import type {ZodType} from 'zod'
import {globalFlags, buildClient, display} from '../../lib/base-command.js'
import {apiPostSingle} from '../../lib/api-client.js'
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
import type {components} from '../../lib/api.generated.js'
import {fieldDescriptions} from '../../lib/descriptions.generated.js'
import {parse as parseSchema} from '../../lib/response-validation.js'
import {uuidFlag} from '../../lib/validators.js'
import {buildMaintenanceWindowBody} from '../../lib/maintenance-windows.js'

type MaintenanceWindowDto = components['schemas']['MaintenanceWindowDto']

const desc = (field: string, fallback?: string) =>
fieldDescriptions['CreateMaintenanceWindowRequest']?.[field] ?? fallback ?? field

export default class MaintenanceWindowsCreate extends Command {
static description = 'Schedule a new maintenance window'
static examples = [
'<%= config.bin %> maintenance-windows create --start 2026-06-01T14:00:00Z --end 2026-06-01T14:30:00Z --reason "Deploy" --monitor <uuid>',
'<%= config.bin %> maintenance-windows create --start 2026-06-01T14:00:00Z --end 2026-06-01T14:30:00Z --reason "Org-wide outage" --org-wide',
]
static flags = {
...globalFlags,
start: Flags.string({description: desc('startsAt'), required: true}),
end: Flags.string({description: desc('endsAt'), required: true}),
reason: Flags.string({description: desc('reason')}),
monitor: uuidFlag({description: 'Monitor ID this window applies to'}),
'org-wide': Flags.boolean({
description: 'Apply this window to every monitor in the org (mutually exclusive with --monitor)',
default: false,
exclusive: ['monitor'],
}),
'repeat-rule': Flags.string({description: desc('repeatRule')}),
'suppress-alerts': Flags.boolean({
description: desc('suppressAlerts'),
allowNo: true,
}),
}

async run() {
const {flags} = await this.parse(MaintenanceWindowsCreate)
const client = buildClient(flags)

if (!flags.monitor && !flags['org-wide']) {
this.error('Pass --monitor <uuid> or --org-wide to scope the window.', {exit: 2})
}

const built = buildMaintenanceWindowBody({
start: flags.start,
end: flags.end,
reason: flags.reason,
monitor: flags.monitor,
orgWide: flags['org-wide'],
repeatRule: flags['repeat-rule'],
suppressAlerts: flags['suppress-alerts'],
}, 'create')

const body = parseSchema(
apiSchemas.CreateMaintenanceWindowRequest,
built,
'maintenance-window.create body invalid',
)

const created = await apiPostSingle<MaintenanceWindowDto>(
client,
'/api/v1/maintenance-windows',
apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>,
body,
)
display(this, created, flags.output)
}
}
27 changes: 27 additions & 0 deletions src/commands/maintenance-windows/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {Command} from '@oclif/core'
import type {ZodType} from 'zod'
import {globalFlags, buildClient, display} from '../../lib/base-command.js'
import {apiGetSingle} from '../../lib/api-client.js'
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
import type {components} from '../../lib/api.generated.js'
import {uuidArg} from '../../lib/validators.js'

type MaintenanceWindowDto = components['schemas']['MaintenanceWindowDto']

export default class MaintenanceWindowsGet extends Command {
static description = 'Get a maintenance window by id'
static examples = ['<%= config.bin %> maintenance-windows get <id>']
static args = {id: uuidArg({description: 'Maintenance window ID', required: true})}
static flags = {...globalFlags}

async run() {
const {args, flags} = await this.parse(MaintenanceWindowsGet)
const client = buildClient(flags)
const value = await apiGetSingle<MaintenanceWindowDto>(
client,
`/api/v1/maintenance-windows/${args.id}`,
apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>,
)
display(this, value, flags.output)
}
}
87 changes: 87 additions & 0 deletions src/commands/maintenance-windows/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {Command, Flags} from '@oclif/core'
import type {ZodType} from 'zod'
import {globalFlags, buildClient, display} from '../../lib/base-command.js'
import {apiGetPage} from '../../lib/api-client.js'
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
import type {components} from '../../lib/api.generated.js'
import {uuidFlag} from '../../lib/validators.js'

type MaintenanceWindowDto = components['schemas']['MaintenanceWindowDto']

// API-supported filter values for `GET /api/v1/maintenance-windows?filter=...`.
// The server understands `active` (in-progress now) and `upcoming` (future).
// Past windows are not server-filterable today — pass no filter and inspect
// the timestamps locally if you need them.
const STATUS_OPTIONS = ['active', 'upcoming'] as const

export default class MaintenanceWindowsList extends Command {
static description = 'List all maintenance windows'
static examples = [
'<%= config.bin %> maintenance-windows list',
'<%= config.bin %> maintenance-windows list --status active',
'<%= config.bin %> maintenance-windows list --monitor <uuid>',
]
static flags = {
...globalFlags,
'page-size': Flags.integer({description: 'Number of items per API request (1–200)', default: 200}),
status: Flags.string({
description: 'Filter by lifecycle state (server-supported: active, upcoming)',
options: [...STATUS_OPTIONS],
}),
monitor: uuidFlag({description: 'Only show windows attached to this monitor ID'}),
}

async run() {
const {flags} = await this.parse(MaintenanceWindowsList)
const client = buildClient(flags)
const schema = apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>

// Roll our own pagination loop because the shared
// `fetchPaginatedValidated` helper accepts only page/size — this
// endpoint also takes `monitorId` and `filter` query params, and
// appending them to the path string would race against openapi-fetch's
// own query serialiser.
const items: MaintenanceWindowDto[] = []
let page = 0
while (true) {
const resp = await apiGetPage<MaintenanceWindowDto>(
client,
'/api/v1/maintenance-windows',
schema,
{
query: {
page,
size: flags['page-size'],
...(flags.status ? {filter: flags.status} : {}),
...(flags.monitor ? {monitorId: flags.monitor} : {}),
},
},
)
items.push(...resp.data)
if (!resp.hasNext) break
page++
}

display(this, items, flags.output, [
{header: 'ID', get: (r: MaintenanceWindowDto) => r.id ?? ''},
{header: 'MONITOR', get: (r: MaintenanceWindowDto) => r.monitorId ?? '(org-wide)'},
{header: 'STARTS', get: (r: MaintenanceWindowDto) => r.startsAt ?? ''},
{header: 'ENDS', get: (r: MaintenanceWindowDto) => r.endsAt ?? ''},
{header: 'STATUS', get: (r: MaintenanceWindowDto) => computeStatus(r)},
{header: 'SUPPRESS', get: (r: MaintenanceWindowDto) => String(r.suppressAlerts ?? '')},
{header: 'REASON', get: (r: MaintenanceWindowDto) => r.reason ?? ''},
])
}
}

// Best-effort lifecycle label derived from the timestamps. The server only
// distinguishes `active` and `upcoming`; we surface `past` so the table
// is still readable when no filter was applied.
function computeStatus(window: MaintenanceWindowDto): string {
const now = Date.now()
const start = Date.parse(window.startsAt ?? '')
const end = Date.parse(window.endsAt ?? '')
if (Number.isFinite(end) && end < now) return 'past'
if (Number.isFinite(start) && start > now) return 'upcoming'
return 'active'
}
80 changes: 80 additions & 0 deletions src/commands/maintenance-windows/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {Command, Flags} from '@oclif/core'
import type {ZodType} from 'zod'
import {globalFlags, buildClient, display} from '../../lib/base-command.js'
import {apiGetSingle, apiPutSingle} from '../../lib/api-client.js'
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
import type {components} from '../../lib/api.generated.js'
import {fieldDescriptions} from '../../lib/descriptions.generated.js'
import {parse as parseSchema} from '../../lib/response-validation.js'
import {uuidArg, uuidFlag} from '../../lib/validators.js'
import {buildMaintenanceWindowBody} from '../../lib/maintenance-windows.js'

type MaintenanceWindowDto = components['schemas']['MaintenanceWindowDto']

const desc = (field: string, fallback?: string) =>
fieldDescriptions['UpdateMaintenanceWindowRequest']?.[field] ?? fallback ?? field

export default class MaintenanceWindowsUpdate extends Command {
static description = 'Update a maintenance window'
static examples = [
'<%= config.bin %> maintenance-windows update <id> --reason "Rescheduled deploy"',
'<%= config.bin %> maintenance-windows update <id> --start 2026-06-01T15:00:00Z --end 2026-06-01T15:30:00Z',
'<%= config.bin %> maintenance-windows update <id> --monitor <uuid>',
]
static args = {id: uuidArg({description: 'Maintenance window ID', required: true})}
static flags = {
...globalFlags,
start: Flags.string({description: desc('startsAt')}),
end: Flags.string({description: desc('endsAt')}),
reason: Flags.string({description: desc('reason') + ' (pass an empty string to clear)'}),
monitor: uuidFlag({description: 'Reassign the window to a different monitor'}),
'org-wide': Flags.boolean({
description: 'Convert the window to org-wide (mutually exclusive with --monitor)',
default: false,
exclusive: ['monitor'],
}),
'repeat-rule': Flags.string({description: desc('repeatRule') + ' (empty string clears)'}),
'suppress-alerts': Flags.boolean({description: desc('suppressAlerts'), allowNo: true}),
}

async run() {
const {args, flags} = await this.parse(MaintenanceWindowsUpdate)
const client = buildClient(flags)
const path = `/api/v1/maintenance-windows/${args.id}`

// The server's update DTO requires `startsAt` and `endsAt` (they're
// not nullish on UpdateMaintenanceWindowRequest), but partial updates
// are still useful — e.g. `--reason "Rescheduled"` alone. Fetch the
// current window and back-fill any timestamp the user didn't pass so
// the round-trip stays semantically a partial update.
const existing = await apiGetSingle<MaintenanceWindowDto>(
client,
path,
apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>,
)

const built = buildMaintenanceWindowBody({
start: flags.start ?? existing.startsAt,
end: flags.end ?? existing.endsAt,
reason: flags.reason,
monitor: flags.monitor,
orgWide: flags['org-wide'],
repeatRule: flags['repeat-rule'],
suppressAlerts: flags['suppress-alerts'],
}, 'update')

const body = parseSchema(
apiSchemas.UpdateMaintenanceWindowRequest,
built,
'maintenance-window.update body invalid',
)

const updated = await apiPutSingle<MaintenanceWindowDto>(
client,
path,
apiSchemas.MaintenanceWindowDto as ZodType<MaintenanceWindowDto>,
body,
)
display(this, updated, flags.output)
}
}
Loading
Loading