Documentation

fsds/dplx-azure-app-config-implementation-plan.md

Centralized Configuration with Azure App Configuration

Overview

Migrate all configuration from distributed appsettings.json files to Azure App Configuration. The AppHost's appsettings.json becomes the single source of truth for local development, automatically seeding the App Config emulator at startup.

Design Decisions

  • Secrets: Azure Key Vault (postgres-kv) - same vault for all secrets
  • Logging: All config in App Config (complete elimination of component appsettings.json)
  • Key Structure: Flat with component prefixes (Iot:Tn360:ApiKey, Bbu:Mqtt:Host)
  • Local Dev: App Config emulator seeded from AppHost config
  • Azure: Real App Config service with Key Vault references

Architecture

Local Development:
  AppHost appsettings.json  ─────► App Config Emulator ─────► Components
         (source of truth)        (seeded at startup)        (read config)

Azure Deployment:
  Azure App Configuration ─────► Key Vault (secrets) ─────► Components
  (pre-populated)                (postgres-kv)

Implementation Stages

Stage 1: Fix Azure.Core 409 Logging Noise (Quick Win)

Add logging filter to suppress expected 409 "ContainerAlreadyExists" responses.

Files:

  • engines/iot/src/Acsis.Dynaplex.Engines.Iot/appsettings.json - Add "Azure.Core": "Error"
  • engines/bbu/src/Acsis.Dynaplex.Engines.Bbu/appsettings.json - Add "Azure.Core": "Error"
  • engines/file-service/src/Acsis.Dynaplex.Engines.FileService/appsettings.json - Add "Azure.Core": "Error"

Change:

"Logging": {
  "LogLevel": {
    "Azure.Core": "Error"
  }
}

Stage 2: App Configuration Seeder (Infrastructure)

Create seeder that populates App Config emulator from AppHost configuration.

New File: strata/orchestration/src/Acsis.Dynaplex.Strata.Orchestration/AppConfigurationSeeder.cs

public class AppConfigurationSeeder {
    private readonly IConfiguration _configuration;
    private readonly string _keyVaultName;

    public async Task SeedAsync(string connectionString, CancellationToken ct) {
        var client = new ConfigurationClient(connectionString);

        // Flatten hierarchical config to colon-separated keys
        // Convert "kv:{secret-name}" values to Key Vault references
        // Upsert all values to App Config
    }
}

Package to add: Azure.Data.AppConfiguration in Orchestration project


Stage 3: Lifecycle Hook for Seeding

Update AddDynaplexAppConfiguration() to seed emulator when ready.

File: strata/orchestration/src/Acsis.Dynaplex.Strata.Orchestration/DynaplexInfrastructureExtensions.cs

public static IDistributedApplicationBuilder AddDynaplexAppConfiguration(...) {
    // ... existing emulator setup ...

    if (!context.IsAzurePublish) {
        // Subscribe to ResourceReadyEvent to seed after emulator starts
        builder.Eventing.Subscribe<ResourceReadyEvent>(
            context.AppConfig.Resource,
            async (evt, ct) => {
                var seeder = new AppConfigurationSeeder(builder.Configuration);
                var connStr = await context.AppConfig.Resource.GetConnectionStringAsync(ct);
                await seeder.SeedAsync(connStr, ct);
            });
    }
}

Stage 4: ServiceDefaults Integration (Component Side)

Add automatic App Config integration to AddServiceDefaults().

File: strata/service-defaults/src/Acsis.Dynaplex.Strata.ServiceDefaults/Extensions.cs

New method:

public static TBuilder AddAcsisAppConfiguration<TBuilder>(this TBuilder builder)
    where TBuilder : IHostApplicationBuilder {

    var configConn = builder.Configuration.GetConnectionString("config");
    if (string.IsNullOrEmpty(configConn)) return builder;

    var componentName = ExtractComponentName(Assembly.GetEntryAssembly());

    builder.AddAzureAppConfiguration("config",
        configureOptions: options => {
            options.Select("Logging:*");  // Shared
            options.Select($"{componentName}:*")
                   .TrimKeyPrefix($"{componentName}:");  // Component-specific
            options.ConfigureKeyVault(kv => {
                kv.SetCredential(new DefaultAzureCredential());
            });
        });

    // Suppress 409 noise
    builder.Logging.AddFilter("Azure.Core", LogLevel.Error);

    return builder;
}

