2026-09-13 08:35:17 +00:00
|
|
|
using System.Collections.Concurrent;
|
2026-09-01 22:12:21 +00:00
|
|
|
using Npgsql;
|
|
|
|
|
using NpgsqlTypes;
|
2026-09-01 19:31:27 +00:00
|
|
|
|
|
|
|
|
namespace w4c_workflows.Services;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Factory for creating <see cref="IWorkflowSource"/> instances scoped to a
|
|
|
|
|
/// specific tenant. The caller provides the tenant ID explicitly (from auth
|
|
|
|
|
/// middleware); the factory resolves directories and returns a source bound
|
|
|
|
|
/// to that tenant's workflow directory.
|
|
|
|
|
///
|
2026-09-01 22:12:21 +00:00
|
|
|
/// <b>Two modes</b> (automatic, config-driven):
|
|
|
|
|
/// <list type="number">
|
|
|
|
|
/// <item><b>Forgejo-backed</b> (preferred): when <c>Forgejo:AdminToken</c> is
|
|
|
|
|
/// configured, each tenant's workflow files live in a private Forgejo repo
|
|
|
|
|
/// (<c>workflows-{tenantId}</c>). The factory clones/pulls via
|
|
|
|
|
/// <see cref="ForgejoWorkflowRepoService"/> and returns a
|
|
|
|
|
/// <see cref="PerTenantWorkflowSource"/> backed by the local clone.
|
|
|
|
|
/// This gives tenants real git history, branching, and remote sync.</item>
|
|
|
|
|
/// <item><b>Filesystem-only</b> (fallback): when Forgejo is not configured,
|
|
|
|
|
/// uses plain directory copies seeded from the shared template dir.
|
|
|
|
|
/// Preserves the original behavior for dev/self-hosted setups.</item>
|
|
|
|
|
/// </list>
|
|
|
|
|
///
|
2026-09-01 19:31:27 +00:00
|
|
|
/// Tenant isolation is <b>mandatory</b>: <c>WorkflowSource:CopiesRoot</c> must
|
|
|
|
|
/// be set in configuration. The factory throws at construction time if it is
|
|
|
|
|
/// missing, preventing any request from serving cross-tenant workflows.
|
|
|
|
|
///
|
|
|
|
|
/// Registered as a <b>Singleton</b> (shared config, no per-request state);
|
|
|
|
|
/// the created sources are lightweight and backed by filesystem operations.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class WorkflowSourceFactory
|
|
|
|
|
{
|
|
|
|
|
private readonly ILoggerFactory _loggers;
|
|
|
|
|
private readonly string _copiesRoot;
|
2026-09-01 22:12:21 +00:00
|
|
|
private readonly ForgejoWorkflowRepoService? _forgejo;
|
|
|
|
|
private readonly NpgsqlDataSource? _ds;
|
2026-09-13 16:28:47 +00:00
|
|
|
private readonly GitRunner _git;
|
2026-09-13 08:35:17 +00:00
|
|
|
private readonly TimeSpan _pullInterval;
|
|
|
|
|
|
|
|
|
|
// Per-(tenant, repo) cache of the resolved source + Forgejo login so clone/pull
|
|
|
|
|
// and the login lookup do not run on every authenticated HTTP request. A
|
|
|
|
|
// per-key gate collapses concurrent refreshes into a single clone/pull.
|
|
|
|
|
private readonly ConcurrentDictionary<string, CachedSource> _cache = new(StringComparer.Ordinal);
|
|
|
|
|
private readonly ConcurrentDictionary<string, SemaphoreSlim> _gates = new(StringComparer.Ordinal);
|
|
|
|
|
|
|
|
|
|
private sealed record CachedSource(IWorkflowSource Source, string? Login, DateTime CreatedAt);
|
2026-09-01 19:31:27 +00:00
|
|
|
|
2026-09-01 22:12:21 +00:00
|
|
|
public WorkflowSourceFactory(IConfiguration config, IWebHostEnvironment env, ILoggerFactory loggers,
|
2026-09-13 16:28:47 +00:00
|
|
|
GitRunner git, IHttpClientFactory httpFactory, NpgsqlDataSource? ds = null)
|
2026-09-01 19:31:27 +00:00
|
|
|
{
|
|
|
|
|
_loggers = loggers;
|
2026-09-01 22:12:21 +00:00
|
|
|
_ds = ds;
|
2026-09-13 16:28:47 +00:00
|
|
|
_git = git;
|
2026-09-01 19:31:27 +00:00
|
|
|
|
2026-09-13 08:35:17 +00:00
|
|
|
_pullInterval = TimeSpan.FromSeconds(
|
|
|
|
|
int.TryParse(config["WorkflowSource:PullIntervalSeconds"], out var seconds) && seconds > 0
|
|
|
|
|
? seconds
|
|
|
|
|
: 30);
|
|
|
|
|
|
2026-09-03 16:27:03 +00:00
|
|
|
_copiesRoot = ResolveCopiesRoot(
|
2026-09-02 19:14:59 +00:00
|
|
|
config["WorkflowSource:CopiesRoot"]
|
2026-09-01 19:31:27 +00:00
|
|
|
?? throw new InvalidOperationException(
|
|
|
|
|
"WorkflowSource:CopiesRoot is not configured. " +
|
|
|
|
|
"Per-tenant filesystem isolation is required — set it to a writable " +
|
2026-09-03 16:27:03 +00:00
|
|
|
"directory path (e.g. \"/data/workflow-tenants\" or \".data/workflow-tenants\")."),
|
|
|
|
|
env);
|
2026-09-01 22:12:21 +00:00
|
|
|
|
|
|
|
|
// Create the Forgejo service when admin provisioning is configured.
|
|
|
|
|
var forgejoLogger = loggers.CreateLogger<ForgejoWorkflowRepoService>();
|
2026-09-13 16:28:47 +00:00
|
|
|
var forgejoSvc = new ForgejoWorkflowRepoService(config, env, forgejoLogger, httpFactory);
|
2026-09-01 22:12:21 +00:00
|
|
|
_forgejo = forgejoSvc.IsConfigured ? forgejoSvc : null;
|
|
|
|
|
|
|
|
|
|
if (_forgejo != null)
|
|
|
|
|
_loggers.CreateLogger<WorkflowSourceFactory>()
|
|
|
|
|
.LogInformation("Workflow source: Forgejo-backed mode (owner={Owner})",
|
|
|
|
|
config["Forgejo:Owner"] ?? config["Forgejo:WorkflowRepoOwner"]);
|
|
|
|
|
else
|
|
|
|
|
_loggers.CreateLogger<WorkflowSourceFactory>()
|
|
|
|
|
.LogInformation("Workflow source: filesystem-only mode (no Forgejo admin token)");
|
2026-09-01 19:31:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-01 22:12:21 +00:00
|
|
|
/// <summary>True when Forgejo-backed mode is active.</summary>
|
|
|
|
|
public bool IsForgejoBacked => _forgejo != null;
|
|
|
|
|
|
|
|
|
|
/// <summary>The Forgejo repo service (null when in filesystem-only mode).</summary>
|
|
|
|
|
public ForgejoWorkflowRepoService? Forgejo => _forgejo;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a per-tenant workflow source.
|
|
|
|
|
///
|
|
|
|
|
/// In Forgejo-backed mode: ensures the local clone exists (creating the
|
|
|
|
|
/// Forgejo repo if needed), then returns a source backed by the clone.
|
|
|
|
|
///
|
|
|
|
|
/// In filesystem-only mode: the tenant directory
|
2026-09-02 19:14:59 +00:00
|
|
|
/// <c>{CopiesRoot}/{sanitizedTenantId}/</c> is created empty on first access
|
|
|
|
|
/// (never seeded from shared templates — workflows are strictly per-tenant).
|
2026-09-01 22:12:21 +00:00
|
|
|
/// </summary>
|
2026-09-01 19:31:27 +00:00
|
|
|
public IWorkflowSource Create(string tenantId)
|
|
|
|
|
{
|
2026-09-02 19:14:59 +00:00
|
|
|
var tenantDir = TenantSourceDir(tenantId);
|
2026-09-01 19:31:27 +00:00
|
|
|
var logger = _loggers.CreateLogger<PerTenantWorkflowSource>();
|
2026-09-13 16:28:47 +00:00
|
|
|
return new PerTenantWorkflowSource(tenantId, tenantDir, logger, _git);
|
2026-09-02 19:14:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Resolves the absolute on-disk directory that holds a tenant's workflow
|
|
|
|
|
/// files. In Forgejo-backed mode this is the per-user clone dir; in
|
|
|
|
|
/// filesystem-only mode it is <c>{CopiesRoot}/{sanitizedTenantId}/</c>.
|
|
|
|
|
/// Used by the workflow-file CRUD controller so edits always land in the same
|
|
|
|
|
/// place <see cref="IWorkflowSource"/> reads from.
|
|
|
|
|
/// </summary>
|
2026-09-03 14:44:39 +00:00
|
|
|
public string ResolveTenantSourceDir(string tenantId, string? forgejoLogin = null, string? repoName = null)
|
2026-09-02 19:14:59 +00:00
|
|
|
{
|
|
|
|
|
if (_forgejo != null && !string.IsNullOrWhiteSpace(forgejoLogin))
|
2026-09-03 14:44:39 +00:00
|
|
|
return _forgejo.TenantCloneDir(tenantId, forgejoLogin, repoName);
|
2026-09-02 19:14:59 +00:00
|
|
|
return TenantSourceDir(tenantId);
|
2026-09-01 19:31:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-02 19:14:59 +00:00
|
|
|
/// <summary>The per-tenant filesystem source directory (not Forgejo-backed).</summary>
|
|
|
|
|
public string TenantSourceDir(string tenantId)
|
|
|
|
|
=> Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId)));
|
|
|
|
|
|
2026-09-01 22:12:21 +00:00
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a per-tenant workflow source asynchronously. In Forgejo-backed
|
|
|
|
|
/// mode this ensures the local clone exists (cloning from Forgejo if needed).
|
|
|
|
|
/// In filesystem-only mode, delegates to <see cref="Create"/>.
|
|
|
|
|
/// </summary>
|
2026-09-03 14:44:39 +00:00
|
|
|
public async Task<IWorkflowSource> CreateAsync(string tenantId, string? repoName = null, CancellationToken ct = default)
|
2026-09-13 08:35:17 +00:00
|
|
|
=> (await ResolveAsync(tenantId, repoName, ct)).Source;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Resolves the per-tenant source and its Forgejo login, caching the result
|
|
|
|
|
/// for <c>WorkflowSource:PullIntervalSeconds</c> (default 30s) so clone/pull and
|
|
|
|
|
/// the login lookup run at most once per interval instead of on every request.
|
|
|
|
|
/// Concurrent refreshes for the same tenant+repo are collapsed into one.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<(IWorkflowSource Source, string? Login)> ResolveAsync(
|
|
|
|
|
string tenantId, string? repoName = null, CancellationToken ct = default)
|
2026-09-01 22:12:21 +00:00
|
|
|
{
|
|
|
|
|
if (_forgejo == null)
|
2026-09-13 08:35:17 +00:00
|
|
|
return (Create(tenantId), null);
|
2026-09-01 22:12:21 +00:00
|
|
|
|
2026-09-13 08:35:17 +00:00
|
|
|
var key = tenantId + "|" + (repoName ?? string.Empty);
|
|
|
|
|
if (TryGetFresh(key, out var cached))
|
|
|
|
|
return (cached!.Source, cached.Login);
|
|
|
|
|
|
|
|
|
|
var gate = _gates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
|
|
|
|
|
await gate.WaitAsync(ct);
|
|
|
|
|
try
|
2026-09-01 22:12:21 +00:00
|
|
|
{
|
2026-09-13 08:35:17 +00:00
|
|
|
if (TryGetFresh(key, out cached))
|
|
|
|
|
return (cached!.Source, cached.Login);
|
|
|
|
|
|
|
|
|
|
// Resolve the user's Forgejo login from the tenant ID (forgejo_id).
|
|
|
|
|
// The repo lives under the user's own account: {login}/{workflowRepoName}.
|
|
|
|
|
var login = await ResolveForgejoLoginAsync(tenantId, ct);
|
|
|
|
|
if (string.IsNullOrEmpty(login))
|
|
|
|
|
{
|
|
|
|
|
_loggers.CreateLogger<WorkflowSourceFactory>()
|
|
|
|
|
.LogWarning("Could not resolve Forgejo login for tenant {TenantId}, falling back to filesystem",
|
|
|
|
|
tenantId);
|
|
|
|
|
return (Create(tenantId), null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure the Forgejo repo exists and is cloned locally.
|
|
|
|
|
var cloneDir = await _forgejo.EnsureCloneAsync(tenantId, login, repoName, ct);
|
|
|
|
|
|
|
|
|
|
// Pull latest changes before reading (from the SELECTED repo, not the default).
|
|
|
|
|
await _forgejo.PullAsync(tenantId, login, repoName, ct);
|
|
|
|
|
|
|
|
|
|
var logger = _loggers.CreateLogger<PerTenantWorkflowSource>();
|
2026-09-13 16:28:47 +00:00
|
|
|
var source = new PerTenantWorkflowSource(tenantId, cloneDir, logger, _git);
|
2026-09-13 08:35:17 +00:00
|
|
|
_cache[key] = new CachedSource(source, login, DateTime.UtcNow);
|
|
|
|
|
return (source, login);
|
2026-09-01 22:12:21 +00:00
|
|
|
}
|
2026-09-13 08:35:17 +00:00
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
gate.Release();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-01 22:12:21 +00:00
|
|
|
|
2026-09-13 08:35:17 +00:00
|
|
|
/// <summary>Drops the cached source for a tenant+repo, forcing a fresh clone/pull next time.</summary>
|
|
|
|
|
public void Invalidate(string tenantId, string? repoName = null)
|
|
|
|
|
=> _cache.TryRemove(tenantId + "|" + (repoName ?? string.Empty), out _);
|
2026-09-01 22:12:21 +00:00
|
|
|
|
2026-09-13 08:35:17 +00:00
|
|
|
private bool TryGetFresh(string key, out CachedSource? cached)
|
|
|
|
|
{
|
|
|
|
|
if (_cache.TryGetValue(key, out cached) && DateTime.UtcNow - cached.CreatedAt < _pullInterval)
|
|
|
|
|
return true;
|
|
|
|
|
cached = null;
|
|
|
|
|
return false;
|
2026-09-01 22:12:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Resolves the Forgejo login for a tenant (forgejo_id) from the auth_users table.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<string?> ResolveForgejoLoginAsync(string tenantId, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
if (_ds == null || !long.TryParse(tenantId, out var forgejoId))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await using var conn = await _ds.OpenConnectionAsync(ct);
|
|
|
|
|
await using var cmd = conn.CreateCommand();
|
|
|
|
|
cmd.CommandTimeout = 3;
|
|
|
|
|
cmd.CommandText = "SELECT login FROM auth_users WHERE forgejo_id = @fid LIMIT 1";
|
|
|
|
|
cmd.Parameters.Add(new NpgsqlParameter("@fid", NpgsqlDbType.Bigint) { Value = forgejoId });
|
|
|
|
|
var result = await cmd.ExecuteScalarAsync(ct);
|
|
|
|
|
return result as string;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_loggers.CreateLogger<WorkflowSourceFactory>()
|
|
|
|
|
.LogWarning(ex, "Could not resolve Forgejo login for tenant {TenantId}", tenantId);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 16:27:03 +00:00
|
|
|
/// <summary>
|
|
|
|
|
/// Resolves <c>WorkflowSource:CopiesRoot</c> to an absolute path independent of
|
|
|
|
|
/// the process CWD. A relative value (e.g. <c>../source-copies</c>) is anchored
|
|
|
|
|
/// to the app's content root instead of <see cref="Directory.GetCurrentDirectory"/>,
|
|
|
|
|
/// so a worker started from a container/different working directory still lands on
|
|
|
|
|
/// the same wizard root as the dev run. An absolute value is normalized as-is.
|
|
|
|
|
/// </summary>
|
|
|
|
|
internal static string ResolveCopiesRoot(string raw, IWebHostEnvironment? env)
|
|
|
|
|
{
|
|
|
|
|
if (Path.IsPathRooted(raw))
|
|
|
|
|
return Path.GetFullPath(raw);
|
|
|
|
|
|
|
|
|
|
var basePath = env?.ContentRootPath;
|
|
|
|
|
if (string.IsNullOrWhiteSpace(basePath))
|
|
|
|
|
basePath = Directory.GetCurrentDirectory();
|
|
|
|
|
return Path.GetFullPath(raw, basePath);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 19:31:27 +00:00
|
|
|
private static string Sanitize(string id)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(id))
|
|
|
|
|
return "_";
|
|
|
|
|
var sb = new System.Text.StringBuilder(id.Length);
|
|
|
|
|
foreach (var ch in id)
|
|
|
|
|
sb.Append(char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' ? ch : '_');
|
|
|
|
|
var result = sb.ToString();
|
|
|
|
|
return result.Length > 120 ? result[..120] : result;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Per-tenant workflow YAML source with directory isolation. Each tenant gets
|
|
|
|
|
/// its own directory at <c>{CopiesRoot}/{tenantId}/</c>, initialized from the
|
|
|
|
|
/// shared template directory on first access.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class PerTenantWorkflowSource : IWorkflowSource
|
|
|
|
|
{
|
|
|
|
|
private readonly string _tenantDir;
|
2026-09-13 16:28:47 +00:00
|
|
|
private readonly GitRunner _git;
|
2026-09-01 19:31:27 +00:00
|
|
|
private readonly ILogger<PerTenantWorkflowSource> _logger;
|
|
|
|
|
|
2026-09-13 16:28:47 +00:00
|
|
|
public PerTenantWorkflowSource(string tenantId, string tenantDir, ILogger<PerTenantWorkflowSource> logger, GitRunner git)
|
2026-09-01 19:31:27 +00:00
|
|
|
{
|
|
|
|
|
_tenantDir = tenantDir;
|
2026-09-13 16:28:47 +00:00
|
|
|
_git = git;
|
2026-09-01 19:31:27 +00:00
|
|
|
_logger = logger;
|
|
|
|
|
_logger.LogInformation(
|
2026-09-02 19:14:59 +00:00
|
|
|
"Per-tenant workflow source: tenant={TenantId} dir={Dir}",
|
|
|
|
|
tenantId, _tenantDir);
|
2026-09-01 19:31:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
|
EnsureInitialized();
|
|
|
|
|
return Task.FromResult(ListYamlFiles(_tenantDir));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<string> ReadAsync(string path, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
EnsureInitialized();
|
|
|
|
|
return await File.ReadAllTextAsync(
|
|
|
|
|
Path.Combine(_tenantDir, path.Replace('/', Path.DirectorySeparatorChar)), ct);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 16:28:47 +00:00
|
|
|
public Task<WorkflowSourceState> GetStateAsync(CancellationToken ct = default)
|
2026-09-01 19:31:27 +00:00
|
|
|
{
|
|
|
|
|
EnsureInitialized();
|
2026-09-13 16:28:47 +00:00
|
|
|
return _git.GetStateAsync(_tenantDir, ct);
|
2026-09-01 19:31:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-09-02 19:14:59 +00:00
|
|
|
/// Ensures the tenant directory exists. Workflows are strictly per-tenant:
|
|
|
|
|
/// we never copy shared example templates into a tenant's directory, so a
|
|
|
|
|
/// tenant sees only the definitions that belong to them.
|
2026-09-01 19:31:27 +00:00
|
|
|
/// </summary>
|
|
|
|
|
private void EnsureInitialized()
|
|
|
|
|
{
|
|
|
|
|
if (Directory.Exists(_tenantDir))
|
|
|
|
|
return;
|
|
|
|
|
|
2026-09-02 19:14:59 +00:00
|
|
|
_logger.LogInformation("Initializing (empty) tenant workflow directory {Dir}", _tenantDir);
|
2026-09-03 14:44:39 +00:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
Directory.CreateDirectory(_tenantDir);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Cannot create tenant workflow directory '{_tenantDir}'. " +
|
|
|
|
|
"Check that WorkflowSource:CopiesRoot points to a writable location " +
|
|
|
|
|
$"and that the process has filesystem permissions. {ex.Message}", ex);
|
|
|
|
|
}
|
2026-09-01 19:31:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static IReadOnlyList<WorkflowFile> ListYamlFiles(string dir)
|
|
|
|
|
{
|
|
|
|
|
var files = new List<WorkflowFile>();
|
|
|
|
|
if (!Directory.Exists(dir))
|
|
|
|
|
return files;
|
|
|
|
|
|
|
|
|
|
foreach (var full in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
|
|
|
|
{
|
|
|
|
|
var ext = Path.GetExtension(full);
|
|
|
|
|
if (ext is not (".yaml" or ".yml"))
|
|
|
|
|
continue;
|
|
|
|
|
// Skip .git
|
|
|
|
|
var rel = Path.GetRelativePath(dir, full);
|
|
|
|
|
if (rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
continue;
|
|
|
|
|
files.Add(new WorkflowFile(rel.Replace(Path.DirectorySeparatorChar, '/')));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return files.OrderBy(f => f.Path, StringComparer.Ordinal).ToList();
|
|
|
|
|
}
|
|
|
|
|
}
|