feat(platform): add ReleaseOrchestratorScriptService compatibility shim
Platform: new ReleaseOrchestratorScriptService translates the Platform-level script API surface into calls against the ReleaseOrchestrator scripts module so clients that still target /api/scripts on Platform continue to work during the transition. Program.cs wires the shim. ScriptApiModels gets a minor contract alignment. ReleaseOrchestrator: ScriptsEndpoints + ScriptRegistry + ScriptModels updated to expose and persist script variables correctly. New integration test (ScriptRegistryVariablePersistenceTests) covers the persistence round-trip; new unit test (ReleaseOrchestratorScriptServiceTests) covers the Platform shim behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,8 @@ public enum ScriptLanguageDto
|
|||||||
Java,
|
Java,
|
||||||
Go,
|
Go,
|
||||||
Bash,
|
Bash,
|
||||||
TypeScript
|
TypeScript,
|
||||||
|
PowerShell
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ using StellaOps.Platform.WebService.Endpoints;
|
|||||||
using StellaOps.Platform.WebService.Options;
|
using StellaOps.Platform.WebService.Options;
|
||||||
using StellaOps.Platform.WebService.Services;
|
using StellaOps.Platform.WebService.Services;
|
||||||
using StellaOps.ReleaseOrchestrator.Environment.Postgres;
|
using StellaOps.ReleaseOrchestrator.Environment.Postgres;
|
||||||
|
using StellaOps.ReleaseOrchestrator.Scripts;
|
||||||
|
using StellaOps.ReleaseOrchestrator.Scripts.Persistence;
|
||||||
using StellaOps.Router.AspNet;
|
using StellaOps.Router.AspNet;
|
||||||
using StellaOps.Signals.UnifiedScore;
|
using StellaOps.Signals.UnifiedScore;
|
||||||
using StellaOps.Telemetry.Core;
|
using StellaOps.Telemetry.Core;
|
||||||
@@ -388,7 +390,21 @@ builder.Services.AddSingleton<StellaOps.Scanner.Reachability.FunctionMap.Verific
|
|||||||
builder.Services.AddSingleton<IFunctionMapService, FunctionMapService>();
|
builder.Services.AddSingleton<IFunctionMapService, FunctionMapService>();
|
||||||
|
|
||||||
// Script registry services (multi-language script editor)
|
// Script registry services (multi-language script editor)
|
||||||
|
if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString))
|
||||||
|
{
|
||||||
|
builder.Services.AddOptions<ScriptsPostgresOptions>()
|
||||||
|
.Configure(options =>
|
||||||
|
{
|
||||||
|
options.ConnectionString = bootstrapOptions.Storage.PostgresConnectionString!;
|
||||||
|
options.SchemaName = ScriptsDataSource.DefaultSchemaName;
|
||||||
|
});
|
||||||
|
builder.Services.AddReleaseOrchestratorScripts();
|
||||||
|
builder.Services.AddSingleton<IScriptService, ReleaseOrchestratorScriptService>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
builder.Services.AddSingleton<IScriptService, InMemoryScriptService>();
|
builder.Services.AddSingleton<IScriptService, InMemoryScriptService>();
|
||||||
|
}
|
||||||
|
|
||||||
// Unified audit emission (posts audit events to Timeline service)
|
// Unified audit emission (posts audit events to Timeline service)
|
||||||
builder.Services.AddAuditEmission(builder.Configuration);
|
builder.Services.AddAuditEmission(builder.Configuration);
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Platform.WebService.Contracts;
|
||||||
|
using StellaOps.ReleaseOrchestrator.Scripts;
|
||||||
|
|
||||||
|
namespace StellaOps.Platform.WebService.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform script service backed by the Release Orchestrator scripts registry.
|
||||||
|
/// </summary>
|
||||||
|
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<IReadOnlyList<ScriptSummary>> 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<int> 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<ScriptDetail?> 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<ScriptDetail> 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<ScriptDetail> 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<bool> DeleteAsync(
|
||||||
|
string tenantId,
|
||||||
|
string scriptId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> _registry.DeleteScriptAsync(scriptId, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ScriptVersionDto>> 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<ScriptValidationResultDto> 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<ScriptVersionDetailDto?> 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<CompatibilityResultDto> 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<string, string>(StringComparer.OrdinalIgnoreCase),
|
||||||
|
AvailableSecrets = request.AvailableSecrets ?? Array.Empty<string>(),
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new CompatibilityResultDto(
|
||||||
|
result.IsCompatible,
|
||||||
|
result.Issues.Select(issue => new CompatibilityIssue(issue.Category, issue.Severity, issue.Message)).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ScriptSearchResult> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||||
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.EvidenceThread\StellaOps.ReleaseOrchestrator.EvidenceThread.csproj" />
|
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.EvidenceThread\StellaOps.ReleaseOrchestrator.EvidenceThread.csproj" />
|
||||||
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.Environment\StellaOps.ReleaseOrchestrator.Environment.csproj" />
|
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.Environment\StellaOps.ReleaseOrchestrator.Environment.csproj" />
|
||||||
|
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.Scripts\StellaOps.ReleaseOrchestrator.Scripts.csproj" />
|
||||||
<ProjectReference Include="..\StellaOps.Platform.Analytics\StellaOps.Platform.Analytics.csproj" />
|
<ProjectReference Include="..\StellaOps.Platform.Analytics\StellaOps.Platform.Analytics.csproj" />
|
||||||
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
|
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
|
||||||
<ProjectReference Include="..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />
|
<ProjectReference Include="..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />
|
||||||
|
|||||||
@@ -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<IScriptRegistry>();
|
||||||
|
var evaluator = Substitute.For<IScriptCompatibilityEvaluator>();
|
||||||
|
registry.SearchAsync(Arg.Any<ScriptSearchCriteria>(), Arg.Any<CancellationToken>())
|
||||||
|
.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<IScriptRegistry>();
|
||||||
|
var evaluator = Substitute.For<IScriptCompatibilityEvaluator>();
|
||||||
|
registry.GetScriptAsync("scr-001", Arg.Any<CancellationToken>())
|
||||||
|
.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<Script>(), Arg.Any<ScriptCompatibilityRequest>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new ScriptCompatibilityResult
|
||||||
|
{
|
||||||
|
IsCompatible = false,
|
||||||
|
Issues =
|
||||||
|
[
|
||||||
|
new ScriptCompatibilityIssue
|
||||||
|
{
|
||||||
|
Category = "secret",
|
||||||
|
Severity = "error",
|
||||||
|
Message = "Required secret 'API_TOKEN' is not declared in availableSecrets.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
var sut = new ReleaseOrchestratorScriptService(registry, evaluator);
|
||||||
|
|
||||||
|
var result = await sut.CheckCompatibilityAsync(
|
||||||
|
"tenant-a",
|
||||||
|
"scr-001",
|
||||||
|
new CheckCompatibilityRequest
|
||||||
|
{
|
||||||
|
TargetType = "docker_host",
|
||||||
|
AvailableSecrets = [],
|
||||||
|
});
|
||||||
|
|
||||||
|
result.IsCompatible.Should().BeFalse();
|
||||||
|
result.Issues.Should().ContainSingle(issue =>
|
||||||
|
issue.Category == "secret" &&
|
||||||
|
issue.Severity == "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,7 @@ internal static class ScriptsEndpoints
|
|||||||
Language = ParseLanguageRequired(body.Language),
|
Language = ParseLanguageRequired(body.Language),
|
||||||
Content = body.Content,
|
Content = body.Content,
|
||||||
Tags = body.Tags?.ToImmutableArray(),
|
Tags = body.Tags?.ToImmutableArray(),
|
||||||
|
Variables = body.Variables?.Select(ToVariable).ToImmutableArray(),
|
||||||
Visibility = ParseVisibilityRequired(body.Visibility),
|
Visibility = ParseVisibilityRequired(body.Visibility),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,6 +179,7 @@ internal static class ScriptsEndpoints
|
|||||||
Description = body.Description,
|
Description = body.Description,
|
||||||
Content = body.Content,
|
Content = body.Content,
|
||||||
Tags = body.Tags?.ToImmutableArray(),
|
Tags = body.Tags?.ToImmutableArray(),
|
||||||
|
Variables = body.Variables?.Select(ToVariable).ToImmutableArray(),
|
||||||
Visibility = body.Visibility is not null ? ParseVisibilityRequired(body.Visibility) : null,
|
Visibility = body.Visibility is not null ? ParseVisibilityRequired(body.Visibility) : null,
|
||||||
ChangeNote = body.ChangeNotes,
|
ChangeNote = body.ChangeNotes,
|
||||||
};
|
};
|
||||||
@@ -365,6 +367,15 @@ internal static class ScriptsEndpoints
|
|||||||
updatedAt = s.UpdatedAt,
|
updatedAt = s.UpdatedAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static ScriptVariable ToVariable(ScriptVariableDto dto) => new()
|
||||||
|
{
|
||||||
|
Name = dto.Name,
|
||||||
|
Description = dto.Description,
|
||||||
|
IsRequired = dto.IsRequired,
|
||||||
|
DefaultValue = dto.DefaultValue,
|
||||||
|
IsSecret = dto.IsSecret,
|
||||||
|
};
|
||||||
|
|
||||||
// -- DTO types -----------------------------------------------------------
|
// -- DTO types -----------------------------------------------------------
|
||||||
|
|
||||||
private sealed record CreateScriptDto
|
private sealed record CreateScriptDto
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ public sealed record CreateScriptRequest
|
|||||||
public string? EntryPoint { get; init; }
|
public string? EntryPoint { get; init; }
|
||||||
public ImmutableArray<ScriptDependency>? Dependencies { get; init; }
|
public ImmutableArray<ScriptDependency>? Dependencies { get; init; }
|
||||||
public ImmutableArray<string>? Tags { get; init; }
|
public ImmutableArray<string>? Tags { get; init; }
|
||||||
|
public ImmutableArray<ScriptVariable>? Variables { get; init; }
|
||||||
public ScriptVisibility Visibility { get; init; } = ScriptVisibility.Private;
|
public ScriptVisibility Visibility { get; init; } = ScriptVisibility.Private;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +339,7 @@ public sealed record UpdateScriptRequest
|
|||||||
public string? EntryPoint { get; init; }
|
public string? EntryPoint { get; init; }
|
||||||
public ImmutableArray<ScriptDependency>? Dependencies { get; init; }
|
public ImmutableArray<ScriptDependency>? Dependencies { get; init; }
|
||||||
public ImmutableArray<string>? Tags { get; init; }
|
public ImmutableArray<string>? Tags { get; init; }
|
||||||
|
public ImmutableArray<ScriptVariable>? Variables { get; init; }
|
||||||
public ScriptVisibility? Visibility { get; init; }
|
public ScriptVisibility? Visibility { get; init; }
|
||||||
public string? ChangeNote { get; init; }
|
public string? ChangeNote { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ public sealed class ScriptRegistry : IScriptRegistry
|
|||||||
Version = 1,
|
Version = 1,
|
||||||
Dependencies = request.Dependencies ?? [],
|
Dependencies = request.Dependencies ?? [],
|
||||||
Tags = request.Tags ?? [],
|
Tags = request.Tags ?? [],
|
||||||
|
Variables = request.Variables ?? [],
|
||||||
Visibility = request.Visibility,
|
Visibility = request.Visibility,
|
||||||
OwnerId = userId,
|
OwnerId = userId,
|
||||||
TeamId = teamId,
|
TeamId = teamId,
|
||||||
@@ -146,6 +147,7 @@ public sealed class ScriptRegistry : IScriptRegistry
|
|||||||
EntryPoint = request.EntryPoint ?? existing.EntryPoint,
|
EntryPoint = request.EntryPoint ?? existing.EntryPoint,
|
||||||
Dependencies = newDependencies,
|
Dependencies = newDependencies,
|
||||||
Tags = request.Tags ?? existing.Tags,
|
Tags = request.Tags ?? existing.Tags,
|
||||||
|
Variables = request.Variables ?? existing.Variables,
|
||||||
Visibility = request.Visibility ?? existing.Visibility,
|
Visibility = request.Visibility ?? existing.Visibility,
|
||||||
Version = newVersion,
|
Version = newVersion,
|
||||||
ContentHash = contentHash,
|
ContentHash = contentHash,
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using StellaOps.ReleaseOrchestrator.Scripts;
|
||||||
|
|
||||||
|
namespace StellaOps.ReleaseOrchestrator.Integration.Tests;
|
||||||
|
|
||||||
|
public sealed class ScriptRegistryVariablePersistenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateScriptAsync_PersistsDeclaredVariables()
|
||||||
|
{
|
||||||
|
var store = new Mock<IScriptStore>(MockBehavior.Strict);
|
||||||
|
var validator = new Mock<IScriptValidator>(MockBehavior.Strict);
|
||||||
|
var indexer = new Mock<ISearchIndexer>(MockBehavior.Strict);
|
||||||
|
Script? savedScript = null;
|
||||||
|
|
||||||
|
validator.Setup(v => v.ValidateAsync(ScriptLanguage.Bash, "echo hi", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ScriptValidationResult { IsValid = true, Errors = [], Diagnostics = [] });
|
||||||
|
store.Setup(s => s.SaveAsync(It.IsAny<Script>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<Script, CancellationToken>((script, _) => savedScript = script)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
store.Setup(s => s.SaveVersionAsync(It.IsAny<ScriptVersion>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
indexer.Setup(i => i.IndexScriptAsync(It.IsAny<Script>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var sut = new ScriptRegistry(
|
||||||
|
store.Object,
|
||||||
|
validator.Object,
|
||||||
|
indexer.Object,
|
||||||
|
TimeProvider.System,
|
||||||
|
NullLogger<ScriptRegistry>.Instance);
|
||||||
|
|
||||||
|
await sut.CreateScriptAsync(
|
||||||
|
new CreateScriptRequest
|
||||||
|
{
|
||||||
|
Name = "test",
|
||||||
|
Language = ScriptLanguage.Bash,
|
||||||
|
Content = "echo hi",
|
||||||
|
Variables =
|
||||||
|
[
|
||||||
|
new ScriptVariable
|
||||||
|
{
|
||||||
|
Name = "SERVICE_URL",
|
||||||
|
IsRequired = true,
|
||||||
|
IsSecret = false,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"admin");
|
||||||
|
|
||||||
|
savedScript.Should().NotBeNull();
|
||||||
|
savedScript!.Variables.Should().ContainSingle(variable => variable.Name == "SERVICE_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateScriptAsync_ReplacesDeclaredVariables()
|
||||||
|
{
|
||||||
|
var store = new Mock<IScriptStore>(MockBehavior.Strict);
|
||||||
|
var validator = new Mock<IScriptValidator>(MockBehavior.Strict);
|
||||||
|
var indexer = new Mock<ISearchIndexer>(MockBehavior.Strict);
|
||||||
|
Script? savedScript = null;
|
||||||
|
var existing = new Script
|
||||||
|
{
|
||||||
|
Id = "scr-001",
|
||||||
|
Name = "test",
|
||||||
|
Language = ScriptLanguage.Bash,
|
||||||
|
Content = "echo hi",
|
||||||
|
Version = 1,
|
||||||
|
Dependencies = [],
|
||||||
|
Tags = [],
|
||||||
|
Variables =
|
||||||
|
[
|
||||||
|
new ScriptVariable
|
||||||
|
{
|
||||||
|
Name = "OLD_SECRET",
|
||||||
|
IsRequired = true,
|
||||||
|
IsSecret = true,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Visibility = ScriptVisibility.Private,
|
||||||
|
OwnerId = "admin",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
ContentHash = "hash",
|
||||||
|
};
|
||||||
|
|
||||||
|
store.Setup(s => s.GetAsync("scr-001", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(existing);
|
||||||
|
store.Setup(s => s.SaveAsync(It.IsAny<Script>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<Script, CancellationToken>((script, _) => savedScript = script)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
indexer.Setup(i => i.IndexScriptAsync(It.IsAny<Script>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var sut = new ScriptRegistry(
|
||||||
|
store.Object,
|
||||||
|
validator.Object,
|
||||||
|
indexer.Object,
|
||||||
|
TimeProvider.System,
|
||||||
|
NullLogger<ScriptRegistry>.Instance);
|
||||||
|
|
||||||
|
await sut.UpdateScriptAsync(
|
||||||
|
"scr-001",
|
||||||
|
new UpdateScriptRequest
|
||||||
|
{
|
||||||
|
Variables =
|
||||||
|
[
|
||||||
|
new ScriptVariable
|
||||||
|
{
|
||||||
|
Name = "NEW_SECRET",
|
||||||
|
IsRequired = true,
|
||||||
|
IsSecret = true,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"admin");
|
||||||
|
|
||||||
|
savedScript.Should().NotBeNull();
|
||||||
|
savedScript!.Variables.Should().ContainSingle(variable => variable.Name == "NEW_SECRET");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user