Skip to content

Commit b5261fe

Browse files
committed
Sync heartbeat with runtime target state
1 parent fdc6fc6 commit b5261fe

3 files changed

Lines changed: 231 additions & 25 deletions

File tree

.github/workflows/execution-report-heartbeat.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ jobs:
4747
RUNTIME_HEARTBEAT_REJECT_STATUSES: ${{ vars.RUNTIME_HEARTBEAT_REJECT_STATUSES }}
4848
RUNTIME_HEARTBEAT_SCHEDULER_AWARE: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_AWARE || 'true' }}
4949
RUNTIME_HEARTBEAT_SCHEDULER_LOCATION: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_LOCATION || vars.CLOUD_RUN_REGION || 'us-central1' }}
50+
RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }}
5051
CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}
5152
CLOUD_RUN_SERVICES: ${{ vars.CLOUD_RUN_SERVICES }}
5253
CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON }}

scripts/execution_report_heartbeat.py

Lines changed: 119 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,67 @@ def _env_bool(name: str, default: bool = False) -> bool:
4343
return value in {"1", "true", "yes", "y", "on"}
4444

4545

46+
def _enabled_value(value: Any, *, default: bool = True) -> bool:
47+
if value is None:
48+
return default
49+
text = str(value).strip().lower()
50+
if not text:
51+
return default
52+
if text in {"0", "false", "no", "n", "off"}:
53+
return False
54+
return True
55+
56+
57+
def _target_runtime_target(target: dict[str, Any]) -> dict[str, Any]:
58+
runtime_target = target.get("runtime_target") or target.get("runtime_target_json")
59+
if isinstance(runtime_target, str):
60+
try:
61+
runtime_target = json.loads(runtime_target)
62+
except json.JSONDecodeError:
63+
runtime_target = {}
64+
return runtime_target if isinstance(runtime_target, dict) else {}
65+
66+
67+
def _target_account_scope(target: dict[str, Any], runtime_target: dict[str, Any]) -> str:
68+
for source in (target, runtime_target):
69+
for key in (
70+
"account_scope",
71+
"account_group",
72+
"account_region",
73+
"ACCOUNT_GROUP",
74+
"ACCOUNT_REGION",
75+
):
76+
value = source.get(key)
77+
if value:
78+
return str(value).strip()
79+
return ""
80+
81+
82+
def _target_matches_expected_scope(target: dict[str, Any], runtime_target: dict[str, Any]) -> bool:
83+
expected_scope = (os.environ.get("RUNTIME_HEARTBEAT_ACCOUNT_SCOPE") or "").strip().lower()
84+
if not expected_scope:
85+
return True
86+
target_scope = _target_account_scope(target, runtime_target).lower()
87+
return not target_scope or target_scope == expected_scope
88+
89+
90+
def _target_enabled(target: dict[str, Any], runtime_target: dict[str, Any]) -> bool:
91+
value = target.get("runtime_target_enabled")
92+
if value is None:
93+
value = target.get("RUNTIME_TARGET_ENABLED")
94+
if value is None:
95+
value = runtime_target.get("runtime_target_enabled")
96+
return _enabled_value(value, default=True)
97+
98+
99+
def _target_service_values(target: dict[str, Any], runtime_target: dict[str, Any]) -> list[str]:
100+
for key in ("service", "service_name", "cloud_run_service"):
101+
value = target.get(key) or runtime_target.get(key)
102+
if value:
103+
return _split_values(str(value))
104+
return []
105+
106+
46107
def _parse_timestamp(value: Any) -> dt.datetime | None:
47108
if not value:
48109
return None
@@ -91,10 +152,56 @@ def _base_report_uris() -> list[str]:
91152
return unique
92153

93154

