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

@@ -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()
{