Skip to content
Merged
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
19 changes: 18 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
4 changes: 2 additions & 2 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down Expand Up @@ -359,7 +359,7 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise<Load
...(structural.data.banner != null && { banner: structural.data.banner }),
}
try {
return { ok: true, value: resolveContext(config, resolvedContextName, profileName) }
return { ok: true, value: resolveContext(config, resolvedContextName, profileName), contextName: resolvedContextName }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { ok: false, error: { message } }
Expand Down
27 changes: 27 additions & 0 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Auth header construction shared by every HTTP client that talks to an Elastic
* service. Keeping this in one place ensures `EsClient`, `KibanaClient`, and the
* `elastic status` probes all encode credentials identically.
*/

/**
* Either of the auth variants accepted by Elastic services that support both:
* an API key, or HTTP Basic (username + password).
*/
export type ApiKeyOrBasicAuth = { api_key: string } | { username: string; password: string }

/**
* Constructs an HTTP `Authorization` header value from a service-block auth
* object. Returns `undefined` when no auth is configured (security disabled).
*/
export function buildAuthHeader (auth: ApiKeyOrBasicAuth | undefined): string | undefined {
if (auth == null) return undefined
if ('api_key' in auth) return `ApiKey ${auth.api_key}`
const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
return `Basic ${encoded}`
}
12 changes: 3 additions & 9 deletions src/lib/es-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { getResolvedConfig } from '../config/store.ts'
import { buildAuthHeader, type ApiKeyOrBasicAuth } from './auth.ts'
import { clientHeaders } from './meta.ts'

export interface EsRequestParams {
Expand Down Expand Up @@ -53,16 +54,9 @@ export class EsClient {
private readonly authHeader: string | undefined
private _fetch: typeof fetch = globalThis.fetch

constructor (url: string, auth?: { api_key: string } | { username: string; password: string }) {
constructor (url: string, auth?: ApiKeyOrBasicAuth) {
this.baseUrl = url.replace(/\/+$/, '')
if (auth == null) {
this.authHeader = undefined
} else if ('api_key' in auth) {
this.authHeader = `ApiKey ${auth.api_key}`
} else {
const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
this.authHeader = `Basic ${encoded}`
}
this.authHeader = buildAuthHeader(auth)
if (this.baseUrl.startsWith('http://') && !/localhost|127\.0\.0\.1/.test(this.baseUrl)) {
process.stderr.write('Warning: using plaintext HTTP. Credentials will be sent unencrypted.\n')
}
Expand Down
12 changes: 3 additions & 9 deletions src/lib/kibana-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import fs from 'node:fs'
import path from 'node:path'
import { getResolvedConfig } from '../config/store.ts'
import { buildAuthHeader, type ApiKeyOrBasicAuth } from './auth.ts'
import { isLoopbackUrl } from './is-loopback-host.ts'
import { clientHeaders } from './meta.ts'

Expand Down Expand Up @@ -39,16 +40,9 @@ export class KibanaClient {
private readonly authHeader: string | undefined
private _fetch: typeof fetch = globalThis.fetch

constructor (baseUrl: string, auth?: { api_key: string } | { username: string; password: string }) {
constructor (baseUrl: string, auth?: ApiKeyOrBasicAuth) {
this.baseUrl = baseUrl.replace(/\/+$/, '')
if (auth == null) {
this.authHeader = undefined
} else if ('api_key' in auth) {
this.authHeader = `ApiKey ${auth.api_key}`
} else {
const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
this.authHeader = `Basic ${encoded}`
}
this.authHeader = buildAuthHeader(auth)
if (this.baseUrl.startsWith('http://') && !isLoopbackUrl(this.baseUrl)) {
process.stderr.write('Warning: using plaintext HTTP. Credentials will be sent unencrypted.\n')
}
Expand Down
179 changes: 179 additions & 0 deletions src/status/checks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Per-service connectivity and authentication probes used by `elastic status`.
*
* Each probe is a pure async function over a single `ServiceBlock`. Probes do
* not throw: every transport, HTTP, or shape failure is reported as a
* structured `{ ok: false, url, error }` value so the status command can
* surface every service's state independently.
*/

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 {
ok: true
url: string
status: string
nodes: number
}

/** Successful Kibana probe. */
export interface KbCheckOk {
ok: true
url: string
status: string
version: string
}

/** Successful Cloud probe. */
export interface CloudCheckOk {
ok: true
url: string
}

/** Failure shape shared across all three probes. */
export interface CheckErr {
ok: false
url: string
error: string
}

export type EsCheck = EsCheckOk | CheckErr
export type KbCheck = KbCheckOk | CheckErr
export type CloudCheck = CloudCheckOk | CheckErr

/** Maps an HTTP status code into a short, user-facing error string. */
function classifyHttp (status: number): string {
if (status === 401 || status === 403) return `auth failed (${status})`
return `request failed (${status})`
}

/** Converts a thrown fetch error into a `network error: <reason>` 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<string, string> = {
...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<EsCheck> {
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<string, unknown>
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<KbCheck> {
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<string, unknown>
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<string, unknown>)['overall']
if (overall == null || typeof overall !== 'object') {
return { ok: false, url: block.url, error: 'unexpected response' }
}
const level = (overall as Record<string, unknown>)['level']
const version = (versionObj as Record<string, unknown>)['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<CloudCheck> {
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 }
}
74 changes: 74 additions & 0 deletions src/status/format.ts
Original file line number Diff line number Diff line change
@@ -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'
}
Loading
Loading