148 lines
5.3 KiB
C#
148 lines
5.3 KiB
C#
using System.Diagnostics;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
|
|
namespace w4c_workflows.Services;
|
|
|
|
// Git transport half of the Forgejo workflow repo service: bounded-timeout
|
|
// process execution, origin reconciliation and local-config cleanup. The admin
|
|
// HTTP API lives in ForgejoWorkflowRepoService.AdminApi.cs; the repo/clone
|
|
// lifecycle lives in ForgejoWorkflowRepoService.cs.
|
|
|
|
public sealed partial class ForgejoWorkflowRepoService
|
|
{
|
|
/// <summary>
|
|
/// Runs git and returns its stdout on success / stderr on failure (null when
|
|
/// git could not be run at all). Callers that must distinguish success from
|
|
/// failure reliably should use <see cref="RunGitExitAsync"/> and check the
|
|
/// exit code instead of scanning the output text.
|
|
/// </summary>
|
|
private async Task<string?> RunGitAsync(string workDir, CancellationToken ct, params string[] args)
|
|
{
|
|
var result = await RunGitExitAsync(workDir, ct, args);
|
|
if (result == null)
|
|
return null;
|
|
return result.Value.ExitCode == 0 ? result.Value.Stdout : result.Value.Stderr;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs git with a hard wall-clock timeout and returns the real exit code plus
|
|
/// separately captured stdout/stderr. 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.
|
|
/// </summary>
|
|
private async Task<(int ExitCode, string Stdout, string Stderr)?> RunGitExitAsync(
|
|
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;
|
|
}
|
|
|
|
return (process.ExitCode, await stdoutTask, await stderrTask);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "git invocation failed in {Dir}", workDir);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 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.
|
|
}
|
|
}
|
|
}
|