Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/datadog

src/core.ts

376 lines · 10.8 KB · TypeScript
/**
 * Shared Datadog transport: site resolution, API + application key secrets,
 * JSON requests, and dry-run mutation previews.
 *
 * Auth is secret-backed (not OAuth). Default user secrets are `datadogApiKey`
 * and `datadogApplicationKey`. Extra accounts use `account: "work"` →
 * `datadogApiKey-work` / `datadogApplicationKey-work`. Placeholders resolve
 * on approved hosts only.
 */

export const DEFAULT_API_KEY_SECRET = 'datadogApiKey'
export const DEFAULT_APP_KEY_SECRET = 'datadogApplicationKey'
export const DEFAULT_SITE = 'datadoghq.com'
export const DEFAULT_API_HOST = 'api.datadoghq.com'

export const DATADOG_API_HOSTS = [
	'api.datadoghq.com',
	'api.us3.datadoghq.com',
	'api.us5.datadoghq.com',
	'api.datadoghq.eu',
	'api.ap1.datadoghq.com',
	'api.ap2.datadoghq.com',
	'api.uk1.datadoghq.com',
	'api.ddog-gov.com',
	'api.us2.ddog-gov.com',
] as const

export const ALLOWED_HOSTS_QUERY = DATADOG_API_HOSTS.join(',')

const SITE_ALIASES: Record<string, string> = {
	us1: 'datadoghq.com',
	us: 'datadoghq.com',
	us3: 'us3.datadoghq.com',
	us5: 'us5.datadoghq.com',
	eu: 'datadoghq.eu',
	eu1: 'datadoghq.eu',
	ap1: 'ap1.datadoghq.com',
	ap2: 'ap2.datadoghq.com',
	uk1: 'uk1.datadoghq.com',
	gov: 'ddog-gov.com',
	'us1-fed': 'ddog-gov.com',
	us1fed: 'ddog-gov.com',
	'us2-fed': 'us2.ddog-gov.com',
	us2fed: 'us2.ddog-gov.com',
}

const SECRET_SUFFIX = '(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?'
const API_KEY_SECRET_PATTERN = new RegExp('^datadogApiKey' + SECRET_SUFFIX + '$')
const APP_KEY_SECRET_PATTERN = new RegExp('^datadogApplicationKey' + SECRET_SUFFIX + '$')
const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/

export type DatadogAuthOptions = {
	/**
	 * Extra Datadog account label. `work` reads `datadogApiKey-work` and
	 * `datadogApplicationKey-work`. Omit (or pass `default`) for the defaults.
	 */
	account?: string
	/** Override the API key secret name. Must be `datadogApiKey` or `datadogApiKey-<label>`. */
	apiKeySecretName?: string
	/** Override the application key secret name. Must be `datadogApplicationKey` or `datadogApplicationKey-<label>`. */
	applicationKeySecretName?: string
	/**
	 * Datadog site alias (`us1`, `eu`, `us3`, `us5`, `ap1`, `ap2`, `uk1`, `gov`)
	 * or site domain (`datadoghq.eu`). Defaults to US1 `datadoghq.com`.
	 */
	site?: string
}

export type DatadogJson = Record<string, any>

export class DatadogApiError extends Error {
	status: number
	label: string
	details: unknown
	apiKeySetupUrl: string
	applicationKeySetupUrl: string

	constructor(
		message: string,
		input: {
			status: number
			label?: string
			details?: unknown
			apiKeySetupUrl?: string
			applicationKeySetupUrl?: string
		},
	) {
		super(message)
		this.name = 'DatadogApiError'
		this.status = input.status
		this.label = input.label ?? 'Datadog API'
		this.details = input.details ?? null
		this.apiKeySetupUrl = input.apiKeySetupUrl ?? API_KEY_SETUP_URL
		this.applicationKeySetupUrl = input.applicationKeySetupUrl ?? APPLICATION_KEY_SETUP_URL
	}
}

export function assertNever(value: never, message: string): never {
	throw new Error(message + String(value))
}

export function parseAction<T extends string>(
	value: unknown,
	allowed: readonly T[],
	fallback: T,
	label: string,
): T {
	const action = (value == null || value === '' ? fallback : value) as unknown
	if (typeof action === 'string' && (allowed as readonly string[]).includes(action)) {
		return action as T
	}
	throw new Error(
		'Unknown ' + label + ' action: ' + String(action) + '. Valid actions: ' + allowed.join(', '),
	)
}

export function clean(value: unknown): string {
	return String(value ?? '').trim()
}

export function positiveInt(value: unknown, fallback: number, max = 100): number {
	const number = Math.floor(Number(value))
	if (!Number.isFinite(number) || number <= 0) return fallback
	return Math.min(number, max)
}

function accountLabel(value: string | undefined): string | null {
	const trimmed = (value ?? '').trim()
	if (!trimmed || trimmed === 'default') return null
	if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
		throw new Error(
			'account must be a short label such as "work" or "live" (letters, numbers, _ or -).',
		)
	}
	return trimmed
}

export function resolveApiKeySecretName(input: DatadogAuthOptions = {}): string {
	if (input.apiKeySecretName != null && String(input.apiKeySecretName).trim() !== '') {
		const name = String(input.apiKeySecretName).trim()
		if (!API_KEY_SECRET_PATTERN.test(name)) {
			throw new Error(
				'apiKeySecretName must be datadogApiKey or datadogApiKey-<account>. Got ' +
					name +
					'. Save it at ' +
					secretSetupUrl(name) +
					'.',
			)
		}
		return name
	}
	const account = accountLabel(input.account)
	return account ? DEFAULT_API_KEY_SECRET + '-' + account : DEFAULT_API_KEY_SECRET
}

export function resolveApplicationKeySecretName(input: DatadogAuthOptions = {}): string {
	if (input.applicationKeySecretName != null && String(input.applicationKeySecretName).trim() !== '') {
		const name = String(input.applicationKeySecretName).trim()
		if (!APP_KEY_SECRET_PATTERN.test(name)) {
			throw new Error(
				'applicationKeySecretName must be datadogApplicationKey or datadogApplicationKey-<account>. Got ' +
					name +
					'. Save it at ' +
					secretSetupUrl(name) +
					'.',
			)
		}
		return name
	}
	const account = accountLabel(input.account)
	return account ? DEFAULT_APP_KEY_SECRET + '-' + account : DEFAULT_APP_KEY_SECRET
}

export function secretSetupUrl(secretName: string): string {
	const description =
		secretName.startsWith(DEFAULT_APP_KEY_SECRET)
			? 'Datadog application key for read and management API calls'
			: 'Datadog API key for the HTTP API'
	return (
		'https://kody.codes/account/secrets/new?name=' +
		encodeURIComponent(secretName) +
		'&description=' +
		encodeURIComponent(description) +
		'&allowedHosts=' +
		ALLOWED_HOSTS_QUERY +
		'&scope=user'
	)
}

export const API_KEY_SETUP_URL = secretSetupUrl(DEFAULT_API_KEY_SECRET)
export const APPLICATION_KEY_SETUP_URL = secretSetupUrl(DEFAULT_APP_KEY_SECRET)

function secretPlaceholder(name: string): string {
	return '{{secret:' + name + '|scope=user}}'
}

