Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 56 additions & 9 deletions src/status/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string> = {
...clientHeaders(),
'Accept': 'application/json',
Expand All @@ -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: {} }
Expand All @@ -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<EsCheck> {
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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only suggestion: if we have any hints that a cluster might be serverless (its hostname, perhaps, or if the commandProfile is set to serverless), we should run the serverless check first to reduce the chances of sending a request that's doomed to fail.

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' }
Expand All @@ -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<EsCheck> {
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<string, unknown>)['version']
if (versionObj == null || typeof versionObj !== 'object') {
return { ok: false, url: block.url, error: 'unexpected response' }
}
const version = (versionObj as Record<string, unknown>)['number']
if (typeof version !== 'string') {
return { ok: false, url: block.url, error: 'unexpected response' }
}
return { ok: true, url: block.url, flavor: 'serverless', version }
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/status/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`
}
Expand Down
46 changes: 45 additions & 1 deletion test/status/checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
Expand Down Expand Up @@ -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', () => {
Expand Down
20 changes: 15 additions & 5 deletions test/status/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)' },
},
Expand All @@ -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}`)
Expand All @@ -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 (<version>)"', () => {
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',
Expand All @@ -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' },
},
})
Expand Down
Loading