fastled 1.2.96__py3-none-any.whl → 1.2.98__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastled/__init__.py +1 -1
- fastled/client_server.py +513 -513
- fastled/compile_server_impl.py +355 -355
- fastled/docker_manager.py +987 -987
- fastled/open_browser.py +137 -137
- fastled/print_filter.py +190 -190
- fastled/project_init.py +129 -129
- fastled/server_flask.py +2 -8
- fastled/site/build.py +449 -449
- fastled/string_diff.py +82 -82
- {fastled-1.2.96.dist-info → fastled-1.2.98.dist-info}/METADATA +471 -471
- {fastled-1.2.96.dist-info → fastled-1.2.98.dist-info}/RECORD +16 -16
- {fastled-1.2.96.dist-info → fastled-1.2.98.dist-info}/WHEEL +0 -0
- {fastled-1.2.96.dist-info → fastled-1.2.98.dist-info}/entry_points.txt +0 -0
- {fastled-1.2.96.dist-info → fastled-1.2.98.dist-info}/licenses/LICENSE +0 -0
- {fastled-1.2.96.dist-info → fastled-1.2.98.dist-info}/top_level.txt +0 -0
fastled/docker_manager.py
CHANGED
@@ -1,987 +1,987 @@
|
|
1
|
-
"""
|
2
|
-
New abstraction for Docker management with improved Ctrl+C handling.
|
3
|
-
"""
|
4
|
-
|
5
|
-
import _thread
|
6
|
-
import json
|
7
|
-
import os
|
8
|
-
import platform
|
9
|
-
import subprocess
|
10
|
-
import sys
|
11
|
-
import threading
|
12
|
-
import time
|
13
|
-
import traceback
|
14
|
-
import warnings
|
15
|
-
from dataclasses import dataclass
|
16
|
-
from datetime import datetime, timezone
|
17
|
-
from pathlib import Path
|
18
|
-
|
19
|
-
import docker
|
20
|
-
from appdirs import user_data_dir
|
21
|
-
from disklru import DiskLRUCache
|
22
|
-
from docker.client import DockerClient
|
23
|
-
from docker.errors import DockerException, ImageNotFound, NotFound
|
24
|
-
from docker.models.containers import Container
|
25
|
-
from docker.models.images import Image
|
26
|
-
from filelock import FileLock
|
27
|
-
|
28
|
-
from fastled.print_filter import PrintFilter, PrintFilterFastled
|
29
|
-
from fastled.spinner import Spinner
|
30
|
-
|
31
|
-
CONFIG_DIR = Path(user_data_dir("fastled", "fastled"))
|
32
|
-
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
33
|
-
DB_FILE = CONFIG_DIR / "db.db"
|
34
|
-
DISK_CACHE = DiskLRUCache(str(DB_FILE), 10)
|
35
|
-
_IS_GITHUB = "GITHUB_ACTIONS" in os.environ
|
36
|
-
|
37
|
-
|
38
|
-
# Docker uses datetimes in UTC but without the timezone info. If we pass in a tz
|
39
|
-
# then it will throw an exception.
|
40
|
-
def _utc_now_no_tz() -> datetime:
|
41
|
-
now = datetime.now(timezone.utc)
|
42
|
-
return now.replace(tzinfo=None)
|
43
|
-
|
44
|
-
|
45
|
-
def _win32_docker_location() -> str | None:
|
46
|
-
home_dir = Path.home()
|
47
|
-
out = [
|
48
|
-
"C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe",
|
49
|
-
f"{home_dir}\\AppData\\Local\\Docker\\Docker Desktop.exe",
|
50
|
-
]
|
51
|
-
for loc in out:
|
52
|
-
if Path(loc).exists():
|
53
|
-
return loc
|
54
|
-
return None
|
55
|
-
|
56
|
-
|
57
|
-
def get_lock(image_name: str) -> FileLock:
|
58
|
-
"""Get the file lock for this DockerManager instance."""
|
59
|
-
lock_file = CONFIG_DIR / f"{image_name}.lock"
|
60
|
-
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
61
|
-
print(CONFIG_DIR)
|
62
|
-
if not lock_file.parent.exists():
|
63
|
-
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
64
|
-
out: FileLock
|
65
|
-
out = FileLock(str(lock_file)) # type: ignore
|
66
|
-
return out
|
67
|
-
|
68
|
-
|
69
|
-
@dataclass
|
70
|
-
class Volume:
|
71
|
-
"""
|
72
|
-
Represents a Docker volume mapping between host and container.
|
73
|
-
|
74
|
-
Attributes:
|
75
|
-
host_path: Path on the host system (e.g., "C:\\Users\\username\\project")
|
76
|
-
container_path: Path inside the container (e.g., "/app/data")
|
77
|
-
mode: Access mode, "rw" for read-write or "ro" for read-only
|
78
|
-
"""
|
79
|
-
|
80
|
-
host_path: str
|
81
|
-
container_path: str
|
82
|
-
mode: str = "rw"
|
83
|
-
|
84
|
-
def to_dict(self) -> dict[str, dict[str, str]]:
|
85
|
-
"""Convert the Volume object to the format expected by Docker API."""
|
86
|
-
return {self.host_path: {"bind": self.container_path, "mode": self.mode}}
|
87
|
-
|
88
|
-
@classmethod
|
89
|
-
def from_dict(cls, volume_dict: dict[str, dict[str, str]]) -> list["Volume"]:
|
90
|
-
"""Create Volume objects from a Docker volume dictionary."""
|
91
|
-
volumes = []
|
92
|
-
for host_path, config in volume_dict.items():
|
93
|
-
volumes.append(
|
94
|
-
cls(
|
95
|
-
host_path=host_path,
|
96
|
-
container_path=config["bind"],
|
97
|
-
mode=config.get("mode", "rw"),
|
98
|
-
)
|
99
|
-
)
|
100
|
-
return volumes
|
101
|
-
|
102
|
-
|
103
|
-
# Override the default PrintFilter to use a custom one.
|
104
|
-
def make_default_print_filter() -> PrintFilter:
|
105
|
-
"""Create a default PrintFilter instance."""
|
106
|
-
return PrintFilterFastled()
|
107
|
-
|
108
|
-
|
109
|
-
class RunningContainer:
|
110
|
-
def __init__(
|
111
|
-
self,
|
112
|
-
container: Container,
|
113
|
-
first_run: bool = False,
|
114
|
-
filter: PrintFilter | None = None,
|
115
|
-
) -> None:
|
116
|
-
self.filter = filter or make_default_print_filter()
|
117
|
-
self.container = container
|
118
|
-
self.first_run = first_run
|
119
|
-
self.running = True
|
120
|
-
self.thread = threading.Thread(target=self._log_monitor)
|
121
|
-
self.thread.daemon = True
|
122
|
-
self.thread.start()
|
123
|
-
|
124
|
-
def _log_monitor(self):
|
125
|
-
from_date = _utc_now_no_tz() if not self.first_run else None
|
126
|
-
to_date = _utc_now_no_tz()
|
127
|
-
|
128
|
-
while self.running:
|
129
|
-
try:
|
130
|
-
for log in self.container.logs(
|
131
|
-
follow=False, since=from_date, until=to_date, stream=True
|
132
|
-
):
|
133
|
-
# print(log.decode("utf-8"), end="")
|
134
|
-
self.filter.print(log)
|
135
|
-
time.sleep(0.1)
|
136
|
-
from_date = to_date
|
137
|
-
to_date = _utc_now_no_tz()
|
138
|
-
except KeyboardInterrupt:
|
139
|
-
print("Monitoring logs interrupted by user.")
|
140
|
-
_thread.interrupt_main()
|
141
|
-
break
|
142
|
-
except Exception as e:
|
143
|
-
print(f"Error monitoring logs: {e}")
|
144
|
-
break
|
145
|
-
|
146
|
-
def detach(self) -> None:
|
147
|
-
"""Stop monitoring the container logs"""
|
148
|
-
self.running = False
|
149
|
-
self.thread.join()
|
150
|
-
|
151
|
-
def stop(self) -> None:
|
152
|
-
"""Stop the container"""
|
153
|
-
self.container.stop()
|
154
|
-
self.detach()
|
155
|
-
|
156
|
-
|
157
|
-
def _hack_to_fix_mac(volumes: list[Volume] | None) -> list[Volume] | None:
|
158
|
-
"""Fixes the volume mounts on MacOS by removing the mode."""
|
159
|
-
if volumes is None:
|
160
|
-
return None
|
161
|
-
if sys.platform != "darwin":
|
162
|
-
# Only macos needs hacking.
|
163
|
-
return volumes
|
164
|
-
|
165
|
-
volumes = volumes.copy()
|
166
|
-
# Work around a Docker bug on MacOS where the expected network socket to the
|
167
|
-
# the host is not mounted correctly. This was actually fixed in recent versions
|
168
|
-
# of docker client but there is a large chunk of Docker clients out there with
|
169
|
-
# this bug in it.
|
170
|
-
#
|
171
|
-
# This hack is done by mounting the socket directly to the container.
|
172
|
-
# This socket talks to the docker daemon on the host.
|
173
|
-
#
|
174
|
-
# Found here.
|
175
|
-
# https://github.com/docker/docker-py/issues/3069#issuecomment-1316778735
|
176
|
-
# if it exists already then return the input
|
177
|
-
for volume in volumes:
|
178
|
-
if volume.host_path == "/var/run/docker.sock":
|
179
|
-
return volumes
|
180
|
-
# ok it doesn't exist, so add it
|
181
|
-
volumes.append(
|
182
|
-
Volume(
|
183
|
-
host_path="/var/run/docker.sock",
|
184
|
-
container_path="/var/run/docker.sock",
|
185
|
-
mode="rw",
|
186
|
-
)
|
187
|
-
)
|
188
|
-
return volumes
|
189
|
-
|
190
|
-
|
191
|
-
class DockerManager:
|
192
|
-
def __init__(self) -> None:
|
193
|
-
from docker.errors import DockerException
|
194
|
-
|
195
|
-
self.is_suspended: bool = False
|
196
|
-
|
197
|
-
try:
|
198
|
-
self._client: DockerClient | None = None
|
199
|
-
self.first_run = False
|
200
|
-
except DockerException as e:
|
201
|
-
stack = traceback.format_exc()
|
202
|
-
warnings.warn(f"Error initializing Docker client: {e}\n{stack}")
|
203
|
-
raise
|
204
|
-
|
205
|
-
@property
|
206
|
-
def client(self) -> DockerClient:
|
207
|
-
if self._client is None:
|
208
|
-
self._client = docker.from_env()
|
209
|
-
return self._client
|
210
|
-
|
211
|
-
@staticmethod
|
212
|
-
def is_docker_installed() -> bool:
|
213
|
-
"""Check if Docker is installed on the system."""
|
214
|
-
try:
|
215
|
-
subprocess.run(["docker", "--version"], capture_output=True, check=True)
|
216
|
-
print("Docker is installed.")
|
217
|
-
return True
|
218
|
-
except subprocess.CalledProcessError as e:
|
219
|
-
print(f"Docker command failed: {str(e)}")
|
220
|
-
return False
|
221
|
-
except FileNotFoundError:
|
222
|
-
print("Docker is not installed.")
|
223
|
-
return False
|
224
|
-
|
225
|
-
@staticmethod
|
226
|
-
def ensure_linux_containers_for_windows() -> bool:
|
227
|
-
"""Ensure Docker is using Linux containers on Windows."""
|
228
|
-
if sys.platform != "win32":
|
229
|
-
return True # Only needed on Windows
|
230
|
-
|
231
|
-
try:
|
232
|
-
# Check if we're already in Linux container mode
|
233
|
-
result = subprocess.run(
|
234
|
-
["docker", "info"], capture_output=True, text=True, check=True
|
235
|
-
)
|
236
|
-
|
237
|
-
if "linux" in result.stdout.lower():
|
238
|
-
print("Already using Linux containers")
|
239
|
-
return True
|
240
|
-
|
241
|
-
if not _IS_GITHUB:
|
242
|
-
answer = (
|
243
|
-
input(
|
244
|
-
"\nDocker on Windows must be in linux mode, this is a global change, switch? [y/n]"
|
245
|
-
)
|
246
|
-
.strip()
|
247
|
-
.lower()[:1]
|
248
|
-
)
|
249
|
-
if answer != "y":
|
250
|
-
return False
|
251
|
-
|
252
|
-
print("Switching to Linux containers...")
|
253
|
-
warnings.warn("Switching Docker to use Linux container context...")
|
254
|
-
|
255
|
-
# Explicitly specify the Linux container context
|
256
|
-
linux_context = "desktop-linux"
|
257
|
-
subprocess.run(
|
258
|
-
["cmd", "/c", f"docker context use {linux_context}"],
|
259
|
-
check=True,
|
260
|
-
capture_output=True,
|
261
|
-
)
|
262
|
-
|
263
|
-
# Verify the switch worked
|
264
|
-
verify = subprocess.run(
|
265
|
-
["docker", "info"], capture_output=True, text=True, check=True
|
266
|
-
)
|
267
|
-
if "linux" in verify.stdout.lower():
|
268
|
-
print(
|
269
|
-
f"Successfully switched to Linux containers using '{linux_context}' context"
|
270
|
-
)
|
271
|
-
return True
|
272
|
-
|
273
|
-
warnings.warn(
|
274
|
-
f"Failed to switch to Linux containers with context '{linux_context}': {verify.stdout}"
|
275
|
-
)
|
276
|
-
return False
|
277
|
-
|
278
|
-
except subprocess.CalledProcessError as e:
|
279
|
-
print(f"Failed to switch to Linux containers: {e}")
|
280
|
-
if e.stdout:
|
281
|
-
print(f"stdout: {e.stdout}")
|
282
|
-
if e.stderr:
|
283
|
-
print(f"stderr: {e.stderr}")
|
284
|
-
return False
|
285
|
-
except Exception as e:
|
286
|
-
print(f"Unexpected error switching to Linux containers: {e}")
|
287
|
-
return False
|
288
|
-
|
289
|
-
@staticmethod
|
290
|
-
def is_running() -> tuple[bool, Exception | None]:
|
291
|
-
"""Check if Docker is running by pinging the Docker daemon."""
|
292
|
-
|
293
|
-
if not DockerManager.is_docker_installed():
|
294
|
-
print("Docker is not installed.")
|
295
|
-
return False, Exception("Docker is not installed.")
|
296
|
-
try:
|
297
|
-
# self.client.ping()
|
298
|
-
client = docker.from_env()
|
299
|
-
client.ping()
|
300
|
-
print("Docker is running.")
|
301
|
-
return True, None
|
302
|
-
except DockerException as e:
|
303
|
-
print(f"Docker is not running: {str(e)}")
|
304
|
-
return False, e
|
305
|
-
except Exception as e:
|
306
|
-
print(f"Error pinging Docker daemon: {str(e)}")
|
307
|
-
return False, e
|
308
|
-
|
309
|
-
def start(self) -> bool:
|
310
|
-
"""Attempt to start Docker Desktop (or the Docker daemon) automatically."""
|
311
|
-
print("Attempting to start Docker...")
|
312
|
-
|
313
|
-
try:
|
314
|
-
if sys.platform == "win32":
|
315
|
-
docker_path = _win32_docker_location()
|
316
|
-
if not docker_path:
|
317
|
-
print("Docker Desktop not found.")
|
318
|
-
return False
|
319
|
-
subprocess.run(["start", "", docker_path], shell=True)
|
320
|
-
elif sys.platform == "darwin":
|
321
|
-
subprocess.run(["open", "-a", "Docker"])
|
322
|
-
elif sys.platform.startswith("linux"):
|
323
|
-
subprocess.run(["sudo", "systemctl", "start", "docker"])
|
324
|
-
else:
|
325
|
-
print("Unknown platform. Cannot auto-launch Docker.")
|
326
|
-
return False
|
327
|
-
|
328
|
-
# Wait for Docker to start up with increasing delays
|
329
|
-
print("Waiting for Docker Desktop to start...")
|
330
|
-
attempts = 0
|
331
|
-
max_attempts = 20 # Increased max wait time
|
332
|
-
while attempts < max_attempts:
|
333
|
-
attempts += 1
|
334
|
-
if self.is_running():
|
335
|
-
print("Docker started successfully.")
|
336
|
-
return True
|
337
|
-
|
338
|
-
# Gradually increase wait time between checks
|
339
|
-
wait_time = min(5, 1 + attempts * 0.5)
|
340
|
-
print(
|
341
|
-
f"Docker not ready yet, waiting {wait_time:.1f}s... (attempt {attempts}/{max_attempts})"
|
342
|
-
)
|
343
|
-
time.sleep(wait_time)
|
344
|
-
|
345
|
-
print("Failed to start Docker within the expected time.")
|
346
|
-
print(
|
347
|
-
"Please try starting Docker Desktop manually and run this command again."
|
348
|
-
)
|
349
|
-
except KeyboardInterrupt:
|
350
|
-
print("Aborted by user.")
|
351
|
-
raise
|
352
|
-
except Exception as e:
|
353
|
-
print(f"Error starting Docker: {str(e)}")
|
354
|
-
return False
|
355
|
-
|
356
|
-
def has_newer_version(
|
357
|
-
self, image_name: str, tag: str = "latest"
|
358
|
-
) -> tuple[bool, str]:
|
359
|
-
"""
|
360
|
-
Check if a newer version of the image is available in the registry.
|
361
|
-
|
362
|
-
Args:
|
363
|
-
image_name: The name of the image to check
|
364
|
-
tag: The tag of the image to check
|
365
|
-
|
366
|
-
Returns:
|
367
|
-
A tuple of (has_newer_version, message)
|
368
|
-
has_newer_version: True if a newer version is available, False otherwise
|
369
|
-
message: A message describing the result
|
370
|
-
"""
|
371
|
-
try:
|
372
|
-
# Get the local image
|
373
|
-
local_image = self.client.images.get(f"{image_name}:{tag}")
|
374
|
-
local_image_id = local_image.id
|
375
|
-
assert local_image_id is not None
|
376
|
-
|
377
|
-
# Get the remote image data
|
378
|
-
remote_image = self.client.images.get_registry_data(f"{image_name}:{tag}")
|
379
|
-
remote_image_hash = remote_image.id
|
380
|
-
|
381
|
-
# Check if we have a cached remote hash for this local image
|
382
|
-
try:
|
383
|
-
remote_image_hash_from_local_image = DISK_CACHE.get(local_image_id)
|
384
|
-
except Exception:
|
385
|
-
remote_image_hash_from_local_image = None
|
386
|
-
|
387
|
-
# Compare the hashes
|
388
|
-
if remote_image_hash_from_local_image == remote_image_hash:
|
389
|
-
return False, f"Local image {image_name}:{tag} is up to date."
|
390
|
-
else:
|
391
|
-
return True, f"Newer version of {image_name}:{tag} is available."
|
392
|
-
|
393
|
-
except ImageNotFound:
|
394
|
-
return True, f"Image {image_name}:{tag} not found locally."
|
395
|
-
except DockerException as e:
|
396
|
-
return False, f"Error checking for newer version: {e}"
|
397
|
-
|
398
|
-
def validate_or_download_image(
|
399
|
-
self, image_name: str, tag: str = "latest", upgrade: bool = False
|
400
|
-
) -> bool:
|
401
|
-
"""
|
402
|
-
Validate if the image exists, and if not, download it.
|
403
|
-
If upgrade is True, will pull the latest version even if image exists locally.
|
404
|
-
"""
|
405
|
-
ok = DockerManager.ensure_linux_containers_for_windows()
|
406
|
-
if not ok:
|
407
|
-
warnings.warn(
|
408
|
-
"Failed to ensure Linux containers on Windows. This build may fail."
|
409
|
-
)
|
410
|
-
print(f"Validating image {image_name}:{tag}...")
|
411
|
-
remote_image_hash_from_local_image: str | None = None
|
412
|
-
remote_image_hash: str | None = None
|
413
|
-
|
414
|
-
with get_lock(f"{image_name}-{tag}"):
|
415
|
-
try:
|
416
|
-
local_image = self.client.images.get(f"{image_name}:{tag}")
|
417
|
-
print(f"Image {image_name}:{tag} is already available.")
|
418
|
-
|
419
|
-
if upgrade:
|
420
|
-
remote_image = self.client.images.get_registry_data(
|
421
|
-
f"{image_name}:{tag}"
|
422
|
-
)
|
423
|
-
remote_image_hash = remote_image.id
|
424
|
-
|
425
|
-
try:
|
426
|
-
local_image_id = local_image.id
|
427
|
-
assert local_image_id is not None
|
428
|
-
remote_image_hash_from_local_image = DISK_CACHE.get(
|
429
|
-
local_image_id
|
430
|
-
)
|
431
|
-
except KeyboardInterrupt:
|
432
|
-
raise
|
433
|
-
except Exception:
|
434
|
-
remote_image_hash_from_local_image = None
|
435
|
-
stack = traceback.format_exc()
|
436
|
-
warnings.warn(
|
437
|
-
f"Error getting remote image hash from local image: {stack}"
|
438
|
-
)
|
439
|
-
if remote_image_hash_from_local_image == remote_image_hash:
|
440
|
-
print(f"Local image {image_name}:{tag} is up to date.")
|
441
|
-
return False
|
442
|
-
|
443
|
-
# Quick check for latest version
|
444
|
-
with Spinner(f"Pulling newer version of {image_name}:{tag}..."):
|
445
|
-
cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
|
446
|
-
cmd_str = subprocess.list2cmdline(cmd_list)
|
447
|
-
print(f"Running command: {cmd_str}")
|
448
|
-
subprocess.run(cmd_list, check=True)
|
449
|
-
print(f"Updated to newer version of {image_name}:{tag}")
|
450
|
-
local_image_hash = self.client.images.get(f"{image_name}:{tag}").id
|
451
|
-
assert local_image_hash is not None
|
452
|
-
if remote_image_hash is not None:
|
453
|
-
DISK_CACHE.put(local_image_hash, remote_image_hash)
|
454
|
-
return True
|
455
|
-
|
456
|
-
except ImageNotFound:
|
457
|
-
print(f"Image {image_name}:{tag} not found.")
|
458
|
-
with Spinner("Loading "):
|
459
|
-
# We use docker cli here because it shows the download.
|
460
|
-
cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
|
461
|
-
cmd_str = subprocess.list2cmdline(cmd_list)
|
462
|
-
print(f"Running command: {cmd_str}")
|
463
|
-
subprocess.run(cmd_list, check=True)
|
464
|
-
try:
|
465
|
-
local_image = self.client.images.get(f"{image_name}:{tag}")
|
466
|
-
local_image_hash = local_image.id
|
467
|
-
print(f"Image {image_name}:{tag} downloaded successfully.")
|
468
|
-
except ImageNotFound:
|
469
|
-
warnings.warn(f"Image {image_name}:{tag} not found after download.")
|
470
|
-
return True
|
471
|
-
|
472
|
-
def tag_image(self, image_name: str, old_tag: str, new_tag: str) -> None:
|
473
|
-
"""
|
474
|
-
Tag an image with a new tag.
|
475
|
-
"""
|
476
|
-
image: Image = self.client.images.get(f"{image_name}:{old_tag}")
|
477
|
-
image.tag(image_name, new_tag)
|
478
|
-
print(f"Image {image_name}:{old_tag} tagged as {new_tag}.")
|
479
|
-
|
480
|
-
def _container_configs_match(
|
481
|
-
self,
|
482
|
-
container: Container,
|
483
|
-
command: str | None,
|
484
|
-
volumes_dict: dict[str, dict[str, str]] | None,
|
485
|
-
ports: dict[int, int] | None,
|
486
|
-
) -> bool:
|
487
|
-
"""Compare if existing container has matching configuration"""
|
488
|
-
try:
|
489
|
-
# Check if container is using the same image
|
490
|
-
image = container.image
|
491
|
-
assert image is not None
|
492
|
-
container_image_id = image.id
|
493
|
-
container_image_tags = image.tags
|
494
|
-
assert container_image_id is not None
|
495
|
-
|
496
|
-
# Simplified image comparison - just compare the IDs directly
|
497
|
-
if not container_image_tags:
|
498
|
-
print(f"Container using untagged image with ID: {container_image_id}")
|
499
|
-
else:
|
500
|
-
current_image = self.client.images.get(container_image_tags[0])
|
501
|
-
if container_image_id != current_image.id:
|
502
|
-
print(
|
503
|
-
f"Container using different image version. Container: {container_image_id}, Current: {current_image.id}"
|
504
|
-
)
|
505
|
-
return False
|
506
|
-
|
507
|
-
# Check command if specified
|
508
|
-
if command and container.attrs["Config"]["Cmd"] != command.split():
|
509
|
-
print(
|
510
|
-
f"Command mismatch: {container.attrs['Config']['Cmd']} != {command}"
|
511
|
-
)
|
512
|
-
return False
|
513
|
-
|
514
|
-
# Check volumes if specified
|
515
|
-
if volumes_dict:
|
516
|
-
container_mounts = (
|
517
|
-
{
|
518
|
-
m["Source"]: {"bind": m["Destination"], "mode": m["Mode"]}
|
519
|
-
for m in container.attrs["Mounts"]
|
520
|
-
}
|
521
|
-
if container.attrs.get("Mounts")
|
522
|
-
else {}
|
523
|
-
)
|
524
|
-
|
525
|
-
for host_dir, mount in volumes_dict.items():
|
526
|
-
if host_dir not in container_mounts:
|
527
|
-
print(f"Volume {host_dir} not found in container mounts.")
|
528
|
-
return False
|
529
|
-
if container_mounts[host_dir] != mount:
|
530
|
-
print(
|
531
|
-
f"Volume {host_dir} has different mount options: {container_mounts[host_dir]} != {mount}"
|
532
|
-
)
|
533
|
-
return False
|
534
|
-
|
535
|
-
# Check ports if specified
|
536
|
-
if ports:
|
537
|
-
container_ports = (
|
538
|
-
container.attrs["Config"]["ExposedPorts"]
|
539
|
-
if container.attrs["Config"].get("ExposedPorts")
|
540
|
-
else {}
|
541
|
-
)
|
542
|
-
container_port_bindings = (
|
543
|
-
container.attrs["HostConfig"]["PortBindings"]
|
544
|
-
if container.attrs["HostConfig"].get("PortBindings")
|
545
|
-
else {}
|
546
|
-
)
|
547
|
-
|
548
|
-
for container_port, host_port in ports.items():
|
549
|
-
port_key = f"{container_port}/tcp"
|
550
|
-
if port_key not in container_ports:
|
551
|
-
print(f"Container port {port_key} not found.")
|
552
|
-
return False
|
553
|
-
if not container_port_bindings.get(port_key, [{"HostPort": None}])[
|
554
|
-
0
|
555
|
-
]["HostPort"] == str(host_port):
|
556
|
-
print(f"Port {host_port} is not bound to {port_key}.")
|
557
|
-
return False
|
558
|
-
except KeyboardInterrupt:
|
559
|
-
raise
|
560
|
-
except NotFound:
|
561
|
-
print("Container not found.")
|
562
|
-
return False
|
563
|
-
except Exception as e:
|
564
|
-
stack = traceback.format_exc()
|
565
|
-
warnings.warn(f"Error checking container config: {e}\n{stack}")
|
566
|
-
return False
|
567
|
-
return True
|
568
|
-
|
569
|
-
def run_container_detached(
|
570
|
-
self,
|
571
|
-
image_name: str,
|
572
|
-
tag: str,
|
573
|
-
container_name: str,
|
574
|
-
command: str | None = None,
|
575
|
-
volumes: list[Volume] | None = None,
|
576
|
-
ports: dict[int, int] | None = None,
|
577
|
-
remove_previous: bool = False,
|
578
|
-
environment: dict[str, str] | None = None,
|
579
|
-
) -> Container:
|
580
|
-
"""
|
581
|
-
Run a container from an image. If it already exists with matching config, start it.
|
582
|
-
If it exists with different config, remove and recreate it.
|
583
|
-
|
584
|
-
Args:
|
585
|
-
volumes: List of Volume objects for container volume mappings
|
586
|
-
ports: Dict mapping host ports to container ports
|
587
|
-
Example: {8080: 80} maps host port 8080 to container port 80
|
588
|
-
"""
|
589
|
-
volumes = _hack_to_fix_mac(volumes)
|
590
|
-
# Convert volumes to the format expected by Docker API
|
591
|
-
volumes_dict = None
|
592
|
-
if volumes is not None:
|
593
|
-
volumes_dict = {}
|
594
|
-
for volume in volumes:
|
595
|
-
volumes_dict.update(volume.to_dict())
|
596
|
-
|
597
|
-
# Serialize the volumes to a json string
|
598
|
-
if volumes_dict:
|
599
|
-
volumes_str = json.dumps(volumes_dict)
|
600
|
-
print(f"Volumes: {volumes_str}")
|
601
|
-
print("Done")
|
602
|
-
image_name = f"{image_name}:{tag}"
|
603
|
-
try:
|
604
|
-
container: Container = self.client.containers.get(container_name)
|
605
|
-
|
606
|
-
if remove_previous:
|
607
|
-
print(f"Removing existing container {container_name}...")
|
608
|
-
container.remove(force=True)
|
609
|
-
raise NotFound("Container removed due to remove_previous")
|
610
|
-
# Check if configuration matches
|
611
|
-
elif not self._container_configs_match(
|
612
|
-
container, command, volumes_dict, ports
|
613
|
-
):
|
614
|
-
print(
|
615
|
-
f"Container {container_name} exists but with different configuration. Removing and recreating..."
|
616
|
-
)
|
617
|
-
container.remove(force=True)
|
618
|
-
raise NotFound("Container removed due to config mismatch")
|
619
|
-
print(f"Container {container_name} found with matching configuration.")
|
620
|
-
|
621
|
-
# Existing container with matching config - handle various states
|
622
|
-
if container.status == "running":
|
623
|
-
print(f"Container {container_name} is already running.")
|
624
|
-
elif container.status == "exited":
|
625
|
-
print(f"Starting existing container {container_name}.")
|
626
|
-
container.start()
|
627
|
-
elif container.status == "restarting":
|
628
|
-
print(f"Waiting for container {container_name} to restart...")
|
629
|
-
timeout = 10
|
630
|
-
container.wait(timeout=10)
|
631
|
-
if container.status == "running":
|
632
|
-
print(f"Container {container_name} has restarted.")
|
633
|
-
else:
|
634
|
-
print(
|
635
|
-
f"Container {container_name} did not restart within {timeout} seconds."
|
636
|
-
)
|
637
|
-
container.stop(timeout=0)
|
638
|
-
print(f"Container {container_name} has been stopped.")
|
639
|
-
container.start()
|
640
|
-
elif container.status == "paused":
|
641
|
-
print(f"Resuming existing container {container_name}.")
|
642
|
-
container.unpause()
|
643
|
-
else:
|
644
|
-
print(f"Unknown container status: {container.status}")
|
645
|
-
print(f"Starting existing container {container_name}.")
|
646
|
-
self.first_run = True
|
647
|
-
container.start()
|
648
|
-
except NotFound:
|
649
|
-
print(f"Creating and starting {container_name}")
|
650
|
-
out_msg = f"# Running in container: {command}"
|
651
|
-
msg_len = len(out_msg)
|
652
|
-
print("\n" + "#" * msg_len)
|
653
|
-
print(out_msg)
|
654
|
-
print("#" * msg_len + "\n")
|
655
|
-
container = self.client.containers.run(
|
656
|
-
image=image_name,
|
657
|
-
command=command,
|
658
|
-
name=container_name,
|
659
|
-
detach=True,
|
660
|
-
tty=True,
|
661
|
-
volumes=volumes_dict,
|
662
|
-
ports=ports, # type: ignore
|
663
|
-
environment=environment,
|
664
|
-
remove=True,
|
665
|
-
)
|
666
|
-
return container
|
667
|
-
|
668
|
-
def run_container_interactive(
|
669
|
-
self,
|
670
|
-
image_name: str,
|
671
|
-
tag: str,
|
672
|
-
container_name: str,
|
673
|
-
command: str | None = None,
|
674
|
-
volumes: list[Volume] | None = None,
|
675
|
-
ports: dict[int, int] | None = None,
|
676
|
-
environment: dict[str, str] | None = None,
|
677
|
-
) -> None:
|
678
|
-
# Convert volumes to the format expected by Docker API
|
679
|
-
volumes = _hack_to_fix_mac(volumes)
|
680
|
-
volumes_dict = None
|
681
|
-
if volumes is not None:
|
682
|
-
volumes_dict = {}
|
683
|
-
for volume in volumes:
|
684
|
-
volumes_dict.update(volume.to_dict())
|
685
|
-
# Remove existing container
|
686
|
-
try:
|
687
|
-
container: Container = self.client.containers.get(container_name)
|
688
|
-
container.remove(force=True)
|
689
|
-
except NotFound:
|
690
|
-
pass
|
691
|
-
start_time = time.time()
|
692
|
-
try:
|
693
|
-
docker_command: list[str] = [
|
694
|
-
"docker",
|
695
|
-
"run",
|
696
|
-
"-it",
|
697
|
-
"--rm",
|
698
|
-
"--name",
|
699
|
-
container_name,
|
700
|
-
]
|
701
|
-
if volumes_dict:
|
702
|
-
for host_dir, mount in volumes_dict.items():
|
703
|
-
docker_volume_arg = [
|
704
|
-
"-v",
|
705
|
-
f"{host_dir}:{mount['bind']}:{mount['mode']}",
|
706
|
-
]
|
707
|
-
docker_command.extend(docker_volume_arg)
|
708
|
-
if ports:
|
709
|
-
for host_port, container_port in ports.items():
|
710
|
-
docker_command.extend(["-p", f"{host_port}:{container_port}"])
|
711
|
-
if environment:
|
712
|
-
for env_name, env_value in environment.items():
|
713
|
-
docker_command.extend(["-e", f"{env_name}={env_value}"])
|
714
|
-
docker_command.append(f"{image_name}:{tag}")
|
715
|
-
if command:
|
716
|
-
docker_command.append(command)
|
717
|
-
cmd_str: str = subprocess.list2cmdline(docker_command)
|
718
|
-
print(f"Running command: {cmd_str}")
|
719
|
-
subprocess.run(docker_command, check=False)
|
720
|
-
except subprocess.CalledProcessError as e:
|
721
|
-
print(f"Error running Docker command: {e}")
|
722
|
-
diff = time.time() - start_time
|
723
|
-
if diff < 5:
|
724
|
-
raise
|
725
|
-
sys.exit(1) # Probably a user exit.
|
726
|
-
|
727
|
-
def attach_and_run(self, container: Container | str) -> RunningContainer:
|
728
|
-
"""
|
729
|
-
Attach to a running container and monitor its logs in a background thread.
|
730
|
-
Returns a RunningContainer object that can be used to stop monitoring.
|
731
|
-
"""
|
732
|
-
if isinstance(container, str):
|
733
|
-
container_name = container
|
734
|
-
tmp = self.get_container(container)
|
735
|
-
assert tmp is not None, f"Container {container_name} not found."
|
736
|
-
container = tmp
|
737
|
-
|
738
|
-
assert container is not None, "Container not found."
|
739
|
-
|
740
|
-
print(f"Attaching to container {container.name}...")
|
741
|
-
|
742
|
-
first_run = self.first_run
|
743
|
-
self.first_run = False
|
744
|
-
|
745
|
-
return RunningContainer(container, first_run)
|
746
|
-
|
747
|
-
def suspend_container(self, container: Container | str) -> None:
|
748
|
-
"""
|
749
|
-
Suspend (pause) the container.
|
750
|
-
"""
|
751
|
-
if self.is_suspended:
|
752
|
-
return
|
753
|
-
if isinstance(container, str):
|
754
|
-
container_name = container
|
755
|
-
# container = self.get_container(container)
|
756
|
-
tmp = self.get_container(container_name)
|
757
|
-
if not tmp:
|
758
|
-
print(f"Could not put container {container_name} to sleep.")
|
759
|
-
return
|
760
|
-
container = tmp
|
761
|
-
assert isinstance(container, Container)
|
762
|
-
try:
|
763
|
-
if platform.system() == "Windows":
|
764
|
-
container.pause()
|
765
|
-
else:
|
766
|
-
container.stop()
|
767
|
-
container.remove()
|
768
|
-
print(f"Container {container.name} has been suspended.")
|
769
|
-
except KeyboardInterrupt:
|
770
|
-
print(f"Container {container.name} interrupted by keyboard interrupt.")
|
771
|
-
except Exception as e:
|
772
|
-
print(f"Failed to suspend container {container.name}: {e}")
|
773
|
-
|
774
|
-
def resume_container(self, container: Container | str) -> None:
|
775
|
-
"""
|
776
|
-
Resume (unpause) the container.
|
777
|
-
"""
|
778
|
-
container_name = "UNKNOWN"
|
779
|
-
if isinstance(container, str):
|
780
|
-
container_name = container
|
781
|
-
container_or_none = self.get_container(container)
|
782
|
-
if container_or_none is None:
|
783
|
-
print(f"Could not resume container {container}.")
|
784
|
-
return
|
785
|
-
container = container_or_none
|
786
|
-
container_name = container.name
|
787
|
-
elif isinstance(container, Container):
|
788
|
-
container_name = container.name
|
789
|
-
assert isinstance(container, Container)
|
790
|
-
if not container:
|
791
|
-
print(f"Could not resume container {container}.")
|
792
|
-
return
|
793
|
-
try:
|
794
|
-
assert isinstance(container, Container)
|
795
|
-
container.unpause()
|
796
|
-
print(f"Container {container.name} has been resumed.")
|
797
|
-
except Exception as e:
|
798
|
-
print(f"Failed to resume container {container_name}: {e}")
|
799
|
-
|
800
|
-
def get_container(self, container_name: str) -> Container | None:
|
801
|
-
"""
|
802
|
-
Get a container by name.
|
803
|
-
"""
|
804
|
-
try:
|
805
|
-
return self.client.containers.get(container_name)
|
806
|
-
except NotFound:
|
807
|
-
return None
|
808
|
-
|
809
|
-
def is_container_running(self, container_name: str) -> bool:
|
810
|
-
"""
|
811
|
-
Check if a container is running.
|
812
|
-
"""
|
813
|
-
try:
|
814
|
-
container = self.client.containers.get(container_name)
|
815
|
-
return container.status == "running"
|
816
|
-
except NotFound:
|
817
|
-
print(f"Container {container_name} not found.")
|
818
|
-
return False
|
819
|
-
|
820
|
-
def build_image(
|
821
|
-
self,
|
822
|
-
image_name: str,
|
823
|
-
tag: str,
|
824
|
-
dockerfile_path: Path,
|
825
|
-
build_context: Path,
|
826
|
-
build_args: dict[str, str] | None = None,
|
827
|
-
platform_tag: str = "",
|
828
|
-
) -> None:
|
829
|
-
"""
|
830
|
-
Build a Docker image from a Dockerfile.
|
831
|
-
|
832
|
-
Args:
|
833
|
-
image_name: Name for the image
|
834
|
-
tag: Tag for the image
|
835
|
-
dockerfile_path: Path to the Dockerfile
|
836
|
-
build_context: Path to the build context directory
|
837
|
-
build_args: Optional dictionary of build arguments
|
838
|
-
platform_tag: Optional platform tag (e.g. "-arm64")
|
839
|
-
"""
|
840
|
-
if not dockerfile_path.exists():
|
841
|
-
raise FileNotFoundError(f"Dockerfile not found at {dockerfile_path}")
|
842
|
-
|
843
|
-
if not build_context.exists():
|
844
|
-
raise FileNotFoundError(
|
845
|
-
f"Build context directory not found at {build_context}"
|
846
|
-
)
|
847
|
-
|
848
|
-
print(f"Building Docker image {image_name}:{tag} from {dockerfile_path}")
|
849
|
-
|
850
|
-
# Prepare build arguments
|
851
|
-
buildargs = build_args or {}
|
852
|
-
if platform_tag:
|
853
|
-
buildargs["PLATFORM_TAG"] = platform_tag
|
854
|
-
|
855
|
-
try:
|
856
|
-
cmd_list = [
|
857
|
-
"docker",
|
858
|
-
"build",
|
859
|
-
"-t",
|
860
|
-
f"{image_name}:{tag}",
|
861
|
-
]
|
862
|
-
|
863
|
-
# Add build args
|
864
|
-
for arg_name, arg_value in buildargs.items():
|
865
|
-
cmd_list.extend(["--build-arg", f"{arg_name}={arg_value}"])
|
866
|
-
|
867
|
-
# Add dockerfile and context paths
|
868
|
-
cmd_list.extend(["-f", str(dockerfile_path), str(build_context)])
|
869
|
-
|
870
|
-
cmd_str = subprocess.list2cmdline(cmd_list)
|
871
|
-
print(f"Running command: {cmd_str}")
|
872
|
-
|
873
|
-
# Run the build command
|
874
|
-
# cp = subprocess.run(cmd_list, check=True, capture_output=True)
|
875
|
-
proc: subprocess.Popen = subprocess.Popen(
|
876
|
-
cmd_list, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
877
|
-
)
|
878
|
-
stdout = proc.stdout
|
879
|
-
assert stdout is not None, "stdout is None"
|
880
|
-
for line in iter(stdout.readline, b""):
|
881
|
-
try:
|
882
|
-
line_str = line.decode("utf-8")
|
883
|
-
print(line_str, end="")
|
884
|
-
except UnicodeDecodeError:
|
885
|
-
print("Error decoding line")
|
886
|
-
rtn = proc.wait()
|
887
|
-
if rtn != 0:
|
888
|
-
warnings.warn(
|
889
|
-
f"Error building Docker image, is docker running? {rtn}, stdout: {stdout}, stderr: {proc.stderr}"
|
890
|
-
)
|
891
|
-
raise subprocess.CalledProcessError(rtn, cmd_str)
|
892
|
-
print(f"Successfully built image {image_name}:{tag}")
|
893
|
-
|
894
|
-
except subprocess.CalledProcessError as e:
|
895
|
-
print(f"Error building Docker image: {e}")
|
896
|
-
raise
|
897
|
-
|
898
|
-
def purge(self, image_name: str) -> None:
|
899
|
-
"""
|
900
|
-
Remove all containers and images associated with the given image name.
|
901
|
-
|
902
|
-
Args:
|
903
|
-
image_name: The name of the image to purge (without tag)
|
904
|
-
"""
|
905
|
-
print(f"Purging all containers and images for {image_name}...")
|
906
|
-
|
907
|
-
# Remove all containers using this image
|
908
|
-
try:
|
909
|
-
containers = self.client.containers.list(all=True)
|
910
|
-
for container in containers:
|
911
|
-
if any(image_name in tag for tag in container.image.tags):
|
912
|
-
print(f"Removing container {container.name}")
|
913
|
-
container.remove(force=True)
|
914
|
-
|
915
|
-
except Exception as e:
|
916
|
-
print(f"Error removing containers: {e}")
|
917
|
-
|
918
|
-
# Remove all images with this name
|
919
|
-
try:
|
920
|
-
self.client.images.prune(filters={"dangling": False})
|
921
|
-
images = self.client.images.list()
|
922
|
-
for image in images:
|
923
|
-
if any(image_name in tag for tag in image.tags):
|
924
|
-
print(f"Removing image {image.tags}")
|
925
|
-
self.client.images.remove(image.id, force=True)
|
926
|
-
except Exception as e:
|
927
|
-
print(f"Error removing images: {e}")
|
928
|
-
|
929
|
-
|
930
|
-
def main() -> None:
|
931
|
-
# Register SIGINT handler
|
932
|
-
# signal.signal(signal.SIGINT, handle_sigint)
|
933
|
-
|
934
|
-
docker_manager = DockerManager()
|
935
|
-
|
936
|
-
# Parameters
|
937
|
-
image_name = "python"
|
938
|
-
tag = "3.10-slim"
|
939
|
-
# new_tag = "my-python"
|
940
|
-
container_name = "my-python-container"
|
941
|
-
command = "python -m http.server"
|
942
|
-
running_container: RunningContainer | None = None
|
943
|
-
|
944
|
-
try:
|
945
|
-
# Step 1: Validate or download the image
|
946
|
-
docker_manager.validate_or_download_image(image_name, tag, upgrade=True)
|
947
|
-
|
948
|
-
# Step 2: Tag the image
|
949
|
-
# docker_manager.tag_image(image_name, tag, new_tag)
|
950
|
-
|
951
|
-
# Step 3: Run the container
|
952
|
-
container = docker_manager.run_container_detached(
|
953
|
-
image_name, tag, container_name, command
|
954
|
-
)
|
955
|
-
|
956
|
-
# Step 4: Attach and monitor the container logs
|
957
|
-
running_container = docker_manager.attach_and_run(container)
|
958
|
-
|
959
|
-
# Wait for keyboard interrupt
|
960
|
-
while True:
|
961
|
-
time.sleep(0.1)
|
962
|
-
|
963
|
-
except KeyboardInterrupt:
|
964
|
-
print("\nStopping container...")
|
965
|
-
if isinstance(running_container, RunningContainer):
|
966
|
-
running_container.stop()
|
967
|
-
container_or_none = docker_manager.get_container(container_name)
|
968
|
-
if container_or_none is not None:
|
969
|
-
docker_manager.suspend_container(container_or_none)
|
970
|
-
else:
|
971
|
-
warnings.warn(f"Container {container_name} not found.")
|
972
|
-
|
973
|
-
try:
|
974
|
-
# Suspend and resume the container
|
975
|
-
container = docker_manager.get_container(container_name)
|
976
|
-
assert container is not None, "Container not found."
|
977
|
-
docker_manager.suspend_container(container)
|
978
|
-
|
979
|
-
input("Press Enter to resume the container...")
|
980
|
-
|
981
|
-
docker_manager.resume_container(container)
|
982
|
-
except Exception as e:
|
983
|
-
print(f"An error occurred: {e}")
|
984
|
-
|
985
|
-
|
986
|
-
if __name__ == "__main__":
|
987
|
-
main()
|
1
|
+
"""
|
2
|
+
New abstraction for Docker management with improved Ctrl+C handling.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import _thread
|
6
|
+
import json
|
7
|
+
import os
|
8
|
+
import platform
|
9
|
+
import subprocess
|
10
|
+
import sys
|
11
|
+
import threading
|
12
|
+
import time
|
13
|
+
import traceback
|
14
|
+
import warnings
|
15
|
+
from dataclasses import dataclass
|
16
|
+
from datetime import datetime, timezone
|
17
|
+
from pathlib import Path
|
18
|
+
|
19
|
+
import docker
|
20
|
+
from appdirs import user_data_dir
|
21
|
+
from disklru import DiskLRUCache
|
22
|
+
from docker.client import DockerClient
|
23
|
+
from docker.errors import DockerException, ImageNotFound, NotFound
|
24
|
+
from docker.models.containers import Container
|
25
|
+
from docker.models.images import Image
|
26
|
+
from filelock import FileLock
|
27
|
+
|
28
|
+
from fastled.print_filter import PrintFilter, PrintFilterFastled
|
29
|
+
from fastled.spinner import Spinner
|
30
|
+
|
31
|
+
CONFIG_DIR = Path(user_data_dir("fastled", "fastled"))
|
32
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
33
|
+
DB_FILE = CONFIG_DIR / "db.db"
|
34
|
+
DISK_CACHE = DiskLRUCache(str(DB_FILE), 10)
|
35
|
+
_IS_GITHUB = "GITHUB_ACTIONS" in os.environ
|
36
|
+
|
37
|
+
|
38
|
+
# Docker uses datetimes in UTC but without the timezone info. If we pass in a tz
|
39
|
+
# then it will throw an exception.
|
40
|
+
def _utc_now_no_tz() -> datetime:
|
41
|
+
now = datetime.now(timezone.utc)
|
42
|
+
return now.replace(tzinfo=None)
|
43
|
+
|
44
|
+
|
45
|
+
def _win32_docker_location() -> str | None:
|
46
|
+
home_dir = Path.home()
|
47
|
+
out = [
|
48
|
+
"C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe",
|
49
|
+
f"{home_dir}\\AppData\\Local\\Docker\\Docker Desktop.exe",
|
50
|
+
]
|
51
|
+
for loc in out:
|
52
|
+
if Path(loc).exists():
|
53
|
+
return loc
|
54
|
+
return None
|
55
|
+
|
56
|
+
|
57
|
+
def get_lock(image_name: str) -> FileLock:
|
58
|
+
"""Get the file lock for this DockerManager instance."""
|
59
|
+
lock_file = CONFIG_DIR / f"{image_name}.lock"
|
60
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
61
|
+
print(CONFIG_DIR)
|
62
|
+
if not lock_file.parent.exists():
|
63
|
+
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
64
|
+
out: FileLock
|
65
|
+
out = FileLock(str(lock_file)) # type: ignore
|
66
|
+
return out
|
67
|
+
|
68
|
+
|
69
|
+
@dataclass
|
70
|
+
class Volume:
|
71
|
+
"""
|
72
|
+
Represents a Docker volume mapping between host and container.
|
73
|
+
|
74
|
+
Attributes:
|
75
|
+
host_path: Path on the host system (e.g., "C:\\Users\\username\\project")
|
76
|
+
container_path: Path inside the container (e.g., "/app/data")
|
77
|
+
mode: Access mode, "rw" for read-write or "ro" for read-only
|
78
|
+
"""
|
79
|
+
|
80
|
+
host_path: str
|
81
|
+
container_path: str
|
82
|
+
mode: str = "rw"
|
83
|
+
|
84
|
+
def to_dict(self) -> dict[str, dict[str, str]]:
|
85
|
+
"""Convert the Volume object to the format expected by Docker API."""
|
86
|
+
return {self.host_path: {"bind": self.container_path, "mode": self.mode}}
|
87
|
+
|
88
|
+
@classmethod
|
89
|
+
def from_dict(cls, volume_dict: dict[str, dict[str, str]]) -> list["Volume"]:
|
90
|
+
"""Create Volume objects from a Docker volume dictionary."""
|
91
|
+
volumes = []
|
92
|
+
for host_path, config in volume_dict.items():
|
93
|
+
volumes.append(
|
94
|
+
cls(
|
95
|
+
host_path=host_path,
|
96
|
+
container_path=config["bind"],
|
97
|
+
mode=config.get("mode", "rw"),
|
98
|
+
)
|
99
|
+
)
|
100
|
+
return volumes
|
101
|
+
|
102
|
+
|
103
|
+
# Override the default PrintFilter to use a custom one.
|
104
|
+
def make_default_print_filter() -> PrintFilter:
|
105
|
+
"""Create a default PrintFilter instance."""
|
106
|
+
return PrintFilterFastled()
|
107
|
+
|
108
|
+
|
109
|
+
class RunningContainer:
|
110
|
+
def __init__(
|
111
|
+
self,
|
112
|
+
container: Container,
|
113
|
+
first_run: bool = False,
|
114
|
+
filter: PrintFilter | None = None,
|
115
|
+
) -> None:
|
116
|
+
self.filter = filter or make_default_print_filter()
|
117
|
+
self.container = container
|
118
|
+
self.first_run = first_run
|
119
|
+
self.running = True
|
120
|
+
self.thread = threading.Thread(target=self._log_monitor)
|
121
|
+
self.thread.daemon = True
|
122
|
+
self.thread.start()
|
123
|
+
|
124
|
+
def _log_monitor(self):
|
125
|
+
from_date = _utc_now_no_tz() if not self.first_run else None
|
126
|
+
to_date = _utc_now_no_tz()
|
127
|
+
|
128
|
+
while self.running:
|
129
|
+
try:
|
130
|
+
for log in self.container.logs(
|
131
|
+
follow=False, since=from_date, until=to_date, stream=True
|
132
|
+
):
|
133
|
+
# print(log.decode("utf-8"), end="")
|
134
|
+
self.filter.print(log)
|
135
|
+
time.sleep(0.1)
|
136
|
+
from_date = to_date
|
137
|
+
to_date = _utc_now_no_tz()
|
138
|
+
except KeyboardInterrupt:
|
139
|
+
print("Monitoring logs interrupted by user.")
|
140
|
+
_thread.interrupt_main()
|
141
|
+
break
|
142
|
+
except Exception as e:
|
143
|
+
print(f"Error monitoring logs: {e}")
|
144
|
+
break
|
145
|
+
|
146
|
+
def detach(self) -> None:
|
147
|
+
"""Stop monitoring the container logs"""
|
148
|
+
self.running = False
|
149
|
+
self.thread.join()
|
150
|
+
|
151
|
+
def stop(self) -> None:
|
152
|
+
"""Stop the container"""
|
153
|
+
self.container.stop()
|
154
|
+
self.detach()
|
155
|
+
|
156
|
+
|
157
|
+
def _hack_to_fix_mac(volumes: list[Volume] | None) -> list[Volume] | None:
|
158
|
+
"""Fixes the volume mounts on MacOS by removing the mode."""
|
159
|
+
if volumes is None:
|
160
|
+
return None
|
161
|
+
if sys.platform != "darwin":
|
162
|
+
# Only macos needs hacking.
|
163
|
+
return volumes
|
164
|
+
|
165
|
+
volumes = volumes.copy()
|
166
|
+
# Work around a Docker bug on MacOS where the expected network socket to the
|
167
|
+
# the host is not mounted correctly. This was actually fixed in recent versions
|
168
|
+
# of docker client but there is a large chunk of Docker clients out there with
|
169
|
+
# this bug in it.
|
170
|
+
#
|
171
|
+
# This hack is done by mounting the socket directly to the container.
|
172
|
+
# This socket talks to the docker daemon on the host.
|
173
|
+
#
|
174
|
+
# Found here.
|
175
|
+
# https://github.com/docker/docker-py/issues/3069#issuecomment-1316778735
|
176
|
+
# if it exists already then return the input
|
177
|
+
for volume in volumes:
|
178
|
+
if volume.host_path == "/var/run/docker.sock":
|
179
|
+
return volumes
|
180
|
+
# ok it doesn't exist, so add it
|
181
|
+
volumes.append(
|
182
|
+
Volume(
|
183
|
+
host_path="/var/run/docker.sock",
|
184
|
+
container_path="/var/run/docker.sock",
|
185
|
+
mode="rw",
|
186
|
+
)
|
187
|
+
)
|
188
|
+
return volumes
|
189
|
+
|
190
|
+
|
191
|
+
class DockerManager:
|
192
|
+
def __init__(self) -> None:
|
193
|
+
from docker.errors import DockerException
|
194
|
+
|
195
|
+
self.is_suspended: bool = False
|
196
|
+
|
197
|
+
try:
|
198
|
+
self._client: DockerClient | None = None
|
199
|
+
self.first_run = False
|
200
|
+
except DockerException as e:
|
201
|
+
stack = traceback.format_exc()
|
202
|
+
warnings.warn(f"Error initializing Docker client: {e}\n{stack}")
|
203
|
+
raise
|
204
|
+
|
205
|
+
@property
|
206
|
+
def client(self) -> DockerClient:
|
207
|
+
if self._client is None:
|
208
|
+
self._client = docker.from_env()
|
209
|
+
return self._client
|
210
|
+
|
211
|
+
@staticmethod
|
212
|
+
def is_docker_installed() -> bool:
|
213
|
+
"""Check if Docker is installed on the system."""
|
214
|
+
try:
|
215
|
+
subprocess.run(["docker", "--version"], capture_output=True, check=True)
|
216
|
+
print("Docker is installed.")
|
217
|
+
return True
|
218
|
+
except subprocess.CalledProcessError as e:
|
219
|
+
print(f"Docker command failed: {str(e)}")
|
220
|
+
return False
|
221
|
+
except FileNotFoundError:
|
222
|
+
print("Docker is not installed.")
|
223
|
+
return False
|
224
|
+
|
225
|
+
@staticmethod
|
226
|
+
def ensure_linux_containers_for_windows() -> bool:
|
227
|
+
"""Ensure Docker is using Linux containers on Windows."""
|
228
|
+
if sys.platform != "win32":
|
229
|
+
return True # Only needed on Windows
|
230
|
+
|
231
|
+
try:
|
232
|
+
# Check if we're already in Linux container mode
|
233
|
+
result = subprocess.run(
|
234
|
+
["docker", "info"], capture_output=True, text=True, check=True
|
235
|
+
)
|
236
|
+
|
237
|
+
if "linux" in result.stdout.lower():
|
238
|
+
print("Already using Linux containers")
|
239
|
+
return True
|
240
|
+
|
241
|
+
if not _IS_GITHUB:
|
242
|
+
answer = (
|
243
|
+
input(
|
244
|
+
"\nDocker on Windows must be in linux mode, this is a global change, switch? [y/n]"
|
245
|
+
)
|
246
|
+
.strip()
|
247
|
+
.lower()[:1]
|
248
|
+
)
|
249
|
+
if answer != "y":
|
250
|
+
return False
|
251
|
+
|
252
|
+
print("Switching to Linux containers...")
|
253
|
+
warnings.warn("Switching Docker to use Linux container context...")
|
254
|
+
|
255
|
+
# Explicitly specify the Linux container context
|
256
|
+
linux_context = "desktop-linux"
|
257
|
+
subprocess.run(
|
258
|
+
["cmd", "/c", f"docker context use {linux_context}"],
|
259
|
+
check=True,
|
260
|
+
capture_output=True,
|
261
|
+
)
|
262
|
+
|
263
|
+
# Verify the switch worked
|
264
|
+
verify = subprocess.run(
|
265
|
+
["docker", "info"], capture_output=True, text=True, check=True
|
266
|
+
)
|
267
|
+
if "linux" in verify.stdout.lower():
|
268
|
+
print(
|
269
|
+
f"Successfully switched to Linux containers using '{linux_context}' context"
|
270
|
+
)
|
271
|
+
return True
|
272
|
+
|
273
|
+
warnings.warn(
|
274
|
+
f"Failed to switch to Linux containers with context '{linux_context}': {verify.stdout}"
|
275
|
+
)
|
276
|
+
return False
|
277
|
+
|
278
|
+
except subprocess.CalledProcessError as e:
|
279
|
+
print(f"Failed to switch to Linux containers: {e}")
|
280
|
+
if e.stdout:
|
281
|
+
print(f"stdout: {e.stdout}")
|
282
|
+
if e.stderr:
|
283
|
+
print(f"stderr: {e.stderr}")
|
284
|
+
return False
|
285
|
+
except Exception as e:
|
286
|
+
print(f"Unexpected error switching to Linux containers: {e}")
|
287
|
+
return False
|
288
|
+
|
289
|
+
@staticmethod
|
290
|
+
def is_running() -> tuple[bool, Exception | None]:
|
291
|
+
"""Check if Docker is running by pinging the Docker daemon."""
|
292
|
+
|
293
|
+
if not DockerManager.is_docker_installed():
|
294
|
+
print("Docker is not installed.")
|
295
|
+
return False, Exception("Docker is not installed.")
|
296
|
+
try:
|
297
|
+
# self.client.ping()
|
298
|
+
client = docker.from_env()
|
299
|
+
client.ping()
|
300
|
+
print("Docker is running.")
|
301
|
+
return True, None
|
302
|
+
except DockerException as e:
|
303
|
+
print(f"Docker is not running: {str(e)}")
|
304
|
+
return False, e
|
305
|
+
except Exception as e:
|
306
|
+
print(f"Error pinging Docker daemon: {str(e)}")
|
307
|
+
return False, e
|
308
|
+
|
309
|
+
def start(self) -> bool:
|
310
|
+
"""Attempt to start Docker Desktop (or the Docker daemon) automatically."""
|
311
|
+
print("Attempting to start Docker...")
|
312
|
+
|
313
|
+
try:
|
314
|
+
if sys.platform == "win32":
|
315
|
+
docker_path = _win32_docker_location()
|
316
|
+
if not docker_path:
|
317
|
+
print("Docker Desktop not found.")
|
318
|
+
return False
|
319
|
+
subprocess.run(["start", "", docker_path], shell=True)
|
320
|
+
elif sys.platform == "darwin":
|
321
|
+
subprocess.run(["open", "-a", "Docker"])
|
322
|
+
elif sys.platform.startswith("linux"):
|
323
|
+
subprocess.run(["sudo", "systemctl", "start", "docker"])
|
324
|
+
else:
|
325
|
+
print("Unknown platform. Cannot auto-launch Docker.")
|
326
|
+
return False
|
327
|
+
|
328
|
+
# Wait for Docker to start up with increasing delays
|
329
|
+
print("Waiting for Docker Desktop to start...")
|
330
|
+
attempts = 0
|
331
|
+
max_attempts = 20 # Increased max wait time
|
332
|
+
while attempts < max_attempts:
|
333
|
+
attempts += 1
|
334
|
+
if self.is_running():
|
335
|
+
print("Docker started successfully.")
|
336
|
+
return True
|
337
|
+
|
338
|
+
# Gradually increase wait time between checks
|
339
|
+
wait_time = min(5, 1 + attempts * 0.5)
|
340
|
+
print(
|
341
|
+
f"Docker not ready yet, waiting {wait_time:.1f}s... (attempt {attempts}/{max_attempts})"
|
342
|
+
)
|
343
|
+
time.sleep(wait_time)
|
344
|
+
|
345
|
+
print("Failed to start Docker within the expected time.")
|
346
|
+
print(
|
347
|
+
"Please try starting Docker Desktop manually and run this command again."
|
348
|
+
)
|
349
|
+
except KeyboardInterrupt:
|
350
|
+
print("Aborted by user.")
|
351
|
+
raise
|
352
|
+
except Exception as e:
|
353
|
+
print(f"Error starting Docker: {str(e)}")
|
354
|
+
return False
|
355
|
+
|
356
|
+
def has_newer_version(
|
357
|
+
self, image_name: str, tag: str = "latest"
|
358
|
+
) -> tuple[bool, str]:
|
359
|
+
"""
|
360
|
+
Check if a newer version of the image is available in the registry.
|
361
|
+
|
362
|
+
Args:
|
363
|
+
image_name: The name of the image to check
|
364
|
+
tag: The tag of the image to check
|
365
|
+
|
366
|
+
Returns:
|
367
|
+
A tuple of (has_newer_version, message)
|
368
|
+
has_newer_version: True if a newer version is available, False otherwise
|
369
|
+
message: A message describing the result
|
370
|
+
"""
|
371
|
+
try:
|
372
|
+
# Get the local image
|
373
|
+
local_image = self.client.images.get(f"{image_name}:{tag}")
|
374
|
+
local_image_id = local_image.id
|
375
|
+
assert local_image_id is not None
|
376
|
+
|
377
|
+
# Get the remote image data
|
378
|
+
remote_image = self.client.images.get_registry_data(f"{image_name}:{tag}")
|
379
|
+
remote_image_hash = remote_image.id
|
380
|
+
|
381
|
+
# Check if we have a cached remote hash for this local image
|
382
|
+
try:
|
383
|
+
remote_image_hash_from_local_image = DISK_CACHE.get(local_image_id)
|
384
|
+
except Exception:
|
385
|
+
remote_image_hash_from_local_image = None
|
386
|
+
|
387
|
+
# Compare the hashes
|
388
|
+
if remote_image_hash_from_local_image == remote_image_hash:
|
389
|
+
return False, f"Local image {image_name}:{tag} is up to date."
|
390
|
+
else:
|
391
|
+
return True, f"Newer version of {image_name}:{tag} is available."
|
392
|
+
|
393
|
+
except ImageNotFound:
|
394
|
+
return True, f"Image {image_name}:{tag} not found locally."
|
395
|
+
except DockerException as e:
|
396
|
+
return False, f"Error checking for newer version: {e}"
|
397
|
+
|
398
|
+
def validate_or_download_image(
|
399
|
+
self, image_name: str, tag: str = "latest", upgrade: bool = False
|
400
|
+
) -> bool:
|
401
|
+
"""
|
402
|
+
Validate if the image exists, and if not, download it.
|
403
|
+
If upgrade is True, will pull the latest version even if image exists locally.
|
404
|
+
"""
|
405
|
+
ok = DockerManager.ensure_linux_containers_for_windows()
|
406
|
+
if not ok:
|
407
|
+
warnings.warn(
|
408
|
+
"Failed to ensure Linux containers on Windows. This build may fail."
|
409
|
+
)
|
410
|
+
print(f"Validating image {image_name}:{tag}...")
|
411
|
+
remote_image_hash_from_local_image: str | None = None
|
412
|
+
remote_image_hash: str | None = None
|
413
|
+
|
414
|
+
with get_lock(f"{image_name}-{tag}"):
|
415
|
+
try:
|
416
|
+
local_image = self.client.images.get(f"{image_name}:{tag}")
|
417
|
+
print(f"Image {image_name}:{tag} is already available.")
|
418
|
+
|
419
|
+
if upgrade:
|
420
|
+
remote_image = self.client.images.get_registry_data(
|
421
|
+
f"{image_name}:{tag}"
|
422
|
+
)
|
423
|
+
remote_image_hash = remote_image.id
|
424
|
+
|
425
|
+
try:
|
426
|
+
local_image_id = local_image.id
|
427
|
+
assert local_image_id is not None
|
428
|
+
remote_image_hash_from_local_image = DISK_CACHE.get(
|
429
|
+
local_image_id
|
430
|
+
)
|
431
|
+
except KeyboardInterrupt:
|
432
|
+
raise
|
433
|
+
except Exception:
|
434
|
+
remote_image_hash_from_local_image = None
|
435
|
+
stack = traceback.format_exc()
|
436
|
+
warnings.warn(
|
437
|
+
f"Error getting remote image hash from local image: {stack}"
|
438
|
+
)
|
439
|
+
if remote_image_hash_from_local_image == remote_image_hash:
|
440
|
+
print(f"Local image {image_name}:{tag} is up to date.")
|
441
|
+
return False
|
442
|
+
|
443
|
+
# Quick check for latest version
|
444
|
+
with Spinner(f"Pulling newer version of {image_name}:{tag}..."):
|
445
|
+
cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
|
446
|
+
cmd_str = subprocess.list2cmdline(cmd_list)
|
447
|
+
print(f"Running command: {cmd_str}")
|
448
|
+
subprocess.run(cmd_list, check=True)
|
449
|
+
print(f"Updated to newer version of {image_name}:{tag}")
|
450
|
+
local_image_hash = self.client.images.get(f"{image_name}:{tag}").id
|
451
|
+
assert local_image_hash is not None
|
452
|
+
if remote_image_hash is not None:
|
453
|
+
DISK_CACHE.put(local_image_hash, remote_image_hash)
|
454
|
+
return True
|
455
|
+
|
456
|
+
except ImageNotFound:
|
457
|
+
print(f"Image {image_name}:{tag} not found.")
|
458
|
+
with Spinner("Loading "):
|
459
|
+
# We use docker cli here because it shows the download.
|
460
|
+
cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
|
461
|
+
cmd_str = subprocess.list2cmdline(cmd_list)
|
462
|
+
print(f"Running command: {cmd_str}")
|
463
|
+
subprocess.run(cmd_list, check=True)
|
464
|
+
try:
|
465
|
+
local_image = self.client.images.get(f"{image_name}:{tag}")
|
466
|
+
local_image_hash = local_image.id
|
467
|
+
print(f"Image {image_name}:{tag} downloaded successfully.")
|
468
|
+
except ImageNotFound:
|
469
|
+
warnings.warn(f"Image {image_name}:{tag} not found after download.")
|
470
|
+
return True
|
471
|
+
|
472
|
+
def tag_image(self, image_name: str, old_tag: str, new_tag: str) -> None:
|
473
|
+
"""
|
474
|
+
Tag an image with a new tag.
|
475
|
+
"""
|
476
|
+
image: Image = self.client.images.get(f"{image_name}:{old_tag}")
|
477
|
+
image.tag(image_name, new_tag)
|
478
|
+
print(f"Image {image_name}:{old_tag} tagged as {new_tag}.")
|
479
|
+
|
480
|
+
def _container_configs_match(
|
481
|
+
self,
|
482
|
+
container: Container,
|
483
|
+
command: str | None,
|
484
|
+
volumes_dict: dict[str, dict[str, str]] | None,
|
485
|
+
ports: dict[int, int] | None,
|
486
|
+
) -> bool:
|
487
|
+
"""Compare if existing container has matching configuration"""
|
488
|
+
try:
|
489
|
+
# Check if container is using the same image
|
490
|
+
image = container.image
|
491
|
+
assert image is not None
|
492
|
+
container_image_id = image.id
|
493
|
+
container_image_tags = image.tags
|
494
|
+
assert container_image_id is not None
|
495
|
+
|
496
|
+
# Simplified image comparison - just compare the IDs directly
|
497
|
+
if not container_image_tags:
|
498
|
+
print(f"Container using untagged image with ID: {container_image_id}")
|
499
|
+
else:
|
500
|
+
current_image = self.client.images.get(container_image_tags[0])
|
501
|
+
if container_image_id != current_image.id:
|
502
|
+
print(
|
503
|
+
f"Container using different image version. Container: {container_image_id}, Current: {current_image.id}"
|
504
|
+
)
|
505
|
+
return False
|
506
|
+
|
507
|
+
# Check command if specified
|
508
|
+
if command and container.attrs["Config"]["Cmd"] != command.split():
|
509
|
+
print(
|
510
|
+
f"Command mismatch: {container.attrs['Config']['Cmd']} != {command}"
|
511
|
+
)
|
512
|
+
return False
|
513
|
+
|
514
|
+
# Check volumes if specified
|
515
|
+
if volumes_dict:
|
516
|
+
container_mounts = (
|
517
|
+
{
|
518
|
+
m["Source"]: {"bind": m["Destination"], "mode": m["Mode"]}
|
519
|
+
for m in container.attrs["Mounts"]
|
520
|
+
}
|
521
|
+
if container.attrs.get("Mounts")
|
522
|
+
else {}
|
523
|
+
)
|
524
|
+
|
525
|
+
for host_dir, mount in volumes_dict.items():
|
526
|
+
if host_dir not in container_mounts:
|
527
|
+
print(f"Volume {host_dir} not found in container mounts.")
|
528
|
+
return False
|
529
|
+
if container_mounts[host_dir] != mount:
|
530
|
+
print(
|
531
|
+
f"Volume {host_dir} has different mount options: {container_mounts[host_dir]} != {mount}"
|
532
|
+
)
|
533
|
+
return False
|
534
|
+
|
535
|
+
# Check ports if specified
|
536
|
+
if ports:
|
537
|
+
container_ports = (
|
538
|
+
container.attrs["Config"]["ExposedPorts"]
|
539
|
+
if container.attrs["Config"].get("ExposedPorts")
|
540
|
+
else {}
|
541
|
+
)
|
542
|
+
container_port_bindings = (
|
543
|
+
container.attrs["HostConfig"]["PortBindings"]
|
544
|
+
if container.attrs["HostConfig"].get("PortBindings")
|
545
|
+
else {}
|
546
|
+
)
|
547
|
+
|
548
|
+
for container_port, host_port in ports.items():
|
549
|
+
port_key = f"{container_port}/tcp"
|
550
|
+
if port_key not in container_ports:
|
551
|
+
print(f"Container port {port_key} not found.")
|
552
|
+
return False
|
553
|
+
if not container_port_bindings.get(port_key, [{"HostPort": None}])[
|
554
|
+
0
|
555
|
+
]["HostPort"] == str(host_port):
|
556
|
+
print(f"Port {host_port} is not bound to {port_key}.")
|
557
|
+
return False
|
558
|
+
except KeyboardInterrupt:
|
559
|
+
raise
|
560
|
+
except NotFound:
|
561
|
+
print("Container not found.")
|
562
|
+
return False
|
563
|
+
except Exception as e:
|
564
|
+
stack = traceback.format_exc()
|
565
|
+
warnings.warn(f"Error checking container config: {e}\n{stack}")
|
566
|
+
return False
|
567
|
+
return True
|
568
|
+
|
569
|
+
def run_container_detached(
|
570
|
+
self,
|
571
|
+
image_name: str,
|
572
|
+
tag: str,
|
573
|
+
container_name: str,
|
574
|
+
command: str | None = None,
|
575
|
+
volumes: list[Volume] | None = None,
|
576
|
+
ports: dict[int, int] | None = None,
|
577
|
+
remove_previous: bool = False,
|
578
|
+
environment: dict[str, str] | None = None,
|
579
|
+
) -> Container:
|
580
|
+
"""
|
581
|
+
Run a container from an image. If it already exists with matching config, start it.
|
582
|
+
If it exists with different config, remove and recreate it.
|
583
|
+
|
584
|
+
Args:
|
585
|
+
volumes: List of Volume objects for container volume mappings
|
586
|
+
ports: Dict mapping host ports to container ports
|
587
|
+
Example: {8080: 80} maps host port 8080 to container port 80
|
588
|
+
"""
|
589
|
+
volumes = _hack_to_fix_mac(volumes)
|
590
|
+
# Convert volumes to the format expected by Docker API
|
591
|
+
volumes_dict = None
|
592
|
+
if volumes is not None:
|
593
|
+
volumes_dict = {}
|
594
|
+
for volume in volumes:
|
595
|
+
volumes_dict.update(volume.to_dict())
|
596
|
+
|
597
|
+
# Serialize the volumes to a json string
|
598
|
+
if volumes_dict:
|
599
|
+
volumes_str = json.dumps(volumes_dict)
|
600
|
+
print(f"Volumes: {volumes_str}")
|
601
|
+
print("Done")
|
602
|
+
image_name = f"{image_name}:{tag}"
|
603
|
+
try:
|
604
|
+
container: Container = self.client.containers.get(container_name)
|
605
|
+
|
606
|
+
if remove_previous:
|
607
|
+
print(f"Removing existing container {container_name}...")
|
608
|
+
container.remove(force=True)
|
609
|
+
raise NotFound("Container removed due to remove_previous")
|
610
|
+
# Check if configuration matches
|
611
|
+
elif not self._container_configs_match(
|
612
|
+
container, command, volumes_dict, ports
|
613
|
+
):
|
614
|
+
print(
|
615
|
+
f"Container {container_name} exists but with different configuration. Removing and recreating..."
|
616
|
+
)
|
617
|
+
container.remove(force=True)
|
618
|
+
raise NotFound("Container removed due to config mismatch")
|
619
|
+
print(f"Container {container_name} found with matching configuration.")
|
620
|
+
|
621
|
+
# Existing container with matching config - handle various states
|
622
|
+
if container.status == "running":
|
623
|
+
print(f"Container {container_name} is already running.")
|
624
|
+
elif container.status == "exited":
|
625
|
+
print(f"Starting existing container {container_name}.")
|
626
|
+
container.start()
|
627
|
+
elif container.status == "restarting":
|
628
|
+
print(f"Waiting for container {container_name} to restart...")
|
629
|
+
timeout = 10
|
630
|
+
container.wait(timeout=10)
|
631
|
+
if container.status == "running":
|
632
|
+
print(f"Container {container_name} has restarted.")
|
633
|
+
else:
|
634
|
+
print(
|
635
|
+
f"Container {container_name} did not restart within {timeout} seconds."
|
636
|
+
)
|
637
|
+
container.stop(timeout=0)
|
638
|
+
print(f"Container {container_name} has been stopped.")
|
639
|
+
container.start()
|
640
|
+
elif container.status == "paused":
|
641
|
+
print(f"Resuming existing container {container_name}.")
|
642
|
+
container.unpause()
|
643
|
+
else:
|
644
|
+
print(f"Unknown container status: {container.status}")
|
645
|
+
print(f"Starting existing container {container_name}.")
|
646
|
+
self.first_run = True
|
647
|
+
container.start()
|
648
|
+
except NotFound:
|
649
|
+
print(f"Creating and starting {container_name}")
|
650
|
+
out_msg = f"# Running in container: {command}"
|
651
|
+
msg_len = len(out_msg)
|
652
|
+
print("\n" + "#" * msg_len)
|
653
|
+
print(out_msg)
|
654
|
+
print("#" * msg_len + "\n")
|
655
|
+
container = self.client.containers.run(
|
656
|
+
image=image_name,
|
657
|
+
command=command,
|
658
|
+
name=container_name,
|
659
|
+
detach=True,
|
660
|
+
tty=True,
|
661
|
+
volumes=volumes_dict,
|
662
|
+
ports=ports, # type: ignore
|
663
|
+
environment=environment,
|
664
|
+
remove=True,
|
665
|
+
)
|
666
|
+
return container
|
667
|
+
|
668
|
+
def run_container_interactive(
|
669
|
+
self,
|
670
|
+
image_name: str,
|
671
|
+
tag: str,
|
672
|
+
container_name: str,
|
673
|
+
command: str | None = None,
|
674
|
+
volumes: list[Volume] | None = None,
|
675
|
+
ports: dict[int, int] | None = None,
|
676
|
+
environment: dict[str, str] | None = None,
|
677
|
+
) -> None:
|
678
|
+
# Convert volumes to the format expected by Docker API
|
679
|
+
volumes = _hack_to_fix_mac(volumes)
|
680
|
+
volumes_dict = None
|
681
|
+
if volumes is not None:
|
682
|
+
volumes_dict = {}
|
683
|
+
for volume in volumes:
|
684
|
+
volumes_dict.update(volume.to_dict())
|
685
|
+
# Remove existing container
|
686
|
+
try:
|
687
|
+
container: Container = self.client.containers.get(container_name)
|
688
|
+
container.remove(force=True)
|
689
|
+
except NotFound:
|
690
|
+
pass
|
691
|
+
start_time = time.time()
|
692
|
+
try:
|
693
|
+
docker_command: list[str] = [
|
694
|
+
"docker",
|
695
|
+
"run",
|
696
|
+
"-it",
|
697
|
+
"--rm",
|
698
|
+
"--name",
|
699
|
+
container_name,
|
700
|
+
]
|
701
|
+
if volumes_dict:
|
702
|
+
for host_dir, mount in volumes_dict.items():
|
703
|
+
docker_volume_arg = [
|
704
|
+
"-v",
|
705
|
+
f"{host_dir}:{mount['bind']}:{mount['mode']}",
|
706
|
+
]
|
707
|
+
docker_command.extend(docker_volume_arg)
|
708
|
+
if ports:
|
709
|
+
for host_port, container_port in ports.items():
|
710
|
+
docker_command.extend(["-p", f"{host_port}:{container_port}"])
|
711
|
+
if environment:
|
712
|
+
for env_name, env_value in environment.items():
|
713
|
+
docker_command.extend(["-e", f"{env_name}={env_value}"])
|
714
|
+
docker_command.append(f"{image_name}:{tag}")
|
715
|
+
if command:
|
716
|
+
docker_command.append(command)
|
717
|
+
cmd_str: str = subprocess.list2cmdline(docker_command)
|
718
|
+
print(f"Running command: {cmd_str}")
|
719
|
+
subprocess.run(docker_command, check=False)
|
720
|
+
except subprocess.CalledProcessError as e:
|
721
|
+
print(f"Error running Docker command: {e}")
|
722
|
+
diff = time.time() - start_time
|
723
|
+
if diff < 5:
|
724
|
+
raise
|
725
|
+
sys.exit(1) # Probably a user exit.
|
726
|
+
|
727
|
+
def attach_and_run(self, container: Container | str) -> RunningContainer:
|
728
|
+
"""
|
729
|
+
Attach to a running container and monitor its logs in a background thread.
|
730
|
+
Returns a RunningContainer object that can be used to stop monitoring.
|
731
|
+
"""
|
732
|
+
if isinstance(container, str):
|
733
|
+
container_name = container
|
734
|
+
tmp = self.get_container(container)
|
735
|
+
assert tmp is not None, f"Container {container_name} not found."
|
736
|
+
container = tmp
|
737
|
+
|
738
|
+
assert container is not None, "Container not found."
|
739
|
+
|
740
|
+
print(f"Attaching to container {container.name}...")
|
741
|
+
|
742
|
+
first_run = self.first_run
|
743
|
+
self.first_run = False
|
744
|
+
|
745
|
+
return RunningContainer(container, first_run)
|
746
|
+
|
747
|
+
def suspend_container(self, container: Container | str) -> None:
|
748
|
+
"""
|
749
|
+
Suspend (pause) the container.
|
750
|
+
"""
|
751
|
+
if self.is_suspended:
|
752
|
+
return
|
753
|
+
if isinstance(container, str):
|
754
|
+
container_name = container
|
755
|
+
# container = self.get_container(container)
|
756
|
+
tmp = self.get_container(container_name)
|
757
|
+
if not tmp:
|
758
|
+
print(f"Could not put container {container_name} to sleep.")
|
759
|
+
return
|
760
|
+
container = tmp
|
761
|
+
assert isinstance(container, Container)
|
762
|
+
try:
|
763
|
+
if platform.system() == "Windows":
|
764
|
+
container.pause()
|
765
|
+
else:
|
766
|
+
container.stop()
|
767
|
+
container.remove()
|
768
|
+
print(f"Container {container.name} has been suspended.")
|
769
|
+
except KeyboardInterrupt:
|
770
|
+
print(f"Container {container.name} interrupted by keyboard interrupt.")
|
771
|
+
except Exception as e:
|
772
|
+
print(f"Failed to suspend container {container.name}: {e}")
|
773
|
+
|
774
|
+
def resume_container(self, container: Container | str) -> None:
|
775
|
+
"""
|
776
|
+
Resume (unpause) the container.
|
777
|
+
"""
|
778
|
+
container_name = "UNKNOWN"
|
779
|
+
if isinstance(container, str):
|
780
|
+
container_name = container
|
781
|
+
container_or_none = self.get_container(container)
|
782
|
+
if container_or_none is None:
|
783
|
+
print(f"Could not resume container {container}.")
|
784
|
+
return
|
785
|
+
container = container_or_none
|
786
|
+
container_name = container.name
|
787
|
+
elif isinstance(container, Container):
|
788
|
+
container_name = container.name
|
789
|
+
assert isinstance(container, Container)
|
790
|
+
if not container:
|
791
|
+
print(f"Could not resume container {container}.")
|
792
|
+
return
|
793
|
+
try:
|
794
|
+
assert isinstance(container, Container)
|
795
|
+
container.unpause()
|
796
|
+
print(f"Container {container.name} has been resumed.")
|
797
|
+
except Exception as e:
|
798
|
+
print(f"Failed to resume container {container_name}: {e}")
|
799
|
+
|
800
|
+
def get_container(self, container_name: str) -> Container | None:
|
801
|
+
"""
|
802
|
+
Get a container by name.
|
803
|
+
"""
|
804
|
+
try:
|
805
|
+
return self.client.containers.get(container_name)
|
806
|
+
except NotFound:
|
807
|
+
return None
|
808
|
+
|
809
|
+
def is_container_running(self, container_name: str) -> bool:
|
810
|
+
"""
|
811
|
+
Check if a container is running.
|
812
|
+
"""
|
813
|
+
try:
|
814
|
+
container = self.client.containers.get(container_name)
|
815
|
+
return container.status == "running"
|
816
|
+
except NotFound:
|
817
|
+
print(f"Container {container_name} not found.")
|
818
|
+
return False
|
819
|
+
|
820
|
+
def build_image(
|
821
|
+
self,
|
822
|
+
image_name: str,
|
823
|
+
tag: str,
|
824
|
+
dockerfile_path: Path,
|
825
|
+
build_context: Path,
|
826
|
+
build_args: dict[str, str] | None = None,
|
827
|
+
platform_tag: str = "",
|
828
|
+
) -> None:
|
829
|
+
"""
|
830
|
+
Build a Docker image from a Dockerfile.
|
831
|
+
|
832
|
+
Args:
|
833
|
+
image_name: Name for the image
|
834
|
+
tag: Tag for the image
|
835
|
+
dockerfile_path: Path to the Dockerfile
|
836
|
+
build_context: Path to the build context directory
|
837
|
+
build_args: Optional dictionary of build arguments
|
838
|
+
platform_tag: Optional platform tag (e.g. "-arm64")
|
839
|
+
"""
|
840
|
+
if not dockerfile_path.exists():
|
841
|
+
raise FileNotFoundError(f"Dockerfile not found at {dockerfile_path}")
|
842
|
+
|
843
|
+
if not build_context.exists():
|
844
|
+
raise FileNotFoundError(
|
845
|
+
f"Build context directory not found at {build_context}"
|
846
|
+
)
|
847
|
+
|
848
|
+
print(f"Building Docker image {image_name}:{tag} from {dockerfile_path}")
|
849
|
+
|
850
|
+
# Prepare build arguments
|
851
|
+
buildargs = build_args or {}
|
852
|
+
if platform_tag:
|
853
|
+
buildargs["PLATFORM_TAG"] = platform_tag
|
854
|
+
|
855
|
+
try:
|
856
|
+
cmd_list = [
|
857
|
+
"docker",
|
858
|
+
"build",
|
859
|
+
"-t",
|
860
|
+
f"{image_name}:{tag}",
|
861
|
+
]
|
862
|
+
|
863
|
+
# Add build args
|
864
|
+
for arg_name, arg_value in buildargs.items():
|
865
|
+
cmd_list.extend(["--build-arg", f"{arg_name}={arg_value}"])
|
866
|
+
|
867
|
+
# Add dockerfile and context paths
|
868
|
+
cmd_list.extend(["-f", str(dockerfile_path), str(build_context)])
|
869
|
+
|
870
|
+
cmd_str = subprocess.list2cmdline(cmd_list)
|
871
|
+
print(f"Running command: {cmd_str}")
|
872
|
+
|
873
|
+
# Run the build command
|
874
|
+
# cp = subprocess.run(cmd_list, check=True, capture_output=True)
|
875
|
+
proc: subprocess.Popen = subprocess.Popen(
|
876
|
+
cmd_list, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
877
|
+
)
|
878
|
+
stdout = proc.stdout
|
879
|
+
assert stdout is not None, "stdout is None"
|
880
|
+
for line in iter(stdout.readline, b""):
|
881
|
+
try:
|
882
|
+
line_str = line.decode("utf-8")
|
883
|
+
print(line_str, end="")
|
884
|
+
except UnicodeDecodeError:
|
885
|
+
print("Error decoding line")
|
886
|
+
rtn = proc.wait()
|
887
|
+
if rtn != 0:
|
888
|
+
warnings.warn(
|
889
|
+
f"Error building Docker image, is docker running? {rtn}, stdout: {stdout}, stderr: {proc.stderr}"
|
890
|
+
)
|
891
|
+
raise subprocess.CalledProcessError(rtn, cmd_str)
|
892
|
+
print(f"Successfully built image {image_name}:{tag}")
|
893
|
+
|
894
|
+
except subprocess.CalledProcessError as e:
|
895
|
+
print(f"Error building Docker image: {e}")
|
896
|
+
raise
|
897
|
+
|
898
|
+
def purge(self, image_name: str) -> None:
|
899
|
+
"""
|
900
|
+
Remove all containers and images associated with the given image name.
|
901
|
+
|
902
|
+
Args:
|
903
|
+
image_name: The name of the image to purge (without tag)
|
904
|
+
"""
|
905
|
+
print(f"Purging all containers and images for {image_name}...")
|
906
|
+
|
907
|
+
# Remove all containers using this image
|
908
|
+
try:
|
909
|
+
containers = self.client.containers.list(all=True)
|
910
|
+
for container in containers:
|
911
|
+
if any(image_name in tag for tag in container.image.tags):
|
912
|
+
print(f"Removing container {container.name}")
|
913
|
+
container.remove(force=True)
|
914
|
+
|
915
|
+
except Exception as e:
|
916
|
+
print(f"Error removing containers: {e}")
|
917
|
+
|
918
|
+
# Remove all images with this name
|
919
|
+
try:
|
920
|
+
self.client.images.prune(filters={"dangling": False})
|
921
|
+
images = self.client.images.list()
|
922
|
+
for image in images:
|
923
|
+
if any(image_name in tag for tag in image.tags):
|
924
|
+
print(f"Removing image {image.tags}")
|
925
|
+
self.client.images.remove(image.id, force=True)
|
926
|
+
except Exception as e:
|
927
|
+
print(f"Error removing images: {e}")
|
928
|
+
|
929
|
+
|
930
|
+
def main() -> None:
|
931
|
+
# Register SIGINT handler
|
932
|
+
# signal.signal(signal.SIGINT, handle_sigint)
|
933
|
+
|
934
|
+
docker_manager = DockerManager()
|
935
|
+
|
936
|
+
# Parameters
|
937
|
+
image_name = "python"
|
938
|
+
tag = "3.10-slim"
|
939
|
+
# new_tag = "my-python"
|
940
|
+
container_name = "my-python-container"
|
941
|
+
command = "python -m http.server"
|
942
|
+
running_container: RunningContainer | None = None
|
943
|
+
|
944
|
+
try:
|
945
|
+
# Step 1: Validate or download the image
|
946
|
+
docker_manager.validate_or_download_image(image_name, tag, upgrade=True)
|
947
|
+
|
948
|
+
# Step 2: Tag the image
|
949
|
+
# docker_manager.tag_image(image_name, tag, new_tag)
|
950
|
+
|
951
|
+
# Step 3: Run the container
|
952
|
+
container = docker_manager.run_container_detached(
|
953
|
+
image_name, tag, container_name, command
|
954
|
+
)
|
955
|
+
|
956
|
+
# Step 4: Attach and monitor the container logs
|
957
|
+
running_container = docker_manager.attach_and_run(container)
|
958
|
+
|
959
|
+
# Wait for keyboard interrupt
|
960
|
+
while True:
|
961
|
+
time.sleep(0.1)
|
962
|
+
|
963
|
+
except KeyboardInterrupt:
|
964
|
+
print("\nStopping container...")
|
965
|
+
if isinstance(running_container, RunningContainer):
|
966
|
+
running_container.stop()
|
967
|
+
container_or_none = docker_manager.get_container(container_name)
|
968
|
+
if container_or_none is not None:
|
969
|
+
docker_manager.suspend_container(container_or_none)
|
970
|
+
else:
|
971
|
+
warnings.warn(f"Container {container_name} not found.")
|
972
|
+
|
973
|
+
try:
|
974
|
+
# Suspend and resume the container
|
975
|
+
container = docker_manager.get_container(container_name)
|
976
|
+
assert container is not None, "Container not found."
|
977
|
+
docker_manager.suspend_container(container)
|
978
|
+
|
979
|
+
input("Press Enter to resume the container...")
|
980
|
+
|
981
|
+
docker_manager.resume_container(container)
|
982
|
+
except Exception as e:
|
983
|
+
print(f"An error occurred: {e}")
|
984
|
+
|
985
|
+
|
986
|
+
if __name__ == "__main__":
|
987
|
+
main()
|