feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes. - Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging. - Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR. - Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes. - Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots. - Introduce XiboFeatureManifests for hardcoded feature ACLs per role. - Add docker-compose.dev.yml for local development with PostgreSQL setup.
This commit is contained in:
@@ -5,8 +5,12 @@ using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
@@ -81,10 +85,26 @@ public class App : Application
|
||||
window.Activate();
|
||||
Log.Information("MainWindow Show() + Activate() called");
|
||||
|
||||
// Start the SignalR connection (fire-and-forget, reconnect handles failures)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var signalR = Services.GetRequiredService<ServerSignalRService>();
|
||||
await signalR.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to start SignalR connection on startup");
|
||||
}
|
||||
});
|
||||
|
||||
desktop.ShutdownRequested += (_, _) =>
|
||||
{
|
||||
var ssh = Services.GetService<SshConnectionService>();
|
||||
ssh?.Dispose();
|
||||
var signalR = Services.GetService<ServerSignalRService>();
|
||||
signalR?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
};
|
||||
}
|
||||
else
|
||||
@@ -140,6 +160,20 @@ public class App : Application
|
||||
services.AddHttpClient("XiboHealth");
|
||||
services.AddHttpClient("AuthentikApi");
|
||||
|
||||
// ── Server API integration ──────────────────────────────────────────
|
||||
services.AddSingleton<TokenStoreService>();
|
||||
services.AddTransient<AuthHeaderHandler>();
|
||||
|
||||
var serverBaseUrl = config["ServerBaseUrl"] ?? "https://localhost:5001";
|
||||
services.AddRefitClient<IServerApiClient>()
|
||||
.ConfigureHttpClient(c => c.BaseAddress = new Uri(serverBaseUrl))
|
||||
.AddHttpMessageHandler<AuthHeaderHandler>()
|
||||
.AddPolicyHandler(HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))));
|
||||
|
||||
services.AddSingleton<ServerSignalRService>();
|
||||
|
||||
// SSH services (singletons — maintain connections)
|
||||
services.AddSingleton<SshConnectionService>();
|
||||
|
||||
|
||||
@@ -22,4 +22,10 @@ public class LiveStackItem
|
||||
|
||||
/// <summary>Label of the host — convenience property for data-binding.</summary>
|
||||
public string HostLabel => Host?.Label ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side customer ID. Populated when fleet data is loaded from the server API.
|
||||
/// Null when loaded only from local Docker discovery.
|
||||
/// </summary>
|
||||
public Guid? CustomerId { get; set; }
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.2" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
|
||||
<PackageReference Include="SSH.NET" Version="2024.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
152
OTSSignsOrchestrator.Desktop/Services/IServerApiClient.cs
Normal file
152
OTSSignsOrchestrator.Desktop/Services/IServerApiClient.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using Refit;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
// ── DTOs matching server REST API responses ─────────────────────────────────
|
||||
|
||||
public record FleetSummaryDto
|
||||
{
|
||||
public Guid CustomerId { get; init; }
|
||||
public string Abbreviation { get; init; } = string.Empty;
|
||||
public string CompanyName { get; init; } = string.Empty;
|
||||
public string Plan { get; init; } = string.Empty;
|
||||
public int ScreenCount { get; init; }
|
||||
public string HealthStatus { get; init; } = "Unknown";
|
||||
public DateTime? LastHealthCheck { get; init; }
|
||||
public bool HasRunningJob { get; init; }
|
||||
}
|
||||
|
||||
public record CustomerDetailDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Abbreviation { get; init; } = string.Empty;
|
||||
public string CompanyName { get; init; } = string.Empty;
|
||||
public string? AdminEmail { get; init; }
|
||||
public string Plan { get; init; } = string.Empty;
|
||||
public int ScreenCount { get; init; }
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public List<CustomerInstanceDto> Instances { get; init; } = [];
|
||||
public List<CustomerJobDto> ActiveJobs { get; init; } = [];
|
||||
}
|
||||
|
||||
public record CustomerInstanceDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string? XiboUrl { get; init; }
|
||||
public string? DockerStackName { get; init; }
|
||||
public string HealthStatus { get; init; } = "Unknown";
|
||||
public DateTime? LastHealthCheck { get; init; }
|
||||
}
|
||||
|
||||
public record CustomerJobDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string JobType { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime? StartedAt { get; init; }
|
||||
}
|
||||
|
||||
public record CreateJobRequest(Guid CustomerId, string JobType, string? Parameters);
|
||||
|
||||
public record CreateJobResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string JobType { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public record JobDetailDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid CustomerId { get; init; }
|
||||
public string JobType { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string? TriggeredBy { get; init; }
|
||||
public string? Parameters { get; init; }
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime? StartedAt { get; init; }
|
||||
public DateTime? CompletedAt { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public List<JobStepDto> Steps { get; init; } = [];
|
||||
}
|
||||
|
||||
public record JobStepDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string StepName { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string? LogOutput { get; init; }
|
||||
public DateTime? StartedAt { get; init; }
|
||||
public DateTime? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
public record LoginRequest(string Email, string Password);
|
||||
public record RefreshRequest(string RefreshToken);
|
||||
|
||||
public record AuthResponse
|
||||
{
|
||||
public string Token { get; init; } = string.Empty;
|
||||
public string RefreshToken { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public record RefreshResponse
|
||||
{
|
||||
public string Token { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Refit interface ─────────────────────────────────────────────────────────
|
||||
|
||||
[Headers("Accept: application/json")]
|
||||
public interface IServerApiClient
|
||||
{
|
||||
[Get("/api/fleet")]
|
||||
Task<List<FleetSummaryDto>> GetFleetAsync();
|
||||
|
||||
[Get("/api/fleet/{id}")]
|
||||
Task<CustomerDetailDto> GetCustomerDetailAsync(Guid id);
|
||||
|
||||
[Post("/api/jobs")]
|
||||
Task<CreateJobResponse> CreateJobAsync([Body] CreateJobRequest body);
|
||||
|
||||
[Get("/api/jobs/{id}")]
|
||||
Task<JobDetailDto> GetJobAsync(Guid id);
|
||||
|
||||
[Post("/api/auth/login")]
|
||||
Task<AuthResponse> LoginAsync([Body] LoginRequest body);
|
||||
|
||||
[Post("/api/auth/refresh")]
|
||||
Task<RefreshResponse> RefreshAsync([Body] RefreshRequest body);
|
||||
|
||||
[Get("/api/reports/billing")]
|
||||
Task<HttpResponseMessage> GetBillingCsvAsync([Query] DateOnly from, [Query] DateOnly to);
|
||||
|
||||
[Get("/api/reports/fleet-health")]
|
||||
Task<HttpResponseMessage> GetFleetHealthPdfAsync();
|
||||
|
||||
[Post("/api/fleet/bulk/{action}")]
|
||||
Task<HttpResponseMessage> BulkActionAsync(string action);
|
||||
}
|
||||
|
||||
// ── DelegatingHandler for Bearer token injection ────────────────────────────
|
||||
|
||||
public class AuthHeaderHandler : DelegatingHandler
|
||||
{
|
||||
private readonly TokenStoreService _tokenStore;
|
||||
|
||||
public AuthHeaderHandler(TokenStoreService tokenStore)
|
||||
{
|
||||
_tokenStore = tokenStore;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var jwt = _tokenStore.GetJwt();
|
||||
if (!string.IsNullOrEmpty(jwt))
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt);
|
||||
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
112
OTSSignsOrchestrator.Desktop/Services/ServerSignalRService.cs
Normal file
112
OTSSignsOrchestrator.Desktop/Services/ServerSignalRService.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton service managing the persistent SignalR connection to the server's FleetHub.
|
||||
/// All handlers dispatch to the UI thread and republish via <see cref="WeakReferenceMessenger"/>.
|
||||
/// </summary>
|
||||
public sealed class ServerSignalRService : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _connection;
|
||||
private readonly ILogger<ServerSignalRService> _logger;
|
||||
|
||||
public ServerSignalRService(
|
||||
TokenStoreService tokenStore,
|
||||
IConfiguration config,
|
||||
ILogger<ServerSignalRService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var baseUrl = config["ServerBaseUrl"] ?? "https://localhost:5001";
|
||||
|
||||
_connection = new HubConnectionBuilder()
|
||||
.WithUrl($"{baseUrl}/hubs/fleet", options =>
|
||||
{
|
||||
options.AccessTokenProvider = () => Task.FromResult(tokenStore.GetJwt());
|
||||
})
|
||||
.WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(30) })
|
||||
.Build();
|
||||
|
||||
RegisterHandlers();
|
||||
|
||||
_connection.Reconnecting += ex =>
|
||||
{
|
||||
_logger.LogWarning(ex, "SignalR reconnecting...");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_connection.Reconnected += connectionId =>
|
||||
{
|
||||
_logger.LogInformation("SignalR reconnected (connId={ConnectionId})", connectionId);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_connection.Closed += ex =>
|
||||
{
|
||||
_logger.LogWarning(ex, "SignalR connection closed");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the SignalR connection. Call from <c>App.OnFrameworkInitializationCompleted</c>.
|
||||
/// Failures are logged but do not throw — automatic reconnect will retry.
|
||||
/// </summary>
|
||||
public async Task StartAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connection.StartAsync();
|
||||
_logger.LogInformation("SignalR connected to FleetHub");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SignalR initial connection failed — will retry via automatic reconnect");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
try { await _connection.StopAsync(); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Error stopping SignalR connection"); }
|
||||
}
|
||||
|
||||
public HubConnectionState State => _connection.State;
|
||||
|
||||
private void RegisterHandlers()
|
||||
{
|
||||
_connection.On<string, string, string>("SendJobCreated", (jobId, abbrev, jobType) =>
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
WeakReferenceMessenger.Default.Send(
|
||||
new JobCreatedMessage(new(jobId, abbrev, jobType)))));
|
||||
|
||||
_connection.On<string, string, int, string>("SendJobProgressUpdate", (jobId, stepName, pct, logLine) =>
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
WeakReferenceMessenger.Default.Send(
|
||||
new JobProgressUpdateMessage(new(jobId, stepName, pct, logLine)))));
|
||||
|
||||
_connection.On<string, bool, string>("SendJobCompleted", (jobId, success, summary) =>
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
WeakReferenceMessenger.Default.Send(
|
||||
new JobCompletedMessage(new(jobId, success, summary)))));
|
||||
|
||||
_connection.On<string, string>("SendInstanceStatusChanged", (customerId, status) =>
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
WeakReferenceMessenger.Default.Send(
|
||||
new InstanceStatusChangedMessage(new(customerId, status)))));
|
||||
|
||||
_connection.On<string, string>("SendAlertRaised", (severity, message) =>
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
WeakReferenceMessenger.Default.Send(
|
||||
new AlertRaisedMessage(new(severity, message)))));
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
35
OTSSignsOrchestrator.Desktop/Services/SignalRMessages.cs
Normal file
35
OTSSignsOrchestrator.Desktop/Services/SignalRMessages.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>SignalR push messages republished via WeakReferenceMessenger for ViewModel consumption.</summary>
|
||||
|
||||
public sealed class JobCreatedMessage : ValueChangedMessage<JobCreatedMessage.Payload>
|
||||
{
|
||||
public JobCreatedMessage(Payload value) : base(value) { }
|
||||
public record Payload(string JobId, string Abbrev, string JobType);
|
||||
}
|
||||
|
||||
public sealed class JobProgressUpdateMessage : ValueChangedMessage<JobProgressUpdateMessage.Payload>
|
||||
{
|
||||
public JobProgressUpdateMessage(Payload value) : base(value) { }
|
||||
public record Payload(string JobId, string StepName, int Pct, string LogLine);
|
||||
}
|
||||
|
||||
public sealed class JobCompletedMessage : ValueChangedMessage<JobCompletedMessage.Payload>
|
||||
{
|
||||
public JobCompletedMessage(Payload value) : base(value) { }
|
||||
public record Payload(string JobId, bool Success, string Summary);
|
||||
}
|
||||
|
||||
public sealed class InstanceStatusChangedMessage : ValueChangedMessage<InstanceStatusChangedMessage.Payload>
|
||||
{
|
||||
public InstanceStatusChangedMessage(Payload value) : base(value) { }
|
||||
public record Payload(string CustomerId, string Status);
|
||||
}
|
||||
|
||||
public sealed class AlertRaisedMessage : ValueChangedMessage<AlertRaisedMessage.Payload>
|
||||
{
|
||||
public AlertRaisedMessage(Payload value) : base(value) { }
|
||||
public record Payload(string Severity, string Message);
|
||||
}
|
||||
268
OTSSignsOrchestrator.Desktop/Services/TokenStoreService.cs
Normal file
268
OTSSignsOrchestrator.Desktop/Services/TokenStoreService.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Stores and retrieves operator JWT and refresh tokens using the OS credential store.
|
||||
/// Windows: advapi32 Credential Manager; macOS: Security.framework Keychain;
|
||||
/// Linux: AES-encrypted file fallback in AppData.
|
||||
/// </summary>
|
||||
public sealed class TokenStoreService
|
||||
{
|
||||
private const string ServiceName = "OTSSignsOrchestrator";
|
||||
private const string JwtAccount = "operator-jwt";
|
||||
private const string RefreshAccount = "operator-refresh";
|
||||
|
||||
private readonly ILogger<TokenStoreService> _logger;
|
||||
|
||||
public TokenStoreService(ILogger<TokenStoreService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void StoreTokens(string jwt, string refreshToken)
|
||||
{
|
||||
WriteCredential(JwtAccount, jwt);
|
||||
WriteCredential(RefreshAccount, refreshToken);
|
||||
_logger.LogDebug("Tokens stored in OS credential store");
|
||||
}
|
||||
|
||||
public string? GetJwt() => ReadCredential(JwtAccount);
|
||||
|
||||
public string? GetRefreshToken() => ReadCredential(RefreshAccount);
|
||||
|
||||
public void ClearTokens()
|
||||
{
|
||||
DeleteCredential(JwtAccount);
|
||||
DeleteCredential(RefreshAccount);
|
||||
_logger.LogDebug("Tokens cleared from OS credential store");
|
||||
}
|
||||
|
||||
// ── Platform dispatch ────────────────────────────────────────────────────
|
||||
|
||||
private void WriteCredential(string account, string secret)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
WindowsCredentialManager.Write(ServiceName, account, secret);
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
MacKeychain.Write(ServiceName, account, secret);
|
||||
else
|
||||
LinuxEncryptedFile.Write(ServiceName, account, secret);
|
||||
}
|
||||
|
||||
private string? ReadCredential(string account)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return WindowsCredentialManager.Read(ServiceName, account);
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return MacKeychain.Read(ServiceName, account);
|
||||
return LinuxEncryptedFile.Read(ServiceName, account);
|
||||
}
|
||||
|
||||
private void DeleteCredential(string account)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
WindowsCredentialManager.Delete(ServiceName, account);
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
MacKeychain.Delete(ServiceName, account);
|
||||
else
|
||||
LinuxEncryptedFile.Delete(ServiceName, account);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Windows — advapi32.dll Credential Manager
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
private static class WindowsCredentialManager
|
||||
{
|
||||
private const int CredTypeGeneric = 1;
|
||||
private const int CredPersistLocalMachine = 2;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct CREDENTIAL
|
||||
{
|
||||
public uint Flags;
|
||||
public uint Type;
|
||||
public string TargetName;
|
||||
public string Comment;
|
||||
public long LastWritten;
|
||||
public uint CredentialBlobSize;
|
||||
public IntPtr CredentialBlob;
|
||||
public uint Persist;
|
||||
public uint AttributeCount;
|
||||
public IntPtr Attributes;
|
||||
public string TargetAlias;
|
||||
public string UserName;
|
||||
}
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CredWriteW(ref CREDENTIAL credential, uint flags);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CredReadW(string target, uint type, uint flags, out IntPtr credential);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CredDeleteW(string target, uint type, uint flags);
|
||||
|
||||
[DllImport("advapi32.dll")]
|
||||
private static extern void CredFree(IntPtr buffer);
|
||||
|
||||
private static string TargetName(string service, string account) => $"{service}/{account}";
|
||||
|
||||
public static void Write(string service, string account, string secret)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(secret);
|
||||
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
var cred = new CREDENTIAL
|
||||
{
|
||||
Type = CredTypeGeneric,
|
||||
TargetName = TargetName(service, account),
|
||||
UserName = account,
|
||||
CredentialBlob = handle.AddrOfPinnedObject(),
|
||||
CredentialBlobSize = (uint)bytes.Length,
|
||||
Persist = CredPersistLocalMachine,
|
||||
};
|
||||
CredWriteW(ref cred, 0);
|
||||
}
|
||||
finally { handle.Free(); }
|
||||
}
|
||||
|
||||
public static string? Read(string service, string account)
|
||||
{
|
||||
if (!CredReadW(TargetName(service, account), CredTypeGeneric, 0, out var credPtr))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
var cred = Marshal.PtrToStructure<CREDENTIAL>(credPtr);
|
||||
if (cred.CredentialBlobSize == 0 || cred.CredentialBlob == IntPtr.Zero) return null;
|
||||
var bytes = new byte[cred.CredentialBlobSize];
|
||||
Marshal.Copy(cred.CredentialBlob, bytes, 0, bytes.Length);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
finally { CredFree(credPtr); }
|
||||
}
|
||||
|
||||
public static void Delete(string service, string account)
|
||||
=> CredDeleteW(TargetName(service, account), CredTypeGeneric, 0);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// macOS — Security.framework Keychain via /usr/bin/security CLI
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
private static class MacKeychain
|
||||
{
|
||||
public static void Write(string service, string account, string secret)
|
||||
{
|
||||
// Delete first to avoid "duplicate" errors on update
|
||||
Delete(service, account);
|
||||
RunSecurity($"add-generic-password -s \"{service}\" -a \"{account}\" -w \"{EscapeShell(secret)}\" -U");
|
||||
}
|
||||
|
||||
public static string? Read(string service, string account)
|
||||
{
|
||||
var (exitCode, stdout) = RunSecurity($"find-generic-password -s \"{service}\" -a \"{account}\" -w");
|
||||
return exitCode == 0 ? stdout.Trim() : null;
|
||||
}
|
||||
|
||||
public static void Delete(string service, string account)
|
||||
=> RunSecurity($"delete-generic-password -s \"{service}\" -a \"{account}\"");
|
||||
|
||||
private static (int exitCode, string stdout) RunSecurity(string args)
|
||||
{
|
||||
using var proc = new System.Diagnostics.Process();
|
||||
proc.StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "/usr/bin/security",
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
proc.Start();
|
||||
var stdout = proc.StandardOutput.ReadToEnd();
|
||||
proc.WaitForExit(5000);
|
||||
return (proc.ExitCode, stdout);
|
||||
}
|
||||
|
||||
private static string EscapeShell(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Linux — AES-256-GCM encrypted file in ~/.local/share
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
private static class LinuxEncryptedFile
|
||||
{
|
||||
// Machine-specific key derived from machine-id + user name
|
||||
private static byte[] DeriveKey()
|
||||
{
|
||||
var machineId = "linux-default";
|
||||
try
|
||||
{
|
||||
if (File.Exists("/etc/machine-id"))
|
||||
machineId = File.ReadAllText("/etc/machine-id").Trim();
|
||||
}
|
||||
catch { /* fallback */ }
|
||||
|
||||
var material = $"{machineId}:{Environment.UserName}:{ServiceName}";
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(material));
|
||||
}
|
||||
|
||||
private static string FilePath(string service, string account)
|
||||
{
|
||||
var dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
service, "credentials");
|
||||
Directory.CreateDirectory(dir);
|
||||
return Path.Combine(dir, $"{account}.enc");
|
||||
}
|
||||
|
||||
public static void Write(string service, string account, string secret)
|
||||
{
|
||||
var key = DeriveKey();
|
||||
var plaintext = Encoding.UTF8.GetBytes(secret);
|
||||
var nonce = new byte[12];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[16];
|
||||
|
||||
using var aes = new AesGcm(key, 16);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
|
||||
// File format: [12 nonce][16 tag][ciphertext]
|
||||
var output = new byte[12 + 16 + ciphertext.Length];
|
||||
nonce.CopyTo(output, 0);
|
||||
tag.CopyTo(output, 12);
|
||||
ciphertext.CopyTo(output, 28);
|
||||
File.WriteAllBytes(FilePath(service, account), output);
|
||||
}
|
||||
|
||||
public static string? Read(string service, string account)
|
||||
{
|
||||
var path = FilePath(service, account);
|
||||
if (!File.Exists(path)) return null;
|
||||
|
||||
var data = File.ReadAllBytes(path);
|
||||
if (data.Length < 28) return null;
|
||||
|
||||
var nonce = data[..12];
|
||||
var tag = data[12..28];
|
||||
var ciphertext = data[28..];
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
|
||||
using var aes = new AesGcm(DeriveKey(), 16);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
return Encoding.UTF8.GetString(plaintext);
|
||||
}
|
||||
|
||||
public static void Delete(string service, string account)
|
||||
{
|
||||
var path = FilePath(service, account);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
@@ -16,10 +18,18 @@ namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
/// <summary>
|
||||
/// ViewModel for listing, viewing, and managing CMS instances.
|
||||
/// All data is fetched live from Docker Swarm hosts — nothing stored locally.
|
||||
/// Server operations (decommission, suspend, reactivate) go through the REST API.
|
||||
/// Real-time updates arrive via SignalR → WeakReferenceMessenger.
|
||||
/// </summary>
|
||||
public partial class InstancesViewModel : ObservableObject
|
||||
public partial class InstancesViewModel : ObservableObject,
|
||||
IRecipient<AlertRaisedMessage>,
|
||||
IRecipient<InstanceStatusChangedMessage>,
|
||||
IRecipient<JobCreatedMessage>,
|
||||
IRecipient<JobCompletedMessage>
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<InstancesViewModel> _logger;
|
||||
private readonly IServerApiClient? _serverApi;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<LiveStackItem> _instances = new();
|
||||
[ObservableProperty] private LiveStackItem? _selectedInstance;
|
||||
@@ -32,6 +42,10 @@ public partial class InstancesViewModel : ObservableObject
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||
|
||||
// ── P1 Authentik Banner ──────────────────────────────────────────────────
|
||||
[ObservableProperty] private bool _isAuthentikP1BannerVisible;
|
||||
[ObservableProperty] private string _authentikP1Message = string.Empty;
|
||||
|
||||
// ── Container Logs ──────────────────────────────────────────────────────
|
||||
[ObservableProperty] private ObservableCollection<ServiceLogEntry> _logEntries = new();
|
||||
[ObservableProperty] private ObservableCollection<string> _logServiceFilter = new();
|
||||
@@ -54,11 +68,26 @@ public partial class InstancesViewModel : ObservableObject
|
||||
/// </summary>
|
||||
public Func<string, string, Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback the View wires up to show a multi-step confirmation dialog for decommission.
|
||||
/// Parameters: (abbreviation) → returns true if confirmed through all steps.
|
||||
/// </summary>
|
||||
public Func<string, Task<bool>>? ConfirmDecommissionAsync { get; set; }
|
||||
|
||||
private string? _pendingSelectAbbrev;
|
||||
|
||||
public InstancesViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_logger = services.GetRequiredService<ILogger<InstancesViewModel>>();
|
||||
_serverApi = services.GetService<IServerApiClient>();
|
||||
|
||||
// Register for SignalR messages via WeakReferenceMessenger
|
||||
WeakReferenceMessenger.Default.Register<AlertRaisedMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<InstanceStatusChangedMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<JobCreatedMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<JobCompletedMessage>(this);
|
||||
|
||||
_ = RefreshAllAsync();
|
||||
}
|
||||
|
||||
@@ -69,6 +98,49 @@ public partial class InstancesViewModel : ObservableObject
|
||||
public void SetPendingSelection(string abbrev)
|
||||
=> _pendingSelectAbbrev = abbrev;
|
||||
|
||||
// ── SignalR Message Handlers ─────────────────────────────────────────────
|
||||
// These are called on the UI thread (SignalR handlers dispatch via Dispatcher.UIThread).
|
||||
|
||||
void IRecipient<AlertRaisedMessage>.Receive(AlertRaisedMessage message)
|
||||
{
|
||||
var (severity, msg) = message.Value;
|
||||
if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) &&
|
||||
msg.Contains("Authentik", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AuthentikP1Message = msg;
|
||||
IsAuthentikP1BannerVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
void IRecipient<InstanceStatusChangedMessage>.Receive(InstanceStatusChangedMessage message)
|
||||
{
|
||||
var (customerId, status) = message.Value;
|
||||
_logger.LogInformation("Instance status changed: customer={CustomerId} status={Status}", customerId, status);
|
||||
StatusMessage = $"Instance {customerId} status → {status}";
|
||||
// Refresh the list to pick up the new status
|
||||
_ = RefreshAllAsync();
|
||||
}
|
||||
|
||||
void IRecipient<JobCreatedMessage>.Receive(JobCreatedMessage message)
|
||||
{
|
||||
var (jobId, abbrev, jobType) = message.Value;
|
||||
_logger.LogInformation("Job created: {JobId} type={JobType} abbrev={Abbrev}", jobId, jobType, abbrev);
|
||||
StatusMessage = $"Job '{jobType}' created for {abbrev} (id: {jobId[..8]}…)";
|
||||
}
|
||||
|
||||
void IRecipient<JobCompletedMessage>.Receive(JobCompletedMessage message)
|
||||
{
|
||||
var (jobId, success, summary) = message.Value;
|
||||
_logger.LogInformation("Job completed: {JobId} success={Success} summary={Summary}", jobId, success, summary);
|
||||
StatusMessage = success
|
||||
? $"Job {jobId[..8]}… completed: {summary}"
|
||||
: $"Job {jobId[..8]}… failed: {summary}";
|
||||
// Refresh the instance list to reflect changes from the completed job
|
||||
_ = RefreshAllAsync();
|
||||
}
|
||||
|
||||
// ── Load / Refresh ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates all SSH hosts, then calls docker stack ls on each to build the
|
||||
/// live instance list. Only stacks matching *-cms-stack are shown.
|
||||
@@ -112,6 +184,25 @@ public partial class InstancesViewModel : ObservableObject
|
||||
catch (Exception ex) { errors.Add($"{host.Label}: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Enrich with server-side customer IDs if the server API is available
|
||||
if (_serverApi is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fleet = await _serverApi.GetFleetAsync();
|
||||
var lookup = fleet.ToDictionary(f => f.Abbreviation, f => f.CustomerId, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in all)
|
||||
{
|
||||
if (lookup.TryGetValue(item.CustomerAbbrev, out var customerId))
|
||||
item.CustomerId = customerId;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not enrich instances with server fleet data");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(FilterText))
|
||||
all = all.Where(i =>
|
||||
i.StackName.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -371,6 +462,144 @@ public partial class InstancesViewModel : ObservableObject
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
// ── P1 Banner Commands ────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void DismissP1Banner()
|
||||
{
|
||||
IsAuthentikP1BannerVisible = false;
|
||||
AuthentikP1Message = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called from a SignalR <c>AlertRaised</c> handler (runs on a background thread).
|
||||
/// CRITICAL: wraps all property updates with <see cref="Dispatcher.UIThread"/> to
|
||||
/// avoid silent cross-thread exceptions in Avalonia.
|
||||
/// </summary>
|
||||
public void HandleAlertRaised(string severity, string message)
|
||||
{
|
||||
if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) &&
|
||||
message.Contains("Authentik", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
AuthentikP1Message = message;
|
||||
IsAuthentikP1BannerVisible = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server-side Job Commands (decommission, suspend, reactivate) ────────
|
||||
// Desktop has NO direct infrastructure access — all operations go through the server REST API.
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DecommissionAsync(LiveStackItem? instance)
|
||||
{
|
||||
instance ??= SelectedInstance;
|
||||
if (instance is null) return;
|
||||
|
||||
if (instance.CustomerId is null)
|
||||
{
|
||||
StatusMessage = "Cannot decommission: no server-side customer ID available for this instance.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-step confirmation: user must type the abbreviation to confirm
|
||||
if (ConfirmDecommissionAsync is not null)
|
||||
{
|
||||
var confirmed = await ConfirmDecommissionAsync(instance.CustomerAbbrev);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
else if (ConfirmAsync is not null)
|
||||
{
|
||||
var confirmed = await ConfirmAsync(
|
||||
"Decommission Instance",
|
||||
$"Are you sure you want to decommission '{instance.CustomerAbbrev}'?\n\n" +
|
||||
"This will:\n" +
|
||||
" • Remove all Docker services and stack\n" +
|
||||
" • Delete Docker secrets\n" +
|
||||
" • Remove NFS volumes and data\n" +
|
||||
" • Revoke Authentik provider\n" +
|
||||
" • Mark the customer as decommissioned\n\n" +
|
||||
"This action is IRREVERSIBLE.");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
await CreateServerJobAsync(instance, "decommission");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SuspendInstanceAsync(LiveStackItem? instance)
|
||||
{
|
||||
instance ??= SelectedInstance;
|
||||
if (instance is null) return;
|
||||
|
||||
if (instance.CustomerId is null)
|
||||
{
|
||||
StatusMessage = "Cannot suspend: no server-side customer ID available for this instance.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (ConfirmAsync is not null)
|
||||
{
|
||||
var confirmed = await ConfirmAsync(
|
||||
"Suspend Instance",
|
||||
$"Are you sure you want to suspend '{instance.CustomerAbbrev}'?\n\n" +
|
||||
"The instance will be scaled to zero replicas. Data will be preserved.");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
await CreateServerJobAsync(instance, "suspend");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ReactivateInstanceAsync(LiveStackItem? instance)
|
||||
{
|
||||
instance ??= SelectedInstance;
|
||||
if (instance is null) return;
|
||||
|
||||
if (instance.CustomerId is null)
|
||||
{
|
||||
StatusMessage = "Cannot reactivate: no server-side customer ID available for this instance.";
|
||||
return;
|
||||
}
|
||||
|
||||
await CreateServerJobAsync(instance, "reactivate");
|
||||
}
|
||||
|
||||
private async Task CreateServerJobAsync(LiveStackItem instance, string jobType)
|
||||
{
|
||||
if (_serverApi is null)
|
||||
{
|
||||
StatusMessage = "Server API client is not configured.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Requesting '{jobType}' for {instance.CustomerAbbrev}...";
|
||||
try
|
||||
{
|
||||
var response = await _serverApi.CreateJobAsync(
|
||||
new CreateJobRequest(instance.CustomerId!.Value, jobType, null));
|
||||
StatusMessage = $"Job '{jobType}' created (id: {response.Id.ToString()[..8]}…). Status: {response.Status}";
|
||||
_logger.LogInformation("Server job created: {JobId} type={JobType} customer={CustomerId}",
|
||||
response.Id, jobType, instance.CustomerId);
|
||||
}
|
||||
catch (Refit.ApiException ex)
|
||||
{
|
||||
StatusMessage = $"Server error creating '{jobType}' job: {ex.StatusCode} — {ex.Content}";
|
||||
_logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error creating '{jobType}' job: {ex.Message}";
|
||||
_logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId);
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
// ── Details ─────────────────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenDetailsAsync()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user