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
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
158 lines
6.2 KiB
C#
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
|
|
};
|
|
}
|