diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Scripts/ScriptsEndpoints.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Scripts/ScriptsEndpoints.cs new file mode 100644 index 000000000..59e072b3a --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.WebService/Scripts/ScriptsEndpoints.cs @@ -0,0 +1,408 @@ +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.ReleaseOrchestrator.Scripts; +using StellaOps.Scheduler.WebService.Auth; +using StellaOps.Scheduler.WebService.Security; + +namespace StellaOps.Scheduler.WebService.Scripts; + +/// +/// Minimal API endpoints for the Scripts registry (/api/v2/scripts). +/// +internal static class ScriptsEndpoints +{ + private static readonly JsonSerializerOptions s_json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + public static IEndpointRouteBuilder MapScriptsEndpoints(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/api/v2/scripts") + .RequireAuthorization(SchedulerPolicies.Read) + .RequireTenant(); + + group.MapGet("/", ListScriptsAsync) + .WithName("ListScripts") + .WithDescription("List scripts with optional search, language, and visibility filters."); + + group.MapGet("/{scriptId}", GetScriptAsync) + .WithName("GetScript") + .WithDescription("Get a single script by ID."); + + group.MapPost("/", CreateScriptAsync) + .WithName("CreateScript") + .WithDescription("Create a new script.") + .RequireAuthorization(SchedulerPolicies.Operate); + + group.MapPatch("/{scriptId}", UpdateScriptAsync) + .WithName("UpdateScript") + .WithDescription("Update an existing script.") + .RequireAuthorization(SchedulerPolicies.Operate); + + group.MapDelete("/{scriptId}", DeleteScriptAsync) + .WithName("DeleteScript") + .WithDescription("Delete a script.") + .RequireAuthorization(SchedulerPolicies.Operate); + + group.MapPost("/validate", ValidateScriptAsync) + .WithName("ValidateScript") + .WithDescription("Validate script syntax without saving."); + + group.MapGet("/{scriptId}/versions", GetVersionsAsync) + .WithName("ListScriptVersions") + .WithDescription("List all versions of a script."); + + group.MapGet("/{scriptId}/versions/{version:int}/content", GetVersionContentAsync) + .WithName("GetScriptVersionContent") + .WithDescription("Get the content of a specific script version."); + + group.MapPost("/{scriptId}/check-compatibility", CheckCompatibilityAsync) + .WithName("CheckScriptCompatibility") + .WithDescription("Check script compatibility with a target environment."); + + return routes; + } + + // ── List ──────────────────────────────────────────────────────────────── + + private static async Task ListScriptsAsync( + HttpContext httpContext, + [FromServices] IScriptRegistry registry, + CancellationToken ct) + { + try + { + var q = httpContext.Request.Query; + var criteria = new ScriptSearchCriteria + { + SearchText = q.TryGetValue("search", out var s) ? s.ToString() : null, + Language = q.TryGetValue("language", out var lang) ? ParseLanguage(lang.ToString()) : null, + Visibility = q.TryGetValue("visibility", out var vis) ? ParseVisibility(vis.ToString()) : null, + Limit = q.TryGetValue("limit", out var lim) && int.TryParse(lim.ToString(), out var l) ? l : 20, + Offset = q.TryGetValue("offset", out var off) && int.TryParse(off.ToString(), out var o) ? o : 0, + }; + + var result = await registry.SearchAsync(criteria, ct).ConfigureAwait(false); + var dtos = result.Scripts.Select(ToDto).ToArray(); + return Results.Json(dtos, s_json); + } + catch (Exception ex) when (ex is ArgumentException) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + + // ── Get ───────────────────────────────────────────────────────────────── + + private static async Task GetScriptAsync( + string scriptId, + [FromServices] IScriptRegistry registry, + CancellationToken ct) + { + var script = await registry.GetScriptAsync(scriptId, ct).ConfigureAwait(false); + if (script is null) return Results.NotFound(new { error = $"Script '{scriptId}' not found." }); + return Results.Json(ToDto(script), s_json); + } + + // ── Create ────────────────────────────────────────────────────────────── + + private static async Task CreateScriptAsync( + HttpContext httpContext, + [FromServices] IScriptRegistry registry, + [FromServices] ITenantContextAccessor tenantAccessor, + CancellationToken ct) + { + try + { + var body = await JsonSerializer.DeserializeAsync(httpContext.Request.Body, s_json, ct).ConfigureAwait(false); + if (body is null) return Results.BadRequest(new { error = "Invalid request body." }); + + var tenant = tenantAccessor.GetTenant(httpContext); + var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous"; + + var request = new CreateScriptRequest + { + Name = body.Name, + Description = body.Description, + Language = ParseLanguageRequired(body.Language), + Content = body.Content, + Tags = body.Tags?.ToImmutableArray(), + Visibility = ParseVisibilityRequired(body.Visibility), + }; + + var script = await registry.CreateScriptAsync(request, userId, ct: ct).ConfigureAwait(false); + + // Update variables on the created script via store if provided + return Results.Json(ToDto(script), s_json, statusCode: StatusCodes.Status201Created); + } + catch (ScriptValidationException ex) + { + return Results.BadRequest(new { error = ex.Message, errors = ex.Errors }); + } + catch (Exception ex) when (ex is ArgumentException or JsonException) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + + // ── Update ────────────────────────────────────────────────────────────── + + private static async Task UpdateScriptAsync( + string scriptId, + HttpContext httpContext, + [FromServices] IScriptRegistry registry, + [FromServices] ITenantContextAccessor tenantAccessor, + CancellationToken ct) + { + try + { + var body = await JsonSerializer.DeserializeAsync(httpContext.Request.Body, s_json, ct).ConfigureAwait(false); + if (body is null) return Results.BadRequest(new { error = "Invalid request body." }); + + var tenant = tenantAccessor.GetTenant(httpContext); + var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous"; + + var request = new UpdateScriptRequest + { + Name = body.Name, + Description = body.Description, + Content = body.Content, + Tags = body.Tags?.ToImmutableArray(), + Visibility = body.Visibility is not null ? ParseVisibilityRequired(body.Visibility) : null, + ChangeNote = body.ChangeNotes, + }; + + var updated = await registry.UpdateScriptAsync(scriptId, request, userId, ct).ConfigureAwait(false); + return Results.Json(ToDto(updated), s_json); + } + catch (ScriptNotFoundException) + { + return Results.NotFound(new { error = $"Script '{scriptId}' not found." }); + } + catch (ScriptValidationException ex) + { + return Results.BadRequest(new { error = ex.Message, errors = ex.Errors }); + } + catch (Exception ex) when (ex is ArgumentException or JsonException) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + + // ── Delete ────────────────────────────────────────────────────────────── + + private static async Task DeleteScriptAsync( + string scriptId, + [FromServices] IScriptRegistry registry, + CancellationToken ct) + { + var deleted = await registry.DeleteScriptAsync(scriptId, ct).ConfigureAwait(false); + return deleted + ? Results.NoContent() + : Results.NotFound(new { error = $"Script '{scriptId}' not found." }); + } + + // ── Validate ──────────────────────────────────────────────────────────── + + private static async Task ValidateScriptAsync( + HttpContext httpContext, + [FromServices] IScriptRegistry registry, + CancellationToken ct) + { + try + { + var body = await JsonSerializer.DeserializeAsync(httpContext.Request.Body, s_json, ct).ConfigureAwait(false); + if (body is null) return Results.BadRequest(new { error = "Invalid request body." }); + + var language = ParseLanguageRequired(body.Language); + var result = await registry.ValidateAsync(language, body.Content, ct).ConfigureAwait(false); + + var response = new + { + isValid = result.IsValid, + errors = result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => new + { + line = d.Line, + column = d.Column, + message = d.Message, + severity = "error", + category = "syntax", + }).ToArray(), + warnings = result.Diagnostics.Where(d => d.Severity != DiagnosticSeverity.Error).Select(d => new + { + line = d.Line, + column = d.Column, + message = d.Message, + severity = d.Severity == DiagnosticSeverity.Warning ? "warning" : "info", + category = "syntax", + }).ToArray(), + }; + return Results.Json(response, s_json); + } + catch (Exception ex) when (ex is ArgumentException or JsonException) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + + // ── Versions ──────────────────────────────────────────────────────────── + + private static async Task GetVersionsAsync( + string scriptId, + [FromServices] IScriptRegistry registry, + CancellationToken ct) + { + var versions = await registry.GetScriptVersionsAsync(scriptId, ct).ConfigureAwait(false); + var dtos = versions.Select(v => new + { + version = v.Version, + contentHash = v.ContentHash, + createdBy = v.CreatedBy, + createdAt = v.CreatedAt, + changeNotes = v.ChangeNote, + }).ToArray(); + return Results.Json(dtos, s_json); + } + + private static async Task GetVersionContentAsync( + string scriptId, + int version, + [FromServices] IScriptRegistry registry, + CancellationToken ct) + { + var ver = await registry.GetScriptVersionAsync(scriptId, version, ct).ConfigureAwait(false); + if (ver is null) return Results.NotFound(new { error = $"Version {version} not found for script '{scriptId}'." }); + + var dto = new + { + version = ver.Version, + contentHash = ver.ContentHash, + createdBy = ver.CreatedBy, + createdAt = ver.CreatedAt, + changeNotes = ver.ChangeNote, + content = ver.Content, + }; + return Results.Json(dto, s_json); + } + + // ── Compatibility ─────────────────────────────────────────────────────── + + private static async Task CheckCompatibilityAsync( + string scriptId, + HttpContext httpContext, + [FromServices] IScriptRegistry registry, + CancellationToken ct) + { + // Stub: always compatible. Real implementation will check runtime/target matrix. + var script = await registry.GetScriptAsync(scriptId, ct).ConfigureAwait(false); + if (script is null) return Results.NotFound(new { error = $"Script '{scriptId}' not found." }); + + var response = new { isCompatible = true, issues = Array.Empty() }; + return Results.Json(response, s_json); + } + + // ── DTO mapping ───────────────────────────────────────────────────────── + + private static object ToDto(Script s) => new + { + id = s.Id, + name = s.Name, + description = s.Description, + language = s.Language.ToString().ToLowerInvariant(), + content = s.Content, + version = s.Version, + visibility = s.Visibility.ToString().ToLowerInvariant(), + ownerId = s.OwnerId, + teamId = s.TeamId, + tags = s.Tags.ToArray(), + variables = s.Variables.Select(v => new + { + name = v.Name, + description = v.Description, + isRequired = v.IsRequired, + defaultValue = v.DefaultValue, + isSecret = v.IsSecret, + }).ToArray(), + contentHash = s.ContentHash, + isSample = s.IsSample, + sampleCategory = s.SampleCategory, + createdAt = s.CreatedAt, + updatedAt = s.UpdatedAt, + }; + + // ── DTO types ─────────────────────────────────────────────────────────── + + private sealed record CreateScriptDto + { + public string Name { get; init; } = ""; + public string Description { get; init; } = ""; + public string Language { get; init; } = ""; + public string Content { get; init; } = ""; + public string Visibility { get; init; } = "private"; + public string[]? Tags { get; init; } + public ScriptVariableDto[]? Variables { get; init; } + } + + private sealed record UpdateScriptDto + { + public string? Name { get; init; } + public string? Description { get; init; } + public string? Content { get; init; } + public string? Visibility { get; init; } + public string[]? Tags { get; init; } + public ScriptVariableDto[]? Variables { get; init; } + public string? ChangeNotes { get; init; } + } + + private sealed record ValidateScriptDto + { + public string Language { get; init; } = ""; + public string Content { get; init; } = ""; + } + + private sealed record ScriptVariableDto + { + public string Name { get; init; } = ""; + public string? Description { get; init; } + public bool IsRequired { get; init; } + public string? DefaultValue { get; init; } + public bool IsSecret { get; init; } + } + + // ── Enum parsing ──────────────────────────────────────────────────────── + + private static ScriptLanguage? ParseLanguage(string? value) => value?.ToLowerInvariant() switch + { + "csharp" => ScriptLanguage.CSharp, + "python" => ScriptLanguage.Python, + "java" => ScriptLanguage.Java, + "go" => ScriptLanguage.Go, + "bash" => ScriptLanguage.Bash, + "typescript" => ScriptLanguage.TypeScript, + "powershell" => ScriptLanguage.PowerShell, + _ => null, + }; + + private static ScriptLanguage ParseLanguageRequired(string value) + => ParseLanguage(value) ?? throw new ArgumentException($"Unknown language: '{value}'"); + + private static ScriptVisibility? ParseVisibility(string? value) => value?.ToLowerInvariant() switch + { + "private" => ScriptVisibility.Private, + "team" => ScriptVisibility.Team, + "organization" => ScriptVisibility.Organization, + "public" => ScriptVisibility.Public, + _ => null, + }; + + private static ScriptVisibility ParseVisibilityRequired(string value) + => ParseVisibility(value) ?? throw new ArgumentException($"Unknown visibility: '{value}'"); +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/script-picker/script-picker.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/script-picker/script-picker.component.ts new file mode 100644 index 000000000..8874d5f7d --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/script-picker/script-picker.component.ts @@ -0,0 +1,165 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject, signal, OnInit } from '@angular/core'; +import { take } from 'rxjs'; +import { SCRIPTS_API } from '../../../core/api/scripts.client'; +import type { Script } from '../../../core/api/scripts.models'; + +@Component({ + selector: 'app-script-picker', + standalone: true, + template: ` +
+ +
+ @if (selectedScript()) { +
+ {{ selectedScript()!.language }} + {{ selectedScript()!.name }} + v{{ selectedScript()!.version }} + +
+ } @else { + + } +
+ + @if (dropdownOpen()) { +
+ + @if (loading()) { +
Loading...
+ } @else if (filteredScripts().length === 0) { +
No scripts found
+ } @else { +
    + @for (s of filteredScripts(); track s.id) { +
  • + {{ s.language }} +
    + {{ s.name }} + @if (s.description) { + {{ s.description }} + } +
    + v{{ s.version }} +
  • + } +
+ } +
+ } +
+ `, + styles: [` + .script-picker { position: relative; } + .script-picker__label { display: block; font-size: 0.6875rem; font-weight: 600; color: var(--color-text-secondary); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.04em; } + + .script-picker__control { display: flex; align-items: center; } + + .script-picker__browse { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.75rem; font-size: 0.75rem; font-family: inherit; font-weight: 500; border: 1px dashed var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-surface-secondary); color: var(--color-text-muted); cursor: pointer; transition: all 150ms; } + .script-picker__browse:hover { border-color: var(--color-brand-primary); color: var(--color-text-primary); background: var(--color-surface-tertiary); } + + .script-picker__selected { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-surface-secondary); } + .script-picker__name { font-weight: 600; color: var(--color-text-primary); } + .script-picker__version { font-family: var(--font-family-mono, monospace); font-size: 0.6875rem; color: var(--color-text-muted); } + .script-picker__clear { margin-left: 0.25rem; padding: 0 0.25rem; border: none; background: none; color: var(--color-text-muted); font-size: 1rem; cursor: pointer; line-height: 1; } + .script-picker__clear:hover { color: var(--color-status-error-text); } + + .script-picker__dropdown { position: absolute; top: 100%; left: 0; z-index: 100; width: 100%; min-width: 320px; max-height: 280px; margin-top: 0.25rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); box-shadow: var(--shadow-lg, 0 4px 16px rgba(0,0,0,.12)); overflow: hidden; display: flex; flex-direction: column; } + + .script-picker__search { width: 100%; padding: 0.5rem 0.6rem; border: none; border-bottom: 1px solid var(--color-border-primary); background: var(--color-surface-secondary); font-size: 0.75rem; font-family: inherit; color: var(--color-text-primary); outline: none; } + .script-picker__search::placeholder { color: var(--color-text-muted); } + + .script-picker__loading, .script-picker__empty { padding: 1rem; text-align: center; font-size: 0.75rem; color: var(--color-text-muted); } + + .script-picker__list { list-style: none; margin: 0; padding: 0; overflow-y: auto; max-height: 220px; } + .script-picker__item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; cursor: pointer; transition: background 100ms; } + .script-picker__item:hover { background: var(--color-surface-tertiary); } + .script-picker__item-body { flex: 1; min-width: 0; } + .script-picker__item-name { display: block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .script-picker__item-desc { display: block; font-size: 0.65rem; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; } + .script-picker__item-version { flex-shrink: 0; font-family: var(--font-family-mono, monospace); font-size: 0.65rem; color: var(--color-text-muted); } + + .script-picker__badge { display: inline-block; flex-shrink: 0; padding: 0.0625rem 0.3rem; font-size: 0.5625rem; font-weight: 600; border-radius: var(--radius-sm); text-transform: uppercase; letter-spacing: 0.03em; background: var(--color-surface-tertiary); color: var(--color-text-secondary); } + .script-picker__badge[data-lang="bash"] { background: var(--color-status-success-bg); color: var(--color-status-success-text); } + .script-picker__badge[data-lang="csharp"] { background: var(--color-status-info-bg); color: var(--color-status-info-text); } + .script-picker__badge[data-lang="python"] { background: #FFF3E0; color: #E65100; } + .script-picker__badge[data-lang="typescript"] { background: #E3F2FD; color: #1565C0; } + .script-picker__badge[data-lang="powershell"] { background: #EDE7F6; color: #6A1B9A; } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ScriptPickerComponent implements OnInit { + private readonly api = inject(SCRIPTS_API); + + @Input() label = 'Script'; + @Input() selectedScriptId: string | null = null; + + @Output() scriptSelected = new EventEmitter