Files
ersatztv/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs
T
timothyandClaude Opus 5.5 324dbdbca0
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Failing after 10s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
review-verdict/h10 Awaiting review verdict for 324dbdb
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m52s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 12m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
fix(913): scripted playout callbacks authenticate with a build-scoped token
A scripted playout's program calls back into ScriptedScheduleController,
which is under the fail-closed global ApiAuthorizationFilter, but was
handed no credential. Reproduced 2026-10-29 on a live local instance at
origin/main e919b925f (scripts/e2e-local.sh, port 8510): the program's
GET .../context and POST .../add_collection both returned 401, and the
playout's buildStatus still read success=true with no items, because a
program that exits 0 after the failure (entrypoint.py catches
ApiException and does) looks like a clean build.

Fix: ScriptedPlayoutBuilderService.StartSession mints a 256-bit token per
build, independent of the build id; ScriptedPlayoutBuilder passes it in
ETV_SCRIPTED_BUILD_TOKEN (environment, not argv: argv is logged at INF
and a fourth positional would shift the user's own arguments);
ApiAuthorizationFilter accepts it as X-Scripted-Build-Token on endpoints
carrying [AcceptsScriptedBuildToken] (ScriptedScheduleController only),
for the build named by the buildId route value, until EndSession.
entrypoint.py sends it as a default header. [SkipApiAuthorization] on the
controller was rejected: it widens the auth-exempt set by a mutating
controller whose only remaining gate would be a logged GUID, and the
Startup host check does not make the endpoint localhost-only.

Tests: ScriptedScheduleAuthorizationTests drives the real filter over the
endpoint metadata MVC builds from the production Startup for all 28
actions; ScriptedBuildTokenTests covers minting/validation and the
launch environment. Clause mutations, each rebuilt and run 2026-10-29:
  - drop [AcceptsScriptedBuildToken] from the controller -> red:
    Running_Builds_Own_Token_Is_Accepted..., Scripted_Controller_Is_Gated...
  - disarm the filter's token clause (no return) -> red: Running_Builds_Own_Token...
  - delete the filter's marker check -> red: Token_Opens_Nothing_Outside...
  - add [SkipApiAuthorization] to the controller -> red: No_Credential...,
    Scripted_Controller_Is_Gated..., Token_Of_Another..., Token_Is_A_401_Once...,
    Wrong_Or_Empty_Token (x2), ApiControllerSecurityTests.Only_Scanner_And_Auth...
  - token comparison always true -> red: Token_Of_Another..., Wrong_Or_Empty("not-the-token")
  - drop WithEnvironmentVariables from CreateCommand -> red: Launch_Hands_The_Token...
  - RouteValueName "buildId" -> "id" -> red: Token_Route_Value_Is_The_Controllers...

Live E2E on the fixed build (port 8510, fresh config): the real
entrypoint.py with the generated etv_client copied from the
ersatztv-test image built 3 playout items from a scanned collection;
as the program, own token -> 200 on GET context and POST add_collection;
no header, wrong token, another build's id, and the token on
GET /api/v1/channels and POST /api/v1/collections -> 401; the token
after the build ended -> 401; token absent from the server log.

Docs: new record api.scripted-build-token; testing.scripted-engine-in-
process-net's 401 clause now records the outcome; api-conventions §9 and
testing.md updated; catalog regenerated. OpenAPI regenerated: no drift.

Fixes #913

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
2026-10-30 00:34:50 +01:00

158 lines
6.2 KiB
C#

using System.CommandLine.Parsing;
using System.IO.Abstractions;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Scheduling.ScriptedScheduling;
public class ScriptedPlayoutBuilder(
IConfigElementRepository configElementRepository,
IScriptedPlayoutBuilderService scriptedPlayoutBuilderService,
ISchedulingEngine schedulingEngine,
IFileSystem fileSystem,
ILogger<ScriptedPlayoutBuilder> logger)
: IScriptedPlayoutBuilder
{
public async Task<Either<BaseError, PlayoutBuildResult>> Build(
DateTimeOffset start,
Playout playout,
PlayoutReferenceData referenceData,
PlayoutBuildMode mode,
CancellationToken cancellationToken)
{
var result = PlayoutBuildResult.Empty;
ScriptedBuildSession session = scriptedPlayoutBuilderService.StartSession(schedulingEngine);
Guid buildId = session.BuildId;
var timeoutSeconds = 30;
try
{
var args = CommandLineParser.SplitCommandLine(playout.ScheduleFile).ToList();
string scriptFile = args[0];
string[] scriptArgs = args.Skip(1).ToArray();
if (!fileSystem.File.Exists(scriptFile))
{
logger.LogError(
"Cannot build scripted playout; schedule file {File} does not exist",
scriptFile);
return BaseError.New($"Cannot build scripted playout; schedule file {scriptFile} does not exist");
}
var arguments = new List<string>
{
$"http://localhost:{Settings.UiPort}",
buildId.ToString(),
mode.ToString().ToLowerInvariant()
};
if (scriptArgs.Length > 0)
{
arguments.AddRange(scriptArgs);
}
logger.LogInformation(
"Building scripted playout {Script} with arguments {Arguments}",
scriptFile,
arguments);
int daysToBuild = await GetDaysToBuild(cancellationToken);
DateTimeOffset finish = start.AddDays(daysToBuild);
schedulingEngine.WithPlayoutId(playout.Id);
schedulingEngine.WithMode(mode);
schedulingEngine.WithSeed(playout.Seed);
schedulingEngine.BuildBetween(start, finish);
schedulingEngine.WithReferenceData(referenceData);
schedulingEngine.RestoreOrReset(Optional(playout.Anchor));
Option<int> maybeTimeoutSeconds = await configElementRepository.GetValue<int>(
ConfigElementKey.PlayoutScriptedScheduleTimeoutSeconds,
cancellationToken);
foreach (int seconds in maybeTimeoutSeconds)
{
timeoutSeconds = seconds;
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token);
Command command = CreateCommand(scriptFile, arguments, session.Token);
var commandResult = await command.ExecuteBufferedAsync(linkedCts.Token);
if (!string.IsNullOrWhiteSpace(commandResult.StandardOutput))
{
logger.LogDebug("Scripted playout output: {Output}", commandResult.StandardOutput);
}
if (commandResult.ExitCode != 0)
{
logger.LogWarning(
"Scripted playout process exited with code {Code}: {Error}",
commandResult.ExitCode,
commandResult.StandardError);
return BaseError.New(
$"Scripted playout process exited with code {commandResult.ExitCode}: {commandResult.StandardError}");
}
playout.Anchor = schedulingEngine.GetAnchor();
result = MergeResult(result, schedulingEngine.GetState());
}
catch (OperationCanceledException)
{
logger.LogWarning("Scripted playout build timed out after {TimeoutSeconds} seconds", timeoutSeconds);
return BaseError.New($"Scripted playout build timed out after {timeoutSeconds} seconds");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Unexpected exception building scripted playout");
return BaseError.New($"Unexpected exception building scripted playout: {ex}");
}
finally
{
scriptedPlayoutBuilderService.EndSession(buildId);
}
return result;
}
/// <summary>
/// The process launch for one build. The arguments are the published contract (host, build id, mode, then
/// the user's own); the build-scoped token travels in <see cref="ScriptedBuildToken.EnvironmentVariable" />
/// instead, because the global API authorization filter rejects an unauthenticated callback and the argument
/// list is logged (ersatztv#913). CliWrap layers the variable over the inherited environment.
/// </summary>
internal static Command CreateCommand(string scriptFile, IEnumerable<string> arguments, string buildToken) =>
Cli.Wrap(scriptFile)
.WithArguments(arguments)
.WithEnvironmentVariables(env => env.Set(ScriptedBuildToken.EnvironmentVariable, buildToken))
.WithValidation(CommandResultValidation.None);
private async Task<int> GetDaysToBuild(CancellationToken cancellationToken) =>
await configElementRepository
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild, cancellationToken)
.IfNoneAsync(2);
private static PlayoutBuildResult MergeResult(PlayoutBuildResult result, ISchedulingEngineState state) =>
result with
{
ClearItems = state.ClearItems,
RemoveBefore = state.RemoveBefore,
AddedItems = state.AddedItems,
//ItemsToRemove = state.ItemsToRemove,
AddedHistory = state.AddedHistory,
HistoryToRemove = state.HistoryToRemove
};
}