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
96 changes: 66 additions & 30 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
/**
* Configuration file discovery, loading, validation, and context resolution.
*
* Pipeline:
* Pipeline (lazy validation):
* 1. Discover config file in home directory (or via --config-file / ELASTIC_CLI_CONFIG_FILE)
* 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
* 2. Structural validation of the outer config shape (without resolving expressions)
* 3. Resolve context name (default or --use-context override)
* 4. Resolve expressions only in the active context and commands section
* 5. Validate active context with full Zod schemas
* 6. Return typed ResolvedConfig to command handlers
*
* Only the active context's expressions are resolved, so inactive contexts
* with unset environment variables or missing files will not cause failures.
*
* Supports:
* - Home-directory discovery (~/.elasticrc.yml and variants)
Expand All @@ -26,7 +30,7 @@ import { homedir } from 'node:os'
import { extname, join } from 'node:path'
import { z } from 'zod'
import { parse as parseYaml } from 'yaml'
import { ConfigFileSchema } from './schema.ts'
import { ContextSchema, CommandPolicySchema, StructuralConfigSchema } from './schema.ts'
import { resolveExpressions } from './resolvers.ts'
import type { ConfigFile, ResolvedConfig, ResolvedContext } from './types.ts'

Expand Down Expand Up @@ -128,14 +132,19 @@ export interface LoadConfigErr { ok: false, error: { message: string } }
export type LoadConfigResult = LoadConfigOk | LoadConfigErr

/**
* Full config loading pipeline: discover/load → validate → resolve context.
* Full config loading pipeline with lazy expression resolution.
*
* Steps:
* 1. Discover or resolve config file path, then read and parse it
* 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`
* 2. Structural validation (shape only, no expression resolution)
* 3. Resolve context name (from `contextName` override or `current_context`)
* 4. Extract active context raw data + commands section
* 5. Resolve expressions only in the active context and commands
* 6. Validate active context with ContextSchema, commands with CommandPolicySchema
* 7. Return a typed `ResolvedConfig`
*
* Only the active context's expressions are resolved, so inactive contexts
* with unset environment variables or missing files will not cause failures.
*
* All failure modes return `{ ok: false, error: { message } }` -- never throw.
*
Expand Down Expand Up @@ -172,27 +181,19 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise<Load
return { ok: false, error: { message } }
}

// 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) } }
// Step 2: structural validation (shape only, no deep context validation)
const structural = StructuralConfigSchema.safeParse(raw)
if (!structural.success) {
return { ok: false, error: { message: z.prettifyError(structural.error) } }
}

const config = parsed.data
const { current_context, contexts, commands: rawCommands } = structural.data

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

if (!(resolvedContextName in config.contexts)) {
const available = Object.keys(config.contexts).join(', ')
if (!(resolvedContextName in contexts)) {
const available = Object.keys(contexts).join(', ')
return {
ok: false,
error: {
Expand All @@ -201,6 +202,41 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise<Load
}
}

// Step 5: resolve and return
return { ok: true, value: resolveContext(config, resolvedContextName) }
// Step 4: resolve expressions only in active context and commands (in parallel)
let resolvedRawContext: unknown
let resolvedRawCommands: unknown
try {
[resolvedRawContext, resolvedRawCommands] = await Promise.all([
resolveExpressions(contexts[resolvedContextName], `contexts.${resolvedContextName}`),
rawCommands != null ? resolveExpressions(rawCommands, 'commands') : undefined,
])
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { ok: false, error: { message: `Failed to resolve config expressions: ${message}` } }
}

// Step 5: validate active context and commands with full schemas
const contextParsed = ContextSchema.safeParse(resolvedRawContext)
if (!contextParsed.success) {
return { ok: false, error: { message: z.prettifyError(contextParsed.error) } }
}

let commands: ConfigFile['commands']
if (resolvedRawCommands != null) {
const commandsParsed = CommandPolicySchema.safeParse(resolvedRawCommands)
if (!commandsParsed.success) {
return { ok: false, error: { message: z.prettifyError(commandsParsed.error) } }
}
commands = commandsParsed.data
}

// Step 6: build and return ResolvedConfig
const ctx = contextParsed.data
const resolved: ResolvedContext = {}
Copy link
Copy Markdown
Contributor

@margaretjgu margaretjgu Apr 14, 2026

Choose a reason for hiding this comment

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

lgtm! since LoadConfig now contains the same lines from resolveContext, i think we can either refactor resolveContext so loadConfig can still call it (instead of duplicating), or add a comment saying this function is no longer used by loadConfig, kept for external callers...for future maintenance if we need ot update the context in two places.

not urgent just nitpick

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Whoops sorry, got trigger happy on merging!

I'll raise a PR to fix this!

if (ctx.elasticsearch != null) resolved.elasticsearch = ctx.elasticsearch
if (ctx.kibana != null) resolved.kibana = ctx.kibana
if (ctx.cloud != null) resolved.cloud = ctx.cloud
const result: ResolvedConfig = { context: resolved }
if (commands != null) result.commands = commands
return { ok: true, value: result }
}
15 changes: 15 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,18 @@ export const ConfigFileSchema = z
(cfg) => cfg.current_context in cfg.contexts,
{ error: 'current_context must reference an existing context key' }
)

/**
* Structural schema for first-pass validation before expression resolution.
* Validates the outer config shape (current_context, contexts keys, commands)
* without deeply validating context values (which may contain unresolved expressions).
*/
export const StructuralConfigSchema = z
.object({
current_context: z.string().min(1),
contexts: z.record(z.string(), z.record(z.string(), z.unknown())).refine(
(map) => Object.keys(map).length > 0,
{ error: 'contexts must contain at least one entry' },
),
commands: z.unknown().optional(),
})
184 changes: 184 additions & 0 deletions test/config/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,190 @@ commands:
})
})

