Platform: add script variables API and scope/policy updates
Add CRUD endpoints for script variables with validation and diff-compatible models (ScriptEndpoints, ScriptApiModels, IScriptService, InMemoryScriptService). Update PlatformScopes and PlatformPolicies for script.read/write permissions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,4 +53,8 @@ public static class PlatformPolicies
|
||||
// Identity provider management policies (SPRINT_20260224_100)
|
||||
public const string IdentityProviderRead = "platform.idp.read";
|
||||
public const string IdentityProviderAdmin = "platform.idp.admin";
|
||||
|
||||
// Script registry policies (script editor / multi-language scripts)
|
||||
public const string ScriptRead = "platform.script.read";
|
||||
public const string ScriptWrite = "platform.script.write";
|
||||
}
|
||||
|
||||
@@ -54,4 +54,8 @@ public static class PlatformScopes
|
||||
// Identity provider management scopes (SPRINT_20260224_100)
|
||||
public const string IdentityProviderRead = "platform.idp.read";
|
||||
public const string IdentityProviderAdmin = "platform.idp.admin";
|
||||
|
||||
// Script registry scopes (script editor / multi-language scripts)
|
||||
public const string ScriptRead = "script:read";
|
||||
public const string ScriptWrite = "script:write";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Supported script languages (mirrors ReleaseOrchestrator.Scripts.ScriptLanguage).
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ScriptLanguageDto
|
||||
{
|
||||
CSharp,
|
||||
Python,
|
||||
Java,
|
||||
Go,
|
||||
Bash,
|
||||
TypeScript
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Script visibility/access control level.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ScriptVisibilityDto
|
||||
{
|
||||
Private,
|
||||
Team,
|
||||
Organization,
|
||||
Public
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Declared variable for a script (runtime input).
|
||||
/// </summary>
|
||||
public sealed record ScriptVariableDeclaration(
|
||||
string Name,
|
||||
string? Description,
|
||||
bool IsRequired,
|
||||
string? DefaultValue,
|
||||
bool IsSecret);
|
||||
|
||||
/// <summary>
|
||||
/// Script summary returned in list results (excludes content for efficiency).
|
||||
/// </summary>
|
||||
public sealed record ScriptSummary(
|
||||
string Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
ScriptLanguageDto Language,
|
||||
ScriptVisibilityDto Visibility,
|
||||
int Version,
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<ScriptVariableDeclaration> Variables,
|
||||
string OwnerId,
|
||||
string? TeamId,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? UpdatedAt,
|
||||
string ContentHash);
|
||||
|
||||
/// <summary>
|
||||
/// Full script detail including content.
|
||||
/// </summary>
|
||||
public sealed record ScriptDetail(
|
||||
string Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
ScriptLanguageDto Language,
|
||||
string Content,
|
||||
string? EntryPoint,
|
||||
ScriptVisibilityDto Visibility,
|
||||
int Version,
|
||||
IReadOnlyList<ScriptDependencyDto> Dependencies,
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<ScriptVariableDeclaration> Variables,
|
||||
string OwnerId,
|
||||
string? TeamId,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? UpdatedAt,
|
||||
string ContentHash,
|
||||
bool IsSample,
|
||||
string? SampleCategory);
|
||||
|
||||
/// <summary>
|
||||
/// Script dependency reference.
|
||||
/// </summary>
|
||||
public sealed record ScriptDependencyDto(
|
||||
string Name,
|
||||
string Version,
|
||||
string? Source,
|
||||
bool IsDevelopment);
|
||||
|
||||
/// <summary>
|
||||
/// Script version history entry.
|
||||
/// </summary>
|
||||
public sealed record ScriptVersionDto(
|
||||
string ScriptId,
|
||||
int Version,
|
||||
string ContentHash,
|
||||
IReadOnlyList<ScriptDependencyDto> Dependencies,
|
||||
DateTimeOffset CreatedAt,
|
||||
string CreatedBy,
|
||||
string? ChangeNote);
|
||||
|
||||
/// <summary>
|
||||
/// Script version with content (for diff).
|
||||
/// </summary>
|
||||
public sealed record ScriptVersionDetailDto(
|
||||
string ScriptId,
|
||||
int Version,
|
||||
string ContentHash,
|
||||
string Content,
|
||||
DateTimeOffset CreatedAt,
|
||||
string CreatedBy,
|
||||
string? ChangeNote);
|
||||
|
||||
/// <summary>
|
||||
/// Create script request body.
|
||||
/// </summary>
|
||||
public sealed record CreateScriptApiRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required ScriptLanguageDto Language { get; init; }
|
||||
public required string Content { get; init; }
|
||||
public string? EntryPoint { get; init; }
|
||||
public IReadOnlyList<ScriptDependencyDto>? Dependencies { get; init; }
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
public IReadOnlyList<ScriptVariableDeclaration>? Variables { get; init; }
|
||||
public ScriptVisibilityDto Visibility { get; init; } = ScriptVisibilityDto.Private;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update script request body.
|
||||
/// </summary>
|
||||
public sealed record UpdateScriptApiRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Content { get; init; }
|
||||
public string? EntryPoint { get; init; }
|
||||
public IReadOnlyList<ScriptDependencyDto>? Dependencies { get; init; }
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
public IReadOnlyList<ScriptVariableDeclaration>? Variables { get; init; }
|
||||
public ScriptVisibilityDto? Visibility { get; init; }
|
||||
public string? ChangeNote { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate script request body (validate-only, no save).
|
||||
/// </summary>
|
||||
public sealed record ValidateScriptApiRequest
|
||||
{
|
||||
public required ScriptLanguageDto Language { get; init; }
|
||||
public required string Content { get; init; }
|
||||
public IReadOnlyList<ScriptVariableDeclaration>? DeclaredVariables { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic severity for script validation results.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ScriptDiagnosticSeverityDto
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Script diagnostic entry from validation.
|
||||
/// </summary>
|
||||
public sealed record ScriptDiagnosticDto(
|
||||
ScriptDiagnosticSeverityDto Severity,
|
||||
string Message,
|
||||
int Line,
|
||||
int Column,
|
||||
int? EndLine,
|
||||
int? EndColumn,
|
||||
string? Category = null);
|
||||
|
||||
/// <summary>
|
||||
/// Script validation result.
|
||||
/// </summary>
|
||||
public sealed record ScriptValidationResultDto(
|
||||
bool IsValid,
|
||||
IReadOnlyList<string> Errors,
|
||||
IReadOnlyList<ScriptDiagnosticDto> Diagnostics);
|
||||
|
||||
/// <summary>
|
||||
/// Request to check script compatibility with a target.
|
||||
/// </summary>
|
||||
public sealed record CheckCompatibilityRequest
|
||||
{
|
||||
public required string TargetType { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? TargetMetadata { get; init; }
|
||||
public IReadOnlyList<string>? AvailableSecrets { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility check result.
|
||||
/// </summary>
|
||||
public sealed record CompatibilityResultDto(
|
||||
bool IsCompatible,
|
||||
IReadOnlyList<CompatibilityIssue> Issues);
|
||||
|
||||
/// <summary>
|
||||
/// A single compatibility issue.
|
||||
/// </summary>
|
||||
public sealed record CompatibilityIssue(
|
||||
string Category,
|
||||
string Severity,
|
||||
string Message);
|
||||
@@ -0,0 +1,410 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Script registry API endpoints (CRUD, versioning, validation).
|
||||
/// </summary>
|
||||
public static class ScriptEndpoints
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 200;
|
||||
|
||||
public static IEndpointRouteBuilder MapScriptEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var scripts = app.MapGroup("/api/v2/scripts")
|
||||
.WithTags("Scripts")
|
||||
.RequireAuthorization(PlatformPolicies.ScriptRead)
|
||||
.RequireTenant();
|
||||
|
||||
// GET /api/v2/scripts - List/search scripts
|
||||
scripts.MapGet(string.Empty, async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScriptService service,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] ScriptLanguageDto? language,
|
||||
[FromQuery] ScriptVisibilityDto? visibility,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
var items = await service.SearchAsync(
|
||||
requestContext!.TenantId,
|
||||
language,
|
||||
visibility,
|
||||
search,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var totalCount = await service.CountAsync(
|
||||
requestContext!.TenantId,
|
||||
language,
|
||||
visibility,
|
||||
search,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ScriptSummary>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
items,
|
||||
totalCount,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
Query: search));
|
||||
})
|
||||
.WithName("ListScripts")
|
||||
.WithSummary("List and search scripts")
|
||||
.WithDescription("Lists scripts with optional filtering by language, visibility, and search text.")
|
||||
.RequireAuthorization(PlatformPolicies.ScriptRead);
|
||||
|
||||
// POST /api/v2/scripts - Create new script
|
||||
scripts.MapPost(string.Empty, async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScriptService service,
|
||||
[FromBody] CreateScriptApiRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await service.CreateAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var location = $"/api/v2/scripts/{created.Id}";
|
||||
return Results.Created(location, created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapServiceError(ex);
|
||||
}
|
||||
})
|
||||
.WithName("CreateScript")
|
||||
.WithSummary("Create new script")
|
||||
.WithDescription("Creates a new script in the registry with initial content and metadata.")
|
||||
.RequireAuthorization(PlatformPolicies.ScriptWrite);
|
||||
|
||||
// GET /api/v2/scripts/{id} - Get script with content
|
||||
scripts.MapGet("/{id}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScriptService service,
|
||||
TimeProvider timeProvider,
|
||||
string id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetByIdAsync(
|
||||
requestContext!.TenantId,
|
||||
id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "script_not_found", id });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ScriptDetail>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetScript")
|
||||
.WithSummary("Get script by id")
|
||||
.WithDescription("Retrieves a script including its full content.")
|
||||
.RequireAuthorization(PlatformPolicies.ScriptRead);
|
||||
|
||||
// PUT /api/v2/scripts/{id} - Update script
|
||||
scripts.MapPut("/{id}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScriptService service,
|
||||
TimeProvider timeProvider,
|
||||
string id,
|
||||
[FromBody] UpdateScriptApiRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await service.UpdateAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
id,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ScriptDetail>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
updated));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapServiceError(ex, id);
|
||||
}
|
||||
})
|
||||
.WithName("UpdateScript")
|
||||
.WithSummary("Update script")
|
||||
.WithDescription("Updates an existing script. Content changes create a new version automatically.")
|
||||
.RequireAuthorization(PlatformPolicies.ScriptWrite);
|
||||
|
||||
// DELETE /api/v2/scripts/{id} - Delete script
|
||||
scripts.MapDelete("/{id}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScriptService service,
|
||||
string id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var deleted = await service.DeleteAsync(
|
||||
requestContext!.TenantId,
|
||||
id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = "script_not_found", id });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("DeleteScript")
|
||||
.WithSummary("Delete script")
|
||||
.WithDescription("Deletes a script and all its version history.")
|
||||
.RequireAuthorization(PlatformPolicies.ScriptWrite);
|
||||
|
||||
// GET /api/v2/scripts/{id}/versions - Version history
|
||||
scripts.MapGet("/{id}/versions", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScriptService service,
|
||||
TimeProvider timeProvider,
|
||||
string id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var versions = await service.GetVersionsAsync(
|
||||
requestContext!.TenantId,
|
||||
id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ScriptVersionDto>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
versions,
|
||||
versions.Count));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapServiceError(ex, id);
|
||||
}
|
||||
})
|
||||
.WithName("ListScriptVersions")
|
||||
.WithSummary("List script version history")
|
||||
.WithDescription("Returns the full version history for a script, newest first.")
|
||||
.RequireAuthorization(PlatformPolicies.ScriptRead);
|
||||
|
||||
// POST /api/v2/scripts/validate - Validate without saving
|
||||
scripts.MapPost("/validate", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScriptService service,
|
||||
[FromBody] ValidateScriptApiRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Content))
|
||||
{
|
||||
return Results.BadRequest(new { error = "script_content_required" });
|
||||
}
|
||||
|
||||
var result = await service.ValidateAsync(
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("ValidateScript")
|
||||
.WithSummary("Validate script without saving")
|
||||
.WithDescription("Validates script syntax and returns diagnostics. Used by the editor compile button.")
|
||||
.RequireAuthorization(PlatformPolicies.ScriptRead);
|
||||
|
||||
// GET /api/v2/scripts/{id}/versions/{version}/content - Get version content for diff
|
||||
scripts.MapGet("/{id}/versions/{version:int}/content", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScriptService service,
|
||||
TimeProvider timeProvider,
|
||||
string id,
|
||||
int version,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetVersionContentAsync(
|
||||
requestContext!.TenantId,
|
||||
id,
|
||||
version,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "script_not_found", id });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ScriptVersionDetailDto>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetScriptVersionContent")
|
||||
.WithSummary("Get script version content")
|
||||
.WithDescription("Retrieves the content of a specific script version for diff comparison.")
|
||||
.RequireAuthorization(PlatformPolicies.ScriptRead);
|
||||
|
||||
// POST /api/v2/scripts/{id}/check-compatibility - Check deployment compatibility
|
||||
scripts.MapPost("/{id}/check-compatibility", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScriptService service,
|
||||
string id,
|
||||
[FromBody] CheckCompatibilityRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.CheckCompatibilityAsync(
|
||||
requestContext!.TenantId,
|
||||
id,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapServiceError(ex, id);
|
||||
}
|
||||
})
|
||||
.WithName("CheckScriptCompatibility")
|
||||
.WithSummary("Check script deployment compatibility")
|
||||
.WithDescription("Checks if a script is compatible with a given deployment target type.")
|
||||
.RequireAuthorization(PlatformPolicies.ScriptRead);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => DefaultLimit,
|
||||
< 1 => 1,
|
||||
> MaxLimit => MaxLimit,
|
||||
_ => value.Value
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeOffset(int? value) => value is null or < 0 ? 0 : value.Value;
|
||||
|
||||
private static IResult MapServiceError(InvalidOperationException exception, string? id = null)
|
||||
{
|
||||
return exception.Message switch
|
||||
{
|
||||
"script_not_found" => Results.NotFound(new { error = "script_not_found", id }),
|
||||
"script_name_required" => Results.BadRequest(new { error = "script_name_required" }),
|
||||
"script_content_required" => Results.BadRequest(new { error = "script_content_required" }),
|
||||
"request_required" => Results.BadRequest(new { error = "request_required" }),
|
||||
"script_validation_failed" => Results.BadRequest(new { error = "script_validation_failed", detail = exception.Message }),
|
||||
_ => Results.BadRequest(new { error = exception.Message })
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,8 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FederationManage, PlatformScopes.FederationManage);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.IdentityProviderRead, PlatformScopes.IdentityProviderRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.IdentityProviderAdmin, PlatformScopes.IdentityProviderAdmin);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ScriptRead, PlatformScopes.ScriptRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ScriptWrite, PlatformScopes.ScriptWrite);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformRequestContextResolver>();
|
||||
@@ -288,6 +290,9 @@ builder.Services.AddSingleton<StellaOps.Scanner.Reachability.FunctionMap.Verific
|
||||
StellaOps.Scanner.Reachability.FunctionMap.Verification.ClaimVerifier>();
|
||||
builder.Services.AddSingleton<IFunctionMapService, FunctionMapService>();
|
||||
|
||||
// Script registry services (multi-language script editor)
|
||||
builder.Services.AddSingleton<IScriptService, InMemoryScriptService>();
|
||||
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "platform",
|
||||
@@ -358,6 +363,7 @@ app.MapFederationTelemetryEndpoints();
|
||||
app.MapSeedEndpoints();
|
||||
app.MapMigrationAdminEndpoints();
|
||||
app.MapRegistrySearchEndpoints();
|
||||
app.MapScriptEndpoints();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
||||
.WithTags("Health")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for script CRUD, versioning, and validation.
|
||||
/// </summary>
|
||||
public interface IScriptService
|
||||
{
|
||||
Task<IReadOnlyList<ScriptSummary>> SearchAsync(
|
||||
string tenantId,
|
||||
ScriptLanguageDto? language,
|
||||
ScriptVisibilityDto? visibility,
|
||||
string? search,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountAsync(
|
||||
string tenantId,
|
||||
ScriptLanguageDto? language,
|
||||
ScriptVisibilityDto? visibility,
|
||||
string? search,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ScriptDetail?> GetByIdAsync(
|
||||
string tenantId,
|
||||
string scriptId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ScriptDetail> CreateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateScriptApiRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ScriptDetail> UpdateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
string scriptId,
|
||||
UpdateScriptApiRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string scriptId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ScriptVersionDto>> GetVersionsAsync(
|
||||
string tenantId,
|
||||
string scriptId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ScriptValidationResultDto> ValidateAsync(
|
||||
ValidateScriptApiRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ScriptVersionDetailDto?> GetVersionContentAsync(
|
||||
string tenantId,
|
||||
string scriptId,
|
||||
int version,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CompatibilityResultDto> CheckCompatibilityAsync(
|
||||
string tenantId,
|
||||
string scriptId,
|
||||
CheckCompatibilityRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IScriptService"/> for development and testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryScriptService : IScriptService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, TenantState> _states = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The 16 known STELLA_* system variable suffixes.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> KnownStellaVars = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"RELEASE_ID", "RELEASE_NAME", "RELEASE_VERSION", "RELEASE_TYPE",
|
||||
"COMPONENT_NAME", "TARGET_ENVIRONMENT", "TARGET_REGION", "DEPLOYMENT_STRATEGY",
|
||||
"TENANT_ID", "ACTOR_ID", "TIMESTAMP", "TIMEOUT_SECONDS",
|
||||
"WORKING_DIR", "AGENT_TYPE", "COMPOSE_LOCK", "VERSION_STICKER"
|
||||
};
|
||||
|
||||
private static readonly Regex HardcodedIpRegex = new(@"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", RegexOptions.Compiled);
|
||||
private static readonly Regex TodoFixmeRegex = new(@"\b(TODO|FIXME)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex DangerousRmRegex = new(@"rm\s+-rf\s+/(?:\s|$|;)", RegexOptions.Compiled);
|
||||
private static readonly Regex EmptyCatchRegex = new(@"catch\s*(\([^)]*\))?\s*\{(\s|\n)*\}", RegexOptions.Compiled);
|
||||
|
||||
// Variable reference patterns per language family
|
||||
private static readonly Regex BashVarRefRegex = new(@"\$\{(STELLA_\w+)\}", RegexOptions.Compiled);
|
||||
private static readonly Regex PowerShellVarRefRegex = new(@"\$env:(STELLA_\w+)", RegexOptions.Compiled);
|
||||
private static readonly Regex CSharpEnvVarRefRegex = new(@"Environment\.GetEnvironmentVariable\(\s*""(STELLA_\w+)""\s*\)", RegexOptions.Compiled);
|
||||
|
||||
public InMemoryScriptService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ScriptSummary>> SearchAsync(
|
||||
string tenantId,
|
||||
ScriptLanguageDto? language,
|
||||
ScriptVisibilityDto? visibility,
|
||||
string? search,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var query = state.Scripts.Values.AsEnumerable();
|
||||
|
||||
if (language is not null)
|
||||
{
|
||||
query = query.Where(s => s.Language == language.Value);
|
||||
}
|
||||
|
||||
if (visibility is not null)
|
||||
{
|
||||
query = query.Where(s => s.Visibility == visibility.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
query = query.Where(s =>
|
||||
s.Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
(s.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
s.Tags.Any(t => t.Contains(term, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
var list = query
|
||||
.OrderByDescending(s => s.UpdatedAt ?? s.CreatedAt)
|
||||
.ThenBy(s => s.Name, StringComparer.Ordinal)
|
||||
.Skip(Math.Max(offset, 0))
|
||||
.Take(Math.Max(limit, 1))
|
||||
.Select(ToSummary)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ScriptSummary>>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> CountAsync(
|
||||
string tenantId,
|
||||
ScriptLanguageDto? language,
|
||||
ScriptVisibilityDto? visibility,
|
||||
string? search,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var query = state.Scripts.Values.AsEnumerable();
|
||||
|
||||
if (language is not null)
|
||||
{
|
||||
query = query.Where(s => s.Language == language.Value);
|
||||
}
|
||||
|
||||
if (visibility is not null)
|
||||
{
|
||||
query = query.Where(s => s.Visibility == visibility.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
query = query.Where(s =>
|
||||
s.Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
(s.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
s.Tags.Any(t => t.Contains(term, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
return Task.FromResult(query.Count());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ScriptDetail?> GetByIdAsync(
|
||||
string tenantId,
|
||||
string scriptId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
return Task.FromResult(
|
||||
state.Scripts.TryGetValue(scriptId, out var script)
|
||||
? script
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ScriptDetail> CreateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateScriptApiRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
throw new InvalidOperationException("script_name_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Content))
|
||||
{
|
||||
throw new InvalidOperationException("script_content_required");
|
||||
}
|
||||
|
||||
var state = GetState(tenantId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var id = Guid.NewGuid().ToString("N")[..12];
|
||||
var dependencies = request.Dependencies?.Select(d => new ScriptDependencyDto(d.Name, d.Version, d.Source, d.IsDevelopment)).ToArray()
|
||||
?? [];
|
||||
var tags = request.Tags?.ToArray() ?? [];
|
||||
var variables = request.Variables?.ToArray() ?? [];
|
||||
var contentHash = ComputeContentHash(request.Content, dependencies);
|
||||
|
||||
var script = new ScriptDetail(
|
||||
Id: id,
|
||||
Name: request.Name.Trim(),
|
||||
Description: request.Description?.Trim(),
|
||||
Language: request.Language,
|
||||
Content: request.Content,
|
||||
EntryPoint: request.EntryPoint?.Trim(),
|
||||
Visibility: request.Visibility,
|
||||
Version: 1,
|
||||
Dependencies: dependencies,
|
||||
Tags: tags,
|
||||
Variables: variables,
|
||||
OwnerId: actorId,
|
||||
TeamId: null,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: null,
|
||||
ContentHash: contentHash,
|
||||
IsSample: false,
|
||||
SampleCategory: null);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
state.Scripts[id] = script;
|
||||
|
||||
// Store initial version
|
||||
var version = new ScriptVersionDto(
|
||||
ScriptId: id,
|
||||
Version: 1,
|
||||
ContentHash: contentHash,
|
||||
Dependencies: dependencies,
|
||||
CreatedAt: now,
|
||||
CreatedBy: actorId,
|
||||
ChangeNote: "Initial version");
|
||||
|
||||
if (!state.Versions.TryGetValue(id, out var versions))
|
||||
{
|
||||
versions = [];
|
||||
state.Versions[id] = versions;
|
||||
}
|
||||
|
||||
versions.Add(version);
|
||||
|
||||
// Store version content
|
||||
state.VersionContents[id] = new Dictionary<int, string> { [1] = request.Content };
|
||||
}
|
||||
|
||||
return Task.FromResult(script);
|
||||
}
|
||||
|
||||
public Task<ScriptDetail> UpdateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
string scriptId,
|
||||
UpdateScriptApiRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Scripts.TryGetValue(scriptId, out var existing))
|
||||
{
|
||||
throw new InvalidOperationException("script_not_found");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var contentChanged = request.Content is not null || request.Dependencies is not null;
|
||||
|
||||
var newContent = request.Content ?? existing.Content;
|
||||
var newDependencies = request.Dependencies?.Select(d => new ScriptDependencyDto(d.Name, d.Version, d.Source, d.IsDevelopment)).ToArray()
|
||||
?? existing.Dependencies.ToArray();
|
||||
var newVersion = contentChanged ? existing.Version + 1 : existing.Version;
|
||||
var contentHash = contentChanged
|
||||
? ComputeContentHash(newContent, newDependencies)
|
||||
: existing.ContentHash;
|
||||
var newVariables = request.Variables?.ToArray() ?? existing.Variables.ToArray();
|
||||
|
||||
var updated = new ScriptDetail(
|
||||
Id: existing.Id,
|
||||
Name: request.Name?.Trim() ?? existing.Name,
|
||||
Description: request.Description?.Trim() ?? existing.Description,
|
||||
Language: existing.Language,
|
||||
Content: newContent,
|
||||
EntryPoint: request.EntryPoint?.Trim() ?? existing.EntryPoint,
|
||||
Visibility: request.Visibility ?? existing.Visibility,
|
||||
Version: newVersion,
|
||||
Dependencies: newDependencies,
|
||||
Tags: request.Tags?.ToArray() ?? existing.Tags.ToArray(),
|
||||
Variables: newVariables,
|
||||
OwnerId: existing.OwnerId,
|
||||
TeamId: existing.TeamId,
|
||||
CreatedAt: existing.CreatedAt,
|
||||
UpdatedAt: now,
|
||||
ContentHash: contentHash,
|
||||
IsSample: existing.IsSample,
|
||||
SampleCategory: existing.SampleCategory);
|
||||
|
||||
state.Scripts[scriptId] = updated;
|
||||
|
||||
if (contentChanged)
|
||||
{
|
||||
if (!state.Versions.TryGetValue(scriptId, out var versions))
|
||||
{
|
||||
versions = [];
|
||||
state.Versions[scriptId] = versions;
|
||||
}
|
||||
|
||||
versions.Add(new ScriptVersionDto(
|
||||
ScriptId: scriptId,
|
||||
Version: newVersion,
|
||||
ContentHash: contentHash,
|
||||
Dependencies: newDependencies,
|
||||
CreatedAt: now,
|
||||
CreatedBy: actorId,
|
||||
ChangeNote: request.ChangeNote));
|
||||
|
||||
// Store version content
|
||||
if (!state.VersionContents.TryGetValue(scriptId, out var versionContents))
|
||||
{
|
||||
versionContents = new Dictionary<int, string>();
|
||||
state.VersionContents[scriptId] = versionContents;
|
||||
}
|
||||
|
||||
versionContents[newVersion] = newContent;
|
||||
}
|
||||
|
||||
return Task.FromResult(updated);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string scriptId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var removed = state.Scripts.Remove(scriptId);
|
||||
state.Versions.Remove(scriptId);
|
||||
state.VersionContents.Remove(scriptId);
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ScriptVersionDto>> GetVersionsAsync(
|
||||
string tenantId,
|
||||
string scriptId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Scripts.ContainsKey(scriptId))
|
||||
{
|
||||
throw new InvalidOperationException("script_not_found");
|
||||
}
|
||||
|
||||
if (!state.Versions.TryGetValue(scriptId, out var versions))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ScriptVersionDto>>([]);
|
||||
}
|
||||
|
||||
var result = versions
|
||||
.OrderByDescending(v => v.Version)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ScriptVersionDto>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ScriptVersionDetailDto?> GetVersionContentAsync(
|
||||
string tenantId,
|
||||
string scriptId,
|
||||
int version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Scripts.ContainsKey(scriptId))
|
||||
{
|
||||
throw new InvalidOperationException("script_not_found");
|
||||
}
|
||||
|
||||
// Look up the version metadata
|
||||
if (!state.Versions.TryGetValue(scriptId, out var versions))
|
||||
{
|
||||
return Task.FromResult<ScriptVersionDetailDto?>(null);
|
||||
}
|
||||
|
||||
var versionMeta = versions.FirstOrDefault(v => v.Version == version);
|
||||
if (versionMeta is null)
|
||||
{
|
||||
return Task.FromResult<ScriptVersionDetailDto?>(null);
|
||||
}
|
||||
|
||||
// Look up the version content
|
||||
if (!state.VersionContents.TryGetValue(scriptId, out var versionContents) ||
|
||||
!versionContents.TryGetValue(version, out var content))
|
||||
{
|
||||
return Task.FromResult<ScriptVersionDetailDto?>(null);
|
||||
}
|
||||
|
||||
var detail = new ScriptVersionDetailDto(
|
||||
ScriptId: versionMeta.ScriptId,
|
||||
Version: versionMeta.Version,
|
||||
ContentHash: versionMeta.ContentHash,
|
||||
Content: content,
|
||||
CreatedAt: versionMeta.CreatedAt,
|
||||
CreatedBy: versionMeta.CreatedBy,
|
||||
ChangeNote: versionMeta.ChangeNote);
|
||||
|
||||
return Task.FromResult<ScriptVersionDetailDto?>(detail);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<CompatibilityResultDto> CheckCompatibilityAsync(
|
||||
string tenantId,
|
||||
string scriptId,
|
||||
CheckCompatibilityRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
ScriptDetail script;
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Scripts.TryGetValue(scriptId, out script!))
|
||||
{
|
||||
throw new InvalidOperationException("script_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
var issues = new List<CompatibilityIssue>();
|
||||
var targetType = request.TargetType?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Language-target compatibility matrix
|
||||
switch (script.Language)
|
||||
{
|
||||
case ScriptLanguageDto.Bash:
|
||||
switch (targetType)
|
||||
{
|
||||
case "docker_host":
|
||||
case "compose_host":
|
||||
case "nomad_job":
|
||||
// OK
|
||||
break;
|
||||
case "ecs_service":
|
||||
issues.Add(new CompatibilityIssue(
|
||||
"runtime",
|
||||
"Warning",
|
||||
"Bash scripts on ECS require ECS Exec with proper IAM permissions"));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
// CSharp, Python, TypeScript, Go, Java — all OK with all targets
|
||||
}
|
||||
|
||||
// Variable resolution: required non-secret variables vs. TargetMetadata
|
||||
foreach (var variable in script.Variables)
|
||||
{
|
||||
if (!variable.IsRequired || variable.IsSecret)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (variable.DefaultValue is not null)
|
||||
{
|
||||
continue; // Has a default, not strictly missing
|
||||
}
|
||||
|
||||
if (request.TargetMetadata is null ||
|
||||
!request.TargetMetadata.ContainsKey(variable.Name))
|
||||
{
|
||||
issues.Add(new CompatibilityIssue(
|
||||
"variable",
|
||||
"Error",
|
||||
$"Required variable '{variable.Name}' is not provided in target metadata"));
|
||||
}
|
||||
}
|
||||
|
||||
// Secret availability: secret variables vs. AvailableSecrets
|
||||
foreach (var variable in script.Variables)
|
||||
{
|
||||
if (!variable.IsSecret)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var availableSecrets = request.AvailableSecrets ?? [];
|
||||
if (!availableSecrets.Any(s => s.Equals(variable.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
issues.Add(new CompatibilityIssue(
|
||||
"secret",
|
||||
"Warning",
|
||||
$"Secret variable '{variable.Name}' is not available in the target environment"));
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime notes
|
||||
switch (targetType)
|
||||
{
|
||||
case "ecs_service":
|
||||
issues.Add(new CompatibilityIssue(
|
||||
"runtime",
|
||||
"Info",
|
||||
"ECS Exec requires ssmmessages:* IAM permissions"));
|
||||
break;
|
||||
case "nomad_job":
|
||||
issues.Add(new CompatibilityIssue(
|
||||
"runtime",
|
||||
"Info",
|
||||
"Script runs via Nomad exec in task allocation"));
|
||||
break;
|
||||
}
|
||||
|
||||
var isCompatible = !issues.Any(i => i.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return Task.FromResult(new CompatibilityResultDto(isCompatible, issues));
|
||||
}
|
||||
|
||||
public Task<ScriptValidationResultDto> ValidateAsync(
|
||||
ValidateScriptApiRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var errors = new List<string>();
|
||||
var diagnostics = new List<ScriptDiagnosticDto>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Content))
|
||||
{
|
||||
errors.Add("Script content cannot be empty");
|
||||
}
|
||||
else
|
||||
{
|
||||
var lines = request.Content.Split('\n');
|
||||
|
||||
// Language-specific validation
|
||||
switch (request.Language)
|
||||
{
|
||||
case ScriptLanguageDto.CSharp:
|
||||
case ScriptLanguageDto.TypeScript:
|
||||
case ScriptLanguageDto.Java:
|
||||
ValidateBracedLanguage(request.Content, lines, errors, diagnostics);
|
||||
break;
|
||||
case ScriptLanguageDto.Python:
|
||||
ValidatePython(lines, diagnostics);
|
||||
break;
|
||||
case ScriptLanguageDto.Bash:
|
||||
ValidateBash(request.Content, lines, errors, diagnostics);
|
||||
break;
|
||||
}
|
||||
|
||||
// CSharp-specific: empty catch blocks
|
||||
if (request.Language == ScriptLanguageDto.CSharp)
|
||||
{
|
||||
ValidateCSharpEmptyCatch(request.Content, lines, diagnostics);
|
||||
}
|
||||
|
||||
// Variable reference validation (all languages)
|
||||
ValidateVariableReferences(request.Content, request.Language, request.DeclaredVariables, diagnostics);
|
||||
|
||||
// General checks (all languages)
|
||||
ValidateGeneral(lines, diagnostics);
|
||||
}
|
||||
|
||||
var result = new ScriptValidationResultDto(
|
||||
IsValid: errors.Count == 0,
|
||||
Errors: errors,
|
||||
Diagnostics: diagnostics);
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static void ValidateBracedLanguage(string content, string[] lines, List<string> errors, List<ScriptDiagnosticDto> diagnostics)
|
||||
{
|
||||
// Walk lines tracking brace balance, report the line where imbalance starts
|
||||
var balance = 0;
|
||||
int? imbalanceStartLine = null;
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
foreach (var c in lines[i])
|
||||
{
|
||||
if (c == '{')
|
||||
{
|
||||
if (balance == 0 && imbalanceStartLine is null)
|
||||
{
|
||||
imbalanceStartLine = i + 1; // 1-based
|
||||
}
|
||||
|
||||
balance++;
|
||||
}
|
||||
else if (c == '}')
|
||||
{
|
||||
balance--;
|
||||
if (balance < 0 && imbalanceStartLine is null)
|
||||
{
|
||||
imbalanceStartLine = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (balance != 0)
|
||||
{
|
||||
var openBraces = content.Count(c => c == '{');
|
||||
var closeBraces = content.Count(c => c == '}');
|
||||
var reportLine = imbalanceStartLine ?? 1;
|
||||
|
||||
errors.Add($"Unbalanced braces: {openBraces} open, {closeBraces} close");
|
||||
diagnostics.Add(new ScriptDiagnosticDto(
|
||||
Severity: ScriptDiagnosticSeverityDto.Error,
|
||||
Message: $"Unbalanced braces: {openBraces} open, {closeBraces} close",
|
||||
Line: reportLine,
|
||||
Column: 1,
|
||||
EndLine: null,
|
||||
EndColumn: null,
|
||||
Category: "structure"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidatePython(string[] lines, List<ScriptDiagnosticDto> diagnostics)
|
||||
{
|
||||
var usesTabs = lines.Any(l => l.StartsWith('\t'));
|
||||
var usesSpaces = lines.Any(l => l.StartsWith(" "));
|
||||
|
||||
if (usesTabs && usesSpaces)
|
||||
{
|
||||
diagnostics.Add(new ScriptDiagnosticDto(
|
||||
Severity: ScriptDiagnosticSeverityDto.Warning,
|
||||
Message: "Mixed tabs and spaces for indentation",
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
EndLine: null,
|
||||
EndColumn: null,
|
||||
Category: "style"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateBash(string content, string[] lines, List<string> errors, List<ScriptDiagnosticDto> diagnostics)
|
||||
{
|
||||
// Warn if content doesn't contain set -euo pipefail
|
||||
if (!content.Contains("set -euo pipefail"))
|
||||
{
|
||||
diagnostics.Add(new ScriptDiagnosticDto(
|
||||
Severity: ScriptDiagnosticSeverityDto.Warning,
|
||||
Message: "Script does not contain 'set -euo pipefail' for safe error handling",
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
EndLine: null,
|
||||
EndColumn: null,
|
||||
Category: "safety"));
|
||||
}
|
||||
|
||||
// Error on rm -rf /
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (DangerousRmRegex.IsMatch(lines[i]))
|
||||
{
|
||||
errors.Add($"Dangerous 'rm -rf /' detected at line {i + 1}");
|
||||
diagnostics.Add(new ScriptDiagnosticDto(
|
||||
Severity: ScriptDiagnosticSeverityDto.Error,
|
||||
Message: "Dangerous 'rm -rf /' command detected",
|
||||
Line: i + 1,
|
||||
Column: 1,
|
||||
EndLine: null,
|
||||
EndColumn: null,
|
||||
Category: "safety"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateCSharpEmptyCatch(string content, string[] lines, List<ScriptDiagnosticDto> diagnostics)
|
||||
{
|
||||
var matches = EmptyCatchRegex.Matches(content);
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
// Find the line number of this match
|
||||
var precedingContent = content[..match.Index];
|
||||
var lineNumber = precedingContent.Count(c => c == '\n') + 1;
|
||||
|
||||
diagnostics.Add(new ScriptDiagnosticDto(
|
||||
Severity: ScriptDiagnosticSeverityDto.Warning,
|
||||
Message: "Empty catch block detected — exceptions should be logged or handled",
|
||||
Line: lineNumber,
|
||||
Column: 1,
|
||||
EndLine: null,
|
||||
EndColumn: null,
|
||||
Category: "safety"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateVariableReferences(
|
||||
string content,
|
||||
ScriptLanguageDto language,
|
||||
IReadOnlyList<ScriptVariableDeclaration>? declaredVariables,
|
||||
List<ScriptDiagnosticDto> diagnostics)
|
||||
{
|
||||
var referencedVars = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Collect variable references based on language patterns
|
||||
// Bash: ${STELLA_*}
|
||||
foreach (Match match in BashVarRefRegex.Matches(content))
|
||||
{
|
||||
referencedVars.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
// PowerShell: $env:STELLA_*
|
||||
foreach (Match match in PowerShellVarRefRegex.Matches(content))
|
||||
{
|
||||
referencedVars.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
// C#: Environment.GetEnvironmentVariable("STELLA_*")
|
||||
foreach (Match match in CSharpEnvVarRefRegex.Matches(content))
|
||||
{
|
||||
referencedVars.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
if (referencedVars.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var declaredNames = new HashSet<string>(
|
||||
(declaredVariables ?? []).Select(v => v.Name),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var varName in referencedVars)
|
||||
{
|
||||
// Strip STELLA_ prefix to check against known system vars
|
||||
var suffix = varName.StartsWith("STELLA_", StringComparison.OrdinalIgnoreCase)
|
||||
? varName["STELLA_".Length..]
|
||||
: varName;
|
||||
|
||||
// Known system var or declared var — skip
|
||||
if (KnownStellaVars.Contains(suffix))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check against declared variables (match full name including STELLA_ prefix)
|
||||
if (declaredNames.Contains(varName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the line where this reference appears
|
||||
var lines = content.Split('\n');
|
||||
var lineNumber = 1;
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (lines[i].Contains(varName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
lineNumber = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics.Add(new ScriptDiagnosticDto(
|
||||
Severity: ScriptDiagnosticSeverityDto.Warning,
|
||||
Message: $"Unknown STELLA_* variable reference: '{varName}' is not a known system variable or declared variable",
|
||||
Line: lineNumber,
|
||||
Column: 1,
|
||||
EndLine: null,
|
||||
EndColumn: null,
|
||||
Category: "variable"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateGeneral(string[] lines, List<ScriptDiagnosticDto> diagnostics)
|
||||
{
|
||||
// Warn on hardcoded IPs
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (HardcodedIpRegex.IsMatch(lines[i]))
|
||||
{
|
||||
diagnostics.Add(new ScriptDiagnosticDto(
|
||||
Severity: ScriptDiagnosticSeverityDto.Warning,
|
||||
Message: "Hardcoded IP address detected — consider using configuration or DNS",
|
||||
Line: i + 1,
|
||||
Column: 1,
|
||||
EndLine: null,
|
||||
EndColumn: null,
|
||||
Category: "safety"));
|
||||
}
|
||||
}
|
||||
|
||||
// Info on TODO/FIXME comments
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (TodoFixmeRegex.IsMatch(lines[i]))
|
||||
{
|
||||
diagnostics.Add(new ScriptDiagnosticDto(
|
||||
Severity: ScriptDiagnosticSeverityDto.Info,
|
||||
Message: "TODO/FIXME comment found",
|
||||
Line: i + 1,
|
||||
Column: 1,
|
||||
EndLine: null,
|
||||
EndColumn: null,
|
||||
Category: "style"));
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if content has >500 lines
|
||||
if (lines.Length > 500)
|
||||
{
|
||||
diagnostics.Add(new ScriptDiagnosticDto(
|
||||
Severity: ScriptDiagnosticSeverityDto.Warning,
|
||||
Message: $"Script has {lines.Length} lines — consider breaking it into smaller scripts",
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
EndLine: null,
|
||||
EndColumn: null,
|
||||
Category: "structure"));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeContentHash(string content, IReadOnlyList<ScriptDependencyDto> dependencies)
|
||||
{
|
||||
var combined = content;
|
||||
foreach (var dep in dependencies.OrderBy(d => d.Name, StringComparer.Ordinal))
|
||||
{
|
||||
combined += $"|{dep.Name}:{dep.Version}";
|
||||
}
|
||||
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static ScriptSummary ToSummary(ScriptDetail detail) => new(
|
||||
Id: detail.Id,
|
||||
Name: detail.Name,
|
||||
Description: detail.Description,
|
||||
Language: detail.Language,
|
||||
Visibility: detail.Visibility,
|
||||
Version: detail.Version,
|
||||
Tags: detail.Tags,
|
||||
Variables: detail.Variables,
|
||||
OwnerId: detail.OwnerId,
|
||||
TeamId: detail.TeamId,
|
||||
CreatedAt: detail.CreatedAt,
|
||||
UpdatedAt: detail.UpdatedAt,
|
||||
ContentHash: detail.ContentHash);
|
||||
|
||||
private TenantState GetState(string tenantId) =>
|
||||
_states.GetOrAdd(tenantId, _ => new TenantState());
|
||||
|
||||
private sealed class TenantState
|
||||
{
|
||||
public readonly object Sync = new();
|
||||
public readonly Dictionary<string, ScriptDetail> Scripts = new(StringComparer.Ordinal);
|
||||
public readonly Dictionary<string, List<ScriptVersionDto>> Versions = new(StringComparer.Ordinal);
|
||||
public readonly Dictionary<string, Dictionary<int, string>> VersionContents = new(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user