249 lines
8.9 KiB
C#
249 lines
8.9 KiB
C#
|
|
using System.Diagnostics;
|
||
|
|
|
||
|
|
namespace w4c_workflows.Services;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Factory for creating <see cref="IWorkflowSource"/> instances scoped to a
|
||
|
|
/// specific tenant. The caller provides the tenant ID explicitly (from auth
|
||
|
|
/// middleware); the factory resolves directories and returns a source bound
|
||
|
|
/// to that tenant's workflow directory.
|
||
|
|
///
|
||
|
|
/// Tenant isolation is <b>mandatory</b>: <c>WorkflowSource:CopiesRoot</c> must
|
||
|
|
/// be set in configuration. The factory throws at construction time if it is
|
||
|
|
/// missing, preventing any request from serving cross-tenant workflows.
|
||
|
|
///
|
||
|
|
/// Registered as a <b>Singleton</b> (shared config, no per-request state);
|
||
|
|
/// the created sources are lightweight and backed by filesystem operations.
|
||
|
|
/// </summary>
|
||
|
|
public sealed class WorkflowSourceFactory
|
||
|
|
{
|
||
|
|
private readonly IConfiguration _config;
|
||
|
|
private readonly IWebHostEnvironment _env;
|
||
|
|
private readonly ILoggerFactory _loggers;
|
||
|
|
private readonly string _copiesRoot;
|
||
|
|
private readonly string _sharedDir;
|
||
|
|
private readonly string _repoRoot;
|
||
|
|
|
||
|
|
public WorkflowSourceFactory(IConfiguration config, IWebHostEnvironment env, ILoggerFactory loggers)
|
||
|
|
{
|
||
|
|
_config = config;
|
||
|
|
_env = env;
|
||
|
|
_loggers = loggers;
|
||
|
|
|
||
|
|
_copiesRoot = 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\").");
|
||
|
|
_sharedDir = config["WorkflowSource:SharedDir"] ?? "workflows";
|
||
|
|
_repoRoot = ResolveRepoRoot(config, env);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Creates a per-tenant workflow source. The tenant directory
|
||
|
|
/// <c>{CopiesRoot}/{sanitizedTenantId}/</c> is created (and seeded from
|
||
|
|
/// the shared template dir) on first access.</summary>
|
||
|
|
public IWorkflowSource Create(string tenantId)
|
||
|
|
{
|
||
|
|
var safeId = Sanitize(tenantId);
|
||
|
|
var tenantDir = Path.GetFullPath(Path.Combine(_copiesRoot, safeId));
|
||
|
|
|
||
|
|
var sharedRel = _sharedDir;
|
||
|
|
string sharedDir;
|
||
|
|
if (Path.IsPathRooted(sharedRel) && Directory.Exists(sharedRel))
|
||
|
|
sharedDir = Path.GetFullPath(sharedRel);
|
||
|
|
else
|
||
|
|
sharedDir = Path.GetFullPath(Path.Combine(_repoRoot, sharedRel));
|
||
|
|
|
||
|
|
var logger = _loggers.CreateLogger<PerTenantWorkflowSource>();
|
||
|
|
return new PerTenantWorkflowSource(tenantId, tenantDir, sharedDir, logger);
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string ResolveRepoRoot(IConfiguration config, IWebHostEnvironment env)
|
||
|
|
{
|
||
|
|
var configured = config["SourceCode:RepoRoot"];
|
||
|
|
if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
|
||
|
|
return Path.GetFullPath(configured);
|
||
|
|
|
||
|
|
var start = AppContext.BaseDirectory;
|
||
|
|
if (!string.IsNullOrEmpty(env.ContentRootPath) && Directory.Exists(env.ContentRootPath))
|
||
|
|
start = env.ContentRootPath;
|
||
|
|
|
||
|
|
var walk = Path.GetFullPath(start);
|
||
|
|
while (true)
|
||
|
|
{
|
||
|
|
if (Directory.Exists(Path.Combine(walk, ".git")))
|
||
|
|
return Path.GetFullPath(walk);
|
||
|
|
var parent = Directory.GetParent(walk)?.FullName;
|
||
|
|
if (string.IsNullOrEmpty(parent) || parent == walk)
|
||
|
|
break;
|
||
|
|
walk = parent;
|
||
|
|
}
|
||
|
|
|
||
|
|
return Path.GetFullPath(Directory.GetCurrentDirectory());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Per-tenant workflow YAML source with directory isolation. Each tenant gets
|
||
|
|
/// its own directory at <c>{CopiesRoot}/{tenantId}/</c>, initialized from the
|
||
|
|
/// shared template directory on first access.
|
||
|
|
/// </summary>
|
||
|
|
public sealed class PerTenantWorkflowSource : IWorkflowSource
|
||
|
|
{
|
||
|
|
private readonly string _tenantDir;
|
||
|
|
private readonly string _sharedDir;
|
||
|
|
private readonly ILogger<PerTenantWorkflowSource> _logger;
|
||
|
|
|
||
|
|
public PerTenantWorkflowSource(string tenantId, string tenantDir, string sharedDir, ILogger<PerTenantWorkflowSource> logger)
|
||
|
|
{
|
||
|
|
_tenantDir = tenantDir;
|
||
|
|
_sharedDir = sharedDir;
|
||
|
|
_logger = logger;
|
||
|
|
_logger.LogInformation(
|
||
|
|
"Per-tenant workflow source: tenant={TenantId} dir={Dir} shared={Shared}",
|
||
|
|
tenantId, _tenantDir, _sharedDir);
|
||
|
|
}
|
||
|
|
|
||
|
|
public Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
|
||
|
|
{
|
||
|
|
ct.ThrowIfCancellationRequested();
|
||
|
|
EnsureInitialized();
|
||
|
|
return Task.FromResult(ListYamlFiles(_tenantDir));
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<string> ReadAsync(string path, CancellationToken ct)
|
||
|
|
{
|
||
|
|
EnsureInitialized();
|
||
|
|
return await File.ReadAllTextAsync(
|
||
|
|
Path.Combine(_tenantDir, path.Replace('/', Path.DirectorySeparatorChar)), ct);
|
||
|
|
}
|
||
|
|
|
||
|
|
public WorkflowSourceState GetState()
|
||
|
|
{
|
||
|
|
EnsureInitialized();
|
||
|
|
var head = RunGit(_tenantDir, "rev-parse", "HEAD");
|
||
|
|
var status = RunGit(_tenantDir, "status", "--porcelain");
|
||
|
|
return new WorkflowSourceState(
|
||
|
|
string.IsNullOrWhiteSpace(head) ? null : head,
|
||
|
|
!string.IsNullOrEmpty(status));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Ensures the tenant directory exists and has been seeded from the shared
|
||
|
|
/// template directory (first-access initialization).
|
||
|
|
/// </summary>
|
||
|
|
private void EnsureInitialized()
|
||
|
|
{
|
||
|
|
if (Directory.Exists(_tenantDir))
|
||
|
|
return;
|
||
|
|
|
||
|
|
_logger.LogInformation("Initializing tenant workflow directory {Dir} from {Shared}", _tenantDir, _sharedDir);
|
||
|
|
Directory.CreateDirectory(_tenantDir);
|
||
|
|
|
||
|
|
if (!Directory.Exists(_sharedDir))
|
||
|
|
{
|
||
|
|
_logger.LogWarning("Shared workflow directory {Shared} does not exist; tenant starts empty", _sharedDir);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
CopyYamlFiles(_sharedDir, _tenantDir, _logger);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static IReadOnlyList<WorkflowFile> ListYamlFiles(string dir)
|
||
|
|
{
|
||
|
|
var files = new List<WorkflowFile>();
|
||
|
|
if (!Directory.Exists(dir))
|
||
|
|
return files;
|
||
|
|
|
||
|
|
foreach (var full in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||
|
|
{
|
||
|
|
var ext = Path.GetExtension(full);
|
||
|
|
if (ext is not (".yaml" or ".yml"))
|
||
|
|
continue;
|
||
|
|
// Skip .git
|
||
|
|
var rel = Path.GetRelativePath(dir, full);
|
||
|
|
if (rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||
|
|
continue;
|
||
|
|
files.Add(new WorkflowFile(rel.Replace(Path.DirectorySeparatorChar, '/')));
|
||
|
|
}
|
||
|
|
|
||
|
|
return files.OrderBy(f => f.Path, StringComparer.Ordinal).ToList();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Copies YAML files from src to dst, skipping .git and non-yaml files.
|
||
|
|
/// </summary>
|
||
|
|
private static void CopyYamlFiles(string src, string dst, ILogger logger)
|
||
|
|
{
|
||
|
|
foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories))
|
||
|
|
{
|
||
|
|
var ext = Path.GetExtension(file);
|
||
|
|
if (ext is not (".yaml" or ".yml"))
|
||
|
|
continue;
|
||
|
|
|
||
|
|
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 ex)
|
||
|
|
{
|
||
|
|
logger.LogWarning(ex, "Failed to copy template file {File}", rel);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private string? RunGit(string workDir, params string[] args)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var psi = new ProcessStartInfo("git")
|
||
|
|
{
|
||
|
|
WorkingDirectory = workDir,
|
||
|
|
RedirectStandardOutput = true,
|
||
|
|
RedirectStandardError = true,
|
||
|
|
UseShellExecute = false,
|
||
|
|
};
|
||
|
|
foreach (var arg in args)
|
||
|
|
psi.ArgumentList.Add(arg);
|
||
|
|
|
||
|
|
using var process = Process.Start(psi);
|
||
|
|
if (process == null)
|
||
|
|
return null;
|
||
|
|
|
||
|
|
var output = process.StandardOutput.ReadToEnd();
|
||
|
|
if (!process.WaitForExit(5000))
|
||
|
|
{
|
||
|
|
process.Kill(entireProcessTree: true);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return process.ExitCode == 0 ? output.Trim() : null;
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
_logger.LogDebug(ex, "git invocation failed in {Dir}", workDir);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|