Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 64 additions & 18 deletions core/services/kraken/harbor/container.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

human representations should be done in the frontend, not in the back.
We should just fix the time with the monotonic clock.

)

@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
Expand Down Expand Up @@ -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]:
Expand Down
48 changes: 48 additions & 0 deletions core/services/kraken/harbor/test_container.py
Original file line number Diff line number Diff line change
@@ -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"
Loading