Skip to content

Commit 63550a2

Browse files
committed
api: harden lifecycle transitions and secret writes
1 parent 9e6630e commit 63550a2

4 files changed

Lines changed: 180 additions & 58 deletions

File tree

bin/youtube-autoencoder

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -710,7 +710,7 @@ def reconcile_lifecycle(runtime: StreamRuntime, initial_state: dict[str, Any]) -
710710
"retry_class": "api",
711711
},
712712
)
713-
if action == "poll":
713+
if lifecycle == "created" or action == "poll":
714714
wait_with_runtime(runtime, poll_interval())
715715
observation = current_observation(runtime, state)
716716
state["lifecycle"] = observation.get("lifecycle")

bin/youtube-autoencoder-api

Lines changed: 61 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import signal
1717
import socket
1818
import subprocess
1919
import sys
20+
import tempfile
2021
import time
2122
import urllib.error
2223
import urllib.parse
@@ -26,7 +27,9 @@ from typing import Any
2627

2728
HOME = pathlib.Path.home()
2829
BASE_DIR = pathlib.Path(os.environ.get("YTA_CONFIG_DIR", HOME / ".config/youtube-autoencoder")).expanduser()
29-
CLIENT_FILE = pathlib.Path(os.environ.get("YTA_YOUTUBE_CLIENT_FILE", BASE_DIR / "google-oauth-client.json")).expanduser()
30+
CLIENT_FILE = pathlib.Path(
31+
os.environ.get("YTA_YOUTUBE_CLIENT_FILE", BASE_DIR / "google-oauth-client.json")
32+
).expanduser()
3033
TOKEN_FILE = pathlib.Path(os.environ.get("YTA_YOUTUBE_TOKEN_FILE", BASE_DIR / "youtube-token.json")).expanduser()
3134
STATE_FILE = pathlib.Path(os.environ.get("YTA_YOUTUBE_STATE_FILE", BASE_DIR / "youtube-live-state.json")).expanduser()
3235
LOCK_FILE = pathlib.Path(os.environ.get("YTA_YOUTUBE_LOCK_FILE", BASE_DIR / "youtube-live-state.lock")).expanduser()
@@ -40,9 +43,11 @@ API_BASE = "https://www.googleapis.com/youtube/v3"
4043
child: subprocess.Popen[Any] | None = None
4144
stopping = False
4245
RATE_LIMIT_REASONS = {
46+
"concurrentBroadcastsExceedLimit",
4347
"dailyLimitExceeded",
4448
"quotaExceeded",
4549
"rateLimitExceeded",
50+
"sharedIngestionBroadcastsExceedLimit",
4651
"userRateLimitExceeded",
4752
"userRequestsExceedRateLimit",
4853
}
@@ -103,16 +108,28 @@ def fsync_directory(path: pathlib.Path) -> None:
103108

104109
def write_json_durable(path: pathlib.Path, data: dict[str, Any], mode: int = 0o600) -> None:
105110
path.parent.mkdir(parents=True, exist_ok=True)
106-
tmp = path.with_suffix(path.suffix + ".tmp")
107-
with tmp.open("w", encoding="utf-8") as handle:
108-
json.dump(data, handle, indent=2, sort_keys=True)
109-
handle.write("\n")
110-
handle.flush()
111-
os.fsync(handle.fileno())
112-
os.chmod(tmp, mode)
113-
tmp.replace(path)
114-
os.chmod(path, mode)
115-
fsync_directory(path.parent)
111+
raw_fd, tmp_name = tempfile.mkstemp(
112+
prefix=f".{path.name}.",
113+
suffix=".tmp",
114+
dir=path.parent,
115+
)
116+
tmp = pathlib.Path(tmp_name)
117+
try:
118+
os.fchmod(raw_fd, mode)
119+
file_handle = os.fdopen(raw_fd, "w", encoding="utf-8")
120+
raw_fd = -1
121+
with file_handle as handle:
122+
json.dump(data, handle, indent=2, sort_keys=True)
123+
handle.write("\n")
124+
handle.flush()
125+
os.fsync(handle.fileno())
126+
tmp.replace(path)
127+
os.chmod(path, mode)
128+
fsync_directory(path.parent)
129+
finally:
130+
if raw_fd >= 0:
131+
os.close(raw_fd)
132+
tmp.unlink(missing_ok=True)
116133

117134

