192 lines
7 KiB
C#
192 lines
7 KiB
C#
|
|
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,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|