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