1212import urllib .parse
1313import urllib .request
1414from typing import Any
15- from zoneinfo import ZoneInfo
15+ from zoneinfo import ZoneInfo , ZoneInfoNotFoundError
1616
1717
1818DEFAULT_ACCEPT_STATUSES = {"ok" , "skipped" , "success" , "completed" , "no_action" }
@@ -104,6 +104,160 @@ def _target_service_values(target: dict[str, Any], runtime_target: dict[str, Any
104104 return []
105105
106106
107+ def _parse_schedule_day_of_month_field (raw : str ) -> set [int ] | None :
108+ text = str (raw or "" ).strip ()
109+ if not text or text in {"*" , "?" }:
110+ return None
111+ days : set [int ] = set ()
112+ for part in text .split ("," ):
113+ part = part .strip ()
114+ if not part :
115+ continue
116+ step = 1
117+ if "/" in part :
118+ part , step_text = part .split ("/" , 1 )
119+ try :
120+ step = max (1 , int (step_text ))
121+ except ValueError :
122+ return None
123+ if "-" in part :
124+ start_text , end_text = part .split ("-" , 1 )
125+ try :
126+ start = int (start_text )
127+ end = int (end_text )
128+ except ValueError :
129+ return None
130+ if start > end :
131+ return None
132+ days .update (day for day in range (start , end + 1 , step ) if 1 <= day <= 31 )
133+ continue
134+ try :
135+ day = int (part )
136+ except ValueError :
137+ return None
138+ if 1 <= day <= 31 :
139+ days .add (day )
140+ return days or None
141+
142+
143+ def _scheduler_window_status (
144+ scheduler : dict [str , Any ],
145+ * ,
146+ since : dt .datetime ,
147+ now : dt .datetime ,
148+ ) -> tuple [bool , str ] | None :
149+ cron = str (scheduler .get ("main_time" ) or "" ).strip ()
150+ fields = cron .split ()
151+ if len (fields ) != 5 :
152+ return None
153+ expected_days = _parse_schedule_day_of_month_field (fields [2 ])
154+ if not expected_days :
155+ return None
156+ timezone_name = str (scheduler .get ("timezone" ) or "UTC" ).strip () or "UTC"
157+ try :
158+ timezone = ZoneInfo (timezone_name )
159+ except ZoneInfoNotFoundError :
160+ timezone = dt .timezone .utc
161+ timezone_name = "UTC"
162+ local_since_date = since .astimezone (timezone ).date ()
163+ local_now_date = now .astimezone (timezone ).date ()
164+ cursor = local_since_date
165+ due = False
166+ while cursor <= local_now_date :
167+ if cursor .day in expected_days :
168+ due = True
169+ break
170+ cursor += dt .timedelta (days = 1 )
171+ day_text = "," .join (str (day ) for day in sorted (expected_days ))
172+ reason = (
173+ f"{ timezone_name } date_window={ local_since_date .isoformat ()} .."
174+ f"{ local_now_date .isoformat ()} ; expected day(s)={ day_text } "
175+ )
176+ return due , reason
177+
178+
179+ def _target_scheduler (target : dict [str , Any ], runtime_target : dict [str , Any ]) -> dict [str , Any ]:
180+ for source in (runtime_target , target ):
181+ scheduler = source .get ("scheduler" ) if isinstance (source , dict ) else None
182+ if isinstance (scheduler , dict ):
183+ return scheduler
184+ return {}
185+
186+
187+ def _runtime_target_payload () -> dict [str , Any ]:
188+ raw_runtime_target = (os .environ .get ("RUNTIME_TARGET_JSON" ) or "" ).strip ()
189+ if not raw_runtime_target :
190+ return {}
191+ try :
192+ runtime_target = json .loads (raw_runtime_target )
193+ except json .JSONDecodeError :
194+ return {}
195+ return runtime_target if isinstance (runtime_target , dict ) else {}
196+
197+
198+ def _runtime_target_enabled () -> bool :
199+ value : Any = os .environ .get ("RUNTIME_TARGET_ENABLED" )
200+ if value is None :
201+ value = _runtime_target_payload ().get ("runtime_target_enabled" )
202+ return _enabled_value (value , default = True )
203+
204+
205+ def _runtime_target_schedule_candidates () -> list [tuple [str , dict [str , Any ]]]:
206+ candidates : list [tuple [str , dict [str , Any ]]] = []
207+ raw_targets = (os .environ .get ("CLOUD_RUN_SERVICE_TARGETS_JSON" ) or "" ).strip ()
208+ if raw_targets :
209+ try :
210+ payload = json .loads (raw_targets )
211+ except json .JSONDecodeError :
212+ payload = {}
213+ targets = payload .get ("targets" ) if isinstance (payload , dict ) else payload
214+ if isinstance (targets , list ):
215+ for target in targets :
216+ if not isinstance (target , dict ):
217+ continue
218+ runtime_target = _target_runtime_target (target )
219+ if not _target_matches_expected_scope (target , runtime_target ):
220+ continue
221+ if not _target_enabled (target , runtime_target ):
222+ continue
223+ scheduler = _target_scheduler (target , runtime_target )
224+ if not scheduler :
225+ continue
226+ services = _target_service_values (target , runtime_target ) or [
227+ str (runtime_target .get ("service_name" ) or "runtime_target" )
228+ ]
229+ candidates .extend ((service , scheduler ) for service in services )
230+ if isinstance (targets , list ):
231+ return candidates
232+
233+ runtime_target = _runtime_target_payload ()
234+ if not runtime_target :
235+ return []
236+ scheduler = _target_scheduler (runtime_target , runtime_target )
237+ if not scheduler :
238+ return []
239+ service = str (runtime_target .get ("service_name" ) or "runtime_target" )
240+ return [(service , scheduler )]
241+
242+
243+ def _runtime_target_scheduler_skip_reason (since : dt .datetime , now : dt .datetime ) -> str | None :
244+ candidates = _runtime_target_schedule_candidates ()
245+ if not candidates :
246+ return None
247+ reasons : list [str ] = []
248+ for service , scheduler in candidates :
249+ status = _scheduler_window_status (scheduler , since = since , now = now )
250+ if status is None :
251+ return None
252+ due , reason = status
253+ if due :
254+ return None
255+ reasons .append (f"{ service } : { reason } " )
256+ if not reasons :
257+ return None
258+ return "runtime target scheduler main_time is not due today (" + "; " .join (reasons ) + ")"
259+
260+
107261def _parse_timestamp (value : Any ) -> dt .datetime | None :
108262 if not value :
109263 return None
@@ -737,7 +891,7 @@ def main(now: dt.datetime | None = None) -> int:
737891 or os .environ .get ("GOOGLE_CLOUD_PROJECT" )
738892 )
739893 name = os .environ .get ("RUNTIME_HEARTBEAT_NAME" ) or os .environ .get ("GITHUB_REPOSITORY" ) or "runtime"
740- if not _env_bool ( "RUNTIME_TARGET_ENABLED" , True ):
894+ if not _runtime_target_enabled ( ):
741895 print (f"Execution report heartbeat skipped for { name } : runtime target is disabled" )
742896 return 0
743897 lookback_hours = float (os .environ .get ("RUNTIME_HEARTBEAT_LOOKBACK_HOURS" ) or "36" )
@@ -749,6 +903,11 @@ def main(now: dt.datetime | None = None) -> int:
749903 now = now .replace (tzinfo = dt .timezone .utc )
750904 now = now .astimezone (dt .timezone .utc )
751905 since = now - dt .timedelta (hours = lookback_hours )
906+ runtime_target_skip_reason = _runtime_target_scheduler_skip_reason (since , now )
907+ if runtime_target_skip_reason :
908+ print (f"Execution report heartbeat skipped for { name } : { runtime_target_skip_reason } " )
909+ return 0
910+
752911 required_services , scheduler_skip_reason , _scheduler_checked = _resolve_required_services (
753912 project = project ,
754913 since = since ,
0 commit comments