using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
/// Where a credential-reference finding came from.
public enum CredentialFindingSource
{
/// A stored DriverConfig / DeviceConfig row (what the next deploy will gate).
Draft,
/// The latest deployment artifact (what the running drivers were given).
Deployed,
/// A running driver that reported resolving it through the #815 warn-only compatibility path.
Running,
}
/// One out-of-namespace credential reference (never carries the value).
/// Where it was found.
/// The driver type.
/// The driver's display name (or instance id for a running driver).
/// The driver instance id.
/// The device, when the reference sits in a DeviceConfig.
/// The field and the allowed forms.
public sealed record CredentialReferenceFinding(
CredentialFindingSource Source, string DriverType, string DriverName, string DriverInstanceId, string? DeviceName, string Problem);
///
/// Upgrade preflight for lmxopcua#815: lists every stored or deployed DriverConfig / DeviceConfig whose credential
/// fields carry a reference outside the driver's namespace — configs written before the namespaces existed that
/// the deploy gate will now refuse (and that a running driver resolves only in warn-only compatibility mode).
/// Reads the ConfigDb only; resolves nothing, so it never touches the secret store or the environment.
///
/// The config DB.
public sealed class CredentialReferencePreflight(IDbContextFactory dbFactory)
{
/// Scans the draft rows and the latest deployment artifact.
/// Cancellation.
/// The findings.
public async Task> ScanAsync(CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var drivers = await db.DriverInstances.AsNoTracking().ToListAsync(ct).ConfigureAwait(false);
var ruled = drivers.Where(d => DriverCredentialPolicy.TryGet(d.DriverType, out _)).ToList();
var ids = ruled.Select(d => d.DriverInstanceId).ToHashSet(StringComparer.Ordinal);
var devices = ids.Count == 0
? []
: await db.Devices.AsNoTracking().Where(d => ids.Contains(d.DriverInstanceId)).ToListAsync(ct).ConfigureAwait(false);
var latestBlob = await db.Deployments.AsNoTracking()
.Where(d => d.ArtifactBlob.Length > 0)
.OrderByDescending(d => d.CreatedAtUtc)
.Select(d => d.ArtifactBlob)
.FirstOrDefaultAsync(ct).ConfigureAwait(false);
var deployed = latestBlob is null ? [] : DeploymentArtifact.ParseDriverInstances(latestBlob);
return Scan(ruled, devices, deployed);
}
/// The pure scan (tests drive it directly).
/// Stored driver rows.
/// Stored device rows.
/// The latest artifact's driver specs.
/// The findings.
public static IReadOnlyList Scan(
IEnumerable drivers, IEnumerable devices, IEnumerable deployed)
{
var findings = new List();
var driverList = drivers.ToList();
var byId = driverList.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
foreach (var d in driverList)
{
foreach (var p in DriverCredentialPolicy.DescribeProblems(d.DriverType, d.DriverConfig))
findings.Add(new(CredentialFindingSource.Draft, d.DriverType, d.Name, d.DriverInstanceId, null, p));
}
foreach (var dev in devices)
{
if (!byId.TryGetValue(dev.DriverInstanceId, out var owner)) continue;
foreach (var p in DriverCredentialPolicy.DescribeProblems(owner.DriverType, dev.DeviceConfig))
findings.Add(new(CredentialFindingSource.Draft, owner.DriverType, owner.Name, owner.DriverInstanceId, dev.Name, p));
}
foreach (var s in deployed)
{
foreach (var p in DriverCredentialPolicy.DescribeProblems(s.DriverType, s.DriverConfig))
findings.Add(new(CredentialFindingSource.Deployed, s.DriverType, s.Name, s.DriverInstanceId, null, p));
}
return findings;
}
///
/// Running drivers that reported a warn-only legacy resolution on their diagnostics bag
/// (), fleet-wide via the driver-health feed.
///
/// The driver-health snapshots.
/// The findings.
public static IReadOnlyList RunningFindings(IEnumerable snapshots) =>
snapshots
.Where(s => s.Diagnostics is not null)
.SelectMany(s => s.Diagnostics!
.Where(kv => kv.Key.StartsWith(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix, StringComparison.Ordinal))
.Select(kv => new CredentialReferenceFinding(
CredentialFindingSource.Running, "", s.DriverInstanceId, s.DriverInstanceId, null,
$"{kv.Key[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix.Length..]}: {kv.Value}")))
.ToList();
}
/// The last preflight result, shown by the AdminUI /raw credential banner.
public sealed class CredentialReferencePreflightState
{
private volatile Snapshot _current = new([], null, null);
/// An immutable result.
/// The findings.
/// When the scan ran (null = not yet).
/// A scan failure, if any.
public sealed record Snapshot(IReadOnlyList Findings, DateTime? ScannedAtUtc, string? Error);
/// The current result.
public Snapshot Current => _current;
/// Replaces the result.
/// The new result.
public void Set(Snapshot snapshot) => _current = snapshot;
}
///
/// Runs once after host start on an admin node and logs one Warning per
/// finding (driver, device, field, allowed forms — never a value). Scheduled after ApplicationStarted plus a
/// short delay, off every Akka starter; it needs only the ConfigDb, never ISecretResolver.
///
/// Resolves the ConfigDb factory lazily (absent ⇒ the preflight is skipped).
/// Host lifetime.
/// Where the result is kept for the AdminUI.
/// Logger.
public sealed class CredentialReferencePreflightService(
IServiceProvider services,
IHostApplicationLifetime lifetime,
CredentialReferencePreflightState state,
ILogger logger) : BackgroundService
{
/// Delay after ApplicationStarted before the scan (tests set it to zero).
public TimeSpan StartDelay { get; init; } = TimeSpan.FromSeconds(15);
///
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using (lifetime.ApplicationStarted.Register(() => started.TrySetResult()))
using (stoppingToken.Register(() => started.TrySetCanceled(stoppingToken)))
{
try
{
await started.Task.ConfigureAwait(false);
if (StartDelay > TimeSpan.Zero) await Task.Delay(StartDelay, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
}
await RunOnceAsync(stoppingToken).ConfigureAwait(false);
}
/// One scan + log pass (internal so tests drive it directly).
/// Cancellation.
/// The findings.
internal async Task> RunOnceAsync(CancellationToken ct)
{
var dbFactory = services.GetService>();
if (dbFactory is null)
{
logger.LogDebug("Credential-reference preflight skipped: no ConfigDb on this node.");
return [];
}
try
{
var findings = await new CredentialReferencePreflight(dbFactory).ScanAsync(ct).ConfigureAwait(false);
state.Set(new(findings, DateTime.UtcNow, null));
foreach (var f in findings)
{
logger.LogWarning(
"Credential-reference preflight (lmxopcua#815): {Source} {DriverType} driver '{DriverName}'{Device}: {Problem} "
+ "The deploy gate refuses this; a running driver resolves it only in deprecated warn-only mode. "
+ "See docs/security.md \"Credential references are namespaced per driver\".",
f.Source, f.DriverType, f.DriverName, f.DeviceName is null ? "" : $" device '{f.DeviceName}'", f.Problem);
}
if (findings.Count == 0)
logger.LogInformation("Credential-reference preflight (lmxopcua#815): every stored and deployed reference is namespaced.");
return findings;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Credential-reference preflight (lmxopcua#815) could not read the config DB.");
state.Set(new([], DateTime.UtcNow, "The credential-reference preflight could not read the config DB."));
return [];
}
}
}