Skip to content

Commit 19a6a33

Browse files
robgrameCopilot
andcommitted
feat(hybridagent): pivot to AD scan producer for Azure-native stack
The Azure-native deployment is a parallel product to the on-prem stack; the two coexist but never integrate. This commit flips Phase 3 so the HybridAgent is the PRODUCER of sealed batches (it has line of sight to on-prem AD) and a future Azure consumer Function will persist rows into Azure SQL. Core * Move IDekSealingFacade + DekSealingService from Functions/Security to Core/Security so the agent can reference them directly (Azure.Security. KeyVault.Keys added to Core). * ConfigurationProvider gains a static-only mode (CreateStaticOnly factory) for hosts that read scan parameters straight from appsettings.json with no SQL backing - guarded by EnsureNotStaticOnly on every write path. * NoOpAuditService for hosts without an AuditEvents table - logs AUDIT lines via Serilog only. HybridAgent * Drop consumer-side wiring (SealedBatchProcessor BackgroundService, IDekUnsealingFacade, replay store, SealedBatchHandler) from DI; files stay dormant for the upcoming consumer Function migration. * New SealedBatchSender wraps ServiceBusSender for SealedDekBatchMessage; uses BatchId as MessageId for SB dedup. * New AdScanProducerJob (Quartz IJob, DisallowConcurrentExecution) drives IAdScannerService, buffers up to 250 rows per batch, seals each with the Key Vault agent-seal key, publishes to dek-sealed-batches queue. * Program.cs rewritten with Serilog + ScanConfiguration/AuthConfiguration binding + static ConfigurationProvider + LdapConnectionFactory + AdScannerService + DekSealingService + Quartz scheduler reading ScanSettings:CronSchedule. * appsettings.json mirrors the on-prem Worker schema (ScanSettings, Authentication, Encryption:KeyVault, SealedBatch) so admins can lift values from the on-prem config. * Quartz + Serilog packages added. Deploy-HybridAgent.ps1 * New params: -KeyVaultUri, -SealKeyName, -LdapServer, -LdapSearchBase, -LdapPort, -LdapUseSsl, -CronSchedule, -ImportFromWorkerAppSettings. * Merge helper now supports nested objects (Encryption.KeyVault) and merges 4 sections in one remote call. Explicit params override imported file. Verified * dotnet build .\BitLockerKeyMonitor.Azure.slnx - 0 warn / 0 err. * dotnet test .\BitLockerKeyMonitor.Azure.slnx - 109/109 pass. * dotnet test .\BitLockerKeyMonitor.slnx - 86/86 on-prem pass. Deferred next session: * Build Azure consumer Function (ServiceBusTrigger - unseal - upsert). * Move IDekUnsealingFacade + SealedBatchHandler to Core/Security. * Flip Bicep RBAC: HybridAgent SP gets Crypto User + SB Data Sender; Functions UAMI flips from Sender to Receiver. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 66c8d10 commit 19a6a33

12 files changed

Lines changed: 682 additions & 92 deletions

File tree

Deploy-HybridAgent.ps1

