6ae0fea558
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).
100 lines
4.2 KiB
C#
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; }
|
|
}
|
|
}
|