Files
ScadaBridge/src/ScadaLink.CLI/CliConfig.cs
T
Joseph Doherty 6ae0fea558 fix(error-handling): close Theme 4 — 18 cancellation / fire-and-forget findings
Async cancellation hygiene, fire-and-forget observability, retry/shutdown
semantics, and audit-row coverage across 9 modules. Highlights:

Cancellation & lifecycle:
- AuditLog-006: SqliteAuditWriter.Dispose hops to thread pool, escaping the
  captured SyncContext that risked sync-over-async deadlock.
- AuditLog-010: SiteAuditTelemetryActor owns a private lifecycle CTS,
  threaded through drain paths instead of CancellationToken.None.
- Comm-019: CentralCommunicationActor adds lifecycle CTS for repo calls.
- Host-019: Migration StartupRetry forwards ApplicationStopping so SIGTERM
  during the bounded-retry window aborts cleanly.

Cursor / retry / counter correctness:
- AuditLog-004: SiteAuditReconciliationActor's cursor now holds at `since`
  when any row's idempotent insert is still being retried (per-EventId
  retry counter, MaxPermanentInsertAttempts=5 escape valve with LogCritical
  abandon). No more silent abandonment of permanently-failing rows.
- ConfigDB-019: Dropped the catch-and-continue on EnsureLookaheadAsync's
  SPLIT loop — by class-doc construction the catch could only mask real
  failures and let the next iteration create permanent partition holes.
- HM-017/018: HealthReportSender + CentralHealthReportLoop snapshot
  per-interval counters before sending, restore via new
  ISiteHealthCollector.AddIntervalCounters on transport failure so counts
  aren't silently lost.

Fire-and-forget / shutdown waits:
- InboundAPI-018: AuditWriteMiddleware observes faulted audit-write tasks
  via OnlyOnFaulted continuation (Warning log; response unchanged).
- SnF-024: StoreAndForwardService.StopAsync awaits in-flight retry sweep
  with a bounded SweepShutdownWaitTimeout (10s).

Leak / refactor:
- Comm-021: SiteStreamGrpcServer.SubscribeInstance wraps Subscribe in its
  own try/catch so a throw doesn't leak the relay actor or _activeStreams
  entry.
- Comm-022: VERIFIED already-closed by Comm-016's dead-code purge.
- CLI-017: BundleCommands' three subcommands delegate to ExecuteCommandAsync
  (auth-failure exit-code contract unified).

Defensive / validation:
- CLI-021: CliConfig.Load wraps file-read/JSON parse so malformed config
  prints a warning and returns defaults instead of crashing the CLI.
- Host-022: ParseLevel emits stderr one-shot warning for unrecognised
  MinimumLevel instead of silently coercing to Information.
- ESG-019: ExternalSystemClient sets HttpClient.Timeout=Infinite so the
  per-call CTS is the sole timeout source (was clipped to 100s by .NET).
- Security-020: New SecurityOptionsValidator (IValidateOptions) rejects
  empty LdapServer/LdapSearchBase with ValidateOnStart.
- DM-019: Lifecycle command timeouts now emit DisableTimedOut/EnableTimedOut/
  DeleteTimedOut audit entries (mirrors DeployFailed pattern).

Plus reconciled stale per-module Open-findings counters that had drifted
from prior sessions.

20+ new regression tests across 11 test projects; build clean; affected
suites all green. README regenerated: 75 open (was 93).
2026-05-28 07:13:28 -04:00

100 lines
4.2 KiB
C#

using System.Text.Json;
namespace ScadaLink.CLI;
/// <summary>
/// Resolved CLI configuration combining config file values, environment variable overrides, and per-invocation credentials.
/// </summary>
public class CliConfig
{
/// <summary>Base URL of the ScadaLink Management API (e.g. http://localhost:9000).</summary>
public string? ManagementUrl { get; set; }
/// <summary>Default output format for CLI commands; defaults to "json".</summary>
public string DefaultFormat { get; set; } = "json";
/// <summary>
/// LDAP username from the <c>SCADALINK_USERNAME</c> environment variable, if set.
/// Credentials are intentionally only sourced from environment variables (or the
/// command line) — never from the config file — so they are not persisted to disk.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// LDAP password from the <c>SCADALINK_PASSWORD</c> environment variable, if set.
/// Provides a safer alternative to <c>--password</c>, which leaks into process
/// listings and shell history.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Loads CLI configuration by merging the config file, environment variables, and credential env vars.
/// </summary>
/// <returns>A populated <see cref="CliConfig"/> instance.</returns>
public static CliConfig Load()
{
var config = new CliConfig();
// Load from config file
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".scadalink", "config.json");
if (File.Exists(configPath))
{
// CLI-021: a malformed (`JsonException`), unreadable
// (`UnauthorizedAccessException`), or otherwise faulted
// (`IOException`) config file must not crash the CLI before any
// command runs — even a command that supplies everything via
// --url/--username/--password/--format on the command line still
// calls Load() and would otherwise inherit the fault. Warn once on
// stderr and fall through to the env-var + command-line precedence
// chain with default settings.
try
{
var json = File.ReadAllText(configPath);
var fileConfig = JsonSerializer.Deserialize<CliConfigFile>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (fileConfig != null)
{
if (!string.IsNullOrEmpty(fileConfig.ManagementUrl))
config.ManagementUrl = fileConfig.ManagementUrl;
if (!string.IsNullOrEmpty(fileConfig.DefaultFormat))
config.DefaultFormat = fileConfig.DefaultFormat;
}
}
catch (Exception ex) when (ex is JsonException || ex is IOException || ex is UnauthorizedAccessException)
{
Console.Error.WriteLine(
$"warning: ignoring malformed or unreadable {configPath}: {ex.Message}");
}
}
// Override from environment variables
var envUrl = Environment.GetEnvironmentVariable("SCADALINK_MANAGEMENT_URL");
if (!string.IsNullOrEmpty(envUrl))
config.ManagementUrl = envUrl;
var envFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
if (!string.IsNullOrEmpty(envFormat))
config.DefaultFormat = envFormat;
// Credentials from environment variables only (never the config file).
var envUsername = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
if (!string.IsNullOrEmpty(envUsername))
config.Username = envUsername;
var envPassword = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
if (!string.IsNullOrEmpty(envPassword))
config.Password = envPassword;
return config;
}
private class CliConfigFile
{
/// <summary>Management API URL from the config file.</summary>
public string? ManagementUrl { get; set; }
/// <summary>Default output format from the config file.</summary>
public string? DefaultFormat { get; set; }
}
}