From ab808f614ca18246d4cca2de4a116c42b21eb441 Mon Sep 17 00:00:00 2001 From: Daniil Mordanov <153565951+Daniiiil1@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:51:24 +0700 Subject: [PATCH] Fix extension uptime after clock sync --- core/services/kraken/harbor/container.py | 82 +++++++++++++++---- core/services/kraken/harbor/test_container.py | 48 +++++++++++ 2 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 core/services/kraken/harbor/test_container.py diff --git a/core/services/kraken/harbor/container.py b/core/services/kraken/harbor/container.py index 4dab1d8253..75a3a3a408 100644 --- a/core/services/kraken/harbor/container.py +++ b/core/services/kraken/harbor/container.py @@ -1,5 +1,6 @@ import asyncio -from typing import AsyncGenerator, Dict, List +import time +from typing import Any, AsyncGenerator, Dict, List import psutil from aiodocker import Docker @@ -13,6 +14,62 @@ class ContainerManager: + @staticmethod + def _human_duration(duration_seconds: float) -> str: + seconds = int(duration_seconds) + if seconds < 1: + human_duration = "Less than a second" + elif seconds == 1: + human_duration = "1 second" + elif seconds < 60: + human_duration = f"{seconds} seconds" + else: + minutes = int(duration_seconds / 60) + hours = int(duration_seconds / 60 / 60 + 0.5) + if minutes == 1: + human_duration = "About a minute" + elif minutes < 60: + human_duration = f"{minutes} minutes" + elif hours == 1: + human_duration = "About an hour" + elif hours < 48: + human_duration = f"{hours} hours" + elif hours < 24 * 7 * 2: + human_duration = f"{hours // 24} days" + elif hours < 24 * 30 * 2: + human_duration = f"{hours // 24 // 7} weeks" + elif hours < 24 * 365 * 2: + human_duration = f"{hours // 24 // 30} months" + else: + human_duration = f"{int(duration_seconds / 60 / 60) // 24 // 365} years" + + return human_duration + + @classmethod + def _status_with_monotonic_uptime(cls, status_text: str, pid: int) -> str: + if not status_text.startswith("Up ") or pid <= 0: + return status_text + + try: + process_start_since_boot = psutil.Process(pid).create_time() - psutil.boot_time() + uptime_seconds = max(0.0, time.monotonic() - process_start_since_boot) + except psutil.Error: + return status_text + + suffix_start = status_text.find(" (") + suffix = status_text[suffix_start:] if suffix_start >= 0 else "" + return f"Up {cls._human_duration(uptime_seconds)}{suffix}" + + @classmethod + def _container_model(cls, container: DockerContainer, details: Dict[str, Any]) -> ContainerModel: + pid = details.get("State", {}).get("Pid", 0) + return ContainerModel( + name=container["Names"][0], + image=container["Image"], + image_id=container["ImageID"], + status=cls._status_with_monotonic_uptime(container["Status"], pid), + ) + @staticmethod async def get_raw_container_by_name(client: Docker, container_name: str) -> DockerContainer: containers = await client.containers.list(filters={"name": {container_name: True}}) # type: ignore @@ -84,32 +141,21 @@ async def _get_stats_from_containers(containers: List[DockerContainer]) -> Dict[ return result - @staticmethod - async def get_running_containers() -> List[ContainerModel]: + @classmethod + async def get_running_containers(cls) -> List[ContainerModel]: async with DockerCtx() as client: containers = await client.containers.list(filters={"status": ["running"]}) # type: ignore + details = await asyncio.gather(*(container.show() for container in containers)) - return [ - ContainerModel( - name=container["Names"][0], - image=container["Image"], - image_id=container["ImageID"], - status=container["Status"], - ) - for container in containers - ] + return [cls._container_model(container, detail) for container, detail in zip(containers, details)] @classmethod async def get_running_container_by_name(cls, container_name: str) -> ContainerModel: async with DockerCtx() as client: container = await cls.get_raw_container_by_name(client, container_name) + details = await container.show() - return ContainerModel( - name=container["Names"][0], - image=container["Image"], - image_id=container["ImageID"], - status=container["Status"], - ) + return cls._container_model(container, details) @classmethod async def get_container_log_by_name(cls, container_name: str) -> AsyncGenerator[str, None]: diff --git a/core/services/kraken/harbor/test_container.py b/core/services/kraken/harbor/test_container.py new file mode 100644 index 0000000000..204348a9c5 --- /dev/null +++ b/core/services/kraken/harbor/test_container.py @@ -0,0 +1,48 @@ +import time + +import psutil +import pytest +from harbor.container import ContainerManager + + +@pytest.mark.parametrize( + ("duration_seconds", "expected"), + [ + (0.5, "Less than a second"), + (1, "1 second"), + (59, "59 seconds"), + (60, "About a minute"), + (59 * 60, "59 minutes"), + (60 * 60, "About an hour"), + (4 * 60 * 60, "4 hours"), + (3 * 24 * 60 * 60, "3 days"), + ], +) +def test_human_duration(duration_seconds: float, expected: str) -> None: + assert ContainerManager._human_duration(duration_seconds) == expected + + +def test_status_uses_process_uptime_and_preserves_health(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeProcess: + @staticmethod + def create_time() -> float: + return 4_600.0 + + monkeypatch.setattr(psutil, "Process", lambda _pid: FakeProcess()) + monkeypatch.setattr(psutil, "boot_time", lambda: 1_000.0) + monkeypatch.setattr(time, "monotonic", lambda: 10_800.0) + + status = ContainerManager._status_with_monotonic_uptime("Up 17 hours (healthy)", 42) + + assert status == "Up 2 hours (healthy)" + + +def test_status_falls_back_when_process_is_gone(monkeypatch: pytest.MonkeyPatch) -> None: + def missing_process(pid: int) -> None: + raise psutil.NoSuchProcess(pid) + + monkeypatch.setattr(psutil, "Process", missing_process) + + status = ContainerManager._status_with_monotonic_uptime("Up 17 hours", 42) + + assert status == "Up 17 hours"