Skip to content

Commit 3337204

Browse files
authored
Merge pull request #96 from VibePod/loading-bar
Add loading bars when download agent, skills engine or datasette image
2 parents f664c42 + d8a215c commit 3337204

4 files changed

Lines changed: 404 additions & 4 deletions

File tree

src/vibepod/core/docker.py

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ def _version_is_podman(version: Any) -> bool:
123123
return "podman" in str(version.get("Name", "")).lower()
124124

125125

126+
def _parse_image_name(image: str) -> tuple[str, str | None]:
127+
"""Parse a full image string into repository and tag/digest."""
128+
if "@" in image:
129+
repository, tag = image.split("@", 1)
130+
return repository, tag
131+
elif ":" in image:
132+
parts = image.rsplit(":", 1)
133+
if "/" not in parts[1]:
134+
return parts[0], parts[1]
135+
return image, None
136+
137+
126138
class DockerManager:
127139
"""Manager for all Docker operations."""
128140

@@ -162,12 +174,95 @@ def is_rootless_podman(self) -> bool:
162174
self._rootless_podman = rootless and _version_is_podman(version)
163175
return self._rootless_podman
164176

165-
def pull_image(self, image: str) -> None:
177+
def _pull_image_with_progress(self, image: str) -> None:
178+
from rich.progress import (
179+
BarColumn,
180+
DownloadColumn,
181+
Progress,
182+
TextColumn,
183+
TimeRemainingColumn,
184+
TransferSpeedColumn,
185+
)
186+
187+
from vibepod.utils.console import console
188+
189+
repository, tag = _parse_image_name(image)
166190
try:
167-
self.client.images.pull(image)
191+
response = self.client.api.pull(repository, tag=tag, stream=True, decode=True)
168192
except APIError as exc:
169193
raise DockerClientError(f"Failed to pull image {image}: {exc}") from exc
170194

195+
tasks: dict[str, Any] = {}
196+
try:
197+
with Progress(
198+
TextColumn("{task.description}"),
199+
BarColumn(),
200+
DownloadColumn(),
201+
TransferSpeedColumn(),
202+
TimeRemainingColumn(),
203+
console=console,
204+
disable=not console.is_terminal,
205+
) as progress:
206+
for chunk in response:
207+
if not isinstance(chunk, dict):
208+
continue
209+
if "error" in chunk:
210+
raise DockerClientError(f"Failed to pull image {image}: {chunk['error']}")
211+
212+
status = chunk.get("status", "")
213+
layer_id = chunk.get("id")
214+
progress_detail = chunk.get("progressDetail") or {}
215+
216+
if not layer_id:
217+
if status and status not in (
218+
"Downloading",
219+
"Extracting",
220+
"Waiting",
221+
"Download complete",
222+
"Pull complete",
223+
"Already exists",
224+
"Pulling fs layer",
225+
):
226+
progress.console.print(f"[dim]{status}[/dim]")
227+
continue
228+
229+
status_color = "cyan"
230+
if status in ("Download complete", "Pull complete", "Already exists"):
231+
status_color = "green"
232+
elif status == "Waiting":
233+
status_color = "yellow"
234+
elif "error" in status.lower() or "fail" in status.lower():
235+
status_color = "red"
236+
237+
description = f"[{status_color}][{layer_id}][/{status_color}] {status}"
238+
239+
if layer_id not in tasks:
240+
tasks[layer_id] = progress.add_task(description, total=None)
241+
242+
task_id = tasks[layer_id]
243+
progress.update(task_id, description=description)
244+
245+
total = progress_detail.get("total", 0)
246+
current = progress_detail.get("current", 0)
247+
248+
if total:
249+
progress.update(task_id, total=total, completed=current)
250+
251+
if status in ("Download complete", "Pull complete", "Already exists"):
252+
if total:
253+
progress.update(task_id, completed=total, total=total)
254+
else:
255+
progress.update(task_id, completed=1, total=1)
256+
except Exception as exc:
257+
if isinstance(exc, DockerClientError):
258+
raise
259+
if isinstance(exc, APIError):
260+
raise DockerClientError(f"Failed to pull image {image}: {exc}") from exc
261+
raise DockerClientError(f"Failed to pull image {image}: {exc}") from exc
262+
263+
def pull_image(self, image: str) -> None:
264+
self._pull_image_with_progress(image)
265+
171266
def pull_if_newer(self, image: str) -> bool:
172267
"""Pull *image* and return True if the local image was updated.
173268
@@ -182,15 +277,15 @@ def pull_if_newer(self, image: str) -> bool:
182277
except NotFound:
183278
old_id = None
184279

185-
self.client.images.pull(image)
280+
self.pull_image(image)
186281

187282
try:
188283
new_id = self.client.images.get(image).id
189284
except NotFound:
190285
return False
191286

192287
return bool(old_id != new_id)
193-
except APIError:
288+
except (APIError, DockerClientError):
194289
return False
195290

196291
def ensure_network(self, name: str) -> None:
@@ -443,6 +538,12 @@ def ensure_datasette(
443538
return existing
444539
existing.remove(force=True)
445540

541+
if hasattr(self.client, "images"):
542+
try:
543+
self.client.images.get(image)
544+
except NotFound:
545+
self.pull_image(image)
546+
446547
logs_db_path.parent.mkdir(parents=True, exist_ok=True)
447548
if not logs_db_path.exists():
448549
logs_db_path.touch()
@@ -489,6 +590,12 @@ def ensure_proxy(self, image: str, db_path: Path, ca_dir: Path, network: str) ->
489590
return existing
490591
existing.remove(force=True)
491592

593+
if hasattr(self.client, "images"):
594+
try:
595+
self.client.images.get(image)
596+
except NotFound:
597+
self.pull_image(image)
598+
492599
db_path.parent.mkdir(parents=True, exist_ok=True)
493600
ca_dir.mkdir(parents=True, exist_ok=True)
494601

src/vibepod/core/skills_engine.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@
1616
SKILLS_ENGINE_IMAGE,
1717
USER_SKILLS_DIR,
1818
)
19+
from vibepod.core.config import get_config
20+
from vibepod.core.docker import DockerClientError, DockerManager, NotFound
1921

2022
Scope = Literal["local", "user"]
2123

24+
_skills_engine_checked = False
25+
2226

2327
class SkillsEngineError(RuntimeError):
2428
"""Raised when the driver cannot return an engine result."""
@@ -115,6 +119,39 @@ def run_engine(
115119
Returns the parsed JSON payload if ``json_output`` is True. Stderr from the
116120
engine (human-readable progress) is always captured but never parsed.
117121
"""
122+
global _skills_engine_checked
123+
if not _skills_engine_checked:
124+
try:
125+
manager = DockerManager()
126+
image_exists = False
127+
try:
128+
manager.client.images.get(SKILLS_ENGINE_IMAGE)
129+
image_exists = True
130+
except NotFound:
131+
pass
132+
133+
config = get_config()
134+
auto_pull_enabled = bool(config.get("auto_pull", True))
135+
is_latest = (
136+
":" not in SKILLS_ENGINE_IMAGE.split("/")[-1]
137+
or SKILLS_ENGINE_IMAGE.endswith(":latest")
138+
)
139+
140+
if not image_exists:
141+
manager.pull_image(SKILLS_ENGINE_IMAGE)
142+
elif auto_pull_enabled and is_latest:
143+
try:
144+
from vibepod.utils.console import info
145+
info("Checking for skills-engine image updates…")
146+
manager.pull_if_newer(SKILLS_ENGINE_IMAGE)
147+
except Exception:
148+
pass
149+
_skills_engine_checked = True
150+
except DockerClientError as exc:
151+
raise SkillsEngineError(str(exc)) from exc
152+
except Exception as exc:
153+
raise SkillsEngineError(f"Docker initialization failed: {exc}") from exc
154+
118155
local, user, cache = _ensure_dirs(cwd, local_required=local_required)
119156

