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

@@ -688,10 +688,10 @@ services:
labels: *release-labels
# --- Slot 10: Excititor ----------------------------------------------------
excititor:
excititor-web:
<<: *resources-medium
image: stellaops/excititor:dev
container_name: stellaops-excititor
image: stellaops/excititor-web:dev
container_name: stellaops-excititor-web
restart: unless-stopped
environment:
ASPNETCORE_URLS: "http://+:8080"
@@ -962,6 +962,9 @@ services:
Authority__ResourceServer__BypassNetworks__2: "::1/128"
Authority__ResourceServer__BypassNetworks__3: "0.0.0.0/0"
Authority__ResourceServer__BypassNetworks__4: "::/0"
# Scripts schema (moved from scheduler to release-orchestrator)
Scripts__Postgres__ConnectionString: "${STELLAOPS_POSTGRES_CONNECTION}"
Scripts__Postgres__SchemaName: "scripts"
Router__Enabled: "${RELEASE_ORCHESTRATOR_ROUTER_ENABLED:-true}"
Router__Messaging__ConsumerGroup: "release-orchestrator"
volumes:
@@ -1709,10 +1712,10 @@ services:
labels: *release-labels
# --- Slot 40: ExportCenter -------------------------------------------------
export:
export-web:
<<: *resources-light
image: stellaops/export:dev
container_name: stellaops-export
image: stellaops/export-web:dev
container_name: stellaops-export-web
restart: unless-stopped
environment:
ASPNETCORE_URLS: "http://+:8080"

View File

@@ -4,6 +4,7 @@
CREATE SCHEMA IF NOT EXISTS scanner;
CREATE SCHEMA IF NOT EXISTS vex;
CREATE SCHEMA IF NOT EXISTS scheduler;
CREATE SCHEMA IF NOT EXISTS scripts;
CREATE SCHEMA IF NOT EXISTS policy;
CREATE SCHEMA IF NOT EXISTS notify;
CREATE SCHEMA IF NOT EXISTS notifier;

View File

@@ -106,7 +106,7 @@
{ "Type": "Microservice", "Path": "^/api/v2/topology(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/topology$1" },
{ "Type": "Microservice", "Path": "^/api/v2/evidence(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/evidence$1" },
{ "Type": "Microservice", "Path": "^/api/v2/integrations(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/integrations$1" },
{ "Type": "Microservice", "Path": "^/api/v2/scripts(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/v2/scripts$1" },
{ "Type": "Microservice", "Path": "^/api/v2/scripts(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v2/scripts$1" },
{ "Type": "Microservice", "Path": "^/api/v1/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v1/$1$2" },
{ "Type": "Microservice", "Path": "^/api/v2/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v2/$1$2" },

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

@@ -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" />

View File

