1212import urllib .parse
1313import urllib .request
1414from typing import Any
15+ from zoneinfo import ZoneInfo
1516
1617
1718DEFAULT_ACCEPT_STATUSES = {"ok" , "skipped" , "success" , "completed" , "no_action" }
@@ -90,10 +91,10 @@ def _base_report_uris() -> list[str]:
9091 return unique
9192
9293
93- def _load_required_services () -> list [str ]:
94+ def _load_required_service_candidates () -> tuple [ list [str ], bool ]:
9495 explicit_services = _split_values (os .environ .get ("RUNTIME_HEARTBEAT_REQUIRED_SERVICES" ))
9596 if explicit_services :
96- return _unique_values (explicit_services )
97+ return _unique_values (explicit_services ), True
9798
9899 services = []
99100 for name in (
@@ -127,7 +128,36 @@ def _load_required_services() -> list[str]:
127128 except json .JSONDecodeError :
128129 pass
129130
130- return _unique_values (services )
131+ return _unique_values (services ), False
132+
133+
134+ def _load_required_services (
135+ * ,
136+ project : str | None = None ,
137+ since : dt .datetime | None = None ,
138+ now : dt .datetime | None = None ,
139+ ) -> list [str ]:
140+ services , explicit = _load_required_service_candidates ()
141+ if explicit or not services :
142+ return services
143+ if not _env_bool ("RUNTIME_HEARTBEAT_SCHEDULER_AWARE" , True ):
144+ return services
145+ if since is None or now is None :
146+ return services
147+ try :
148+ return _filter_scheduler_due_services (
149+ services ,
150+ project = project ,
151+ since = since ,
152+ now = now ,
153+ )
154+ except RuntimeError as exc :
155+ print (
156+ f"Unable to resolve Cloud Scheduler-backed heartbeat services: { exc } ; "
157+ "falling back to all configured services." ,
158+ file = sys .stderr ,
159+ )
160+ return services
131161
132162
133163def _unique_values (values : list [str ]) -> list [str ]:
@@ -140,6 +170,176 @@ def _unique_values(values: list[str]) -> list[str]:
140170 return unique
141171
142172
173+ def _scheduler_location () -> str :
174+ return (
175+ os .environ .get ("RUNTIME_HEARTBEAT_SCHEDULER_LOCATION" )
176+ or os .environ .get ("CLOUD_RUN_REGION" )
177+ or "us-central1"
178+ )
179+
180+
181+ def _list_scheduler_jobs (* , project : str | None ) -> list [dict [str , Any ]]:
182+ command = [
183+ "gcloud" ,
184+ "scheduler" ,
185+ "jobs" ,
186+ "list" ,
187+ "--location" ,
188+ _scheduler_location (),
189+ "--format=json" ,
190+ ]
191+ if project :
192+ command .extend (["--project" , project ])
193+ result = _run_gcloud (command )
194+ if result .returncode != 0 :
195+ detail = (result .stderr or result .stdout or "" ).strip ()
196+ raise RuntimeError (detail or "gcloud scheduler jobs list failed" )
197+ if not result .stdout .strip ():
198+ return []
199+ try :
200+ payload = json .loads (result .stdout )
201+ except json .JSONDecodeError as exc :
202+ raise RuntimeError (f"gcloud scheduler jobs list returned invalid JSON: { exc } " ) from exc
203+ return payload if isinstance (payload , list ) else []
204+
205+
206+ def _scheduler_job_targets_strategy_run (job : dict [str , Any ], service : str ) -> bool :
207+ if str (job .get ("state" ) or "" ).strip ().upper () not in {"" , "ENABLED" }:
208+ return False
209+ uri = str ((job .get ("httpTarget" ) or {}).get ("uri" ) or "" ).strip ()
210+ if not uri :
211+ return False
212+ parsed = urllib .parse .urlparse (uri )
213+ path = parsed .path or "/"
214+ if path != "/" :
215+ return False
216+ service_text = str (service or "" ).strip ().lower ()
217+ return bool (service_text and service_text in parsed .netloc .lower ())
218+
219+
220+ def _cron_token_value (token : str , * , names : dict [str , int ] | None = None ) -> int :
221+ normalized = token .strip ().lower ()
222+ if names and normalized in names :
223+ return names [normalized ]
224+ return int (normalized )
225+
226+
227+ def _cron_field_values (
228+ field : str ,
229+ * ,
230+ minimum : int ,
231+ maximum : int ,
232+ names : dict [str , int ] | None = None ,
233+ ) -> set [int ] | None :
234+ text = str (field or "" ).strip ().lower ()
235+ if text in {"" , "*" }:
236+ return None
237+ values : set [int ] = set ()
238+ for raw_part in text .split ("," ):
239+ part = raw_part .strip ()
240+ if not part :
241+ continue
242+ base , raw_step = part , "1"
243+ if "/" in part :
244+ base , raw_step = part .split ("/" , 1 )
245+ step = max (1 , int (raw_step ))
246+ if base == "*" :
247+ start , end = minimum , maximum
248+ elif "-" in base :
249+ raw_start , raw_end = base .split ("-" , 1 )
250+ start = _cron_token_value (raw_start , names = names )
251+ end = _cron_token_value (raw_end , names = names )
252+ else :
253+ start = end = _cron_token_value (base , names = names )
254+ for value in range (start , end + 1 , step ):
255+ if minimum <= value <= maximum :
256+ values .add (value )
257+ elif maximum == 6 and value == 7 :
258+ values .add (0 )
259+ return values
260+
261+
262+ def _cron_matches (schedule : str , value : dt .datetime ) -> bool :
263+ fields = str (schedule or "" ).split ()
264+ if len (fields ) != 5 :
265+ return False
266+ minute , hour , day_of_month , month , day_of_week = fields
267+ dow_names = {
268+ "sun" : 0 ,
269+ "mon" : 1 ,
270+ "tue" : 2 ,
271+ "wed" : 3 ,
272+ "thu" : 4 ,
273+ "fri" : 5 ,
274+ "sat" : 6 ,
275+ }
276+ minute_values = _cron_field_values (minute , minimum = 0 , maximum = 59 )
277+ hour_values = _cron_field_values (hour , minimum = 0 , maximum = 23 )
278+ dom_values = _cron_field_values (day_of_month , minimum = 1 , maximum = 31 )
279+ month_values = _cron_field_values (month , minimum = 1 , maximum = 12 )
280+ dow_values = _cron_field_values (day_of_week , minimum = 0 , maximum = 6 , names = dow_names )
281+ if minute_values is not None and value .minute not in minute_values :
282+ return False
283+ if hour_values is not None and value .hour not in hour_values :
284+ return False
285+ if month_values is not None and value .month not in month_values :
286+ return False
287+
288+ dom_matches = dom_values is None or value .day in dom_values
289+ cron_weekday = value .isoweekday () % 7
290+ dow_matches = dow_values is None or cron_weekday in dow_values
291+ if dom_values is not None and dow_values is not None :
292+ return dom_matches or dow_matches
293+ return dom_matches and dow_matches
294+
295+
296+ def _scheduler_job_due_between (
297+ job : dict [str , Any ],
298+ * ,
299+ since : dt .datetime ,
300+ now : dt .datetime ,
301+ ) -> bool :
302+ schedule = str (job .get ("schedule" ) or "" ).strip ()
303+ if not schedule :
304+ return False
305+ try :
306+ timezone = ZoneInfo (str (job .get ("timeZone" ) or "UTC" ))
307+ except Exception : # noqa: BLE001
308+ timezone = dt .timezone .utc
309+
310+ since_utc = since .astimezone (dt .timezone .utc )
311+ now_utc = now .astimezone (dt .timezone .utc )
312+ cursor = since_utc .replace (second = 0 , microsecond = 0 )
313+ if cursor < since_utc :
314+ cursor += dt .timedelta (minutes = 1 )
315+ while cursor <= now_utc :
316+ if _cron_matches (schedule , cursor .astimezone (timezone )):
317+ return True
318+ cursor += dt .timedelta (minutes = 1 )
319+ return False
320+
321+
322+ def _filter_scheduler_due_services (
323+ services : list [str ],
324+ * ,
325+ project : str | None ,
326+ since : dt .datetime ,
327+ now : dt .datetime ,
328+ ) -> list [str ]:
329+ jobs = _list_scheduler_jobs (project = project )
330+ due_services = []
331+ for service in services :
332+ service_jobs = [
333+ job for job in jobs if _scheduler_job_targets_strategy_run (job , service )
334+ ]
335+ if not service_jobs or any (
336+ _scheduler_job_due_between (job , since = since , now = now )
337+ for job in service_jobs
338+ ):
339+ due_services .append (service )
340+ return due_services
341+
342+
143343def _report_globs (since : dt .datetime , now : dt .datetime ) -> list [str ]:
144344 explicit = _split_values (os .environ .get ("RUNTIME_HEARTBEAT_GCS_GLOBS" ))
145345 if explicit :
@@ -332,10 +532,10 @@ def main() -> int:
332532 lookback_hours = float (os .environ .get ("RUNTIME_HEARTBEAT_LOOKBACK_HOURS" ) or "36" )
333533 max_reports = int (os .environ .get ("RUNTIME_HEARTBEAT_MAX_REPORTS_TO_READ" ) or "20" )
334534 fail_workflow = _env_bool ("RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT" , True )
335- required_services = _load_required_services ()
336535
337536 now = dt .datetime .now (dt .timezone .utc )
338537 since = now - dt .timedelta (hours = lookback_hours )
538+ required_services = _load_required_services (project = project , since = since , now = now )
339539 globs = _report_globs (since , now )
340540 if not globs :
341541 raise SystemExit ("No heartbeat GCS report URI configured" )
0 commit comments