From 17e4c9b3d1260b4b1dbc21a690e97227e339341e Mon Sep 17 00:00:00 2001 From: Matt Devy Date: Tue, 14 Apr 2026 18:58:02 +0100 Subject: [PATCH] feat: lazy validation, only resolve expressions for the active context Inactive contexts with unresolved expressions (e.g. unset env vars) no longer cause loadConfig to fail. The pipeline now structurally validates the outer config shape first, then resolves expressions and runs full Zod validation only on the active context and commands. Closes #144 --- src/config/loader.ts | 96 +++++++++++++------ src/config/schema.ts | 15 +++ test/config/loader.test.ts | 184 +++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 30 deletions(-) diff --git a/src/config/loader.ts b/src/config/loader.ts index 5b9f7378..05d91ce1 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -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) @@ -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' @@ -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. * @@ -172,27 +181,19 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise 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(), + }) diff --git a/test/config/loader.test.ts b/test/config/loader.test.ts index c6cc71f8..ab7c1d67 100644 --- a/test/config/loader.test.ts +++ b/test/config/loader.test.ts @@ -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 () => {