From 39dad49fb4f0d8a014d03b4ade1a072661996073 Mon Sep 17 00:00:00 2001 From: LuRy Date: Mon, 4 May 2026 12:06:28 +0200 Subject: [PATCH] feat(postgresql): add service plugin --- ...NKS.WebDevConsole.Plugin.PostgreSQL.csproj | 17 + .../PostgreSqlModule.cs | 304 ++++++++++++++++++ .../PostgreSqlPlugin.cs | 62 ++++ NKS.WebDevConsole.Plugin.PostgreSQL/README.md | 17 + .../Resources/icon.svg | 4 + .../plugin.json | 12 + 6 files changed, 416 insertions(+) create mode 100644 NKS.WebDevConsole.Plugin.PostgreSQL/NKS.WebDevConsole.Plugin.PostgreSQL.csproj create mode 100644 NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlModule.cs create mode 100644 NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlPlugin.cs create mode 100644 NKS.WebDevConsole.Plugin.PostgreSQL/README.md create mode 100644 NKS.WebDevConsole.Plugin.PostgreSQL/Resources/icon.svg create mode 100644 NKS.WebDevConsole.Plugin.PostgreSQL/plugin.json diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/NKS.WebDevConsole.Plugin.PostgreSQL.csproj b/NKS.WebDevConsole.Plugin.PostgreSQL/NKS.WebDevConsole.Plugin.PostgreSQL.csproj new file mode 100644 index 0000000..b8f4e37 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.PostgreSQL/NKS.WebDevConsole.Plugin.PostgreSQL.csproj @@ -0,0 +1,17 @@ + + + net9.0 + enable + enable + NKS.WebDevConsole.Plugin.PostgreSQL + NKS.WebDevConsole.Plugin.PostgreSQL + true + + + + + + + + + diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlModule.cs b/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlModule.cs new file mode 100644 index 0000000..db0bed8 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlModule.cs @@ -0,0 +1,304 @@ +using System.Diagnostics; +using System.Threading.Channels; +using CliWrap; +using CliWrap.Buffered; +using Microsoft.Extensions.Logging; +using NKS.WebDevConsole.Core.Interfaces; +using NKS.WebDevConsole.Core.Models; +using NKS.WebDevConsole.Core.Services; + +namespace NKS.WebDevConsole.Plugin.PostgreSQL; + +public sealed class PostgreSqlConfig +{ + public string BinariesRoot { get; set; } = Path.Combine(WdcPaths.BinariesRoot, "postgresql"); + public string DataDir { get; set; } = Path.Combine(WdcPaths.DataRoot, "postgresql"); + public string LogDirectory { get; set; } = Path.Combine(WdcPaths.LogsRoot, "postgresql"); + public string? PostgresPath { get; set; } + public string? PgCtlPath { get; set; } + public string? InitDbPath { get; set; } + public string? PgIsReadyPath { get; set; } + public int Port { get; set; } = 5432; + public int GracefulTimeoutSecs { get; set; } = 15; +} + +public sealed class PostgreSqlModule : IServiceModule, IAsyncDisposable +{ + public string ServiceId => "postgresql"; + public string DisplayName => "PostgreSQL"; + public ServiceType Type => ServiceType.Database; + + private readonly ILogger _logger; + private readonly PostgreSqlConfig _config; + private readonly object _stateLock = new(); + private readonly Channel _logChannel = Channel.CreateBounded( + new BoundedChannelOptions(2000) { FullMode = BoundedChannelFullMode.DropOldest }); + + private Process? _process; + private ServiceState _state = ServiceState.Stopped; + private DateTime? _startTime; + private string LogFile => Path.Combine(_config.LogDirectory, "postgresql.log"); + + public PostgreSqlModule(ILogger logger, PostgreSqlConfig? config = null) + { + _logger = logger; + _config = config ?? new PostgreSqlConfig(); + } + + public Task InitializeAsync(CancellationToken ct) + { + Directory.CreateDirectory(_config.DataDir); + Directory.CreateDirectory(_config.LogDirectory); + DetectBinaries(); + + if (!string.IsNullOrEmpty(_config.PostgresPath) && File.Exists(_config.PostgresPath)) + _logger.LogInformation("Using PostgreSQL: {Path}", _config.PostgresPath); + else + _logger.LogWarning("postgres executable not found"); + + return Task.CompletedTask; + } + + public Task ValidateConfigAsync(CancellationToken ct) + { + if (string.IsNullOrEmpty(_config.PostgresPath) || !File.Exists(_config.PostgresPath)) + return Task.FromResult(new ValidationResult(false, "postgres executable not found")); + if (string.IsNullOrEmpty(_config.PgCtlPath) || !File.Exists(_config.PgCtlPath)) + return Task.FromResult(new ValidationResult(false, "pg_ctl executable not found")); + if (string.IsNullOrEmpty(_config.InitDbPath) || !File.Exists(_config.InitDbPath)) + return Task.FromResult(new ValidationResult(false, "initdb executable not found")); + if (string.IsNullOrEmpty(_config.PgIsReadyPath) || !File.Exists(_config.PgIsReadyPath)) + return Task.FromResult(new ValidationResult(false, "pg_isready executable not found")); + if (_config.Port is < 1 or > 65535) + return Task.FromResult(new ValidationResult(false, $"Invalid PostgreSQL port: {_config.Port}")); + return Task.FromResult(new ValidationResult(true)); + } + + public async Task StartAsync(CancellationToken ct) + { + lock (_stateLock) + { + if (_state is ServiceState.Running or ServiceState.Starting) + throw new InvalidOperationException($"PostgreSQL is already {_state}."); + _state = ServiceState.Starting; + } + + try + { + var validation = await ValidateConfigAsync(ct); + if (!validation.IsValid) + throw new InvalidOperationException($"Config validation failed: {validation.ErrorMessage}"); + + await EnsureDataDirInitializedAsync(ct); + + var args = new[] + { + "-D", _config.DataDir, + "-l", LogFile, + "-o", $"-p {_config.Port} -h 127.0.0.1", + "start" + }; + var result = await Cli.Wrap(_config.PgCtlPath!) + .WithArguments(args) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(ct); + PublishBuffered(result); + + if (result.ExitCode != 0) + throw new InvalidOperationException($"pg_ctl start exited {result.ExitCode}: {result.StandardError.Trim()}"); + + await WaitUntilReadyAsync(ct); + _process = TryAttachPostgresProcess(); + if (_process is not null) + { + _process.EnableRaisingEvents = true; + _process.Exited += OnProcessExited; + DaemonJobObject.AssignProcess(_process); + } + + _startTime = DateTime.UtcNow; + lock (_stateLock) _state = ServiceState.Running; + _logger.LogInformation("PostgreSQL running on port {Port}", _config.Port); + } + catch + { + lock (_stateLock) + { + if (_state != ServiceState.Crashed) + _state = ServiceState.Stopped; + } + throw; + } + } + + public async Task StopAsync(CancellationToken ct) + { + lock (_stateLock) + { + if (_state == ServiceState.Stopped) return; + _state = ServiceState.Stopping; + } + + if (!string.IsNullOrEmpty(_config.PgCtlPath) && File.Exists(_config.PgCtlPath)) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(_config.GracefulTimeoutSecs)); + var result = await Cli.Wrap(_config.PgCtlPath) + .WithArguments(new[] { "-D", _config.DataDir, "stop", "-m", "fast" }) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(cts.Token); + PublishBuffered(result); + } + + _process?.Dispose(); + _process = null; + _startTime = null; + lock (_stateLock) _state = ServiceState.Stopped; + } + + public async Task ReloadAsync(CancellationToken ct) + { + if (string.IsNullOrEmpty(_config.PgCtlPath) || !File.Exists(_config.PgCtlPath)) + throw new InvalidOperationException("pg_ctl executable not found"); + + var result = await Cli.Wrap(_config.PgCtlPath) + .WithArguments(new[] { "-D", _config.DataDir, "reload" }) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(ct); + PublishBuffered(result); + if (result.ExitCode != 0) + throw new InvalidOperationException($"pg_ctl reload exited {result.ExitCode}: {result.StandardError.Trim()}"); + } + + public Task GetStatusAsync(CancellationToken ct) + { + ServiceState state; + int? pid; + lock (_stateLock) + { + if (_state is ServiceState.Running or ServiceState.Starting) + _process ??= TryAttachPostgresProcess(); + if (_state is ServiceState.Running or ServiceState.Starting && _process is null) + _state = ServiceState.Crashed; + state = _state; + pid = _process?.Id; + } + + var (cpu, memory) = ProcessMetricsSampler.Sample(_process); + var uptime = _startTime.HasValue ? DateTime.UtcNow - _startTime.Value : TimeSpan.Zero; + return Task.FromResult(new ServiceStatus(ServiceId, DisplayName, state, pid, cpu, memory, uptime)); + } + + public async Task> GetLogsAsync(int lines, CancellationToken ct) + { + var result = new List(lines); + if (File.Exists(LogFile)) + { + var tail = File.ReadLines(LogFile).TakeLast(lines).ToArray(); + result.AddRange(tail); + } + + while (result.Count < lines && _logChannel.Reader.TryRead(out var line)) + result.Add(line); + + return await Task.FromResult(result); + } + + public ValueTask DisposeAsync() + { + _process?.Dispose(); + return ValueTask.CompletedTask; + } + + private void DetectBinaries() + { + if (!Directory.Exists(_config.BinariesRoot)) return; + var ext = OperatingSystem.IsWindows() ? ".exe" : ""; + var versionDirs = Directory.GetDirectories(_config.BinariesRoot) + .Where(d => !Path.GetFileName(d).StartsWith('.') && !Path.GetFileName(d).EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(d => Path.GetFileName(d), SemverVersionComparer.Instance); + + foreach (var dir in versionDirs) + { + var bin = Path.Combine(dir, "bin"); + var postgres = Path.Combine(bin, "postgres" + ext); + var pgCtl = Path.Combine(bin, "pg_ctl" + ext); + var initDb = Path.Combine(bin, "initdb" + ext); + var pgIsReady = Path.Combine(bin, "pg_isready" + ext); + if (!File.Exists(postgres) || !File.Exists(pgCtl) || !File.Exists(initDb) || !File.Exists(pgIsReady)) + continue; + + _config.PostgresPath = postgres; + _config.PgCtlPath = pgCtl; + _config.InitDbPath = initDb; + _config.PgIsReadyPath = pgIsReady; + return; + } + } + + private async Task EnsureDataDirInitializedAsync(CancellationToken ct) + { + if (File.Exists(Path.Combine(_config.DataDir, "PG_VERSION"))) + return; + + var result = await Cli.Wrap(_config.InitDbPath!) + .WithArguments(new[] { "-D", _config.DataDir, "-A", "trust", "-U", "postgres", "--no-locale" }) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(ct); + PublishBuffered(result); + if (result.ExitCode != 0) + throw new InvalidOperationException($"initdb exited {result.ExitCode}: {result.StandardError.Trim()}"); + } + + private async Task WaitUntilReadyAsync(CancellationToken ct) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20); + while (DateTime.UtcNow < deadline) + { + var result = await Cli.Wrap(_config.PgIsReadyPath!) + .WithArguments(new[] { "-h", "127.0.0.1", "-p", _config.Port.ToString(), "-U", "postgres" }) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(ct); + PublishBuffered(result); + if (result.ExitCode == 0) return; + await Task.Delay(500, ct); + } + throw new TimeoutException($"PostgreSQL did not become ready on port {_config.Port} within 20 seconds."); + } + + private Process? TryAttachPostgresProcess() + { + var pidFile = Path.Combine(_config.DataDir, "postmaster.pid"); + if (!File.Exists(pidFile)) return null; + var first = File.ReadLines(pidFile).FirstOrDefault(); + return int.TryParse(first, out var pid) ? TryGetProcess(pid) : null; + } + + private static Process? TryGetProcess(int pid) + { + try { return Process.GetProcessById(pid); } + catch { return null; } + } + + private void OnProcessExited(object? sender, EventArgs e) + { + lock (_stateLock) + { + if (_state is ServiceState.Stopping or ServiceState.Stopped) + return; + _state = ServiceState.Crashed; + _startTime = null; + } + } + + private void PublishBuffered(BufferedCommandResult result) + { + foreach (var line in SplitLines(result.StandardOutput)) + _logChannel.Writer.TryWrite(line); + foreach (var line in SplitLines(result.StandardError)) + _logChannel.Writer.TryWrite("[ERR] " + line); + } + + private static IEnumerable SplitLines(string text) => + text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); +} diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlPlugin.cs b/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlPlugin.cs new file mode 100644 index 0000000..a7d5a49 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlPlugin.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NKS.WebDevConsole.Core.Interfaces; +using NKS.WebDevConsole.Core.Models; +using NKS.WebDevConsole.Plugin.SDK; + +namespace NKS.WebDevConsole.Plugin.PostgreSQL; + +public sealed class PostgreSqlPlugin : IWdcPlugin, IFrontendPanelProvider +{ + public string Id => "nks.wdc.postgresql"; + public string DisplayName => "PostgreSQL"; + public string Version => "1.0.0"; + + private PostgreSqlModule? _module; + private IDisposable? _binaryInstalledSub; + + public void Initialize(IServiceCollection services, IPluginContext context) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + public async Task StartAsync(IPluginContext context, CancellationToken ct) + { + var logger = context.GetLogger(); + logger.LogInformation("PostgreSQL plugin v{Version} loaded", Version); + + _module = context.ServiceProvider.GetRequiredService(); + await _module.InitializeAsync(ct); + + var bus = context.ServiceProvider.GetService(typeof(IBinaryInstalledEventBus)) + as IBinaryInstalledEventBus; + _binaryInstalledSub = bus?.Subscribe(async evt => + { + if (!string.Equals(evt.App, "postgresql", StringComparison.OrdinalIgnoreCase)) return; + logger.LogInformation( + "BinaryInstalled postgresql {Version} -> re-initializing PostgreSQL module", evt.Version); + if (_module is not null) + await _module.InitializeAsync(CancellationToken.None); + }); + } + + public async Task StopAsync(CancellationToken ct) + { + _binaryInstalledSub?.Dispose(); + _binaryInstalledSub = null; + if (_module is not null) + await _module.StopAsync(ct); + } + + public PluginUiDefinition GetUiDefinition() => + new UiSchemaBuilder(Id) + .Category("Databases") + .Icon("el-icon-coin") + .SetServiceCategory("db", "postgresql") + .AddServiceCard("postgresql") + .AddConfigEditor("postgresql") + .AddLogViewer("postgresql") + .AddMetricsChart("postgresql") + .Build(); +} diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/README.md b/NKS.WebDevConsole.Plugin.PostgreSQL/README.md new file mode 100644 index 0000000..bc48d69 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.PostgreSQL/README.md @@ -0,0 +1,17 @@ +# PostgreSQL Plugin + +Manages a local PostgreSQL instance from WDC-managed binaries. + +## Capabilities + +- Detects `~/.wdc/binaries/postgresql//bin/postgres`. +- Initializes `~/.wdc/data/postgresql` with `initdb`. +- Starts and stops PostgreSQL through `pg_ctl`. +- Checks readiness with `pg_isready`. +- Streams the PostgreSQL log from `~/.wdc/logs/postgresql/postgresql.log`. + +Install binaries through WDC first: + +```bash +wdc binaries install postgresql@18.3 +``` diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/Resources/icon.svg b/NKS.WebDevConsole.Plugin.PostgreSQL/Resources/icon.svg new file mode 100644 index 0000000..60ce8bf --- /dev/null +++ b/NKS.WebDevConsole.Plugin.PostgreSQL/Resources/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/plugin.json b/NKS.WebDevConsole.Plugin.PostgreSQL/plugin.json new file mode 100644 index 0000000..f5de5d5 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.PostgreSQL/plugin.json @@ -0,0 +1,12 @@ +{ + "id": "nks.wdc.postgresql", + "name": "PostgreSQL", + "version": "1.0.0", + "description": "PostgreSQL service module - manages postgres lifecycle with initdb, pg_ctl, pg_isready, generated data directory, and local development defaults.", + "author": "NKS Hub", + "category": "Databases", + "serviceId": "postgresql", + "serviceType": "Database", + "defaultPort": 5432, + "entryAssembly": "NKS.WebDevConsole.Plugin.PostgreSQL.dll" +}