wf real integ.
This commit is contained in:
parent
990a528d67
commit
0ea4277022
29
Controllers/AboutController.cs
Normal file
29
Controllers/AboutController.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using w4c_workflows.Services;
|
||||
|
||||
namespace w4c_workflows.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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 });
|
||||
}
|
||||
}
|
||||
191
Controllers/WorkflowRuntimesController.cs
Normal file
191
Controllers/WorkflowRuntimesController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Workflow runtime registry surface (<c>/api/runtimes</c>): which workflow-api
|
||||
/// instance executes a tenant's workflows. Distinct from
|
||||
/// <see cref="RuntimesController"/> (<c>/api/languages</c>), which reports
|
||||
/// execution <em>languages</em>. Authenticates with the tenant operator key.
|
||||
///
|
||||
/// Phase 1 covers the platform runtime (always present) and self-hosted runtimes
|
||||
/// in <b>inbound</b> mode (public URL + API key). Phase 2 adds the outbound
|
||||
/// runner channel (registration token + WebSocket relay).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/runtimes")]
|
||||
public class WorkflowRuntimesController : ControllerBase
|
||||
{
|
||||
private readonly WorkflowRuntimeStore _store;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<WorkflowRuntimesController> _logger;
|
||||
|
||||
public WorkflowRuntimesController(
|
||||
WorkflowRuntimeStore store,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<WorkflowRuntimesController> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Lists the tenant's runtimes: the platform runtime first (always present),
|
||||
/// then self-hosted runtimes with their cached self-info + online/offline state.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[RequireScope("read")]
|
||||
public async Task<IActionResult> List(CancellationToken ct)
|
||||
{
|
||||
var runtimes = await _store.ListAsync(TenantId, ct);
|
||||
return Ok(runtimes.Select(ToDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects a self-hosted runtime in inbound mode (legacy direct-URL model).
|
||||
/// The API key is stored hashed and never returned.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[RequireScope("manage")]
|
||||
public async Task<IActionResult> 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 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes a self-hosted runtime. The platform runtime cannot be deleted.</summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequireScope("manage")]
|
||||
public async Task<IActionResult> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Tests connectivity to a self-hosted runtime (inbound mode) by hitting its
|
||||
/// <c>/health</c> endpoint. Always returns 200 with a result object (never
|
||||
/// throws on a down/unreachable engine).
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/test")]
|
||||
[RequireScope("manage")]
|
||||
public async Task<IActionResult> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ public class WorkflowsDbContext : DbContext
|
|||
public DbSet<DurableState> DurableStates => Set<DurableState>();
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
public DbSet<TenantWorkflowRepo> WorkflowRepos => Set<TenantWorkflowRepo>();
|
||||
public DbSet<WorkflowRuntime> WorkflowRuntimes => Set<WorkflowRuntime>();
|
||||
|
||||
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<WorkflowRuntime>(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<svc> — no operator key needed
|
||||
|
|
|
|||
|
|
@ -57,6 +57,25 @@ public static class ApiKeyScope
|
|||
public const string Read = "read";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The kind of a workflow runtime (which workflow-api instance executes a
|
||||
/// tenant's workflows).
|
||||
/// </summary>
|
||||
public static class WorkflowRuntimeKind
|
||||
{
|
||||
/// <summary>The built-in platform runtime (multi-tenant, git/Forgejo-backed).</summary>
|
||||
public const string Platform = "platform";
|
||||
/// <summary>A tenant-owned workflows-api instance (self-hosted, Lite mode).</summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A compiled workflow definition. The authoritative source is a YAML file in
|
||||
/// git; this row is the compiled snapshot keyed by <c>git_sha</c>.
|
||||
|
|
@ -238,3 +257,47 @@ public class ApiKey
|
|||
public DateTime? LastUsedAt { get; set; }
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A workflow runtime — a workflow-api instance that can execute a tenant's
|
||||
/// workflows. There is always one virtual <see cref="WorkflowRuntimeKind.Platform"/>
|
||||
/// runtime per tenant (the built-in multi-tenant engine); a tenant may additionally
|
||||
/// connect <see cref="WorkflowRuntimeKind.SelfHosted"/> instances.
|
||||
///
|
||||
/// Connectivity model:
|
||||
/// - <c>platform</c>: local, git/Forgejo-backed, shared filesystem (no endpoint).
|
||||
/// - <c>self-hosted</c> (inbound/legacy): <see cref="Endpoint"/> + <see cref="ApiKeyHash"/>
|
||||
/// — the tenant exposes a public URL and the platform calls it directly.
|
||||
/// - <c>self-hosted</c> (outbound/runner): <see cref="SecretHash"/> only — the runtime
|
||||
/// dials out to the platform over WebSocket and holds the channel; no inbound URL.
|
||||
/// </summary>
|
||||
public class WorkflowRuntime
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
/// <summary>User-facing name (e.g. "Platform" or "my-server").</summary>
|
||||
public required string Label { get; set; }
|
||||
/// <summary><see cref="WorkflowRuntimeKind"/>: "platform" | "self-hosted".</summary>
|
||||
public required string Kind { get; set; }
|
||||
/// <summary>Legacy/inbound URL; null for the platform runtime or outbound-only runtimes.</summary>
|
||||
public string? Endpoint { get; set; }
|
||||
/// <summary>SHA-256 hash of the inbound API key (legacy migration path).</summary>
|
||||
public string? ApiKeyHash { get; set; }
|
||||
/// <summary>SHA-256 hash of the outbound channel secret (runner model).</summary>
|
||||
public string? SecretHash { get; set; }
|
||||
/// <summary><see cref="WorkflowRuntimeStatus"/>: "online" | "offline" | "unknown".</summary>
|
||||
public string Status { get; set; } = WorkflowRuntimeStatus.Unknown;
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
/// <summary>Cached self-info json: { name, version, startedAt, multiTenant }.</summary>
|
||||
public string? InfoJson { get; set; }
|
||||
/// <summary>
|
||||
/// True for the tenant's default <em>self-hosted</em> runtime (used when the
|
||||
/// user has not picked a specific self-hosted runtime). The platform runtime
|
||||
/// is the implicit global fallback (identified by <see cref="Kind"/>), not a
|
||||
/// default among self-hosted runtimes, so its <see cref="IsDefault"/> is false.
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
|
|
|||
54
Program.cs
54
Program.cs
|
|
@ -167,6 +167,11 @@ builder.Services.AddSingleton<IScriptExecutor>(sp =>
|
|||
sp.GetRequiredService<ILogger<AgentScriptExecutor>>()));
|
||||
builder.Services.AddSingleton<RuntimeRegistry>();
|
||||
|
||||
// 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<RuntimeSelfInfoProvider>();
|
||||
builder.Services.AddScoped<WorkflowRuntimeStore>();
|
||||
|
||||
// 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<RemoteServerExecutor>();
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ namespace w4c_workflows.Services;
|
|||
/// </summary>
|
||||
public sealed class ForgejoWorkflowRepoService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="RunGitAsync"/>.
|
||||
/// </summary>
|
||||
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)));
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<string?> 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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<HttpResponseMessage> SendAdminAsync(
|
||||
HttpMethod method, string path, object? body, CancellationToken ct)
|
||||
{
|
||||
|
|
|
|||
72
Services/RuntimeSelfInfo.cs
Normal file
72
Services/RuntimeSelfInfo.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Static self-description of this workflows-api instance, served at
|
||||
/// <c>GET /api/about</c>. 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.
|
||||
/// </summary>
|
||||
public sealed record RuntimeSelfInfo(
|
||||
string Name,
|
||||
string Version,
|
||||
DateTimeOffset StartedAt,
|
||||
bool MultiTenant);
|
||||
|
||||
/// <summary>
|
||||
/// Produces <see cref="RuntimeSelfInfo"/> from configuration + process state.
|
||||
/// <c>name</c> ← <c>Runtime:Name</c> (default <c>APP_NAME</c> / app name),
|
||||
/// <c>version</c> ← assembly informational version, <c>startedAt</c> ← process
|
||||
/// start, <c>multiTenant</c> ← <c>!UseLiteMode</c> (overridable via
|
||||
/// <c>Runtime:MultiTenant</c>).
|
||||
/// </summary>
|
||||
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<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? Assembly.GetExecutingAssembly().GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
|
||||
DateTimeOffset startedAt;
|
||||
try
|
||||
{
|
||||
startedAt = Process.GetCurrentProcess().StartTime.ToUniversalTime();
|
||||
}
|
||||
catch
|
||||
{
|
||||
startedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
var liteMode = config.GetValue<bool>("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;
|
||||
}
|
||||
}
|
||||
196
Services/WorkflowRuntimeStore.cs
Normal file
196
Services/WorkflowRuntimeStore.cs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
159
w4c-workflows-api.Tests/WorkflowRuntimeStoreTests.cs
Normal file
159
w4c-workflows-api.Tests/WorkflowRuntimeStoreTests.cs
Normal file
|
|
@ -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<ArgumentException>(
|
||||
() => store.CreateInboundAsync(tenantId, "bad", "not-a-url", null));
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => 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<InvalidOperationException>(
|
||||
() => store.DeleteAsync(tenantId, platform.Id));
|
||||
|
||||
// Platform is still there.
|
||||
var after = await store.ListAsync(tenantId);
|
||||
Assert.Single(after);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue