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:
Matt Batchelder
2026-03-18 10:27:26 -04:00
parent c2e03de8bb
commit c6d46098dd
77 changed files with 9412 additions and 29 deletions

View 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);
}
}

View 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();
}
}

View 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);
}

View 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);
}
}
}