|
37 | 37 | # the backend/github/server objects and needs a restart — apply_reloaded_config |
38 | 38 | # warns about and ignores changes to those rather than half-applying them. |
39 | 39 | _HOT_RELOAD: dict[str, tuple[str, ...]] = { |
40 | | - "backend": ("min_ready", "max_total"), |
| 40 | + # image_ref is hot: a new ref is picked up by sync_images on the next tick, |
| 41 | + # which stages the new golden and drains slots onto it (see image-pipeline.md |
| 42 | + # Phase C). Per-host image_ref overrides are still restart-only. |
| 43 | + "backend": ("min_ready", "max_total", "image_ref"), |
41 | 44 | "controller": ("shrink_ticks",), |
42 | 45 | "timeouts": ( |
43 | 46 | "poll_interval_sec", |
@@ -189,10 +192,31 @@ def apply_reloaded_config(self, new: Config) -> None: |
189 | 192 | self.cfg = dataclasses.replace(self.cfg, **replacements) |
190 | 193 | log.info("config reload applied: %s", "; ".join(changes)) |
191 | 194 |
|
| 195 | + def _sync_images(self) -> None: |
| 196 | + """Hand the backend the current image config so it can stage the golden |
| 197 | + image to every host (and GC orphans). Optional: backends with no image |
| 198 | + delivery (OpenStack/fake) don't implement it. A failure here must not kill |
| 199 | + the tick — we log and proceed with whatever image the hosts already hold.""" |
| 200 | + fn = getattr(self.backend, "sync_images", None) |
| 201 | + if fn is None: |
| 202 | + return |
| 203 | + try: |
| 204 | + fn(self.cfg.backend) |
| 205 | + except Exception: |
| 206 | + log.warning( |
| 207 | + "image sync failed; continuing with current host image", |
| 208 | + exc_info=True, |
| 209 | + ) |
| 210 | + |
192 | 211 | # ----------------------------------------------------------------- tick |
193 | 212 | def tick(self) -> ControllerState | None: |
194 | 213 | now = self._clock() |
195 | 214 |
|
| 215 | + # 0. IMAGE SYNC — ensure each host holds the configured golden image |
| 216 | + # before any create/rebuild this tick (a no-op once synced; on a ref |
| 217 | + # change it stages the new image and slots drain onto it below). |
| 218 | + self._sync_images() |
| 219 | + |
196 | 220 | # 1. FAIL-SAFE SNAPSHOT — a raise aborts the whole tick (no mutations). |
197 | 221 | try: |
198 | 222 | slots = self.backend.list_slots() |
@@ -287,12 +311,20 @@ def tick(self) -> ControllerState | None: |
287 | 311 | log.warning("slot %s busy past max_job_duration; stopping", s.id) |
288 | 312 | self._safe(lambda: self.backend.stop_slot(s), f"stop {s.id}") |
289 | 313 | elif state is SlotState.IDLE: |
290 | | - if ( |
291 | | - runner |
292 | | - and self._state_age(s.id, now) > self.cfg.timeouts.idle_timeout_sec |
293 | | - ): |
| 314 | + stale = s.image_stale |
| 315 | + idle_timed_out = ( |
| 316 | + self._state_age(s.id, now) > self.cfg.timeouts.idle_timeout_sec |
| 317 | + ) |
| 318 | + if runner and (stale or idle_timed_out): |
| 319 | + # Deregister the (idle, no-job) runner so GitHub stops |
| 320 | + # dispatching to it; the slot then recycles — rebuilding onto |
| 321 | + # the current golden, which clears a stale image. Same drain |
| 322 | + # path as the idle-timeout reap, just also triggered by a |
| 323 | + # config image_ref change. |
294 | 324 | log.info( |
295 | | - "slot %s idle past idle_timeout; deregistering runner", s.id |
| 325 | + "slot %s idle; deregistering runner (%s)", |
| 326 | + s.id, |
| 327 | + "stale image" if stale else "idle_timeout", |
296 | 328 | ) |
297 | 329 | self._safe( |
298 | 330 | lambda: self.github.delete_runner(runner.id), "delete_runner" |
|
0 commit comments