diff --git a/common/changes/@boostercloud/framework-core/azure_app_configuration_2025-08-06-21-35.json b/common/changes/@boostercloud/framework-core/azure_app_configuration_2025-08-06-21-35.json new file mode 100644 index 0000000000..cd93677c49 --- /dev/null +++ b/common/changes/@boostercloud/framework-core/azure_app_configuration_2025-08-06-21-35.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@boostercloud/framework-core", + "comment": "Configuration provider with Azure App Configuration support", + "type": "minor" + } + ], + "packageName": "@boostercloud/framework-core" +} \ No newline at end of file diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index bddaa69c61..3ad308541c 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: specifier: 3.7.13 version: 3.7.13(graphql@16.10.0)(react@17.0.2)(subscriptions-transport-ws@0.11.0(graphql@16.10.0)) '@boostercloud/framework-types': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -47,7 +47,7 @@ importers: version: 8.18.0 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@types/jsonwebtoken': specifier: 9.0.8 @@ -104,10 +104,10 @@ importers: ../../packages/cli: dependencies: '@boostercloud/framework-core': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-core '@boostercloud/framework-types': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -150,10 +150,10 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/application-tester': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../application-tester '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@oclif/test': specifier: ^4.1.10 @@ -264,7 +264,7 @@ importers: ../../packages/framework-common-helpers: dependencies: '@boostercloud/framework-types': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -280,7 +280,7 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@types/chai': specifier: 4.2.18 @@ -370,10 +370,10 @@ importers: ../../packages/framework-core: dependencies: '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-common-helpers '@boostercloud/framework-types': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-types '@effect/cli': specifier: 0.56.2 @@ -437,10 +437,10 @@ importers: version: 8.18.0 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@boostercloud/metadata-booster': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../metadata-booster '@types/chai': specifier: 4.2.18 @@ -545,22 +545,22 @@ importers: ../../packages/framework-integration-tests: dependencies: '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-common-helpers '@boostercloud/framework-core': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-core '@boostercloud/framework-provider-aws': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-provider-aws '@boostercloud/framework-provider-azure': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-provider-azure '@boostercloud/framework-provider-local': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-provider-local '@boostercloud/framework-types': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -618,25 +618,25 @@ importers: specifier: 3.7.13 version: 3.7.13(graphql@16.10.0)(react@17.0.2)(subscriptions-transport-ws@0.11.0(graphql@16.10.0)) '@boostercloud/application-tester': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../application-tester '@boostercloud/cli': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../cli '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@boostercloud/framework-provider-aws-infrastructure': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-provider-aws-infrastructure '@boostercloud/framework-provider-azure-infrastructure': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-provider-azure-infrastructure '@boostercloud/framework-provider-local-infrastructure': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-provider-local-infrastructure '@boostercloud/metadata-booster': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../metadata-booster '@seald-io/nedb': specifier: 4.0.2 @@ -777,10 +777,10 @@ importers: ../../packages/framework-provider-aws: dependencies: '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-common-helpers '@boostercloud/framework-types': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -790,7 +790,7 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@types/aws-lambda': specifier: 8.10.48 @@ -943,13 +943,13 @@ importers: specifier: ^1.170.0 version: 1.204.0 '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-common-helpers '@boostercloud/framework-provider-aws': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-provider-aws '@boostercloud/framework-types': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -983,7 +983,7 @@ importers: version: 1.10.2 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@types/archiver': specifier: 5.1.0 @@ -1081,6 +1081,9 @@ importers: ../../packages/framework-provider-azure: dependencies: + '@azure/app-configuration': + specifier: ^1.7.0 + version: 1.9.0 '@azure/cosmos': specifier: ^4.3.0 version: 4.4.1 @@ -1097,10 +1100,10 @@ importers: specifier: ~1.1.0 version: 1.1.3 '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-common-helpers '@boostercloud/framework-types': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -1110,7 +1113,7 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@types/chai': specifier: 4.2.18 @@ -1203,16 +1206,16 @@ importers: specifier: ~4.7.0 version: 4.7.0 '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-common-helpers '@boostercloud/framework-core': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-core '@boostercloud/framework-provider-azure': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-provider-azure '@boostercloud/framework-types': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-types '@cdktf/provider-azurerm': specifier: 13.18.0 @@ -1279,7 +1282,7 @@ importers: version: 11.0.5 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@types/chai': specifier: 4.2.18 @@ -1360,10 +1363,10 @@ importers: ../../packages/framework-provider-local: dependencies: '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-common-helpers '@boostercloud/framework-types': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -1379,7 +1382,7 @@ importers: version: 8.18.0 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@types/chai': specifier: 4.2.18 @@ -1475,13 +1478,13 @@ importers: ../../packages/framework-provider-local-infrastructure: dependencies: '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-common-helpers '@boostercloud/framework-provider-local': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-provider-local '@boostercloud/framework-types': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -1500,7 +1503,7 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@types/chai': specifier: 4.2.18 @@ -1636,10 +1639,10 @@ importers: version: 8.18.0 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@boostercloud/metadata-booster': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../metadata-booster '@types/chai': specifier: 4.2.18 @@ -1733,7 +1736,7 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.0 + specifier: workspace:^3.4.1 version: link:../../tools/eslint-config '@types/node': specifier: ^20.17.17 @@ -2705,6 +2708,10 @@ packages: resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} engines: {node: '>=18.0.0'} + '@azure/app-configuration@1.9.0': + resolution: {integrity: sha512-X0AVDQygL4AGLtplLYW+W0QakJpJ417sQldOacqwcBQ882tAPdUVs6V3mZ4jUjwVsgr+dV1v9zMmijvsp6XBxA==} + engines: {node: '>=18.0.0'} + '@azure/arm-appservice@16.0.0': resolution: {integrity: sha512-oJBb1kpI6okJouyGKBqA9Kp1Em6CutdqbI+Q74pQz7ssv6zBoxIC9BCg15jvHOdK14JE16lbuf3nGqUZ6AyNbw==} engines: {node: '>=18.0.0'} @@ -7395,8 +7402,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20250801: - resolution: {integrity: sha512-b/X5+OCIRwL/sCMYpynN/Lkwx3H8Jnt+ttDIZo5bKWpYK1TTeh76tXoKsrUSex2dn8Sd8qqUf7OHifvdGmeKhg==} + typescript@6.0.0-dev.20250806: + resolution: {integrity: sha512-inDvi8ujsZXA/dSgj8QiSjHSi7fYDnkRck9vvnd400VBY5RSzNl6G3zZXjFZTawwYfETOakld5vQ6a36JuNOBQ==} engines: {node: '>=14.17'} hasBin: true @@ -8509,6 +8516,22 @@ snapshots: dependencies: tslib: 2.8.1 + '@azure/app-configuration@1.9.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.9.0 + '@azure/core-client': 1.9.2 + '@azure/core-http-compat': 2.3.0 + '@azure/core-lro': 2.7.2 + '@azure/core-paging': 1.6.2 + '@azure/core-rest-pipeline': 1.20.0 + '@azure/core-tracing': 1.2.0 + '@azure/core-util': 1.11.0 + '@azure/logger': 1.1.4 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@azure/arm-appservice@16.0.0': dependencies: '@azure/abort-controller': 2.1.2 @@ -10912,7 +10935,7 @@ snapshots: dependencies: semver: 7.6.3 shelljs: 0.8.5 - typescript: 6.0.0-dev.20250801 + typescript: 6.0.0-dev.20250806 dunder-proto@1.0.1: dependencies: @@ -14375,7 +14398,7 @@ snapshots: typescript@5.7.3: {} - typescript@6.0.0-dev.20250801: {} + typescript@6.0.0-dev.20250806: {} unbox-primitive@1.1.0: dependencies: diff --git a/packages/framework-core/src/index.ts b/packages/framework-core/src/index.ts index ca41b8b131..5d668f3004 100644 --- a/packages/framework-core/src/index.ts +++ b/packages/framework-core/src/index.ts @@ -17,6 +17,7 @@ export { BoosterDataMigrationFinished } from './core-concepts/data-migration/eve export { BoosterDataMigrationEntity } from './core-concepts/data-migration/entities/booster-data-migration-entity' export { BoosterTouchEntityHandler } from './booster-touch-entity-handler' export * from './services/token-verifiers' +export * from './services/configuration-service' export * from './instrumentation/index' export * from './decorators/health-sensor' export * as Injectable from './injectable' diff --git a/packages/framework-core/src/services/configuration-service.ts b/packages/framework-core/src/services/configuration-service.ts new file mode 100644 index 0000000000..8036b4faf2 --- /dev/null +++ b/packages/framework-core/src/services/configuration-service.ts @@ -0,0 +1,90 @@ +import { + BoosterConfig, + BoosterConfigEnvProvider, + ConfigurationProvider, + ConfigurationResolution, + ConfigurationResolver, + DefaultConfigurationResolver, + EnvironmentVariablesProvider, +} from '@boostercloud/framework-types' + +export class ConfigurationService { + private static instance: ConfigurationService | undefined + private resolver: ConfigurationResolver + + private constructor(config: BoosterConfig) { + this.resolver = new DefaultConfigurationResolver() + + // Add default providers (these are always available) + this.resolver.addProvider(new EnvironmentVariablesProvider()) + this.resolver.addProvider(new BoosterConfigEnvProvider(config.env)) + + // Add any registered configuration providers from the config + for (const provider of config.configurationProviders) { + this.resolver.addProvider(provider) + } + } + + /** + * Get the singleton instance of the ConfigurationService + */ + public static getInstance(config: BoosterConfig): ConfigurationService { + if (!this.instance) { + this.instance = new ConfigurationService(config) + } + return this.instance + } + + /** + * Reset the singleton instance (useful for testing) + */ + public static reset(): void { + this.instance = undefined + } + + /** + * Resolve a configuration value from all available providers + * @param key The configuration key to resolve + * @returns Promise resolving to the configuration value or undefined if not found + */ + public async getValue(key: string): Promise { + const resolution = await this.resolver.resolve(key) + return resolution.value + } + + /** + * Resolve a configuration value with source tracking + * @param key The configuration key to resolve + * @returns Promise resolving to the full configuration resolution result + */ + public async resolve(key: string): Promise { + return this.resolver.resolve(key) + } + + /** + * Get all registered providers + */ + public getProviders(): ConfigurationProvider[] { + return this.resolver.getProviders() + } +} + +/** + * Utility function to resolve a configuration value using the Booster configuration + * This is the main API for configuration resolution within the framework + */ +export async function resolveConfigurationValue(config: BoosterConfig, key: string): Promise { + const configService = ConfigurationService.getInstance(config) + return configService.getValue(key) +} + +/** + * Utility function to resolve a configuration value with source tracking + */ +export async function resolveConfigurationWithSource( + config: BoosterConfig, + key: string +): Promise { + const configService = ConfigurationService.getInstance(config) + return configService.resolve(key) +} diff --git a/packages/framework-core/test/services/configuration-service.test.ts b/packages/framework-core/test/services/configuration-service.test.ts new file mode 100644 index 0000000000..50d0587a4f --- /dev/null +++ b/packages/framework-core/test/services/configuration-service.test.ts @@ -0,0 +1,259 @@ +// Mock configuration provider for testing +import { BoosterConfig, ConfigurationProvider } from '@boostercloud/framework-types' +import { ConfigurationService, resolveConfigurationValue, resolveConfigurationWithSource } from '../../src' +import { restore } from 'sinon' +import { expect } from '../expect' + +class MockConfigurationProvider implements ConfigurationProvider { + constructor( + public readonly name: string, + public readonly priority: number, + private readonly values: Record = {}, + private readonly available: boolean = true + ) {} + + async getValue(key: string): Promise { + return this.values[key] + } + + async isAvailable(): Promise { + return this.available + } +} + +describe('ConfigurationService', () => { + let mockConfig: BoosterConfig + + beforeEach(() => { + ConfigurationService.reset() + mockConfig = new BoosterConfig('test') + // Override the readonly env property for testing + Object.assign(mockConfig, { + env: { + CONFIG_VAR: 'config-value', + SHARED_VAR: 'config-shared-value', + }, + }) + }) + + afterEach(() => { + restore() + ConfigurationService.reset() + }) + + describe('getInstance', () => { + it('should create singleton instance', () => { + const instance1 = ConfigurationService.getInstance(mockConfig) + const instance2 = ConfigurationService.getInstance(mockConfig) + + expect(instance1).to.equal(instance2) + }) + + it('should initialize with default providers', () => { + const instance = ConfigurationService.getInstance(mockConfig) + const providers = instance.getProviders() + + expect(providers).to.have.length(2) + expect(providers.some((p) => p.name === 'environment-variables')).to.be.true + expect(providers.some((p) => p.name === 'booster-config-env')).to.be.true + }) + + it('should include registered configuration providers', () => { + const customProvider = new MockConfigurationProvider('custom', 25) + mockConfig.addConfigurationProvider(customProvider) + + const instance = ConfigurationService.getInstance(mockConfig) + const providers = instance.getProviders() + + expect(providers).to.have.length(3) + expect(providers.some((p) => p.name === 'custom')).to.be.true + expect(providers[0].name).to.equal('custom') // Highest priority first + }) + }) + + describe('getValue', () => { + let originalEnv: typeof process.env + + beforeEach(() => { + originalEnv = { ...process.env } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('should resolve from highest priority provider', async () => { + // Set up environment variable + process.env['TEST_VAR'] = 'env-value' + + // Add higher priority provider + const highPriorityProvider = new MockConfigurationProvider('high-priority', 25, { + TEST_VAR: 'high-priority-value', + }) + mockConfig.addConfigurationProvider(highPriorityProvider) + + const instance = ConfigurationService.getInstance(mockConfig) + const value = await instance.getValue('TEST_VAR') + + expect(value).to.equal('high-priority-value') + }) + + it('should fallback to config.env', async () => { + const instance = ConfigurationService.getInstance(mockConfig) + const value = await instance.getValue('CONFIG_VAR') + + expect(value).to.equal('config-value') + }) + + it('should fallback to environment variables', async () => { + process.env['ENV_ONLY_VAR'] = 'env-only-value' + + const instance = ConfigurationService.getInstance(mockConfig) + const value = await instance.getValue('ENV_ONLY_VAR') + + expect(value).to.equal('env-only-value') + }) + + it('should return undefined for nonexistent keys', async () => { + const instance = ConfigurationService.getInstance(mockConfig) + const value = await instance.getValue('NONEXISTENT_KEY') + + expect(value).to.be.undefined + }) + }) + + describe('resolve', () => { + it('should return resolution with source tracking', async () => { + const customProvider = new MockConfigurationProvider('custom', 20, { + TEST_KEY: 'custom-value', + }) + mockConfig.addConfigurationProvider(customProvider) + + const instance = ConfigurationService.getInstance(mockConfig) + const resolution = await instance.resolve('TEST_KEY') + + expect(resolution.value).to.equal('custom-value') + expect(resolution.source).to.equal('custom') + expect(resolution.key).to.equal('TEST_KEY') + }) + + it('should track source as none when no provider resolves', async () => { + const instance = ConfigurationService.getInstance(mockConfig) + const resolution = await instance.resolve('NONEXISTENT_KEY') + + expect(resolution.value).to.be.undefined + expect(resolution.source).to.equal('none') + expect(resolution.key).to.equal('NONEXISTENT_KEY') + }) + }) +}) + +describe('utility functions', () => { + let mockConfig: BoosterConfig + + beforeEach(() => { + ConfigurationService.reset() + mockConfig = new BoosterConfig('test') + // Override the readonly env property for testing + Object.assign(mockConfig, { + env: { + UTIL_TEST_VAR: 'util-config-value', + }, + }) + }) + + afterEach(() => { + ConfigurationService.reset() + }) + + describe('resolveConfigurationValue', () => { + it('should resolve configuration value', async () => { + const value = await resolveConfigurationValue(mockConfig, 'UTIL_TEST_VAR') + expect(value).to.equal('util-config-value') + }) + + it('should return undefined for missing values', async () => { + const value = await resolveConfigurationValue(mockConfig, 'MISSING_VAR') + expect(value).to.be.undefined + }) + }) + + describe('resolveConfigurationWithSource', () => { + it('should resolve with source information', async () => { + const resolution = await resolveConfigurationWithSource(mockConfig, 'UTIL_TEST_VAR') + + expect(resolution.value).to.equal('util-config-value') + expect(resolution.source).to.equal('booster-config-env') + expect(resolution.key).to.equal('UTIL_TEST_VAR') + }) + }) +}) + +describe('integration scenarios', () => { + let mockConfig: BoosterConfig + let originalEnv: typeof process.env + + beforeEach(() => { + ConfigurationService.reset() + mockConfig = new BoosterConfig('test') + originalEnv = { ...process.env } + }) + + afterEach(() => { + process.env = originalEnv + ConfigurationService.reset() + }) + + it('should implement correct precedence order', async () => { + // Set up all configuration sources + process.env['PRECEDENCE_TEST'] = 'env-value' + mockConfig.env['PRECEDENCE_TEST'] = 'config-value' + + const customProvider = new MockConfigurationProvider('custom', 25, { + PRECEDENCE_TEST: 'custom-value', + }) + mockConfig.addConfigurationProvider(customProvider) + + const instance = ConfigurationService.getInstance(mockConfig) + + // should resolve from highest priority (custom provider) + const value = await instance.getValue('PRECEDENCE_TEST') + expect(value).to.equal('custom-value') + + const resolution = await instance.resolve('PRECEDENCE_TEST') + expect(resolution.source).to.equal('custom') + }) + + it('should handle provider unavailability', async () => { + process.env['FALLBACK_TEST'] = 'env-fallback' + + const unavailableProvider = new MockConfigurationProvider( + 'unavailable', + 25, + { FALLBACK_TEST: 'unavailable-value' }, + false // Not available + ) + mockConfig.addConfigurationProvider(unavailableProvider) + + const instance = ConfigurationService.getInstance(mockConfig) + const value = await instance.getValue('FALLBACK_TEST') + + // Should fallback to environment variables + expect(value).to.equal('env-fallback') + }) + + it('should handle multiple custom providers with correct priority', async () => { + const lowProvider = new MockConfigurationProvider('low', 15, { MULTI_TEST: 'low-value' }) + const highProvider = new MockConfigurationProvider('high', 25, { MULTI_TEST: 'high-value' }) + const mediumProvider = new MockConfigurationProvider('mid', 20, { MULTI_TEST: 'medium-value' }) + + mockConfig.addConfigurationProvider(lowProvider) + mockConfig.addConfigurationProvider(highProvider) + mockConfig.addConfigurationProvider(mediumProvider) + + const instance = ConfigurationService.getInstance(mockConfig) + const value = await instance.getValue('MULTI_TEST') + + expect(value).to.equal('high-value') + }) +}) diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/application-builder.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/application-builder.ts index d17f4e7744..b2b1add4fa 100644 --- a/packages/framework-provider-azure-infrastructure/src/infrastructure/application-builder.ts +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/application-builder.ts @@ -2,7 +2,7 @@ import { BoosterConfig } from '@boostercloud/framework-types' import { InfrastructureRocket } from './rockets/infrastructure-rocket' import { AzureStack } from './azure-stack' import * as ckdtfTemplate from './templates/cdktf' -import { renderToFile } from './helper/utils' +import { buildAzureAppConfigConnectionString, renderToFile } from './helper/utils' import { getLogger, Promises } from '@boostercloud/framework-common-helpers' import { App } from 'cdktf' import { ZipResource } from './types/zip-resource' @@ -28,6 +28,7 @@ export class ApplicationBuilder { webPubSubBaseFile = await FunctionZip.copyBaseZip(this.config) } const azureStack = await this.synthApplication(app, webPubSubBaseFile) + this.populateInfrastructureEnvironmentVariables(azureStack) const rocketBuilder = new RocketBuilder(this.config, azureStack.applicationStack, this.rockets) await rocketBuilder.synthRocket() // add rocket-related env vars to main function app settings @@ -54,6 +55,22 @@ export class ApplicationBuilder { } } + private populateInfrastructureEnvironmentVariables(azureStack: AzureStack): void { + const appConfiguration = azureStack.applicationStack.appConfiguration + if (appConfiguration?.primaryWriteKey && appConfiguration?.name) { + this.config.env['AZURE_APP_CONFIG_CONNECTION_STRING'] = buildAzureAppConfigConnectionString( + appConfiguration.name, + { + id: appConfiguration.primaryWriteKey.get(0).id, + secret: appConfiguration.primaryWriteKey.get(0).secret, + } + ) + } + if (appConfiguration?.endpoint) { + this.config.env['AZURE_APP_CONFIG_ENDPOINT'] = appConfiguration.endpoint + } + } + private async synthApplication(app: App, destinationFile?: string): Promise { const logger = getLogger(this.config, 'ApplicationBuilder#synthApplication') logger.info('Synth...') diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/helper/utils.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/helper/utils.ts index 07edd6f88a..197767f028 100644 --- a/packages/framework-provider-azure-infrastructure/src/infrastructure/helper/utils.ts +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/helper/utils.ts @@ -5,7 +5,7 @@ import * as Mustache from 'mustache' import { configuration } from './params' import { WebSiteManagementClient as WebSiteManagement } from '@azure/arm-appservice' import { ResourceManagementClient } from '@azure/arm-resources' -import { TokenCredential, ClientSecretCredential } from '@azure/identity' +import { ClientSecretCredential, TokenCredential } from '@azure/identity' const MAX_TERRAFORM_SIZE_NAME = 24 const MAX_RESOURCE_GROUP_NAME_SIZE = 20 @@ -113,6 +113,19 @@ export function createDomainNameLabel(resourceGroupName: string): string { return `${resourceGroupName}apis` } +/** + * Builds Azure App Configuration connection string from primary write key details + * @param appConfigName The name of the Azure App Configuration resource + * @param primaryWriteKey The primary write key object containing id and secret + * @returns Formatted connection string for Azure App Configuration + */ +export function buildAzureAppConfigConnectionString( + appConfigName: string, + primaryWriteKey: { id: string; secret: string } +): string { + return `Endpoint=https://${appConfigName}.azconfig.io;Id=${primaryWriteKey.id};Secret=${primaryWriteKey.secret}` +} + function loadUserProject(userProjectPath: string): UserApp { const projectIndexJSPath = path.resolve(path.join(userProjectPath, 'dist', 'index.js')) return require(projectIndexJSPath) diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/application-synth.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/application-synth.ts index 3e53a17550..ded5e63194 100644 --- a/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/application-synth.ts +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/application-synth.ts @@ -35,6 +35,7 @@ import { TerraformSubnetSecurity } from './gateway/terraform-subnet-security' import { BASIC_SERVICE_PLAN } from '../constants' import { TerraformFunctionAppSettings } from './terraform-function-app-settings' import { configuration } from '../helper/params' +import { TerraformAppConfiguration } from './terraform-app-configuration' export class ApplicationSynth { readonly config: BoosterConfig @@ -93,6 +94,7 @@ export class ApplicationSynth { stack.cosmosdbDatabase = TerraformCosmosdbDatabase.build(stack) stack.cosmosdbSqlDatabase = TerraformCosmosdbSqlDatabase.build(stack, this.config) stack.containers = TerraformContainers.build(stack, this.config) + this.buildAppConfiguration(stack) this.buildEventHub(stack) this.buildWebPubSub(stack) if (BASIC_SERVICE_PLAN === 'true') { @@ -132,6 +134,13 @@ export class ApplicationSynth { ) } + private buildAppConfiguration(stack: ApplicationSynthStack): void { + if (TerraformAppConfiguration.isEnabled(this.config)) { + const appConfigResource = new TerraformAppConfiguration(stack.terraformStack, stack, this.config) + stack.appConfiguration = appConfigResource.appConfiguration + } + } + private buildEventHub(stack: ApplicationSynthStack): void { if (this.config.eventStreamConfiguration.enabled) { stack.eventHubNamespace = TerraformEventHubNamespace.build(stack) diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-app-configuration.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-app-configuration.ts new file mode 100644 index 0000000000..26df798a96 --- /dev/null +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-app-configuration.ts @@ -0,0 +1,76 @@ +import { Construct } from 'constructs' +import { appConfiguration } from '@cdktf/provider-azurerm' +import { BoosterConfig } from '@boostercloud/framework-types' +import { ApplicationSynthStack } from '../types/application-synth-stack' +import { buildAzureAppConfigConnectionString, toTerraformName } from '../helper/utils' + +export class TerraformAppConfiguration extends Construct { + public readonly appConfiguration: appConfiguration.AppConfiguration + + constructor(scope: Construct, applicationStack: ApplicationSynthStack, config: BoosterConfig) { + super(scope, 'AppConfiguration') + + const { appPrefix, resourceGroup } = applicationStack + + // Check if Azure App Configuration is enabled in the config + const azureAppConfigOptions = config.getAzureAppConfigOptions() + if (!azureAppConfigOptions?.enabled) { + // If not enabled, create a placeholder without actual resources + this.appConfiguration = {} as appConfiguration.AppConfiguration + return + } + + const name = toTerraformName(appPrefix, 'appconfig') + + this.appConfiguration = new appConfiguration.AppConfiguration(this, 'AppConfiguration', { + name, + resourceGroupName: resourceGroup.name, + location: resourceGroup.location, + sku: 'free', // Use free tier by default. For more information, see https://azure.microsoft.com/en-us/pricing/details/app-configuration/ + tags: { + Application: config.appName, + Environment: config.environmentName, + BoosterManaged: 'true', + }, + // Enable managed identity for secure access + identity: { + type: 'SystemAssigned', + }, + // Configure public network access + publicNetworkAccess: 'Enabled', + // Configure local authentication + localAuthEnabled: true, + }) + } + + /** + * Get the connection string for the App Configuration resource + */ + public getConnectionString(): string { + if (!this.appConfiguration || !this.appConfiguration.primaryWriteKey) { + return '' + } + return buildAzureAppConfigConnectionString(this.appConfiguration.name, { + id: this.appConfiguration.primaryWriteKey.get(0).id, + secret: this.appConfiguration.primaryWriteKey.get(0).secret, + }) + } + + /** + * Get the endpoint URL for the App Configuration resource + */ + public getEndpoint(): string { + if (!this.appConfiguration || !this.appConfiguration.endpoint) { + return '' + } + return this.appConfiguration.endpoint + } + + /** + * Check if App Configuration is enabled for this configuration + */ + public static isEnabled(config: BoosterConfig): boolean { + const azureAppConfigOptions = config.getAzureAppConfigOptions() + return azureAppConfigOptions?.enabled === true + } +} diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-function-app-settings.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-function-app-settings.ts index 54907f4642..d24d6ece84 100644 --- a/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-function-app-settings.ts +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-function-app-settings.ts @@ -1,12 +1,20 @@ import { environmentVarNames } from '@boostercloud/framework-provider-azure' import { ApplicationSynthStack } from '../types/application-synth-stack' -import { toTerraformName } from '../helper/utils' +import { buildAzureAppConfigConnectionString, toTerraformName } from '../helper/utils' import { BoosterConfig } from '@boostercloud/framework-types' import { storageAccount } from '@cdktf/provider-azurerm' export class TerraformFunctionAppSettings { static build( - { appPrefix, cosmosdbDatabase, domainNameLabel, eventHubNamespace, eventHub, webPubSub }: ApplicationSynthStack, + { + appPrefix, + cosmosdbDatabase, + domainNameLabel, + eventHubNamespace, + eventHub, + webPubSub, + appConfiguration, + }: ApplicationSynthStack, config: BoosterConfig, storageAccount: storageAccount.StorageAccount, suffixName: string @@ -20,6 +28,17 @@ export class TerraformFunctionAppSettings { ? `${eventHubNamespace.defaultPrimaryConnectionString};EntityPath=${eventHub.name}` : '' const region = (process.env['REGION'] ?? '').toLowerCase().replace(/ /g, '') + + // Azure App Configuration settings + const appConfigConnectionString = + appConfiguration?.primaryWriteKey && appConfiguration?.name + ? buildAzureAppConfigConnectionString(appConfiguration.name, { + id: appConfiguration.primaryWriteKey.get(0).id, + secret: appConfiguration.primaryWriteKey.get(0).secret, + }) + : '' + const appConfigEndpoint = appConfiguration?.endpoint || '' + return { WEBSITE_RUN_FROM_PACKAGE: '1', WEBSITE_CONTENTSHARE: id, @@ -35,6 +54,9 @@ export class TerraformFunctionAppSettings { COSMOSDB_CONNECTION_STRING: `AccountEndpoint=https://${cosmosdbDatabase.name}.documents.azure.com:443/;AccountKey=${cosmosdbDatabase.primaryKey};`, WEBSITE_CONTENTAZUREFILECONNECTIONSTRING: storageAccount.primaryConnectionString, // Terraform bug: https://github.com/hashicorp/terraform-provider-azurerm/issues/16650 BOOSTER_APP_NAME: process.env['BOOSTER_APP_NAME'] ?? '', + // Azure App Configuration settings + AZURE_APP_CONFIG_CONNECTION_STRING: appConfigConnectionString, + AZURE_APP_CONFIG_ENDPOINT: appConfigEndpoint, } } } diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/types/application-synth-stack.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/types/application-synth-stack.ts index 8da66e7fff..de1cca670a 100644 --- a/packages/framework-provider-azure-infrastructure/src/infrastructure/types/application-synth-stack.ts +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/types/application-synth-stack.ts @@ -1,5 +1,6 @@ import { apiManagementApi, + appConfiguration, applicationGateway, cosmosdbAccount, cosmosdbSqlContainer, @@ -13,10 +14,10 @@ import { resourceGroup, servicePlan, storageAccount, - virtualNetwork, - webPubsub, subnet, subnetNetworkSecurityGroupAssociation, + virtualNetwork, + webPubsub, webPubsubHub, windowsFunctionApp, } from '@cdktf/provider-azurerm' @@ -62,5 +63,6 @@ export interface ApplicationSynthStack extends StackNames { consumerFunctionDefinitions?: Array eventHubNamespace?: eventhubNamespace.EventhubNamespace eventHub?: eventhub.Eventhub + appConfiguration?: appConfiguration.AppConfiguration rocketStack?: Array } diff --git a/packages/framework-provider-azure/package.json b/packages/framework-provider-azure/package.json index 32842c7048..e3762ccbf6 100644 --- a/packages/framework-provider-azure/package.json +++ b/packages/framework-provider-azure/package.json @@ -23,6 +23,7 @@ "node": ">=20.0.0 <21.0.0" }, "dependencies": { + "@azure/app-configuration": "^1.7.0", "@azure/cosmos": "^4.3.0", "@azure/functions": "^1.2.2", "@azure/identity": "~4.7.0", diff --git a/packages/framework-provider-azure/src/constants.ts b/packages/framework-provider-azure/src/constants.ts index 428c72d746..8f5f3c951d 100644 --- a/packages/framework-provider-azure/src/constants.ts +++ b/packages/framework-provider-azure/src/constants.ts @@ -37,6 +37,8 @@ export const environmentVarNames = { eventHubMode: 'EVENTHUB_MODE', rocketFunctionAppNames: 'ROCKET_FUNCTION_APP_NAMES', rocketPackageMapping: 'ROCKET_PACKAGE_MAPPING', + appConfigurationConnectionString: 'AZURE_APP_CONFIG_CONNECTION_STRING', + appConfigurationEndpoint: 'AZURE_APP_CONFIG_ENDPOINT', } as const // Azure special error codes diff --git a/packages/framework-provider-azure/src/index.ts b/packages/framework-provider-azure/src/index.ts index bcfc724026..2999c8278a 100644 --- a/packages/framework-provider-azure/src/index.ts +++ b/packages/framework-provider-azure/src/index.ts @@ -49,6 +49,7 @@ import { } from './library/health-adapter' import { deleteEvent, deleteSnapshot, findDeletableEvent, findDeletableSnapshot } from './library/event-delete-adapter' import { storeEvents } from './library/events-store-adapter' +import { ConfigurationAdapter } from './library/configuration-adapter' let cosmosClient: CosmosClient if (typeof process.env[environmentVarNames.cosmosDbConnectionString] === 'undefined') { @@ -107,6 +108,32 @@ if ( }) } +const azureAppConfigConnectionString = process.env[environmentVarNames.appConfigurationConnectionString] +const azureAppConfigEndpoint = process.env[environmentVarNames.appConfigurationEndpoint] + +if (azureAppConfigConnectionString || azureAppConfigEndpoint) { + try { + const config = require('@boostercloud/framework-core').Booster.config + + const azureAppConfigOptions = config.getAzureAppConfigOptions() + + // Use user overrides if provided, otherwise fall back to environment variables + const connectionString = azureAppConfigOptions?.connectionString || azureAppConfigConnectionString + const endpoint = azureAppConfigOptions?.endpoint || azureAppConfigEndpoint + const labelFilter = azureAppConfigOptions?.labelFilter + + // Initialize if we have either environment variables or user config with enabled=true + if (connectionString || endpoint || azureAppConfigOptions?.enabled) { + const provider = connectionString + ? ConfigurationAdapter.withConnectionString(connectionString, labelFilter) + : ConfigurationAdapter.withEndpoint(endpoint, labelFilter) + config.addConfigurationProvider(provider) + } + } catch (error) { + console.warn('[Azure Provider] Failed to initialize Azure App Configuration adapter:', error) + } +} + /* We load the infrastructure package dynamically here to avoid including it in the * dependencies that are deployed in the lambda functions. The infrastructure * package is only used during the deploy. @@ -201,3 +228,4 @@ export const Provider = (rockets?: RocketDescriptor[]): ProviderLibrary => ({ }) export * from './constants' +export * from './library/configuration-adapter' diff --git a/packages/framework-provider-azure/src/library/configuration-adapter.ts b/packages/framework-provider-azure/src/library/configuration-adapter.ts new file mode 100644 index 0000000000..fe2dba36ea --- /dev/null +++ b/packages/framework-provider-azure/src/library/configuration-adapter.ts @@ -0,0 +1,112 @@ +import { ConfigurationProvider } from '@boostercloud/framework-types' +import { AppConfigurationClient } from '@azure/app-configuration' +import { DefaultAzureCredential } from '@azure/identity' + +export class ConfigurationAdapter implements ConfigurationProvider { + readonly name = 'azure-app-configuration' + readonly priority = 20 // High priority - external configuration source + + private client: AppConfigurationClient | undefined + private isInitialized = false + private initializationError: Error | undefined + + constructor( + private readonly connectionString?: string, + private readonly endpoint?: string, + private readonly labelFilter?: string + ) {} + + /** + * Initialize the Azure App Configuration client + * @returns true if initialization was successful, false otherwise + */ + private async initialize(): Promise { + if (this.isInitialized) { + return !this.initializationError + } + + try { + if (this.connectionString) { + // Use connection string if provided + this.client = new AppConfigurationClient(this.connectionString) + } else if (this.endpoint) { + // Use managed identity or default Azure credential with endpoint + const credential = new DefaultAzureCredential() + this.client = new AppConfigurationClient(this.endpoint, credential) + } else { + this.initializationError = new Error( + 'Azure App Configuration requires either a connection string or endpoint URL' + ) + this.isInitialized = true + return false + } + + this.isInitialized = true + return true + } catch (error) { + // Preserve original error information by wrapping it + const originalError = error instanceof Error ? error : new Error(String(error)) + this.initializationError = new Error( + `Failed to initialize Azure App Configuration client: ${originalError.message}` + ) + // Preserve the original error as a property for debugging + ;(this.initializationError as any).originalError = originalError + this.isInitialized = true // Mark as initialized to avoid retrying + return false + } + } + + async getValue(key: string): Promise { + const initialized = await this.initialize() + + if (!initialized || !this.client) { + return undefined + } + + try { + // Get the configuration setting with optional label filter + const configurationSetting = await this.client.getConfigurationSetting({ + key, + label: this.labelFilter, + }) + + return configurationSetting.value + } catch (error) { + // Log the error but don't throw - this allows fallback to other providers + console.warn(`Azure App Configuration failed to get the value for key '${key}':`, error) + return undefined + } + } + + async isAvailable(): Promise { + const initialized = await this.initialize() + return initialized && !!this.client && !this.initializationError + } + + /** + * Create a ConfigurationAdapter instance from environment variables + * This is the standard way to initialize the provider in Azure Function App environments, + * where these environment variables are automatically injected. In other environments, + * you may need to set these variables manually. + */ + static fromEnvironment(labelFilter?: string): ConfigurationAdapter { + const connectionString = process.env['AZURE_APP_CONFIG_CONNECTION_STRING'] + const endpoint = process.env['AZURE_APP_CONFIG_ENDPOINT'] + + return new ConfigurationAdapter(connectionString, endpoint, labelFilter) + } + + /** + * Create a ConfigurationAdapter instance with connection string + */ + static withConnectionString(connectionString: string, labelFilter?: string): ConfigurationAdapter { + return new ConfigurationAdapter(connectionString, undefined, labelFilter) + } + + /** + * Create a ConfigurationAdapter instance with endpoint and managed identity + */ + static withEndpoint(endpoint: string, labelFilter?: string): ConfigurationAdapter { + return new ConfigurationAdapter(undefined, endpoint, labelFilter) + } +} diff --git a/packages/framework-provider-azure/test/library/configuration-adapter.test.ts b/packages/framework-provider-azure/test/library/configuration-adapter.test.ts new file mode 100644 index 0000000000..62be5a691a --- /dev/null +++ b/packages/framework-provider-azure/test/library/configuration-adapter.test.ts @@ -0,0 +1,141 @@ +import { restore, stub } from 'sinon' +import { ConfigurationAdapter } from '../../src' +import { expect } from '../expect' + +describe('ConfigurationAdapter', () => { + beforeEach(() => { + // Silence console warning during tests to avoid clutter + stub(console, 'warn') + }) + + afterEach(() => { + restore() + }) + + describe('constructor', () => { + it('should create an instance with connection string', () => { + const adapter = new ConfigurationAdapter('mock-connection-string') + expect(adapter.name).to.equal('azure-app-configuration') + expect(adapter.priority).to.equal(20) + }) + + it('should create an instance with endpoint', () => { + const adapter = new ConfigurationAdapter(undefined, 'https://mock-endpoint.azconfig.io') + expect(adapter.name).to.equal('azure-app-configuration') + expect(adapter.priority).to.equal(20) + }) + + it('should create an instance with label filter', () => { + const adapter = new ConfigurationAdapter('mock-connection-string', undefined, 'test-label') + expect(adapter.name).to.equal('azure-app-configuration') + expect(adapter.priority).to.equal(20) + }) + }) + + describe('static factory methods', () => { + beforeEach(() => { + // Clear environment variables + delete process.env['AZURE_APP_CONFIG_CONNECTION_STRING'] + delete process.env['AZURE_APP_CONFIG_ENDPOINT'] + }) + + afterEach(() => { + // Restore environment variables + delete process.env['AZURE_APP_CONFIG_CONNECTION_STRING'] + delete process.env['AZURE_APP_CONFIG_ENDPOINT'] + }) + + it('should create adapter from environment with connection string', () => { + process.env['AZURE_APP_CONFIG_CONNECTION_STRING'] = 'mock-connection-string' + + const adapter = ConfigurationAdapter.fromEnvironment() + expect(adapter.name).to.equal('azure-app-configuration') + }) + + it('should create adapter from environment with endpoint', () => { + process.env['AZURE_APP_CONFIG_ENDPOINT'] = 'https://mock-endpoint.azconfig.io' + + const adapter = ConfigurationAdapter.fromEnvironment() + expect(adapter.name).to.equal('azure-app-configuration') + }) + + it('should create adapter with connection string', () => { + const adapter = ConfigurationAdapter.withConnectionString('mock-connection-string') + expect(adapter.name).to.equal('azure-app-configuration') + }) + + it('should create adapter with endpoint', () => { + const adapter = ConfigurationAdapter.withEndpoint('https://mock-endpoint.azconfig.io') + expect(adapter.name).to.equal('azure-app-configuration') + }) + }) + + describe('isAvailable', () => { + it('should return false when no connection string or endpoint is provided', async () => { + const adapter = new ConfigurationAdapter() + const available = await adapter.isAvailable() + expect(available).to.be.false + }) + + it('should return false when initialization fails', async () => { + // Create provider with invalid connection string to force initialization error + const adapter = new ConfigurationAdapter('invalid-connection-string') + const available = await adapter.isAvailable() + expect(available).to.be.false + }) + }) + + describe('getValue', () => { + it('should return undefined when not available', async () => { + const adapter = new ConfigurationAdapter() + const value = await adapter.getValue('test-key') + expect(value).to.be.undefined + }) + + it('should return undefined when client fails', async () => { + const adapter = new ConfigurationAdapter('invalid-connection-string') + const value = await adapter.getValue('test-key') + expect(value).to.be.undefined + }) + }) + + describe('error handling', () => { + it('should handle initialization errors gracefully', async () => { + const adapter = new ConfigurationAdapter('invalid-connection-string') + + // Should not throw, even with invalid connection string + const available = await adapter.isAvailable() + expect(available).to.be.false + + const value = await adapter.getValue('test-key') + expect(value).to.be.undefined + }) + + it('should handle missing configuration gracefully', async () => { + const adapter = new ConfigurationAdapter() + + const available = await adapter.isAvailable() + expect(available).to.be.false + + const value = await adapter.getValue('nonexistent-key') + expect(value).to.be.undefined + }) + }) + + describe('integration scenarios', () => { + it('should work with label filters', async () => { + const adapter = new ConfigurationAdapter('mock-connection-string', undefined, 'production') + + // Should create without throwing + expect(adapter.name).to.equal('azure-app-configuration') + expect(adapter.priority).to.equal(20) + }) + + it('should prefer connection string over endpoint', async () => { + const adapter = new ConfigurationAdapter('mock-connection-string', 'https://mock-endpoint.azconfig.io') + + // Should create without throwing + expect(adapter.name).to.equal('azure-app-configuration') + }) + }) +}) diff --git a/packages/framework-types/src/config.ts b/packages/framework-types/src/config.ts index 70e555acc5..09754665e9 100644 --- a/packages/framework-types/src/config.ts +++ b/packages/framework-types/src/config.ts @@ -117,6 +117,17 @@ export class BoosterConfig { // TTL for events stored in dispatched events table. Default to 5 minutes (i.e., 300 seconds). public dispatchedEventsTtl = 300 + /** Azure App Configuration options stored for provider access */ + private _azureAppConfigOptions?: AzureAppConfigurationOptions + + /** + * Get Azure App Configuration options (used by Azure provider package) + * @returns Azure App Configuration options if enabled, undefined otherwise + */ + public getAzureAppConfigOptions(): AzureAppConfigurationOptions | undefined { + return this._azureAppConfigOptions + } + public registerRocketFunction(id: string, func: RocketFunction): void { const currentFunction = this.rocketFunctionMap[id] if (currentFunction) { @@ -143,6 +154,9 @@ export class BoosterConfig { /** Environment variables set at deployment time on the target lambda functions */ public readonly env: Record = {} + /** Configuration providers for external configuration sources (Azure App Configuration, etc.) */ + public readonly configurationProviders: ConfigurationProvider[] = [] + /** * Add `TokenVerifier` implementations to this array to enable token verification. * When a bearer token arrives in a request 'Authorization' header, it will be checked @@ -201,6 +215,43 @@ export class BoosterConfig { this.validateAllMigrations() } + /** + * Register a configuration provider for external configuration sources + * @param provider The configuration provider to register + */ + public addConfigurationProvider(provider: ConfigurationProvider): void { + // Remove any existing provider with the same name + const existingIndex = this.configurationProviders.findIndex((p) => p.name === provider.name) + if (existingIndex >= 0) { + this.configurationProviders.splice(existingIndex, 1) + } + + // Add the new provider and sort by priority (highest first) + this.configurationProviders.push(provider) + this.configurationProviders.sort((a, b) => b.priority - a.priority) + } + + /** + * Enable Azure App Configuration for this environment + * This is a convenience method that automatically configures the Azure App Configuration provider + * @param options Configuration options for Azure App Configuration + */ + public enableAzureAppConfiguration(options?: { + connectionString?: string + endpoint?: string + labelFilter?: string + }): void { + // This method signature needs to remain in framework-types, but the actual implementation + // will be provided by the Azure provider package to avoid circular dependencies + // Store the options in a special property that the Azure provider can read + this._azureAppConfigOptions = { + connectionString: options?.connectionString, + endpoint: options?.endpoint, + labelFilter: options?.labelFilter, + enabled: true, + } + } + public get provider(): ProviderLibrary { if (!this._provider && this.providerPackage) { const rockets = this.rockets ?? [] @@ -313,6 +364,84 @@ export interface RetryConfig { retryableErrors?: Array } +/** + * Configuration options for Azure App Configuration integration + * Used to store configuration without circular dependencies + */ +export interface AzureAppConfigurationOptions { + /** Connection string for Azure App Configuration (alternative to endpoint + managed identity) */ + connectionString?: string + + /** Endpoint URL for Azure App Configuration (used with managed identity) */ + endpoint?: string + + /** Optional label filter to target specific configuration values */ + labelFilter?: string + + /** Whether to enable Azure App Configuration (default: false) */ + enabled?: boolean +} + +/** + * Configuration provider interface for external configuration sources + */ +export interface ConfigurationProvider { + /** + * Retrieve a configuration value by key + * @param key The configuration key to retrieve + * @returns Promise resolving to the configuration value or undefined if not found + */ + getValue(key: string): Promise + + /** + * Check if the configuration provider is available and properly initialized + * @returns Promise resolving to a true if available, false otherwise + */ + isAvailable(): Promise + + /** + * Priority of this configuration provider (higher number = higher priority) + */ + readonly priority: number + + /** + * Name identifier for this configuration provider + */ + readonly name: string +} + +/** + * Configuration resolution result with source tracking + */ +export interface ConfigurationResolution { + value: string | undefined + source: string + key: string +} + +/** + * Configuration resolver that manages multiple providers with fallback + */ +export interface ConfigurationResolver { + /** + * Resolve a configuration value from all available providers + * @param key The configuration key to resolve + * @returns Promise resolving to the configuration resolution result + */ + resolve(key: string): Promise + + /** + * Add a configuration provider + * @param provider The configuration provider to add + */ + addProvider(provider: ConfigurationProvider): void + + /** + * Get all registered providers sorted by priority + */ + getProviders(): ConfigurationProvider[] +} + type EntityName = string type EventName = string type CommandName = string diff --git a/packages/framework-types/src/configuration-resolver.ts b/packages/framework-types/src/configuration-resolver.ts new file mode 100644 index 0000000000..a2448240ca --- /dev/null +++ b/packages/framework-types/src/configuration-resolver.ts @@ -0,0 +1,82 @@ +import { ConfigurationProvider, ConfigurationResolver, ConfigurationResolution } from './config' + +export class DefaultConfigurationResolver implements ConfigurationResolver { + private providers: ConfigurationProvider[] = [] + + constructor(providers: ConfigurationProvider[] = []) { + this.providers = [...providers].sort((a, b) => b.priority - a.priority) + } + + addProvider(provider: ConfigurationProvider): void { + // Remove any existing provider with the same name + const existingIndex = this.providers.findIndex((p) => p.name === provider.name) + if (existingIndex >= 0) { + this.providers.splice(existingIndex, 1) + } + + // Add the new provider and sort by priority (highest first) + this.providers.push(provider) + this.providers.sort((a, b) => b.priority - a.priority) + } + + getProviders(): ConfigurationProvider[] { + return [...this.providers] + } + + async resolve(key: string): Promise { + // Try each provider in priority order + for (const provider of this.providers) { + try { + // Check if provider is available before attempting to get value + if (await provider.isAvailable()) { + const value = await provider.getValue(key) + if (value !== undefined) { + return { value, source: provider.name, key } + } + } + } catch (error) { + // Log error but continue to next provider + console.warn(`Configuration provider '${provider.name}' failed to resolve key '${key}':`, error) + } + } + + // No provider could resolve the value + return { value: undefined, source: 'none', key } + } +} + +/** + * Environment variables configuration provider + * This is the fallback provider that reads from process.env + */ +export class EnvironmentVariablesProvider implements ConfigurationProvider { + readonly name = 'environment-variables' + readonly priority = 0 // Lowest priority - fallback provider + + async getValue(key: string): Promise { + return process.env[key] + } + + async isAvailable(): Promise { + return true // Environment variables are always available + } +} + +/** + * Booster config.env provider + * Reads from the Booster configuration env object + */ +export class BoosterConfigEnvProvider implements ConfigurationProvider { + readonly name = 'booster-config-env' + readonly priority = 10 // Medium priority + + constructor(private readonly envConfig: Record) {} + + async getValue(key: string): Promise { + return this.envConfig[key] + } + + async isAvailable(): Promise { + return true // Booster config env is always available + } +} diff --git a/packages/framework-types/src/index.ts b/packages/framework-types/src/index.ts index 18948278b5..5a90030691 100644 --- a/packages/framework-types/src/index.ts +++ b/packages/framework-types/src/index.ts @@ -1,6 +1,7 @@ export * from './provider' export * from './envelope' export * from './config' +export * from './configuration-resolver' export * from './concepts' export * from './typelevel' export * from './logger' diff --git a/packages/framework-types/test/configuration-resolver.test.ts b/packages/framework-types/test/configuration-resolver.test.ts new file mode 100644 index 0000000000..c83fb4f9c7 --- /dev/null +++ b/packages/framework-types/test/configuration-resolver.test.ts @@ -0,0 +1,218 @@ +import { + BoosterConfigEnvProvider, + ConfigurationProvider, + DefaultConfigurationResolver, + EnvironmentVariablesProvider, +} from '../src' +import { expect } from './expect' + +// Mock configuration provider for testing +class MockConfigurationProvider implements ConfigurationProvider { + constructor( + public readonly name: string, + public readonly priority: number, + private readonly values: Record = {}, + private readonly available: boolean = true + ) {} + + async getValue(key: string): Promise { + return this.values[key] + } + + async isAvailable(): Promise { + return this.available + } +} + +describe('DefaultConfigurationResolver', () => { + let resolver: DefaultConfigurationResolver + + beforeEach(() => { + resolver = new DefaultConfigurationResolver() + }) + + describe('constructor', () => { + it('should create an empty resolver', () => { + expect(resolver.getProviders()).to.have.length(0) + }) + + it('should create resolver with initial providers', () => { + const provider1 = new MockConfigurationProvider('test1', 10) + const provider2 = new MockConfigurationProvider('test2', 20) + + const resolverWithProviders = new DefaultConfigurationResolver([provider1, provider2]) + + const providers = resolverWithProviders.getProviders() + expect(providers).to.have.length(2) + expect(providers[0].name).to.equal('test2') // Higher priority first + expect(providers[1].name).to.equal('test1') + }) + }) + + describe('addProvider', () => { + it('should add providers and sort by priority', () => { + const provider1 = new MockConfigurationProvider('test1', 10) + const provider2 = new MockConfigurationProvider('test2', 20) + const provider3 = new MockConfigurationProvider('test3', 15) + + resolver.addProvider(provider1) + resolver.addProvider(provider2) + resolver.addProvider(provider3) + + const providers = resolver.getProviders() + expect(providers).to.have.length(3) + expect(providers[0].name).to.equal('test2') // Priority 20 + expect(providers[1].name).to.equal('test3') // Priority 15 + expect(providers[2].name).to.equal('test1') // Priority 10 + }) + + it('should replace provider with same name', () => { + const provider1 = new MockConfigurationProvider('test', 10, { key: 'low-priority' }) + const provider2 = new MockConfigurationProvider('test', 20, { key: 'high-priority' }) + + resolver.addProvider(provider1) + resolver.addProvider(provider2) + + const providers = resolver.getProviders() + expect(providers).to.have.length(1) + expect(providers[0].priority).to.equal(20) + }) + }) + + describe('resolve', () => { + it('should resolve from highest priority provider', async () => { + const provider1 = new MockConfigurationProvider('test1', 10, { key1: 'low-priority' }) + const provider2 = new MockConfigurationProvider('test2', 20, { key1: 'high-priority' }) + + resolver.addProvider(provider1) + resolver.addProvider(provider2) + + const result = await resolver.resolve('key1') + expect(result.value).to.equal('high-priority') + expect(result.source).to.equal('test2') + expect(result.key).to.equal('key1') + }) + + it('should fallback to lower priority providers', async () => { + const provider1 = new MockConfigurationProvider('test1', 10, { key1: 'fallback-value' }) + const provider2 = new MockConfigurationProvider('test2', 20, {}) // No key1 + + resolver.addProvider(provider1) + resolver.addProvider(provider2) + + const result = await resolver.resolve('key1') + expect(result.value).to.equal('fallback-value') + expect(result.source).to.equal('test1') + }) + + it('should return undefined when no provider has the value', async () => { + const provider1 = new MockConfigurationProvider('test1', 10, {}) + const provider2 = new MockConfigurationProvider('test2', 20, {}) + + resolver.addProvider(provider1) + resolver.addProvider(provider2) + + const result = await resolver.resolve('nonexistent-key') + expect(result.value).to.be.undefined + expect(result.source).to.equal('none') + expect(result.key).to.equal('nonexistent-key') + }) + + it('should skip unavailable providers', async () => { + const provider1 = new MockConfigurationProvider('test1', 10, { key1: 'unavailable-value' }, false) + const provider2 = new MockConfigurationProvider('test2', 20, { key1: 'available-value' }, true) + + resolver.addProvider(provider1) + resolver.addProvider(provider2) + + const result = await resolver.resolve('key1') + expect(result.value).to.equal('available-value') + expect(result.source).to.equal('test2') + }) + + it('should handle provider errors gracefully', async () => { + const errorProvider = new MockConfigurationProvider('error-provider', 20) + // Mock getValue to throw an error + errorProvider.getValue = async () => { + throw new Error('Provider error') + } + + const fallbackProvider = new MockConfigurationProvider('fallback', 10, { key1: 'fallback-value' }) + + resolver.addProvider(errorProvider) + resolver.addProvider(fallbackProvider) + + const result = await resolver.resolve('key1') + expect(result.value).to.equal('fallback-value') + expect(result.source).to.equal('fallback') + }) + }) +}) + +describe('EnvironmentVariablesProvider', () => { + let provider: EnvironmentVariablesProvider + let originalEnv: typeof process.env + + beforeEach(() => { + provider = new EnvironmentVariablesProvider() + originalEnv = { ...process.env } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('should have correct name and priority', () => { + expect(provider.name).to.equal('environment-variables') + expect(provider.priority).to.equal(0) + }) + + it('should always be available', async () => { + const available = await provider.isAvailable() + expect(available).to.be.true + }) + + it('should get values from process.env', async () => { + process.env['TEST_VAR'] = 'test-value' + + const value = await provider.getValue('TEST_VAR') + expect(value).to.equal('test-value') + }) + + it('should return undefined for missing variables', async () => { + const value = await provider.getValue('NONEXISTENT_VAR') + expect(value).to.be.undefined + }) +}) + +describe('BoosterConfigEnvProvider', () => { + let provider: BoosterConfigEnvProvider + + beforeEach(() => { + const envConfig = { + VAR1: 'value1', + VAR2: 'value2', + } + provider = new BoosterConfigEnvProvider(envConfig) + }) + + it('should have correct name and priority', () => { + expect(provider.name).to.equal('booster-config-env') + expect(provider.priority).to.equal(10) + }) + + it('should always be available', async () => { + const available = await provider.isAvailable() + expect(available).to.be.true + }) + + it('should get values from config.env', async () => { + const value = await provider.getValue('VAR1') + expect(value).to.equal('value1') + }) + + it('should return undefined for missing variables', async () => { + const value = await provider.getValue('NONEXISTENT_VAR') + expect(value).to.be.undefined + }) +}) diff --git a/website/docs/03_features/03_configuration.mdx b/website/docs/03_features/03_configuration.mdx new file mode 100644 index 0000000000..ea520be11e --- /dev/null +++ b/website/docs/03_features/03_configuration.mdx @@ -0,0 +1,414 @@ +import TerminalWindow from '@site/src/components/TerminalWindow/TerminalWindow' + +# Configuration Management + +Booster provides a flexible configuration management system that allows you to retrieve configuration values from multiple sources with automatic fallback mechanisms. This enables you to manage application settings dynamically without code changes or redeployments. + +## Configuration Resolution Hierarchy + +Booster resolves configuration values using a 3-tier priority system (highest to lowest priority): + +1. **External Configuration Providers** - Azure App Configuration, custom providers (highest priority) +2. **Booster config.env** - Configuration defined in your Booster config +3. **System Environment Variables** - Standard process.env values (lowest priority) + +When you request a configuration value, Booster checks each source in order and returns the first value found. + +```typescript +import { Booster } from '@boostercloud/framework-core' +import { resolveConfigurationWithSource, resolveConfigurationValue } from '@boostercloud/framework-core' + +// Resolve a configuration value with source tracking +const resolution = await resolveConfigurationWithSource(Booster.config, 'API_TIMEOUT') +console.log(`Value: ${resolution.value}, Source: ${resolution.source}`) + +// Resolve a configuration value without source tracking +const timeout = await resolveConfigurationValue(Booster.config, 'API_TIMEOUT') +console.log(`Timeout: ${timeout}`) +``` + +## Built-in Configuration Providers + +### Environment Variables Provider + +Automatically reads values from `process.env`. This is always available and has the lowest priority. + +```typescript +// Will read from process.env.DATABASE_URL +const dbUrl = await resolveConfigurationValue(Booster.config, 'DATABASE_URL') +``` + +### Booster Config Environment Provider + +Reads values from the `config.env` object defined in your Booster configuration: + +```typescript title="src/config/config.ts" +Booster.configure('production', (config: BoosterConfig): void =>{ + config.appName = 'my-app' + config.providerPackage = '@boostercloud/framework-provider-azure' + + // Define configuration values + config.env = { + 'API_TIMEOUT': '5000', + 'MAX_RETRIES': '3', + 'FEATURE_FLAG_X': 'enabled', + } +}) +``` + +## Azure App Configuration + +Azure App Configuration provides centralized configuration management for cloud applications. It allows you to: + +- **Update configuration without redeployment** - Change values in Azure portal and they're immediately available +- **Feature flags and A/B testing** - Dynamic feature management +- **Environment-specific configuration** - Different values per environment using labels +- **Secure configuration storage** - Integration with Azure Key Vault for secrets + +### Enabling Azure App Configuration + +To enable Azure App Configuration for your Booster application: + +```typescript title="src/config/config.ts" +import { Booster } from '@boostercloud/framework-core' +import { BoosterConfig } from '@boostercloud/framework-types' + +Booster.configure('production', (config: BoosterConfig): void => { + config.appName = 'my-app' + config.providerPackage = '@boostercloud/framework-provider-azure' + + // Enable Azure App Configuration + config.enableAzureAppConfiguration() +}) +``` + +### Advanced Azure App Configuration Options + +You can customize the Azure App Configuration integration: + +```typescript title="src/config/config.ts" +Booster.configure('production', (config: BoosterConfig): void => { + config.appName = 'my-app' + config.providerPackage = '@boostercloud/framework-provider-azure' + + // Advanced configuration + config.enableAzureAppConfiguration({ + // Optional: Override connection string (usually set by infrastructure) + connectionString: 'Endpoint=https://myappconfig.azconfig.io;Id=xxx;Secret=xxx', + + // Optional: Override endpoint (alternative to connection string) + endpoint: 'https://myappconfig.azconfig.io', + + // Optional: Filter by label (for environment-specific values) + labelFilter: 'production', + }) +}) +``` + +### Using Labels for Environment Isolation + +Labels are a powerful feature of Azure App Configuration that allow you to maintain different configuration values for different environments while using the same keys. Here's how to set this up: + +#### 1. Configure Different Environments with Labels + +```typescript title="src/config/config.ts" +// Development environment - uses 'development' label +Booster.configure('development', (config: BoosterConfig): void => { + config.appName = 'my-app-dev' + config.providerPackage = '@boostercloud/framework-provider-azure' + + config.enableAzureAppConfiguration({ + labelFilter: 'development', // Only reads keys with 'development' label + }) +}) + +// Staging environment - uses 'staging' label +Booster.configure('staging', (config: BoosterConfig): void => { + config.appName = 'my-app-staging' + config.providerPackage = '@boostercloud/framework-provider-azure' + + config.enableAzureAppConfiguration({ + labelFilter: 'staging', // Only reads keys with 'staging' label + }) +}) + +// Production environment - uses 'production' label +Booster.configure('production', (config: BoosterConfig): void => { + config.appName = 'my-app-prod' + config.providerPackage = '@boostercloud/framework-provider-azure' + + config.enableAzureAppConfiguration({ + labelFilter: 'production', // Only reads keys with 'production' label + }) +}) +``` + +#### 2. Azure App Configuration Setup with Labels + +In your Azure App Configuration resource, create the same keys with different labels: + +| Key | Label | Value | Use Case | +|-----|-------|-------|----------| +| `API_TIMEOUT` | `development` | `5000` | Fast timeout for dev | +| `API_TIMEOUT` | `staging` | `15000` | Medium timeout for staging | +| `API_TIMEOUT` | `production` | `30000` | Longer timeout for prod | +| `DEBUG_MODE` | `development` | `enabled` | Debug logs in dev | +| `DEBUG_MODE` | `staging` | `enabled` | Debug logs in staging | +| `DEBUG_MODE` | `production` | `disabled` | No debug logs in prod | +| `FEATURE_NEW_CHECKOUT` | `development` | `enabled` | Test new features | +| `FEATURE_NEW_CHECKOUT` | `staging` | `enabled` | QA testing | +| `FEATURE_NEW_CHECKOUT` | `production` | `disabled` | Stable prod version | + +:::important Label Filter Behavior +When you configure a `labelFilter` (e.g., `labelFilter: 'production'`), Azure App Configuration will **only retrieve configuration values that have that specific label**. If you try to access a configuration key that exists in Azure App Configuration but doesn't have the matching label, it will **not be found** and the resolution will fall back to the next provider in the hierarchy (Booster config.env or environment variables). + +For example, if you have: +- A key `API_KEY` with label `development` +- A key `API_KEY` with no label (empty label) +- Your config uses `labelFilter: 'production'` + +Then requesting `API_KEY` will return `undefined` from Azure App Configuration because neither the `development` labeled value nor the unlabeled value matches the `production` filter. The system will then check other configuration sources in the hierarchy. +::: + +#### 3. Using Labeled Configuration in Your Code + +Your application code remains the same - the label filtering is handled automatically: + +```typescript title="src/commands/process-payment.ts" +@Command({ + authorize: 'all', +}) +export class ProcessPayment { + public constructor(readonly amount: number) {} + + public static async handle(command: ProcessPayment): Promise { + // This will get different values based on the environment's label filter: + // - development: 5000ms + // - staging: 15000ms + // - production: 30000ms + const timeoutMs = await resolveConfigurationValue(Booster.config, 'API_TIMEOUT') || '10000' + + // This will show debug info only in dev/staging, not production + const debugMode = await resolveConfigurationValue(Booster.config, 'DEBUG_MODE') + + if (debugMode === 'enabled') { + console.log(`Processing payment of ${command.amount} with timeout ${timeoutMs}ms`) + } + + // Your payment processing logic here... + return `Payment processed with ${timeoutMs}ms timeout` + } +} +``` + +#### 4. Benefits of Label-Based Environment Isolation + +- **🔧 Same Codebase**: No code changes needed between environments +- **🔁 Easy Promotion**: Promote the same code through dev → staging → prod +- **🎯 Environment-Specific Tuning**: Different timeouts, feature flags, etc. per environment +- **🛡️ Safety**: Production values are isolated from development changes +- **📊 A/B Testing**: Use different labels for different user groups in the same environment + +#### 5. Advanced Label Usage Examples + +**Feature Flag Rollout by Environment:** +```typescript +// Gradually roll out new features +const useNewPaymentFlow = await resolveConfigurationValue(Booster.config, 'NEW_PAYMENT_FLOW') +// development: 'enabled' - test new flow +// staging: 'enabled' - QA the new flow +// production: 'disabled' - keep stable flow until ready +``` + +**Environment-Specific Integrations:** +```typescript +// Different API endpoints per environment +const paymentUrl = await resolveConfigurationValue(Booster.config, 'PAYMENT_API_URL') +// development: 'https://sandbox-payments.example.com' +// staging: 'https://staging-payments.example.com' +// production: 'https://api-payments.example.com' +``` + +:::note +In most cases, you don't need to specify connection strings or endpoints manually. The Booster Azure provider automatically provisions the Azure App Configuration resource and injects the connection details as environment variables during deployment. +::: + +### Infrastructure Provisioning + +When you enable Azure App Configuration, the Booster Azure provider automatically: + +1. **Creates the Azure App Configuration resource** using Terraform +2. **Sets up authentication** with managed identity and access keys +3. **Injects environment variables** into your Function App: + - `AZURE_APP_CONFIG_CONNECTION_STRING` + - `AZURE_APP_CONFIG_ENDPOINT` +4. **Initializes the configuration provider** at runtime when environment variables are available + +## Creating Custom Configuration Providers + +You can implement custom configuration providers for other external systems: + +```typescript title="src/providers/custom-config-provider.ts" +import { ConfigurationProvider } from '@boostercloud/framework-types' + +export class CustomConfigurationProvider implements ConfigurationProvider { + public readonly name = 'CustomProvider' + public readonly priority = 15 // Between Azure App Config (20) and Booster config.env (10) + + constructor(private apiEndpoint: string, private apiKey: string) {} + + async getValue(key: string): Promise { + try { + // Implement your custom logic to fetch configuration + const response = await fetch(`${this.apiEndpoint}/config/${key}`, { + headers: { 'Authorization': `Bearer ${this.apiKey}` }, + }) + + if (response.ok) { + const data = await response.json() + return data.value + } + } catch (error) { + console.warn(`Custom provider failed to get ${key}:`, error) + } + + return undefined + } + + async isAvailable(): Promise { + // Check if the service is reachable + try { + const response = await fetch(`${this.apiEndpoint}/health`) + return response.ok + } catch { + return false + } + } +} +``` + +Register your custom provider in the configuration: + +```typescript title="src/config/config.ts" +import { CustomConfigurationProvider } from '../providers/custom-config-provider' + +Booster.configure('production', (config: BoosterConfig): void => { + config.appName = 'my-app' + config.providerPackage = '@boostercloud/framework-provider-azure' + + // Add custom configuration provider + const customProvider = new CustomConfigurationProvider( + 'https://api.myservice.com', + process.env.CUSTOM_API_KEY, + ) + config.addConfigurationProvider(customProvider) +}) +``` + +## Best Practices + +### 1. Use Descriptive Configuration Keys + +```typescript +// Good +const timeout = await resolveConfigurationValue(Booster.config, 'PAYMENT_API_TIMEOUT_MS') + +// Avoid +const timeout = await resolveConfigurationValue(Booster.config, 'TIMEOUT') +``` + +### 2. Provide Sensible Defaults + +```typescript +const maxRetries = await resolveConfigurationValue(Booster.config, 'MAX_RETRIES') || '3' +const timeoutMs = parseInt( + await resolveConfigurationValue(Booster.config, 'API_TIMEOUT_MS') || '30000' +) +``` + +### 3. Use Source Tracking for Debugging + +```typescript +const resolution = await resolveConfigurationWithSource(Booster.config, 'DEBUG_MODE') +console.log(`Debug mode: ${resolution.value} (from ${resolution.source})`) +``` + +### 4. Handle Configuration Errors Gracefully + +```typescript +try { + const apiKey = await resolveConfigurationValue(Booster.config, 'EXTERNAL_API_KEY') + if (!apiKey) { + throw new Error('EXTERNAL_API_KEY configuration is required') + } + // Use apiKey... +} catch (error) { + console.error('Configuration error:', error) + // Provide fallback behavior or fail gracefully +} +``` + +### 5. Cache Configuration Values for Performance + +```typescript +class ConfigCache { + private cache = new Map() + private readonly TTL = 5 * 60 * 1000 // 5 minutes + + async get(key: string): Promise { + const cached = this.cache.get(key) + if (cached && Date.now() < cached.expiry) { + return cached.value + } + + const value = await resolveConfigurationValue(Booster.config, key) + if (value) { + this.cache.set(key, { value, expiry: Date.now() + this.TTL }) + } + return value + } +} +``` + +## Troubleshooting + +### Configuration Not Found + +If a configuration value is not found: + +```typescript +const resolution = await resolveConfigurationWithSource(Booster.config, 'MISSING_KEY') +console.log(resolution.source) // "none" +console.log(resolution.value) // undefined +``` + +### Azure App Configuration Connection Issues + +Check your environment variables are properly set: + + + ```shell + # In your deployed Function App, these should be automatically set: + echo $AZURE_APP_CONFIG_CONNECTION_STRING + echo $AZURE_APP_CONFIG_ENDPOINT + ``` + + +### Provider Priority Conflicts + +List all registered providers to debug priority issues: + +```typescript +const configService = ConfigurationService.getInstance() +const providers = configService.getProviders() + +providers.forEach((provider) => { + console.log(`${provider.name}: priority ${provider.priority}`) +}) +``` + +## Conclusion + +Booster's configuration management system provides a powerful, flexible way to manage application settings across different environments and sources. By leveraging external providers like Azure App Configuration, you can achieve true configuration-driven applications that adapt without requiring code changes or redeployments. + +The hierarchical resolution system ensures predictable behavior while providing multiple layers of configuration sources for maximum flexibility and reliability. diff --git a/website/docs/10_going-deeper/environment-configuration.mdx b/website/docs/10_going-deeper/environment-configuration.mdx index 9d0591e844..ab9adce273 100644 --- a/website/docs/10_going-deeper/environment-configuration.mdx +++ b/website/docs/10_going-deeper/environment-configuration.mdx @@ -42,4 +42,15 @@ This way, you can have different configurations depending on your needs. Booster environments are extremely flexible. As shown in the first example, your 'fruit-store' app can have three team-wide environments: 'dev', 'stage', and 'prod', each of them with different app names or providers, that are deployed by your CI/CD processes. Developers, like "John" in the second example, can create their own private environments in separate config files to test their changes in realistic environments before committing them. Likewise, CI/CD processes could generate separate production-like environments to test different branches to perform QA in separate environments without interferences from other features under test. -The only thing you need to do to deploy a whole new completely-independent copy of your application is to use a different name. Also, Booster uses the credentials available in the machine (`~/.aws/credentials` in AWS) that performs the deployment process, so developers can even work on separate accounts than production or staging environments. \ No newline at end of file +The only thing you need to do to deploy a whole new completely-independent copy of your application is to use a different name. Also, Booster uses the credentials available in the machine (`~/.aws/credentials` in AWS) that performs the deployment process, so developers can even work on separate accounts than production or staging environments. + +## Advanced Configuration Management + +For managing dynamic configuration values that can be updated without redeployment, Booster provides a comprehensive configuration management system with support for external configuration providers like Azure App Configuration. + +See the [Configuration Management](/features/configuration) documentation for detailed information about: +- Multi-tier configuration resolutions +- Azure App Configuration integration +- Custom configuration providers +- Feature flags and A/B testing +- Dynamic configuration updates \ No newline at end of file diff --git a/website/docs/10_going-deeper/infrastructure-providers.mdx b/website/docs/10_going-deeper/infrastructure-providers.mdx index 6f83e8bdd8..34c60d7900 100644 --- a/website/docs/10_going-deeper/infrastructure-providers.mdx +++ b/website/docs/10_going-deeper/infrastructure-providers.mdx @@ -327,6 +327,96 @@ Azure Provider will generate a default `host.json` file if there is not a `host. If you want to use your own `host.json` file just add it to `config.assets` array and Booster will use yours. +### Azure App Configuration Integration + +The Azure provider includes seamless integration with [Azure App Configuration service](https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview) for centralized configuration management. When enabled, Booster automatically provisions the necessary infrastructure and provides a unified configuration API. + +#### Enabling Azure App Configuration + +Add the following to your Azure environment configuration: + +```typescript +Booster.configure('production', (config: BoosterConfig): void => { + config.appName = 'my-app-name' + config.providerPackage = '@boostercloud/framework-provider-azure' + + // Enable Azure App Configuration + config.enableAzureAppConfiguration() +}) +``` + +#### Infrastructure Provisioning + +When you deploy with Azure App Configuration enabled, the Azure provider automatically: + +- **Creates an Azure App Configuration resource** in your resource group +- **Sets up authentication** using managed identity and access keys +- **Configures environment variables** in your Function App: + - `AZURE_APP_CONFIG_CONNECTION_STRING`: Connection string for the App Configuration resource + - `AZURE_APP_CONFIG_ENDPOINT`: Endpoint URL for the App Configuration resource +- **Initializes the configuration provider** at runtime + +#### Using Configuration Values + +Once deployed, you can retrieve configuration values from any part of your application: + +```typescript +import { resolveConfigurationValue, resolveConfigurationWithSource } from '@boostercloud/framework-core' + +// Simple value resolution +const appTimeout = await resolveConfigurationValue(Booster.config, 'API_TIMEOUT') + +// Resolution with source tracking (useful for debugging) +const resolution = await resolveConfigurationWithSource(Booster.config, 'FEATURE_FLAG_X') +console.log(`${resolution.key}: ${resolution.value} (from ${resolution.source})`) +``` + +The configuration system uses a 3-tier priority hierarchy: +1. Azure App Configuration (highest priority) +2. Booster config.env values +3. System environment variables (lowest priority) + +#### Using Labels for Environment Isolation + +Configure different environments to use different label filters: + +```typescript +// Development environment +Booster.configure('development', (config: BoosterConfig): void => { + config.appName = 'my-app-dev' + config.providerPackage = '@boostercloud/framework-provider-azure' + + config.enableAzureAppConfiguration({ + labelFilter: 'development', // Reads only keys with 'development' label + }) +}) + +// Production environment +Booster.configure('production', (config: BoosterConfig): void => { + config.appName = 'my-app-prod' + config.providerPackage = '@boostercloud/framework-provider-azure' + + config.enableAzureAppConfiguration({ + labelFilter: 'production', // Reads only keys with 'production' label + }) +}) +``` + +In Azure App Configuration, create the same keys with different labels: +- `API_TIMEOUT` with label `development` set to `5000` +- `API_TIMEOUT` with label `production` set to `30000` + +Your code stays the same - the environment determines which value is retrieved. + +#### Benefits + +- **No redeployment required** - Update configuration values in Azure portal and they're immediately available +- **Environment isolation** - Use labels to manage different values per environment +- **Feature flags** - Enable/disable features dynamically +- **Secure configuration** - Integration with Azure Key Vault for sensitive values + +For detailed information about configuration management, see the [Configuration](/features/configuration) documentation. + ## Local Provider All Booster projects come with a local development environment configured by default, so you can test your app before deploying it to the cloud. diff --git a/website/sidebars.js b/website/sidebars.js index 2742979ccb..074fd4ff3c 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -53,7 +53,8 @@ const sidebars = { 'features/event-stream', 'features/schedule-actions', 'features/logging', - 'features/error-handling' + 'features/error-handling', + 'features/configuration', ], }, {