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 () => {