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
62 lines
2.3 KiB
Python
Executable File
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()
|