2026-09-03 16:27:03 +00:00
|
|
|
using Microsoft.AspNetCore.Hosting;
|
2026-09-01 22:12:21 +00:00
|
|
|
|
|
|
|
|
namespace w4c_workflows.Services;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Manages per-tenant private Forgejo repositories for workflow source files.
|
|
|
|
|
/// Each tenant gets a repo named <c>workflows-{tenantId}</c> 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 <c>WorkflowSource:SharedDir</c>. Subsequent calls
|
|
|
|
|
/// pull remote changes before the sync service reads the files.
|
|
|
|
|
///
|
|
|
|
|
/// The local clone lives at <c>{WorkflowSource:CopiesRoot}/{tenantId}/</c> — the
|
|
|
|
|
/// same directory layout the legacy <see cref="PerTenantWorkflowSource"/> used, so
|
|
|
|
|
/// the existing <see cref="WorkflowSyncService"/> and worker runtime need no path
|
|
|
|
|
/// changes when the backing store switches from plain filesystem to git.
|
2026-09-13 16:28:47 +00:00
|
|
|
///
|
|
|
|
|
/// The class is split across partial files by responsibility: this file holds
|
|
|
|
|
/// configuration, repo naming and the clone-directory layout;
|
|
|
|
|
/// <c>ForgejoWorkflowRepoService.Lifecycle.cs</c> the repo/clone/pull/push
|
|
|
|
|
/// lifecycle, <c>.Git.cs</c> the bounded-timeout git process calls and
|
|
|
|
|
/// <c>.AdminApi.cs</c> the single admin REST call.
|
2026-09-01 22:12:21 +00:00
|
|
|
/// </summary>
|
2026-09-13 16:28:47 +00:00
|
|
|
public sealed partial class ForgejoWorkflowRepoService
|
2026-09-01 22:12:21 +00:00
|
|
|
{
|
2026-09-09 13:09:48 +00:00
|
|
|
/// <summary>
|
|
|
|
|
/// 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 <see cref="RunGitAsync"/>.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static readonly TimeSpan GitOperationTimeout = TimeSpan.FromSeconds(20);
|
|
|
|
|
|
2026-09-13 16:28:47 +00:00
|
|
|
/// <summary>Named HttpClient used for the Forgejo admin API (pooled, not per-call).</summary>
|
|
|
|
|
public const string AdminClientName = "forgejo-admin";
|
|
|
|
|
|
2026-09-01 22:12:21 +00:00
|
|
|
private readonly string _forgejoBase;
|
|
|
|
|
private readonly string? _forgejoToken;
|
|
|
|
|
private readonly string? _forgejoAdminToken;
|
|
|
|
|
private readonly string _forgejoOwner;
|
|
|
|
|
private readonly string _copiesRoot;
|
2026-09-13 16:28:47 +00:00
|
|
|
private readonly HttpClient _adminHttp;
|
2026-09-01 22:12:21 +00:00
|
|
|
private readonly ILogger<ForgejoWorkflowRepoService> _logger;
|
|
|
|
|
|
2026-09-13 16:28:47 +00:00
|
|
|
public ForgejoWorkflowRepoService(IConfiguration config, IWebHostEnvironment env,
|
|
|
|
|
ILogger<ForgejoWorkflowRepoService> logger, IHttpClientFactory? httpFactory = null)
|
2026-09-01 22:12:21 +00:00
|
|
|
{
|
|
|
|
|
_logger = logger;
|
2026-09-13 16:28:47 +00:00
|
|
|
// One reused client for the service lifetime instead of `new HttpClient`
|
|
|
|
|
// per admin call — the old pattern leaked sockets under load (the service
|
|
|
|
|
// is a singleton, so this client is effectively shared anyway).
|
|
|
|
|
_adminHttp = httpFactory?.CreateClient(AdminClientName) ?? new HttpClient();
|
|
|
|
|
_adminHttp.Timeout = TimeSpan.FromSeconds(30);
|
2026-09-01 22:12:21 +00:00
|
|
|
_forgejoBase = (config["Forgejo:BaseUrl"] ?? "https://forgejo.wiz4chat.com").TrimEnd('/');
|
|
|
|
|
_forgejoToken = config["Forgejo:AccessToken"]?.Trim();
|
|
|
|
|
_forgejoAdminToken = config["Forgejo:AdminToken"]?.Trim();
|
2026-09-03 07:59:54 +00:00
|
|
|
// 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;
|
2026-09-03 16:27:03 +00:00
|
|
|
// 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(
|
2026-09-01 22:12:21 +00:00
|
|
|
config["WorkflowSource:CopiesRoot"]
|
|
|
|
|
?? throw new InvalidOperationException(
|
|
|
|
|
"WorkflowSource:CopiesRoot is not configured. " +
|
2026-09-03 16:27:03 +00:00
|
|
|
"Set it to a writable directory path (e.g. \"/data/workflow-tenants\")."),
|
|
|
|
|
env);
|
2026-09-03 14:44:39 +00:00
|
|
|
// 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;
|
2026-09-01 22:12:21 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-02 19:14:59 +00:00
|
|
|
/// <summary>The Forgejo base URL (for building repo links).</summary>
|
|
|
|
|
public string ForgejoBaseUrl => _forgejoBase;
|
|
|
|
|
|
2026-09-03 07:59:54 +00:00
|
|
|
/// <summary>
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private string GitAuthToken =>
|
2026-09-13 08:35:17 +00:00
|
|
|
!string.IsNullOrWhiteSpace(_forgejoAdminToken) ? _forgejoAdminToken : _forgejoToken ?? string.Empty;
|
2026-09-03 07:59:54 +00:00
|
|
|
|
2026-09-01 22:12:21 +00:00
|
|
|
/// <summary>True when Forgejo admin provisioning is configured.</summary>
|
|
|
|
|
public bool IsConfigured =>
|
|
|
|
|
!string.IsNullOrWhiteSpace(_forgejoAdminToken) &&
|
|
|
|
|
!string.IsNullOrWhiteSpace(_forgejoOwner);
|
|
|
|
|
|
|
|
|
|
/// <summary>The repo full name on Forgejo (owner/repo-name).</summary>
|
2026-09-03 14:44:39 +00:00
|
|
|
/// <summary>
|
|
|
|
|
/// The repository name (basename) used for a tenant's workflow repo. Default
|
|
|
|
|
/// is <c>workflows</c> (the repo full name <c>{login}/workflows</c>), so a
|
|
|
|
|
/// tenant's workflows live in a repo whose name contains "workflow". A tenant
|
|
|
|
|
/// may select a different repo (e.g. <c>wiz4apps</c>) to share the directory
|
|
|
|
|
/// with the main-api Source Code explorer; the selection is persisted per
|
|
|
|
|
/// tenant and overrides this default.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string WorkflowRepoName { get; }
|
2026-09-01 22:12:21 +00:00
|
|
|
|
2026-09-03 14:44:39 +00:00
|
|
|
/// <summary>
|
|
|
|
|
/// 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 (<c>{owner}_{repo}</c>). Deriving full name AND
|
|
|
|
|
/// slug from a single repo name is what keeps the two services pointing at the
|
|
|
|
|
/// same directory.
|
|
|
|
|
/// </summary>
|
|
|
|
|
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}");
|
|
|
|
|
}
|
2026-09-01 22:12:21 +00:00
|
|
|
|
|
|
|
|
/// <summary>
|
2026-09-03 14:44:39 +00:00
|
|
|
/// Resolves the Forgejo repo full name for a user given the configured/selected
|
|
|
|
|
/// workflow repo name: <c>{login}/{repoName}</c>.
|
2026-09-01 22:12:21 +00:00
|
|
|
/// </summary>
|
2026-09-03 14:44:39 +00:00
|
|
|
public string RepoFullNameForLogin(string login, string? repoName = null) =>
|
|
|
|
|
ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoFullName;
|
2026-09-01 22:12:21 +00:00
|
|
|
|
2026-09-03 07:59:54 +00:00
|
|
|
/// <summary>
|
|
|
|
|
/// The source-copies owner-repo slug for a user's workflow repo, matching the
|
|
|
|
|
/// webapi SourceCodeCopyService layout (<c>{owner}_{repo}</c>, e.g.
|
2026-09-03 14:44:39 +00:00
|
|
|
/// <c>test_workflows</c>). 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.
|
2026-09-03 07:59:54 +00:00
|
|
|
/// </summary>
|
2026-09-03 14:44:39 +00:00
|
|
|
public string OwnerRepoSlug(string login, string? repoName = null) =>
|
|
|
|
|
ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoSlug;
|
2026-09-03 07:59:54 +00:00
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The absolute path to a tenant's local clone directory — shared with the
|
|
|
|
|
/// webapi source-code explorer. Layout:
|
|
|
|
|
/// <c>{CopiesRoot}/{tenantId}/{ownerRepo}</c> where <c>ownerRepo</c> is
|
|
|
|
|
/// <see cref="OwnerRepoSlug"/> (e.g. <c>source-copies/10/test_workflows-test</c>).
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
2026-09-03 14:44:39 +00:00
|
|
|
public string TenantCloneDir(string tenantId, string login, string? repoName = null)
|
|
|
|
|
=> Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId), OwnerRepoSlug(login, repoName)));
|
2026-09-01 22:12:21 +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;
|
|
|
|
|
}
|
|
|
|
|
}
|