persistense change
This commit is contained in:
parent
9354f3ab02
commit
1d9987c3ca
|
|
@ -8,10 +8,21 @@ namespace w4c_workflows.Controllers;
|
|||
/// <summary>
|
||||
/// Workflow file management endpoints. Provides CRUD operations for the
|
||||
/// workflow YAML definitions and their sibling code files (shell scripts,
|
||||
/// JS, Python, etc.) stored in the tenant's private Forgejo repo.
|
||||
/// JS, Python, etc.) that live in the tenant's per-tenant workflow repository.
|
||||
///
|
||||
/// Requires the Forgejo-backed mode to be active (<c>Forgejo:AdminToken</c>
|
||||
/// configured). In filesystem-only mode these endpoints return 503.
|
||||
/// The repository is the single source of truth for a tenant's workflow content:
|
||||
/// the workflows module edits files here and <c>POST /api/workflows/sync</c>
|
||||
/// recompiles them (see <see cref="WorkflowSyncService"/>). Edits land in the
|
||||
/// SAME directory <see cref="IWorkflowSource"/> reads from, so the "edit in the
|
||||
/// workflows module" and "the engine runs it" paths are always coherent.
|
||||
///
|
||||
/// Works in both storage modes:
|
||||
/// - <b>Forgejo-backed</b> (Forgejo:AdminToken set): files live in a private
|
||||
/// per-user Forgejo repo <c>{login}/workflows-{login}</c>, cloned locally.
|
||||
/// <c>/commit</c> and <c>/pull</c> sync to/from the remote.
|
||||
/// - <b>Filesystem-only</b> (no AdminToken): files live under
|
||||
/// <c>{CopiesRoot}/{tenantId}/</c> with no remote. <c>/commit</c> and
|
||||
/// <c>/pull</c> are no-ops.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/workflow-files")]
|
||||
|
|
@ -32,12 +43,22 @@ public class WorkflowFilesController : ControllerBase
|
|||
?? throw new InvalidOperationException("TenantId not resolved by auth middleware");
|
||||
|
||||
/// <summary>
|
||||
/// The user's Forgejo login, resolved from the tenant ID by the
|
||||
/// WorkflowSourceMiddleware. Used for repo path lookups since repos
|
||||
/// are per-user: <c>{login}/workflows-{login}</c>.
|
||||
/// The user's Forgejo login when the middleware resolved it (Forgejo-backed
|
||||
/// mode succeeded). Null when in filesystem-only mode OR when the Forgejo
|
||||
/// login could not be resolved (the request then falls back to the filesystem
|
||||
/// source, so file CRUD must target the filesystem dir to stay consistent).
|
||||
/// </summary>
|
||||
private string ForgejoLogin => (string?)HttpContext.Items["ForgejoLogin"]
|
||||
?? throw new InvalidOperationException("ForgejoLogin not resolved by WorkflowSourceMiddleware");
|
||||
private string? MaybeForgejoLogin =>
|
||||
HttpContext.Items.TryGetValue("ForgejoLogin", out var v) ? v as string : null;
|
||||
|
||||
/// <summary>
|
||||
/// The on-disk directory holding the tenant's workflow files. In Forgejo-backed
|
||||
/// mode (login resolved) it is the per-user clone dir; otherwise it is the
|
||||
/// per-tenant directory. Both are exactly the root <see cref="IWorkflowSource"/>
|
||||
/// reads from, so file edits made through these endpoints are what get compiled
|
||||
/// on sync.
|
||||
/// </summary>
|
||||
private string SourceDir => _sourceFactory.ResolveTenantSourceDir(TenantId, MaybeForgejoLogin);
|
||||
|
||||
/// <summary>Returns the Forgejo repo info for this tenant's workflow files.</summary>
|
||||
[HttpGet("repo")]
|
||||
|
|
@ -46,8 +67,10 @@ public class WorkflowFilesController : ControllerBase
|
|||
{
|
||||
if (_sourceFactory.Forgejo == null)
|
||||
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
|
||||
var login = MaybeForgejoLogin;
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
|
||||
|
||||
var login = ForgejoLogin;
|
||||
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(login);
|
||||
var fullName = ForgejoWorkflowRepoService.RepoFullNameForLogin(login);
|
||||
var exists = Directory.Exists(Path.Combine(cloneDir, ".git"));
|
||||
|
|
@ -57,47 +80,50 @@ public class WorkflowFilesController : ControllerBase
|
|||
repoFullName = fullName,
|
||||
cloneDir,
|
||||
cloned = exists,
|
||||
forgejoUrl = $"{_sourceFactory.Forgejo.GetType().GetProperty("ForgejoBase")?.GetValue(_sourceFactory.Forgejo) ?? "https://forgejo.wiz4chat.com"}/{fullName}",
|
||||
forgejoUrl = $"{_sourceFactory.Forgejo.ForgejoBaseUrl}/{fullName}",
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all files in the tenant's workflow repo (recursive, excluding .git).
|
||||
/// Returns relative paths and basic file info.
|
||||
/// Lists all files under a directory (recursive, excluding .git), plus the
|
||||
/// top-level subdirectories. Returns repo-relative paths.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[RequireScope("read")]
|
||||
public IActionResult ListFiles([FromQuery] string? path = null)
|
||||
{
|
||||
if (_sourceFactory.Forgejo == null)
|
||||
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
|
||||
|
||||
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
|
||||
if (!Directory.Exists(cloneDir))
|
||||
var sourceDir = SourceDir;
|
||||
if (!Directory.Exists(sourceDir))
|
||||
return Ok(new { files = Array.Empty<object>() });
|
||||
|
||||
var dir = string.IsNullOrWhiteSpace(path)
|
||||
? cloneDir
|
||||
: Path.Combine(cloneDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||
? sourceDir
|
||||
: Path.Combine(sourceDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
if (!Directory.Exists(dir))
|
||||
var fullSourceDir = Path.GetFullPath(sourceDir);
|
||||
var fullDir = Path.GetFullPath(dir);
|
||||
if (!fullDir.StartsWith(fullSourceDir, StringComparison.Ordinal))
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
|
||||
if (!Directory.Exists(fullDir))
|
||||
return NotFound(new { error = $"Directory not found: {path}" });
|
||||
|
||||
var files = new List<object>();
|
||||
foreach (var full in Directory.EnumerateFiles(dir))
|
||||
foreach (var full in Directory.EnumerateFiles(fullDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var name = Path.GetFileName(full);
|
||||
var rel = Path.GetRelativePath(cloneDir, full).Replace(Path.DirectorySeparatorChar, '/');
|
||||
var rel = Path.GetRelativePath(sourceDir, full).Replace(Path.DirectorySeparatorChar, '/');
|
||||
if (rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
var info = new FileInfo(full);
|
||||
files.Add(new { name, path = rel, size = info.Length, modified = info.LastWriteTimeUtc });
|
||||
files.Add(new { path = rel, name = Path.GetFileName(full), size = info.Length, modified = info.LastWriteTimeUtc });
|
||||
}
|
||||
|
||||
foreach (var full in Directory.EnumerateDirectories(dir))
|
||||
foreach (var full in Directory.EnumerateDirectories(fullDir))
|
||||
{
|
||||
var name = Path.GetFileName(full);
|
||||
if (name == ".git") continue;
|
||||
var rel = Path.GetRelativePath(cloneDir, full).Replace(Path.DirectorySeparatorChar, '/');
|
||||
files.Add(new { name, path = rel, isDir = true });
|
||||
var rel = Path.GetRelativePath(sourceDir, full).Replace(Path.DirectorySeparatorChar, '/');
|
||||
files.Add(new { path = rel, name, isDir = true });
|
||||
}
|
||||
|
||||
return Ok(new { files });
|
||||
|
|
@ -108,19 +134,11 @@ public class WorkflowFilesController : ControllerBase
|
|||
[RequireScope("read")]
|
||||
public IActionResult GetContent([FromQuery] string path)
|
||||
{
|
||||
if (_sourceFactory.Forgejo == null)
|
||||
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return BadRequest(new { error = "path is required" });
|
||||
|
||||
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
|
||||
var full = Path.Combine(cloneDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
// Reject path traversal.
|
||||
var fullCloneDir = Path.GetFullPath(cloneDir);
|
||||
var fullFile = Path.GetFullPath(full);
|
||||
if (!fullFile.StartsWith(fullCloneDir, StringComparison.Ordinal))
|
||||
var full = ResolveSafeFile(path, out var status);
|
||||
if (status.HasValue)
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
|
||||
if (!System.IO.File.Exists(full))
|
||||
|
|
@ -132,26 +150,18 @@ public class WorkflowFilesController : ControllerBase
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves content to a file in the tenant's workflow repo.
|
||||
/// Writes to the local clone (does not commit/push — use POST /commit for that).
|
||||
/// Saves content to a file in the tenant's workflow repo. Writes to the local
|
||||
/// working tree (no commit/push — use POST /commit in Forgejo mode for that).
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[RequireScope("manage")]
|
||||
public IActionResult SaveFile([FromBody] SaveWorkflowFileRequest request)
|
||||
{
|
||||
if (_sourceFactory.Forgejo == null)
|
||||
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Path))
|
||||
return BadRequest(new { error = "path is required" });
|
||||
|
||||
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
|
||||
var full = Path.Combine(cloneDir, request.Path.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
// Reject path traversal.
|
||||
var fullCloneDir = Path.GetFullPath(cloneDir);
|
||||
var fullFile = Path.GetFullPath(full);
|
||||
if (!fullFile.StartsWith(fullCloneDir, StringComparison.Ordinal))
|
||||
var full = ResolveSafeFile(request.Path, out var status);
|
||||
if (status.HasValue)
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
|
||||
try
|
||||
|
|
@ -161,7 +171,8 @@ public class WorkflowFilesController : ControllerBase
|
|||
Directory.CreateDirectory(dir);
|
||||
|
||||
System.IO.File.WriteAllText(full, request.Content ?? string.Empty, new System.Text.UTF8Encoding(false));
|
||||
_logger.LogInformation("Saved workflow file {Path} for user {Login}", request.Path, ForgejoLogin);
|
||||
_logger.LogInformation("Saved workflow file {Path} for tenant {TenantId} ({Login})",
|
||||
request.Path, TenantId, MaybeForgejoLogin ?? "-");
|
||||
return Ok(new { success = true, path = request.Path });
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -171,15 +182,55 @@ public class WorkflowFilesController : ControllerBase
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the git status of the tenant's workflow repo.</summary>
|
||||
/// <summary>
|
||||
/// Deletes files/directories from the tenant's workflow repo. Used to remove a
|
||||
/// whole workflow (its folder) or an individual file. Returns the paths deleted.
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[RequireScope("manage")]
|
||||
public IActionResult DeleteFiles([FromBody] DeleteWorkflowFileRequest? request)
|
||||
{
|
||||
if (request == null || request.Paths == null || request.Paths.Length == 0)
|
||||
return BadRequest(new { error = "paths is required" });
|
||||
|
||||
var sourceDir = SourceDir;
|
||||
var fullSourceDir = Path.GetFullPath(sourceDir);
|
||||
var deleted = new List<string>();
|
||||
|
||||
foreach (var raw in request.Paths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) continue;
|
||||
var full = Path.GetFullPath(Path.Combine(sourceDir, raw.Replace('/', Path.DirectorySeparatorChar)));
|
||||
if (!full.StartsWith(fullSourceDir, StringComparison.Ordinal) ||
|
||||
full == fullSourceDir)
|
||||
continue;
|
||||
|
||||
if (Directory.Exists(full))
|
||||
Directory.Delete(full, recursive: true);
|
||||
else if (System.IO.File.Exists(full))
|
||||
System.IO.File.Delete(full);
|
||||
else
|
||||
continue;
|
||||
deleted.Add(raw);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Deleted workflow files for tenant {TenantId}: {Paths}",
|
||||
TenantId, string.Join(", ", deleted));
|
||||
return Ok(new { success = true, deleted });
|
||||
}
|
||||
|
||||
/// <summary>Returns the git status of the tenant's workflow repo (Forgejo-backed mode only).</summary>
|
||||
[HttpGet("status")]
|
||||
[RequireScope("read")]
|
||||
public IActionResult GetStatus()
|
||||
{
|
||||
if (_sourceFactory.Forgejo == null)
|
||||
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
|
||||
var login = MaybeForgejoLogin;
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
|
||||
|
||||
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
|
||||
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(login);
|
||||
if (!Directory.Exists(Path.Combine(cloneDir, ".git")))
|
||||
return Ok(new { branch = "", changed = Array.Empty<object>(), untracked = Array.Empty<object>() });
|
||||
|
||||
|
|
@ -206,41 +257,63 @@ public class WorkflowFilesController : ControllerBase
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits all changes and pushes to Forgejo. Optionally provides a commit message.
|
||||
/// After committing, call POST /api/workflows/sync to recompile.
|
||||
/// Commits and pushes all changes to Forgejo. A no-op (returns success) in
|
||||
/// filesystem-only mode. After committing, call POST /api/workflows/sync to
|
||||
/// recompile.
|
||||
/// </summary>
|
||||
[HttpPost("commit")]
|
||||
[RequireScope("manage")]
|
||||
public async Task<IActionResult> CommitAndPush([FromBody] CommitWorkflowRequest? request = null)
|
||||
{
|
||||
if (_sourceFactory.Forgejo == null)
|
||||
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
|
||||
return Ok(new { success = true, output = "filesystem mode — no remote to push" });
|
||||
var login = MaybeForgejoLogin;
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
|
||||
|
||||
var message = string.IsNullOrWhiteSpace(request?.Message)
|
||||
? $"Update workflow files ({DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC)"
|
||||
: request!.Message;
|
||||
|
||||
var (success, output) = await _sourceFactory.Forgejo.CommitAndPushAsync(
|
||||
ForgejoLogin, message, HttpContext.RequestAborted);
|
||||
login, message, HttpContext.RequestAborted);
|
||||
|
||||
_logger.LogInformation("Commit/push for login {Login}: {Success} ({Output})",
|
||||
ForgejoLogin, success, output?.Trim() ?? "");
|
||||
login, success, output?.Trim() ?? "");
|
||||
|
||||
return Ok(new { success, output });
|
||||
}
|
||||
|
||||
/// <summary>Pulls the latest changes from Forgejo.</summary>
|
||||
/// <summary>Pulls the latest changes from Forgejo (no-op in filesystem-only mode).</summary>
|
||||
[HttpPost("pull")]
|
||||
[RequireScope("manage")]
|
||||
public async Task<IActionResult> Pull()
|
||||
{
|
||||
if (_sourceFactory.Forgejo == null)
|
||||
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
|
||||
return Ok(new { success = true, output = "filesystem mode — no remote to pull" });
|
||||
var login = MaybeForgejoLogin;
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
|
||||
|
||||
var ok = await _sourceFactory.Forgejo.PullAsync(ForgejoLogin, HttpContext.RequestAborted);
|
||||
var ok = await _sourceFactory.Forgejo.PullAsync(login, HttpContext.RequestAborted);
|
||||
return Ok(new { success = ok });
|
||||
}
|
||||
|
||||
/// <summary>Resolves a repo-relative path to an absolute path inside the tenant source dir.</summary>
|
||||
private string ResolveSafeFile(string path, out int? status)
|
||||
{
|
||||
var sourceDir = SourceDir;
|
||||
var full = Path.GetFullPath(Path.Combine(sourceDir, path.Replace('/', Path.DirectorySeparatorChar)));
|
||||
var fullSourceDir = Path.GetFullPath(sourceDir);
|
||||
if (!full.StartsWith(fullSourceDir, StringComparison.Ordinal))
|
||||
{
|
||||
status = 400;
|
||||
return full;
|
||||
}
|
||||
status = null;
|
||||
return full;
|
||||
}
|
||||
|
||||
private string? RunGit(string workDir, params string[] args)
|
||||
{
|
||||
try
|
||||
|
|
@ -276,3 +349,4 @@ public class WorkflowFilesController : ControllerBase
|
|||
|
||||
public sealed record SaveWorkflowFileRequest(string Path, string? Content);
|
||||
public sealed record CommitWorkflowRequest(string? Message);
|
||||
public sealed record DeleteWorkflowFileRequest(string[] Paths);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ public sealed class ForgejoWorkflowRepoService
|
|||
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)
|
||||
|
|
@ -41,9 +40,11 @@ public sealed class ForgejoWorkflowRepoService
|
|||
?? 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>The Forgejo base URL (for building repo links).</summary>
|
||||
public string ForgejoBaseUrl => _forgejoBase;
|
||||
|
||||
/// <summary>True when Forgejo admin provisioning is configured.</summary>
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(_forgejoAdminToken) &&
|
||||
|
|
@ -187,8 +188,11 @@ public sealed class ForgejoWorkflowRepoService
|
|||
// 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);
|
||||
// NOTE: we deliberately do NOT seed shared example templates into a fresh
|
||||
// workflow repo. Workflows are strictly per-tenant: a tenant starts with
|
||||
// exactly the content of their own repo (empty for a freshly-created repo)
|
||||
// and builds up their own definitions. Seeding shared examples made every
|
||||
// tenant see the same "predefined" workflows and broke tenant isolation.
|
||||
|
||||
return tenantDir;
|
||||
}
|
||||
|
|
@ -257,100 +261,6 @@ public sealed class ForgejoWorkflowRepoService
|
|||
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
|
||||
|
|
|
|||
|
|
@ -32,30 +32,23 @@ namespace w4c_workflows.Services;
|
|||
/// </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;
|
||||
private readonly ForgejoWorkflowRepoService? _forgejo;
|
||||
private readonly NpgsqlDataSource? _ds;
|
||||
|
||||
public WorkflowSourceFactory(IConfiguration config, IWebHostEnvironment env, ILoggerFactory loggers,
|
||||
NpgsqlDataSource? ds = null)
|
||||
{
|
||||
_config = config;
|
||||
_env = env;
|
||||
_loggers = loggers;
|
||||
_ds = ds;
|
||||
|
||||
_copiesRoot = config["WorkflowSource:CopiesRoot"]
|
||||
_copiesRoot = Path.GetFullPath(
|
||||
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);
|
||||
"directory path (e.g. \"/data/workflow-tenants\" or \".data/workflow-tenants\")."));
|
||||
|
||||
// Create the Forgejo service when admin provisioning is configured.
|
||||
var forgejoLogger = loggers.CreateLogger<ForgejoWorkflowRepoService>();
|
||||
|
|
@ -84,25 +77,34 @@ public sealed class WorkflowSourceFactory
|
|||
/// Forgejo repo if needed), then returns a source backed by the clone.
|
||||
///
|
||||
/// In filesystem-only mode: the tenant directory
|
||||
/// <c>{CopiesRoot}/{sanitizedTenantId}/</c> is created (and seeded from
|
||||
/// the shared template dir) on first access.
|
||||
/// <c>{CopiesRoot}/{sanitizedTenantId}/</c> is created empty on first access
|
||||
/// (never seeded from shared templates — workflows are strictly per-tenant).
|
||||
/// </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 tenantDir = TenantSourceDir(tenantId);
|
||||
var logger = _loggers.CreateLogger<PerTenantWorkflowSource>();
|
||||
return new PerTenantWorkflowSource(tenantId, tenantDir, sharedDir, logger);
|
||||
return new PerTenantWorkflowSource(tenantId, tenantDir, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the absolute on-disk directory that holds a tenant's workflow
|
||||
/// files. In Forgejo-backed mode this is the per-user clone dir; in
|
||||
/// filesystem-only mode it is <c>{CopiesRoot}/{sanitizedTenantId}/</c>.
|
||||
/// Used by the workflow-file CRUD controller so edits always land in the same
|
||||
/// place <see cref="IWorkflowSource"/> reads from.
|
||||
/// </summary>
|
||||
public string ResolveTenantSourceDir(string tenantId, string? forgejoLogin = null)
|
||||
{
|
||||
if (_forgejo != null && !string.IsNullOrWhiteSpace(forgejoLogin))
|
||||
return _forgejo.TenantCloneDir(forgejoLogin);
|
||||
return TenantSourceDir(tenantId);
|
||||
}
|
||||
|
||||
/// <summary>The per-tenant filesystem source directory (not Forgejo-backed).</summary>
|
||||
public string TenantSourceDir(string tenantId)
|
||||
=> Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId)));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a per-tenant workflow source asynchronously. In Forgejo-backed
|
||||
/// mode this ensures the local clone exists (cloning from Forgejo if needed).
|
||||
|
|
@ -131,7 +133,7 @@ public sealed class WorkflowSourceFactory
|
|||
await _forgejo.PullAsync(login, ct);
|
||||
|
||||
var logger = _loggers.CreateLogger<PerTenantWorkflowSource>();
|
||||
return new PerTenantWorkflowSource(tenantId, cloneDir, _sharedDir, logger);
|
||||
return new PerTenantWorkflowSource(tenantId, cloneDir, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -170,30 +172,6 @@ public sealed class WorkflowSourceFactory
|
|||
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>
|
||||
|
|
@ -204,17 +182,15 @@ public sealed class WorkflowSourceFactory
|
|||
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)
|
||||
public PerTenantWorkflowSource(string tenantId, string tenantDir, ILogger<PerTenantWorkflowSource> logger)
|
||||
{
|
||||
_tenantDir = tenantDir;
|
||||
_sharedDir = sharedDir;
|
||||
_logger = logger;
|
||||
_logger.LogInformation(
|
||||
"Per-tenant workflow source: tenant={TenantId} dir={Dir} shared={Shared}",
|
||||
tenantId, _tenantDir, _sharedDir);
|
||||
"Per-tenant workflow source: tenant={TenantId} dir={Dir}",
|
||||
tenantId, _tenantDir);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
|
||||
|
|
@ -242,24 +218,17 @@ public sealed class PerTenantWorkflowSource : IWorkflowSource
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the tenant directory exists and has been seeded from the shared
|
||||
/// template directory (first-access initialization).
|
||||
/// Ensures the tenant directory exists. Workflows are strictly per-tenant:
|
||||
/// we never copy shared example templates into a tenant's directory, so a
|
||||
/// tenant sees only the definitions that belong to them.
|
||||
/// </summary>
|
||||
private void EnsureInitialized()
|
||||
{
|
||||
if (Directory.Exists(_tenantDir))
|
||||
return;
|
||||
|
||||
_logger.LogInformation("Initializing tenant workflow directory {Dir} from {Shared}", _tenantDir, _sharedDir);
|
||||
_logger.LogInformation("Initializing (empty) tenant workflow directory {Dir}", _tenantDir);
|
||||
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)
|
||||
|
|
@ -283,37 +252,6 @@ public sealed class PerTenantWorkflowSource : IWorkflowSource
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue