Files
ersatztv/scripts/scripted-schedules/entrypoint.py
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

62 lines
2.3 KiB
Python
Executable File

#!/usr/bin/python3
import argparse
import importlib
import os
import sys
from uuid import UUID
import etv_client
from etv_client.api import ScriptedScheduleApi
def main():
parser = argparse.ArgumentParser(description="Run an ETV scripted schedule")
parser.add_argument("host", help="The ETV host (e.g., http://localhost:8409)")
parser.add_argument("build_id", type=UUID, help="The build ID for the playout")
parser.add_argument("mode", choices=["reset", "continue"], help="The playout build mode")
parser.add_argument("script_name", help="The name of the script module to use (e.g., one)")
known_args, unknown_args = parser.parse_known_args()
try:
script_module = importlib.import_module(f"scripts.{known_args.script_name}")
except ImportError:
print(f"Error: cannot find a script file named '{known_args.script_name}.py' in the 'scripts' directory.")
sys.exit(1)
configuration = etv_client.Configuration(host=known_args.host)
# ErsatzTV mints a token per build and passes it in the environment; the scripted callback endpoints
# reject a request without it (ersatztv#913). A custom header, not the machine X-Api-Key.
build_token = os.environ.get("ETV_SCRIPTED_BUILD_TOKEN")
with etv_client.ApiClient(configuration) as api_client:
if build_token:
api_client.set_default_header("X-Scripted-Build-Token", build_token)
try:
define_content = script_module.define_content
reset_playout = script_module.reset_playout
build_playout = script_module.build_playout
api_instance = ScriptedScheduleApi(api_client)
context = api_instance.get_context(known_args.build_id)
define_content(api_instance, context, known_args.build_id)
if known_args.mode == "reset":
new_context = reset_playout(api_instance, context, known_args.build_id)
context = new_context or api_instance.get_context(known_args.build_id)
build_playout(api_instance, context, known_args.build_id)
except etv_client.ApiException as e:
print(f"Exception when calling scripted schedule api: {e}\n")
except AttributeError as e:
print(f"Error: the '{known_args.script_name}' script is missing a required function. {e}")
if __name__ == "__main__":
main()