Skip to content

Commit 77dbd5f

Browse files
authored
feat: lazy validation, only resolve expressions for the active context (#148)
1 parent 8004b93 commit 77dbd5f

3 files changed

Lines changed: 265 additions & 30 deletions

File tree

src/config/loader.ts

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@
66
/**
77
* Configuration file discovery, loading, validation, and context resolution.
88
*
9-
* Pipeline:
9+
* Pipeline (lazy validation):
1010
* 1. Discover config file in home directory (or via --config-file / ELASTIC_CLI_CONFIG_FILE)
11-
* 2. Resolve expressions in config values (e.g. $(env:VAR), $(cmd:...), $(keychain:...))
12-
* 3. Validate resolved config with Zod schemas
13-
* 4. Resolve the active context (default or --use-context override)
14-
* 5. Return typed ResolvedConfig to command handlers
11+
* 2. Structural validation of the outer config shape (without resolving expressions)
12+
* 3. Resolve context name (default or --use-context override)
13+
* 4. Resolve expressions only in the active context and commands section
14+
* 5. Validate active context with full Zod schemas
15+
* 6. Return typed ResolvedConfig to command handlers
16+
*
17+
* Only the active context's expressions are resolved, so inactive contexts
18+
* with unset environment variables or missing files will not cause failures.
1519
*
1620
* Supports:
1721
* - Home-directory discovery (~/.elasticrc.yml and variants)
@@ -26,7 +30,7 @@ import { homedir } from 'node:os'
2630
import { extname, join } from 'node:path'
2731
import { z } from 'zod'
2832
import { parse as parseYaml } from 'yaml'
29-
import { ConfigFileSchema } from './schema.ts'
33+
import { ContextSchema, CommandPolicySchema, StructuralConfigSchema } from './schema.ts'
3034
import { resolveExpressions } from './resolvers.ts'
3135
import type { ConfigFile, ResolvedConfig, ResolvedContext } from './types.ts'
3236

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

130134
/**
131-
* Full config loading pipeline: discover/load → validate → resolve context.
135+
* Full config loading pipeline with lazy expression resolution.
132136
*
133137
* Steps:
134138
* 1. Discover or resolve config file path, then read and parse it
135-
* 2. Resolve expressions in string values (env, cmd, keychain)
136-
* 3. Validate with `ConfigFileSchema` (Zod)
137-
* 4. Resolve the active context (from `contextName` override or `current_context`)
138-
* 5. Return a typed `ResolvedConfig`
139+
* 2. Structural validation (shape only, no expression resolution)
140+
* 3. Resolve context name (from `contextName` override or `current_context`)
141+
* 4. Extract active context raw data + commands section
142+
* 5. Resolve expressions only in the active context and commands
143+
* 6. Validate active context with ContextSchema, commands with CommandPolicySchema
144+
* 7. Return a typed `ResolvedConfig`
145+
*
146+
* Only the active context's expressions are resolved, so inactive contexts
147+
* with unset environment variables or missing files will not cause failures.
139148
*
140149
* All failure modes return `{ ok: false, error: { message } }` -- never throw.
141150
*
@@ -172,27 +181,19 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise<Load
172181
return { ok: false, error: { message } }
173182
}
174183

175-
// Step 2: resolve expressions in string values
176-
try {
177-
raw = await resolveExpressions(raw)
178-
} catch (err) {
179-
const message = err instanceof Error ? err.message : String(err)
180-
return { ok: false, error: { message: `Failed to resolve config expressions: ${message}` } }
181-
}
182-
183-
// Step 3: validate with Zod
184-
const parsed = ConfigFileSchema.safeParse(raw)
185-
if (!parsed.success) {
186-
return { ok: false, error: { message: z.prettifyError(parsed.error) } }
184+
// Step 2: structural validation (shape only, no deep context validation)
185+
const structural = StructuralConfigSchema.safeParse(raw)
186+
if (!structural.success) {
187+
return { ok: false, error: { message: z.prettifyError(structural.error) } }
187188
}
188189

189-
const config = parsed.data
190+
const { current_context, contexts, commands: rawCommands } = structural.data
190191

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

194-
if (!(resolvedContextName in config.contexts)) {
195-
const available = Object.keys(config.contexts).join(', ')
195+
if (!(resolvedContextName in contexts)) {
196+
const available = Object.keys(contexts).join(', ')
196197
return {
197198
ok: false,
198199
error: {
@@ -201,6 +202,41 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise<Load
201202
}
202203
}
203204

204-
// Step 5: resolve and return
205-
return { ok: true, value: resolveContext(config, resolvedContextName) }
205+
// Step 4: resolve expressions only in active context and commands (in parallel)
206+
let resolvedRawContext: unknown
207+
let resolvedRawCommands: unknown
208+
try {
209+
[resolvedRawContext, resolvedRawCommands] = await Promise.all([
210+
resolveExpressions(contexts[resolvedContextName], `contexts.${resolvedContextName}`),
211+
rawCommands != null ? resolveExpressions(rawCommands, 'commands') : undefined,
212+
])
213+
} catch (err) {
214+
const message = err instanceof Error ? err.message : String(err)
215+
return { ok: false, error: { message: `Failed to resolve config expressions: ${message}` } }
216+
}
217+
218+
// Step 5: validate active context and commands with full schemas
219+
const contextParsed = ContextSchema.safeParse(resolvedRawContext)
220+
if (!contextParsed.success) {
221+
return { ok: false, error: { message: z.prettifyError(contextParsed.error) } }
222+
}
223+
224+
let commands: ConfigFile['commands']
225+
if (resolvedRawCommands != null) {
226+
const commandsParsed = CommandPolicySchema.safeParse(resolvedRawCommands)
227+
if (!commandsParsed.success) {
228+
return { ok: false, error: { message: z.prettifyError(commandsParsed.error) } }
229+
}
230+
commands = commandsParsed.data
231+
}
232+
233+
// Step 6: build and return ResolvedConfig
234+
const ctx = contextParsed.data
235+
const resolved: ResolvedContext = {}
236+
if (ctx.elasticsearch != null) resolved.elasticsearch = ctx.elasticsearch
237+
if (ctx.kibana != null) resolved.kibana = ctx.kibana
238+
if (ctx.cloud != null) resolved.cloud = ctx.cloud
239+
const result: ResolvedConfig = { context: resolved }
240+
if (commands != null) result.commands = commands
241+
return { ok: true, value: result }
206242
}

src/config/schema.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,18 @@ export const ConfigFileSchema = z
8282
(cfg) => cfg.current_context in cfg.contexts,
8383
{ error: 'current_context must reference an existing context key' }
8484
)
85+
86+
/**
87+
* Structural schema for first-pass validation before expression resolution.
88+
* Validates the outer config shape (current_context, contexts keys, commands)
89+
* without deeply validating context values (which may contain unresolved expressions).
90+
*/
91+
export const StructuralConfigSchema = z
92+
.object({
93+
current_context: z.string().min(1),
94+
contexts: z.record(z.string(), z.record(z.string(), z.unknown())).refine(
95+
(map) => Object.keys(map).length > 0,
96+
{ error: 'contexts must contain at least one entry' },
97+
),
98+
commands: z.unknown().optional(),
99+
})

test/config/loader.test.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,190 @@ commands:
449449
})
450450
})
451451

452+
// ---------------------------------------------------------------------------
453+
// Lazy validation: only resolve expressions for the active context (#144)
454+
// ---------------------------------------------------------------------------
455+
456+
describe('lazy validation: inactive context expressions are not resolved', () => {
457+
const ACTIVE_VAR = 'ELASTIC_CLI_TEST_ACTIVE_KEY'
458+
const INACTIVE_VAR = 'ELASTIC_CLI_TEST_INACTIVE_KEY'
459+
let tmpDir: string
460+
461+
before(async () => {
462+
tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-lazy-'))
463+
})
464+
after(async () => {
465+
delete process.env[ACTIVE_VAR]
466+
delete process.env[INACTIVE_VAR]
467+
await rm(tmpDir, { recursive: true })
468+
})
469+
470+
it('succeeds when the active context resolves but an inactive context would fail', async () => {
471+
process.env[ACTIVE_VAR] = 'active-api-key'
472+
delete process.env[INACTIVE_VAR]
473+
const yaml = `
474+
current_context: local
475+
contexts:
476+
local:
477+
elasticsearch:
478+
url: http://localhost:9200
479+
auth:
480+
api_key: $(env:${ACTIVE_VAR})
481+
staging:
482+
elasticsearch:
483+
url: https://staging.example.com:9200
484+
auth:
485+
api_key: $(env:${INACTIVE_VAR})
486+
`.trimStart()
487+
const configPath = join(tmpDir, 'lazy-ok.yml')
488+
await writeFile(configPath, yaml)
489+
const result = await loadConfig({ configPath })
490+
assert.ok(result.ok, `expected ok, got: ${!result.ok ? result.error.message : ''}`)
491+
if (!result.ok) return
492+
assert.equal(
493+
(result.value.context.elasticsearch!.auth as { api_key: string }).api_key,
494+
'active-api-key'
495+
)
496+
})
497+
498+
it('still fails when the active context has an unresolvable expression', async () => {
499+
delete process.env[ACTIVE_VAR]
500+
delete process.env[INACTIVE_VAR]
501+
const yaml = `
502+
current_context: local
503+
contexts:
504+
local:
505+
elasticsearch:
506+
url: http://localhost:9200
507+
auth:
508+
api_key: $(env:${ACTIVE_VAR})
509+
staging:
510+
elasticsearch:
511+
url: https://staging.example.com:9200
512+
auth:
513+
api_key: $(env:${INACTIVE_VAR})
514+
`.trimStart()
515+
const configPath = join(tmpDir, 'lazy-fail.yml')
516+
await writeFile(configPath, yaml)
517+
const result = await loadConfig({ configPath })
518+
assert.ok(!result.ok, 'expected failure for unresolvable active context expression')
519+
if (result.ok) return
520+
assert.match(result.error.message, new RegExp(ACTIVE_VAR))
521+
})
522+
523+
it('resolves expressions in the active context selected via --use-context', async () => {
524+
process.env[INACTIVE_VAR] = 'staging-key'
525+
delete process.env[ACTIVE_VAR]
526+
const yaml = `
527+
current_context: local
528+
contexts:
529+
local:
530+
elasticsearch:
531+
url: http://localhost:9200
532+
auth:
533+
api_key: $(env:${ACTIVE_VAR})
534+
staging:
535+
elasticsearch:
536+
url: https://staging.example.com:9200
537+
auth:
538+
api_key: $(env:${INACTIVE_VAR})
539+
`.trimStart()
540+
const configPath = join(tmpDir, 'lazy-override.yml')
541+
await writeFile(configPath, yaml)
542+
const result = await loadConfig({ configPath, contextName: 'staging' })
543+
assert.ok(result.ok, `expected ok with --use-context staging, got: ${!result.ok ? result.error.message : ''}`)
544+
if (!result.ok) return
545+
assert.equal(
546+
(result.value.context.elasticsearch!.auth as { api_key: string }).api_key,
547+
'staging-key'
548+
)
549+
})
550+
551+
it('still validates structural config shape (missing current_context)', async () => {
552+
const yaml = `
553+
contexts:
554+
local:
555+
elasticsearch:
556+
url: http://localhost:9200
557+
auth:
558+
api_key: some-key
559+
`.trimStart()
560+
const configPath = join(tmpDir, 'lazy-no-current.yml')
561+
await writeFile(configPath, yaml)
562+
const result = await loadConfig({ configPath })
563+
assert.ok(!result.ok, 'should fail without current_context')
564+
})
565+
566+
it('still validates structural config shape (empty contexts)', async () => {
567+
const yaml = `
568+
current_context: local
569+
contexts: {}
570+
`.trimStart()
571+
const configPath = join(tmpDir, 'lazy-empty-contexts.yml')
572+
await writeFile(configPath, yaml)
573+
const result = await loadConfig({ configPath })
574+
assert.ok(!result.ok, 'should fail with empty contexts')
575+
})
576+
577+
it('rejects current_context that references a nonexistent context key', async () => {
578+
const yaml = `
579+
current_context: nonexistent
580+
contexts:
581+
local:
582+
elasticsearch:
583+
url: http://localhost:9200
584+
auth:
585+
api_key: some-key
586+
`.trimStart()
587+
const configPath = join(tmpDir, 'lazy-bad-ref.yml')
588+
await writeFile(configPath, yaml)
589+
const result = await loadConfig({ configPath })
590+
assert.ok(!result.ok, 'should fail when current_context references missing key')
591+
if (result.ok) return
592+
assert.ok(result.error.message.includes('nonexistent'))
593+
})
594+
595+
it('resolves expressions in commands section', async () => {
596+
process.env[ACTIVE_VAR] = 'my-key'
597+
const yaml = `
598+
current_context: local
599+
contexts:
600+
local:
601+
elasticsearch:
602+
url: http://localhost:9200
603+
auth:
604+
api_key: $(env:${ACTIVE_VAR})
605+
commands:
606+
allowed:
607+
- ping
608+
- elasticsearch.search
609+
`.trimStart()
610+
const configPath = join(tmpDir, 'lazy-commands.yml')
611+
await writeFile(configPath, yaml)
612+
const result = await loadConfig({ configPath })
613+
assert.ok(result.ok, `expected ok, got: ${!result.ok ? result.error.message : ''}`)
614+
if (!result.ok) return
615+
assert.deepEqual(result.value.commands, { allowed: ['ping', 'elasticsearch.search'] })
616+
})
617+
618+
it('validates active context with full schema after expression resolution', async () => {
619+
process.env[ACTIVE_VAR] = 'resolved-key'
620+
const yaml = `
621+
current_context: local
622+
contexts:
623+
local:
624+
elasticsearch:
625+
url: not-a-valid-url
626+
auth:
627+
api_key: $(env:${ACTIVE_VAR})
628+
`.trimStart()
629+
const configPath = join(tmpDir, 'lazy-bad-url.yml')
630+
await writeFile(configPath, yaml)
631+
const result = await loadConfig({ configPath })
632+
assert.ok(!result.ok, 'should fail when active context has invalid URL after resolution')
633+
})
634+
})
635+
452636
describe('security: executable config formats are rejected', () => {
453637
let tmpDir: string
454638
before(async () => {

0 commit comments

Comments
 (0)