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