diff --git a/Controllers/AboutController.cs b/Controllers/AboutController.cs new file mode 100644 index 0000000..a305f42 --- /dev/null +++ b/Controllers/AboutController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; +using w4c_workflows.Services; + +namespace w4c_workflows.Controllers; + +/// +/// Self-description of this workflows-api instance. Public (no operator key): +/// the frontend uses it to show the platform runtime's name/version in the +/// runtime selector, and self-hosted runtimes report the same shape when they +/// connect. Contains no sensitive data. +/// +[ApiController] +[Route("api/about")] +public class AboutController : ControllerBase +{ + private readonly RuntimeSelfInfoProvider _info; + + public AboutController(RuntimeSelfInfoProvider info) + { + _info = info; + } + + [HttpGet] + public IActionResult Get() + { + var i = _info.Get(); + return Ok(new { i.Name, i.Version, i.StartedAt, i.MultiTenant }); + } +} diff --git a/Controllers/WorkflowRuntimesController.cs b/Controllers/WorkflowRuntimesController.cs new file mode 100644 index 0000000..8bbb887 --- /dev/null +++ b/Controllers/WorkflowRuntimesController.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using w4c_workflows.Filters; +using w4c_workflows.Models; +using w4c_workflows.Services; + +namespace w4c_workflows.Controllers; + +/// +/// Workflow runtime registry surface (/api/runtimes): which workflow-api +/// instance executes a tenant's workflows. Distinct from +/// (/api/languages), which reports +/// execution languages. Authenticates with the tenant operator key. +/// +/// Phase 1 covers the platform runtime (always present) and self-hosted runtimes +/// in inbound mode (public URL + API key). Phase 2 adds the outbound +/// runner channel (registration token + WebSocket relay). +/// +[ApiController] +[Route("api/runtimes")] +public class WorkflowRuntimesController : ControllerBase +{ + private readonly WorkflowRuntimeStore _store; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public WorkflowRuntimesController( + WorkflowRuntimeStore store, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _store = store; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + private string TenantId => (string?)HttpContext.Items["TenantId"] + ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); + + public sealed record CreateInboundRequest(string? Label = null, string? Endpoint = null, string? ApiKey = null); + + /// + /// Lists the tenant's runtimes: the platform runtime first (always present), + /// then self-hosted runtimes with their cached self-info + online/offline state. + /// + [HttpGet] + [RequireScope("read")] + public async Task List(CancellationToken ct) + { + var runtimes = await _store.ListAsync(TenantId, ct); + return Ok(runtimes.Select(ToDto)); + } + + /// + /// Connects a self-hosted runtime in inbound mode (legacy direct-URL model). + /// The API key is stored hashed and never returned. + /// + [HttpPost] + [RequireScope("manage")] + public async Task Create([FromBody] CreateInboundRequest? request, CancellationToken ct) + { + try + { + var runtime = await _store.CreateInboundAsync( + TenantId, + request?.Label ?? "Self-hosted", + request?.Endpoint ?? string.Empty, + request?.ApiKey, + ct); + + _logger.LogInformation("Connected self-hosted runtime {RuntimeId} for tenant {TenantId} (inbound)", + runtime.Id, TenantId); + return Ok(ToDto(runtime)); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + /// Removes a self-hosted runtime. The platform runtime cannot be deleted. + [HttpDelete("{id:guid}")] + [RequireScope("manage")] + public async Task Delete(Guid id, CancellationToken ct) + { + try + { + var deleted = await _store.DeleteAsync(TenantId, id, ct); + if (!deleted) + return NotFound(new { error = "Runtime not found." }); + + _logger.LogInformation("Removed runtime {RuntimeId} for tenant {TenantId}", id, TenantId); + return NoContent(); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + public sealed record TestResultDto(bool Ok, string Message, int? LatencyMs = null); + + /// + /// Tests connectivity to a self-hosted runtime (inbound mode) by hitting its + /// /health endpoint. Always returns 200 with a result object (never + /// throws on a down/unreachable engine). + /// + [HttpPost("{id:guid}/test")] + [RequireScope("manage")] + public async Task Test(Guid id, CancellationToken ct) + { + var runtime = await _store.GetAsync(TenantId, id, ct); + if (runtime == null) + return NotFound(new { error = "Runtime not found." }); + if (runtime.Kind == WorkflowRuntimeKind.Platform) + return Ok(new TestResultDto(true, "Platform runtime is local", 0)); + if (string.IsNullOrWhiteSpace(runtime.Endpoint)) + return Ok(new TestResultDto(false, "This runtime has no inbound endpoint (outbound runner channel).")); + + var sw = Stopwatch.StartNew(); + try + { + using var client = _httpClientFactory.CreateClient("runtime-test"); + client.Timeout = TimeSpan.FromSeconds(10); + using var req = new HttpRequestMessage(HttpMethod.Get, $"{runtime.Endpoint.TrimEnd('/')}/health"); + using var resp = await client.SendAsync(req, ct); + sw.Stop(); + return Ok(new TestResultDto( + resp.IsSuccessStatusCode, + resp.IsSuccessStatusCode + ? $"Runtime reachable in {sw.ElapsedMilliseconds} ms" + : $"Runtime returned HTTP {(int)resp.StatusCode}", + (int)sw.ElapsedMilliseconds)); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + sw.Stop(); + return Ok(new TestResultDto(false, "Connection timed out", (int)sw.ElapsedMilliseconds)); + } + catch (Exception ex) + { + sw.Stop(); + return Ok(new TestResultDto(false, ex.Message, (int)sw.ElapsedMilliseconds)); + } + } + + private static object ToDto(WorkflowRuntime r) + { + string? name = null, version = null, startedAt = null; + bool? multiTenant = null; + if (!string.IsNullOrWhiteSpace(r.InfoJson)) + { + try + { + using var doc = JsonDocument.Parse(r.InfoJson); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + name = doc.RootElement.TryGetProperty("name", out var n) ? n.GetString() : null; + version = doc.RootElement.TryGetProperty("version", out var v) ? v.GetString() : null; + startedAt = doc.RootElement.TryGetProperty("startedAt", out var s) && s.ValueKind == JsonValueKind.String + ? s.GetString() + : null; + multiTenant = doc.RootElement.TryGetProperty("multiTenant", out var m) && m.ValueKind is JsonValueKind.True or JsonValueKind.False + ? m.GetBoolean() + : null; + } + } + catch (JsonException) + { + // best-effort self-info + } + } + + return new + { + r.Id, + r.Label, + r.Kind, + r.Endpoint, + r.Status, + r.IsDefault, + r.LastSeenAt, + r.LastError, + name, + version, + startedAt, + multiTenant, + }; + } +} diff --git a/Data/WorkflowsDbContext.cs b/Data/WorkflowsDbContext.cs index de1059a..3130adb 100644 --- a/Data/WorkflowsDbContext.cs +++ b/Data/WorkflowsDbContext.cs @@ -39,6 +39,7 @@ public class WorkflowsDbContext : DbContext public DbSet DurableStates => Set(); public DbSet ApiKeys => Set(); public DbSet WorkflowRepos => Set(); + public DbSet WorkflowRuntimes => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -154,5 +155,21 @@ public class WorkflowsDbContext : DbContext e.Property(r => r.TenantId).HasMaxLength(120).IsRequired(); e.Property(r => r.RepoName).HasMaxLength(120).IsRequired(); }); + + modelBuilder.Entity(e => + { + e.HasKey(r => r.Id); + e.Property(r => r.Id).ValueGeneratedOnAdd(); + e.Property(r => r.TenantId).HasMaxLength(120).IsRequired(); + e.Property(r => r.Label).HasMaxLength(200).IsRequired(); + e.Property(r => r.Kind).HasMaxLength(32).IsRequired(); + e.Property(r => r.Endpoint).HasMaxLength(500); + e.Property(r => r.ApiKeyHash).HasMaxLength(64); + e.Property(r => r.SecretHash).HasMaxLength(64); + e.Property(r => r.Status).HasMaxLength(32).IsRequired().HasDefaultValue(WorkflowRuntimeStatus.Unknown); + e.Property(r => r.LastError).HasColumnType("text"); + e.Property(r => r.InfoJson).HasColumnType("jsonb"); + e.HasIndex(r => r.TenantId); + }); } } diff --git a/Middleware/AuthMiddleware.cs b/Middleware/AuthMiddleware.cs index bd7e716..30ac021 100644 --- a/Middleware/AuthMiddleware.cs +++ b/Middleware/AuthMiddleware.cs @@ -17,6 +17,7 @@ public class AuthMiddleware { new("/health"), new("/health/ready"), + new("/api/about"), // self-info: name/version/multiTenant, no operator key needed new("/openapi"), new("/scalar"), // interactive API explorer (Scalar) — no operator key needed new("/api/scalar"), // Scalar reference exposed under /api/scalar/ — no operator key needed diff --git a/Models/Entities.cs b/Models/Entities.cs index 184f2c3..ca809eb 100644 --- a/Models/Entities.cs +++ b/Models/Entities.cs @@ -57,6 +57,25 @@ public static class ApiKeyScope public const string Read = "read"; } +/// +/// The kind of a workflow runtime (which workflow-api instance executes a +/// tenant's workflows). +/// +public static class WorkflowRuntimeKind +{ + /// The built-in platform runtime (multi-tenant, git/Forgejo-backed). + public const string Platform = "platform"; + /// A tenant-owned workflows-api instance (self-hosted, Lite mode). + public const string SelfHosted = "self-hosted"; +} + +public static class WorkflowRuntimeStatus +{ + public const string Online = "online"; + public const string Offline = "offline"; + public const string Unknown = "unknown"; +} + /// /// A compiled workflow definition. The authoritative source is a YAML file in /// git; this row is the compiled snapshot keyed by git_sha. @@ -238,3 +257,47 @@ public class ApiKey public DateTime? LastUsedAt { get; set; } public DateTime? RevokedAt { get; set; } } + +/// +/// A workflow runtime — a workflow-api instance that can execute a tenant's +/// workflows. There is always one virtual +/// runtime per tenant (the built-in multi-tenant engine); a tenant may additionally +/// connect instances. +/// +/// Connectivity model: +/// - platform: local, git/Forgejo-backed, shared filesystem (no endpoint). +/// - self-hosted (inbound/legacy): + +/// — the tenant exposes a public URL and the platform calls it directly. +/// - self-hosted (outbound/runner): only — the runtime +/// dials out to the platform over WebSocket and holds the channel; no inbound URL. +/// +public class WorkflowRuntime +{ + public Guid Id { get; set; } + public required string TenantId { get; set; } + /// User-facing name (e.g. "Platform" or "my-server"). + public required string Label { get; set; } + /// : "platform" | "self-hosted". + public required string Kind { get; set; } + /// Legacy/inbound URL; null for the platform runtime or outbound-only runtimes. + public string? Endpoint { get; set; } + /// SHA-256 hash of the inbound API key (legacy migration path). + public string? ApiKeyHash { get; set; } + /// SHA-256 hash of the outbound channel secret (runner model). + public string? SecretHash { get; set; } + /// : "online" | "offline" | "unknown". + public string Status { get; set; } = WorkflowRuntimeStatus.Unknown; + public DateTime? LastSeenAt { get; set; } + public string? LastError { get; set; } + /// Cached self-info json: { name, version, startedAt, multiTenant }. + public string? InfoJson { get; set; } + /// + /// True for the tenant's default self-hosted runtime (used when the + /// user has not picked a specific self-hosted runtime). The platform runtime + /// is the implicit global fallback (identified by ), not a + /// default among self-hosted runtimes, so its is false. + /// + public bool IsDefault { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Program.cs b/Program.cs index 397910c..a1bfbe5 100644 --- a/Program.cs +++ b/Program.cs @@ -167,6 +167,11 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService>())); builder.Services.AddSingleton(); +// Workflow runtime registry (which workflow-api instance runs a tenant's +// workflows): self-info provider (GET /api/about) + the per-tenant runtime store. +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + // S8: remote (SSH) task execution via the w4c-webapi server-console exec endpoint. // Used only when a task carries a `server` reference; local subprocess stays the default. builder.Services.AddSingleton(); @@ -320,6 +325,55 @@ try "CREATE TABLE IF NOT EXISTS workflows.\"WorkflowRepos\" (" + "\"TenantId\" text NOT NULL, \"RepoName\" text NOT NULL, \"UpdatedAt\" timestamptz NULL, " + "CONSTRAINT \"PK_WorkflowRepos\" PRIMARY KEY (\"TenantId\"));"); + // Workflow runtime registry: the platform runtime is seeded per tenant on + // first access (see WorkflowRuntimeStore); self-hosted runtimes are added + // when a tenant connects their own workflows-api instance. + await db.Database.ExecuteSqlRawAsync( + "CREATE TABLE IF NOT EXISTS workflows.\"WorkflowRuntimes\" (" + + "\"Id\" uuid NOT NULL, " + + "\"TenantId\" text NOT NULL, " + + "\"Label\" text NOT NULL, " + + "\"Kind\" text NOT NULL, " + + "\"Endpoint\" text NULL, " + + "\"ApiKeyHash\" text NULL, " + + "\"SecretHash\" text NULL, " + + "\"Status\" text NOT NULL DEFAULT 'unknown', " + + "\"LastSeenAt\" timestamptz NULL, " + + "\"LastError\" text NULL, " + + "\"InfoJson\" jsonb NULL, " + + "\"IsDefault\" boolean NOT NULL DEFAULT FALSE, " + + "\"CreatedAt\" timestamptz NOT NULL, " + + "\"UpdatedAt\" timestamptz NOT NULL, " + + "CONSTRAINT \"PK_WorkflowRuntimes\" PRIMARY KEY (\"Id\"));"); + await db.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS \"IX_WorkflowRuntimes_TenantId\" ON workflows.\"WorkflowRuntimes\" (\"TenantId\");"); + } + else + { + // Lite mode (self-hosted): SQLite has no schemas and maps Guid→TEXT, + // DateTime→TEXT, bool→INTEGER. The platform runtime row is irrelevant here + // (a self-hosted runtime talks to the platform over the outbound channel), + // but the table must still exist so the registry service can be used + // uniformly; it is left empty in Lite mode. + await db.Database.ExecuteSqlRawAsync( + "CREATE TABLE IF NOT EXISTS \"WorkflowRuntimes\" (" + + "\"Id\" TEXT NOT NULL, " + + "\"TenantId\" TEXT NOT NULL, " + + "\"Label\" TEXT NOT NULL, " + + "\"Kind\" TEXT NOT NULL, " + + "\"Endpoint\" TEXT NULL, " + + "\"ApiKeyHash\" TEXT NULL, " + + "\"SecretHash\" TEXT NULL, " + + "\"Status\" TEXT NOT NULL DEFAULT 'unknown', " + + "\"LastSeenAt\" TEXT NULL, " + + "\"LastError\" TEXT NULL, " + + "\"InfoJson\" TEXT NULL, " + + "\"IsDefault\" INTEGER NOT NULL DEFAULT 0, " + + "\"CreatedAt\" TEXT NOT NULL, " + + "\"UpdatedAt\" TEXT NOT NULL, " + + "CONSTRAINT \"PK_WorkflowRuntimes\" PRIMARY KEY (\"Id\"));"); + await db.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS \"IX_WorkflowRuntimes_TenantId\" ON \"WorkflowRuntimes\" (\"TenantId\");"); } } catch (Exception ex) diff --git a/Services/ForgejoWorkflowRepoService.cs b/Services/ForgejoWorkflowRepoService.cs index 9fec2af..0d731de 100644 --- a/Services/ForgejoWorkflowRepoService.cs +++ b/Services/ForgejoWorkflowRepoService.cs @@ -20,6 +20,13 @@ namespace w4c_workflows.Services; /// public sealed class ForgejoWorkflowRepoService { + /// + /// Hard ceiling for any single git invocation. Without it, `git pull`/`git clone` + /// against an unreachable remote (e.g. a stale Forgejo base URL) hangs forever, + /// blocking the request that triggered it. See . + /// + private static readonly TimeSpan GitOperationTimeout = TimeSpan.FromSeconds(20); + private readonly string _forgejoBase; private readonly string? _forgejoToken; private readonly string? _forgejoAdminToken; @@ -192,6 +199,14 @@ public sealed class ForgejoWorkflowRepoService if (Directory.Exists(Path.Combine(tenantDir, ".git"))) { + // The clone already exists, but its remote `origin` may point at an old + // Forgejo base (e.g. after the server location changed from + // forgejo.wiz4chat.com to localhost). Pulling from a dead origin hangs + // indefinitely (no timeout), blocking every request that runs the + // post-auth middleware. Reconcile origin to the configured base so + // subsequent pulls target the live server. Best-effort: never fail the + // request over a remote rewrite. + await ReconcileOriginAsync(tenantDir, login, repoName, ct); _logger.LogDebug("Local clone for login {Login} already exists at {Dir}", login, tenantDir); return tenantDir; } @@ -231,9 +246,26 @@ public sealed class ForgejoWorkflowRepoService if (process == null) throw new InvalidOperationException("git clone failed to start"); - var stdout = await process.StandardOutput.ReadToEndAsync(ct); - var stderr = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); + using var cloneTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + cloneTimeout.CancelAfter(GitOperationTimeout); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + try + { + await process.WaitForExitAsync(cloneTimeout.Token); + } + catch (OperationCanceledException) + { + TryKill(process); + _logger.LogWarning("Clone {FullName} timed out after {Timeout}s", fullName, GitOperationTimeout.TotalSeconds); + throw new InvalidOperationException( + $"git clone of {fullName} timed out after {GitOperationTimeout.TotalSeconds}s."); + } + + var stdout = await stdoutTask; + var stderr = await stderrTask; if (process.ExitCode != 0) { @@ -248,6 +280,11 @@ public sealed class ForgejoWorkflowRepoService // Clean up any baked-in extra header from clone config. UnsetLocalConfig(tenantDir, "http.extraheader"); + // Pin the remote to the configured Forgejo base (git infers origin from + // the clone URL, but an explicit set-url guarantees it matches the current + // configuration even if the base URL changed between clones). + await RunGitAsync(tenantDir, ct, "remote", "set-url", "origin", cloneUrl); + // NOTE: we deliberately do NOT seed shared example templates into a fresh // workflow repo. Workflows are strictly per-tenant: a tenant starts with // exactly the content of their own repo (empty for a freshly-created repo) @@ -329,6 +366,12 @@ public sealed class ForgejoWorkflowRepoService public string TenantCloneDir(string tenantId, string login, string? repoName = null) => Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId), OwnerRepoSlug(login, repoName))); + /// + /// Runs git with a hard wall-clock timeout. Default git has no connect/transfer + /// timeout, so a dead or unreachable remote (e.g. a stale Forgejo base URL after + /// the server moved) would otherwise block the calling request forever. On timeout + /// the whole process tree is killed and null is returned instead of hanging. + /// private async Task RunGitAsync(string workDir, CancellationToken ct, params string[] args) { try @@ -346,10 +389,26 @@ public sealed class ForgejoWorkflowRepoService using var process = Process.Start(psi); if (process == null) return null; - var stdout = await process.StandardOutput.ReadToEndAsync(ct); - var stderr = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(GitOperationTimeout); + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + // Timed out (or the request was aborted) — kill the process tree so a + // hung git transport can't leak or keep a subsequent request blocked. + TryKill(process); + return null; + } + + var stdout = await stdoutTask; + var stderr = await stderrTask; return process.ExitCode == 0 ? stdout : stderr; } catch (Exception ex) @@ -359,6 +418,50 @@ public sealed class ForgejoWorkflowRepoService } } + /// + /// Ensures the clone's remote `origin` points at the currently-configured Forgejo + /// base. Used when a clone already exists but may have been created against an old + /// Forgejo location (e.g. forgejo.wiz4chat.com → localhost). A stale origin makes + /// `git pull` hang against a dead host. Best-effort and non-blocking on failure. + /// + private async Task ReconcileOriginAsync(string tenantDir, string login, string? repoName, CancellationToken ct) + { + var fullName = RepoFullNameForLogin(login, repoName); + var desired = $"{_forgejoBase}/{fullName}.git"; + + try + { + var current = await RunGitAsync(tenantDir, ct, "remote", "get-url", "origin"); + if (string.IsNullOrWhiteSpace(current)) + return; + current = current.Trim(); + if (current.Equals(desired, StringComparison.OrdinalIgnoreCase)) + return; + + _logger.LogInformation( + "Reconciling workflow repo origin {Dir}: {Current} -> {Desired}", + tenantDir, current, desired); + await RunGitAsync(tenantDir, ct, "remote", "set-url", "origin", desired); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not reconcile origin for {Dir}", tenantDir); + } + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + // Best-effort cleanup. + } + } + private async Task SendAdminAsync( HttpMethod method, string path, object? body, CancellationToken ct) { diff --git a/Services/RuntimeSelfInfo.cs b/Services/RuntimeSelfInfo.cs new file mode 100644 index 0000000..0ea942b --- /dev/null +++ b/Services/RuntimeSelfInfo.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using System.Reflection; + +namespace w4c_workflows.Services; + +/// +/// Static self-description of this workflows-api instance, served at +/// GET /api/about. Used by the platform to show a runtime's name/version in +/// the runtime selector and by self-hosted runtimes to report themselves when they +/// connect over the outbound channel. +/// +public sealed record RuntimeSelfInfo( + string Name, + string Version, + DateTimeOffset StartedAt, + bool MultiTenant); + +/// +/// Produces from configuration + process state. +/// name ← Runtime:Name (default APP_NAME / app name), +/// version ← assembly informational version, startedAt ← process +/// start, multiTenant ← !UseLiteMode (overridable via +/// Runtime:MultiTenant). +/// +public sealed class RuntimeSelfInfoProvider +{ + private readonly RuntimeSelfInfo _info; + + public RuntimeSelfInfoProvider(IConfiguration config) + { + var name = FirstNonEmpty( + config["Runtime:Name"], + config["APP_NAME"], + "w4c-workflows-api")!; + + var version = + Assembly.GetExecutingAssembly() + .GetCustomAttribute()?.InformationalVersion + ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() + ?? "unknown"; + + DateTimeOffset startedAt; + try + { + startedAt = Process.GetCurrentProcess().StartTime.ToUniversalTime(); + } + catch + { + startedAt = DateTimeOffset.UtcNow; + } + + var liteMode = config.GetValue("UseLiteMode"); + var multiTenant = !liteMode; + if (config["Runtime:MultiTenant"] is { Length: > 0 } raw && + bool.TryParse(raw, out var parsed)) + { + multiTenant = parsed; + } + + _info = new RuntimeSelfInfo(name, version, startedAt, multiTenant); + } + + public RuntimeSelfInfo Get() => _info; + + private static string? FirstNonEmpty(params string?[] values) + { + foreach (var value in values) + if (!string.IsNullOrWhiteSpace(value)) + return value.Trim(); + return null; + } +} diff --git a/Services/WorkflowRuntimeStore.cs b/Services/WorkflowRuntimeStore.cs new file mode 100644 index 0000000..80dd3ff --- /dev/null +++ b/Services/WorkflowRuntimeStore.cs @@ -0,0 +1,196 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using w4c_workflows.Data; +using w4c_workflows.Models; + +namespace w4c_workflows.Services; + +/// +/// Persistence + lifecycle for the tenant's workflow runtimes (the registry +/// behind /api/runtimes). Every tenant always has a virtual +/// platform runtime (seeded on first access); a tenant may additionally +/// connect self-hosted 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. +/// +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; + } + + /// + /// 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. + /// + public async Task> 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(1 + selfHosted.Count) { platform }; + result.AddRange(selfHosted); + return result; + } + + public Task GetAsync(string tenantId, Guid id, CancellationToken ct = default) + => _db.WorkflowRuntimes + .AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == id && r.TenantId == tenantId, ct); + + /// + /// Creates a self-hosted runtime in inbound 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. + /// + public async Task 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; + } + + /// + /// 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. + /// + public async Task 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; + } + + /// + /// Seeds (or refreshes) the tenant's virtual platform runtime. The platform + /// runtime is always because it is + /// local to this process. + /// + private async Task 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; + } +} diff --git a/w4c-workflows-api.Tests/WorkflowRuntimeStoreTests.cs b/w4c-workflows-api.Tests/WorkflowRuntimeStoreTests.cs new file mode 100644 index 0000000..d8eafb7 --- /dev/null +++ b/w4c-workflows-api.Tests/WorkflowRuntimeStoreTests.cs @@ -0,0 +1,159 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using w4c_workflows.Data; +using w4c_workflows.Models; +using w4c_workflows.Services; +using Xunit; + +namespace w4c_workflows.Tests; + +[Collection("WorkflowsPostgres")] +public class WorkflowRuntimeStoreTests +{ + private readonly WorkflowsPostgresFixture _fixture; + + public WorkflowRuntimeStoreTests(WorkflowsPostgresFixture fixture) + { + _fixture = fixture; + } + + private static WorkflowRuntimeStore Store(WorkflowsDbContext db) => + new(db, new RuntimeSelfInfoProvider(new ConfigurationBuilder().Build())); + + private static string Tenant() => "t" + Guid.NewGuid().ToString("N")[..12]; + + private static string Sha256(string raw) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant(); + + [Fact] + public async Task List_seeds_one_platform_runtime_per_tenant() + { + var tenantId = Tenant(); + await using var db = _fixture.CreateContext(); + var store = Store(db); + + var first = await store.ListAsync(tenantId); + var platform = Assert.Single(first); + Assert.Equal(WorkflowRuntimeKind.Platform, platform.Kind); + Assert.Equal(WorkflowRuntimeStatus.Online, platform.Status); + Assert.False(platform.IsDefault); // platform is the implicit fallback, not a "default" self-hosted runtime + Assert.NotNull(platform.InfoJson); + + // Idempotent: listing again must NOT create a second platform runtime. + var second = await store.ListAsync(tenantId); + var platformAgain = Assert.Single(second); + Assert.Equal(platform.Id, platformAgain.Id); + } + + [Fact] + public async Task Platform_runtime_is_isolated_per_tenant() + { + var tenantA = Tenant(); + var tenantB = Tenant(); + await using var db = _fixture.CreateContext(); + var store = Store(db); + + var a = await store.ListAsync(tenantA); + var b = await store.ListAsync(tenantB); + + var platformA = Assert.Single(a); + var platformB = Assert.Single(b); + Assert.NotEqual(platformA.Id, platformB.Id); + + // Tenant A must never see tenant B's runtimes. + await store.CreateInboundAsync(tenantB, "B engine", "https://b.example.com", "secret-b"); + var aAfter = await store.ListAsync(tenantA); + Assert.Single(aAfter); + } + + [Fact] + public async Task CreateInboundAsync_hashes_key_and_sets_default_on_first() + { + var tenantId = Tenant(); + await using var db = _fixture.CreateContext(); + var store = Store(db); + + var first = await store.CreateInboundAsync(tenantId, "my-server", "https://wf.example.com/", "raw-key"); + Assert.Equal(WorkflowRuntimeKind.SelfHosted, first.Kind); + Assert.Equal("https://wf.example.com", first.Endpoint); // trailing slash trimmed + Assert.True(first.IsDefault); + Assert.NotNull(first.ApiKeyHash); + Assert.NotEqual("raw-key", first.ApiKeyHash); + Assert.Equal(Sha256("raw-key"), first.ApiKeyHash); + + // Second self-hosted runtime is not default. + var second = await store.CreateInboundAsync(tenantId, "second", "https://wf2.example.com", null); + Assert.False(second.IsDefault); + Assert.Null(second.ApiKeyHash); + } + + [Fact] + public async Task CreateInboundAsync_rejects_invalid_endpoint() + { + var tenantId = Tenant(); + await using var db = _fixture.CreateContext(); + var store = Store(db); + + await Assert.ThrowsAsync( + () => store.CreateInboundAsync(tenantId, "bad", "not-a-url", null)); + await Assert.ThrowsAsync( + () => store.CreateInboundAsync(tenantId, "bad", "ftp://example.com", null)); + } + + [Fact] + public async Task DeleteAsync_removes_self_hosted_and_reassigns_default() + { + var tenantId = Tenant(); + await using var db = _fixture.CreateContext(); + var store = Store(db); + + var first = await store.CreateInboundAsync(tenantId, "first", "https://a.example.com", null); + var second = await store.CreateInboundAsync(tenantId, "second", "https://b.example.com", null); + Assert.True(first.IsDefault); + Assert.False(second.IsDefault); + + // Deleting the default reassigns it to the remaining self-hosted runtime. + Assert.True(await store.DeleteAsync(tenantId, first.Id)); + var remaining = await store.GetAsync(tenantId, second.Id); + Assert.NotNull(remaining); + Assert.True(remaining!.IsDefault); + } + + [Fact] + public async Task CreateInboundAsync_is_idempotent_per_endpoint() + { + var tenantId = Tenant(); + await using var db = _fixture.CreateContext(); + var store = Store(db); + + var first = await store.CreateInboundAsync(tenantId, "first", "https://wf.example.com/", "key"); + var second = await store.CreateInboundAsync(tenantId, "second", "https://wf.example.com", "other"); + + // Same normalized endpoint → same row, no duplicate. + Assert.Equal(first.Id, second.Id); + + var all = await store.ListAsync(tenantId); + Assert.Equal(2, all.Count); // platform + the single self-hosted runtime + Assert.Single(all, r => r.Kind == WorkflowRuntimeKind.SelfHosted); + } + + [Fact] + public async Task DeleteAsync_never_deletes_platform_runtime() + { + var tenantId = Tenant(); + await using var db = _fixture.CreateContext(); + var store = Store(db); + + var runtimes = await store.ListAsync(tenantId); + var platform = Assert.Single(runtimes); + + await Assert.ThrowsAsync( + () => store.DeleteAsync(tenantId, platform.Id)); + + // Platform is still there. + var after = await store.ListAsync(tenantId); + Assert.Single(after); + } +} diff --git a/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs b/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs index 65134e0..4dac6ff 100644 --- a/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs +++ b/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs @@ -48,6 +48,26 @@ public sealed class WorkflowsPostgresFixture : IAsyncLifetime "CREATE TABLE IF NOT EXISTS workflows.\"WorkflowRepos\" (" + "\"TenantId\" text NOT NULL, \"RepoName\" text NOT NULL, \"UpdatedAt\" timestamptz NULL, " + "CONSTRAINT \"PK_WorkflowRepos\" PRIMARY KEY (\"TenantId\"));"); + // Workflow runtime registry (mirrors Program.cs startup ordering). + await context.Database.ExecuteSqlRawAsync( + "CREATE TABLE IF NOT EXISTS workflows.\"WorkflowRuntimes\" (" + + "\"Id\" uuid NOT NULL, " + + "\"TenantId\" text NOT NULL, " + + "\"Label\" text NOT NULL, " + + "\"Kind\" text NOT NULL, " + + "\"Endpoint\" text NULL, " + + "\"ApiKeyHash\" text NULL, " + + "\"SecretHash\" text NULL, " + + "\"Status\" text NOT NULL DEFAULT 'unknown', " + + "\"LastSeenAt\" timestamptz NULL, " + + "\"LastError\" text NULL, " + + "\"InfoJson\" jsonb NULL, " + + "\"IsDefault\" boolean NOT NULL DEFAULT FALSE, " + + "\"CreatedAt\" timestamptz NOT NULL, " + + "\"UpdatedAt\" timestamptz NOT NULL, " + + "CONSTRAINT \"PK_WorkflowRuntimes\" PRIMARY KEY (\"Id\"));"); + await context.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS \"IX_WorkflowRuntimes_TenantId\" ON workflows.\"WorkflowRuntimes\" (\"TenantId\");"); } public async Task DisposeAsync()