Skip to content
Closed
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
26 changes: 26 additions & 0 deletions docs/cli/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,32 @@ Multiple contexts are supported. Override `current_context` for a single command

Each context can have any combination of service blocks (`elasticsearch`, `kibana`, and `cloud`). Authentication supports `api_key` or `username` + `password`.

### Reaching Elasticsearch through Kibana

Some deployments publish only Kibana and keep Elasticsearch unreachable from clients. Set `via: kibana` on the `elasticsearch` block and requests are forwarded by Kibana's Console proxy instead of being sent to Elasticsearch directly:

```yaml
current_context: proxied

contexts:
proxied:
kibana:
url: https://kibana.example.internal
auth:
api_key: your-kibana-api-key-here
elasticsearch:
via: kibana
```

Notes:

- `via` and `url` are mutually exclusive, and a `via: kibana` block needs a `kibana` block in the same context to route through.
- The Kibana credentials are reused, so the `elasticsearch` block takes no `auth` of its own.
- Every `elastic es …` command works as usual, and Elasticsearch errors keep their original status code.
- `elastic status` marks the route, for example `green (5 nodes) via Kibana`.
- The Kibana API key must be allowed to use the Console proxy, and the deployment must have it enabled (`console.ui.enabled` is `true` by default).
- Extensions receive no `ELASTIC_ES_*` environment variables for such a context, since there is no Elasticsearch endpoint to pass on.

## Authoring the config from the CLI

