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:
master
2026-03-27 12:28:17 +02:00
parent c58a236d70
commit 7abdb5334d
8 changed files with 1720 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
# Sprint 20260324_001 — Scripts Library Enhancement: Variables, Validation, Diff, Compatibility
## Topic & Scope
- Add per-target variable/secret declarations to scripts, with editable UI and Monaco completions.
- Enhance Compile/Validate with real lint checks (safety, variable refs, structure) and Monaco diagnostic markers.
- Add version diff viewer using Monaco diff editor for side-by-side version comparison.
- Add deployment compatibility checker (language-target matrix, variable resolution, secret availability).
- Working directory: `src/Platform/StellaOps.Platform.WebService/`, `src/Web/StellaOps.Web/src/app/`.
- Expected evidence: backend build success, frontend build success, e2e tests pass (T10.1T10.6).
## Dependencies & Concurrency
- No upstream sprint dependencies.
- All 5 phases implemented in a single batch (Phase 1 models first, Phases 2-5 in parallel).
## Documentation Prerequisites
- None — self-contained feature enhancement.
## Delivery Tracker
### TASK-001 - Phase 1: Models & Contracts
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Add `ScriptVariableDeclaration`, `ScriptVersionDetailDto`, `CheckCompatibilityRequest`, `CompatibilityResultDto`, `CompatibilityIssue` records to backend contracts.
- Add `Variables` field to `ScriptDetail`, `ScriptSummary`, `CreateScriptApiRequest`, `UpdateScriptApiRequest`.
- Add `DeclaredVariables` to `ValidateScriptApiRequest`, `Category` to `ScriptDiagnosticDto`.
- Add corresponding TypeScript interfaces to frontend models.
- Extend `IScriptService` with `GetVersionContentAsync` and `CheckCompatibilityAsync`.
- Extend `ScriptsApi` interface with `getVersionContent`, `checkCompatibility`, and `declaredVariables` param.
Completion criteria:
- [x] All backend DTOs compile
- [x] All frontend interfaces defined
- [x] Service interface extended
### TASK-002 - Phase 2: Per-Target Variables & Secrets
Status: DONE
Dependency: TASK-001
Owners: Developer
Task description:
- Propagate variables through create/update in `InMemoryScriptService`.
- Add sample variables to seed scripts (SERVICE_URL, TIMEOUT, DB_CONNECTION, IMAGE_REF, etc.).
- Add variables editor UI in script-detail with add/remove/edit rows.
- Show user variables in script editor context panel with click-to-insert.
- Register user variable completions alongside system vars in Monaco.
Completion criteria:
- [x] Variables persisted through create/update cycle
- [x] Mock scripts include sample variables
- [x] Variables editor renders and modifies signal state
- [x] Context panel shows user variables
- [x] Monaco autocomplete includes user variables on `$` trigger
### TASK-003 - Phase 3: Enhanced Compile/Validate
Status: DONE
Dependency: TASK-001
Owners: Developer
Task description:
- Variable reference validation: scan for `${STELLA_*}`, `$env:STELLA_*`, `Environment.GetEnvironmentVariable("STELLA_*")`.
- Bash: warn if missing `set -euo pipefail`; error on `rm -rf /`.
- C#: warn on empty catch blocks.
- General: hardcoded IPs, TODO/FIXME, >500 lines.
- Improved brace matching with line numbers.
- Frontend: pass declared variables to validate, set Monaco diagnostic markers.
Completion criteria:
- [x] Backend validation returns categorized diagnostics
- [x] Frontend mock validates bash variable refs and safety
- [x] Monaco editor shows squiggly underlines from diagnostics
- [x] Markers cleared on content change
### TASK-004 - Phase 4: Script Version Diff Viewer
Status: DONE
Dependency: TASK-001
Owners: Developer
Task description:
- Store version content in `TenantState.VersionContents` on create/update.
- Implement `GetVersionContentAsync` and GET endpoint.
- Create `ScriptDiffComponent` with Monaco diff editor.
- Add route `:scriptId/diff` and "Compare" links in version history.
Completion criteria:
- [x] Version content stored per version number
- [x] GET endpoint returns version content
- [x] Diff component loads both versions and renders Monaco diff editor
- [x] Compare links navigate with correct query params
### TASK-005 - Phase 5: Deployment Compatibility Check
Status: DONE
Dependency: TASK-001
Owners: Developer
Task description:
- Language-target matrix (bash/ECS warn, PowerShell/Linux warn).
- Variable resolution against target metadata, secret availability check.
- Runtime notes for ECS and Nomad targets.
- POST endpoint for compatibility check.
- Compatibility panel in script-detail with target type dropdown and results.
Completion criteria:
- [x] Backend returns categorized compatibility issues
- [x] POST endpoint wired
- [x] UI panel toggles, sends request, displays results
- [x] Mock client implements same matrix logic
### TASK-006 - Version restore (edit older version)
Status: DONE
Dependency: TASK-004
Owners: Developer
Task description:
- Add "Edit" button on each non-current version in version history.
- Load older version content into Monaco editor via `getVersionContent()` API.
- Show warning banner when editing an old version.
- Show confirmation modal on save — "this will create a new latest version".
- Add `setContent()` method to ScriptEditorComponent.
Completion criteria:
- [x] Edit button loads older version content into editor
- [x] Warning banner visible with "Back to latest" dismiss
- [x] Modal blocks save until confirmed
- [x] After save, editingVersion resets and version list refreshes
### TASK-007 - Input field styling (Stella Ops design system)
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Match all form inputs to global search style: warm `surface-tertiary` background, same border/text/height/transition.
- Fix `box-sizing: border-box` overlap bug (inputs on same row overlapping by ~10px).
- Increase grid gaps between side-by-side fields (0.5rem → 1rem).
- Document the input field convention in `src/Web/StellaOps.Web/AGENTS.md`.
Completion criteria:
- [x] All inputs use surface-tertiary background, border-primary, 34px height, 0.12s transitions
- [x] box-sizing: border-box on all inputs — no overlap
- [x] Grid gaps 1rem between fields
- [x] AGENTS.md updated with Input Field Convention section
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-24 | Sprint created. All 5 phases implemented. Backend + frontend build clean. | Developer |
| 2026-03-24 | Docker images rebuilt (platform + console). Stack restarted. | Developer |
| 2026-03-24 | E2E tests T10.1T10.6 pass (7/7 including setup). | Developer |
| 2026-03-24 | TASK-006: Version restore with warning modal implemented. | Developer |
| 2026-03-24 | TASK-007: Input field styling fixed — warm bg, border-box, spacing. AGENTS.md updated with Input Field Convention. | Developer |
## Decisions & Risks
- All script storage is in-memory (`InMemoryScriptService`). No database migration required for this sprint.
- PowerShell not in `ScriptLanguageDto` enum — PowerShell-specific backend validation omitted but variable reference scanning includes `$env:STELLA_*` patterns.
- Version content storage is append-only within `TenantState`; no cleanup on version limit.
- Input overlap root cause: `box-sizing: content-box` (browser default) + `padding: 0 0.75rem` + `border: 1px` caused inputs to extend ~26px beyond their grid cell. Fix: `box-sizing: border-box` on all inputs.
## Next Checkpoints
- Integration with real `ScriptService` backed by PostgreSQL (future sprint).
- E2E tests for new features (variables editor, diff viewer, compatibility panel) — not yet covered by existing T10 suite.
## Files Modified
| File | Changes |
|------|---------|
| `src/Platform/.../Contracts/ScriptApiModels.cs` | +6 new DTOs/records, Variables on Detail/Summary/Requests |
| `src/Platform/.../Services/IScriptService.cs` | +2 methods |
| `src/Platform/.../Services/InMemoryScriptService.cs` | Variables storage, enhanced validation, version content, compatibility |
| `src/Platform/.../Endpoints/ScriptEndpoints.cs` | +2 endpoints (version content, compatibility) |
| `src/Web/.../core/api/scripts.models.ts` | +5 interfaces |
| `src/Web/.../core/api/scripts.client.ts` | +3 API methods, mock expansions, variables on scripts |
| `src/Web/.../features/scripts/script-detail.component.ts` | Variables editor, enhanced compile, compare links, compatibility panel |
| `src/Web/.../features/scripts/script-diff.component.ts` | **New** — Monaco diff viewer |
| `src/Web/.../features/scripts/scripts.routes.ts` | +1 route (diff) |
| `src/Web/.../shared/components/script-editor/script-editor.component.ts` | User variables input, diagnostic markers |
| `src/Web/.../shared/components/script-editor/script-context.ts` | User variables in completions |

View File

@@ -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";
}

View File

@@ -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";
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

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

View File

@@ -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);
}

View File

@@ -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);
}
}