w4c-workflows-api/Services/PerTenantWorkflowSource.cs
2026-09-02 01:12:21 +03:00

351 lines
13 KiB
C#

using System.Diagnostics;
using Npgsql;
using NpgsqlTypes;
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.
///
/// <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>
///
/// 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 IConfiguration _config;
private readonly IWebHostEnvironment _env;
private readonly ILoggerFactory _loggers;
private readonly string _copiesRoot;
private readonly string _sharedDir;
private readonly string _repoRoot;
private readonly ForgejoWorkflowRepoService? _forgejo;
private readonly NpgsqlDataSource? _ds;
public WorkflowSourceFactory(IConfiguration config, IWebHostEnvironment env, ILoggerFactory loggers,
NpgsqlDataSource? ds = null)
{
_config = config;
_env = env;
_loggers = loggers;
_ds = ds;
_copiesRoot = config["WorkflowSource:CopiesRoot"]
?? throw new InvalidOperationException(
"WorkflowSource:CopiesRoot is not configured. " +
"Per-tenant filesystem isolation is required — set it to a writable " +
"directory path (e.g. \"/data/workflow-tenants\" or \".data/workflow-tenants\").");
_sharedDir = config["WorkflowSource:SharedDir"] ?? "workflows";
_repoRoot = ResolveRepoRoot(config, env);
// Create the Forgejo service when admin provisioning is configured.
var forgejoLogger = loggers.CreateLogger<ForgejoWorkflowRepoService>();
var forgejoSvc = new ForgejoWorkflowRepoService(config, forgejoLogger);
_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)");
}
/// <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
/// <c>{CopiesRoot}/{sanitizedTenantId}/</c> is created (and seeded from
/// the shared template dir) on first access.
/// </summary>
public IWorkflowSource Create(string tenantId)
{
var safeId = Sanitize(tenantId);
var tenantDir = Path.GetFullPath(Path.Combine(_copiesRoot, safeId));
var sharedRel = _sharedDir;
string sharedDir;
if (Path.IsPathRooted(sharedRel) && Directory.Exists(sharedRel))
sharedDir = Path.GetFullPath(sharedRel);
else
sharedDir = Path.GetFullPath(Path.Combine(_repoRoot, sharedRel));
var logger = _loggers.CreateLogger<PerTenantWorkflowSource>();
return new PerTenantWorkflowSource(tenantId, tenantDir, sharedDir, logger);
}
/// <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>
public async Task<IWorkflowSource> CreateAsync(string tenantId, CancellationToken ct = default)
{
if (_forgejo == null)
return Create(tenantId);
// Resolve the user's Forgejo login from the tenant ID (forgejo_id).
// The repo lives under the user's own account: {login}/workflows-{login}.
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);
}
// Ensure the Forgejo repo exists and is cloned locally.
var cloneDir = await _forgejo.EnsureCloneAsync(login, ct);
// Pull latest changes before reading.
await _forgejo.PullAsync(login, ct);
var logger = _loggers.CreateLogger<PerTenantWorkflowSource>();
return new PerTenantWorkflowSource(tenantId, cloneDir, _sharedDir, logger);
}
/// <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;
}
}
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;
}
private static string ResolveRepoRoot(IConfiguration config, IWebHostEnvironment env)
{
var configured = config["SourceCode:RepoRoot"];
if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
return Path.GetFullPath(configured);
var start = AppContext.BaseDirectory;
if (!string.IsNullOrEmpty(env.ContentRootPath) && Directory.Exists(env.ContentRootPath))
start = env.ContentRootPath;
var walk = Path.GetFullPath(start);
while (true)
{
if (Directory.Exists(Path.Combine(walk, ".git")))
return Path.GetFullPath(walk);
var parent = Directory.GetParent(walk)?.FullName;
if (string.IsNullOrEmpty(parent) || parent == walk)
break;
walk = parent;
}
return Path.GetFullPath(Directory.GetCurrentDirectory());
}
}
/// <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;
private readonly string _sharedDir;
private readonly ILogger<PerTenantWorkflowSource> _logger;
public PerTenantWorkflowSource(string tenantId, string tenantDir, string sharedDir, ILogger<PerTenantWorkflowSource> logger)
{
_tenantDir = tenantDir;
_sharedDir = sharedDir;
_logger = logger;
_logger.LogInformation(
"Per-tenant workflow source: tenant={TenantId} dir={Dir} shared={Shared}",
tenantId, _tenantDir, _sharedDir);
}
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);
}
public WorkflowSourceState GetState()
{
EnsureInitialized();
var head = RunGit(_tenantDir, "rev-parse", "HEAD");
var status = RunGit(_tenantDir, "status", "--porcelain");
return new WorkflowSourceState(
string.IsNullOrWhiteSpace(head) ? null : head,
!string.IsNullOrEmpty(status));
}
/// <summary>
/// Ensures the tenant directory exists and has been seeded from the shared
/// template directory (first-access initialization).
/// </summary>
private void EnsureInitialized()
{
if (Directory.Exists(_tenantDir))
return;
_logger.LogInformation("Initializing tenant workflow directory {Dir} from {Shared}", _tenantDir, _sharedDir);
Directory.CreateDirectory(_tenantDir);
if (!Directory.Exists(_sharedDir))
{
_logger.LogWarning("Shared workflow directory {Shared} does not exist; tenant starts empty", _sharedDir);
return;
}
CopyYamlFiles(_sharedDir, _tenantDir, _logger);
}
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();
}
/// <summary>
/// Copies YAML files from src to dst, skipping .git and non-yaml files.
/// </summary>
private static void CopyYamlFiles(string src, string dst, ILogger logger)
{
foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories))
{
var ext = Path.GetExtension(file);
if (ext is not (".yaml" or ".yml"))
continue;
var rel = Path.GetRelativePath(src, file);
if (rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase))
continue;
var destPath = Path.Combine(dst, rel);
var destDir = Path.GetDirectoryName(destPath);
if (destDir != null && !Directory.Exists(destDir))
Directory.CreateDirectory(destDir);
try
{
File.Copy(file, destPath, overwrite: true);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to copy template file {File}", rel);
}
}
}
private string? RunGit(string workDir, params string[] args)
{
try
{
var psi = new ProcessStartInfo("git")
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var arg in args)
psi.ArgumentList.Add(arg);
using var process = Process.Start(psi);
if (process == null)
return null;
var output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(5000))
{
process.Kill(entireProcessTree: true);
return null;
}
return process.ExitCode == 0 ? output.Trim() : null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "git invocation failed in {Dir}", workDir);
return null;
}
}
}