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,179 @@
using System.Globalization;
using System.Text;
using CsvHelper;
using CsvHelper.Configuration;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Server.Data;
using OTSSignsOrchestrator.Server.Data.Entities;
namespace OTSSignsOrchestrator.Server.Reports;
public class BillingReportService
{
private readonly IServiceProvider _services;
private readonly ILogger<BillingReportService> _logger;
public BillingReportService(IServiceProvider services, ILogger<BillingReportService> logger)
{
_services = services;
_logger = logger;
}
/// <summary>
/// Generates a billing CSV for the given date range.
/// Columns: customer_abbrev, company_name, date, screen_count, plan
/// </summary>
public async Task<byte[]> GenerateBillingCsvAsync(DateOnly from, DateOnly to)
{
await using var scope = _services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
var rows = await db.ScreenSnapshots
.AsNoTracking()
.Include(s => s.Instance)
.ThenInclude(i => i.Customer)
.Where(s => s.SnapshotDate >= from && s.SnapshotDate <= to)
.Select(s => new
{
Abbrev = s.Instance.Customer.Abbreviation,
CompanyName = s.Instance.Customer.CompanyName,
Plan = s.Instance.Customer.Plan,
s.SnapshotDate,
s.ScreenCount,
})
.ToListAsync();
// Group by customer abbreviation + date, sum screen counts across instances
var grouped = rows
.GroupBy(r => new { r.Abbrev, r.SnapshotDate })
.Select(g => new BillingCsvRow
{
CustomerAbbrev = g.Key.Abbrev,
CompanyName = g.First().CompanyName,
Date = g.Key.SnapshotDate,
ScreenCount = g.Sum(r => r.ScreenCount),
Plan = g.First().Plan.ToString(),
})
.OrderBy(r => r.CustomerAbbrev)
.ThenBy(r => r.Date)
.ToList();
_logger.LogInformation("Generated billing CSV: {RowCount} rows for {From} to {To}",
grouped.Count, from, to);
return WriteCsv(grouped);
}
/// <summary>
/// Generates a version drift CSV showing current vs latest Xibo version and OAuth credential age.
/// Columns: abbrev, current_version, latest_version, credential_age_days
/// </summary>
public async Task<byte[]> GenerateVersionDriftCsvAsync()
{
await using var scope = _services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
// Latest health event per instance where check_name = "xibo-version"
var versionEvents = await db.HealthEvents
.AsNoTracking()
.Include(h => h.Instance)
.ThenInclude(i => i.Customer)
.Where(h => h.CheckName == "xibo-version")
.GroupBy(h => h.InstanceId)
.Select(g => g.OrderByDescending(h => h.OccurredAt).First())
.ToListAsync();
// Latest OauthAppRegistry per instance
var latestOauth = await db.OauthAppRegistries
.AsNoTracking()
.GroupBy(o => o.InstanceId)
.Select(g => g.OrderByDescending(o => o.CreatedAt).First())
.ToDictionaryAsync(o => o.InstanceId);
var rows = versionEvents.Select(h =>
{
var parts = ParseVersionMessage(h.Message);
latestOauth.TryGetValue(h.InstanceId, out var oauth);
var credAgeDays = oauth is not null
? (int)(DateTime.UtcNow - oauth.CreatedAt).TotalDays
: -1;
return new VersionDriftCsvRow
{
Abbrev = h.Instance.Customer.Abbreviation,
CurrentVersion = parts.Current,
LatestVersion = parts.Latest,
CredentialAgeDays = credAgeDays,
};
})
.OrderBy(r => r.Abbrev)
.ToList();
_logger.LogInformation("Generated version drift CSV: {RowCount} rows", rows.Count);
return WriteCsv(rows);
}
private static byte[] WriteCsv<T>(IEnumerable<T> records)
{
using var stream = new MemoryStream();
using var writer = new StreamWriter(stream, Encoding.UTF8);
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = true,
});
csv.WriteRecords(records);
writer.Flush();
return stream.ToArray();
}
/// <summary>
/// Parses the health event message to extract current/latest version strings.
/// Expected format from XiboVersionHealthCheck: "Current: X.Y.Z, Latest: A.B.C" or similar.
/// Returns ("unknown", "unknown") if parsing fails.
/// </summary>
private static (string Current, string Latest) ParseVersionMessage(string? message)
{
if (string.IsNullOrWhiteSpace(message))
return ("unknown", "unknown");
var current = "unknown";
var latest = "unknown";
// Try to extract "Current: X.Y.Z" pattern
var currentIdx = message.IndexOf("Current:", StringComparison.OrdinalIgnoreCase);
if (currentIdx >= 0)
{
var afterCurrent = message[(currentIdx + 8)..].Trim();
var end = afterCurrent.IndexOfAny([',', ' ', '\n']);
current = end > 0 ? afterCurrent[..end].Trim() : afterCurrent.Trim();
}
var latestIdx = message.IndexOf("Latest:", StringComparison.OrdinalIgnoreCase);
if (latestIdx >= 0)
{
var afterLatest = message[(latestIdx + 7)..].Trim();
var end = afterLatest.IndexOfAny([',', ' ', '\n']);
latest = end > 0 ? afterLatest[..end].Trim() : afterLatest.Trim();
}
return (current, latest);
}
}
internal sealed class BillingCsvRow
{
public string CustomerAbbrev { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public DateOnly Date { get; set; }
public int ScreenCount { get; set; }
public string Plan { get; set; } = string.Empty;
}
internal sealed class VersionDriftCsvRow
{
public string Abbrev { get; set; } = string.Empty;
public string CurrentVersion { get; set; } = string.Empty;
public string LatestVersion { get; set; } = string.Empty;
public int CredentialAgeDays { get; set; }
}