155+
def _load_target_service_candidates() -> tuple[list[str], list[str]]:
156+
disabled_target_services: list[str] = []
157+
enabled_target_services: list[str] = []
158+
raw_targets = (os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON") or "").strip()
159+
if not raw_targets:
160+
return enabled_target_services, disabled_target_services
161+
try:
162+
payload = json.loads(raw_targets)
163+
targets = payload.get("targets") if isinstance(payload, dict) else payload
164+
if isinstance(targets, list):
165+
for target in targets:
166+
if not isinstance(target, dict):
167+
continue
168+
runtime_target = _target_runtime_target(target)
169+
if not _target_matches_expected_scope(target, runtime_target):
170+
continue
171+
target_services = _target_service_values(target, runtime_target)
172+
if not target_services:
173+
continue
174+
if _target_enabled(target, runtime_target):
175+
enabled_target_services.extend(target_services)
176+
else:
177+
disabled_target_services.extend(target_services)
178+
except json.JSONDecodeError:
179+
pass
180+
return _unique_values(enabled_target_services), _unique_values(disabled_target_services)
181+
182+
183+
def _filter_disabled_services(
184+
services: list[str],
185+
enabled_services: list[str],
186+
disabled_services: list[str],
187+
) -> list[str]:
188+
if not disabled_services:
189+
return services
190+
enabled_service_set = set(enabled_services)
191+
disabled_service_set = set(disabled_services) - enabled_service_set
192+
return [service for service in services if service not in disabled_service_set]
193+
194+
94195
def _load_required_service_candidates() -> tuple[list[str], bool]:
196+
enabled_target_services, disabled_target_services = _load_target_service_candidates()
95197
explicit_services = _split_values(os.environ.get("RUNTIME_HEARTBEAT_REQUIRED_SERVICES"))
96198
if explicit_services:
97-
return _unique_values(explicit_services), True
199+
services = _filter_disabled_services(
200+
explicit_services,
201+
enabled_target_services,
202+
disabled_target_services,
203+
)
204+
return _unique_values(services), True
98205

99206
services = []
100207
for name in (
@@ -103,30 +210,12 @@ def _load_required_service_candidates() -> tuple[list[str], bool]:
103210
):
104211
services.extend(_split_values(os.environ.get(name)))
105212

106-
raw_targets = (os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON") or "").strip()
107-
if raw_targets:
108-
try:
109-
payload = json.loads(raw_targets)
110-
targets = payload.get("targets") if isinstance(payload, dict) else payload
111-
if isinstance(targets, list):
112-
for target in targets:
113-
if not isinstance(target, dict):
114-
continue
115-
runtime_target = target.get("runtime_target") or target.get("runtime_target_json")
116-
if isinstance(runtime_target, str):
117-
try:
118-
runtime_target = json.loads(runtime_target)
119-
except json.JSONDecodeError:
120-
runtime_target = {}
121-
for key in ("service", "service_name", "cloud_run_service"):
122-
value = target.get(key) or (
123-
runtime_target.get(key) if isinstance(runtime_target, dict) else None
124-
)
125-
if value:
126-
services.extend(_split_values(str(value)))
127-
break
128-
except json.JSONDecodeError:
129-
pass
213+
services = _filter_disabled_services(
214+
services,
215+
enabled_target_services,
216+
disabled_target_services,
217+
)
218+
services.extend(enabled_target_services)
130219

131220
return _unique_values(services), False
132221

@@ -152,6 +241,8 @@ def _resolve_required_services(
152241
now: dt.datetime | None = None,
153242
) -> tuple[list[str], str | None, bool]:
154243
services, explicit = _load_required_service_candidates()
244+
if explicit and not services:
245+
return [], "all explicitly required heartbeat services are disabled", False
155246
if explicit or not services:
156247
return services, None, False
157248
if not _env_bool("RUNTIME_HEARTBEAT_SCHEDULER_AWARE", True):
@@ -610,6 +701,9 @@ def main(now: dt.datetime | None = None) -> int:
610701
or os.environ.get("GOOGLE_CLOUD_PROJECT")
611702
)
612703
name = os.environ.get("RUNTIME_HEARTBEAT_NAME") or os.environ.get("GITHUB_REPOSITORY") or "runtime"
704+
if not _env_bool("RUNTIME_TARGET_ENABLED", True):
705+
print(f"Execution report heartbeat skipped for {name}: runtime target is disabled")
706+
return 0
613707
lookback_hours = float(os.environ.get("RUNTIME_HEARTBEAT_LOOKBACK_HOURS") or "36")
614708
max_reports = int(os.environ.get("RUNTIME_HEARTBEAT_MAX_REPORTS_TO_READ") or "20")
615709
fail_workflow = _env_bool("RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT", True)

tests/test_execution_report_heartbeat.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,100 @@ def test_required_services_fall_back_to_cloud_run_targets(monkeypatch):
4343
assert heartbeat._load_required_services() == ["svc-a", "svc-b"]
4444

4545

46+
def test_target_derived_required_services_skip_disabled_targets(monkeypatch):
47+
monkeypatch.delenv("RUNTIME_HEARTBEAT_REQUIRED_SERVICES", raising=False)
48+
monkeypatch.delenv("CLOUD_RUN_SERVICE", raising=False)
49+
monkeypatch.delenv("CLOUD_RUN_SERVICES", raising=False)
50+
monkeypatch.delenv("RUNTIME_HEARTBEAT_ACCOUNT_SCOPE", raising=False)
51+
monkeypatch.setenv(
52+
"CLOUD_RUN_SERVICE_TARGETS_JSON",
53+
json.dumps(
54+
{
55+
"targets": [
56+
{
57+
"service": "interactive-brokers-quant-disabled-service",
58+
"RUNTIME_TARGET_ENABLED": "false",
59+
},
60+
{
61+
"service": "interactive-brokers-quant-enabled-service",
62+
"runtime_target": {
63+
"service_name": "interactive-brokers-quant-enabled-service"
64+
},
65+
},
66+
{
67+
"service": "interactive-brokers-quant-disabled-nested-service",
68+
"runtime_target": {
69+
"runtime_target_enabled": "false",
70+
},
71+
},
72+
]
73+
}
74+
),
75+
)
76+
77+
assert heartbeat._load_required_services() == [
78+
"interactive-brokers-quant-enabled-service"
79+
]
80+
81+
82+
def test_explicit_required_services_skip_disabled_targets(monkeypatch):
83+
monkeypatch.setenv(
84+
"RUNTIME_HEARTBEAT_REQUIRED_SERVICES",
85+
"interactive-brokers-enabled-service,interactive-brokers-disabled-service",
86+
)
87+
monkeypatch.setenv(
88+
"CLOUD_RUN_SERVICE_TARGETS_JSON",
89+
json.dumps(
90+
{
91+
"targets": [
92+
{
93+
"service": "interactive-brokers-enabled-service",
94+
"RUNTIME_TARGET_ENABLED": "true",
95+
},
96+
{
97+
"service": "interactive-brokers-disabled-service",
98+
"RUNTIME_TARGET_ENABLED": "false",
99+
},
100+
]
101+
}
102+
),
103+
)
104+
105+
assert heartbeat._load_required_services() == [
106+
"interactive-brokers-enabled-service"
107+
]
108+
109+
110+
def test_all_explicit_required_services_disabled_skips(monkeypatch):
111+
monkeypatch.setenv(
112+
"RUNTIME_HEARTBEAT_REQUIRED_SERVICES",
113+
"interactive-brokers-disabled-service",
114+
)
115+
monkeypatch.setenv(
116+
"CLOUD_RUN_SERVICE_TARGETS_JSON",
117+
json.dumps(
118+
{
119+
"targets": [
120+
{
121+
"service": "interactive-brokers-disabled-service",
122+
"RUNTIME_TARGET_ENABLED": "false",
123+
}
124+
]
125+
}
126+
),
127+
)
128+
129+
required, skip_reason, scheduler_checked = heartbeat._resolve_required_services(
130+
project="project-1",
131+
since=dt.datetime(2026, 6, 20, 0, 0, tzinfo=dt.timezone.utc),
132+
now=dt.datetime(2026, 6, 20, 1, 0, tzinfo=dt.timezone.utc),
133+
)
134+
135+
assert required == []
136+
assert skip_reason == "all explicitly required heartbeat services are disabled"
137+
assert scheduler_checked is False
138+
139+
46140
def test_scheduler_aware_required_services_only_include_due_main_schedulers(monkeypatch):
47141
monkeypatch.delenv("RUNTIME_HEARTBEAT_REQUIRED_SERVICES", raising=False)
48142
monkeypatch.setenv(
@@ -221,3 +315,20 @@ def test_main_skips_when_no_scheduler_main_job_is_due(monkeypatch, capsys):
221315
output = capsys.readouterr().out
222316
assert "Execution report heartbeat skipped for Monthly runtime" in output
223317
assert "no configured Cloud Scheduler main job was due" in output
318+
319+
320+
def test_main_skips_when_runtime_target_is_disabled(monkeypatch, capsys):
321+
monkeypatch.setenv("RUNTIME_HEARTBEAT_NAME", "Disabled runtime")
322+
monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "false")
323+
monkeypatch.setattr(
324+
heartbeat,
325+
"_list_gcs_objects",
326+
lambda *_args, **_kwargs: pytest.fail("GCS should not be queried for disabled targets"),
327+
)
328+
329+
result = heartbeat.main(now=dt.datetime(2026, 6, 20, 1, 35, tzinfo=dt.timezone.utc))
330+
331+
assert result == 0
332+
output = capsys.readouterr().out
333+
assert "Execution report heartbeat skipped for Disabled runtime" in output
334+
assert "runtime target is disabled" in output

0 commit comments

Comments
 (0)