diff --git a/docs/implplan/SPRINT_20260324_001_Platform_scripts_variables_validation_diff_compat.md b/docs/implplan/SPRINT_20260324_001_Platform_scripts_variables_validation_diff_compat.md new file mode 100644 index 000000000..f57c4a73c --- /dev/null +++ b/docs/implplan/SPRINT_20260324_001_Platform_scripts_variables_validation_diff_compat.md @@ -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.1–T10.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.1–T10.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 | diff --git a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs index 574d5b987..c7dbb18d3 100644 --- a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs +++ b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs @@ -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"; } diff --git a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs index 3aaa41637..83bc1f863 100644 --- a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs +++ b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs @@ -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"; } diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/ScriptApiModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/ScriptApiModels.cs new file mode 100644 index 000000000..abf32f15f --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Contracts/ScriptApiModels.cs @@ -0,0 +1,212 @@ +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace StellaOps.Platform.WebService.Contracts; + +/// +/// Supported script languages (mirrors ReleaseOrchestrator.Scripts.ScriptLanguage). +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ScriptLanguageDto +{ + CSharp, + Python, + Java, + Go, + Bash, + TypeScript +} + +/// +/// Script visibility/access control level. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ScriptVisibilityDto +{ + Private, + Team, + Organization, + Public +} + +/// +/// Declared variable for a script (runtime input). +/// +public sealed record ScriptVariableDeclaration( + string Name, + string? Description, + bool IsRequired, + string? DefaultValue, + bool IsSecret); + +/// +/// Script summary returned in list results (excludes content for efficiency). +/// +public sealed record ScriptSummary( + string Id, + string Name, + string? Description, + ScriptLanguageDto Language, + ScriptVisibilityDto Visibility, + int Version, + IReadOnlyList Tags, + IReadOnlyList Variables, + string OwnerId, + string? TeamId, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt, + string ContentHash); + +/// +/// Full script detail including content. +/// +public sealed record ScriptDetail( + string Id, + string Name, + string? Description, + ScriptLanguageDto Language, + string Content, + string? EntryPoint, + ScriptVisibilityDto Visibility, + int Version, + IReadOnlyList Dependencies, + IReadOnlyList Tags, + IReadOnlyList Variables, + string OwnerId, + string? TeamId, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt, + string ContentHash, + bool IsSample, + string? SampleCategory); + +/// +/// Script dependency reference. +/// +public sealed record ScriptDependencyDto( + string Name, + string Version, + string? Source, + bool IsDevelopment); + +/// +/// Script version history entry. +/// +public sealed record ScriptVersionDto( + string ScriptId, + int Version, + string ContentHash, + IReadOnlyList Dependencies, + DateTimeOffset CreatedAt, + string CreatedBy, + string? ChangeNote); + +/// +/// Script version with content (for diff). +/// +public sealed record ScriptVersionDetailDto( + string ScriptId, + int Version, + string ContentHash, + string Content, + DateTimeOffset CreatedAt, + string CreatedBy, + string? ChangeNote); + +/// +/// Create script request body. +/// +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? Dependencies { get; init; } + public IReadOnlyList? Tags { get; init; } + public IReadOnlyList? Variables { get; init; } + public ScriptVisibilityDto Visibility { get; init; } = ScriptVisibilityDto.Private; +} + +/// +/// Update script request body. +/// +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? Dependencies { get; init; } + public IReadOnlyList? Tags { get; init; } + public IReadOnlyList? Variables { get; init; } + public ScriptVisibilityDto? Visibility { get; init; } + public string? ChangeNote { get; init; } +} + +/// +/// Validate script request body (validate-only, no save). +/// +public sealed record ValidateScriptApiRequest +{ + public required ScriptLanguageDto Language { get; init; } + public required string Content { get; init; } + public IReadOnlyList? DeclaredVariables { get; init; } +} + +/// +/// Diagnostic severity for script validation results. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ScriptDiagnosticSeverityDto +{ + Info, + Warning, + Error +} + +/// +/// Script diagnostic entry from validation. +/// +public sealed record ScriptDiagnosticDto( + ScriptDiagnosticSeverityDto Severity, + string Message, + int Line, + int Column, + int? EndLine, + int? EndColumn, + string? Category = null); + +/// +/// Script validation result. +/// +public sealed record ScriptValidationResultDto( + bool IsValid, + IReadOnlyList Errors, + IReadOnlyList Diagnostics); + +/// +/// Request to check script compatibility with a target. +/// +public sealed record CheckCompatibilityRequest +{ + public required string TargetType { get; init; } + public IReadOnlyDictionary? TargetMetadata { get; init; } + public IReadOnlyList? AvailableSecrets { get; init; } +} + +/// +/// Compatibility check result. +/// +public sealed record CompatibilityResultDto( + bool IsCompatible, + IReadOnlyList Issues); + +/// +/// A single compatibility issue. +/// +public sealed record CompatibilityIssue( + string Category, + string Severity, + string Message); diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/ScriptEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/ScriptEndpoints.cs new file mode 100644 index 000000000..c25ec4021 --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/ScriptEndpoints.cs @@ -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; + +/// +/// Script registry API endpoints (CRUD, versioning, validation). +/// +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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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; + } +} diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs index 2070d47e7..f1aba7787 100644 --- a/src/Platform/StellaOps.Platform.WebService/Program.cs +++ b/src/Platform/StellaOps.Platform.WebService/Program.cs @@ -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(); @@ -288,6 +290,9 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Script registry services (multi-language script editor) +builder.Services.AddSingleton(); + 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") diff --git a/src/Platform/StellaOps.Platform.WebService/Services/IScriptService.cs b/src/Platform/StellaOps.Platform.WebService/Services/IScriptService.cs new file mode 100644 index 000000000..c39bd050b --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Services/IScriptService.cs @@ -0,0 +1,69 @@ +using StellaOps.Platform.WebService.Contracts; + +namespace StellaOps.Platform.WebService.Services; + +/// +/// Service interface for script CRUD, versioning, and validation. +/// +public interface IScriptService +{ + Task> SearchAsync( + string tenantId, + ScriptLanguageDto? language, + ScriptVisibilityDto? visibility, + string? search, + int limit, + int offset, + CancellationToken cancellationToken = default); + + Task CountAsync( + string tenantId, + ScriptLanguageDto? language, + ScriptVisibilityDto? visibility, + string? search, + CancellationToken cancellationToken = default); + + Task GetByIdAsync( + string tenantId, + string scriptId, + CancellationToken cancellationToken = default); + + Task CreateAsync( + string tenantId, + string actorId, + CreateScriptApiRequest request, + CancellationToken cancellationToken = default); + + Task UpdateAsync( + string tenantId, + string actorId, + string scriptId, + UpdateScriptApiRequest request, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + string tenantId, + string scriptId, + CancellationToken cancellationToken = default); + + Task> GetVersionsAsync( + string tenantId, + string scriptId, + CancellationToken cancellationToken = default); + + Task ValidateAsync( + ValidateScriptApiRequest request, + CancellationToken cancellationToken = default); + + Task GetVersionContentAsync( + string tenantId, + string scriptId, + int version, + CancellationToken cancellationToken = default); + + Task CheckCompatibilityAsync( + string tenantId, + string scriptId, + CheckCompatibilityRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Platform/StellaOps.Platform.WebService/Services/InMemoryScriptService.cs b/src/Platform/StellaOps.Platform.WebService/Services/InMemoryScriptService.cs new file mode 100644 index 000000000..87743410e --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Services/InMemoryScriptService.cs @@ -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; + +/// +/// In-memory implementation of for development and testing. +/// +public sealed class InMemoryScriptService : IScriptService +{ + private readonly TimeProvider _timeProvider; + private readonly ConcurrentDictionary _states = new(StringComparer.OrdinalIgnoreCase); + + /// + /// The 16 known STELLA_* system variable suffixes. + /// + private static readonly HashSet 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> 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>(list); + } + } + + public Task 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 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 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 { [1] = request.Content }; + } + + return Task.FromResult(script); + } + + public Task 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(); + state.VersionContents[scriptId] = versionContents; + } + + versionContents[newVersion] = newContent; + } + + return Task.FromResult(updated); + } + } + + public Task 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> 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>([]); + } + + var result = versions + .OrderByDescending(v => v.Version) + .ToArray(); + + return Task.FromResult>(result); + } + } + + public Task 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(null); + } + + var versionMeta = versions.FirstOrDefault(v => v.Version == version); + if (versionMeta is null) + { + return Task.FromResult(null); + } + + // Look up the version content + if (!state.VersionContents.TryGetValue(scriptId, out var versionContents) || + !versionContents.TryGetValue(version, out var content)) + { + return Task.FromResult(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(detail); + } + } + + public Task 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(); + 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 ValidateAsync( + ValidateScriptApiRequest request, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var errors = new List(); + var diagnostics = new List(); + + 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 errors, List 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 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 errors, List 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 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? declaredVariables, + List diagnostics) + { + var referencedVars = new HashSet(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( + (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 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 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 Scripts = new(StringComparer.Ordinal); + public readonly Dictionary> Versions = new(StringComparer.Ordinal); + public readonly Dictionary> VersionContents = new(StringComparer.Ordinal); + } +}