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,
|
||||
Go,
|
||||
Bash,
|
||||
TypeScript
|
||||
TypeScript,
|
||||
PowerShell
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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<StellaOps.Scanner.Reachability.FunctionMap.Verific
|
||||
builder.Services.AddSingleton<IFunctionMapService, FunctionMapService>();
|
||||
|
||||
// Script registry services (multi-language script editor)
|
||||
builder.Services.AddSingleton<IScriptService, InMemoryScriptService>();
|
||||
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>();
|
||||
}
|
||||
|
||||
// Unified audit emission (posts audit events to Timeline service)
|
||||
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="..\..\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.Scripts\StellaOps.ReleaseOrchestrator.Scripts.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Platform.Analytics\StellaOps.Platform.Analytics.csproj" />
|
||||
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.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),
|
||||
Content = body.Content,
|
||||
Tags = body.Tags?.ToImmutableArray(),
|
||||
Variables = body.Variables?.Select(ToVariable).ToImmutableArray(),
|
||||
Visibility = ParseVisibilityRequired(body.Visibility),
|
||||
};
|
||||
|
||||
@@ -178,6 +179,7 @@ internal static class ScriptsEndpoints
|
||||
Description = body.Description,
|
||||
Content = body.Content,
|
||||
Tags = body.Tags?.ToImmutableArray(),
|
||||
Variables = body.Variables?.Select(ToVariable).ToImmutableArray(),
|
||||
Visibility = body.Visibility is not null ? ParseVisibilityRequired(body.Visibility) : null,
|
||||
ChangeNote = body.ChangeNotes,
|
||||
};
|
||||
@@ -365,6 +367,15 @@ internal static class ScriptsEndpoints
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
private sealed record CreateScriptDto
|
||||
|
||||
@@ -324,6 +324,7 @@ public sealed record CreateScriptRequest
|
||||
public string? EntryPoint { get; init; }
|
||||
public ImmutableArray<ScriptDependency>? Dependencies { get; init; }
|
||||
public ImmutableArray<string>? Tags { get; init; }
|
||||
public ImmutableArray<ScriptVariable>? Variables { get; init; }
|
||||
public ScriptVisibility Visibility { get; init; } = ScriptVisibility.Private;
|
||||
}
|
||||
|
||||
@@ -338,6 +339,7 @@ public sealed record UpdateScriptRequest
|
||||
public string? EntryPoint { get; init; }
|
||||
public ImmutableArray<ScriptDependency>? Dependencies { get; init; }
|
||||
public ImmutableArray<string>? Tags { get; init; }
|
||||
public ImmutableArray<ScriptVariable>? Variables { get; init; }
|
||||
public ScriptVisibility? Visibility { get; init; }
|
||||
public string? ChangeNote { get; init; }
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ public sealed class ScriptRegistry : IScriptRegistry
|
||||
Version = 1,
|
||||
Dependencies = request.Dependencies ?? [],
|
||||
Tags = request.Tags ?? [],
|
||||
Variables = request.Variables ?? [],
|
||||
Visibility = request.Visibility,
|
||||
OwnerId = userId,
|
||||
TeamId = teamId,
|
||||
@@ -146,6 +147,7 @@ public sealed class ScriptRegistry : IScriptRegistry
|
||||
EntryPoint = request.EntryPoint ?? existing.EntryPoint,
|
||||
Dependencies = newDependencies,
|
||||
Tags = request.Tags ?? existing.Tags,
|
||||
Variables = request.Variables ?? existing.Variables,
|
||||
Visibility = request.Visibility ?? existing.Visibility,
|
||||
Version = newVersion,
|
||||
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