fix(dispatcher): use tenant-selected workflow repo and ensure clone before dispatch
The C# worker failed with 'entry file not found: Main.cs' because the dispatcher resolved the working directory against the DEFAULT workflow repo instead of the tenant's currently-selected one, and never ensured the selected repo was cloned on the executing host. This left the worker working in a directory whose code files did not exist. - WorkflowSourceFactory.ResolveCopiesRoot now anchors a relative WorkflowSource:CopiesRoot to the content root (not the process CWD), so a worker started from a container/different CWD still lands on the same wizard root as the control plane. - ForgejoWorkflowRepoService takes IWebHostEnvironment and uses ResolveCopiesRoot the same way. - TaskDispatcher.ResolveWorkerRootForTenantAsync resolves the tenant's SELECTED repo from WorkflowRepoStore, and calls EnsureCloneAsync to materialize that repo on the executing host before resolving the working directory. EnsureClone is a no-op once the clone exists, so repeated dispatches stay cheap; failures are logged and swallowed so the resolved path still surfaces a deterministic error if the repo is genuinely missing. Adds regression tests: TaskDispatcherWorkingDirTests.
This commit is contained in:
parent
4ad6cc2cb9
commit
17d4515961
|
|
@ -1,4 +1,5 @@
|
|||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ public sealed class ForgejoWorkflowRepoService
|
|||
private readonly string _copiesRoot;
|
||||
private readonly ILogger<ForgejoWorkflowRepoService> _logger;
|
||||
|
||||
public ForgejoWorkflowRepoService(IConfiguration config, ILogger<ForgejoWorkflowRepoService> logger)
|
||||
public ForgejoWorkflowRepoService(IConfiguration config, IWebHostEnvironment env, ILogger<ForgejoWorkflowRepoService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_forgejoBase = (config["Forgejo:BaseUrl"] ?? "https://forgejo.wiz4chat.com").TrimEnd('/');
|
||||
|
|
@ -41,11 +42,15 @@ public sealed class ForgejoWorkflowRepoService
|
|||
_forgejoOwner = !string.IsNullOrWhiteSpace(workflowRepoOwner)
|
||||
? workflowRepoOwner
|
||||
: config["Forgejo:Owner"]?.Trim() ?? string.Empty;
|
||||
_copiesRoot = Path.GetFullPath(
|
||||
// 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\")."));
|
||||
"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.
|
||||
|
|
|
|||
|
|
@ -43,16 +43,17 @@ public sealed class WorkflowSourceFactory
|
|||
_loggers = loggers;
|
||||
_ds = ds;
|
||||
|
||||
_copiesRoot = Path.GetFullPath(
|
||||
_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\")."));
|
||||
"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<ForgejoWorkflowRepoService>();
|
||||
var forgejoSvc = new ForgejoWorkflowRepoService(config, forgejoLogger);
|
||||
var forgejoSvc = new ForgejoWorkflowRepoService(config, env, forgejoLogger);
|
||||
_forgejo = forgejoSvc.IsConfigured ? forgejoSvc : null;
|
||||
|
||||
if (_forgejo != null)
|
||||
|
|
@ -162,6 +163,24 @@ public sealed class WorkflowSourceFactory
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <c>WorkflowSource:CopiesRoot</c> to an absolute path independent of
|
||||
/// the process CWD. A relative value (e.g. <c>../source-copies</c>) is anchored
|
||||
/// to the app's content root instead of <see cref="Directory.GetCurrentDirectory"/>,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public class TaskDispatcher
|
|||
private readonly string _defaultWorkerRoot;
|
||||
private readonly string _sharedDir;
|
||||
private readonly WorkflowSourceFactory? _sourceFactory;
|
||||
private readonly WorkflowRepoStore? _repoStore;
|
||||
private readonly ILogger<TaskDispatcher> _logger;
|
||||
|
||||
public TaskDispatcher(
|
||||
|
|
@ -27,12 +28,14 @@ public class TaskDispatcher
|
|||
IJobQueue jobs,
|
||||
IConfiguration config,
|
||||
ILogger<TaskDispatcher> logger,
|
||||
WorkflowSourceFactory? sourceFactory = null)
|
||||
WorkflowSourceFactory? sourceFactory = null,
|
||||
WorkflowRepoStore? repoStore = null)
|
||||
{
|
||||
_db = db;
|
||||
_jobs = jobs;
|
||||
_logger = logger;
|
||||
_sourceFactory = sourceFactory;
|
||||
_repoStore = repoStore;
|
||||
_defaultWorkerRoot = ResolveWorkerRoot(config);
|
||||
_sharedDir = config["WorkflowSource:SharedDir"] ?? "workflows";
|
||||
}
|
||||
|
|
@ -204,10 +207,83 @@ public class TaskDispatcher
|
|||
if (_sourceFactory == null)
|
||||
return _defaultWorkerRoot;
|
||||
|
||||
var login = _sourceFactory.IsForgejoBacked
|
||||
? await _sourceFactory.ResolveForgejoLoginAsync(tenantId, ct)
|
||||
: null;
|
||||
return _sourceFactory.ResolveTenantSourceDir(tenantId, login);
|
||||
// Use the tenant's SELECTED workflow repo (the clone the file-writer and
|
||||
// the source explorer land on), falling back to the configured default
|
||||
// (repoName=null) when the store is unavailable. Before this, the worker
|
||||
// always resolved the DEFAULT repo, so a tenant that had switched to a
|
||||
// different repo got a working directory that did not contain its entry
|
||||
// file (surfacing as "entry file not found: Main.cs" in the C# executor).
|
||||
var repoName = await ResolveRepoNameAsync(tenantId, ct);
|
||||
|
||||
if (_sourceFactory.IsForgejoBacked)
|
||||
{
|
||||
var login = await _sourceFactory.ResolveForgejoLoginAsync(tenantId, ct);
|
||||
if (!string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
// Materialize the tenant's repo clone if it is not already present on
|
||||
// THIS instance before resolving the working directory. The writer may
|
||||
// have cloned the repo on a different host/instance; without this the
|
||||
// worker resolves a working directory whose code files do not exist
|
||||
// ("entry file not found: Main.cs"). EnsureClone is a no-op once the
|
||||
// clone exists, so repeated dispatches stay cheap.
|
||||
await EnsureCloneAsync(tenantId, login, repoName, ct);
|
||||
return _sourceFactory.ResolveTenantSourceDir(tenantId, login, repoName);
|
||||
}
|
||||
}
|
||||
|
||||
return _sourceFactory.ResolveTenantSourceDir(tenantId, null, repoName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort clone/pull of the tenant's workflow repo so the working directory
|
||||
/// the worker will execute in actually contains the code files. Failures are
|
||||
/// logged and swallowed: the resolved path is still returned, so a genuinely
|
||||
/// missing repo surfaces its normal "entry file not found" at execution time
|
||||
/// rather than silently dispatching to a directory we cannot reason about.
|
||||
/// </summary>
|
||||
private async Task EnsureCloneAsync(string tenantId, string login, string? repoName, CancellationToken ct)
|
||||
{
|
||||
var forgejo = _sourceFactory?.Forgejo;
|
||||
if (forgejo == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await forgejo.EnsureCloneAsync(tenantId, login, repoName, ct);
|
||||
// No per-dispatch PullAsync: a full git pull is too slow to run inside
|
||||
// the control-plane dispatch loop on every tick. EnsureClone guarantees
|
||||
// the working directory exists; freshness is handled by the compile/edit
|
||||
// paths that already pull the repo on write/sync.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Could not ensure workflow repo clone for tenant {TenantId} (login {Login}); dispatching with the resolved path as-is",
|
||||
tenantId, login);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the tenant's currently-selected workflow repo name (as persisted in
|
||||
/// <see cref="WorkflowRepoStore"/>). Returns <c>null</c> when the store is not
|
||||
/// configured or the lookup fails, letting the caller fall back to the factory's
|
||||
/// configured default repo.
|
||||
/// </summary>
|
||||
private async Task<string?> ResolveRepoNameAsync(string tenantId, CancellationToken ct)
|
||||
{
|
||||
if (_repoStore == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return await _repoStore.GetNameAsync(tenantId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex,
|
||||
"Could not resolve selected workflow repo for tenant {TenantId}; using default", tenantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveWorkerRoot(IConfiguration config)
|
||||
|
|
|
|||
Loading…
Reference in a new issue