443 lines
18 KiB
C#
443 lines
18 KiB
C#
using System.Diagnostics;
|
|
|
|
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.
|
|
/// </summary>
|
|
public sealed class ForgejoWorkflowRepoService
|
|
{
|
|
private readonly string _forgejoBase;
|
|
private readonly string? _forgejoToken;
|
|
private readonly string? _forgejoAdminToken;
|
|
private readonly string _forgejoOwner;
|
|
private readonly string _copiesRoot;
|
|
private readonly string _sharedDir;
|
|
private readonly ILogger<ForgejoWorkflowRepoService> _logger;
|
|
|
|
public ForgejoWorkflowRepoService(IConfiguration config, ILogger<ForgejoWorkflowRepoService> logger)
|
|
{
|
|
_logger = logger;
|
|
_forgejoBase = (config["Forgejo:BaseUrl"] ?? "https://forgejo.wiz4chat.com").TrimEnd('/');
|
|
_forgejoToken = config["Forgejo:AccessToken"]?.Trim();
|
|
_forgejoAdminToken = config["Forgejo:AdminToken"]?.Trim();
|
|
_forgejoOwner = config["Forgejo:WorkflowRepoOwner"]
|
|
?? config["Forgejo:Owner"]
|
|
?? string.Empty;
|
|
_copiesRoot = Path.GetFullPath(
|
|
config["WorkflowSource:CopiesRoot"]
|
|
?? throw new InvalidOperationException(
|
|
"WorkflowSource:CopiesRoot is not configured. " +
|
|
"Set it to a writable directory path (e.g. \"/data/workflow-tenants\")."));
|
|
_sharedDir = config["WorkflowSource:SharedDir"] ?? "workflows";
|
|
}
|
|
|
|
/// <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>
|
|
public string RepoFullName(string tenantId) =>
|
|
$"{_forgejoOwner.ToLowerInvariant()}/{RepoName(tenantId)}";
|
|
|
|
/// <summary>The sanitized repo name for a tenant.</summary>
|
|
public static string RepoName(string tenantId) =>
|
|
$"workflows-{Sanitize(tenantId)}";
|
|
|
|
/// <summary>
|
|
/// Resolves the Forgejo repo full name for a user. The repo lives under
|
|
/// the user's own Forgejo account: <c>{login}/workflows-{login}</c>.
|
|
/// </summary>
|
|
public static string RepoFullNameForLogin(string login) =>
|
|
$"{login.ToLowerInvariant()}/workflows-{Sanitize(login.ToLowerInvariant())}";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<string> 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 = $"workflows-{Sanitize(owner)}";
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<string> EnsureCloneAsync(string login, CancellationToken ct = default)
|
|
{
|
|
var tenantDir = TenantCloneDir(login);
|
|
|
|
if (Directory.Exists(Path.Combine(tenantDir, ".git")))
|
|
{
|
|
_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");
|
|
if (!string.IsNullOrWhiteSpace(_forgejoToken))
|
|
{
|
|
psi.ArgumentList.Add("-c");
|
|
psi.ArgumentList.Add($"http.extraHeader=Authorization: token {_forgejoToken}");
|
|
}
|
|
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");
|
|
|
|
var stdout = await process.StandardOutput.ReadToEndAsync(ct);
|
|
var stderr = await process.StandardError.ReadToEndAsync(ct);
|
|
await process.WaitForExitAsync(ct);
|
|
|
|
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");
|
|
|
|
// If the repo is empty (freshly created with auto_init), seed with templates.
|
|
await SeedTemplatesIfEmptyAsync(tenantDir, ct);
|
|
|
|
return tenantDir;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pulls the latest changes from the remote. Returns true on success.
|
|
/// </summary>
|
|
public async Task<bool> PullAsync(string login, CancellationToken ct = default)
|
|
{
|
|
var tenantDir = TenantCloneDir(login);
|
|
if (!Directory.Exists(Path.Combine(tenantDir, ".git")))
|
|
{
|
|
_logger.LogWarning("No local clone for login {Login}, cannot pull", login);
|
|
return false;
|
|
}
|
|
|
|
var token = !string.IsNullOrWhiteSpace(_forgejoToken) ? _forgejoToken : _forgejoAdminToken;
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Commits and pushes all changes in the tenant's clone. Used after file
|
|
/// edits via the source code API to persist changes to Forgejo.
|
|
/// </summary>
|
|
public async Task<(bool Success, string Output)> CommitAndPushAsync(
|
|
string tenantId, string message, CancellationToken ct = default)
|
|
{
|
|
var tenantDir = TenantCloneDir(tenantId);
|
|
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 = !string.IsNullOrWhiteSpace(_forgejoToken) ? _forgejoToken : _forgejoAdminToken;
|
|
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");
|
|
}
|
|
|
|
/// <summary>The absolute path to a tenant's local clone directory.</summary>
|
|
public string TenantCloneDir(string tenantId)
|
|
=> Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId)));
|
|
|
|
/// <summary>
|
|
/// Seeds the local clone with template files from the shared directory.
|
|
/// Only runs when the repo is empty (no workflow YAML files yet).
|
|
/// </summary>
|
|
private async Task SeedTemplatesIfEmptyAsync(string cloneDir, CancellationToken ct)
|
|
{
|
|
// Check if the repo already has workflow YAML files.
|
|
var yamlCount = Directory.EnumerateFiles(cloneDir, "*.yaml", SearchOption.AllDirectories)
|
|
.Concat(Directory.EnumerateFiles(cloneDir, "*.yml", SearchOption.AllDirectories))
|
|
.Count(f => !f.Contains(".git", StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (yamlCount > 0)
|
|
{
|
|
_logger.LogDebug("Clone already has {Count} YAML files, skipping seed", yamlCount);
|
|
return;
|
|
}
|
|
|
|
var sharedDir = ResolveSharedDir();
|
|
if (!Directory.Exists(sharedDir))
|
|
{
|
|
_logger.LogWarning("Shared workflow directory {Shared} does not exist; clone starts empty", sharedDir);
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Seeding empty clone from {Shared}", sharedDir);
|
|
|
|
// Copy ALL files (YAML + code) from the shared directory.
|
|
CopyAllFiles(sharedDir, cloneDir);
|
|
|
|
// Commit the seeded templates.
|
|
await RunGitAsync(cloneDir, ct, "add", "-A");
|
|
var commitOutput = await RunGitAsync(cloneDir, ct, "commit", "-m", "Initial workflow templates");
|
|
if (commitOutput != null && !commitOutput.Contains("nothing to commit", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var token = !string.IsNullOrWhiteSpace(_forgejoToken) ? _forgejoToken : _forgejoAdminToken;
|
|
if (!string.IsNullOrWhiteSpace(token))
|
|
{
|
|
await RunGitAsync(cloneDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "push");
|
|
}
|
|
else
|
|
{
|
|
await RunGitAsync(cloneDir, ct, "push");
|
|
}
|
|
_logger.LogInformation("Pushed initial templates to Forgejo");
|
|
}
|
|
}
|
|
|
|
private string ResolveSharedDir()
|
|
{
|
|
var sharedRel = _sharedDir;
|
|
if (Path.IsPathRooted(sharedRel) && Directory.Exists(sharedRel))
|
|
return Path.GetFullPath(sharedRel);
|
|
|
|
// Try resolving relative to the repo root (walk up from the service dir).
|
|
var start = AppContext.BaseDirectory;
|
|
var walk = Path.GetFullPath(start);
|
|
while (true)
|
|
{
|
|
var candidate = Path.Combine(walk, sharedRel);
|
|
if (Directory.Exists(candidate))
|
|
return Path.GetFullPath(candidate);
|
|
var parent = Directory.GetParent(walk)?.FullName;
|
|
if (string.IsNullOrEmpty(parent) || parent == walk)
|
|
break;
|
|
walk = parent;
|
|
}
|
|
|
|
return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), sharedRel));
|
|
}
|
|
|
|
private static void CopyAllFiles(string src, string dst)
|
|
{
|
|
foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories))
|
|
{
|
|
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)
|
|
{
|
|
// Best-effort — template files that fail to copy are non-fatal.
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<string?> 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;
|
|
|
|
var stdout = await process.StandardOutput.ReadToEndAsync(ct);
|
|
var stderr = await process.StandardError.ReadToEndAsync(ct);
|
|
await process.WaitForExitAsync(ct);
|
|
|
|
return process.ExitCode == 0 ? stdout : stderr;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "git invocation failed in {Dir}", workDir);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Task<HttpResponseMessage> 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;
|
|
}
|
|
}
|