// ---------------------------------------------------------------------------
// Lazy validation: only resolve expressions for the active context (#144)
// ---------------------------------------------------------------------------

describe('lazy validation: inactive context expressions are not resolved', () => {
const ACTIVE_VAR = 'ELASTIC_CLI_TEST_ACTIVE_KEY'
const INACTIVE_VAR = 'ELASTIC_CLI_TEST_INACTIVE_KEY'
let tmpDir: string

before(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-lazy-'))
})
after(async () => {
delete process.env[ACTIVE_VAR]
delete process.env[INACTIVE_VAR]
await rm(tmpDir, { recursive: true })
})

it('succeeds when the active context resolves but an inactive context would fail', async () => {
process.env[ACTIVE_VAR] = 'active-api-key'
delete process.env[INACTIVE_VAR]
const yaml = `
current_context: local
contexts:
local:
elasticsearch:
url: http://localhost:9200
auth:
api_key: $(env:${ACTIVE_VAR})
staging:
elasticsearch:
url: https://staging.example.com:9200
auth:
api_key: $(env:${INACTIVE_VAR})
`.trimStart()
const configPath = join(tmpDir, 'lazy-ok.yml')
await writeFile(configPath, yaml)
const result = await loadConfig({ configPath })
assert.ok(result.ok, `expected ok, got: ${!result.ok ? result.error.message : ''}`)
if (!result.ok) return
assert.equal(
(result.value.context.elasticsearch!.auth as { api_key: string }).api_key,
'active-api-key'
)
})

it('still fails when the active context has an unresolvable expression', async () => {
delete process.env[ACTIVE_VAR]
delete process.env[INACTIVE_VAR]
const yaml = `
current_context: local
contexts:
local:
elasticsearch:
url: http://localhost:9200
auth:
api_key: $(env:${ACTIVE_VAR})
staging:
elasticsearch:
url: https://staging.example.com:9200
auth:
api_key: $(env:${INACTIVE_VAR})
`.trimStart()
const configPath = join(tmpDir, 'lazy-fail.yml')
await writeFile(configPath, yaml)
const result = await loadConfig({ configPath })
assert.ok(!result.ok, 'expected failure for unresolvable active context expression')
if (result.ok) return
assert.match(result.error.message, new RegExp(ACTIVE_VAR))
})

it('resolves expressions in the active context selected via --use-context', async () => {
process.env[INACTIVE_VAR] = 'staging-key'
delete process.env[ACTIVE_VAR]
const yaml = `
current_context: local
contexts:
local:
elasticsearch:
url: http://localhost:9200
auth:
api_key: $(env:${ACTIVE_VAR})
staging:
elasticsearch:
url: https://staging.example.com:9200
auth:
api_key: $(env:${INACTIVE_VAR})
`.trimStart()
const configPath = join(tmpDir, 'lazy-override.yml')
await writeFile(configPath, yaml)
const result = await loadConfig({ configPath, contextName: 'staging' })
assert.ok(result.ok, `expected ok with --use-context staging, got: ${!result.ok ? result.error.message : ''}`)
if (!result.ok) return
assert.equal(
(result.value.context.elasticsearch!.auth as { api_key: string }).api_key,
'staging-key'
)
})

it('still validates structural config shape (missing current_context)', async () => {
const yaml = `
contexts:
local:
elasticsearch:
url: http://localhost:9200
auth:
api_key: some-key
`.trimStart()
const configPath = join(tmpDir, 'lazy-no-current.yml')
await writeFile(configPath, yaml)
const result = await loadConfig({ configPath })
assert.ok(!result.ok, 'should fail without current_context')
})

it('still validates structural config shape (empty contexts)', async () => {
const yaml = `
current_context: local
contexts: {}
`.trimStart()
const configPath = join(tmpDir, 'lazy-empty-contexts.yml')
await writeFile(configPath, yaml)
const result = await loadConfig({ configPath })
assert.ok(!result.ok, 'should fail with empty contexts')
})

it('rejects current_context that references a nonexistent context key', async () => {
const yaml = `
current_context: nonexistent
contexts:
local:
elasticsearch:
url: http://localhost:9200
auth:
api_key: some-key
`.trimStart()
const configPath = join(tmpDir, 'lazy-bad-ref.yml')
await writeFile(configPath, yaml)
const result = await loadConfig({ configPath })
assert.ok(!result.ok, 'should fail when current_context references missing key')
if (result.ok) return
assert.ok(result.error.message.includes('nonexistent'))
})

it('resolves expressions in commands section', async () => {
process.env[ACTIVE_VAR] = 'my-key'
const yaml = `
current_context: local
contexts:
local:
elasticsearch:
url: http://localhost:9200
auth:
api_key: $(env:${ACTIVE_VAR})
commands:
allowed:
- ping
- elasticsearch.search
`.trimStart()
const configPath = join(tmpDir, 'lazy-commands.yml')
await writeFile(configPath, yaml)
const result = await loadConfig({ configPath })
assert.ok(result.ok, `expected ok, got: ${!result.ok ? result.error.message : ''}`)
if (!result.ok) return
assert.deepEqual(result.value.commands, { allowed: ['ping', 'elasticsearch.search'] })
})

it('validates active context with full schema after expression resolution', async () => {
process.env[ACTIVE_VAR] = 'resolved-key'
const yaml = `
current_context: local
contexts:
local:
elasticsearch:
url: not-a-valid-url
auth:
api_key: $(env:${ACTIVE_VAR})
`.trimStart()
const configPath = join(tmpDir, 'lazy-bad-url.yml')
await writeFile(configPath, yaml)
const result = await loadConfig({ configPath })
assert.ok(!result.ok, 'should fail when active context has invalid URL after resolution')
})
})

describe('security: executable config formats are rejected', () => {
let tmpDir: string
before(async () => {
Expand Down