@@ -6,13 +6,12 @@ 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;
namespace StellaOps.ReleaseOrchestrator.WebApi.Endpoints;
/// <summary>
/// Minimal API endpoints for the Scripts registry (/api/v2/scripts).
/// Moved from Scheduler to Release-Orchestrator so each service owns exactly one schema.
/// </summary>
internal static class ScriptsEndpoints
{
@@ -26,7 +25,7 @@ internal static class ScriptsEndpoints
public static IEndpointRouteBuilder MapScriptsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v2/scripts")
.RequireAuthorization(SchedulerPolicies.Read)
.RequireAuthorization(ReleaseOrchestratorPolicies.Read)
.RequireTenant();
group.MapGet("/", ListScriptsAsync)
@@ -40,17 +39,17 @@ internal static class ScriptsEndpoints
group.MapPost("/", CreateScriptAsync)
.WithName("CreateScript")
.WithDescription("Create a new script.")
.RequireAuthorization(SchedulerPolicies.Operate);
.RequireAuthorization(ReleaseOrchestratorPolicies.Operate);
group.MapPatch("/{scriptId}", UpdateScriptAsync)
.WithName("UpdateScript")
.WithDescription("Update an existing script.")
.RequireAuthorization(SchedulerPolicies.Operate);
.RequireAuthorization(ReleaseOrchestratorPolicies.Operate);
group.MapDelete("/{scriptId}", DeleteScriptAsync)
.WithName("DeleteScript")
.WithDescription("Delete a script.")
.RequireAuthorization(SchedulerPolicies.Operate);
.RequireAuthorization(ReleaseOrchestratorPolicies.Operate);
group.MapPost("/validate", ValidateScriptAsync)
.WithName("ValidateScript")
@@ -71,7 +70,7 @@ internal static class ScriptsEndpoints
return routes;
}
// ── List ────────────────────────────────────────────────────────────────
// -- List ----------------------------------------------------------------
private static async Task<IResult> ListScriptsAsync(
HttpContext httpContext,
@@ -100,7 +99,7 @@ internal static class ScriptsEndpoints
}
}
// ── Get ─────────────────────────────────────────────────────────────────
// -- Get -----------------------------------------------------------------
private static async Task<IResult> GetScriptAsync(
string scriptId,
@@ -112,12 +111,12 @@ internal static class ScriptsEndpoints
return Results.Json(ToDto(script), s_json);
}
// ── Create ──────────────────────────────────────────────────────────────
// -- Create --------------------------------------------------------------
private static async Task<IResult> CreateScriptAsync(
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
CancellationToken ct)
{
try
@@ -125,7 +124,7 @@ internal static class ScriptsEndpoints
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 tenantId = tenantAccessor.TenantId;
var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
var request = new CreateScriptRequest
@@ -140,7 +139,6 @@ internal static class ScriptsEndpoints
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)
@@ -153,13 +151,13 @@ internal static class ScriptsEndpoints
}
}
// ── Update ──────────────────────────────────────────────────────────────
// -- Update --------------------------------------------------------------
private static async Task<IResult> UpdateScriptAsync(
string scriptId,
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
CancellationToken ct)
{
try
@@ -167,7 +165,7 @@ internal static class ScriptsEndpoints
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 tenantId = tenantAccessor.TenantId;
var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
var request = new UpdateScriptRequest
@@ -197,7 +195,7 @@ internal static class ScriptsEndpoints
}
}
// ── Delete ──────────────────────────────────────────────────────────────
// -- Delete --------------------------------------------------------------
private static async Task<IResult> DeleteScriptAsync(
string scriptId,
@@ -210,7 +208,7 @@ internal static class ScriptsEndpoints
: Results.NotFound(new { error = $"Script '{scriptId}' not found." });
}
// ── Validate ────────────────────────────────────────────────────────────
// -- Validate ------------------------------------------------------------
private static async Task<IResult> ValidateScriptAsync(
HttpContext httpContext,
@@ -253,7 +251,7 @@ internal static class ScriptsEndpoints
}
}
// ── Versions ────────────────────────────────────────────────────────────
// -- Versions ------------------------------------------------------------
private static async Task<IResult> GetVersionsAsync(
string scriptId,
@@ -293,7 +291,7 @@ internal static class ScriptsEndpoints
return Results.Json(dto, s_json);
}
// ── Compatibility ───────────────────────────────────────────────────────
// -- Compatibility -------------------------------------------------------
private static async Task<IResult> CheckCompatibilityAsync(
string scriptId,
@@ -309,7 +307,7 @@ internal static class ScriptsEndpoints
return Results.Json(response, s_json);
}
// ── DTO mapping ─────────────────────────────────────────────────────────
// -- DTO mapping ---------------------------------------------------------
private static object ToDto(Script s) => new
{
@@ -338,7 +336,7 @@ internal static class ScriptsEndpoints
updatedAt = s.UpdatedAt,
};
// ── DTO types ───────────────────────────────────────────────────────────
// -- DTO types -----------------------------------------------------------
private sealed record CreateScriptDto
{
@@ -377,7 +375,7 @@ internal static class ScriptsEndpoints
public bool IsSecret { get; init; }
}
// ── Enum parsing ────────────────────────────────────────────────────────
// -- Enum parsing --------------------------------------------------------
private static ScriptLanguage? ParseLanguage(string? value) => value?.ToLowerInvariant() switch
{

View File

@@ -1,7 +1,11 @@
using StellaOps.Router.AspNet;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.JobEngine.Infrastructure;
using StellaOps.ReleaseOrchestrator.Scripts;
using StellaOps.ReleaseOrchestrator.Scripts.Persistence;
using StellaOps.ReleaseOrchestrator.Scripts.Search;
using StellaOps.ReleaseOrchestrator.WebApi;
using StellaOps.ReleaseOrchestrator.WebApi.Endpoints;
using StellaOps.ReleaseOrchestrator.WebApi.Services;
@@ -40,6 +44,32 @@ builder.Services.AddSingleton<InMemoryDeploymentCompatibilityStore>();
builder.Services.AddSingleton<IDeploymentCompatibilityStore>(sp =>
sp.GetRequiredService<InMemoryDeploymentCompatibilityStore>());
// Scripts registry (owns the 'scripts' schema — moved from scheduler to fix dual-schema violation)
var scriptsSection = builder.Configuration.GetSection("Scripts:Postgres");
if (scriptsSection.Exists())
{
builder.Services.Configure<PostgresOptions>(scriptsSection);
}
else
{
// Fallback: reuse the default connection string with scripts schema
builder.Services.Configure<PostgresOptions>(opt =>
{
opt.ConnectionString = builder.Configuration.GetConnectionString("Default")
?? builder.Configuration["ConnectionStrings__Default"]
?? "Host=localhost;Database=stellaops_platform;Username=stellaops;Password=stellaops";
opt.SchemaName = "scripts";
});
}
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>();
// Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
@@ -68,6 +98,7 @@ app.MapReleaseControlV2Endpoints();
app.MapEvidenceEndpoints();
app.MapAuditEndpoints();
app.MapFirstSignalEndpoints();
app.MapScriptsEndpoints();
app.TryRefreshStellaRouterEndpoints(routerEnabled);

View File

@@ -20,6 +20,7 @@
<ProjectReference Include="..\..\..\JobEngine\StellaOps.JobEngine\StellaOps.JobEngine.Core\StellaOps.JobEngine.Core.csproj" />
<ProjectReference Include="..\..\..\JobEngine\StellaOps.JobEngine\StellaOps.JobEngine.Infrastructure\StellaOps.JobEngine.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.ReleaseOrchestrator.Scripts\StellaOps.ReleaseOrchestrator.Scripts.csproj" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">