From b2ef5bb54e44f5b6f958fc57cdb3953a2e0ed3c8 Mon Sep 17 00:00:00 2001 From: ivanbenko Date: Thu, 2 Apr 2026 11:02:01 +0200 Subject: [PATCH 1/4] feat(base-ops): add grouped secret facts and secret resolver Support defining multiple secret values under a single group name via secretGroup(). Add resolveSecret/requireSecret helpers that transparently resolve a camelCase name (e.g. "netbirdApiKey") by trying exact match first, then splitting into group + property ("netbird" + "apiKey"). Update vault-fact-store to read/write grouped secret specs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../base-ops/src/__tests__/fact-store.test.ts | 38 ++++++- .../src/__tests__/secret-resolver.test.ts | 100 ++++++++++++++++++ packages/base-ops/src/fact.ts | 18 ++++ packages/base-ops/src/index.ts | 1 + packages/base-ops/src/secret-resolver.ts | 66 ++++++++++++ .../src/__tests__/vault-fact-store.test.ts | 46 +++++++- .../vault-fact-store/src/vault-fact-store.ts | 8 +- 7 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 packages/base-ops/src/__tests__/secret-resolver.test.ts create mode 100644 packages/base-ops/src/secret-resolver.ts diff --git a/packages/base-ops/src/__tests__/fact-store.test.ts b/packages/base-ops/src/__tests__/fact-store.test.ts index 0f182a4..c271801 100644 --- a/packages/base-ops/src/__tests__/fact-store.test.ts +++ b/packages/base-ops/src/__tests__/fact-store.test.ts @@ -3,7 +3,7 @@ import type { FactStoreReader, FactStoreWriter } from '../fact-store.js' import type { InfrastructureConfig } from '../config.js' import { InfrastructureFactsPool } from '../facts-pool.js' import { InfrastructureConfigMerger } from '../config-merger.js' -import { simpleSecret, SIMPLE_SECRET_KIND, type SimpleSecretFact } from '../fact.js' +import { simpleSecret, secretGroup, SIMPLE_SECRET_KIND, type SimpleSecretFact, type SecretGroupFact } from '../fact.js' describe('FactStoreReader', () => { it('returns facts from a mock reader', async () => { @@ -184,3 +184,39 @@ describe('SimpleSecret', () => { expect(pool.getAll(SIMPLE_SECRET_KIND)).toHaveLength(2) }) }) + +describe('SecretGroup', () => { + it('creates a grouped secret fact', () => { + const fact = secretGroup('netbird', { apiKey: 'key1', webhookSecret: 'wh1' }, 'admin') + + expect(fact).toEqual({ + kind: SIMPLE_SECRET_KIND, + metadata: { name: 'netbird' }, + spec: { apiKey: 'key1', webhookSecret: 'wh1' }, + owner: 'admin', + }) + }) + + it('works with FactsPool lookup', () => { + const config: InfrastructureConfig = { + facts: [secretGroup('netbird', { apiKey: 'key1', token: 'tok1' }, 'admin')], + } + + const pool = new InfrastructureFactsPool(config) + const group = pool.requireFact(SIMPLE_SECRET_KIND, 'netbird') + + expect(group.spec.apiKey).toBe('key1') + expect(group.spec.token).toBe('tok1') + }) + + it('coexists with simple secrets in the same pool', () => { + const config: InfrastructureConfig = { + facts: [simpleSecret('standalone', 'val', 'admin'), secretGroup('netbird', { apiKey: 'key1' }, 'admin')], + } + + const pool = new InfrastructureFactsPool(config) + expect(pool.getAll(SIMPLE_SECRET_KIND)).toHaveLength(2) + expect(pool.requireFact(SIMPLE_SECRET_KIND, 'standalone').spec.value).toBe('val') + expect(pool.requireFact(SIMPLE_SECRET_KIND, 'netbird').spec.apiKey).toBe('key1') + }) +}) diff --git a/packages/base-ops/src/__tests__/secret-resolver.test.ts b/packages/base-ops/src/__tests__/secret-resolver.test.ts new file mode 100644 index 0000000..66134ec --- /dev/null +++ b/packages/base-ops/src/__tests__/secret-resolver.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' +import type { InfrastructureConfig } from '../config.js' +import { InfrastructureFactsPool } from '../facts-pool.js' +import { simpleSecret, secretGroup } from '../fact.js' +import { camelCaseSplits, resolveSecret, requireSecret } from '../secret-resolver.js' + +describe('camelCaseSplits', () => { + it('splits a two-word camelCase name', () => { + expect(camelCaseSplits('apiKey')).toEqual([{ prefix: 'api', property: 'key' }]) + }) + + it('splits a three-word camelCase name into two pairs', () => { + expect(camelCaseSplits('netbirdApiKey')).toEqual([ + { prefix: 'netbird', property: 'apiKey' }, + { prefix: 'netbirdApi', property: 'key' }, + ]) + }) + + it('returns empty array for all-lowercase name', () => { + expect(camelCaseSplits('apikey')).toEqual([]) + }) + + it('returns empty array for single character', () => { + expect(camelCaseSplits('a')).toEqual([]) + }) + + it('handles leading uppercase (acronym-style)', () => { + const splits = camelCaseSplits('awsSecretKey') + expect(splits).toEqual([ + { prefix: 'aws', property: 'secretKey' }, + { prefix: 'awsSecret', property: 'key' }, + ]) + }) + + it('handles consecutive uppercase letters', () => { + const splits = camelCaseSplits('myAWSKey') + expect(splits).toEqual([ + { prefix: 'my', property: 'aWSKey' }, + { prefix: 'myA', property: 'wSKey' }, + { prefix: 'myAW', property: 'sKey' }, + { prefix: 'myAWS', property: 'key' }, + ]) + }) +}) + +describe('resolveSecret', () => { + function poolFrom(...facts: InfrastructureConfig['facts']): InfrastructureFactsPool { + return new InfrastructureFactsPool({ facts }) + } + + it('resolves a simple secret by exact name', () => { + const pool = poolFrom(simpleSecret('netbirdApiKey', 's3cret', 'admin')) + expect(resolveSecret(pool, 'netbirdApiKey')).toBe('s3cret') + }) + + it('resolves a grouped secret property via camelCase split', () => { + const pool = poolFrom(secretGroup('netbird', { apiKey: 'key1', webhookSecret: 'wh1' }, 'admin')) + expect(resolveSecret(pool, 'netbirdApiKey')).toBe('key1') + expect(resolveSecret(pool, 'netbirdWebhookSecret')).toBe('wh1') + }) + + it('prefers exact match over group match', () => { + const pool = poolFrom( + simpleSecret('netbirdApiKey', 'exact-value', 'admin'), + secretGroup('netbird', { apiKey: 'group-value' }, 'admin2'), + ) + expect(resolveSecret(pool, 'netbirdApiKey')).toBe('exact-value') + }) + + it('returns undefined when secret not found', () => { + const pool = poolFrom(simpleSecret('other', 'val', 'admin')) + expect(resolveSecret(pool, 'netbirdApiKey')).toBeUndefined() + }) + + it('returns undefined when group exists but property does not', () => { + const pool = poolFrom(secretGroup('netbird', { token: 'tok' }, 'admin')) + expect(resolveSecret(pool, 'netbirdApiKey')).toBeUndefined() + }) + + it('tries multiple split points and matches the first valid one', () => { + const pool = poolFrom(secretGroup('netbirdApi', { key: 'from-second-split' }, 'admin')) + expect(resolveSecret(pool, 'netbirdApiKey')).toBe('from-second-split') + }) +}) + +describe('requireSecret', () => { + function poolFrom(...facts: InfrastructureConfig['facts']): InfrastructureFactsPool { + return new InfrastructureFactsPool({ facts }) + } + + it('returns value when secret exists', () => { + const pool = poolFrom(simpleSecret('dbPassword', 'hunter2', 'admin')) + expect(requireSecret(pool, 'dbPassword')).toBe('hunter2') + }) + + it('throws when secret not found', () => { + const pool = poolFrom() + expect(() => requireSecret(pool, 'missing')).toThrow('Secret "missing" not found') + }) +}) diff --git a/packages/base-ops/src/fact.ts b/packages/base-ops/src/fact.ts index 11dc92c..d3f54e8 100644 --- a/packages/base-ops/src/fact.ts +++ b/packages/base-ops/src/fact.ts @@ -45,3 +45,21 @@ export function simpleSecret(name: string, value: string, owner: string): Simple owner, } } + +// --- Grouped Secrets --- + +/** Spec for a secret group holding multiple named properties. */ +export type SecretGroupSpec = Record + +/** A fact that holds multiple named secret values under a single group name. */ +export type SecretGroupFact = InfrastructureFact + +/** Create a grouped secret fact with multiple named properties. */ +export function secretGroup(name: string, properties: Record, owner: string): SecretGroupFact { + return { + kind: SIMPLE_SECRET_KIND, + metadata: { name }, + spec: properties, + owner, + } +} diff --git a/packages/base-ops/src/index.ts b/packages/base-ops/src/index.ts index 0a1a73a..20f8aa3 100644 --- a/packages/base-ops/src/index.ts +++ b/packages/base-ops/src/index.ts @@ -1,4 +1,5 @@ export * from './fact' +export * from './secret-resolver' export * from './config' export * from './config-reader' export * from './config-merger' diff --git a/packages/base-ops/src/secret-resolver.ts b/packages/base-ops/src/secret-resolver.ts new file mode 100644 index 0000000..758f691 --- /dev/null +++ b/packages/base-ops/src/secret-resolver.ts @@ -0,0 +1,66 @@ +import { SIMPLE_SECRET_KIND } from './fact' +import { InfrastructureFactsPool } from './facts-pool' + +export interface SecretSplit { + prefix: string + property: string +} + +/** + * Split a camelCase name into all possible (prefix, property) pairs. + * + * "netbirdApiKey" → [{ prefix: "netbird", property: "apiKey" }, { prefix: "netbirdApi", property: "key" }] + */ +export function camelCaseSplits(name: string): SecretSplit[] { + const splits: SecretSplit[] = [] + + for (let i = 1; i < name.length; i++) { + if (name[i] >= 'A' && name[i] <= 'Z') { + const prefix = name.slice(0, i) + const rest = name.slice(i) + const property = rest[0].toLowerCase() + rest.slice(1) + splits.push({ prefix, property }) + } + } + + return splits +} + +/** + * Resolve a secret name to its string value. + * + * 1. Exact match — looks up `secret#` and returns `.spec.value` + * 2. Group match — splits name at camelCase boundaries and tries each + * `(prefix, property)` pair against the pool + * + * Returns `undefined` if no match is found. + */ +export function resolveSecret(pool: InfrastructureFactsPool, name: string): string | undefined { + const direct = pool.getFact(SIMPLE_SECRET_KIND, name) + if (direct && typeof direct.spec.value === 'string') { + return direct.spec.value + } + + for (const { prefix, property } of camelCaseSplits(name)) { + const group = pool.getFact(SIMPLE_SECRET_KIND, prefix) + if (group) { + const value = (group.spec as Record)[property] + if (typeof value === 'string') { + return value + } + } + } + + return undefined +} + +/** + * Resolve a secret name to its string value, throwing if not found. + */ +export function requireSecret(pool: InfrastructureFactsPool, name: string): string { + const value = resolveSecret(pool, name) + if (value === undefined) { + throw new Error(`Secret "${name}" not found`) + } + return value +} diff --git a/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts b/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts index a7e8089..3b8729e 100644 --- a/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts +++ b/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { InfrastructureFact } from '@opsen/base-ops' -import { SIMPLE_SECRET_KIND, simpleSecret } from '@opsen/base-ops' +import { SIMPLE_SECRET_KIND, simpleSecret, secretGroup } from '@opsen/base-ops' import { VaultFactStore } from '../vault-fact-store.js' import { VaultKV2Client } from '../vault-client.js' @@ -181,16 +181,16 @@ describe('VaultFactStore', () => { expect(config.facts[0]).toEqual(expect.objectContaining({ metadata: { name: 'ok' } })) }) - it('skips simple secrets with non-string value', async () => { + it('skips secrets with non-string property values', async () => { mockClient.list.mockImplementation(async (path: string) => { if (path === 'owner') return ['secret/'] - if (path === 'owner/secret') return ['good', 'no-value', 'object-value'] + if (path === 'owner/secret') return ['good', 'object-value', 'empty'] return ['owner/'] }) mockClient.get.mockImplementation(async (path: string) => { if (path === 'owner/secret/good') return { value: 'hunter2' } - if (path === 'owner/secret/no-value') return { something: 'else' } if (path === 'owner/secret/object-value') return { value: { nested: true } } + if (path === 'owner/secret/empty') return {} return null }) @@ -346,6 +346,44 @@ describe('VaultFactStore', () => { expect(mockClient.put).toHaveBeenCalledWith('manual/cluster/prod', expect.objectContaining({ kind: 'cluster' })) }) + it('reads grouped secrets with multiple string properties', async () => { + mockClient.list.mockImplementation(async (path: string) => { + if (path === 'manual') return ['secret/'] + if (path === 'manual/secret') return ['netbird'] + return ['manual/'] + }) + mockClient.get.mockImplementation(async (path: string) => { + if (path === 'manual/secret/netbird') return { apiKey: 'key1', webhookSecret: 'wh1' } + return null + }) + + const store = new VaultFactStore({ address: 'https://vault', token: 'tok' }) + const config = await store.read() + + expect(config.facts).toHaveLength(1) + expect(config.facts[0]).toEqual({ + kind: 'secret', + metadata: { name: 'netbird' }, + spec: { apiKey: 'key1', webhookSecret: 'wh1' }, + owner: 'manual', + }) + }) + + it('writes grouped secrets as raw properties', async () => { + mockClient.put.mockResolvedValue(undefined) + mockClient.list.mockResolvedValue([]) + + const store = new VaultFactStore({ address: 'https://vault', token: 'tok' }, 'manual') + await store.write({ + facts: [secretGroup('netbird', { apiKey: 'key1', webhookSecret: 'wh1' }, 'manual')], + }) + + expect(mockClient.put).toHaveBeenCalledWith('manual/secret/netbird', { + apiKey: 'key1', + webhookSecret: 'wh1', + }) + }) + it('cleans up stale simple secrets via directory listing', async () => { mockClient.put.mockResolvedValue(undefined) mockClient.delete.mockResolvedValue(undefined) diff --git a/packages/vault-fact-store/src/vault-fact-store.ts b/packages/vault-fact-store/src/vault-fact-store.ts index e9b1c95..d67d094 100644 --- a/packages/vault-fact-store/src/vault-fact-store.ts +++ b/packages/vault-fact-store/src/vault-fact-store.ts @@ -63,12 +63,12 @@ export class VaultFactStore implements FactStoreReader, FactStoreWriter { if (!data) return if (kind === SIMPLE_SECRET_KIND) { - const value = data.value - if (typeof value === 'string') { + const allStrings = Object.keys(data).length > 0 && Object.values(data).every((v) => typeof v === 'string') + if (allStrings) { facts.push({ kind: SIMPLE_SECRET_KIND, metadata: { name }, - spec: { value }, + spec: data as Record, owner, }) } @@ -101,7 +101,7 @@ export class VaultFactStore implements FactStoreReader, FactStoreWriter { const writes = config.facts.map(async (fact) => { const path = factPath(this.basePath, this.owner!, fact.kind, fact.metadata.name) if (fact.kind === SIMPLE_SECRET_KIND) { - await this.client.put(path, { value: (fact.spec as { value: string }).value }) + await this.client.put(path, fact.spec as Record) } else { await this.client.put(path, fact as unknown as Record) } From 1cf7104bf08ea2bb58ef384e1be62d1e95035faf Mon Sep 17 00:00:00 2001 From: ivanbenko Date: Thu, 2 Apr 2026 11:13:44 +0200 Subject: [PATCH 2/4] fix(vault-fact-store): coerce number values to strings when reading secrets Vault may store numeric values (e.g. ports) that should be treated as strings in the secret spec. Convert numbers to strings during read so they pass validation and are usable as secret properties. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/__tests__/vault-fact-store.test.ts | 25 ++++++++++++++++++- .../vault-fact-store/src/vault-fact-store.ts | 12 ++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts b/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts index 3b8729e..74a1aa4 100644 --- a/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts +++ b/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts @@ -181,7 +181,7 @@ describe('VaultFactStore', () => { expect(config.facts[0]).toEqual(expect.objectContaining({ metadata: { name: 'ok' } })) }) - it('skips secrets with non-string property values', async () => { + it('skips secrets with non-scalar property values', async () => { mockClient.list.mockImplementation(async (path: string) => { if (path === 'owner') return ['secret/'] if (path === 'owner/secret') return ['good', 'object-value', 'empty'] @@ -203,6 +203,29 @@ describe('VaultFactStore', () => { ) }) + it('converts number values to strings when reading secrets', async () => { + mockClient.list.mockImplementation(async (path: string) => { + if (path === 'owner') return ['secret/'] + if (path === 'owner/secret') return ['port-config', 'simple-num'] + return ['owner/'] + }) + mockClient.get.mockImplementation(async (path: string) => { + if (path === 'owner/secret/port-config') return { host: 'localhost', port: 5432 } + if (path === 'owner/secret/simple-num') return { value: 42 } + return null + }) + + const store = new VaultFactStore({ address: 'https://vault', token: 'tok' }) + const config = await store.read() + + expect(config.facts).toHaveLength(2) + expect(config.facts.find((f) => f.metadata.name === 'port-config')!.spec).toEqual({ + host: 'localhost', + port: '5432', + }) + expect(config.facts.find((f) => f.metadata.name === 'simple-num')!.spec).toEqual({ value: '42' }) + }) + it('reads with basePath prefix when configured', async () => { mockClient.list.mockImplementation(async (path: string) => { if (path === 'infra') return ['my-stack/'] diff --git a/packages/vault-fact-store/src/vault-fact-store.ts b/packages/vault-fact-store/src/vault-fact-store.ts index d67d094..3b0e089 100644 --- a/packages/vault-fact-store/src/vault-fact-store.ts +++ b/packages/vault-fact-store/src/vault-fact-store.ts @@ -63,12 +63,18 @@ export class VaultFactStore implements FactStoreReader, FactStoreWriter { if (!data) return if (kind === SIMPLE_SECRET_KIND) { - const allStrings = Object.keys(data).length > 0 && Object.values(data).every((v) => typeof v === 'string') - if (allStrings) { + const entries = Object.entries(data) + const allScalar = + entries.length > 0 && entries.every(([, v]) => typeof v === 'string' || typeof v === 'number') + if (allScalar) { + const spec: Record = {} + for (const [k, v] of entries) { + spec[k] = String(v) + } facts.push({ kind: SIMPLE_SECRET_KIND, metadata: { name }, - spec: data as Record, + spec, owner, }) } From c1939fcd5be47de6e73b796395a8a373eef28647 Mon Sep 17 00:00:00 2001 From: ivanbenko Date: Thu, 2 Apr 2026 11:19:58 +0200 Subject: [PATCH 3/4] feat(base-ops): add getSecret/requireSecret methods to FactsPool Move secret resolution logic into InfrastructureFactsPool so modules can call pool.requireSecret('netbirdApiKey') directly. The standalone resolveSecret/requireSecret functions are kept as deprecated delegates. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/__tests__/secret-resolver.test.ts | 44 ++++++++------ packages/base-ops/src/facts-pool.ts | 53 ++++++++++++++++- packages/base-ops/src/secret-resolver.ts | 58 +++---------------- 3 files changed, 85 insertions(+), 70 deletions(-) diff --git a/packages/base-ops/src/__tests__/secret-resolver.test.ts b/packages/base-ops/src/__tests__/secret-resolver.test.ts index 66134ec..589f246 100644 --- a/packages/base-ops/src/__tests__/secret-resolver.test.ts +++ b/packages/base-ops/src/__tests__/secret-resolver.test.ts @@ -4,6 +4,10 @@ import { InfrastructureFactsPool } from '../facts-pool.js' import { simpleSecret, secretGroup } from '../fact.js' import { camelCaseSplits, resolveSecret, requireSecret } from '../secret-resolver.js' +function poolFrom(...facts: InfrastructureConfig['facts']): InfrastructureFactsPool { + return new InfrastructureFactsPool({ facts }) +} + describe('camelCaseSplits', () => { it('splits a two-word camelCase name', () => { expect(camelCaseSplits('apiKey')).toEqual([{ prefix: 'api', property: 'key' }]) @@ -43,20 +47,16 @@ describe('camelCaseSplits', () => { }) }) -describe('resolveSecret', () => { - function poolFrom(...facts: InfrastructureConfig['facts']): InfrastructureFactsPool { - return new InfrastructureFactsPool({ facts }) - } - +describe('pool.getSecret', () => { it('resolves a simple secret by exact name', () => { const pool = poolFrom(simpleSecret('netbirdApiKey', 's3cret', 'admin')) - expect(resolveSecret(pool, 'netbirdApiKey')).toBe('s3cret') + expect(pool.getSecret('netbirdApiKey')).toBe('s3cret') }) it('resolves a grouped secret property via camelCase split', () => { const pool = poolFrom(secretGroup('netbird', { apiKey: 'key1', webhookSecret: 'wh1' }, 'admin')) - expect(resolveSecret(pool, 'netbirdApiKey')).toBe('key1') - expect(resolveSecret(pool, 'netbirdWebhookSecret')).toBe('wh1') + expect(pool.getSecret('netbirdApiKey')).toBe('key1') + expect(pool.getSecret('netbirdWebhookSecret')).toBe('wh1') }) it('prefers exact match over group match', () => { @@ -64,36 +64,44 @@ describe('resolveSecret', () => { simpleSecret('netbirdApiKey', 'exact-value', 'admin'), secretGroup('netbird', { apiKey: 'group-value' }, 'admin2'), ) - expect(resolveSecret(pool, 'netbirdApiKey')).toBe('exact-value') + expect(pool.getSecret('netbirdApiKey')).toBe('exact-value') }) it('returns undefined when secret not found', () => { const pool = poolFrom(simpleSecret('other', 'val', 'admin')) - expect(resolveSecret(pool, 'netbirdApiKey')).toBeUndefined() + expect(pool.getSecret('netbirdApiKey')).toBeUndefined() }) it('returns undefined when group exists but property does not', () => { const pool = poolFrom(secretGroup('netbird', { token: 'tok' }, 'admin')) - expect(resolveSecret(pool, 'netbirdApiKey')).toBeUndefined() + expect(pool.getSecret('netbirdApiKey')).toBeUndefined() }) it('tries multiple split points and matches the first valid one', () => { const pool = poolFrom(secretGroup('netbirdApi', { key: 'from-second-split' }, 'admin')) - expect(resolveSecret(pool, 'netbirdApiKey')).toBe('from-second-split') + expect(pool.getSecret('netbirdApiKey')).toBe('from-second-split') }) }) -describe('requireSecret', () => { - function poolFrom(...facts: InfrastructureConfig['facts']): InfrastructureFactsPool { - return new InfrastructureFactsPool({ facts }) - } - +describe('pool.requireSecret', () => { it('returns value when secret exists', () => { const pool = poolFrom(simpleSecret('dbPassword', 'hunter2', 'admin')) - expect(requireSecret(pool, 'dbPassword')).toBe('hunter2') + expect(pool.requireSecret('dbPassword')).toBe('hunter2') }) it('throws when secret not found', () => { + const pool = poolFrom() + expect(() => pool.requireSecret('missing')).toThrow('Secret "missing" not found') + }) +}) + +describe('standalone functions (deprecated delegates)', () => { + it('resolveSecret delegates to pool.getSecret', () => { + const pool = poolFrom(simpleSecret('key', 'val', 'admin')) + expect(resolveSecret(pool, 'key')).toBe('val') + }) + + it('requireSecret delegates to pool.requireSecret', () => { const pool = poolFrom() expect(() => requireSecret(pool, 'missing')).toThrow('Secret "missing" not found') }) diff --git a/packages/base-ops/src/facts-pool.ts b/packages/base-ops/src/facts-pool.ts index 0e6920b..016033a 100644 --- a/packages/base-ops/src/facts-pool.ts +++ b/packages/base-ops/src/facts-pool.ts @@ -1,6 +1,25 @@ -import { InfrastructureFact, InfrastructureFactLabelValue } from './fact' +import { InfrastructureFact, InfrastructureFactLabelValue, SIMPLE_SECRET_KIND } from './fact' import { InfrastructureConfig } from './config' +export interface SecretSplit { + prefix: string + property: string +} + +/** Split a camelCase name into all possible (prefix, property) pairs. */ +export function camelCaseSplits(name: string): SecretSplit[] { + const splits: SecretSplit[] = [] + for (let i = 1; i < name.length; i++) { + if (name[i] >= 'A' && name[i] <= 'Z') { + const prefix = name.slice(0, i) + const rest = name.slice(i) + const property = rest[0].toLowerCase() + rest.slice(1) + splits.push({ prefix, property }) + } + } + return splits +} + export class InfrastructureFactsPool { private readonly factsMap: Map = new Map() private readonly factsByKindMap: Map = new Map() @@ -55,6 +74,38 @@ export class InfrastructureFactsPool this.matches(x, labels)) as T[] } + /** + * Resolve a secret by name. Tries exact match first, then camelCase group+property splits. + * Returns `undefined` if no match is found. + */ + public getSecret(name: string): string | undefined { + const direct = this.getFact(SIMPLE_SECRET_KIND, name) + if (direct && typeof direct.spec.value === 'string') { + return direct.spec.value + } + + for (const { prefix, property } of camelCaseSplits(name)) { + const group = this.getFact(SIMPLE_SECRET_KIND, prefix) + if (group) { + const value = (group.spec as Record)[property] + if (typeof value === 'string') { + return value + } + } + } + + return undefined + } + + /** Resolve a secret by name, throwing if not found. */ + public requireSecret(name: string): string { + const value = this.getSecret(name) + if (value === undefined) { + throw new Error(`Secret "${name}" not found`) + } + return value + } + private matches(fact: TFacts, labels: Record): boolean { for (const [key, value] of Object.entries(labels)) { const factLabelValue = fact.metadata.labels?.[key] diff --git a/packages/base-ops/src/secret-resolver.ts b/packages/base-ops/src/secret-resolver.ts index 758f691..a1b6c4e 100644 --- a/packages/base-ops/src/secret-resolver.ts +++ b/packages/base-ops/src/secret-resolver.ts @@ -1,66 +1,22 @@ -import { SIMPLE_SECRET_KIND } from './fact' import { InfrastructureFactsPool } from './facts-pool' -export interface SecretSplit { - prefix: string - property: string -} - -/** - * Split a camelCase name into all possible (prefix, property) pairs. - * - * "netbirdApiKey" → [{ prefix: "netbird", property: "apiKey" }, { prefix: "netbirdApi", property: "key" }] - */ -export function camelCaseSplits(name: string): SecretSplit[] { - const splits: SecretSplit[] = [] - - for (let i = 1; i < name.length; i++) { - if (name[i] >= 'A' && name[i] <= 'Z') { - const prefix = name.slice(0, i) - const rest = name.slice(i) - const property = rest[0].toLowerCase() + rest.slice(1) - splits.push({ prefix, property }) - } - } - - return splits -} +export type { SecretSplit } from './facts-pool' +export { camelCaseSplits } from './facts-pool' /** * Resolve a secret name to its string value. * - * 1. Exact match — looks up `secret#` and returns `.spec.value` - * 2. Group match — splits name at camelCase boundaries and tries each - * `(prefix, property)` pair against the pool - * - * Returns `undefined` if no match is found. + * @deprecated Use `pool.getSecret(name)` directly instead. */ export function resolveSecret(pool: InfrastructureFactsPool, name: string): string | undefined { - const direct = pool.getFact(SIMPLE_SECRET_KIND, name) - if (direct && typeof direct.spec.value === 'string') { - return direct.spec.value - } - - for (const { prefix, property } of camelCaseSplits(name)) { - const group = pool.getFact(SIMPLE_SECRET_KIND, prefix) - if (group) { - const value = (group.spec as Record)[property] - if (typeof value === 'string') { - return value - } - } - } - - return undefined + return pool.getSecret(name) } /** * Resolve a secret name to its string value, throwing if not found. + * + * @deprecated Use `pool.requireSecret(name)` directly instead. */ export function requireSecret(pool: InfrastructureFactsPool, name: string): string { - const value = resolveSecret(pool, name) - if (value === undefined) { - throw new Error(`Secret "${name}" not found`) - } - return value + return pool.requireSecret(name) } From a2a0f9410f7a15c09a38cd6170c5c71b6134e281 Mon Sep 17 00:00:00 2001 From: ivanbenko Date: Thu, 2 Apr 2026 11:41:54 +0200 Subject: [PATCH 4/4] chore: add changeset for grouped secret facts Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/grouped-secret-facts.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/grouped-secret-facts.md diff --git a/.changeset/grouped-secret-facts.md b/.changeset/grouped-secret-facts.md new file mode 100644 index 0000000..0fe68c4 --- /dev/null +++ b/.changeset/grouped-secret-facts.md @@ -0,0 +1,10 @@ +--- +'@opsen/base-ops': minor +'@opsen/vault-fact-store': minor +--- + +Add grouped secret facts and secret resolver to FactsPool + +- `secretGroup()` factory for defining multiple named secret values under a single group name +- `pool.getSecret()`/`pool.requireSecret()` resolve camelCase names by exact match or group+property split (e.g. `netbirdApiKey` → group `netbird`, property `apiKey`) +- Vault fact store now reads/writes grouped secret specs and coerces number values to strings