Lines changed: 144 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,22 @@ param(
110110
[string]$ServiceBusFullyQualifiedNamespace,
111111
[string]$QueueName = "dek-sealed-batches",
112112

113+
# ─── KV sealing target (producer) ────────────────────────────────────────
114+
[string]$KeyVaultUri,
115+
[string]$SealKeyName = "blkmon-agent-seal",
116+
117+
# ─── AD scan parameters (mirrors on-prem Worker schema) ──────────────────
118+
# When -ImportFromWorkerAppSettings is supplied, ScanSettings:* values are
119+
# pulled from that file and merged before the explicit parameters below —
120+
# explicit values WIN over the imported file so an admin can override one
121+
# field without re-editing the file.
122+
[string]$ImportFromWorkerAppSettings,
123+
[string]$LdapServer,
124+
[string]$LdapSearchBase,
125+
[int]$LdapPort,
126+
[Nullable[bool]]$LdapUseSsl,
127+
[string]$CronSchedule,
128+
113129
[string]$AzureTenantId,
114130
[string]$AzureClientId,
115131

@@ -307,65 +323,138 @@ try {
307323
Write-Ok "HybridAgent → $InstallRoot"
308324

309325
# ── Merge configuration into appsettings.json ────────────────────────────
310-
if ($ServiceBusFullyQualifiedNamespace -or $QueueName) {
311-
Write-Step "Merging SealedBatch configuration into appsettings.json"
326+
# Build the section dictionary on the BUILD machine (where -ImportFromWorkerAppSettings
327+
# lives) and ship the resolved values to the target. Each parameter explicitly supplied
328+
# by the caller overrides the imported file so an admin can override one knob without
329+
# re-editing the source.
330+
$scanSettings = [ordered]@{}
331+
$authSettings = [ordered]@{}
332+
$kvSettings = [ordered]@{}
333+
$sbSettings = [ordered]@{ QueueName = $QueueName }
334+
335+
if ($ImportFromWorkerAppSettings) {
336+
if (-not (Test-Path -LiteralPath $ImportFromWorkerAppSettings)) {
337+
throw "ImportFromWorkerAppSettings file not found: $ImportFromWorkerAppSettings"
338+
}
339+
Write-Step "Importing ScanSettings/Authentication from $ImportFromWorkerAppSettings"
340+
$imported = Get-Content -LiteralPath $ImportFromWorkerAppSettings -Raw | ConvertFrom-Json
341+
342+
# ScanSettings: pull the AD-relevant fields only — Graph/Entra fields are intentionally
343+
# skipped because Entra scanning lives on the Azure Functions side of the deployment.
344+
$adFields = @(
345+
'CronSchedule','LdapServer','LdapSearchBase','LdapPort','LdapUseSsl',
346+
'IncludeRecoveryKeyValue','MaxRetryAttempts','BaseRetryDelaySeconds',
347+
'CsvOutputPath','KeyValueExportPath','RetirementGraceDays'
348+
)
349+
if ($imported.PSObject.Properties.Name -contains 'ScanSettings') {
350+
foreach ($f in $adFields) {
351+
if ($imported.ScanSettings.PSObject.Properties.Name -contains $f) {
352+
$scanSettings[$f] = $imported.ScanSettings.$f
353+
}
354+
}
355+
Write-Ok "Imported $($scanSettings.Count) ScanSettings field(s) from on-prem Worker"
356+
}
312357

313-
Invoke-Command -Session $session -ScriptBlock {
314-
param($InstallRoot, $SbFqdn, $QueueName)
315-
316-
# Same PSCustomObject merge helper used in Deploy-Remote.ps1 — PS 5.1 compatible.
317-
function Update-AppSettings {
318-
param(
319-
[Parameter(Mandatory)][string]$Path,
320-
[Parameter(Mandatory)][hashtable]$Sections
321-
)
322-
if (-not (Test-Path -LiteralPath $Path)) { throw "appsettings.json not found at $Path" }
323-
324-
$backup = "$Path.bak.$(Get-Date -Format 'yyyyMMdd-HHmmss')"
325-
Copy-Item -LiteralPath $Path -Destination $backup -Force
326-
327-
$obj = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
328-
$changes = @()
329-
330-
foreach ($sectionName in $Sections.Keys) {
331-
$sectionData = $Sections[$sectionName]
332-
if ($null -eq $obj.$sectionName) {
333-
$obj | Add-Member -NotePropertyName $sectionName -NotePropertyValue ([pscustomobject]@{}) -Force
334-
}
335-
foreach ($key in $sectionData.Keys) {
336-
$newVal = $sectionData[$key]
337-
$oldVal = if ($obj.$sectionName.PSObject.Properties.Name -contains $key) { $obj.$sectionName.$key } else { $null }
338-
if ($oldVal -ne $newVal) {
339-
if ($obj.$sectionName.PSObject.Properties.Name -contains $key) {
340-
$obj.$sectionName.$key = $newVal
341-
} else {
342-
$obj.$sectionName | Add-Member -NotePropertyName $key -NotePropertyValue $newVal -Force
358+
if ($imported.PSObject.Properties.Name -contains 'Authentication' -and
359+
$imported.Authentication.PSObject.Properties.Name -contains 'AdAuthMode') {
360+
$authSettings['AdAuthMode'] = $imported.Authentication.AdAuthMode
361+
}
362+
}
363+
364+
# Explicit parameter overrides
365+
if ($PSBoundParameters.ContainsKey('CronSchedule')) { $scanSettings['CronSchedule'] = $CronSchedule }
366+
if ($PSBoundParameters.ContainsKey('LdapServer')) { $scanSettings['LdapServer'] = $LdapServer }
367+
if ($PSBoundParameters.ContainsKey('LdapSearchBase')) { $scanSettings['LdapSearchBase'] = $LdapSearchBase }
368+
if ($PSBoundParameters.ContainsKey('LdapPort')) { $scanSettings['LdapPort'] = $LdapPort }
369+
if ($PSBoundParameters.ContainsKey('LdapUseSsl') -and $null -ne $LdapUseSsl) {
370+
$scanSettings['LdapUseSsl'] = [bool]$LdapUseSsl
371+
}
372+
373+
if ($PSBoundParameters.ContainsKey('KeyVaultUri')) { $kvSettings['Uri'] = $KeyVaultUri }
374+
if ($PSBoundParameters.ContainsKey('SealKeyName')) { $kvSettings['SealKeyName'] = $SealKeyName }
375+
376+
if ($ServiceBusFullyQualifiedNamespace) {
377+
$sbSettings['ServiceBusFullyQualifiedNamespace'] = $ServiceBusFullyQualifiedNamespace
378+
}
379+
380+
# ────────────────────────────────────────────────────────────────────────
381+
Write-Step "Merging configuration into appsettings.json"
382+
383+
Invoke-Command -Session $session -ScriptBlock {
384+
param($InstallRoot, $Sections)
385+
386+
# PSCustomObject merge helper — PS 5.1 compatible. Supports nested PSCustomObject sections
387+
# (Encryption.KeyVault) via dotted keys: pass an inner hashtable as the section value and
388+
# the function recurses one level.
389+
function Update-AppSettings {
390+
param(
391+
[Parameter(Mandatory)][string]$Path,
392+
[Parameter(Mandatory)][hashtable]$Sections
393+
)
394+
if (-not (Test-Path -LiteralPath $Path)) { throw "appsettings.json not found at $Path" }
395+
396+
$backup = "$Path.bak.$(Get-Date -Format 'yyyyMMdd-HHmmss')"
397+
Copy-Item -LiteralPath $Path -Destination $backup -Force
398+
399+
$obj = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
400+
$changes = @()
401+
402+
function Set-Field {
403+
param($Parent, [string]$Name, $Value)
404+
if ($Parent.PSObject.Properties.Name -contains $Name) {
405+
$Parent.$Name = $Value
406+
} else {
407+
$Parent | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force
408+
}
409+
}
410+
411+
foreach ($sectionName in $Sections.Keys) {
412+
$sectionData = $Sections[$sectionName]
413+
if ($null -eq $sectionData -or $sectionData.Count -eq 0) { continue }
414+
if ($null -eq $obj.$sectionName) {
415+
Set-Field -Parent $obj -Name $sectionName -Value ([pscustomobject]@{})
416+
}
417+
foreach ($key in $sectionData.Keys) {
418+
$newVal = $sectionData[$key]
419+
# Nested object support (Encryption.KeyVault)
420+
if ($newVal -is [System.Collections.IDictionary]) {
421+
if ($null -eq $obj.$sectionName.$key) {
422+
Set-Field -Parent ($obj.$sectionName) -Name $key -Value ([pscustomobject]@{})
423+
}
424+
foreach ($innerKey in $newVal.Keys) {
425+
$innerVal = $newVal[$innerKey]
426+
$oldInner = if ($obj.$sectionName.$key.PSObject.Properties.Name -contains $innerKey) { $obj.$sectionName.$key.$innerKey } else { $null }
427+
if ($oldInner -ne $innerVal) {
428+
Set-Field -Parent ($obj.$sectionName.$key) -Name $innerKey -Value $innerVal
429+
$changes += "${sectionName}:${key}:${innerKey} = $innerVal"
343430
}
344-
$changes += "${sectionName}:${key} = $newVal"
345431
}
432+
continue
433+
}
434+
$oldVal = if ($obj.$sectionName.PSObject.Properties.Name -contains $key) { $obj.$sectionName.$key } else { $null }
435+
if ($oldVal -ne $newVal) {
436+
Set-Field -Parent ($obj.$sectionName) -Name $key -Value $newVal
437+
$changes += "${sectionName}:${key} = $newVal"
346438
}
347439
}
348-
349-
$json = $obj | ConvertTo-Json -Depth 32
350-
[System.IO.File]::WriteAllText($Path, $json, [System.Text.UTF8Encoding]::new($false))
351-
352-
return [pscustomobject]@{ Path = $Path; Backup = $backup; Changes = $changes }
353440
}
354441

355-
$sections = @{
356-
SealedBatch = @{ QueueName = $QueueName }
357-
}
358-
if ($SbFqdn) {
359-
$sections.SealedBatch.ServiceBusFullyQualifiedNamespace = $SbFqdn
360-
}
442+
$json = $obj | ConvertTo-Json -Depth 32
443+
[System.IO.File]::WriteAllText($Path, $json, [System.Text.UTF8Encoding]::new($false))
361444

362-
$appSettings = Join-Path $InstallRoot "appsettings.json"
363-
$r = Update-AppSettings -Path $appSettings -Sections $sections
364-
foreach ($c in $r.Changes) { Write-Output "appsettings: $c" }
365-
Write-Output "backup: $($r.Backup)"
366-
} -ArgumentList $InstallRoot, $ServiceBusFullyQualifiedNamespace, $QueueName |
367-
ForEach-Object { Write-Ok $_ }
368-
}
445+
return [pscustomobject]@{ Path = $Path; Backup = $backup; Changes = $changes }
446+
}
447+
448+
$appSettings = Join-Path $InstallRoot "appsettings.json"
449+
$r = Update-AppSettings -Path $appSettings -Sections $Sections
450+
foreach ($c in $r.Changes) { Write-Output "appsettings: $c" }
451+
Write-Output "backup: $($r.Backup)"
452+
} -ArgumentList $InstallRoot, @{
453+
SealedBatch = $sbSettings
454+
ScanSettings = $scanSettings
455+
Authentication = $authSettings
456+
Encryption = @{ KeyVault = $kvSettings }
457+
} | ForEach-Object { Write-Ok $_ }
369458

370459
# ── Pre-flight: verify cert is present when thumbprint supplied ──────────
371460
if ($AzureClientCertificateThumbprint) {
@@ -474,6 +563,10 @@ try {
474563
else { Write-Host " Service Bus : (not configured — agent runs in idle/heartbeat mode)" }
475564
if ($AzureClientId) { Write-Host " Entra app : $AzureClientId (tenant $AzureTenantId)" }
476565
if ($AzureClientCertificateThumbprint) { Write-Host " Auth cert : $($AzureClientCertificateThumbprint.Substring(0,8))..." }
566+
if ($KeyVaultUri) { Write-Host " KV agent-seal : $KeyVaultUri ($SealKeyName)" }
567+
if ($scanSettings.Count -gt 0) {
568+
Write-Host " AD scan : LdapServer=$($scanSettings['LdapServer']) base=$($scanSettings['LdapSearchBase']) cron='$($scanSettings['CronSchedule'])'"
569+
}
477570
Write-Host ""
478571

479572
} finally {

src/BitLockerKeyMonitor.Core/BitLockerKeyMonitor.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<ItemGroup>
1919
<PackageReference Include="Azure.Identity" Version="1.21.0" />
2020
<PackageReference Include="Azure.Monitor.Ingestion" Version="1.1.2" />
21+
<PackageReference Include="Azure.Security.KeyVault.Keys" Version="4.10.0" />
2122
<PackageReference Include="CsvHelper" Version="33.1.0" />
2223
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
2324
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

src/BitLockerKeyMonitor.Functions/Security/DekSealingService.cs renamed to src/BitLockerKeyMonitor.Core/Security/DekSealingService.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
using System.Security.Cryptography;
2-
using BitLockerKeyMonitor.Core.Security;
32
using BitLockerKeyMonitor.Shared.SealedBatch;
43
using Microsoft.Extensions.Logging;
54

6-
namespace BitLockerKeyMonitor.Functions.Security;
5+
namespace BitLockerKeyMonitor.Core.Security;
76

87
/// <summary>
98
/// Functions-side service that wraps a list of recovery-key rows into a fully-sealed

src/BitLockerKeyMonitor.Functions/Security/IDekSealingFacade.cs renamed to src/BitLockerKeyMonitor.Core/Security/IDekSealingFacade.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
using Azure.Security.KeyVault.Keys;
44
using Azure.Security.KeyVault.Keys.Cryptography;
55

6-
namespace BitLockerKeyMonitor.Functions.Security;
6+
namespace BitLockerKeyMonitor.Core.Security;
77

88
/// <summary>
99
/// Thin abstraction over the Key Vault <c>encrypt</c> operation used to seal the per-batch

src/BitLockerKeyMonitor.Core/Services/ConfigurationProvider.cs

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ namespace BitLockerKeyMonitor.Core.Services;
1515
/// </summary>
1616
public class ConfigurationProvider
1717
{
18-
private readonly IDbContextFactory<MonitorDbContext> _dbFactory;
18+
private readonly IDbContextFactory<MonitorDbContext>? _dbFactory;
1919
private readonly ScanConfiguration _staticConfig;
20-
private readonly IAuditService _audit;
20+
private readonly IAuditService? _audit;
2121
private readonly ILogger<ConfigurationProvider> _logger;
22+
private readonly bool _staticOnly;
2223

2324
// In-memory cache with expiry
2425
private RuntimeConfig? _cachedConfig;
@@ -41,19 +42,55 @@ public ConfigurationProvider(
4142
_staticConfig = staticConfig.Value;
4243
_audit = audit;
4344
_logger = logger;
45+
_staticOnly = false;
4446
}
4547

48+
// Private ctor used by the static-only factory below. We don't expose dbFactory=null
49+
// through the public ctor because the rest of the codebase (Worker/Web/Functions) wires
50+
// the DB-backed version via DI and would silently break if the wrong overload were
51+
// resolved. The HybridAgent uses the factory explicitly.
52+
private ConfigurationProvider(
53+
IOptions<ScanConfiguration> staticConfig,
54+
ILogger<ConfigurationProvider> logger)
55+
{
56+
_dbFactory = null;
57+
_audit = null;
58+
_staticConfig = staticConfig.Value;
59+
_logger = logger;
60+
_staticOnly = true;
61+
}
62+
63+
/// <summary>
64+
/// Static-only factory for hosts that have no RuntimeConfigs table (e.g. the
65+
/// Azure-native HybridAgent on-prem). The returned provider serves the
66+
/// <see cref="ScanConfiguration"/> bound from appsettings.json directly, and all
67+
/// write/passphrase operations throw <see cref="InvalidOperationException"/>.
68+
/// </summary>
69+
public static ConfigurationProvider CreateStaticOnly(
70+
IOptions<ScanConfiguration> staticConfig,
71+
ILogger<ConfigurationProvider> logger)
72+
=> new(staticConfig, logger);
73+
4674
/// <summary>
4775
/// Gets the effective runtime configuration (DB overrides appsettings).
4876
/// Cached for 30 seconds to avoid excessive DB queries.
4977
/// Returns a detached copy — mutations do not affect the cache.
5078
/// </summary>
5179
public async Task<RuntimeConfig> GetEffectiveConfigAsync(CancellationToken ct = default)
5280
{
81+
if (_staticOnly)
82+
{
83+
// No DB cache lifecycle in static mode — every call returns a fresh clone of the
84+
// appsettings-derived RuntimeConfig so a caller mutating the returned instance
85+
// cannot leak changes into the next call. Cheap; this is invoked once per scan
86+
// cycle, not per row.
87+
return SeedFromStatic();
88+
}
89+
5390
if (_cachedConfig != null && DateTime.UtcNow < _cacheExpiry)
5491
return CloneConfig(_cachedConfig);
5592

56-
await using var db = await _dbFactory.CreateDbContextAsync(ct);
93+
await using var db = await _dbFactory!.CreateDbContextAsync(ct);
5794
var dbConfig = await db.RuntimeConfigs.AsNoTracking().FirstOrDefaultAsync(ct);
5895

5996
if (dbConfig == null)
@@ -84,10 +121,11 @@ public async Task<RuntimeConfig> GetEffectiveConfigAsync(CancellationToken ct =
84121
/// </summary>
85122
public async Task UpdateConfigAsync(RuntimeConfig config, string? modifiedBy = null, CancellationToken ct = default)
86123
{
124+
EnsureNotStaticOnly(nameof(UpdateConfigAsync));
87125
config.LastModifiedAt = DateTime.UtcNow;
88126
config.LastModifiedBy = modifiedBy;
89127

90-
await using var db = await _dbFactory.CreateDbContextAsync(ct);
128+
await using var db = await _dbFactory!.CreateDbContextAsync(ct);
91129
var existing = await db.RuntimeConfigs.FirstOrDefaultAsync(ct);
92130

93131
if (existing != null)
@@ -123,10 +161,11 @@ public async Task UpdateConfigAsync(RuntimeConfig config, string? modifiedBy = n
123161
/// </summary>
124162
public async Task SetPassphraseAsync(string newPassphrase, string? modifiedBy = null, CancellationToken ct = default)
125163
{
164+
EnsureNotStaticOnly(nameof(SetPassphraseAsync));
126165
if (string.IsNullOrEmpty(newPassphrase))
127166
throw new ArgumentException("Passphrase must be non-empty. Use ClearPassphraseAsync to remove.", nameof(newPassphrase));
128167

129-
await using var db = await _dbFactory.CreateDbContextAsync(ct);
168+
await using var db = await _dbFactory!.CreateDbContextAsync(ct);
130169
var existing = await db.RuntimeConfigs.FirstOrDefaultAsync(ct)
131170
?? throw new InvalidOperationException("RuntimeConfig row not initialized — call GetEffectiveConfigAsync first.");
132171

@@ -138,7 +177,7 @@ public async Task SetPassphraseAsync(string newPassphrase, string? modifiedBy =
138177
await db.SaveChangesAsync(ct);
139178
InvalidateCache();
140179

141-
await _audit.RecordAsync(
180+
await _audit!.RecordAsync(
142181
AuditAction.PassphraseChanged,
143182
user: modifiedBy ?? _audit.ProcessIdentity,
144183
outcome: AuditOutcome.Success,
@@ -152,7 +191,8 @@ await _audit.RecordAsync(
152191
/// </summary>
153192
public async Task ClearPassphraseAsync(string? modifiedBy = null, CancellationToken ct = default)
154193
{
155-
await using var db = await _dbFactory.CreateDbContextAsync(ct);
194+
EnsureNotStaticOnly(nameof(ClearPassphraseAsync));
195+
await using var db = await _dbFactory!.CreateDbContextAsync(ct);
156196
var existing = await db.RuntimeConfigs.FirstOrDefaultAsync(ct);
157197
if (existing == null) return;
158198

@@ -164,7 +204,7 @@ public async Task ClearPassphraseAsync(string? modifiedBy = null, CancellationTo
164204
await db.SaveChangesAsync(ct);
165205
InvalidateCache();
166206

167-
await _audit.RecordAsync(
207+
await _audit!.RecordAsync(
168208
AuditAction.PassphraseCleared,
169209
user: modifiedBy ?? _audit.ProcessIdentity,
170210
outcome: AuditOutcome.Success,
@@ -269,6 +309,15 @@ private void WarnIfRuntimeConfigDivergesFromAppsettings(RuntimeConfig dbConfig)
269309
string.Join(Environment.NewLine + " - ", divergences));
270310
}
271311

312+
private void EnsureNotStaticOnly(string operation)
313+
{
314+
if (_staticOnly)
315+
throw new InvalidOperationException(
316+
$"{operation} is unavailable in static-only mode: this ConfigurationProvider was constructed without a DbContextFactory " +
317+
"(HybridAgent / Azure-native hosts read configuration directly from appsettings.json). " +
318+
"Edit appsettings.json on the agent host instead.");
319+
}
320+
272321
private RuntimeConfig SeedFromStatic() => new()
273322
{
274323
AdScanCron = _staticConfig.CronSchedule,

0 commit comments

Comments
 (0)