using System.Diagnostics; using Npgsql; using NpgsqlTypes; namespace w4c_workflows.Services; /// /// Factory for creating 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. /// /// Two modes (automatic, config-driven): /// /// Forgejo-backed (preferred): when Forgejo:AdminToken is /// configured, each tenant's workflow files live in a private Forgejo repo /// (workflows-{tenantId}). The factory clones/pulls via /// and returns a /// backed by the local clone. /// This gives tenants real git history, branching, and remote sync. /// Filesystem-only (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. /// /// /// Tenant isolation is mandatory: WorkflowSource:CopiesRoot 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 Singleton (shared config, no per-request state); /// the created sources are lightweight and backed by filesystem operations. /// public sealed class WorkflowSourceFactory { private readonly ILoggerFactory _loggers; private readonly string _copiesRoot; private readonly ForgejoWorkflowRepoService? _forgejo; private readonly NpgsqlDataSource? _ds; public WorkflowSourceFactory(IConfiguration config, IWebHostEnvironment env, ILoggerFactory loggers, NpgsqlDataSource? ds = null) { _loggers = loggers; _ds = ds; _copiesRoot = ResolveCopiesRoot( 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\")."), env); // Create the Forgejo service when admin provisioning is configured. var forgejoLogger = loggers.CreateLogger(); var forgejoSvc = new ForgejoWorkflowRepoService(config, env, forgejoLogger); _forgejo = forgejoSvc.IsConfigured ? forgejoSvc : null; if (_forgejo != null) _loggers.CreateLogger() .LogInformation("Workflow source: Forgejo-backed mode (owner={Owner})", config["Forgejo:Owner"] ?? config["Forgejo:WorkflowRepoOwner"]); else _loggers.CreateLogger() .LogInformation("Workflow source: filesystem-only mode (no Forgejo admin token)"); } /// True when Forgejo-backed mode is active. public bool IsForgejoBacked => _forgejo != null; /// The Forgejo repo service (null when in filesystem-only mode). public ForgejoWorkflowRepoService? Forgejo => _forgejo; /// /// 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 /// {CopiesRoot}/{sanitizedTenantId}/ is created empty on first access /// (never seeded from shared templates — workflows are strictly per-tenant). /// public IWorkflowSource Create(string tenantId) { var tenantDir = TenantSourceDir(tenantId); var logger = _loggers.CreateLogger(); return new PerTenantWorkflowSource(tenantId, tenantDir, logger); } /// /// 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 {CopiesRoot}/{sanitizedTenantId}/. /// Used by the workflow-file CRUD controller so edits always land in the same /// place reads from. /// public string ResolveTenantSourceDir(string tenantId, string? forgejoLogin = null, string? repoName = null) { if (_forgejo != null && !string.IsNullOrWhiteSpace(forgejoLogin)) return _forgejo.TenantCloneDir(tenantId, forgejoLogin, repoName); return TenantSourceDir(tenantId); } /// The per-tenant filesystem source directory (not Forgejo-backed). public string TenantSourceDir(string tenantId) => Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId))); /// /// 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 . /// public async Task CreateAsync(string tenantId, string? repoName = null, 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}/{workflowRepoName}. var login = await ResolveForgejoLoginAsync(tenantId, ct); if (string.IsNullOrEmpty(login)) { _loggers.CreateLogger() .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(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(); return new PerTenantWorkflowSource(tenantId, cloneDir, logger); } /// /// Resolves the Forgejo login for a tenant (forgejo_id) from the auth_users table. /// public async Task 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() .LogWarning(ex, "Could not resolve Forgejo login for tenant {TenantId}", tenantId); return null; } } /// /// Resolves WorkflowSource:CopiesRoot to an absolute path independent of /// the process CWD. A relative value (e.g. ../source-copies) is anchored /// to the app's content root instead of , /// 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. /// 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); } 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; } } /// /// Per-tenant workflow YAML source with directory isolation. Each tenant gets /// its own directory at {CopiesRoot}/{tenantId}/, initialized from the /// shared template directory on first access. /// public sealed class PerTenantWorkflowSource : IWorkflowSource { private readonly string _tenantDir; private readonly ILogger _logger; public PerTenantWorkflowSource(string tenantId, string tenantDir, ILogger logger) { _tenantDir = tenantDir; _logger = logger; _logger.LogInformation( "Per-tenant workflow source: tenant={TenantId} dir={Dir}", tenantId, _tenantDir); } public Task> ListAsync(CancellationToken ct) { ct.ThrowIfCancellationRequested(); EnsureInitialized(); return Task.FromResult(ListYamlFiles(_tenantDir)); } public async Task 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)); } /// /// 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. /// private void EnsureInitialized() { if (Directory.Exists(_tenantDir)) return; _logger.LogInformation("Initializing (empty) tenant workflow directory {Dir}", _tenantDir); 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); } } private static IReadOnlyList ListYamlFiles(string dir) { var files = new List(); 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(); } 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; } } }