diff --git a/src/status/checks.ts b/src/status/checks.ts index 188a36a5..5fed8d6f 100644 --- a/src/status/checks.ts +++ b/src/status/checks.ts @@ -16,14 +16,31 @@ import type { ServiceBlock } from '../config/types.ts' import { buildAuthHeader } from '../lib/auth.ts' import { clientHeaders } from '../lib/meta.ts' -/** Successful Elasticsearch probe. */ -export interface EsCheckOk { +/** Successful Elasticsearch probe against a stateful cluster. */ +export interface EsCheckStateful { ok: true url: string + flavor: 'stateful' status: string nodes: number } +/** + * Successful Elasticsearch probe against a Serverless project. + * + * Serverless removes cluster-level APIs (`_cluster/health` returns 410 Gone), + * so there is no cluster health colour or node count to report. The root + * endpoint is used instead, which yields the build version. + */ +export interface EsCheckServerless { + ok: true + url: string + flavor: 'serverless' + version: string +} + +export type EsCheckOk = EsCheckStateful | EsCheckServerless + /** Successful Kibana probe. */ export interface KbCheckOk { ok: true @@ -70,7 +87,7 @@ async function pingService ( pathSegment: string, auth: ServiceBlock['auth'], fetchFn: typeof fetch, -): Promise<{ ok: true, body: unknown } | { ok: false, error: string }> { +): Promise<{ ok: true, body: unknown } | { ok: false, error: string, status?: number }> { const headers: Record = { ...clientHeaders(), 'Accept': 'application/json', @@ -85,7 +102,7 @@ async function pingService ( } catch (err) { return { ok: false, error: classifyNetwork(err) } } - if (!response.ok) return { ok: false, error: classifyHttp(response.status) } + if (!response.ok) return { ok: false, error: classifyHttp(response.status), status: response.status } const text = await response.text() if (text.length === 0) return { ok: true, body: {} } @@ -99,16 +116,21 @@ async function pingService ( /** * 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. + * On a stateful cluster this returns the cluster `status` (green / yellow / red) + * and the `number_of_nodes`. Serverless projects remove cluster-level APIs and + * answer `_cluster/health` with 410 Gone; in that case the probe falls back to + * the root endpoint (see {@link checkServerlessRoot}) and reports the build + * version instead. Other request, response, or shape failures are classified. */ 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 } + if (!result.ok) { + if (result.status === 410) return checkServerlessRoot(block, fetchFn) + 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' } @@ -119,7 +141,32 @@ export async function checkElasticsearch ( if (typeof status !== 'string' || typeof nodes !== 'number') { return { ok: false, url: block.url, error: 'unexpected response' } } - return { ok: true, url: block.url, status, nodes } + return { ok: true, url: block.url, flavor: 'stateful', status, nodes } +} + +/** + * Probes a Serverless Elasticsearch project via `GET /`, reading `version.number`. + * Reached only after `_cluster/health` returns 410, the Serverless signal. + */ +async function checkServerlessRoot ( + block: ServiceBlock, + fetchFn: typeof fetch, +): Promise { + const result = await pingService(block.url, '/', 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 versionObj = (body as Record)['version'] + if (versionObj == null || typeof versionObj !== 'object') { + return { ok: false, url: block.url, error: 'unexpected response' } + } + const version = (versionObj as Record)['number'] + if (typeof version !== 'string') { + return { ok: false, url: block.url, error: 'unexpected response' } + } + return { ok: true, url: block.url, flavor: 'serverless', version } } /** diff --git a/src/status/format.ts b/src/status/format.ts index c268df1a..e0385e25 100644 --- a/src/status/format.ts +++ b/src/status/format.ts @@ -29,6 +29,7 @@ interface Row { function esSummary (s: EsCheck): string { if (!s.ok) return s.error + if (s.flavor === 'serverless') return `serverless (${s.version})` const noun = s.nodes === 1 ? 'node' : 'nodes' return `${s.status} (${s.nodes} ${noun})` } diff --git a/test/status/checks.test.ts b/test/status/checks.test.ts index 11a541be..40b4ff2d 100644 --- a/test/status/checks.test.ts +++ b/test/status/checks.test.ts @@ -37,7 +37,7 @@ describe('checkElasticsearch', () => { { url: 'http://localhost:9200', auth: { api_key: 'k' } }, fetchFn, ) - assert.deepEqual(result, { ok: true, url: 'http://localhost:9200', status: 'green', nodes: 3 }) + assert.deepEqual(result, { ok: true, url: 'http://localhost:9200', flavor: 'stateful', 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 @@ -146,6 +146,50 @@ describe('checkElasticsearch', () => { assert.deepEqual(result, { ok: false, url: 'http://localhost:9200', error: 'unexpected response' }) }) + it('falls back to the root endpoint on a 410 (serverless) and reports the version', async () => { + const { fetch: fetchFn, calls } = recordingFetch((url) => { + if (url.endsWith('/_cluster/health')) return new Response('gone', { status: 410 }) + return new Response( + JSON.stringify({ name: 'serverless', version: { number: '9.5.0', build_flavor: 'serverless' } }), + { status: 200 }, + ) + }) + const result = await checkElasticsearch( + { url: 'https://x.es.cloud', auth: { username: 'admin', password: 'p' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: true, url: 'https://x.es.cloud', flavor: 'serverless', version: '9.5.0' }) + assert.equal(calls.length, 2) + assert.equal(calls[0]!.url, 'https://x.es.cloud/_cluster/health') + assert.equal(calls[1]!.url, 'https://x.es.cloud/') + }) + + it('reports unexpected response when the serverless root lacks version.number', async () => { + const { fetch: fetchFn } = recordingFetch((url) => + url.endsWith('/_cluster/health') + ? new Response('gone', { status: 410 }) + : new Response(JSON.stringify({ name: 'serverless', version: {} }), { status: 200 }) + ) + const result = await checkElasticsearch( + { url: 'https://x.es.cloud', auth: { api_key: 'k' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'https://x.es.cloud', error: 'unexpected response' }) + }) + + it('propagates a serverless root failure', async () => { + const { fetch: fetchFn } = recordingFetch((url) => + url.endsWith('/_cluster/health') + ? new Response('gone', { status: 410 }) + : new Response('nope', { status: 401 }) + ) + const result = await checkElasticsearch( + { url: 'https://x.es.cloud', auth: { api_key: 'bad' } }, + fetchFn, + ) + assert.deepEqual(result, { ok: false, url: 'https://x.es.cloud', error: 'auth failed (401)' }) + }) + }) describe('checkKibana', () => { diff --git a/test/status/format.test.ts b/test/status/format.test.ts index 5be31658..ff33f81f 100644 --- a/test/status/format.test.ts +++ b/test/status/format.test.ts @@ -12,7 +12,7 @@ describe('formatStatusText', () => { const out = formatStatusText({ context: 'local', services: { - elasticsearch: { ok: true, url: 'http://localhost:9200', status: 'green', nodes: 3 }, + elasticsearch: { ok: true, url: 'http://localhost:9200', flavor: 'stateful', 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)' }, }, @@ -34,7 +34,7 @@ describe('formatStatusText', () => { const out = formatStatusText({ context: 'es-only', services: { - elasticsearch: { ok: true, url: 'http://localhost:9200', status: 'green', nodes: 1 }, + elasticsearch: { ok: true, url: 'http://localhost:9200', flavor: 'stateful', status: 'green', nodes: 1 }, }, }) assert.ok(out.startsWith('Context: es-only\n\n'), `got ${out}`) @@ -46,18 +46,28 @@ describe('formatStatusText', () => { it('pluralises the node count correctly', () => { const one = formatStatusText({ context: 'c', - services: { elasticsearch: { ok: true, url: 'u', status: 'green', nodes: 1 } }, + services: { elasticsearch: { ok: true, url: 'u', flavor: 'stateful', 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 } }, + services: { elasticsearch: { ok: true, url: 'u', flavor: 'stateful', status: 'green', nodes: 5 } }, }) assert.ok(many.includes('5 nodes)'), `got ${many}`) }) + it('renders a serverless Elasticsearch as "serverless ()"', () => { + const out = formatStatusText({ + context: 'cloud', + services: { + elasticsearch: { ok: true, url: 'https://x.es.cloud', flavor: 'serverless', version: '9.5.0' }, + }, + }) + assert.ok(out.includes('✓ serverless (9.5.0)'), `got ${out}`) + }) + it('renders failed services with their classified error message', () => { const out = formatStatusText({ context: 'local', @@ -77,7 +87,7 @@ describe('formatStatusText', () => { const out = formatStatusText({ context: 'c', services: { - elasticsearch: { ok: true, url: 'http://es', status: 'green', nodes: 1 }, + elasticsearch: { ok: true, url: 'http://es', flavor: 'stateful', status: 'green', nodes: 1 }, kibana: { ok: true, url: 'http://kibana-very-long-url.example.com', status: 'available', version: '9' }, }, })