diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/ScriptApiModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/ScriptApiModels.cs index abf32f15f..614e9b099 100644 --- a/src/Platform/StellaOps.Platform.WebService/Contracts/ScriptApiModels.cs +++ b/src/Platform/StellaOps.Platform.WebService/Contracts/ScriptApiModels.cs @@ -14,7 +14,8 @@ public enum ScriptLanguageDto Java, Go, Bash, - TypeScript + TypeScript, + PowerShell } /// diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs index a55666641..e122b800c 100644 --- a/src/Platform/StellaOps.Platform.WebService/Program.cs +++ b/src/Platform/StellaOps.Platform.WebService/Program.cs @@ -17,6 +17,8 @@ using StellaOps.Platform.WebService.Endpoints; using StellaOps.Platform.WebService.Options; using StellaOps.Platform.WebService.Services; using StellaOps.ReleaseOrchestrator.Environment.Postgres; +using StellaOps.ReleaseOrchestrator.Scripts; +using StellaOps.ReleaseOrchestrator.Scripts.Persistence; using StellaOps.Router.AspNet; using StellaOps.Signals.UnifiedScore; using StellaOps.Telemetry.Core; @@ -388,7 +390,21 @@ builder.Services.AddSingleton(); // Script registry services (multi-language script editor) -builder.Services.AddSingleton(); +if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString)) +{ + builder.Services.AddOptions() + .Configure(options => + { + options.ConnectionString = bootstrapOptions.Storage.PostgresConnectionString!; + options.SchemaName = ScriptsDataSource.DefaultSchemaName; + }); + builder.Services.AddReleaseOrchestratorScripts(); + builder.Services.AddSingleton(); +} +else +{ + builder.Services.AddSingleton(); +} // Unified audit emission (posts audit events to Timeline service) builder.Services.AddAuditEmission(builder.Configuration); diff --git a/src/Platform/StellaOps.Platform.WebService/Services/ReleaseOrchestratorScriptService.cs b/src/Platform/StellaOps.Platform.WebService/Services/ReleaseOrchestratorScriptService.cs new file mode 100644 index 000000000..8e50105f2 --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Services/ReleaseOrchestratorScriptService.cs @@ -0,0 +1,332 @@ +using System.Collections.Immutable; +using StellaOps.Platform.WebService.Contracts; +using StellaOps.ReleaseOrchestrator.Scripts; + +namespace StellaOps.Platform.WebService.Services; + +/// +/// Platform script service backed by the Release Orchestrator scripts registry. +/// +public sealed class ReleaseOrchestratorScriptService : IScriptService +{ + private readonly IScriptRegistry _registry; + private readonly IScriptCompatibilityEvaluator _compatibilityEvaluator; + + public ReleaseOrchestratorScriptService( + IScriptRegistry registry, + IScriptCompatibilityEvaluator compatibilityEvaluator) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _compatibilityEvaluator = compatibilityEvaluator ?? throw new ArgumentNullException(nameof(compatibilityEvaluator)); + } + + public async Task> SearchAsync( + string tenantId, + ScriptLanguageDto? language, + ScriptVisibilityDto? visibility, + string? search, + int limit, + int offset, + CancellationToken cancellationToken = default) + { + var result = await SearchCoreAsync(language, visibility, search, limit, offset, cancellationToken).ConfigureAwait(false); + return result.Scripts.Select(ToSummary).ToArray(); + } + + public async Task CountAsync( + string tenantId, + ScriptLanguageDto? language, + ScriptVisibilityDto? visibility, + string? search, + CancellationToken cancellationToken = default) + { + var result = await SearchCoreAsync(language, visibility, search, limit: 1, offset: 0, cancellationToken).ConfigureAwait(false); + return result.TotalCount; + } + + public async Task GetByIdAsync( + string tenantId, + string scriptId, + CancellationToken cancellationToken = default) + { + var script = await _registry.GetScriptAsync(scriptId, cancellationToken).ConfigureAwait(false); + return script is null ? null : ToDetail(script); + } + + public async Task CreateAsync( + string tenantId, + string actorId, + CreateScriptApiRequest request, + CancellationToken cancellationToken = default) + { + var created = await _registry.CreateScriptAsync( + new CreateScriptRequest + { + Name = request.Name, + Description = request.Description, + Language = MapLanguage(request.Language), + Content = request.Content, + EntryPoint = request.EntryPoint, + Dependencies = request.Dependencies?.Select(ToDependency).ToImmutableArray(), + Tags = request.Tags?.ToImmutableArray(), + Variables = request.Variables?.Select(ToVariable).ToImmutableArray(), + Visibility = MapVisibility(request.Visibility), + }, + actorId, + ct: cancellationToken).ConfigureAwait(false); + + return ToDetail(created); + } + + public async Task UpdateAsync( + string tenantId, + string actorId, + string scriptId, + UpdateScriptApiRequest request, + CancellationToken cancellationToken = default) + { + var updated = await _registry.UpdateScriptAsync( + scriptId, + new UpdateScriptRequest + { + Name = request.Name, + Description = request.Description, + Content = request.Content, + EntryPoint = request.EntryPoint, + Dependencies = request.Dependencies?.Select(ToDependency).ToImmutableArray(), + Tags = request.Tags?.ToImmutableArray(), + Variables = request.Variables?.Select(ToVariable).ToImmutableArray(), + Visibility = request.Visibility is null ? null : MapVisibility(request.Visibility.Value), + ChangeNote = request.ChangeNote, + }, + actorId, + cancellationToken).ConfigureAwait(false); + + return ToDetail(updated); + } + + public Task DeleteAsync( + string tenantId, + string scriptId, + CancellationToken cancellationToken = default) + => _registry.DeleteScriptAsync(scriptId, cancellationToken); + + public async Task> GetVersionsAsync( + string tenantId, + string scriptId, + CancellationToken cancellationToken = default) + { + var versions = await _registry.GetScriptVersionsAsync(scriptId, cancellationToken).ConfigureAwait(false); + return versions.Select(ToVersion).ToArray(); + } + + public async Task ValidateAsync( + ValidateScriptApiRequest request, + CancellationToken cancellationToken = default) + { + var validation = await _registry.ValidateAsync( + MapLanguage(request.Language), + request.Content, + cancellationToken).ConfigureAwait(false); + + return new ScriptValidationResultDto( + validation.IsValid, + validation.Errors, + validation.Diagnostics.Select(ToDiagnostic).ToArray()); + } + + public async Task GetVersionContentAsync( + string tenantId, + string scriptId, + int version, + CancellationToken cancellationToken = default) + { + var scriptVersion = await _registry.GetScriptVersionAsync(scriptId, version, cancellationToken).ConfigureAwait(false); + return scriptVersion is null ? null : ToVersionDetail(scriptVersion); + } + + public async Task CheckCompatibilityAsync( + string tenantId, + string scriptId, + CheckCompatibilityRequest request, + CancellationToken cancellationToken = default) + { + var script = await _registry.GetScriptAsync(scriptId, cancellationToken).ConfigureAwait(false); + if (script is null) + { + throw new InvalidOperationException("script_not_found"); + } + + var result = await _compatibilityEvaluator.EvaluateAsync( + script, + new ScriptCompatibilityRequest + { + TargetType = request.TargetType, + TargetMetadata = request.TargetMetadata ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + AvailableSecrets = request.AvailableSecrets ?? Array.Empty(), + }, + cancellationToken).ConfigureAwait(false); + + return new CompatibilityResultDto( + result.IsCompatible, + result.Issues.Select(issue => new CompatibilityIssue(issue.Category, issue.Severity, issue.Message)).ToArray()); + } + + private async Task SearchCoreAsync( + ScriptLanguageDto? language, + ScriptVisibilityDto? visibility, + string? search, + int limit, + int offset, + CancellationToken cancellationToken) + { + return await _registry.SearchAsync( + new ScriptSearchCriteria + { + SearchText = string.IsNullOrWhiteSpace(search) ? null : search.Trim(), + Language = language is null ? null : MapLanguage(language.Value), + Visibility = visibility is null ? null : MapVisibility(visibility.Value), + Limit = Math.Max(limit, 1), + Offset = Math.Max(offset, 0), + }, + cancellationToken).ConfigureAwait(false); + } + + private static ScriptSummary ToSummary(Script script) => new( + script.Id, + script.Name, + script.Description, + MapLanguage(script.Language), + MapVisibility(script.Visibility), + script.Version, + script.Tags, + script.Variables.Select(ToVariableDeclaration).ToArray(), + script.OwnerId, + script.TeamId, + script.CreatedAt, + script.UpdatedAt, + script.ContentHash); + + private static ScriptDetail ToDetail(Script script) => new( + script.Id, + script.Name, + script.Description, + MapLanguage(script.Language), + script.Content, + script.EntryPoint, + MapVisibility(script.Visibility), + script.Version, + script.Dependencies.Select(ToDependencyDto).ToArray(), + script.Tags, + script.Variables.Select(ToVariableDeclaration).ToArray(), + script.OwnerId, + script.TeamId, + script.CreatedAt, + script.UpdatedAt, + script.ContentHash, + script.IsSample, + script.SampleCategory); + + private static ScriptVersionDto ToVersion(ScriptVersion version) => new( + version.ScriptId, + version.Version, + version.ContentHash, + version.Dependencies.Select(ToDependencyDto).ToArray(), + version.CreatedAt, + version.CreatedBy, + version.ChangeNote); + + private static ScriptVersionDetailDto ToVersionDetail(ScriptVersion version) => new( + version.ScriptId, + version.Version, + version.ContentHash, + version.Content, + version.CreatedAt, + version.CreatedBy, + version.ChangeNote); + + private static ScriptVariableDeclaration ToVariableDeclaration(ScriptVariable variable) => new( + variable.Name, + variable.Description, + variable.IsRequired, + variable.DefaultValue, + variable.IsSecret); + + private static ScriptVariable ToVariable(ScriptVariableDeclaration variable) => new() + { + Name = variable.Name, + Description = variable.Description, + IsRequired = variable.IsRequired, + DefaultValue = variable.DefaultValue, + IsSecret = variable.IsSecret, + }; + + private static ScriptDependency ToDependency(ScriptDependencyDto dependency) => new() + { + Name = dependency.Name, + Version = dependency.Version, + Source = dependency.Source, + IsDevelopment = dependency.IsDevelopment, + }; + + private static ScriptDependencyDto ToDependencyDto(ScriptDependency dependency) => new( + dependency.Name, + dependency.Version, + dependency.Source, + dependency.IsDevelopment); + + private static ScriptDiagnosticDto ToDiagnostic(ScriptDiagnostic diagnostic) => new( + diagnostic.Severity switch + { + DiagnosticSeverity.Warning => ScriptDiagnosticSeverityDto.Warning, + DiagnosticSeverity.Error => ScriptDiagnosticSeverityDto.Error, + _ => ScriptDiagnosticSeverityDto.Info, + }, + diagnostic.Message, + diagnostic.Line, + diagnostic.Column, + diagnostic.EndLine, + diagnostic.EndColumn); + + private static ScriptLanguage MapLanguage(ScriptLanguageDto language) => language switch + { + ScriptLanguageDto.CSharp => ScriptLanguage.CSharp, + ScriptLanguageDto.Python => ScriptLanguage.Python, + ScriptLanguageDto.Java => ScriptLanguage.Java, + ScriptLanguageDto.Go => ScriptLanguage.Go, + ScriptLanguageDto.Bash => ScriptLanguage.Bash, + ScriptLanguageDto.TypeScript => ScriptLanguage.TypeScript, + ScriptLanguageDto.PowerShell => ScriptLanguage.PowerShell, + _ => ScriptLanguage.Bash, + }; + + private static ScriptLanguageDto MapLanguage(ScriptLanguage language) => language switch + { + ScriptLanguage.CSharp => ScriptLanguageDto.CSharp, + ScriptLanguage.Python => ScriptLanguageDto.Python, + ScriptLanguage.Java => ScriptLanguageDto.Java, + ScriptLanguage.Go => ScriptLanguageDto.Go, + ScriptLanguage.Bash => ScriptLanguageDto.Bash, + ScriptLanguage.TypeScript => ScriptLanguageDto.TypeScript, + ScriptLanguage.PowerShell => ScriptLanguageDto.PowerShell, + _ => ScriptLanguageDto.Bash, + }; + + private static ScriptVisibility MapVisibility(ScriptVisibilityDto visibility) => visibility switch + { + ScriptVisibilityDto.Private => ScriptVisibility.Private, + ScriptVisibilityDto.Team => ScriptVisibility.Team, + ScriptVisibilityDto.Organization => ScriptVisibility.Organization, + ScriptVisibilityDto.Public => ScriptVisibility.Public, + _ => ScriptVisibility.Private, + }; + + private static ScriptVisibilityDto MapVisibility(ScriptVisibility visibility) => visibility switch + { + ScriptVisibility.Private => ScriptVisibilityDto.Private, + ScriptVisibility.Team => ScriptVisibilityDto.Team, + ScriptVisibility.Organization => ScriptVisibilityDto.Organization, + ScriptVisibility.Public => ScriptVisibilityDto.Public, + _ => ScriptVisibilityDto.Private, + }; +} diff --git a/src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj b/src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj index ede5dcefd..76b0eebed 100644 --- a/src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj +++ b/src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj @@ -26,6 +26,7 @@ + diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/ReleaseOrchestratorScriptServiceTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/ReleaseOrchestratorScriptServiceTests.cs new file mode 100644 index 000000000..b979ed89a --- /dev/null +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/ReleaseOrchestratorScriptServiceTests.cs @@ -0,0 +1,113 @@ +using FluentAssertions; +using NSubstitute; +using StellaOps.Platform.WebService.Contracts; +using StellaOps.Platform.WebService.Services; +using StellaOps.ReleaseOrchestrator.Scripts; + +namespace StellaOps.Platform.WebService.Tests; + +public sealed class ReleaseOrchestratorScriptServiceTests +{ + [Fact] + public async Task SearchAsync_MapsRegistryResultsIncludingPowerShell() + { + var registry = Substitute.For(); + var evaluator = Substitute.For(); + registry.SearchAsync(Arg.Any(), Arg.Any()) + .Returns(new ScriptSearchResult + { + Scripts = + [ + new Script + { + Id = "scr-ps", + Name = "PowerShell Script", + Description = "Runs on PowerShell.", + Language = ScriptLanguage.PowerShell, + Content = "Write-Host 'ok'", + Version = 2, + Dependencies = [], + Tags = ["ops"], + Variables = + [ + new ScriptVariable + { + Name = "TOKEN", + IsRequired = true, + IsSecret = true, + } + ], + Visibility = ScriptVisibility.Team, + OwnerId = "admin", + CreatedAt = DateTimeOffset.Parse("2026-04-13T10:00:00Z"), + UpdatedAt = DateTimeOffset.Parse("2026-04-13T11:00:00Z"), + ContentHash = "hash", + } + ], + TotalCount = 1, + Offset = 0, + Limit = 20, + }); + + var sut = new ReleaseOrchestratorScriptService(registry, evaluator); + + var scripts = await sut.SearchAsync("tenant-a", null, null, null, 20, 0); + + scripts.Should().ContainSingle(); + scripts[0].Language.Should().Be(ScriptLanguageDto.PowerShell); + scripts[0].Variables.Should().ContainSingle(variable => variable.Name == "TOKEN" && variable.IsSecret); + } + + [Fact] + public async Task CheckCompatibilityAsync_MapsEvaluatorIssues() + { + var registry = Substitute.For(); + var evaluator = Substitute.For(); + registry.GetScriptAsync("scr-001", Arg.Any()) + .Returns(new Script + { + Id = "scr-001", + Name = "Compatibility", + Language = ScriptLanguage.Bash, + Content = "echo hi", + Version = 1, + Dependencies = [], + Tags = [], + Variables = [], + Visibility = ScriptVisibility.Private, + OwnerId = "admin", + CreatedAt = DateTimeOffset.UtcNow, + ContentHash = "hash", + }); + evaluator.EvaluateAsync(Arg.Any