120157
cmd: list[str] = [

tests/test_docker.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Tests for Docker manager image pulling and parsing helper functions."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from unittest.mock import MagicMock, patch
7+
8+
import pytest
9+
10+
from vibepod.core.docker import (
11+
APIError,
12+
DockerClientError,
13+
DockerManager,
14+
NotFound,
15+
_parse_image_name,
16+
)
17+
18+
19+
def test_parse_image_name() -> None:
20+
assert _parse_image_name("vibepod/datasette:latest") == ("vibepod/datasette", "latest")
21+
assert _parse_image_name("vibepod/datasette@sha256:abcd") == (
22+
"vibepod/datasette",
23+
"sha256:abcd",
24+
)
25+
assert _parse_image_name("localhost:5000/vibepod/datasette:latest") == (
26+
"localhost:5000/vibepod/datasette",
27+
"latest",
28+
)
29+
assert _parse_image_name("localhost:5000/vibepod/datasette") == (
30+
"localhost:5000/vibepod/datasette",
31+
None,
32+
)
33+
assert _parse_image_name("ubuntu") == ("ubuntu", None)
34+
35+
36+
@patch("vibepod.core.docker.docker")
37+
def test_pull_image_success(mock_docker) -> None:
38+
# Set up mocks for DockerManager initialization
39+
mock_client = MagicMock()
40+
mock_docker.from_env.return_value = mock_client
41+
42+
# Define mock response from api.pull stream
43+
mock_client.api.pull.return_value = [
44+
{"status": "Pulling from library/ubuntu", "id": "latest"},
45+
{"status": "Pulling fs layer", "id": "layer1"},
46+
{"status": "Downloading", "id": "layer1", "progressDetail": {"current": 50, "total": 100}},
47+
{"status": "Download complete", "id": "layer1"},
48+
{"status": "Extracting", "id": "layer1", "progressDetail": {"current": 100, "total": 100}},
49+
{"status": "Pull complete", "id": "layer1"},
50+
{"status": "Already exists", "id": "layer2"},
51+
]
52+
53+
manager = DockerManager()
54+
manager.pull_image("vibepod/datasette:latest")
55+
56+
mock_client.api.pull.assert_called_once_with(
57+
"vibepod/datasette", tag="latest", stream=True, decode=True
58+
)
59+
60+
61+
@patch("vibepod.core.docker.docker")
62+
def test_pull_image_api_error(mock_docker) -> None:
63+
mock_client = MagicMock()
64+
mock_docker.from_env.return_value = mock_client
65+
66+
# api.pull raises APIError
67+
mock_client.api.pull.side_effect = APIError("Failed")
68+
69+
manager = DockerManager()
70+
with pytest.raises(DockerClientError) as exc_info:
71+
manager.pull_image("vibepod/datasette:latest")
72+
assert "Failed to pull image" in str(exc_info.value)
73+
74+
75+
@patch("vibepod.core.docker.docker")
76+
def test_pull_image_chunk_error(mock_docker) -> None:
77+
mock_client = MagicMock()
78+
mock_docker.from_env.return_value = mock_client
79+
80+
# api.pull yields an error chunk
81+
mock_client.api.pull.return_value = [
82+
{"status": "Pulling fs layer", "id": "layer1"},
83+
{"error": "Registry returned 404"},
84+
]
85+
86+
manager = DockerManager()
87+
with pytest.raises(DockerClientError) as exc_info:
88+
manager.pull_image("vibepod/datasette:latest")
89+
assert "Registry returned 404" in str(exc_info.value)
90+
91+
92+
@patch("vibepod.core.docker.docker")
93+
def test_pull_if_newer(mock_docker) -> None:
94+
mock_client = MagicMock()
95+
mock_docker.from_env.return_value = mock_client
96+
97+
# Scenario 1: Image exists and gets updated (old_id != new_id)
98+
mock_image_old = MagicMock()
99+
mock_image_old.id = "sha256:old"
100+
mock_image_new = MagicMock()
101+
mock_image_new.id = "sha256:new"
102+
103+
mock_client.images.get.side_effect = [mock_image_old, mock_image_new]
104+
mock_client.api.pull.return_value = [{"status": "Image is up to date"}]
105+
106+
manager = DockerManager()
107+
updated = manager.pull_if_newer("vibepod/datasette:latest")
108+
assert updated is True
109+
110+
# Scenario 2: Image is already up to date (old_id == new_id)
111+
mock_client.images.get.side_effect = [mock_image_old, mock_image_old]
112+
updated = manager.pull_if_newer("vibepod/datasette:latest")
113+
assert updated is False
114+
115+
# Scenario 3: Image not found locally before, but pulled successfully
116+
mock_client.images.get.side_effect = [NotFound("not found"), mock_image_new]
117+
updated = manager.pull_if_newer("vibepod/datasette:latest")
118+
assert updated is True
119+
120+
# Scenario 4: Pull fails
121+
mock_client.images.get.side_effect = [NotFound("not found")]
122+
mock_client.api.pull.side_effect = APIError("Failed")
123+
updated = manager.pull_if_newer("vibepod/datasette:latest")
124+
assert updated is False
125+
126+
127+
@patch("vibepod.core.docker.docker")
128+
def test_ensure_datasette_pulls_image_when_missing(mock_docker, tmp_path: Path) -> None:
129+
mock_client = MagicMock()
130+
mock_docker.from_env.return_value = mock_client
131+
132+
mock_client.containers.list.return_value = []
133+
mock_client.images.get.side_effect = NotFound("Image not found")
134+
mock_client.api.pull.return_value = [{"status": "Pulling"}]
135+
136+
manager = DockerManager()
137+
manager.ensure_datasette(
138+
image="vibepod/datasette:latest",
139+
logs_db_path=tmp_path / "logs.db",
140+
proxy_db_path=tmp_path / "proxy.db",
141+
port=8001,
142+
)
143+
144+
mock_client.api.pull.assert_called_once_with(
145+
"vibepod/datasette", tag="latest", stream=True, decode=True
146+
)
147+
mock_client.containers.run.assert_called_once()
148+
149+
150+
@patch("vibepod.core.docker.docker")
151+
def test_ensure_proxy_pulls_image_when_missing(mock_docker, tmp_path: Path) -> None:
152+
mock_client = MagicMock()
153+
mock_docker.from_env.return_value = mock_client
154+
155+
mock_client.containers.list.return_value = []
156+
mock_client.images.get.side_effect = NotFound("Image not found")
157+
mock_client.api.pull.return_value = [{"status": "Pulling"}]
158+
159+
manager = DockerManager()
160+
manager.is_rootless_podman = MagicMock(return_value=False)
161+
162+
manager.ensure_proxy(
163+
image="vibepod/proxy:latest",
164+
db_path=tmp_path / "proxy.db",
165+
ca_dir=tmp_path / "ca",
166+
network="vibepod-network",
167+
)
168+
169+
mock_client.api.pull.assert_called_once_with(
170+
"vibepod/proxy", tag="latest", stream=True, decode=True
171+
)
172+
mock_client.containers.run.assert_called_once()

0 commit comments

Comments
 (0)