Update AddServiceDefaults():

public static TBuilder AddServiceDefaults<TBuilder>(...) {
    builder.AddAcsisAppConfiguration();  // Add this first
    builder.ConfigureOpenTelemetry();
    // ... rest unchanged
}

Stage 5: Component Wire-up

Update component registration to automatically reference App Config.

File: strata/orchestration/src/Acsis.Dynaplex.Strata.Orchestration/DynaplexComponentExtensions.cs

In AddComponent<T>():

if (context.AppConfig != null) {
    component.WithReference(context.AppConfig);
}

Stage 6: Master Configuration Structure

Restructure AppHost's appsettings.json as master config.

File: projects/bbu-rfid/src/Acsis.Dynaplex.Projects.BbuRfid/appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Azure.Core": "Error"
    }
  },

  "Iot": {
    "Tn360": {
      "BaseUrl": "https://api-us.telematics.com/v1",
      "ApiKey": "kv:tn360-api-key",
      "RetryPolicy": { "MaxAttempts": 3 }
    },
    "DatalogicBlobProcessor": {
      "Enabled": true,
      "ContainerName": "bbu-image",
      "PollingIntervalSeconds": 15
    }
  },

  "Bbu": {
    "TenantId": "00000000-0000-0000-0000-000000000001",
    "Mqtt": {
      "Enabled": true,
      "BrokerHost": "i5122f12.ala.us-east-1.emqxsl.com",
      "BrokerPort": 8883,
      "Username": "kv:bbu-mqtt-username",
      "Password": "kv:bbu-mqtt-password"
    }
  },

  "Identity": {
    "Issuer": "acsis-identity",
    "Audience": "acsis-api",
    "ExpirationSpan": 8,
    "RsaPrivateKey": "kv:identity-rsa-private-key",
    "RsaPublicKey": "kv:identity-rsa-public-key"
  }
}

Values prefixed with kv: are converted to Key Vault references during seeding.


Stage 7: Remove Component appsettings.json Files

After migration, delete:

  • engines/iot/src/Acsis.Dynaplex.Engines.Iot/appsettings.json
  • engines/bbu/src/Acsis.Dynaplex.Engines.Bbu/appsettings.json
  • engines/events/src/Acsis.Dynaplex.Engines.Events/appsettings.json
  • engines/catalog/src/Acsis.Dynaplex.Engines.Catalog/appsettings.json
  • All other component appsettings.json files

Stage 8: Clean Up IoT Manual Integration

Remove manual App Config code from IoT component.

File: engines/iot/src/Acsis.Dynaplex.Engines.Iot/Program.cs

Remove lines 33-38:

// DELETE:
var appConfigConnection = builder.Configuration.GetConnectionString("config");
if (!string.IsNullOrEmpty(appConfigConnection))
{
    builder.AddAzureAppConfiguration("config");
}

Now handled automatically by AddServiceDefaults().


Critical Files Summary

File Change
DynaplexInfrastructureExtensions.cs Add seeding lifecycle hook
DynaplexComponentExtensions.cs Auto-reference App Config
ServiceDefaults/Extensions.cs Add AddAcsisAppConfiguration()
AppHost appsettings.json Restructure as master config
Component Program.cs files Remove manual App Config code
Component appsettings.json files Delete after migration

Key Vault Secrets to Add

For local dev, add to user-secrets. For Azure, add to postgres-kv:

  • tn360-api-key
  • bbu-mqtt-username
  • bbu-mqtt-password
  • identity-rsa-private-key
  • identity-rsa-public-key
  • events-eventgrid-sastoken
  • okta-client-secret
  • azure-ad-client-secret

Execution Order

  1. Quick Win: Fix Azure.Core logging noise (Stage 1)
  2. Infrastructure: Create seeder + lifecycle hook (Stages 2-3)
  3. ServiceDefaults: Add automatic integration (Stage 4)
  4. Orchestration: Update component wire-up (Stage 5)
  5. Migration: Move config to AppHost, delete component configs (Stages 6-8)
  6. Testing: Verify local and Azure deployments