refactor(scripts): move Scripts API from scheduler to release-orchestrator

- Fix dual-schema violation (scheduler was writing to scheduler + scripts)
- Move ScriptsDataSource, PostgresScriptStore, script endpoints
- Update gateway routes and UI references
- Each service now owns exactly one schema

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-08 15:37:28 +03:00
parent 87eac86fb9
commit 13c4811e32
8 changed files with 95 additions and 46 deletions

View File

@@ -9,6 +9,7 @@ using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
using StellaOps.Router.AspNet;
using StellaOps.Scheduler.Plugin;
using StellaOps.Scheduler.ImpactIndex;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Persistence.Extensions;
@@ -29,12 +30,8 @@ using StellaOps.Scheduler.WebService.PolicyRuns;
using StellaOps.Scheduler.WebService.PolicySimulations;
using StellaOps.Scheduler.WebService.Runs;
using StellaOps.Scheduler.WebService.Schedules;
using StellaOps.Scheduler.WebService.Scripts;
using StellaOps.Scheduler.WebService.Exceptions;
using StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
using StellaOps.ReleaseOrchestrator.Scripts;
using StellaOps.ReleaseOrchestrator.Scripts.Persistence;
using StellaOps.ReleaseOrchestrator.Scripts.Search;
using StellaOps.Scheduler.Worker.Exceptions;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Options;
@@ -123,16 +120,6 @@ else
builder.Services.AddSingleton<ISchedulerAuditService, InMemorySchedulerAuditService>();
builder.Services.AddSingleton<IPolicyRunService, InMemoryPolicyRunService>();
}
// Scripts registry (shares the same Postgres options as Scheduler)
builder.Services.AddSingleton<ScriptsDataSource>();
builder.Services.AddSingleton<IScriptStore, PostgresScriptStore>();
builder.Services.AddSingleton<ISearchIndexer, InMemorySearchIndexer>();
builder.Services.AddSingleton<IScriptValidator, ScriptValidator>();
builder.Services.AddSingleton<ILanguageValidator, CSharpScriptValidator>();
builder.Services.AddSingleton<ILanguageValidator, PythonScriptValidator>();
builder.Services.AddSingleton<ILanguageValidator, TypeScriptScriptValidator>();
builder.Services.AddSingleton<IScriptRegistry, ScriptRegistry>();
// Workflow engine HTTP client (starts workflow instances for system schedules)
builder.Services.AddHttpClient<StellaOps.Scheduler.WebService.Workflow.WorkflowTriggerClient>((sp, client) =>
{
@@ -188,6 +175,28 @@ var pluginHostOptions = SchedulerPluginHostFactory.Build(schedulerOptions.Plugin
builder.Services.AddSingleton(pluginHostOptions);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
// Scheduler plugin registry: discover and register ISchedulerJobPlugin implementations
var pluginRegistry = new SchedulerPluginRegistry();
// Register built-in scan plugin (default for all existing schedules)
var scanPlugin = new ScanJobPlugin();
pluginRegistry.Register(scanPlugin);
// Discover ISchedulerJobPlugin implementations from assembly-loaded plugins
var loadResult = PluginHost.LoadPlugins(pluginHostOptions);
foreach (var pluginAssembly in loadResult.Plugins)
{
var jobPlugins = StellaOps.Plugin.PluginLoader.LoadPlugins<ISchedulerJobPlugin>(
new[] { pluginAssembly.Assembly });
foreach (var jobPlugin in jobPlugins)
{
pluginRegistry.Register(jobPlugin);
jobPlugin.ConfigureServices(builder.Services, builder.Configuration);
}
}
builder.Services.AddSingleton<ISchedulerPluginRegistry>(pluginRegistry);
if (authorityOptions.Enabled)
{
builder.Services.AddHttpContextAccessor();
@@ -319,7 +328,13 @@ app.MapFailureSignatureEndpoints();
app.MapPolicyRunEndpoints();
app.MapPolicySimulationEndpoints();
app.MapSchedulerEventWebhookEndpoints();
app.MapScriptsEndpoints();
// Map plugin-provided endpoints (e.g., Doctor trend endpoints)
foreach (var (jobKind, _) in pluginRegistry.ListRegistered())
{
var plugin = pluginRegistry.Resolve(jobKind);
plugin?.MapEndpoints(app);
}
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerEnabled);

View File

@@ -1,408 +0,0 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.ReleaseOrchestrator.Scripts;
using StellaOps.Scheduler.WebService.Auth;
using StellaOps.Scheduler.WebService.Security;
namespace StellaOps.Scheduler.WebService.Scripts;
/// <summary>
/// Minimal API endpoints for the Scripts registry (/api/v2/scripts).
/// </summary>
internal static class ScriptsEndpoints
{
private static readonly JsonSerializerOptions s_json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public static IEndpointRouteBuilder MapScriptsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v2/scripts")
.RequireAuthorization(SchedulerPolicies.Read)
.RequireTenant();
group.MapGet("/", ListScriptsAsync)
.WithName("ListScripts")
.WithDescription("List scripts with optional search, language, and visibility filters.");
group.MapGet("/{scriptId}", GetScriptAsync)
.WithName("GetScript")
.WithDescription("Get a single script by ID.");
group.MapPost("/", CreateScriptAsync)
.WithName("CreateScript")
.WithDescription("Create a new script.")
.RequireAuthorization(SchedulerPolicies.Operate);
group.MapPatch("/{scriptId}", UpdateScriptAsync)
.WithName("UpdateScript")
.WithDescription("Update an existing script.")
.RequireAuthorization(SchedulerPolicies.Operate);
group.MapDelete("/{scriptId}", DeleteScriptAsync)
.WithName("DeleteScript")
.WithDescription("Delete a script.")
.RequireAuthorization(SchedulerPolicies.Operate);
group.MapPost("/validate", ValidateScriptAsync)
.WithName("ValidateScript")
.WithDescription("Validate script syntax without saving.");
group.MapGet("/{scriptId}/versions", GetVersionsAsync)
.WithName("ListScriptVersions")
.WithDescription("List all versions of a script.");
group.MapGet("/{scriptId}/versions/{version:int}/content", GetVersionContentAsync)
.WithName("GetScriptVersionContent")
.WithDescription("Get the content of a specific script version.");
group.MapPost("/{scriptId}/check-compatibility", CheckCompatibilityAsync)
.WithName("CheckScriptCompatibility")
.WithDescription("Check script compatibility with a target environment.");
return routes;
}
// ── List ────────────────────────────────────────────────────────────────
private static async Task<IResult> ListScriptsAsync(
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
try
{
var q = httpContext.Request.Query;
var criteria = new ScriptSearchCriteria
{
SearchText = q.TryGetValue("search", out var s) ? s.ToString() : null,
Language = q.TryGetValue("language", out var lang) ? ParseLanguage(lang.ToString()) : null,
Visibility = q.TryGetValue("visibility", out var vis) ? ParseVisibility(vis.ToString()) : null,
Limit = q.TryGetValue("limit", out var lim) && int.TryParse(lim.ToString(), out var l) ? l : 20,
Offset = q.TryGetValue("offset", out var off) && int.TryParse(off.ToString(), out var o) ? o : 0,
};
var result = await registry.SearchAsync(criteria, ct).ConfigureAwait(false);
var dtos = result.Scripts.Select(ToDto).ToArray();
return Results.Json(dtos, s_json);
}
catch (Exception ex) when (ex is ArgumentException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// ── Get ─────────────────────────────────────────────────────────────────
private static async Task<IResult> GetScriptAsync(
string scriptId,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
var script = await registry.GetScriptAsync(scriptId, ct).ConfigureAwait(false);
if (script is null) return Results.NotFound(new { error = $"Script '{scriptId}' not found." });
return Results.Json(ToDto(script), s_json);
}
// ── Create ──────────────────────────────────────────────────────────────
private static async Task<IResult> CreateScriptAsync(
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
[FromServices] ITenantContextAccessor tenantAccessor,
CancellationToken ct)
{
try
{
var body = await JsonSerializer.DeserializeAsync<CreateScriptDto>(httpContext.Request.Body, s_json, ct).ConfigureAwait(false);
if (body is null) return Results.BadRequest(new { error = "Invalid request body." });
var tenant = tenantAccessor.GetTenant(httpContext);
var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
var request = new CreateScriptRequest
{
Name = body.Name,
Description = body.Description,
Language = ParseLanguageRequired(body.Language),
Content = body.Content,
Tags = body.Tags?.ToImmutableArray(),
Visibility = ParseVisibilityRequired(body.Visibility),
};
var script = await registry.CreateScriptAsync(request, userId, ct: ct).ConfigureAwait(false);
// Update variables on the created script via store if provided
return Results.Json(ToDto(script), s_json, statusCode: StatusCodes.Status201Created);
}
catch (ScriptValidationException ex)
{
return Results.BadRequest(new { error = ex.Message, errors = ex.Errors });
}
catch (Exception ex) when (ex is ArgumentException or JsonException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// ── Update ──────────────────────────────────────────────────────────────
private static async Task<IResult> UpdateScriptAsync(
string scriptId,
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
[FromServices] ITenantContextAccessor tenantAccessor,
CancellationToken ct)
{
try
{
var body = await JsonSerializer.DeserializeAsync<UpdateScriptDto>(httpContext.Request.Body, s_json, ct).ConfigureAwait(false);
if (body is null) return Results.BadRequest(new { error = "Invalid request body." });
var tenant = tenantAccessor.GetTenant(httpContext);
var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
var request = new UpdateScriptRequest
{
Name = body.Name,
Description = body.Description,
Content = body.Content,
Tags = body.Tags?.ToImmutableArray(),
Visibility = body.Visibility is not null ? ParseVisibilityRequired(body.Visibility) : null,
ChangeNote = body.ChangeNotes,
};
var updated = await registry.UpdateScriptAsync(scriptId, request, userId, ct).ConfigureAwait(false);
return Results.Json(ToDto(updated), s_json);
}
catch (ScriptNotFoundException)
{
return Results.NotFound(new { error = $"Script '{scriptId}' not found." });
}
catch (ScriptValidationException ex)
{
return Results.BadRequest(new { error = ex.Message, errors = ex.Errors });
}
catch (Exception ex) when (ex is ArgumentException or JsonException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// ── Delete ──────────────────────────────────────────────────────────────
private static async Task<IResult> DeleteScriptAsync(
string scriptId,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
var deleted = await registry.DeleteScriptAsync(scriptId, ct).ConfigureAwait(false);
return deleted
? Results.NoContent()
: Results.NotFound(new { error = $"Script '{scriptId}' not found." });
}
// ── Validate ────────────────────────────────────────────────────────────
private static async Task<IResult> ValidateScriptAsync(
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
try
{
var body = await JsonSerializer.DeserializeAsync<ValidateScriptDto>(httpContext.Request.Body, s_json, ct).ConfigureAwait(false);
if (body is null) return Results.BadRequest(new { error = "Invalid request body." });
var language = ParseLanguageRequired(body.Language);
var result = await registry.ValidateAsync(language, body.Content, ct).ConfigureAwait(false);
var response = new
{
isValid = result.IsValid,
errors = result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => new
{
line = d.Line,
column = d.Column,
message = d.Message,
severity = "error",
category = "syntax",
}).ToArray(),
warnings = result.Diagnostics.Where(d => d.Severity != DiagnosticSeverity.Error).Select(d => new
{
line = d.Line,
column = d.Column,
message = d.Message,
severity = d.Severity == DiagnosticSeverity.Warning ? "warning" : "info",
category = "syntax",
}).ToArray(),
};
return Results.Json(response, s_json);
}
catch (Exception ex) when (ex is ArgumentException or JsonException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// ── Versions ────────────────────────────────────────────────────────────
private static async Task<IResult> GetVersionsAsync(
string scriptId,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
var versions = await registry.GetScriptVersionsAsync(scriptId, ct).ConfigureAwait(false);
var dtos = versions.Select(v => new
{
version = v.Version,
contentHash = v.ContentHash,
createdBy = v.CreatedBy,
createdAt = v.CreatedAt,
changeNotes = v.ChangeNote,
}).ToArray();
return Results.Json(dtos, s_json);
}
private static async Task<IResult> GetVersionContentAsync(
string scriptId,
int version,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
var ver = await registry.GetScriptVersionAsync(scriptId, version, ct).ConfigureAwait(false);
if (ver is null) return Results.NotFound(new { error = $"Version {version} not found for script '{scriptId}'." });
var dto = new
{
version = ver.Version,
contentHash = ver.ContentHash,
createdBy = ver.CreatedBy,
createdAt = ver.CreatedAt,
changeNotes = ver.ChangeNote,
content = ver.Content,
};
return Results.Json(dto, s_json);
}
// ── Compatibility ───────────────────────────────────────────────────────
private static async Task<IResult> CheckCompatibilityAsync(
string scriptId,
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
// Stub: always compatible. Real implementation will check runtime/target matrix.
var script = await registry.GetScriptAsync(scriptId, ct).ConfigureAwait(false);
if (script is null) return Results.NotFound(new { error = $"Script '{scriptId}' not found." });
var response = new { isCompatible = true, issues = Array.Empty<object>() };
return Results.Json(response, s_json);
}
// ── DTO mapping ─────────────────────────────────────────────────────────
private static object ToDto(Script s) => new
{
id = s.Id,
name = s.Name,
description = s.Description,
language = s.Language.ToString().ToLowerInvariant(),
content = s.Content,
version = s.Version,
visibility = s.Visibility.ToString().ToLowerInvariant(),
ownerId = s.OwnerId,
teamId = s.TeamId,
tags = s.Tags.ToArray(),
variables = s.Variables.Select(v => new
{
name = v.Name,
description = v.Description,
isRequired = v.IsRequired,
defaultValue = v.DefaultValue,
isSecret = v.IsSecret,
}).ToArray(),
contentHash = s.ContentHash,
isSample = s.IsSample,
sampleCategory = s.SampleCategory,
createdAt = s.CreatedAt,
updatedAt = s.UpdatedAt,
};
// ── DTO types ───────────────────────────────────────────────────────────
private sealed record CreateScriptDto
{
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public string Language { get; init; } = "";
public string Content { get; init; } = "";
public string Visibility { get; init; } = "private";
public string[]? Tags { get; init; }
public ScriptVariableDto[]? Variables { get; init; }
}
private sealed record UpdateScriptDto
{
public string? Name { get; init; }
public string? Description { get; init; }
public string? Content { get; init; }
public string? Visibility { get; init; }
public string[]? Tags { get; init; }
public ScriptVariableDto[]? Variables { get; init; }
public string? ChangeNotes { get; init; }
}
private sealed record ValidateScriptDto
{
public string Language { get; init; } = "";
public string Content { get; init; } = "";
}
private sealed record ScriptVariableDto
{
public string Name { get; init; } = "";
public string? Description { get; init; }
public bool IsRequired { get; init; }
public string? DefaultValue { get; init; }
public bool IsSecret { get; init; }
}
// ── Enum parsing ────────────────────────────────────────────────────────
private static ScriptLanguage? ParseLanguage(string? value) => value?.ToLowerInvariant() switch
{
"csharp" => ScriptLanguage.CSharp,
"python" => ScriptLanguage.Python,
"java" => ScriptLanguage.Java,
"go" => ScriptLanguage.Go,
"bash" => ScriptLanguage.Bash,
"typescript" => ScriptLanguage.TypeScript,
"powershell" => ScriptLanguage.PowerShell,
_ => null,
};
private static ScriptLanguage ParseLanguageRequired(string value)
=> ParseLanguage(value) ?? throw new ArgumentException($"Unknown language: '{value}'");
private static ScriptVisibility? ParseVisibility(string? value) => value?.ToLowerInvariant() switch
{
"private" => ScriptVisibility.Private,
"team" => ScriptVisibility.Team,
"organization" => ScriptVisibility.Organization,
"public" => ScriptVisibility.Public,
_ => null,
};
private static ScriptVisibility ParseVisibilityRequired(string value)
=> ParseVisibility(value) ?? throw new ArgumentException($"Unknown visibility: '{value}'");
}

View File

@@ -11,6 +11,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
<ProjectReference Include="../StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/StellaOps.Scheduler.Plugin.Abstractions.csproj" />
<ProjectReference Include="../StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.ImpactIndex/StellaOps.Scheduler.ImpactIndex.csproj" />
<ProjectReference Include="../StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Queue/StellaOps.Scheduler.Queue.csproj" />
<ProjectReference Include="../StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/StellaOps.Scheduler.Persistence.csproj" />
@@ -23,7 +24,6 @@
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
<ProjectReference Include="../../ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/StellaOps.ReleaseOrchestrator.Scripts.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Translations\*.json" />