From 24a422f79ce7d0437aef2c1a48471e00b0d08e79 Mon Sep 17 00:00:00 2001 From: Matt Devy Date: Wed, 20 May 2026 12:33:52 +0100 Subject: [PATCH 1/2] feat(cli): add elastic status command to verify connectivity and auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `elastic status`: a top-level command that pings each configured service in the active context (elasticsearch, kibana, cloud) in parallel and reports per-service health. Output matches the issue mock — aligned columns with check/cross glyphs by default, `{ context, services }` under `--json`. The handler loads the config itself (bypassing the preAction hook) so a partially broken context is reported as a structured `config_error` envelope instead of crashing before any probe runs. Exit code is 1 if any configured service fails, 0 if all succeed; services missing from the context are omitted rather than shown as errors. `--use-context` plumbs through so a non-default context can be checked without switching. Closes #317 Other changes: - `LoadConfigOk` now exposes the resolved `contextName` so handlers can report which context they probed. - Auth-header construction (`api_key` / basic) extracted to a new `src/lib/auth.ts` helper, replacing duplicated logic in `EsClient`, `KibanaClient`, and the new status probes. --- src/cli.ts | 19 +- src/config/loader.ts | 4 +- src/lib/auth.ts | 27 +++ src/lib/es-client.ts | 12 +- src/lib/kibana-client.ts | 12 +- src/status/checks.ts | 179 ++++++++++++++++++ src/status/format.ts | 74 ++++++++ src/status/register.ts | 128 +++++++++++++ src/status/types.ts | 16 ++ test/status/checks.test.ts | 341 +++++++++++++++++++++++++++++++++++ test/status/format.test.ts | 98 ++++++++++ test/status/register.test.ts | 288 +++++++++++++++++++++++++++++ 12 files changed, 1177 insertions(+), 21 deletions(-) create mode 100644 src/lib/auth.ts create mode 100644 src/status/checks.ts create mode 100644 src/status/format.ts create mode 100644 src/status/register.ts create mode 100644 src/status/types.ts create mode 100644 test/status/checks.test.ts create mode 100644 test/status/format.test.ts create mode 100644 test/status/register.test.ts diff --git a/src/cli.ts b/src/cli.ts index f0577ae..c0d4d4a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,6 +39,9 @@ let earlyConfig: LoadConfigResult | undefined program.hook('preAction', async (thisCommand, actionCommand) => { if (actionCommand.name() === 'version') return + // `status` loads the config itself so a partially broken config is reported as + // a structured result rather than exiting before any probe runs. + if (actionCommand.name() === 'status') return if (actionCommand.parent?.name() === 'docs') return if (actionCommand.parent?.name() === 'sanitize') return // `config` commands author the config file itself — loading it would be @@ -177,11 +180,25 @@ if (firstArg === 'sanitize') { program.addCommand(defineGroup({ name: 'sanitize', description: 'Sanitize values for safe use in Elasticsearch' })) } +if (firstArg === 'status') { + const { registerStatusCommand } = await import('./status/register.ts') + program.addCommand(registerStatusCommand()) +} else { + // Stub: a leaf command that appears in --help without paying the import cost. + // The lazy branch above fires whenever `status` is the first arg, so the stub + // handler is never invoked. + program.addCommand(defineCommand({ + name: 'status', + description: 'Verify connectivity and authentication for the active context', + handler: () => '', + })) +} + // Load config early so --help can hide blocked commands. Skip for commands // that don't need config (e.g. `version`, `sanitize`, or `config` which authors the file) // to avoid unnecessary file I/O and a confusing "no config found" path. // The result is cached in earlyConfig so the preAction hook can reuse it. -if (firstArg !== 'version' && firstArg !== 'config' && firstArg !== 'sanitize') { +if (firstArg !== 'version' && firstArg !== 'config' && firstArg !== 'sanitize' && firstArg !== 'status') { // Parse --profile early (before Commander's full parse) so the early config load // and hideBlockedCommands can apply the correct profile-based allow-list to --help. const profileArgIdx = process.argv.indexOf('--command-profile') diff --git a/src/config/loader.ts b/src/config/loader.ts index fea9683..01d06d9 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -222,7 +222,7 @@ export interface LoadConfigOptions { } /** Successful result from {@link loadConfig}. */ -export interface LoadConfigOk { ok: true, value: ResolvedConfig } +export interface LoadConfigOk { ok: true, value: ResolvedConfig, contextName: string } /** Failure result from {@link loadConfig}. */ export interface LoadConfigErr { ok: false, error: { message: string } } @@ -359,7 +359,7 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise` string. */ +function classifyNetwork (err: unknown): string { + const msg = err instanceof Error ? err.message : String(err) + return `network error: ${msg}` +} + +/** + * Performs a single GET probe and returns the parsed JSON body, or a classified + * error string. Used internally by all three service probes. + */ +async function pingService ( + url: string, + pathSegment: string, + auth: ServiceBlock['auth'], + fetchFn: typeof fetch, +): Promise<{ ok: true, body: unknown } | { ok: false, error: string }> { + const headers: Record = { + ...clientHeaders(), + 'Accept': 'application/json', + } + const h = buildAuthHeader(auth) + if (h != null) headers['Authorization'] = h + + const target = `${url.replace(/\/+$/, '')}${pathSegment}` + let response: Response + try { + response = await fetchFn(target, { method: 'GET', headers, redirect: 'error' }) + } catch (err) { + return { ok: false, error: classifyNetwork(err) } + } + if (!response.ok) return { ok: false, error: classifyHttp(response.status) } + + const text = await response.text() + if (text.length === 0) return { ok: true, body: {} } + try { + return { ok: true, body: JSON.parse(text) } + } catch { + return { ok: false, error: 'unexpected response' } + } +} + +/** + * Probes an Elasticsearch service by calling `GET /_cluster/health`. + * + * Returns a structured success containing cluster `status` (green / yellow / red) + * and the `number_of_nodes`, or a classified failure when the request, response, + * or response shape is invalid. + */ +export async function checkElasticsearch ( + block: ServiceBlock, + fetchFn: typeof fetch = globalThis.fetch, +): Promise { + const result = await pingService(block.url, '/_cluster/health', block.auth, fetchFn) + if (!result.ok) return { ok: false, url: block.url, error: result.error } + const body = result.body + if (body == null || typeof body !== 'object') { + return { ok: false, url: block.url, error: 'unexpected response' } + } + const rec = body as Record + const status = rec['status'] + const nodes = rec['number_of_nodes'] + if (typeof status !== 'string' || typeof nodes !== 'number') { + return { ok: false, url: block.url, error: 'unexpected response' } + } + return { ok: true, url: block.url, status, nodes } +} + +/** + * Probes a Kibana service by calling `GET /api/status`. + * + * Reads `status.overall.level` (e.g. `"available"`) and `version.number`. + * Returns a classified failure when the request fails or the response shape + * is unexpected. + */ +export async function checkKibana ( + block: ServiceBlock, + fetchFn: typeof fetch = globalThis.fetch, +): Promise { + const result = await pingService(block.url, '/api/status', block.auth, fetchFn) + if (!result.ok) return { ok: false, url: block.url, error: result.error } + const body = result.body + if (body == null || typeof body !== 'object') { + return { ok: false, url: block.url, error: 'unexpected response' } + } + const rec = body as Record + const statusObj = rec['status'] + const versionObj = rec['version'] + if ( + statusObj == null || typeof statusObj !== 'object' || + versionObj == null || typeof versionObj !== 'object' + ) { + return { ok: false, url: block.url, error: 'unexpected response' } + } + const overall = (statusObj as Record)['overall'] + if (overall == null || typeof overall !== 'object') { + return { ok: false, url: block.url, error: 'unexpected response' } + } + const level = (overall as Record)['level'] + const version = (versionObj as Record)['number'] + if (typeof level !== 'string' || typeof version !== 'string') { + return { ok: false, url: block.url, error: 'unexpected response' } + } + return { ok: true, url: block.url, status: level, version } +} + +/** + * Probes an Elastic Cloud service by calling `GET /api/v1/user`. + * + * Cloud auth must be an API key; basic auth or a missing key is reported as a + * failure without making a request. + */ +export async function checkCloud ( + block: ServiceBlock, + fetchFn: typeof fetch = globalThis.fetch, +): Promise { + if (block.auth == null || !('api_key' in block.auth)) { + return { ok: false, url: block.url, error: 'cloud requires api_key auth' } + } + const result = await pingService(block.url, '/api/v1/user', block.auth, fetchFn) + if (!result.ok) return { ok: false, url: block.url, error: result.error } + return { ok: true, url: block.url } +} diff --git a/src/status/format.ts b/src/status/format.ts new file mode 100644 index 0000000..c268df1 --- /dev/null +++ b/src/status/format.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Plain-text formatter for `elastic status` output. + * + * Produces the aligned-column layout from the issue mock, e.g. + * + * ``` + * Context: local + * + * Elasticsearch http://localhost:9200 ✓ green (3 nodes) + * Kibana http://localhost:5601 ✓ available (8.18.0) + * Cloud https://api.elastic-cloud.com ✗ auth failed (401) + * ``` + */ + +import type { StatusResult } from './types.ts' +import type { EsCheck, KbCheck, CloudCheck } from './checks.ts' + +interface Row { + label: string + url: string + ok: boolean + summary: string +} + +function esSummary (s: EsCheck): string { + if (!s.ok) return s.error + const noun = s.nodes === 1 ? 'node' : 'nodes' + return `${s.status} (${s.nodes} ${noun})` +} + +function kbSummary (s: KbCheck): string { + if (!s.ok) return s.error + return `${s.status} (${s.version})` +} + +function cloudSummary (s: CloudCheck): string { + if (!s.ok) return s.error + return 'available' +} + +/** + * Renders a {@link StatusResult} as a multi-line human-readable string. + * + * Services absent from the active context are omitted from the table. The + * output always ends with a trailing newline. + */ +export function formatStatusText (result: StatusResult): string { + const rows: Row[] = [] + const s = result.services + if (s.elasticsearch != null) { + rows.push({ label: 'Elasticsearch', url: s.elasticsearch.url, ok: s.elasticsearch.ok, summary: esSummary(s.elasticsearch) }) + } + if (s.kibana != null) { + rows.push({ label: 'Kibana', url: s.kibana.url, ok: s.kibana.ok, summary: kbSummary(s.kibana) }) + } + if (s.cloud != null) { + rows.push({ label: 'Cloud', url: s.cloud.url, ok: s.cloud.ok, summary: cloudSummary(s.cloud) }) + } + + const labelW = rows.reduce((m, r) => Math.max(m, r.label.length), 0) + const urlW = rows.reduce((m, r) => Math.max(m, r.url.length), 0) + + const header = `Context: ${result.context}\n\n` + if (rows.length === 0) return header + const lines = rows.map((r) => + ` ${r.label.padEnd(labelW)} ${r.url.padEnd(urlW)} ${r.ok ? '✓' : '✗'} ${r.summary}` + ) + return header + lines.join('\n') + '\n' +} diff --git a/src/status/register.ts b/src/status/register.ts new file mode 100644 index 0000000..4f05afc --- /dev/null +++ b/src/status/register.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * `elastic status` -- a connectivity and authentication diagnostic for the + * active config context. + * + * Runs one probe per configured service block (`elasticsearch`, `kibana`, + * `cloud`) concurrently and reports a per-service result. Services missing + * from the active context are omitted from the output; they are not failures. + * + * Unlike most commands, the handler loads the config itself rather than + * relying on the `preAction` hook so that a partially broken config is + * reported as a structured error instead of exiting before any probe runs. + */ + +import { defineCommand } from '../factory.ts' +import type { JsonValue, OpaqueCommandHandle, ParsedResult } from '../factory.ts' +import { loadConfig } from '../config/loader.ts' +import type { BuiltInProfile, ResolvedContext } from '../config/types.ts' +import { BUILT_IN_PROFILES } from '../config/profiles.ts' +import { checkElasticsearch, checkKibana, checkCloud } from './checks.ts' +import type { EsCheck, KbCheck, CloudCheck } from './checks.ts' +import { formatStatusText } from './format.ts' +import type { StatusResult } from './types.ts' + +/** + * Test seam: the fetch implementation used by the three service probes. + * Production code uses `globalThis.fetch`; integration tests replace it via + * {@link _testSetFetch}. + */ +let _fetchImpl: typeof fetch = globalThis.fetch + +/** + * Replace the fetch implementation used by `elastic status`. Returns a restore + * callback; call it in a `finally` block to avoid test pollution. + * + * @internal not part of the public API + */ +export function _testSetFetch (fn: typeof fetch): () => void { + const prev = _fetchImpl + _fetchImpl = fn + return () => { _fetchImpl = prev } +} + +/** + * Runs each configured probe concurrently and returns the merged result. + * Exported for direct unit testing of the orchestration logic without going + * through Commander. + */ +export async function runStatusChecks ( + contextName: string, + context: ResolvedContext, + fetchFn: typeof fetch = _fetchImpl, +): Promise { + const tasks: Array> = [] + if (context.elasticsearch != null) { + const block = context.elasticsearch + tasks.push(checkElasticsearch(block, fetchFn).then((r): ['elasticsearch', EsCheck] => ['elasticsearch', r])) + } + if (context.kibana != null) { + const block = context.kibana + tasks.push(checkKibana(block, fetchFn).then((r): ['kibana', KbCheck] => ['kibana', r])) + } + if (context.cloud != null) { + const block = context.cloud + tasks.push(checkCloud(block, fetchFn).then((r): ['cloud', CloudCheck] => ['cloud', r])) + } + + const settled = await Promise.allSettled(tasks) + const services: StatusResult['services'] = {} + for (const outcome of settled) { + if (outcome.status !== 'fulfilled') continue + const [name, value] = outcome.value + if (name === 'elasticsearch') services.elasticsearch = value + else if (name === 'kibana') services.kibana = value + else services.cloud = value + } + return { context: contextName, services } +} + +function isBuiltInProfile (val: string): val is BuiltInProfile { + return (BUILT_IN_PROFILES as readonly string[]).includes(val) +} + +async function statusHandler (parsed: ParsedResult): Promise { + // Global flags are exposed on parsed.options as kebab-case keys by the factory + // (see factory.ts:641-646). They are not declared as command options here. + const opts = parsed.options + const useContextRaw = opts['use-context'] + const configFileRaw = opts['config-file'] + const profileRaw = opts['command-profile'] + const useContext = typeof useContextRaw === 'string' ? useContextRaw : undefined + const configFile = typeof configFileRaw === 'string' ? configFileRaw : undefined + const profileName = typeof profileRaw === 'string' && isBuiltInProfile(profileRaw) ? profileRaw : undefined + + const loaded = await loadConfig({ + ...(useContext != null && { contextName: useContext }), + ...(configFile != null && { configPath: configFile }), + ...(profileName != null && { profileName }), + }) + if (!loaded.ok) { + return { error: { code: 'config_error', message: loaded.error.message } } + } + + const result = await runStatusChecks(loaded.contextName, loaded.value.context) + const anyFail = Object.values(result.services).some((svc) => svc != null && !svc.ok) + if (anyFail) { + process.exitCode = 1 + } + return result as unknown as JsonValue +} + +/** + * Builds the `elastic status` command handle. Returned commands are registered + * lazily by `cli.ts` so that the rest of the command tree does not pay the + * import cost when `status` is not invoked. + */ +export function registerStatusCommand (): OpaqueCommandHandle { + return defineCommand({ + name: 'status', + description: 'Verify connectivity and authentication for the active context', + handler: statusHandler, + formatOutput: (result) => formatStatusText(result as unknown as StatusResult), + }) +} diff --git a/src/status/types.ts b/src/status/types.ts new file mode 100644 index 0000000..40c8516 --- /dev/null +++ b/src/status/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { EsCheck, KbCheck, CloudCheck } from './checks.ts' + +/** Aggregate result returned by the `elastic status` command. */ +export interface StatusResult { + context: string + services: { + elasticsearch?: EsCheck + kibana?: KbCheck + cloud?: CloudCheck + } +} diff --git a/test/status/checks.test.ts b/test/status/checks.test.ts new file mode 100644 index 0000000..11a541b --- /dev/null +++ b/test/status/checks.test.ts @@ -0,0 +1,341 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + checkElasticsearch, + checkKibana, + checkCloud, +} from '../../src/status/checks.ts' + +type FetchCall = { url: string; init: RequestInit } + +function recordingFetch (responder: (url: string) => Response | Promise | Error): { + fetch: typeof fetch + calls: FetchCall[] +} { + const calls: FetchCall[] = [] + const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === 'string' ? url : url.toString() + calls.push({ url: urlStr, init: init ?? {} }) + const r = await responder(urlStr) + if (r instanceof Error) throw r + return r + }) as unknown as typeof fetch + return { fetch: fetchFn, calls } +} + +describe('checkElasticsearch', () => { + it('returns ok with status and node count on a healthy cluster', async () => { + const { fetch: fetchFn, calls } = recordingFetch(() => + new Response(JSON.stringify({ status: 'green', number_of_nodes: 3 }), { status: 200 }) + ) + const result = await checkElasticsearch( + { url: 'http://localhost:9200', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: true, url: 'http://localhost:9200', status: 'green', nodes: 3 }) + assert.equal(calls.length, 1) + assert.equal(calls[0]!.url, 'http://localhost:9200/_cluster/health') + const headers = calls[0]!.init.headers as Record + assert.equal(headers['Authorization'], 'ApiKey k') + assert.equal(headers['Accept'], 'application/json') + assert.equal(calls[0]!.init.redirect, 'error') + assert.equal(calls[0]!.init.method, 'GET') + }) + + it('strips trailing slashes from the URL', async () => { + const { fetch: fetchFn, calls } = recordingFetch(() => + new Response(JSON.stringify({ status: 'yellow', number_of_nodes: 1 }), { status: 200 }) + ) + await checkElasticsearch({ url: 'http://localhost:9200///', auth: { api_key: 'k' } }, fetchFn) + assert.equal(calls[0]!.url, 'http://localhost:9200/_cluster/health') + }) + + it('uses Basic auth when given username/password', async () => { + const { fetch: fetchFn, calls } = recordingFetch(() => + new Response(JSON.stringify({ status: 'green', number_of_nodes: 1 }), { status: 200 }) + ) + await checkElasticsearch( + { url: 'http://localhost:9200', auth: { username: 'elastic', password: 'changeme' } }, + fetchFn, + ) + const headers = calls[0]!.init.headers as Record + const expected = `Basic ${Buffer.from('elastic:changeme').toString('base64')}` + assert.equal(headers['Authorization'], expected) + }) + + it('omits Authorization header when no auth is configured', async () => { + const { fetch: fetchFn, calls } = recordingFetch(() => + new Response(JSON.stringify({ status: 'green', number_of_nodes: 1 }), { status: 200 }) + ) + await checkElasticsearch({ url: 'http://localhost:9200' }, fetchFn) + const headers = calls[0]!.init.headers as Record + assert.equal(headers['Authorization'], undefined) + }) + + it('classifies 401 as auth failed', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Response('nope', { status: 401 })) + const result = await checkElasticsearch( + { url: 'http://localhost:9200', auth: { api_key: 'bad' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:9200', error: 'auth failed (401)' }) + }) + + it('classifies 403 as auth failed', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Response('forbidden', { status: 403 })) + const result = await checkElasticsearch( + { url: 'http://localhost:9200', auth: { api_key: 'bad' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:9200', error: 'auth failed (403)' }) + }) + + it('classifies non-auth HTTP errors with the status code', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Response('boom', { status: 503 })) + const result = await checkElasticsearch( + { url: 'http://localhost:9200', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:9200', error: 'request failed (503)' }) + }) + + it('reports network errors with the underlying message', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Error('ECONNREFUSED 9200')) + const result = await checkElasticsearch( + { url: 'http://localhost:9200', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.equal(result.ok, false) + if (!result.ok) { + assert.ok(result.error.startsWith('network error: '), `got ${result.error}`) + assert.ok(result.error.includes('ECONNREFUSED'), `got ${result.error}`) + } + }) + + it('reports unexpected response when body shape is wrong', async () => { + const { fetch: fetchFn } = recordingFetch(() => + new Response(JSON.stringify({ wrong: 'shape' }), { status: 200 }) + ) + const result = await checkElasticsearch( + { url: 'http://localhost:9200', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:9200', error: 'unexpected response' }) + }) + + it('reports unexpected response when body is not JSON', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Response('', { status: 200 })) + const result = await checkElasticsearch( + { url: 'http://localhost:9200', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:9200', error: 'unexpected response' }) + }) + + it('reports unexpected response when body is a JSON primitive', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Response('null', { status: 200 })) + const result = await checkElasticsearch( + { url: 'http://localhost:9200', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:9200', error: 'unexpected response' }) + }) + +}) + +describe('checkKibana', () => { + it('returns ok with overall level and version on a healthy Kibana', async () => { + const body = { + status: { overall: { level: 'available' } }, + version: { number: '8.18.0' }, + } + const { fetch: fetchFn, calls } = recordingFetch(() => + new Response(JSON.stringify(body), { status: 200 }) + ) + const result = await checkKibana( + { url: 'http://localhost:5601', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { + ok: true, + url: 'http://localhost:5601', + status: 'available', + version: '8.18.0', + }) + assert.equal(calls[0]!.url, 'http://localhost:5601/api/status') + }) + + it('classifies 401 as auth failed', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Response('nope', { status: 401 })) + const result = await checkKibana( + { url: 'http://localhost:5601', auth: { api_key: 'bad' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:5601', error: 'auth failed (401)' }) + }) + + it('reports unexpected response when status.overall.level is missing', async () => { + const { fetch: fetchFn } = recordingFetch(() => + new Response(JSON.stringify({ status: {}, version: { number: '8.18.0' } }), { status: 200 }) + ) + const result = await checkKibana( + { url: 'http://localhost:5601', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:5601', error: 'unexpected response' }) + }) + + it('reports unexpected response when version.number is missing', async () => { + const { fetch: fetchFn } = recordingFetch(() => + new Response(JSON.stringify({ status: { overall: { level: 'available' } }, version: {} }), { status: 200 }) + ) + const result = await checkKibana( + { url: 'http://localhost:5601', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:5601', error: 'unexpected response' }) + }) + + it('reports unexpected response when top-level fields are absent', async () => { + const { fetch: fetchFn } = recordingFetch(() => + new Response(JSON.stringify({ status: 'ok' }), { status: 200 }) + ) + const result = await checkKibana( + { url: 'http://localhost:5601', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:5601', error: 'unexpected response' }) + }) + + it('reports unexpected response when body is not an object', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Response('"plain"', { status: 200 })) + const result = await checkKibana( + { url: 'http://localhost:5601', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:5601', error: 'unexpected response' }) + }) + + it('reports unexpected response when status.overall is missing', async () => { + const { fetch: fetchFn } = recordingFetch(() => + new Response(JSON.stringify({ status: { other: 1 }, version: { number: '8.18.0' } }), { status: 200 }) + ) + const result = await checkKibana( + { url: 'http://localhost:5601', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'http://localhost:5601', error: 'unexpected response' }) + }) + + it('reports network errors', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Error('getaddrinfo ENOTFOUND')) + const result = await checkKibana( + { url: 'http://localhost:5601', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.equal(result.ok, false) + if (!result.ok) { + assert.ok(result.error.includes('network error'), `got ${result.error}`) + } + }) +}) + +describe('checkCloud', () => { + it('returns ok when auth is an api_key and the request succeeds', async () => { + const { fetch: fetchFn, calls } = recordingFetch(() => + new Response(JSON.stringify({ user_id: 'me' }), { status: 200 }) + ) + const result = await checkCloud( + { url: 'https://api.elastic-cloud.com', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: true, url: 'https://api.elastic-cloud.com' }) + assert.equal(calls[0]!.url, 'https://api.elastic-cloud.com/api/v1/user') + const headers = calls[0]!.init.headers as Record + assert.equal(headers['Authorization'], 'ApiKey k') + }) + + it('rejects basic auth without making a request', async () => { + const { fetch: fetchFn, calls } = recordingFetch(() => { + throw new Error('should not be called') + }) + const result = await checkCloud( + { url: 'https://api.elastic-cloud.com', auth: { username: 'a', password: 'b' } }, + fetchFn, + ) + assert.deepEqual(result, { + ok: false, + url: 'https://api.elastic-cloud.com', + error: 'cloud requires api_key auth', + }) + assert.equal(calls.length, 0) + }) + + it('rejects missing auth without making a request', async () => { + const { fetch: fetchFn, calls } = recordingFetch(() => { + throw new Error('should not be called') + }) + const result = await checkCloud( + { url: 'https://api.elastic-cloud.com' }, + fetchFn, + ) + assert.deepEqual(result, { + ok: false, + url: 'https://api.elastic-cloud.com', + error: 'cloud requires api_key auth', + }) + assert.equal(calls.length, 0) + }) + + it('classifies 401 as auth failed', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Response('nope', { status: 401 })) + const result = await checkCloud( + { url: 'https://api.elastic-cloud.com', auth: { api_key: 'bad' } }, + fetchFn, + ) + assert.deepEqual(result, { + ok: false, + url: 'https://api.elastic-cloud.com', + error: 'auth failed (401)', + }) + }) + + it('reports network errors', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Error('ECONNRESET')) + const result = await checkCloud( + { url: 'https://api.elastic-cloud.com', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.equal(result.ok, false) + if (!result.ok) { + assert.ok(result.error.includes('network error'), `got ${result.error}`) + } + }) + + it('reports request failed for non-auth HTTP errors', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Response('oops', { status: 500 })) + const result = await checkCloud( + { url: 'https://api.elastic-cloud.com', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { + ok: false, + url: 'https://api.elastic-cloud.com', + error: 'request failed (500)', + }) + }) + + it('treats an empty response body as ok', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Response('', { status: 200 })) + const result = await checkCloud( + { url: 'https://api.elastic-cloud.com', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: true, url: 'https://api.elastic-cloud.com' }) + }) +}) diff --git a/test/status/format.test.ts b/test/status/format.test.ts new file mode 100644 index 0000000..5be3165 --- /dev/null +++ b/test/status/format.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { formatStatusText } from '../../src/status/format.ts' + +describe('formatStatusText', () => { + it('renders the issue mock layout for all three services', () => { + const out = formatStatusText({ + context: 'local', + services: { + elasticsearch: { ok: true, url: 'http://localhost:9200', status: 'green', nodes: 3 }, + kibana: { ok: true, url: 'http://localhost:5601', status: 'available', version: '8.18.0' }, + cloud: { ok: false, url: 'https://api.elastic-cloud.com', error: 'auth failed (401)' }, + }, + }) + assert.equal( + out, + [ + 'Context: local', + '', + ' Elasticsearch http://localhost:9200 ✓ green (3 nodes)', + ' Kibana http://localhost:5601 ✓ available (8.18.0)', + ' Cloud https://api.elastic-cloud.com ✗ auth failed (401)', + '', + ].join('\n') + ) + }) + + it('omits services missing from the context', () => { + const out = formatStatusText({ + context: 'es-only', + services: { + elasticsearch: { ok: true, url: 'http://localhost:9200', status: 'green', nodes: 1 }, + }, + }) + assert.ok(out.startsWith('Context: es-only\n\n'), `got ${out}`) + assert.ok(out.includes('Elasticsearch http://localhost:9200 ✓ green (1 node)')) + assert.ok(!out.includes('Kibana')) + assert.ok(!out.includes('Cloud')) + }) + + it('pluralises the node count correctly', () => { + const one = formatStatusText({ + context: 'c', + services: { elasticsearch: { ok: true, url: 'u', status: 'green', nodes: 1 } }, + }) + assert.ok(one.includes('1 node)'), `got ${one}`) + assert.ok(!one.includes('1 nodes)')) + + const many = formatStatusText({ + context: 'c', + services: { elasticsearch: { ok: true, url: 'u', status: 'green', nodes: 5 } }, + }) + assert.ok(many.includes('5 nodes)'), `got ${many}`) + }) + + it('renders failed services with their classified error message', () => { + const out = formatStatusText({ + context: 'local', + services: { + elasticsearch: { ok: false, url: 'http://es', error: 'network error: ECONNREFUSED' }, + }, + }) + assert.ok(out.includes('✗ network error: ECONNREFUSED'), `got ${out}`) + }) + + it('returns just the header when no services are configured', () => { + const out = formatStatusText({ context: 'empty', services: {} }) + assert.equal(out, 'Context: empty\n\n') + }) + + it('pads the URL column to the widest URL', () => { + const out = formatStatusText({ + context: 'c', + services: { + elasticsearch: { ok: true, url: 'http://es', status: 'green', nodes: 1 }, + kibana: { ok: true, url: 'http://kibana-very-long-url.example.com', status: 'available', version: '9' }, + }, + }) + const lines = out.split('\n') + const esLine = lines.find((l) => l.includes('Elasticsearch'))! + const kbLine = lines.find((l) => l.includes('Kibana'))! + // Glyph column must align between rows + assert.equal(esLine.indexOf('✓'), kbLine.indexOf('✓')) + }) + + it('renders cloud success as "available"', () => { + const out = formatStatusText({ + context: 'c', + services: { cloud: { ok: true, url: 'https://cloud' } }, + }) + assert.ok(out.includes('✓ available'), `got ${out}`) + }) +}) diff --git a/test/status/register.test.ts b/test/status/register.test.ts new file mode 100644 index 0000000..a02afb3 --- /dev/null +++ b/test/status/register.test.ts @@ -0,0 +1,288 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, afterEach, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { registerStatusCommand, runStatusChecks, _testSetFetch } from '../../src/status/register.ts' +import type { ResolvedContext } from '../../src/config/types.ts' + +const SAMPLE_HEALTH = JSON.stringify({ status: 'green', number_of_nodes: 3 }) +const SAMPLE_KIBANA = JSON.stringify({ + status: { overall: { level: 'available' } }, + version: { number: '8.18.0' }, +}) +const SAMPLE_CLOUD = JSON.stringify({ user_id: 'me' }) + +function mockFetch (responder: (url: string) => Response | Promise): typeof fetch { + return (async (url: string | URL | Request) => { + const u = typeof url === 'string' ? url : url.toString() + return responder(u) + }) as unknown as typeof fetch +} + +function makeProgram (): InstanceType { + const prog = new Command('elastic') + prog.exitOverride() + prog.option('--config-file ', 'path to a config file') + prog.option('--use-context ', 'override the active context') + prog.option('--command-profile ', 'restrict available commands to a deployment profile') + prog.option('--json', 'output as JSON') + prog.option('--output-fields ', '') + prog.option('--output-template ', '') + prog.addCommand(registerStatusCommand()) + return prog +} + +interface CapturedOutput { stdout: string, stderr: string, exitCode: number | undefined } + +async function captured (run: () => Promise): Promise { + const stdoutChunks: string[] = [] + const stderrChunks: string[] = [] + const origStdout = process.stdout.write.bind(process.stdout) + const origStderr = process.stderr.write.bind(process.stderr) + const origExit = process.exitCode + process.stdout.write = ((chunk: string) => { stdoutChunks.push(chunk); return true }) as typeof process.stdout.write + process.stderr.write = ((chunk: string) => { stderrChunks.push(chunk); return true }) as typeof process.stderr.write + process.exitCode = undefined + try { + await run() + } finally { + process.stdout.write = origStdout + process.stderr.write = origStderr + } + const exitCode = process.exitCode + process.exitCode = origExit + return { stdout: stdoutChunks.join(''), stderr: stderrChunks.join(''), exitCode } +} + +describe('runStatusChecks', () => { + it('runs only the configured services concurrently', async () => { + const seen: string[] = [] + const fetchFn = mockFetch((url) => { + seen.push(url) + if (url.includes('_cluster/health')) return new Response(SAMPLE_HEALTH, { status: 200 }) + return new Response('not configured', { status: 500 }) + }) + const ctx: ResolvedContext = { + elasticsearch: { url: 'http://localhost:9200', auth: { api_key: 'k' } }, + } + const result = await runStatusChecks('local', ctx, fetchFn) + assert.equal(result.context, 'local') + assert.ok(result.services.elasticsearch?.ok) + assert.equal(result.services.kibana, undefined) + assert.equal(result.services.cloud, undefined) + assert.equal(seen.length, 1) + }) + + it('aggregates results across services', async () => { + const fetchFn = mockFetch((url) => { + if (url.includes('_cluster/health')) return new Response(SAMPLE_HEALTH, { status: 200 }) + if (url.includes('/api/status')) return new Response(SAMPLE_KIBANA, { status: 200 }) + if (url.includes('/api/v1/user')) return new Response('nope', { status: 401 }) + return new Response('', { status: 404 }) + }) + const ctx: ResolvedContext = { + elasticsearch: { url: 'http://localhost:9200', auth: { api_key: 'k' } }, + kibana: { url: 'http://localhost:5601', auth: { api_key: 'k' } }, + cloud: { url: 'https://api.elastic-cloud.com', auth: { api_key: 'bad' } }, + } + const result = await runStatusChecks('local', ctx, fetchFn) + assert.equal(result.services.elasticsearch?.ok, true) + assert.equal(result.services.kibana?.ok, true) + assert.equal(result.services.cloud?.ok, false) + if (result.services.cloud?.ok === false) { + assert.equal(result.services.cloud.error, 'auth failed (401)') + } + }) +}) + +describe('elastic status -- command', () => { + let dir: string + let configPath: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'elastic-status-')) + configPath = join(dir, '.elasticrc.yml') + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + async function writeConfig (yaml: string): Promise { + await writeFile(configPath, yaml) + } + + it('prints aligned-column text by default and exits 0 on full success', async () => { + await writeConfig([ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://localhost:9200', + ' auth: { api_key: k }', + ' kibana:', + ' url: http://localhost:5601', + ' auth: { api_key: k }', + ].join('\n')) + const restore = _testSetFetch(mockFetch((url) => { + if (url.includes('_cluster/health')) return new Response(SAMPLE_HEALTH, { status: 200 }) + if (url.includes('/api/status')) return new Response(SAMPLE_KIBANA, { status: 200 }) + return new Response('not configured', { status: 500 }) + })) + try { + const out = await captured(async () => { + const prog = makeProgram() + await prog.parseAsync(['--config-file', configPath, 'status'], { from: 'user' }) + }) + assert.ok(out.stdout.includes('Context: local'), `expected Context line, got: ${out.stdout}`) + assert.ok(out.stdout.includes('Elasticsearch'), `expected ES row, got: ${out.stdout}`) + assert.ok(out.stdout.includes('green (3 nodes)'), `expected ES summary, got: ${out.stdout}`) + assert.ok(out.stdout.includes('available (8.18.0)'), `expected Kibana summary, got: ${out.stdout}`) + assert.ok(out.stdout.includes('✓'), `expected check glyph, got: ${out.stdout}`) + assert.equal(out.exitCode, undefined) + } finally { + restore() + } + }) + + it('emits structured JSON under --json and sets exit code 1 on any failure', async () => { + await writeConfig([ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://localhost:9200', + ' auth: { api_key: bad }', + ' cloud:', + ' url: https://api.elastic-cloud.com', + ' auth: { api_key: k }', + ].join('\n')) + const restore = _testSetFetch(mockFetch((url) => { + if (url.includes('_cluster/health')) return new Response('no', { status: 401 }) + if (url.includes('/api/v1/user')) return new Response(SAMPLE_CLOUD, { status: 200 }) + return new Response('', { status: 404 }) + })) + try { + const out = await captured(async () => { + const prog = makeProgram() + await prog.parseAsync(['--config-file', configPath, '--json', 'status'], { from: 'user' }) + }) + const parsed = JSON.parse(out.stdout) as { + context: string + services: { + elasticsearch: { ok: boolean, error?: string } + cloud: { ok: boolean } + } + } + assert.equal(parsed.context, 'local') + assert.equal(parsed.services.elasticsearch.ok, false) + assert.equal(parsed.services.elasticsearch.error, 'auth failed (401)') + assert.equal(parsed.services.cloud.ok, true) + assert.equal(out.exitCode, 1) + } finally { + restore() + } + }) + + it('honours --use-context to check a non-default context', async () => { + await writeConfig([ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://es-local', + ' auth: { api_key: k }', + ' staging:', + ' elasticsearch:', + ' url: http://es-staging', + ' auth: { api_key: k }', + ].join('\n')) + const restore = _testSetFetch(mockFetch(() => + new Response(SAMPLE_HEALTH, { status: 200 }) + )) + try { + const out = await captured(async () => { + const prog = makeProgram() + await prog.parseAsync( + ['--config-file', configPath, '--use-context', 'staging', '--json', 'status'], + { from: 'user' }, + ) + }) + const parsed = JSON.parse(out.stdout) as { context: string, services: { elasticsearch: { url: string } } } + assert.equal(parsed.context, 'staging') + assert.equal(parsed.services.elasticsearch.url, 'http://es-staging') + } finally { + restore() + } + }) + + it('returns a config_error envelope when no config file is found', async () => { + const missingPath = join(dir, 'does-not-exist.yml') + const out = await captured(async () => { + const prog = makeProgram() + await prog.parseAsync(['--config-file', missingPath, '--json', 'status'], { from: 'user' }) + }) + assert.ok(out.stderr.length > 0, `expected stderr, got: ${out.stderr}`) + const parsed = JSON.parse(out.stderr) as { error: { code: string, message: string } } + assert.equal(parsed.error.code, 'config_error') + assert.ok(parsed.error.message.length > 0) + assert.equal(out.exitCode, 1) + }) + + it('omits services missing from the context', async () => { + await writeConfig([ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://es-only', + ' auth: { api_key: k }', + ].join('\n')) + const restore = _testSetFetch(mockFetch(() => new Response(SAMPLE_HEALTH, { status: 200 }))) + try { + const out = await captured(async () => { + const prog = makeProgram() + await prog.parseAsync(['--config-file', configPath, '--json', 'status'], { from: 'user' }) + }) + const parsed = JSON.parse(out.stdout) as { services: Record } + assert.ok(parsed.services.elasticsearch != null) + assert.equal(parsed.services.kibana, undefined) + assert.equal(parsed.services.cloud, undefined) + } finally { + restore() + } + }) + + it('--dry-run validates and exits without making any HTTP calls', async () => { + await writeConfig([ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://localhost:9200', + ' auth: { api_key: k }', + ].join('\n')) + let called = false + const restore = _testSetFetch(mockFetch(() => { + called = true + return new Response('', { status: 200 }) + })) + try { + const out = await captured(async () => { + const prog = makeProgram() + await prog.parseAsync(['--config-file', configPath, 'status', '--dry-run'], { from: 'user' }) + }) + assert.equal(called, false, 'fetch should not be called in --dry-run') + assert.ok(out.stdout.length > 0, `expected dry-run message on stdout, got: ${out.stdout}`) + } finally { + restore() + } + }) +}) From 3ff6c8aaca9f69feefe58aab0116fc5e07145d88 Mon Sep 17 00:00:00 2001 From: Matt Devy Date: Wed, 20 May 2026 15:50:35 +0100 Subject: [PATCH 2/2] test(status): make exit-code success assertion runtime-agnostic Bun initialises `process.exitCode` to `0`, whereas Node leaves it `undefined` until something sets it. Asserting `exitCode === undefined` for the "no failure" case passed on Node but failed on Bun. Compare against the failure value (`!== 1`) instead, which matches the semantic intent ("the handler did not flag failure") and works on both runtimes. --- test/status/register.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/status/register.test.ts b/test/status/register.test.ts index a02afb3..83abc1e 100644 --- a/test/status/register.test.ts +++ b/test/status/register.test.ts @@ -146,7 +146,9 @@ describe('elastic status -- command', () => { assert.ok(out.stdout.includes('green (3 nodes)'), `expected ES summary, got: ${out.stdout}`) assert.ok(out.stdout.includes('available (8.18.0)'), `expected Kibana summary, got: ${out.stdout}`) assert.ok(out.stdout.includes('✓'), `expected check glyph, got: ${out.stdout}`) - assert.equal(out.exitCode, undefined) + // Bun defaults process.exitCode to 0 instead of undefined; assert against the + // failure value rather than the runtime-specific "no value set" sentinel. + assert.notEqual(out.exitCode, 1, `unexpected failure exit code, got: ${out.exitCode}`) } finally { restore() }