diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index e22905f..4f53824 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -231,7 +231,11 @@ def stale_checks(plan: DockerBottlePlan) -> None: return committed = read_committed_image(plan.slug) if committed and docker_mod.image_exists(committed): - check_stale(f"agent image {committed!r}", docker_mod.image_created_at(committed)) + ts = docker_mod.image_created_at(committed) + if ts is not None: + check_stale(f"agent image {committed!r}", ts) return if docker_mod.image_exists(plan.image): - check_stale(f"agent image {plan.image!r}", docker_mod.image_created_at(plan.image)) + ts = docker_mod.image_created_at(plan.image) + if ts is not None: + check_stale(f"agent image {plan.image!r}", ts) diff --git a/bot_bottle/backend/docker/util.py b/bot_bottle/backend/docker/util.py index 50fa9d4..fb2091b 100644 --- a/bot_bottle/backend/docker/util.py +++ b/bot_bottle/backend/docker/util.py @@ -200,8 +200,10 @@ def commit_container(container_name: str, image_tag: str) -> None: info(f"committed {container_name!r} → {image_tag!r}") -def image_created_at(ref: str) -> datetime: - """Return Docker's image Created timestamp as an aware UTC datetime.""" +def image_created_at(ref: str) -> datetime | None: + """Return Docker's image Created timestamp as an aware UTC datetime, or + None when the field is absent or unparseable. Callers should skip the + stale check when None is returned.""" r = subprocess.run( ["docker", "image", "inspect", "--format", "{{.Created}}", ref], capture_output=True, @@ -214,10 +216,12 @@ def image_created_at(ref: str) -> datetime: f"{(r.stderr or '').strip() or ''}" ) raw = r.stdout.strip() + if not raw: + return None try: return _parse_docker_timestamp(raw) except ValueError: - die(f"docker image inspect for {ref!r} returned invalid Created timestamp: {raw!r}") + return None def _parse_docker_timestamp(raw: str) -> datetime: diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index f9ef6a2..c54eeb4 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -211,10 +211,14 @@ def stale_checks(plan: MacosContainerBottlePlan) -> None: return committed = read_committed_image(plan.slug) if committed and container_mod.image_exists(committed): - check_stale(f"agent image {committed!r}", container_mod.image_created_at(committed)) + ts = container_mod.image_created_at(committed) + if ts is not None: + check_stale(f"agent image {committed!r}", ts) return if container_mod.image_exists(plan.image): - check_stale(f"agent image {plan.image!r}", container_mod.image_created_at(plan.image)) + ts = container_mod.image_created_at(plan.image) + if ts is not None: + check_stale(f"agent image {plan.image!r}", ts) def _provision_git_gate_keys( diff --git a/bot_bottle/backend/macos_container/util.py b/bot_bottle/backend/macos_container/util.py index 1678417..9f5adca 100644 --- a/bot_bottle/backend/macos_container/util.py +++ b/bot_bottle/backend/macos_container/util.py @@ -612,10 +612,11 @@ def image_id(ref: str) -> str: raise AssertionError("unreachable") -def image_created_at(ref: str) -> datetime: - """Return the image creation timestamp as an aware UTC datetime. - - Parses the `created` field from `container image inspect` JSON output.""" +def image_created_at(ref: str) -> datetime | None: + """Return the image creation timestamp as an aware UTC datetime, or None + when the field is absent or unparseable (e.g. FROM-scratch images, images + pulled from registries that omit the field). Callers should skip the stale + check when None is returned rather than treating it as an error.""" result = subprocess.run( [_CONTAINER, "image", "inspect", ref], capture_output=True, @@ -641,8 +642,7 @@ def image_created_at(ref: str) -> datetime: return datetime.fromisoformat(ts).replace(tzinfo=timezone.utc) except ValueError: pass - die(f"container image inspect for {ref!r} did not include a creation timestamp") - raise AssertionError("unreachable") + return None def save(ref: str, output: str) -> None: diff --git a/tests/unit/test_docker_util_image.py b/tests/unit/test_docker_util_image.py index b7a47fa..43c1922 100644 --- a/tests/unit/test_docker_util_image.py +++ b/tests/unit/test_docker_util_image.py @@ -54,17 +54,21 @@ class TestImageCreatedAt(unittest.TestCase): die.assert_called_once() self.assertIn("missing:tag", die.call_args.args[0]) - def test_dies_on_invalid_timestamp(self): + def test_returns_none_on_invalid_timestamp(self): with patch.object( docker_mod.subprocess, "run", return_value=_ok(stdout="not-a-timestamp\n"), - ), patch.object( - docker_mod, "die", side_effect=SystemExit("die"), - ) as die: - with self.assertRaises(SystemExit): - docker_mod.image_created_at("some:tag") - die.assert_called_once() - self.assertIn("some:tag", die.call_args.args[0]) + ): + result = docker_mod.image_created_at("some:tag") + self.assertIsNone(result) + + def test_returns_none_on_empty_stdout(self): + with patch.object( + docker_mod.subprocess, "run", + return_value=_ok(stdout=""), + ): + result = docker_mod.image_created_at("some:tag") + self.assertIsNone(result) def test_parse_docker_timestamp_no_tzinfo_defaults_to_utc(self): # A bare datetime with no tz offset should be treated as UTC. diff --git a/tests/unit/test_macos_container_util.py b/tests/unit/test_macos_container_util.py index 3c930f9..965117b 100644 --- a/tests/unit/test_macos_container_util.py +++ b/tests/unit/test_macos_container_util.py @@ -371,22 +371,17 @@ class TestMacosContainerImageCreatedAt(unittest.TestCase): util.image_created_at("some:tag") die.assert_called_once() - def test_dies_when_no_created_field(self): + def test_returns_none_when_no_created_field(self): payload = '[{"id": "sha256:abc123"}]' - with patch.object(util.subprocess, "run", return_value=self._ok(payload)), \ - patch.object(util, "die", side_effect=SystemExit("die")) as die: - with self.assertRaises(SystemExit): - util.image_created_at("some:tag") - die.assert_called_once() - self.assertIn("creation timestamp", die.call_args.args[0]) + with patch.object(util.subprocess, "run", return_value=self._ok(payload)): + result = util.image_created_at("some:tag") + self.assertIsNone(result) - def test_dies_on_invalid_timestamp_format(self): + def test_returns_none_on_invalid_timestamp_format(self): payload = '[{"created": "not-a-date"}]' - with patch.object(util.subprocess, "run", return_value=self._ok(payload)), \ - patch.object(util, "die", side_effect=SystemExit("die")) as die: - with self.assertRaises(SystemExit): - util.image_created_at("some:tag") - die.assert_called_once() + with patch.object(util.subprocess, "run", return_value=self._ok(payload)): + result = util.image_created_at("some:tag") + self.assertIsNone(result) if __name__ == "__main__":