197 lines
7.8 KiB
C#
197 lines
7.8 KiB
C#
|
|
using System.Text.Json;
|
||
|
|
using Microsoft.EntityFrameworkCore;
|
||
|
|
using w4c_workflows.Data;
|
||
|
|
using w4c_workflows.Models;
|
||
|
|
|
||
|
|
namespace w4c_workflows.Services;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Persistence + lifecycle for the tenant's workflow runtimes (the registry
|
||
|
|
/// behind <c>/api/runtimes</c>). Every tenant always has a virtual
|
||
|
|
/// <c>platform</c> runtime (seeded on first access); a tenant may additionally
|
||
|
|
/// connect <c>self-hosted</c> runtimes, either inbound (public URL + API key) or
|
||
|
|
/// outbound (runner channel — see Phase 2). Only the platform runtime is
|
||
|
|
/// multi-tenant; self-hosted runtimes are Lite-mode single-tenant instances.
|
||
|
|
/// </summary>
|
||
|
|
public sealed class WorkflowRuntimeStore
|
||
|
|
{
|
||
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||
|
|
{
|
||
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||
|
|
};
|
||
|
|
|
||
|
|
private readonly WorkflowsDbContext _db;
|
||
|
|
private readonly RuntimeSelfInfoProvider _selfInfo;
|
||
|
|
|
||
|
|
public WorkflowRuntimeStore(WorkflowsDbContext db, RuntimeSelfInfoProvider selfInfo)
|
||
|
|
{
|
||
|
|
_db = db;
|
||
|
|
_selfInfo = selfInfo;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Lists the tenant's runtimes: the platform runtime first (always present),
|
||
|
|
/// then the tenant's self-hosted runtimes (default first, then by label).
|
||
|
|
/// Refreshes the platform runtime's cached self-info on every call so the
|
||
|
|
/// version shown in the UI tracks deploys/restarts.
|
||
|
|
/// </summary>
|
||
|
|
public async Task<IReadOnlyList<WorkflowRuntime>> ListAsync(string tenantId, CancellationToken ct = default)
|
||
|
|
{
|
||
|
|
var platform = await EnsurePlatformAsync(tenantId, ct);
|
||
|
|
var selfHosted = await _db.WorkflowRuntimes
|
||
|
|
.AsNoTracking()
|
||
|
|
.Where(r => r.TenantId == tenantId && r.Kind != WorkflowRuntimeKind.Platform)
|
||
|
|
.OrderByDescending(r => r.IsDefault)
|
||
|
|
.ThenBy(r => r.Label)
|
||
|
|
.ToListAsync(ct);
|
||
|
|
|
||
|
|
var result = new List<WorkflowRuntime>(1 + selfHosted.Count) { platform };
|
||
|
|
result.AddRange(selfHosted);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
public Task<WorkflowRuntime?> GetAsync(string tenantId, Guid id, CancellationToken ct = default)
|
||
|
|
=> _db.WorkflowRuntimes
|
||
|
|
.AsNoTracking()
|
||
|
|
.FirstOrDefaultAsync(r => r.Id == id && r.TenantId == tenantId, ct);
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Creates a self-hosted runtime in <b>inbound</b> mode (the legacy direct-URL
|
||
|
|
/// model): the tenant exposes a public URL and the platform calls it directly.
|
||
|
|
/// The API key is hashed (never stored in plaintext). If this is the tenant's
|
||
|
|
/// first self-hosted runtime it becomes the default. Idempotent per endpoint:
|
||
|
|
/// an existing self-hosted runtime with the same endpoint is returned as-is
|
||
|
|
/// instead of inserting a duplicate.
|
||
|
|
/// </summary>
|
||
|
|
public async Task<WorkflowRuntime> CreateInboundAsync(
|
||
|
|
string tenantId, string label, string endpoint, string? apiKey, CancellationToken ct = default)
|
||
|
|
{
|
||
|
|
var cleanEndpoint = (endpoint ?? string.Empty).Trim().TrimEnd('/');
|
||
|
|
if (string.IsNullOrWhiteSpace(cleanEndpoint))
|
||
|
|
throw new ArgumentException("Endpoint is required", nameof(endpoint));
|
||
|
|
if (!Uri.TryCreate(cleanEndpoint, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
|
||
|
|
throw new ArgumentException("Endpoint must be an absolute http(s) URL", nameof(endpoint));
|
||
|
|
|
||
|
|
// Idempotent: never create a duplicate row for the same endpoint (e.g. the
|
||
|
|
// legacy-config migration racing across two tabs).
|
||
|
|
var existing = await _db.WorkflowRuntimes
|
||
|
|
.FirstOrDefaultAsync(r => r.TenantId == tenantId
|
||
|
|
&& r.Kind == WorkflowRuntimeKind.SelfHosted
|
||
|
|
&& r.Endpoint == cleanEndpoint, ct);
|
||
|
|
if (existing != null)
|
||
|
|
return existing;
|
||
|
|
|
||
|
|
var hasSelfHosted = await _db.WorkflowRuntimes
|
||
|
|
.AnyAsync(r => r.TenantId == tenantId && r.Kind == WorkflowRuntimeKind.SelfHosted, ct);
|
||
|
|
|
||
|
|
var now = DateTime.UtcNow;
|
||
|
|
var runtime = new WorkflowRuntime
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
TenantId = tenantId,
|
||
|
|
Label = string.IsNullOrWhiteSpace(label) ? "Self-hosted" : label.Trim(),
|
||
|
|
Kind = WorkflowRuntimeKind.SelfHosted,
|
||
|
|
Endpoint = cleanEndpoint,
|
||
|
|
ApiKeyHash = string.IsNullOrWhiteSpace(apiKey) ? null : ApiKeyService.Hash(apiKey.Trim()),
|
||
|
|
Status = WorkflowRuntimeStatus.Unknown,
|
||
|
|
IsDefault = !hasSelfHosted,
|
||
|
|
CreatedAt = now,
|
||
|
|
UpdatedAt = now,
|
||
|
|
};
|
||
|
|
|
||
|
|
_db.WorkflowRuntimes.Add(runtime);
|
||
|
|
await _db.SaveChangesAsync(ct);
|
||
|
|
return runtime;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Deletes a self-hosted runtime. The platform runtime can never be deleted.
|
||
|
|
/// When the deleted runtime was the default, the oldest remaining self-hosted
|
||
|
|
/// runtime (if any) becomes the default.
|
||
|
|
/// </summary>
|
||
|
|
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken ct = default)
|
||
|
|
{
|
||
|
|
var runtime = await _db.WorkflowRuntimes
|
||
|
|
.FirstOrDefaultAsync(r => r.Id == id && r.TenantId == tenantId, ct);
|
||
|
|
if (runtime == null)
|
||
|
|
return false;
|
||
|
|
if (runtime.Kind == WorkflowRuntimeKind.Platform)
|
||
|
|
throw new InvalidOperationException("The platform runtime cannot be deleted.");
|
||
|
|
|
||
|
|
var wasDefault = runtime.IsDefault;
|
||
|
|
_db.WorkflowRuntimes.Remove(runtime);
|
||
|
|
await _db.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
if (wasDefault)
|
||
|
|
{
|
||
|
|
var next = await _db.WorkflowRuntimes
|
||
|
|
.OrderBy(r => r.CreatedAt)
|
||
|
|
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Kind == WorkflowRuntimeKind.SelfHosted, ct);
|
||
|
|
if (next != null)
|
||
|
|
{
|
||
|
|
next.IsDefault = true;
|
||
|
|
await _db.SaveChangesAsync(ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Seeds (or refreshes) the tenant's virtual platform runtime. The platform
|
||
|
|
/// runtime is always <see cref="WorkflowRuntimeStatus.Online"/> because it is
|
||
|
|
/// local to this process.
|
||
|
|
/// </summary>
|
||
|
|
private async Task<WorkflowRuntime> EnsurePlatformAsync(string tenantId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var now = DateTime.UtcNow;
|
||
|
|
var infoJson = JsonSerializer.Serialize(_selfInfo.Get(), JsonOptions);
|
||
|
|
|
||
|
|
var platform = await _db.WorkflowRuntimes
|
||
|
|
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Kind == WorkflowRuntimeKind.Platform, ct);
|
||
|
|
|
||
|
|
if (platform == null)
|
||
|
|
{
|
||
|
|
platform = new WorkflowRuntime
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
TenantId = tenantId,
|
||
|
|
Label = "Platform",
|
||
|
|
Kind = WorkflowRuntimeKind.Platform,
|
||
|
|
Status = WorkflowRuntimeStatus.Online,
|
||
|
|
// IsDefault is left false: the platform runtime is the implicit
|
||
|
|
// fallback (kind == "platform"), not a default among the tenant's
|
||
|
|
// self-hosted runtimes.
|
||
|
|
IsDefault = false,
|
||
|
|
InfoJson = infoJson,
|
||
|
|
LastSeenAt = now,
|
||
|
|
CreatedAt = now,
|
||
|
|
UpdatedAt = now,
|
||
|
|
};
|
||
|
|
_db.WorkflowRuntimes.Add(platform);
|
||
|
|
await _db.SaveChangesAsync(ct);
|
||
|
|
return platform;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Refresh cached self-info (version/startedAt change across deploys) and
|
||
|
|
// keep the platform runtime always-online.
|
||
|
|
var changed = false;
|
||
|
|
if (!string.Equals(platform.InfoJson, infoJson, StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
platform.InfoJson = infoJson;
|
||
|
|
changed = true;
|
||
|
|
}
|
||
|
|
if (platform.Status != WorkflowRuntimeStatus.Online)
|
||
|
|
{
|
||
|
|
platform.Status = WorkflowRuntimeStatus.Online;
|
||
|
|
changed = true;
|
||
|
|
}
|
||
|
|
platform.LastSeenAt = now;
|
||
|
|
platform.UpdatedAt = now;
|
||
|
|
if (changed)
|
||
|
|
await _db.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
return platform;
|
||
|
|
}
|
||
|
|
}
|