diff --git a/README.md b/README.md index 946e20a..147564d 100644 --- a/README.md +++ b/README.md @@ -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 ` | 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` | diff --git a/package.json b/package.json index ce0dc22..d34da03 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/extract-descriptions.mjs b/scripts/extract-descriptions.mjs index 2a03fee..096b7c9 100644 --- a/scripts/extract-descriptions.mjs +++ b/scripts/extract-descriptions.mjs @@ -29,6 +29,7 @@ const TARGET_SCHEMAS = [ 'CreateResourceGroupRequest', 'UpdateResourceGroupRequest', 'CreateWebhookEndpointRequest', 'UpdateWebhookEndpointRequest', 'CreateApiKeyRequest', 'UpdateApiKeyRequest', + 'CreateMaintenanceWindowRequest', 'UpdateMaintenanceWindowRequest', 'ResolveIncidentRequest', 'MonitorTestRequest', 'AcquireDeployLockRequest', 'HttpMonitorConfig', 'TcpMonitorConfig', 'DnsMonitorConfig', diff --git a/src/commands/maintenance-windows/cancel.ts b/src/commands/maintenance-windows/cancel.ts new file mode 100644 index 0000000..4bab2ee --- /dev/null +++ b/src/commands/maintenance-windows/cancel.ts @@ -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 ', + '<%= config.bin %> maintenance-windows cancel --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, + path: string, + id: string, +): Promise { + let label = `'${id}'` + try { + const value = await apiGetSingle( + client, + path, + apiSchemas.MaintenanceWindowDto as ZodType, + ) + 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((resolve) => { + rl.question(`Cancel maintenance window ${label}? [y/N] `, resolve) + }) + rl.close() + const normalized = answer.trim().toLowerCase() + return normalized === 'y' || normalized === 'yes' +} diff --git a/src/commands/maintenance-windows/create.ts b/src/commands/maintenance-windows/create.ts new file mode 100644 index 0000000..e226f67 --- /dev/null +++ b/src/commands/maintenance-windows/create.ts @@ -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 ', + '<%= 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 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( + client, + '/api/v1/maintenance-windows', + apiSchemas.MaintenanceWindowDto as ZodType, + body, + ) + display(this, created, flags.output) + } +} diff --git a/src/commands/maintenance-windows/get.ts b/src/commands/maintenance-windows/get.ts new file mode 100644 index 0000000..dd7f5f8 --- /dev/null +++ b/src/commands/maintenance-windows/get.ts @@ -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 '] + 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( + client, + `/api/v1/maintenance-windows/${args.id}`, + apiSchemas.MaintenanceWindowDto as ZodType, + ) + display(this, value, flags.output) + } +} diff --git a/src/commands/maintenance-windows/list.ts b/src/commands/maintenance-windows/list.ts new file mode 100644 index 0000000..6ccaca0 --- /dev/null +++ b/src/commands/maintenance-windows/list.ts @@ -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 ', + ] + 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 + + // 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( + 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' +} diff --git a/src/commands/maintenance-windows/update.ts b/src/commands/maintenance-windows/update.ts new file mode 100644 index 0000000..6f97521 --- /dev/null +++ b/src/commands/maintenance-windows/update.ts @@ -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 --reason "Rescheduled deploy"', + '<%= config.bin %> maintenance-windows update --start 2026-06-01T15:00:00Z --end 2026-06-01T15:30:00Z', + '<%= config.bin %> maintenance-windows update --monitor ', + ] + 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( + client, + path, + apiSchemas.MaintenanceWindowDto as ZodType, + ) + + 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( + client, + path, + apiSchemas.MaintenanceWindowDto as ZodType, + body, + ) + display(this, updated, flags.output) + } +} diff --git a/src/commands/monitors/test.ts b/src/commands/monitors/test.ts index cb595e3..d442b51 100644 --- a/src/commands/monitors/test.ts +++ b/src/commands/monitors/test.ts @@ -1,19 +1,149 @@ -import {Command} from '@oclif/core' +import {readFileSync, existsSync} from 'node:fs' +import {extname, resolve} from 'node:path' +import {Args, Command, Flags} from '@oclif/core' +import {parse as parseYaml} from 'yaml' import {globalFlags, buildClient, display} from '../../lib/base-command.js' import {checkedFetch, unwrapData} from '../../lib/api-client.js' -import {uuidArg} from '../../lib/validators.js' +import {schemas as apiSchemas} from '../../lib/api-zod.generated.js' +import {DevhelmValidationError, EXIT_CODES} from '../../lib/errors.js' + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i export default class MonitorsTest extends Command { - static description = 'Run an ad-hoc test for a monitor' - static examples = ['<%= config.bin %> monitors test 42'] - static args = {id: uuidArg({description: 'Monitor ID', required: true})} - static flags = {...globalFlags} + static description = + 'Run an ad-hoc test for a monitor. Pass an existing id, or use --config to validate a proposed config (YAML or JSON) without persisting it.' + static examples = [ + '<%= config.bin %> monitors test 42', + '<%= config.bin %> monitors test --config monitor.yml', + '<%= config.bin %> monitors test --config monitor.json --output json', + ] + // The id arg is now optional because `--config` is an alternative + // entry point. We can't reuse `uuidArg` here (it pins `required: true`), + // so we run the UUID format check inline once we know which mode the + // user picked. + static args = { + id: Args.string({description: 'Monitor ID', required: false}), + } + static flags = { + ...globalFlags, + config: Flags.string({ + description: + 'Path to a YAML or JSON file containing a CreateMonitorRequest payload to validate (and dry-run, when no id is given)', + }), + } async run() { const {args, flags} = await this.parse(MonitorsTest) + const id = args.id + + if (id && flags.config) { + this.error( + 'Pass either a monitor id (live test against an existing monitor) or --config (validate a proposed config), not both.', + {exit: EXIT_CODES.VALIDATION}, + ) + } + if (!id && !flags.config) { + this.error( + 'Pass a monitor id to run a live test, or --config to validate a proposed monitor configuration.', + {exit: EXIT_CODES.VALIDATION}, + ) + } + const client = buildClient(flags) + + if (flags.config) { + await this.runConfigCheck(flags.config, flags.output, client) + return + } + + if (!UUID_RE.test(id as string)) { + this.error( + `Invalid UUID format: got '${id}', expected xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, + {exit: EXIT_CODES.VALIDATION}, + ) + } + this.log('Running test...') - const resp = await checkedFetch(client.POST('/api/v1/monitors/{id}/test', {params: {path: {id: args.id}}})) + const resp = await checkedFetch( + client.POST('/api/v1/monitors/{id}/test', {params: {path: {id: id as string}}}), + ) display(this, unwrapData(resp), flags.output) } + + // Validates the file locally against `CreateMonitorRequest`; if the + // shape is valid, also dispatches a `MonitorTestRequest` (the + // server-supported subset) to `/api/v1/monitors/test` so the user + // sees a real probe result for the proposed config — same data they'd + // get from `monitors test ` after persisting. + private async runConfigCheck( + path: string, + outputFormat: string, + client: ReturnType, + ): Promise { + const parsed = readConfigFile(path) + + const result = apiSchemas.CreateMonitorRequest.safeParse(parsed) + if (!result.success) { + const issues = result.error.issues + .map((i) => ` - ${i.path.length > 0 ? i.path.join('.') : ''}: ${i.message}`) + .join('\n') + this.error(`Config '${path}' failed CreateMonitorRequest validation:\n${issues}`, { + exit: EXIT_CODES.VALIDATION, + }) + } + + this.log(`✓ Config '${path}' is valid against CreateMonitorRequest.`) + + // The dry-run endpoint only accepts the test subset of the create + // request; strip the persistence-only fields (`name`, `frequencySeconds`, + // `regions`, …) before posting. The MonitorTestRequest schema parse + // catches anything that doesn't pass through cleanly. + const testBody: Record = { + type: result.data.type, + config: result.data.config, + } + if (result.data.assertions) testBody.assertions = result.data.assertions + + const testParse = apiSchemas.MonitorTestRequest.safeParse(testBody) + if (!testParse.success) { + this.warn( + 'Skipping probe dispatch — could not derive a MonitorTestRequest from the config. ' + + 'The config validates as a CreateMonitorRequest but contains shape variants the test endpoint does not accept.', + ) + return + } + + this.log('Dispatching probe simulation against /api/v1/monitors/test...') + const resp = await checkedFetch( + client.POST('/api/v1/monitors/test', {body: testParse.data}), + ) + display(this, unwrapData(resp), outputFormat) + } +} + +// Reads the file and parses it as YAML or JSON based on extension. JSON +// is round-tripped through `parseYaml` (yaml is a strict superset of +// JSON) so `.json`, `.yml`, `.yaml`, and extension-less files all work. +// Returns `unknown` so the caller can hand the value straight to the +// Zod safe-parser without an `as any`. +function readConfigFile(path: string): unknown { + const absPath = resolve(path) + if (!existsSync(absPath)) { + throw new DevhelmValidationError(`Config file not found: ${path}`) + } + + const raw = readFileSync(absPath, 'utf8') + const ext = extname(absPath).toLowerCase() + + try { + if (ext === '.json') { + return JSON.parse(raw) as unknown + } + return parseYaml(raw) as unknown + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + throw new DevhelmValidationError( + `Failed to parse '${path}' as ${ext === '.json' ? 'JSON' : 'YAML'}: ${msg}`, + ) + } } diff --git a/src/lib/descriptions.generated.ts b/src/lib/descriptions.generated.ts index bee76e0..fd881af 100644 --- a/src/lib/descriptions.generated.ts +++ b/src/lib/descriptions.generated.ts @@ -122,6 +122,22 @@ export const fieldDescriptions: Record> = "UpdateApiKeyRequest": { "name": "New name for this API key" }, + "CreateMaintenanceWindowRequest": { + "monitorId": "Monitor to attach this maintenance window to; null for org-wide", + "startsAt": "Scheduled start of the maintenance window (ISO 8601)", + "endsAt": "Scheduled end of the maintenance window (ISO 8601)", + "repeatRule": "iCal RRULE for recurring windows (max 100 chars); null for one-time", + "reason": "Human-readable reason for the maintenance", + "suppressAlerts": "Whether to suppress alerts during this window (default: true)" + }, + "UpdateMaintenanceWindowRequest": { + "monitorId": "Monitor to attach this maintenance window to; null preserves current", + "startsAt": "Updated start time (ISO 8601)", + "endsAt": "Updated end time (ISO 8601)", + "repeatRule": "Updated iCal RRULE; null clears the repeat rule", + "reason": "Updated reason; null clears the existing reason", + "suppressAlerts": "Whether to suppress alerts; null preserves current" + }, "ResolveIncidentRequest": { "body": "Optional resolution message or post-mortem notes" }, diff --git a/src/lib/maintenance-windows.ts b/src/lib/maintenance-windows.ts new file mode 100644 index 0000000..03b1f67 --- /dev/null +++ b/src/lib/maintenance-windows.ts @@ -0,0 +1,66 @@ +/** + * Shared body builder for `maintenance-windows create` / `update`. + * + * Both commands map identical flags onto `CreateMaintenanceWindowRequest` + * / `UpdateMaintenanceWindowRequest`. The two request DTOs have the + * same field set today (server requires `startsAt` / `endsAt` on + * update too), so a single builder keeps the flag → body mapping + * in one place. + */ + +export interface MaintenanceWindowFlags { + start?: string + end?: string + reason?: string + monitor?: string + orgWide?: boolean + repeatRule?: string + suppressAlerts?: boolean + /** + * `update` mode also accepts an explicit "clear" intent, e.g. when + * the user passes `--reason ""` to wipe a previous value. Today + * we surface that via empty string only — keeping the API's nullish + * semantics (`null` clears the field, omission preserves it). + */ +} + +type Mode = 'create' | 'update' + +/** + * Returns a plain `Record` ready to be parsed against + * the matching Zod schema. Empty `--reason ""` is normalised to `null` + * so the user can explicitly clear the field on update; on create the + * server treats `null` and omission identically. + */ +export function buildMaintenanceWindowBody( + flags: MaintenanceWindowFlags, + _mode: Mode, +): Record { + const body: Record = {} + + if (flags.start !== undefined) body.startsAt = flags.start + if (flags.end !== undefined) body.endsAt = flags.end + + // `--monitor` and `--org-wide` are mutually exclusive at the flag + // layer (the create command also runs an explicit guard); here we + // just translate one or the other into the wire field. + if (flags.orgWide) { + body.monitorId = null + } else if (flags.monitor !== undefined) { + body.monitorId = flags.monitor + } + + if (flags.reason !== undefined) { + body.reason = flags.reason === '' ? null : flags.reason + } + + if (flags.repeatRule !== undefined) { + body.repeatRule = flags.repeatRule === '' ? null : flags.repeatRule + } + + if (flags.suppressAlerts !== undefined) { + body.suppressAlerts = flags.suppressAlerts + } + + return body +} diff --git a/test/commands/maintenance-windows.test.ts b/test/commands/maintenance-windows.test.ts new file mode 100644 index 0000000..e416589 --- /dev/null +++ b/test/commands/maintenance-windows.test.ts @@ -0,0 +1,68 @@ +import {expect, test, describe} from 'vitest' +import {execSync} from 'node:child_process' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(__dirname, '..', '..') + +function run(argv: string): string { + // Some help / --help invocations exit 0; mutual-exclusion errors exit + // non-zero. Capture stderr too so the assertions can match either. + try { + return execSync(`node bin/dev.js ${argv}`, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + } catch (err) { + // execSync throws on non-zero exit; normalize to stdout+stderr. + const e = err as {stdout?: string; stderr?: string; status?: number} + return `${e.stdout ?? ''}${e.stderr ?? ''}` + } +} + +describe('maintenance-windows topic', () => { + test('topic --help lists every subcommand', () => { + const out = run('maintenance-windows --help') + for (const cmd of ['list', 'get', 'create', 'update', 'cancel']) { + expect(out).toContain(`maintenance-windows ${cmd}`) + } + expect(out).toContain('Schedule downtime windows') + }) + + test('list --help advertises the server-supported status filter', () => { + const out = run('maintenance-windows list --help') + expect(out).toContain('--status') + // Past / cancelled are intentionally NOT listed — the API only filters + // on active / upcoming and we shouldn't pretend otherwise. + expect(out).toContain('active|upcoming') + }) + + test('create --help requires --start and --end', () => { + const out = run('maintenance-windows create --help') + expect(out).toContain('--start=') + expect(out).toContain('--end=') + expect(out).toMatch(/--monitor[^\n]*Monitor ID/) + expect(out).toContain('--org-wide') + }) + + test('create errors when neither --monitor nor --org-wide is provided', () => { + const out = run( + 'maintenance-windows create --start 2026-06-01T14:00:00Z --end 2026-06-01T14:30:00Z --reason Deploy --api-token devhelm-dev-token --api-url http://127.0.0.1:9999', + ) + expect(out).toContain('Pass --monitor or --org-wide') + }) + + test('create rejects passing both --monitor and --org-wide', () => { + const out = run( + 'maintenance-windows create --start 2026-06-01T14:00:00Z --end 2026-06-01T14:30:00Z --reason Deploy --monitor 11111111-1111-1111-1111-111111111111 --org-wide --api-token devhelm-dev-token --api-url http://127.0.0.1:9999', + ) + expect(out).toContain('cannot also be provided when using --org-wide') + }) + + test('cancel --help mentions --yes', () => { + const out = run('maintenance-windows cancel --help') + expect(out).toContain('--yes') + }) +}) diff --git a/test/commands/monitors-test-config.test.ts b/test/commands/monitors-test-config.test.ts new file mode 100644 index 0000000..51ee01b --- /dev/null +++ b/test/commands/monitors-test-config.test.ts @@ -0,0 +1,97 @@ +import {expect, test, describe, beforeEach, afterEach} from 'vitest' +import {execSync} from 'node:child_process' +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(__dirname, '..', '..') + +function run(argv: string): string { + try { + return execSync(`node bin/dev.js ${argv}`, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + } catch (err) { + const e = err as {stdout?: string; stderr?: string; status?: number} + return `${e.stdout ?? ''}${e.stderr ?? ''}` + } +} + +describe('monitors test --config', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'devhelm-monitors-test-')) + }) + + afterEach(() => { + rmSync(dir, {recursive: true, force: true}) + }) + + function write(name: string, contents: string): string { + const p = join(dir, name) + writeFileSync(p, contents, 'utf8') + return p + } + + test('--help advertises both the id arg and the --config flag', () => { + const out = run('monitors test --help') + expect(out).toContain('[ID]') + expect(out).toContain('--config=') + expect(out).toMatch(/--config[\s\S]*CreateMonitorRequest/) + }) + + test('errors when neither id nor --config is given', () => { + const out = run('monitors test --api-token devhelm-dev-token --api-url http://127.0.0.1:9999') + expect(out).toContain('Pass a monitor id to run a live test, or --config ') + }) + + test('errors when both id and --config are given', () => { + const cfg = write( + 'm.yml', + 'name: x\ntype: HTTP\nmanagedBy: CLI\nconfig:\n url: https://example.com\n method: GET\n', + ) + const out = run( + `monitors test 11111111-1111-1111-1111-111111111111 --config ${cfg} --api-token devhelm-dev-token --api-url http://127.0.0.1:9999`, + ) + expect(out).toContain('Pass either a monitor id') + }) + + test('reports a clear validation error for a malformed config', () => { + const cfg = write('bad.yml', 'name: bad\ntype: HTTP\nconfig:\n url: not-a-url\n') + const out = run( + `monitors test --config ${cfg} --api-token devhelm-dev-token --api-url http://127.0.0.1:9999`, + ) + expect(out).toContain('failed CreateMonitorRequest validation') + expect(out).toContain('managedBy') + }) + + test('errors when the config file does not exist', () => { + const out = run( + `monitors test --config ${join(dir, 'missing.yml')} --api-token devhelm-dev-token --api-url http://127.0.0.1:9999`, + ) + expect(out).toContain('Config file not found') + }) + + test('accepts a JSON config and prints the validation success line', () => { + const cfg = write( + 'm.json', + JSON.stringify({ + name: 'json-monitor', + type: 'HTTP', + managedBy: 'CLI', + config: {url: 'https://example.com', method: 'GET'}, + }), + ) + const out = run( + `monitors test --config ${cfg} --api-token devhelm-dev-token --api-url http://127.0.0.1:9999`, + ) + // Validation line should print before the (expected) network failure + // when the harness can't reach the dummy --api-url. + expect(out).toContain('is valid against CreateMonitorRequest') + }) +}) diff --git a/test/lib/maintenance-windows.test.ts b/test/lib/maintenance-windows.test.ts new file mode 100644 index 0000000..9fb115c --- /dev/null +++ b/test/lib/maintenance-windows.test.ts @@ -0,0 +1,61 @@ +import {describe, expect, it} from 'vitest' +import {buildMaintenanceWindowBody} from '../../src/lib/maintenance-windows.js' + +describe('buildMaintenanceWindowBody', () => { + it('produces a minimal create body when only start/end/monitor are given', () => { + const body = buildMaintenanceWindowBody( + { + start: '2026-06-01T14:00:00Z', + end: '2026-06-01T14:30:00Z', + monitor: '11111111-1111-1111-1111-111111111111', + }, + 'create', + ) + expect(body).toEqual({ + startsAt: '2026-06-01T14:00:00Z', + endsAt: '2026-06-01T14:30:00Z', + monitorId: '11111111-1111-1111-1111-111111111111', + }) + }) + + it('emits monitorId: null when --org-wide is set', () => { + const body = buildMaintenanceWindowBody( + {start: 's', end: 'e', orgWide: true}, + 'create', + ) + expect(body.monitorId).toBeNull() + }) + + it('translates an empty --reason into null on update (clears the field)', () => { + const body = buildMaintenanceWindowBody({reason: ''}, 'update') + expect(body.reason).toBeNull() + }) + + it('preserves non-empty --reason verbatim', () => { + const body = buildMaintenanceWindowBody({reason: 'Planned deploy'}, 'create') + expect(body.reason).toBe('Planned deploy') + }) + + it('omits keys that the user did not pass (partial-update semantics)', () => { + const body = buildMaintenanceWindowBody({start: '2026-06-01T14:00:00Z'}, 'update') + expect(Object.keys(body)).toEqual(['startsAt']) + }) + + it('clears repeatRule when an empty string is provided', () => { + const body = buildMaintenanceWindowBody({repeatRule: ''}, 'update') + expect(body.repeatRule).toBeNull() + }) + + it('passes suppressAlerts through as a boolean', () => { + expect(buildMaintenanceWindowBody({suppressAlerts: false}, 'create').suppressAlerts).toBe(false) + expect(buildMaintenanceWindowBody({suppressAlerts: true}, 'create').suppressAlerts).toBe(true) + }) + + it('does not coerce orgWide=false into a clearing monitorId', () => { + // Passing --no-org-wide implicitly via orgWide:false should leave the + // field unset so the API treats it as "no change". Only an explicit + // --org-wide writes monitorId: null. + const body = buildMaintenanceWindowBody({orgWide: false}, 'update') + expect('monitorId' in body).toBe(false) + }) +})