using System.Diagnostics; using Microsoft.AspNetCore.Hosting; namespace w4c_workflows.Services; /// /// Manages per-tenant private Forgejo repositories for workflow source files. /// Each tenant gets a repo named workflows-{tenantId} under the configured /// Forgejo owner. The repo contains the tenant's workflow YAML definitions and /// their sibling code files (shell scripts, JS, Python, etc.). /// /// On first access the repo is created via the Forgejo admin API, cloned locally, /// and seeded with templates from WorkflowSource:SharedDir. Subsequent calls /// pull remote changes before the sync service reads the files. /// /// The local clone lives at {WorkflowSource:CopiesRoot}/{tenantId}/ — the /// same directory layout the legacy used, so /// the existing and worker runtime need no path /// changes when the backing store switches from plain filesystem to git. /// public sealed class ForgejoWorkflowRepoService { /// /// 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 . /// private static readonly TimeSpan GitOperationTimeout = TimeSpan.FromSeconds(20); private readonly string _forgejoBase; private readonly string? _forgejoToken; private readonly string? _forgejoAdminToken; private readonly string _forgejoOwner; private readonly string _copiesRoot; private readonly ILogger _logger; public ForgejoWorkflowRepoService(IConfiguration config, IWebHostEnvironment env, ILogger logger) { _logger = logger; _forgejoBase = (config["Forgejo:BaseUrl"] ?? "https://forgejo.wiz4chat.com").TrimEnd('/'); _forgejoToken = config["Forgejo:AccessToken"]?.Trim(); _forgejoAdminToken = config["Forgejo:AdminToken"]?.Trim(); // Prefer WorkflowRepoOwner, but treat an explicit empty string as // "not configured" and fall back to Forgejo:Owner. A plain `??` is not // enough: appsettings ships `"WorkflowRepoOwner": ""`, which is non-null // and would win over Forgejo:Owner, silently disabling Forgejo-backed // workflow mode (IsConfigured reads owner as empty). var workflowRepoOwner = config["Forgejo:WorkflowRepoOwner"]?.Trim(); _forgejoOwner = !string.IsNullOrWhiteSpace(workflowRepoOwner) ? workflowRepoOwner : config["Forgejo:Owner"]?.Trim() ?? string.Empty; // Resolve CopiesRoot against the content root (not the process CWD) so a // relative value like "../source-copies" lands on the same wizard root // regardless of the working directory the service was launched from. _copiesRoot = WorkflowSourceFactory.ResolveCopiesRoot( config["WorkflowSource:CopiesRoot"] ?? throw new InvalidOperationException( "WorkflowSource:CopiesRoot is not configured. " + "Set it to a writable directory path (e.g. \"/data/workflow-tenants\")."), env); // The workflow repo name (basename). Default "workflows" → {login}/workflows, // so a tenant's workflows live in a repo whose name contains "workflow". // A per-tenant selection overrides this at runtime. var repoName = config["WorkflowSource:WorkflowRepoName"]?.Trim(); WorkflowRepoName = string.IsNullOrWhiteSpace(repoName) ? "workflows" : repoName; } /// The Forgejo base URL (for building repo links). public string ForgejoBaseUrl => _forgejoBase; /// /// The token used for git transport (clone/pull/push) against the per-user /// private workflow repos. The repos are created and administered with the /// ADMIN token, so that is the credential that actually works against them; /// the plain AccessToken has no read/write grant on a user's private repo and /// Forgejo reports it as "Repository not found". Fall back to the access /// token when no admin token is configured. /// private string GitAuthToken => !string.IsNullOrWhiteSpace(_forgejoAdminToken) ? _forgejoAdminToken : _forgejoToken; /// True when Forgejo admin provisioning is configured. public bool IsConfigured => !string.IsNullOrWhiteSpace(_forgejoAdminToken) && !string.IsNullOrWhiteSpace(_forgejoOwner); /// The repo full name on Forgejo (owner/repo-name). /// /// The repository name (basename) used for a tenant's workflow repo. Default /// is workflows (the repo full name {login}/workflows), so a /// tenant's workflows live in a repo whose name contains "workflow". A tenant /// may select a different repo (e.g. wiz4apps) to share the directory /// with the main-api Source Code explorer; the selection is persisted per /// tenant and overrides this default. /// public string WorkflowRepoName { get; } /// /// Consolidated repo-name resolver. Both the workflows-api engine and the /// webapi source-code explorer operate on a local clone whose directory name is /// the slug of the repo full name ({owner}_{repo}). Deriving full name AND /// slug from a single repo name is what keeps the two services pointing at the /// same directory. /// public static (string RepoFullName, string RepoSlug) ResolveWorkflowRepo(string login, string repoName) { var owner = Sanitize(login.ToLowerInvariant()); var repo = Sanitize(repoName); return ($"{owner}/{repo}", $"{owner}_{repo}"); } /// /// Resolves the Forgejo repo full name for a user given the configured/selected /// workflow repo name: {login}/{repoName}. /// public string RepoFullNameForLogin(string login, string? repoName = null) => ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoFullName; /// /// The source-copies owner-repo slug for a user's workflow repo, matching the /// webapi SourceCodeCopyService layout ({owner}_{repo}, e.g. /// test_workflows). Sharing this exact directory with the webapi /// source-code explorer is what keeps workflow edits visible in the source-code /// view without a manual fetch/pull. /// public string OwnerRepoSlug(string login, string? repoName = null) => ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoSlug; /// /// Ensures the user's Forgejo repo exists. Creates it via the admin API /// if missing (idempotent). Returns the repo full name (owner/repo). /// Throws on hard failures. /// public async Task EnsureRepoAsync(string login, CancellationToken ct = default) { if (!IsConfigured) throw new InvalidOperationException( "Forgejo workflow repo provisioning is not configured. " + "Set Forgejo:AdminToken and Forgejo:Owner (or Forgejo:WorkflowRepoOwner)."); var owner = login.ToLowerInvariant(); var name = Sanitize(WorkflowRepoName); var fullName = $"{owner}/{name}"; try { // Check if repo already exists. using var exists = await SendAdminAsync(HttpMethod.Get, $"/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}", null, ct); if (exists.IsSuccessStatusCode) { _logger.LogDebug("Forgejo workflow repo {FullName} already exists", fullName); return fullName; } // Create the repo. using var create = await SendAdminAsync(HttpMethod.Post, $"/admin/users/{Uri.EscapeDataString(owner)}/repos", new { name, @private = true, auto_init = true, description = $"Workflow definitions for user {login}", }, ct); if (!create.IsSuccessStatusCode) { var body = await create.Content.ReadAsStringAsync(ct); _logger.LogWarning("Forgejo workflow repo create {FullName} -> {Status}: {Body}", fullName, (int)create.StatusCode, body); throw new InvalidOperationException( $"Could not create Forgejo workflow repo {fullName}: {(int)create.StatusCode}"); } _logger.LogInformation("Created Forgejo workflow repo {FullName}", fullName); return fullName; } catch (InvalidOperationException) { throw; } catch (Exception ex) { _logger.LogError(ex, "Forgejo workflow repo ensure failed for {FullName}", fullName); throw new InvalidOperationException( $"Could not create Forgejo workflow repo {fullName}: {ex.Message}"); } } /// /// Ensures a local clone of the user's workflow repo exists. If the repo /// doesn't exist on Forgejo yet, it is created. If the clone directory is /// missing, the repo is cloned. Returns the absolute path to the local clone. /// public async Task EnsureCloneAsync(string tenantId, string login, string? repoName = null, CancellationToken ct = default) { var tenantDir = TenantCloneDir(tenantId, login, repoName); 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; } // Ensure the Forgejo repo exists. var fullName = await EnsureRepoAsync(login, ct); var cloneUrl = $"{_forgejoBase}/{fullName}.git"; Directory.CreateDirectory(tenantDir); // Clone the repo. var psi = new ProcessStartInfo("git") { WorkingDirectory = tenantDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; psi.ArgumentList.Add("clone"); // The per-user workflow repos are created and managed via the Forgejo // ADMIN token, and each is private (owned by the user's own account). // The plain AccessToken has no grant on those private repos (Forgejo // returns 404), so cloning with it makes git fail with "Repository not // found". Use the admin token (fallback to the access token) so the // clone — and the later pull/push — succeed against the private repo. if (!string.IsNullOrWhiteSpace(GitAuthToken)) { psi.ArgumentList.Add("-c"); psi.ArgumentList.Add($"http.extraHeader=Authorization: token {GitAuthToken}"); } psi.ArgumentList.Add("--"); psi.ArgumentList.Add(cloneUrl); psi.ArgumentList.Add("."); using var process = Process.Start(psi); if (process == null) throw new InvalidOperationException("git clone failed to start"); 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) { _logger.LogWarning("Clone {FullName} failed ({Code}): {Err}", fullName, process.ExitCode, stderr.Trim()); throw new InvalidOperationException( string.IsNullOrWhiteSpace(stderr) ? "git clone failed" : stderr.Trim()); } _logger.LogInformation("Cloned workflow repo {FullName} into {Dir}", fullName, tenantDir); // 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) // and builds up their own definitions. Seeding shared examples made every // tenant see the same "predefined" workflows and broke tenant isolation. return tenantDir; } /// /// Pulls the latest changes from the remote. Returns true on success. /// public async Task PullAsync(string tenantId, string login, CancellationToken ct = default) { var tenantDir = TenantCloneDir(tenantId, login); if (!Directory.Exists(Path.Combine(tenantDir, ".git"))) { _logger.LogWarning("No local clone for login {Login}, cannot pull", login); return false; } var token = GitAuthToken; var output = !string.IsNullOrWhiteSpace(token) ? await RunGitAsync(tenantDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "pull", "--ff-only") : await RunGitAsync(tenantDir, ct, "pull", "--ff-only"); if (output == null) { _logger.LogWarning("Pull failed for login {Login}", login); return false; } var ok = !output.Contains("fatal:", StringComparison.OrdinalIgnoreCase) && !output.Contains("Could not resolve host", StringComparison.OrdinalIgnoreCase) && !output.Contains("Permission denied", StringComparison.OrdinalIgnoreCase); _logger.LogInformation("Pull for login {Login}: {Ok} ({Out})", login, ok, output.Trim()); return ok; } /// /// Commits and pushes all changes in the tenant's clone. Used after file /// edits via the source code API to persist changes to Forgejo. /// public async Task<(bool Success, string Output)> CommitAndPushAsync( string tenantId, string login, string message, CancellationToken ct = default) { var tenantDir = TenantCloneDir(tenantId, login); if (!Directory.Exists(Path.Combine(tenantDir, ".git"))) return (false, "No local clone"); await RunGitAsync(tenantDir, ct, "add", "-A"); var commitOutput = await RunGitAsync(tenantDir, ct, "commit", "-m", message); if (commitOutput == null || commitOutput.Contains("nothing to commit", StringComparison.OrdinalIgnoreCase)) return (true, "Nothing to commit"); var token = GitAuthToken; var pushOutput = !string.IsNullOrWhiteSpace(token) ? await RunGitAsync(tenantDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "push") : await RunGitAsync(tenantDir, ct, "push"); var ok = pushOutput != null && !pushOutput.Contains("fatal:", StringComparison.OrdinalIgnoreCase) && !pushOutput.Contains("Permission denied", StringComparison.OrdinalIgnoreCase); return (ok, ok ? pushOutput?.Trim() ?? "Pushed" : pushOutput?.Trim() ?? "Push failed"); } /// /// The absolute path to a tenant's local clone directory — shared with the /// webapi source-code explorer. Layout: /// {CopiesRoot}/{tenantId}/{ownerRepo} where ownerRepo is /// (e.g. source-copies/10/test_workflows-test). /// Both the w4c-workflows-api engine and the webapi SourceCodeController operate /// on this one working copy, so workflow edits and the source-code view stay /// consistent without a manual git pull. /// public string TenantCloneDir(string tenantId, string login, string? repoName = null) => Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId), OwnerRepoSlug(login, repoName))); /// /// 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. /// private async Task RunGitAsync(string workDir, CancellationToken ct, params string[] args) { try { var psi = new ProcessStartInfo("git") { WorkingDirectory = workDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; foreach (var a in args) psi.ArgumentList.Add(a); using var process = Process.Start(psi); if (process == null) return null; 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) { _logger.LogDebug(ex, "git invocation failed in {Dir}", workDir); return null; } } /// /// 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. /// 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 SendAdminAsync( HttpMethod method, string path, object? body, CancellationToken ct) { using var request = new HttpRequestMessage(method, $"{_forgejoBase}/api/v1{path}"); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( "Bearer", _forgejoAdminToken); if (body != null) { request.Content = new StringContent( System.Text.Json.JsonSerializer.Serialize(body, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, }), System.Text.Encoding.UTF8, "application/json"); } using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; return await client.SendAsync(request, ct); } private static void UnsetLocalConfig(string repoRoot, string key) { try { var psi = new ProcessStartInfo("git") { WorkingDirectory = repoRoot, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; psi.ArgumentList.Add("config"); psi.ArgumentList.Add("--local"); psi.ArgumentList.Add("--unset"); psi.ArgumentList.Add(key); using var p = Process.Start(psi); p?.WaitForExit(3000); } catch { // Best-effort cleanup. } } 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; } }