Instead of hand-editing YAML, the `elastic config` command group creates and maintains contexts and stores secrets in the OS keychain when available (macOS Keychain, Linux libsecret, `pass`, Windows Credential Manager). The YAML then holds a resolver expression like `$(keychain:...)` rather than the raw secret.
Expand Down
14 changes: 14 additions & 0 deletions docs/cli/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -69326,6 +69326,13 @@
"required": false,
"summary": "Elasticsearch URL"
},
{
"role": "flag",
"name": "es-via",
"type": "string",
"required": false,
"summary": "Reach Elasticsearch through another service instead of a URL. Only \"kibana\" is supported, and it reuses the Kibana credentials -- for deployments where Elasticsearch is unreachable but Kibana is"
},
{
"role": "flag",
"name": "es-username",
Expand Down Expand Up @@ -69423,6 +69430,13 @@
"required": false,
"summary": "Elasticsearch URL"
},
{
"role": "flag",
"name": "es-via",
"type": "string",
"required": false,
"summary": "Reach Elasticsearch through another service instead of a URL. Only \"kibana\" is supported, and it reuses the Kibana credentials -- for deployments where Elasticsearch is unreachable but Kibana is"
},
{
"role": "flag",
"name": "es-username",
Expand Down
1 change: 1 addition & 0 deletions src/config/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const SECRET_FIELDS: SecretField[] = [

const PLAIN_FIELDS: PlainField[] = [
{ flag: 'es-url', path: ['elasticsearch', 'url'], description: 'Elasticsearch URL' },
{ flag: 'es-via', path: ['elasticsearch', 'via'], description: 'Reach Elasticsearch through another service instead of a URL. Only "kibana" is supported, and it reuses the Kibana credentials -- for deployments where Elasticsearch is unreachable but Kibana is' },
{ flag: 'es-username', path: ['elasticsearch', 'auth', 'username'], description: 'Elasticsearch username (pair with --es-password)' },
{ flag: 'kb-url', path: ['kibana', 'url'], description: 'Kibana URL' },
{ flag: 'kb-username', path: ['kibana', 'auth', 'username'], description: 'Kibana username (pair with --kb-password)' },
Expand Down
44 changes: 39 additions & 5 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,45 @@ export const BasicAuthSchema = z.object({
/** Union of all supported auth variants -- type is inferred from whichever fields are present. */
export const AuthSchema = z.union([ApiKeyAuthSchema, BasicAuthSchema])

/** Validates that a configured endpoint is an absolute http(s) URL. */
const ServiceUrlSchema = z.string().url().refine(
(u) => u.startsWith('https://') || u.startsWith('http://'),
{ message: 'URL must use http:// or https:// scheme' }
)

/** Endpoint URL and authentication credentials for a single service. */
export const ServiceBlockSchema = z.object({
url: z.string().url().refine(
(u) => u.startsWith('https://') || u.startsWith('http://'),
{ message: 'URL must use http:// or https:// scheme' }
),
url: ServiceUrlSchema,
auth: AuthSchema.optional()
})

/**
* The Elasticsearch service block.
*
* Unlike Kibana and Cloud, Elasticsearch may be addressed in two ways:
* - `url` (+ optional `auth`) — a direct connection, the default.
* - `via: kibana` — requests are forwarded by Kibana's Console proxy, reusing the
* context's `kibana` credentials. This is for deployments where Elasticsearch is
* not reachable from the client but Kibana is.
*
* The two are mutually exclusive: `via` means there is no ES endpoint to address
* directly, so accepting a `url` alongside it would silently ignore one of them.
*/
export const EsServiceBlockSchema = z
.object({
url: ServiceUrlSchema.optional(),
auth: AuthSchema.optional(),
via: z.literal('kibana').optional(),
})
.refine(
(es) => (es.via == null) !== (es.url == null),
{ error: 'elasticsearch: set either "url" for a direct connection or "via: kibana", but not both' }
)
.refine(
(es) => !(es.via != null && es.auth != null),
{ error: 'elasticsearch: "via: kibana" reuses the kibana credentials; remove "auth"' }
)

/**
* Policy controlling which commands are permitted to run.
*
Expand Down Expand Up @@ -78,7 +108,7 @@ export const CommandPolicySchema = z
*/
export const ContextSchema = z
.object({
elasticsearch: ServiceBlockSchema.optional(),
elasticsearch: EsServiceBlockSchema.optional(),
kibana: ServiceBlockSchema.optional(),
cloud: ServiceBlockSchema.optional(),
commands: CommandPolicySchema.optional(),
Expand All @@ -87,6 +117,10 @@ export const ContextSchema = z
(ctx) => ctx.elasticsearch != null || ctx.kibana != null || ctx.cloud != null,
{ error: 'at least one service block (elasticsearch, kibana, or cloud) is required' }
)
.refine(
(ctx) => ctx.elasticsearch?.via !== 'kibana' || ctx.kibana != null,
{ error: 'elasticsearch: "via: kibana" requires a kibana block in the same context' }
)

/**
* The root configuration file structure.
Expand Down
27 changes: 26 additions & 1 deletion src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { z } from 'zod'
import type {
AuthSchema,
ServiceBlockSchema,
EsServiceBlockSchema,
ContextSchema,
ConfigFileSchema,
CommandPolicySchema,
Expand All @@ -31,6 +32,12 @@ export type Auth = z.infer<typeof AuthSchema>
/** Endpoint URL and authentication credentials for a single service. */
export type ServiceBlock = z.infer<typeof ServiceBlockSchema>

/** The Elasticsearch service block: a direct connection, or routed through Kibana. */
export type EsServiceBlock = z.infer<typeof EsServiceBlockSchema>

/** An Elasticsearch block whose requests are forwarded by Kibana's Console proxy. */
export interface EsViaKibanaBlock { via: 'kibana' }

/** A context value: optional service blocks with at least one present. */
export type Context = z.infer<typeof ContextSchema>

Expand All @@ -42,11 +49,29 @@ export type CommandPolicy = z.infer<typeof CommandPolicySchema>

/** The active context after resolution — only its configured service blocks, no extras. */
export interface ResolvedContext {
elasticsearch?: ServiceBlock
elasticsearch?: EsServiceBlock
kibana?: ServiceBlock
cloud?: ServiceBlock
}

/**
* Narrows an Elasticsearch block to the Kibana-routed variant.
*
* The schema guarantees `via` and `url` are mutually exclusive, so this doubles as
* a check that no direct endpoint is available.
*/
export function isEsViaKibana (block: EsServiceBlock): block is EsViaKibanaBlock {
return block.via === 'kibana'
}

/**
* Narrows an Elasticsearch block to the direct-connection variant, whose `url` is
* guaranteed present by the schema.
*/
export function isEsDirect (block: EsServiceBlock): block is ServiceBlock {
return block.url != null
}

/** Typed configuration object passed to command handlers after loading and context resolution. */
export interface ResolvedConfig {
context: ResolvedContext
Expand Down
6 changes: 3 additions & 3 deletions src/es/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type { EsClient } from '../lib/es-client.ts'
import type { EsTransport } from '../lib/es-client.ts'
import type { EsApiDefinition } from './types.ts'
import type { SchemaArgDefinition } from '../lib/schema-args.ts'
import { buildRequestParams } from './request-builder.ts'
Expand All @@ -16,8 +16,8 @@ import type { JsonValue, ParsedResult } from '../factory.ts'
* Production code uses the defaults; tests supply stubs.
*/
export interface EsHandlerDeps {
/** returns the active EsClient instance, or throws `missing_config` */
getEsClient: () => EsClient
/** returns the active EsTransport instance, or throws `missing_config` */
getEsClient: () => EsTransport
/** builds EsRequestParams from a definition, parsed CLI input, and schema args */
buildRequestParams: typeof buildRequestParams
}
Expand Down
8 changes: 4 additions & 4 deletions src/es/helpers/bulk-ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { z } from 'zod'
import { readFileSync } from 'node:fs'
import type { EsClient } from '../../lib/es-client.ts'
import type { EsTransport } from '../../lib/es-client.ts'
import { defineCommand } from '../../factory.ts'
import type { OpaqueCommandHandle, JsonValue } from '../../factory.ts'
import { getEsClient } from '../../lib/es-client.ts'
Expand All @@ -23,7 +23,7 @@ import {

/** Dependencies injectable for testing. */
export interface BulkIngestDeps {
getEsClient: () => EsClient
getEsClient: () => EsTransport
}

const defaultDeps: BulkIngestDeps = { getEsClient }
Expand Down Expand Up @@ -139,7 +139,7 @@ function collectDocuments (opts: BulkIngestInput): { docs: unknown[], filesProce

/** Sends a single bulk batch to Elasticsearch. Returns the count of errors. */
async function sendBatch (
transport: EsClient,
transport: EsTransport,
ndjsonBody: string,
index: string
): Promise<{ errors: number, total: number }> {
Expand All @@ -165,7 +165,7 @@ function createBulkIngestHandler (deps: BulkIngestDeps = defaultDeps) {
return async (parsed: { input?: BulkIngestInput; options: Record<string, string | number | boolean> }): Promise<JsonValue> => {
const opts = parsed.input!

let transport: EsClient
let transport: EsTransport
try {
transport = deps.getEsClient()
} catch (err) {
Expand Down
6 changes: 3 additions & 3 deletions src/es/helpers/msearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { z } from 'zod'
import type { EsClient } from '../../lib/es-client.ts'
import type { EsTransport } from '../../lib/es-client.ts'
import { defineCommand } from '../../factory.ts'
import type { OpaqueCommandHandle, JsonValue } from '../../factory.ts'
import { getEsClient } from '../../lib/es-client.ts'
Expand All @@ -22,7 +22,7 @@ interface MsearchResponse {

/** Dependencies injectable for testing. */
export interface MsearchDeps {
getEsClient: () => EsClient
getEsClient: () => EsTransport
}

const defaultDeps: MsearchDeps = { getEsClient }
Expand Down Expand Up @@ -73,7 +73,7 @@ function createMsearchHandler (deps: MsearchDeps = defaultDeps) {
return async (parsed: { input?: z.infer<typeof inputSchema>; options: Record<string, string | number | boolean> }): Promise<JsonValue> => {
const { index, query_file, batch_size, concurrency } = parsed.input!

let transport: EsClient
let transport: EsTransport
try {
transport = deps.getEsClient()
} catch (err) {
Expand Down
6 changes: 3 additions & 3 deletions src/es/helpers/scroll-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { z } from 'zod'
import type { EsClient } from '../../lib/es-client.ts'
import type { EsTransport } from '../../lib/es-client.ts'
import { defineCommand } from '../../factory.ts'
import type { OpaqueCommandHandle, JsonValue } from '../../factory.ts'
import { getEsClient } from '../../lib/es-client.ts'
Expand All @@ -26,7 +26,7 @@ interface SearchResponse {

/** Dependencies injectable for testing. */
export interface ScrollSearchDeps {
getEsClient: () => EsClient
getEsClient: () => EsTransport
stdout: { write: (chunk: string) => boolean }
stderr: { write: (chunk: string) => boolean }
env?: NodeJS.ProcessEnv
Expand All @@ -53,7 +53,7 @@ function createScrollSearchHandler (deps: ScrollSearchDeps = defaultDeps) {
const { index, query, query_file, scroll, size, max_docs } = parsed.input!
const maxDocs = max_docs ?? Infinity

let transport: EsClient
let transport: EsTransport
try {
transport = deps.getEsClient()
} catch (err) {
Expand Down
6 changes: 3 additions & 3 deletions src/es/helpers/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { z } from 'zod'
import type { EsClient } from '../../lib/es-client.ts'
import type { EsTransport } from '../../lib/es-client.ts'
import { defineCommand } from '../../factory.ts'
import type { OpaqueCommandHandle, JsonValue } from '../../factory.ts'
import { getEsClient } from '../../lib/es-client.ts'
Expand All @@ -25,7 +25,7 @@ interface SearchResponse {

/** Dependencies injectable for testing. */
export interface WatchDeps {
getEsClient: () => EsClient
getEsClient: () => EsTransport
stdout: { write: (chunk: string) => boolean }
stderr: { write: (chunk: string) => boolean }
sleep: (ms: number) => Promise<void>
Expand Down Expand Up @@ -118,7 +118,7 @@ function createWatchHandler (deps: WatchDeps = defaultDeps) {
return async (parsed: { input?: z.infer<typeof inputSchema>; options: Record<string, string | number | boolean> }): Promise<JsonValue> => {
const { index, query, query_file, sort_field, poll_interval, from, size, format } = parsed.input!

let transport: EsClient
let transport: EsTransport
try {
transport = deps.getEsClient()
} catch (err) {
Expand Down
7 changes: 6 additions & 1 deletion src/extension/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
*/

import type { ResolvedConfig, ServiceBlock } from '../config/types.ts'
import { isEsViaKibana } from '../config/types.ts'

type EnvMap = Record<string, string>

Expand All @@ -59,7 +60,11 @@ function serviceEnv (prefix: string, block: ServiceBlock): EnvMap {
export function buildContextEnv (config: ResolvedConfig): EnvMap {
const env: EnvMap = {}
const { elasticsearch, kibana, cloud } = config.context
if (elasticsearch != null) Object.assign(env, serviceEnv('ELASTIC_ES', elasticsearch))
// A `via: kibana` context has no Elasticsearch endpoint to hand to an extension: the
// Kibana variables below are the only usable credentials, so no ES_* vars are emitted.
if (elasticsearch != null && !isEsViaKibana(elasticsearch)) {
Object.assign(env, serviceEnv('ELASTIC_ES', elasticsearch as ServiceBlock))
}
if (kibana != null) Object.assign(env, serviceEnv('ELASTIC_KIBANA', kibana))
if (cloud != null) Object.assign(env, serviceEnv('ELASTIC_CLOUD', cloud))
return env
Expand Down
21 changes: 21 additions & 0 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,24 @@ export function buildAuthHeader (auth: ApiKeyOrBasicAuth | undefined): string |
const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
return `Basic ${encoded}`
}

/**
* Narrows a config service block's `auth` field to {@link ApiKeyOrBasicAuth}.
*
* The resolved config types auth loosely, since a config file may supply either
* variant (or neither, when security is disabled). Every client needs the same
* narrowing before it can build a header.
*
* @returns the narrowed auth, or `undefined` when absent or incomplete
*/
export function narrowAuth (auth: unknown): ApiKeyOrBasicAuth | undefined {
if (auth == null || typeof auth !== 'object') return undefined
const record = auth as Record<string, unknown>
if (typeof record['api_key'] === 'string') {
return { api_key: record['api_key'] }
}
if (typeof record['username'] === 'string' && typeof record['password'] === 'string') {
return { username: record['username'], password: record['password'] }
}
return undefined
}
Loading