118135
def write_secret_json(path: pathlib.Path, data: dict[str, Any]) -> None:
@@ -223,11 +240,7 @@ def http_json(
223240
details = parsed.get("error") if isinstance(parsed, dict) else None
224241
details = details if isinstance(details, dict) else {}
225242
errors = details.get("errors") or []
226-
reasons = tuple(
227-
str(item.get("reason"))
228-
for item in errors
229-
if isinstance(item, dict) and item.get("reason")
230-
)
243+
reasons = tuple(str(item.get("reason")) for item in errors if isinstance(item, dict) and item.get("reason"))
231244
message = str(details.get("message") or exc.reason or "YouTube API request failed")
232245
retry_after = None
233246
retry_after_value = exc.headers.get("Retry-After") if exc.headers else None
@@ -350,7 +363,7 @@ def list_streams() -> list[dict[str, Any]]:
350363
def find_obs_stream() -> dict[str, Any]:
351364
stream_name = obs_stream_name()
352365
for stream in list_streams():
353-
ingestion = ((stream.get("cdn") or {}).get("ingestionInfo") or {})
366+
ingestion = (stream.get("cdn") or {}).get("ingestionInfo") or {}
354367
if ingestion.get("streamName") == stream_name:
355368
return stream
356369
raise LookupError("no YouTube liveStream matched the configured OBS stream key")
@@ -373,7 +386,7 @@ def create_stream() -> dict[str, Any]:
373386

374387

375388
def save_stream_to_obs(stream: dict[str, Any]) -> None:
376-
ingestion = ((stream.get("cdn") or {}).get("ingestionInfo") or {})
389+
ingestion = (stream.get("cdn") or {}).get("ingestionInfo") or {}
377390
stream_name = ingestion.get("streamName")
378391
server = ingestion.get("rtmpsIngestionAddress") or ingestion.get("ingestionAddress")
379392
if not stream_name or not server:
@@ -430,13 +443,9 @@ def has_marker(broadcast: dict[str, Any], marker: str) -> bool:
430443

431444
def broadcast_generation(broadcast: dict[str, Any]) -> str:
432445
description = str((broadcast.get("snippet") or {}).get("description") or "")
433-
marker_pattern = re.compile(
434-
rf"^\[{re.escape(GENERATION_MARKER_PREFIX)}:([A-Za-z0-9._-]+)\]$"
435-
)
446+
marker_pattern = re.compile(rf"^\[{re.escape(GENERATION_MARKER_PREFIX)}:([A-Za-z0-9._-]+)\]$")
436447
generations = [
437-
match.group(1)
438-
for line in description.splitlines()
439-
if (match := marker_pattern.fullmatch(line)) is not None
448+
match.group(1) for line in description.splitlines() if (match := marker_pattern.fullmatch(line)) is not None
440449
]
441450
if len(generations) != 1:
442451
raise ReconciliationError("managed broadcast must contain exactly one generation marker")
@@ -539,9 +548,7 @@ def lifecycle_state(
539548
return state
540549

541550

542-
def validate_candidate(
543-
broadcast: dict[str, Any], *, instance: str, generation: str | None, stream_id: str
544-
) -> None:
551+
def validate_candidate(broadcast: dict[str, Any], *, instance: str, generation: str | None, stream_id: str) -> None:
545552
lifecycle = broadcast_lifecycle(broadcast)
546553
if lifecycle not in KNOWN_LIFECYCLE_STATES:
547554
raise ReconciliationError(f"unknown YouTube broadcast lifecycle: {lifecycle or '<empty>'}")
@@ -578,9 +585,7 @@ def choose_candidate(
578585
return candidate
579586

580587

581-
def reconcile_broadcast(
582-
*, stream_id: str, title: str, staging_privacy: str, allow_create: bool
583-
) -> dict[str, Any]:
588+
def reconcile_broadcast(*, stream_id: str, title: str, staging_privacy: str, allow_create: bool) -> dict[str, Any]:
584589
with mutation_lock(timeout=lock_timeout()):
585590
instance = instance_id()
586591
state = read_state()
@@ -650,8 +655,7 @@ def reconcile_broadcast(
650655
stream_status_value = str((stream.get("status") or {}).get("streamStatus") or "")
651656
if stream_status_value != "active":
652657
raise ReconciliationError(
653-
"YouTube ingest stopped before broadcast creation; "
654-
f"got {stream_status_value or 'unknown'}"
658+
f"YouTube ingest stopped before broadcast creation; got {stream_status_value or 'unknown'}"
655659
)
656660
broadcast = create_broadcast(
657661
title,
@@ -925,9 +929,7 @@ def stream_status_command(args: argparse.Namespace) -> int:
925929

926930
def broadcast_status_command(args: argparse.Namespace) -> int:
927931
state = read_state()
928-
broadcast_id = args.broadcast_id or str(
929-
state.get("broadcast_id") or state.get("last_broadcast_id") or ""
930-
)
932+
broadcast_id = args.broadcast_id or str(state.get("broadcast_id") or state.get("last_broadcast_id") or "")
931933
if not broadcast_id:
932934
raise ValueError("no broadcast id provided and no previous state file found")
933935
print(json.dumps(broadcast_status(broadcast_id), indent=2, sort_keys=True))
@@ -937,9 +939,7 @@ def broadcast_status_command(args: argparse.Namespace) -> int:
937939
def set_privacy_command(args: argparse.Namespace) -> int:
938940
with mutation_lock(timeout=lock_timeout()):
939941
state = read_state()
940-
broadcast_id = args.broadcast_id or str(
941-
state.get("broadcast_id") or state.get("last_broadcast_id") or ""
942-
)
942+
broadcast_id = args.broadcast_id or str(state.get("broadcast_id") or state.get("last_broadcast_id") or "")
943943
if not broadcast_id:
944944
raise ValueError("no broadcast id provided and no previous state file found")
945945
result = set_broadcast_privacy(broadcast_id, args.privacy)
@@ -1050,11 +1050,15 @@ def run_visible_test(args: argparse.Namespace) -> int:
10501050
def complete(args: argparse.Namespace) -> int:
10511051
with mutation_lock(timeout=lock_timeout()):
10521052
state = read_state()
1053-
broadcast_id = args.broadcast_id or str(
1054-
state.get("broadcast_id") or state.get("last_broadcast_id") or ""
1055-
)
1053+
broadcast_id = args.broadcast_id or str(state.get("broadcast_id") or state.get("last_broadcast_id") or "")
10561054
if not broadcast_id:
10571055
raise ValueError("no broadcast id provided and no previous state file found")
1056+
current = broadcast_by_id(broadcast_id)
1057+
lifecycle = broadcast_lifecycle(current or {})
1058+
if current is None or lifecycle != "live":
1059+
raise ReconciliationError(
1060+
f"refusing completion because broadcast is not confirmed live: {lifecycle or 'missing'}"
1061+
)
10581062
result = transition(broadcast_id, "complete")
10591063
print(json.dumps({"id": result.get("id"), "status": result.get("status")}, indent=2, sort_keys=True))
10601064
return 0
@@ -1063,9 +1067,7 @@ def complete(args: argparse.Namespace) -> int:
10631067
def transition_command(args: argparse.Namespace) -> int:
10641068
with mutation_lock(timeout=lock_timeout()):
10651069
state = read_state()
1066-
broadcast_id = args.broadcast_id or str(
1067-
state.get("broadcast_id") or state.get("last_broadcast_id") or ""
1068-
)
1070+
broadcast_id = args.broadcast_id or str(state.get("broadcast_id") or state.get("last_broadcast_id") or "")
10691071
if not broadcast_id:
10701072
raise ValueError("no broadcast id provided and no previous state file found")
10711073
result = transition(broadcast_id, args.status)
@@ -1097,10 +1099,16 @@ def main() -> int:
10971099
broadcast_status_p.set_defaults(func=broadcast_status_command)
10981100

10991101
prepare_p = sub.add_parser("prepare-broadcast", help="Create and bind a broadcast for the reusable stream")
1100-
prepare_p.add_argument("--privacy", choices=["public", "unlisted", "private"], default=os.environ.get("YTA_YOUTUBE_PRIVACY", "unlisted"))
1102+
prepare_p.add_argument(
1103+
"--privacy",
1104+
choices=["public", "unlisted", "private"],
1105+
default=os.environ.get("YTA_YOUTUBE_PRIVACY", "unlisted"),
1106+
)
11011107
prepare_p.add_argument("--title")
11021108
prepare_p.add_argument("--title-prefix", default=os.environ.get("YTA_YOUTUBE_TITLE_PREFIX", "AutoEncoder Live"))
1103-
prepare_p.add_argument("--create-stream", action="store_true", help="Create a reusable stream if OBS key is not found")
1109+
prepare_p.add_argument(
1110+
"--create-stream", action="store_true", help="Create a reusable stream if OBS key is not found"
1111+
)
11041112
prepare_p.set_defaults(func=prepare_broadcast)
11051113

11061114
reconcile_p = sub.add_parser(
@@ -1112,9 +1120,7 @@ def main() -> int:
11121120
default=os.environ.get("YTA_YOUTUBE_PRIVACY", "unlisted"),
11131121
)
11141122
reconcile_p.add_argument("--title")
1115-
reconcile_p.add_argument(
1116-
"--title-prefix", default=os.environ.get("YTA_YOUTUBE_TITLE_PREFIX", "AutoEncoder Live")
1117-
)
1123+
reconcile_p.add_argument("--title-prefix", default=os.environ.get("YTA_YOUTUBE_TITLE_PREFIX", "AutoEncoder Live"))
11181124
reconcile_p.add_argument(
11191125
"--create-stream", action="store_true", help="Create a reusable stream if OBS key is not found"
11201126
)
@@ -1134,7 +1140,11 @@ def main() -> int:
11341140
test_p.add_argument("--duration", type=int, default=900)
11351141
test_p.add_argument("--wait-stream-active", type=int, default=120)
11361142
test_p.add_argument("--testing-delay", type=int, default=8)
1137-
test_p.add_argument("--privacy", choices=["public", "unlisted", "private"], default=os.environ.get("YTA_YOUTUBE_PRIVACY", "unlisted"))
1143+
test_p.add_argument(
1144+
"--privacy",
1145+
choices=["public", "unlisted", "private"],
1146+
default=os.environ.get("YTA_YOUTUBE_PRIVACY", "unlisted"),
1147+
)
11381148
test_p.add_argument("--title")
11391149
test_p.add_argument("--create-stream", action="store_true", help="Create a reusable stream if OBS key is not found")
11401150
test_p.add_argument(
@@ -1150,7 +1160,7 @@ def main() -> int:
11501160
complete_p.set_defaults(func=complete)
11511161

11521162
transition_p = sub.add_parser("transition", help="Transition the last or specified broadcast")
1153-
transition_p.add_argument("status", choices=["testing", "live", "complete"])
1163+
transition_p.add_argument("status", choices=["testing", "live"])
11541164
transition_p.add_argument("broadcast_id", nargs="?")
11551165
transition_p.set_defaults(func=transition_command)
11561166

tests/test_api.py

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import contextlib
55
import io
66
import json
7+
import os
78
import urllib.error
89
import uuid
910

@@ -82,6 +83,24 @@ def tracking_fsync(fd):
8283
assert not path.with_suffix(path.suffix + ".tmp").exists()
8384

8485

86+
def test_write_json_durable_creates_private_unique_temp(load_script, monkeypatch, tmp_path):
87+
api = load_script("youtube-autoencoder-api", "yta_api_private_temp")
88+
path = tmp_path / "youtube-token.json"
89+
real_dump = api.json.dump
90+
observed_modes = []
91+
92+
def inspect_mode(data, handle, *args, **kwargs):
93+
observed_modes.append(os.fstat(handle.fileno()).st_mode & 0o777)
94+
return real_dump(data, handle, *args, **kwargs)
95+
96+
monkeypatch.setattr(api.json, "dump", inspect_mode)
97+
98+
api.write_json_durable(path, {"refresh_token": "refresh-token"})
99+
100+
assert observed_modes == [0o600]
101+
assert list(tmp_path.glob(".youtube-token.json.*.tmp")) == []
102+
103+
85104
def test_http_json_exposes_youtube_error_reason(load_script, monkeypatch):
86105
api = load_script("youtube-autoencoder-api", "yta_api_http_error")
87106
body = json.dumps(
@@ -173,6 +192,17 @@ def fail_main():
173192
assert payload["reasons"] == ["backendError"]
174193

175194

195+
@pytest.mark.parametrize(
196+
"reason",
197+
["concurrentBroadcastsExceedLimit", "sharedIngestionBroadcastsExceedLimit"],
198+
)
199+
def test_concurrent_broadcast_limits_use_quota_backoff(load_script, reason):
200+
api = load_script("youtube-autoencoder-api", f"yta_api_rate_{reason}")
201+
error = api.YouTubeApiError(status=403, reasons=(reason,), message="broadcast limit")
202+
203+
assert error.retry_class == "quota"
204+
205+
176206
def test_create_broadcast_payload(load_script, monkeypatch):
177207
api = load_script("youtube-autoencoder-api", "yta_api_broadcast_payload")
178208
calls = []
@@ -206,9 +236,7 @@ def test_description_contains_exact_instance_and_generation_markers(load_script)
206236

207237

208238
@pytest.mark.parametrize("lifecycle", ["created", "ready", "testStarting", "testing", "liveStarting", "live"])
209-
def test_reconcile_reuses_nonterminal_broadcast_without_insert(
210-
load_script, monkeypatch, tmp_path, lifecycle
211-
):
239+
def test_reconcile_reuses_nonterminal_broadcast_without_insert(load_script, monkeypatch, tmp_path, lifecycle):
212240
api = load_script("youtube-autoencoder-api", f"yta_api_reuse_{lifecycle}")
213241
configure_reconciliation(api, monkeypatch, tmp_path)
214242
api.write_state(
@@ -465,9 +493,7 @@ def fake_api(method, path, params, body=None):
465493
"body": {
466494
"id": "broadcast-1",
467495
"snippet": {"scheduledStartTime": "2026-07-10T21:00:00Z"},
468-
"contentDetails": {
469-
"monitorStream": {"enableMonitorStream": True, "broadcastStreamDelayMs": 0}
470-
},
496+
"contentDetails": {"monitorStream": {"enableMonitorStream": True, "broadcastStreamDelayMs": 0}},
471497
"status": {"privacyStatus": "public", "selfDeclaredMadeForKids": False},
472498
},
473499
}
@@ -552,6 +578,50 @@ def fake_lock(timeout):
552578
assert json.loads(capsys.readouterr().out)["status"]["lifeCycleStatus"] == "live"
553579

554580

581+
def test_complete_requires_confirmed_live_broadcast(load_script, monkeypatch, tmp_path):
582+
api = load_script("youtube-autoencoder-api", "yta_api_complete_live")
583+
configure_reconciliation(api, monkeypatch, tmp_path)
584+
broadcast = managed_broadcast(api, "broadcast-1", "live")
585+
transitions = []
586+
monkeypatch.setattr(api, "broadcast_by_id", lambda _broadcast_id: broadcast)
587+
monkeypatch.setattr(
588+
api,
589+
"transition",
590+
lambda broadcast_id, status: transitions.append((broadcast_id, status))
591+
or {"id": broadcast_id, "status": {"lifeCycleStatus": "complete"}},
592+
)
593+
594+
args = argparse.Namespace(broadcast_id="broadcast-1")
595+
596+
assert api.complete(args) == 0
597+
assert transitions == [("broadcast-1", "complete")]
598+
599+
600+
def test_complete_refuses_nonlive_broadcast(load_script, monkeypatch, tmp_path):
601+
api = load_script("youtube-autoencoder-api", "yta_api_complete_nonlive")
602+
configure_reconciliation(api, monkeypatch, tmp_path)
603+
broadcast = managed_broadcast(api, "broadcast-1", "ready")
604+
monkeypatch.setattr(api, "broadcast_by_id", lambda _broadcast_id: broadcast)
605+
monkeypatch.setattr(api, "transition", lambda *_args: pytest.fail("transition called"))
606+
607+
with pytest.raises(api.ReconciliationError, match="not confirmed live"):
608+
api.complete(argparse.Namespace(broadcast_id="broadcast-1"))
609+
610+
611+
def test_generic_transition_parser_cannot_complete_broadcast(load_script, monkeypatch):
612+
api = load_script("youtube-autoencoder-api", "yta_api_no_generic_complete")
613+
monkeypatch.setattr(
614+
api.sys,
615+
"argv",
616+
["youtube-autoencoder-api", "transition", "complete", "broadcast-1"],
617+
)
618+
619+
with pytest.raises(SystemExit) as raised:
620+
api.main()
621+
622+
assert raised.value.code == 2
623+
624+
555625
def test_state_command_outputs_non_secret_recovery_state(load_script, monkeypatch, capsys):
556626
api = load_script("youtube-autoencoder-api", "yta_api_state_command")
557627
monkeypatch.setattr(

0 commit comments

Comments
 (0)