export function resolveSite(input: DatadogAuthOptions = {}): string {
	const raw = clean(input.site).toLowerCase()
	if (!raw) return DEFAULT_SITE
	if (SITE_ALIASES[raw]) return SITE_ALIASES[raw]
	const stripped = raw.replace(/^https?:\/\//, '').replace(/\/+$/, '')
	if (stripped.startsWith('api.')) {
		const site = stripped.slice('api.'.length)
		if (DATADOG_API_HOSTS.includes(('api.' + site) as (typeof DATADOG_API_HOSTS)[number])) {
			return site
		}
	}
	if (DATADOG_API_HOSTS.includes(('api.' + stripped) as (typeof DATADOG_API_HOSTS)[number])) {
		return stripped
	}
	throw new Error(
		'site must be a Datadog site alias (us1, eu, us3, us5, ap1, ap2, uk1, gov) or domain. Got ' +
			raw +
			'.',
	)
}

export function resolveApiHost(input: DatadogAuthOptions = {}): string {
	return 'api.' + resolveSite(input)
}

export function resolveApiBaseUrl(input: DatadogAuthOptions = {}): string {
	return 'https://' + resolveApiHost(input)
}

export function datadogAuthHeaders(input: DatadogAuthOptions = {}): Record<string, string> {
	return {
		'DD-API-KEY': secretPlaceholder(resolveApiKeySecretName(input)),
		'DD-APPLICATION-KEY': secretPlaceholder(resolveApplicationKeySecretName(input)),
	}
}

export async function parseJsonResponse(
	response: Response,
	label = 'Datadog API',
	input: DatadogAuthOptions = {},
): Promise<any> {
	const text = await response.text()
	let body: any = null
	try {
		body = text ? JSON.parse(text) : null
	} catch {
		body = { raw: text }
	}
	if (!response.ok) {
		const apiKey = resolveApiKeySecretName(input)
		const appKey = resolveApplicationKeySecretName(input)
		const hint =
			response.status === 401 || response.status === 403
				? ' Save valid keys at ' +
					secretSetupUrl(apiKey) +
					' and ' +
					secretSetupUrl(appKey) +
					'.'
				: ''
		const errors = Array.isArray(body?.errors) ? body.errors.join('; ') : ''
		throw new DatadogApiError(
			label + ' HTTP ' + response.status + (errors ? ': ' + errors : '.') + hint,
			{
				status: response.status,
				label,
				details: body,
				apiKeySetupUrl: secretSetupUrl(apiKey),
				applicationKeySetupUrl: secretSetupUrl(appKey),
			},
		)
	}
	return body
}

export async function datadogRequest(
	path: string,
	input: DatadogAuthOptions & {
		method?: string
		query?: Record<string, unknown>
		body?: unknown
		label?: string
	} = {},
): Promise<any> {
	const suffix = path.startsWith('/') ? path : '/' + path
	const url = new URL(resolveApiBaseUrl(input) + suffix)
	for (const [key, value] of Object.entries(input.query ?? {})) {
		if (value === undefined || value === null) continue
		if (Array.isArray(value)) {
			for (const item of value) {
				if (item !== undefined && item !== null && clean(item)) {
					url.searchParams.append(key, String(item))
				}
			}
			continue
		}
		if (clean(value) || typeof value === 'number' || typeof value === 'boolean') {
			url.searchParams.set(key, String(value))
		}
	}
	const method = (input.method ?? 'GET').toUpperCase()
	const headers: Record<string, string> = {
		...datadogAuthHeaders(input),
		Accept: 'application/json',
	}
	let body: string | undefined
	if (input.body !== undefined && method !== 'GET' && method !== 'HEAD') {
		body = JSON.stringify(input.body)
		headers['content-type'] = 'application/json'
	}
	const response = await fetch(url, { method, headers, body })
	return await parseJsonResponse(
		response,
		input.label ?? 'Datadog ' + method + ' ' + path,
		input,
	)
}

export function requireConfirm(input: { confirm?: boolean }, action: string) {
	if (input.confirm !== true) {
		throw new Error(
			'Refusing to ' +
				action +
				' without confirm: true. This action changes live Datadog state; pass dryRun: true to preview, or confirm: true to proceed.',
		)
	}
}

export type DatadogDryRun = {
	dryRun: true
	action: string
	method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'
	path: string
	host: string
	query?: Record<string, unknown>
	body?: unknown
}

export type MutationGuardInput = DatadogAuthOptions & {
	dryRun?: boolean
	confirm?: boolean
}

/**
 * Preview a mutation when `dryRun: true`. Live mutations also require
 * `confirm: true` before they contact Datadog.
 */
export function mutationPreview(
	input: MutationGuardInput,
	options: {
		action: string
		method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'
		path: string
		query?: Record<string, unknown>
		body?: unknown
	},
): DatadogDryRun | null {
	if (input.dryRun === true) {
		return {
			dryRun: true,
			action: options.action,
			method: options.method,
			path: options.path,
			host: resolveApiHost(input),
			query: options.query,
			body: options.body,
		}
	}
	requireConfirm(input, options.action)
	return null
}

export function asStringArray(value: unknown): string[] {
	if (Array.isArray(value)) return value.map((item) => clean(item)).filter(Boolean)
	const text = clean(value)
	if (!text) return []
	return text
		.split(',')
		.map((item) => item.trim())
		.filter(Boolean)
}