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
84 changes: 65 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,19 @@ elastic --help

## Configuration

The CLI searches for a config file starting from the current working directory and
walks up toward your home directory, stopping at the first file found. As a final
fallback it also checks the platform-specific user config directory
(`~/.config/elastic/` on Linux/macOS, `%APPDATA%\elastic\` on Windows).

The following file names are recognised in each directory (checked in this order):

1. `package.json` - `elastic` key
2. `.elasticrc`
3. `.elasticrc.json`
4. `.elasticrc.yaml`
5. `.elasticrc.yml`
6. `.elasticrc.js` / `.elasticrc.ts` / `.elasticrc.cjs` / `.elasticrc.mjs`
7. `.config/elasticrc` (and `.json` / `.yaml` / `.yml` / `.js` / `.ts` / `.cjs` / `.mjs` variants)
8. `elastic.config.js` / `.ts` / `.cjs` / `.mjs`

**Only the first matching file is used - configs are not merged.** Place your
personal config at `~/.elasticrc.yml` (recommended) so it applies everywhere, or
put one in a project root to override it for that project.
The CLI looks for a config file in your home directory. The following file names
are recognised (checked in this order):

1. `.elasticrc`
2. `.elasticrc.json`
3. `.elasticrc.yaml`
4. `.elasticrc.yml`

You can also point to a config file explicitly with `--config-file <path>` or
the `ELASTIC_CLI_CONFIG_FILE` environment variable. Precedence:
`--config-file` > `ELASTIC_CLI_CONFIG_FILE` > home directory discovery.

JavaScript and TypeScript config files are not supported for security reasons.

```yaml
current_context: local
Expand Down Expand Up @@ -69,6 +63,58 @@ Override `current_context` for a single command with `--use-context <name>`.
Each context can have any combination of service blocks (`elasticsearch`, `kibana`, `cloud`).
Authentication can also use `username` + `password` instead of `api_key`.

### External credentials

Instead of storing secrets in plaintext, any string value in the config file can
use `$(resolver:params)` expressions to fetch values from external sources at
runtime.

#### `file` - read from a file

Reads the contents of a file (trimmed). Useful for Docker/Kubernetes secrets
mounted at `/run/secrets/`.

```yaml
auth:
api_key: $(file:/run/secrets/elastic_api_key)
```

#### `env` - environment variables

```yaml
auth:
api_key: $(env:ELASTIC_API_KEY)
```

#### `cmd` - shell command output

The command is executed and its stdout (trimmed) is used as the value.

```yaml
auth:
api_key: $(cmd:pass show elastic/api-key)
```

#### `keychain` - macOS Keychain (macOS only)

Reads a password from the macOS Keychain using the `service/account` format.

```yaml
auth:
api_key: $(keychain:elastic-cli/api-key)
```

To store a value: `security add-generic-password -s elastic-cli -a api-key -w`

Expressions can appear in any string field, including URLs:

```yaml
elasticsearch:
url: https://$(env:ES_HOST):9200
auth:
api_key: $(keychain:elastic-cli/api-key)
```

## Global options

| Option | Description |
Expand Down
29 changes: 20 additions & 9 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
*
* Pipeline:
* 1. Create cosmiconfig explorer with ID 'elastic' for discovery
* 2. Load and validate config file with Zod schemas
* 3. Resolve the active context (default or --use-context override)
* 4. Return typed ResolvedConfig to command handlers
* 2. Resolve expressions in config values (e.g. $(env:VAR), $(cmd:...), $(keychain:...))
* 3. Validate resolved config with Zod schemas
* 4. Resolve the active context (default or --use-context override)
* 5. Return typed ResolvedConfig to command handlers
*
* Supports:
* - cosmiconfig discovery (searches standard locations)
Expand All @@ -23,6 +24,7 @@
import { z } from 'zod'
import { cosmiconfig } from 'cosmiconfig'
import { ConfigFileSchema } from './schema.ts'
import { resolveExpressions } from './resolvers.ts'
import type { ConfigFile, ResolvedConfig, ResolvedContext } from './types.ts'

/**
Expand Down Expand Up @@ -120,9 +122,10 @@ export type LoadConfigResult = LoadConfigOk | LoadConfigErr
*
* Steps:
* 1. Load raw config via cosmiconfig (discovery or explicit path)
* 2. Validate with `ConfigFileSchema` (Zod)
* 3. Resolve the active context (from `contextName` override or `current_context`)
* 4. Return a typed `ResolvedConfig`
* 2. Resolve expressions in string values (env, cmd, keychain)
* 3. Validate with `ConfigFileSchema` (Zod)
* 4. Resolve the active context (from `contextName` override or `current_context`)
* 5. Return a typed `ResolvedConfig`
*
* All failure modes return `{ ok: false, error: { message } }` -- never throw.
*
Expand Down Expand Up @@ -153,15 +156,23 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise<Load
return { ok: false, error: { message } }
}

// Step 2: validate with Zod
// Step 2: resolve expressions in string values
try {
raw = await resolveExpressions(raw)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { ok: false, error: { message: `Failed to resolve config expressions: ${message}` } }
}

// Step 3: validate with Zod
const parsed = ConfigFileSchema.safeParse(raw)
if (!parsed.success) {
return { ok: false, error: { message: z.prettifyError(parsed.error) } }
}

const config = parsed.data

// Step 3: resolve context name (--use-context override or current_context from file)
// Step 4: resolve context name (--use-context override or current_context from file)
const resolvedContextName = contextName ?? config.current_context

if (!(resolvedContextName in config.contexts)) {
Expand All @@ -174,6 +185,6 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise<Load
}
}

// Step 4: resolve and return
// Step 5: resolve and return
return { ok: true, value: resolveContext(config, resolvedContextName) }
}
210 changes: 210 additions & 0 deletions src/config/resolvers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { readFileSync, statSync } from 'node:fs'
import { execSync, type ExecSyncOptionsWithStringEncoding } from 'node:child_process'

export type ResolverFn = (params: string) => string | Promise<string>

// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------

const registry = new Map<string, ResolverFn>()

export function registerResolver (name: string, fn: ResolverFn): void {
registry.set(name, fn)
}

export function getResolver (name: string): ResolverFn | undefined {
return registry.get(name)
}

// ---------------------------------------------------------------------------
// Expression parser
// ---------------------------------------------------------------------------

const EXPRESSION_RE = /\$\(([a-z_][a-z0-9_]*):([^)]+)\)/

export function containsExpression (value: string): boolean {
return value.includes('$(')
}

export async function resolveString (
value: string,
fieldPath: string
): Promise<string> {
if (!containsExpression(value)) return value

const matches = [...value.matchAll(new RegExp(EXPRESSION_RE.source, 'g'))]
if (matches.length === 0) return value

let result = value
for (const match of matches) {
const full = match[0]
const name = match[1]!
const params = match[2]!
const resolver = getResolver(name)
if (resolver == null) {
throw new Error(
`Unknown resolver "${name}" in expression "${full}" at ${fieldPath}. ` +
`Available resolvers: ${[...registry.keys()].join(', ')}`
)
}
const resolved = await resolver(params)
result = result.replaceAll(full, resolved)
}

return result
}

// ---------------------------------------------------------------------------
// Deep object walk
// ---------------------------------------------------------------------------

export async function resolveExpressions (
obj: unknown,
path: string = ''
): Promise<unknown> {
if (typeof obj === 'string') {
return resolveString(obj, path || '<root>')
}
if (Array.isArray(obj)) {
return Promise.all(obj.map((item, i) => resolveExpressions(item, `${path}[${i}]`)))
}
if (obj !== null && typeof obj === 'object') {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
if (key === '__proto__' || key === 'constructor') continue
const fieldPath = path ? `${path}.${key}` : key
result[key] = await resolveExpressions(value, fieldPath)
}
return result
}
return obj
}

// ---------------------------------------------------------------------------
// Built-in resolvers
// ---------------------------------------------------------------------------

let _execSync: typeof execSync = execSync
let _platform: string = process.platform

function execOpts (timeoutMs: number): ExecSyncOptionsWithStringEncoding {
return { encoding: 'utf-8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
}

function shellEscape (value: string): string {
return "'" + value.replace(/'/g, "'\\''") + "'"
}

const MAX_FILE_SIZE = 64 * 1024 // 64 KB

function fileResolver (params: string): string {
let stat
try {
stat = statSync(params)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Failed to read file "${params}": ${message}`, { cause: err })
}
if (!stat.isFile()) {
throw new Error(`Failed to read file "${params}": not a regular file`)
}
if (stat.size > MAX_FILE_SIZE) {
throw new Error(`Failed to read file "${params}": file is ${stat.size} bytes (max ${MAX_FILE_SIZE})`)
}
return readFileSync(params, 'utf-8').trimEnd()
}

function envResolver (params: string): string {
const value = process.env[params]
if (value == null || value === '') {
throw new Error(`Environment variable "${params}" is not set or is empty`)
}
return value
}

function cmdResolver (params: string): string {
try {
return _execSync(params, execOpts(10_000)).trimEnd()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Command failed: ${params}\n${message}`, { cause: err })
}
}

function keychainResolver (params: string): string {
if (_platform !== 'darwin') {
throw new Error(
`The keychain resolver is only supported on macOS (current platform: ${_platform})`
)
}

if (!/^[\x20-\x7e]+$/.test(params)) {
throw new Error(
`Invalid keychain parameter "${params}": contains non-printable characters`
)
}

const slashIndex = params.indexOf('/')
if (slashIndex === -1 || slashIndex === 0 || slashIndex === params.length - 1) {
throw new Error(
`Invalid keychain parameter "${params}": expected format "service/account" ` +
`(e.g., "elastic-cli/api-key")`
)
}

const service = params.slice(0, slashIndex)
const account = params.slice(slashIndex + 1)

try {
return _execSync(
`security find-generic-password -s ${shellEscape(service)} -a ${shellEscape(account)} -w`,
execOpts(5_000)
).trimEnd()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(
`Keychain lookup failed for service="${service}", account="${account}": ${message}`,
{ cause: err }
)
}
}

// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------

function registerBuiltins (): void {
registerResolver('file', fileResolver)
registerResolver('env', envResolver)
registerResolver('cmd', cmdResolver)
registerResolver('keychain', keychainResolver)
}

registerBuiltins()

// ---------------------------------------------------------------------------
// Test seams
// ---------------------------------------------------------------------------

export function _testResetResolvers (): void {
registry.clear()
registerBuiltins()
}

export function _testSetExecSync (fn: typeof execSync): () => void {
const prev = _execSync
_execSync = fn
return () => { _execSync = prev }
}

export function _testSetPlatform (p: string): () => void {
const prev = _platform
_platform = p
return () => { _platform = prev }
}
Loading
Loading