dlab-cli 0.1.0__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.
dlab/docker.py ADDED
@@ -0,0 +1,591 @@
1
+ """
2
+ Docker container management for dlab.
3
+
4
+ This module handles:
5
+ - Building Docker images from decision-pack config directories
6
+ - Starting/stopping containers with volume mounts
7
+ - Executing commands inside containers
8
+ - Automatic rebuild detection when docker/ contents change
9
+ """
10
+
11
+ import hashlib
12
+ import json
13
+ import subprocess
14
+ import tempfile
15
+ from pathlib import Path
16
+ from typing import Callable
17
+
18
+
19
+ # Template for the wrapper Dockerfile that adds opencode to the base image
20
+ # Dependencies:
21
+ # - git: version control (used by coding agents)
22
+ # - ripgrep: required by opencode for grep/glob/list tools
23
+ # - curl: needed to install Node.js
24
+ # - nodejs: required to run opencode (installed via npm)
25
+ OPENCODE_WRAPPER_DOCKERFILE: str = """FROM {base_image}
26
+
27
+ # Install git, ripgrep, and Node.js (required for opencode)
28
+ RUN apt-get update && apt-get install -y git ripgrep curl && \\
29
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \\
30
+ apt-get install -y nodejs && \\
31
+ apt-get clean && rm -rf /var/lib/apt/lists/*
32
+
33
+ # Install opencode
34
+ RUN npm install -g {opencode_package}
35
+
36
+ # Verify installation
37
+ RUN opencode --version
38
+ """
39
+
40
+ # Label name for storing the source hash in Docker images
41
+ SOURCE_HASH_LABEL: str = "dlab.source-hash"
42
+
43
+
44
+ def compute_docker_dir_hash(
45
+ docker_dir: Path, opencode_version: str = "latest",
46
+ ) -> str:
47
+ """
48
+ Compute a SHA256 hash of all files in the docker/ directory plus opencode version.
49
+
50
+ This hash is used to detect when the docker/ contents or opencode version
51
+ have changed, triggering an automatic rebuild of the Docker image.
52
+
53
+ Parameters
54
+ ----------
55
+ docker_dir : Path
56
+ Path to the docker/ directory.
57
+ opencode_version : str
58
+ Version of opencode to install (included in hash so version
59
+ changes trigger rebuilds).
60
+
61
+ Returns
62
+ -------
63
+ str
64
+ Hex-encoded SHA256 hash of the directory contents and opencode version.
65
+ """
66
+ hasher = hashlib.sha256()
67
+ hasher.update(f"opencode_version={opencode_version}".encode("utf-8"))
68
+
69
+ # Get all files sorted by path for deterministic ordering
70
+ files: list[Path] = sorted(docker_dir.rglob("*"))
71
+
72
+ for file_path in files:
73
+ if file_path.is_file():
74
+ # Skip __pycache__ directories (contain timestamps that change on import)
75
+ if "__pycache__" in file_path.parts:
76
+ continue
77
+ # Skip .pyc files
78
+ if file_path.suffix == ".pyc":
79
+ continue
80
+ # Include relative path in hash (so renames are detected)
81
+ rel_path: str = str(file_path.relative_to(docker_dir))
82
+ hasher.update(rel_path.encode("utf-8"))
83
+ # Include file contents
84
+ hasher.update(file_path.read_bytes())
85
+
86
+ return hasher.hexdigest()
87
+
88
+
89
+ def get_image_source_hash(image_name: str) -> str | None:
90
+ """
91
+ Get the source hash label from a Docker image.
92
+
93
+ Parameters
94
+ ----------
95
+ image_name : str
96
+ Name of the Docker image.
97
+
98
+ Returns
99
+ -------
100
+ str | None
101
+ The source hash if the label exists, None otherwise.
102
+ """
103
+ result: subprocess.CompletedProcess[str] = subprocess.run(
104
+ ["docker", "inspect", "--format", "{{json .Config.Labels}}", image_name],
105
+ capture_output=True,
106
+ text=True,
107
+ )
108
+
109
+ if result.returncode != 0:
110
+ return None
111
+
112
+ try:
113
+ labels: dict[str, str] = json.loads(result.stdout.strip())
114
+ if labels is None:
115
+ return None
116
+ return labels.get(SOURCE_HASH_LABEL)
117
+ except json.JSONDecodeError:
118
+ return None
119
+
120
+
121
+ def needs_rebuild(
122
+ config_dir: str, image_name: str, opencode_version: str = "latest",
123
+ ) -> tuple[bool, str]:
124
+ """
125
+ Check if a Docker image needs to be rebuilt based on source changes.
126
+
127
+ Parameters
128
+ ----------
129
+ config_dir : str
130
+ Path to the decision-pack config directory (contains docker/ subdirectory).
131
+ image_name : str
132
+ Name of the Docker image.
133
+ opencode_version : str
134
+ Version of opencode to install (included in hash check).
135
+
136
+ Returns
137
+ -------
138
+ tuple[bool, str]
139
+ Tuple of (needs_rebuild, reason).
140
+ reason is a human-readable explanation of why rebuild is needed.
141
+ """
142
+ docker_dir: Path = Path(config_dir) / "docker"
143
+
144
+ if not docker_dir.exists():
145
+ return True, "docker/ directory not found"
146
+
147
+ # Check if image exists at all
148
+ if not image_exists(image_name):
149
+ return True, "image does not exist"
150
+
151
+ # Compute current hash (includes opencode version)
152
+ current_hash: str = compute_docker_dir_hash(docker_dir, opencode_version)
153
+
154
+ # Get stored hash from image
155
+ stored_hash: str | None = get_image_source_hash(image_name)
156
+
157
+ if stored_hash is None:
158
+ return True, "image missing source hash label (built before auto-rebuild)"
159
+
160
+ if current_hash != stored_hash:
161
+ return True, "docker/ contents or opencode version have changed"
162
+
163
+ return False, "image is up to date"
164
+
165
+
166
+ def image_exists(image_name: str) -> bool:
167
+ """
168
+ Check if a Docker image exists locally.
169
+
170
+ Parameters
171
+ ----------
172
+ image_name : str
173
+ Name of the Docker image to check.
174
+
175
+ Returns
176
+ -------
177
+ bool
178
+ True if image exists, False otherwise.
179
+ """
180
+ result: subprocess.CompletedProcess[str] = subprocess.run(
181
+ ["docker", "images", "-q", image_name],
182
+ capture_output=True,
183
+ text=True,
184
+ )
185
+ # docker images -q returns image ID if found, empty string if not
186
+ return bool(result.stdout.strip())
187
+
188
+
189
+ def _get_image_id(image_name: str) -> str | None:
190
+ """Get the image ID for a given image name, or None if not found."""
191
+ result: subprocess.CompletedProcess[str] = subprocess.run(
192
+ ["docker", "images", "-q", "--no-trunc", image_name],
193
+ capture_output=True,
194
+ text=True,
195
+ )
196
+ image_id: str = result.stdout.strip()
197
+ return image_id if image_id else None
198
+
199
+
200
+ def count_dangling_images() -> int:
201
+ """Return the number of dangling (untagged) Docker images."""
202
+ result: subprocess.CompletedProcess[str] = subprocess.run(
203
+ ["docker", "images", "-q", "--filter", "dangling=true"],
204
+ capture_output=True,
205
+ text=True,
206
+ )
207
+ return len(result.stdout.strip().splitlines()) if result.stdout.strip() else 0
208
+
209
+
210
+ def _remove_dangling_image(old_id: str | None, current_name: str) -> None:
211
+ """Remove an old image by ID if it's now dangling (untagged)."""
212
+ if old_id is None:
213
+ return
214
+ new_id: str | None = _get_image_id(current_name)
215
+ if new_id == old_id:
216
+ return
217
+ # Old ID is now dangling — remove it
218
+ subprocess.run(["docker", "rmi", old_id], capture_output=True)
219
+
220
+
221
+ def _run_docker_build(
222
+ cmd: list[str],
223
+ on_output: Callable[[str], None] | None = None,
224
+ ) -> tuple[int, str]:
225
+ """
226
+ Run a docker build command, streaming output line by line.
227
+
228
+ Parameters
229
+ ----------
230
+ cmd : list[str]
231
+ Docker build command.
232
+ on_output : Callable[[str], None] | None
233
+ Called for each output line.
234
+
235
+ Returns
236
+ -------
237
+ tuple[int, str]
238
+ (return_code, stderr_text).
239
+ """
240
+ proc: subprocess.Popen[str] = subprocess.Popen(
241
+ cmd,
242
+ stdout=subprocess.PIPE,
243
+ stderr=subprocess.STDOUT,
244
+ text=True,
245
+ )
246
+ stderr_lines: list[str] = []
247
+ for line in proc.stdout: # type: ignore[union-attr]
248
+ line = line.rstrip("\n")
249
+ if on_output:
250
+ on_output(line)
251
+ proc.wait()
252
+ return proc.returncode, "\n".join(stderr_lines)
253
+
254
+
255
+ def build_image(
256
+ config_dir: str,
257
+ image_name: str,
258
+ opencode_version: str = "latest",
259
+ on_output: Callable[[str], None] | None = None,
260
+ ) -> None:
261
+ """
262
+ Build a Docker image from a decision-pack's docker/ directory with opencode installed.
263
+
264
+ This function:
265
+ 1. Builds the decision-pack's Dockerfile as a base image
266
+ 2. Creates a wrapper Dockerfile that adds opencode
267
+ 3. Builds the final image with opencode installed
268
+ 4. Removes previous image IDs if they became dangling
269
+
270
+ Parameters
271
+ ----------
272
+ config_dir : str
273
+ Path to the decision-pack config directory (contains docker/ subdirectory).
274
+ image_name : str
275
+ Name to tag the built image with.
276
+ opencode_version : str
277
+ Version of opencode to install (default: "latest").
278
+ on_output : Callable[[str], None] | None
279
+ Optional callback invoked for each line of build output.
280
+
281
+ Raises
282
+ ------
283
+ ValueError
284
+ If the docker/ directory doesn't exist or build fails.
285
+ """
286
+ docker_dir: Path = Path(config_dir) / "docker"
287
+
288
+ if not docker_dir.exists():
289
+ raise ValueError(f"docker/ directory not found in: {config_dir}")
290
+
291
+ base_image_name: str = f"{image_name}-base"
292
+
293
+ # Capture old image IDs so we can clean up dangling images after build
294
+ old_base_id: str | None = _get_image_id(base_image_name)
295
+ old_wrapper_id: str | None = _get_image_id(image_name)
296
+
297
+ # Step 1: Build the base image from decision-pack's Dockerfile
298
+ returncode, stderr = _run_docker_build(
299
+ ["docker", "build", "-t", base_image_name, str(docker_dir)],
300
+ on_output=on_output,
301
+ )
302
+
303
+ if returncode != 0:
304
+ raise ValueError(f"Docker build failed: {stderr}")
305
+
306
+ # Step 2: Create wrapper Dockerfile that adds opencode
307
+ if opencode_version == "latest":
308
+ opencode_package: str = "opencode-ai@latest"
309
+ else:
310
+ opencode_package = f"opencode-ai@{opencode_version}"
311
+
312
+ wrapper_dockerfile: str = OPENCODE_WRAPPER_DOCKERFILE.format(
313
+ base_image=base_image_name,
314
+ opencode_package=opencode_package,
315
+ )
316
+
317
+ # Step 3: Compute source hash for auto-rebuild detection
318
+ source_hash: str = compute_docker_dir_hash(docker_dir, opencode_version)
319
+
320
+ # Step 4: Build final image with opencode and source hash label
321
+ with tempfile.TemporaryDirectory() as tmpdir:
322
+ dockerfile_path: Path = Path(tmpdir) / "Dockerfile"
323
+ dockerfile_path.write_text(wrapper_dockerfile)
324
+
325
+ returncode, stderr = _run_docker_build(
326
+ [
327
+ "docker", "build",
328
+ "-t", image_name,
329
+ "--label", f"{SOURCE_HASH_LABEL}={source_hash}",
330
+ tmpdir,
331
+ ],
332
+ on_output=on_output,
333
+ )
334
+
335
+ if returncode != 0:
336
+ raise ValueError(f"Docker build (opencode wrapper) failed: {stderr}")
337
+
338
+ # Step 5: Remove old images if they became dangling after re-tagging
339
+ _remove_dangling_image(old_base_id, base_image_name)
340
+ _remove_dangling_image(old_wrapper_id, image_name)
341
+
342
+
343
+ def container_exists(container_name: str) -> bool:
344
+ """
345
+ Check if a Docker container exists (running or stopped).
346
+
347
+ Parameters
348
+ ----------
349
+ container_name : str
350
+ Name of the container to check.
351
+
352
+ Returns
353
+ -------
354
+ bool
355
+ True if container exists, False otherwise.
356
+ """
357
+ result: subprocess.CompletedProcess[str] = subprocess.run(
358
+ ["docker", "ps", "-a", "-q", "-f", f"name=^{container_name}$"],
359
+ capture_output=True,
360
+ text=True,
361
+ )
362
+ # docker ps -q returns container ID if found, empty string if not
363
+ return bool(result.stdout.strip())
364
+
365
+
366
+ def start_container(
367
+ image_name: str,
368
+ work_dir: str,
369
+ container_name: str,
370
+ env_file: str | None = None,
371
+ extra_env: dict[str, str] | None = None,
372
+ ) -> None:
373
+ """
374
+ Start a new Docker container with volume mounts.
375
+
376
+ The container runs in detached mode with `tail -f /dev/null` to keep it
377
+ alive for subsequent `docker exec` commands.
378
+
379
+ Parameters
380
+ ----------
381
+ image_name : str
382
+ Name of the Docker image to use.
383
+ work_dir : str
384
+ Path to the work directory to mount at /workspace.
385
+ container_name : str
386
+ Name to give the container.
387
+ env_file : str | None
388
+ Optional path to an environment file to pass to the container.
389
+ extra_env : dict[str, str] | None
390
+ Additional environment variables to pass via -e flags.
391
+
392
+ Raises
393
+ ------
394
+ ValueError
395
+ If the container already exists, env file not found, or fails to start.
396
+ """
397
+ if container_exists(container_name):
398
+ raise ValueError(f"Container already exists: {container_name}")
399
+
400
+ if env_file is not None:
401
+ env_path: Path = Path(env_file).resolve()
402
+ if not env_path.exists():
403
+ raise ValueError(f"Environment file not found: {env_file}")
404
+
405
+ work_path: Path = Path(work_dir).resolve()
406
+
407
+ # Build the docker run command
408
+ cmd: list[str] = [
409
+ "docker", "run",
410
+ "-d", # Detached mode
411
+ "--name", container_name, # Container name
412
+ "-v", f"{work_path}:/workspace", # Mount work_dir at /workspace
413
+ "-v", f"{work_path}/_opencode_logs:/_opencode_logs", # Mount logs at /_opencode_logs
414
+ "-w", "/workspace", # Set working directory
415
+ ]
416
+
417
+ # Add env file if provided
418
+ if env_file is not None:
419
+ cmd.extend(["--env-file", str(env_path)])
420
+
421
+ # Add extra environment variables
422
+ if extra_env:
423
+ for key, value in extra_env.items():
424
+ cmd.extend(["-e", f"{key}={value}"])
425
+
426
+ cmd.extend([
427
+ image_name,
428
+ "tail", "-f", "/dev/null", # Keep container running
429
+ ])
430
+
431
+ result: subprocess.CompletedProcess[str] = subprocess.run(
432
+ cmd,
433
+ capture_output=True,
434
+ text=True,
435
+ )
436
+
437
+ if result.returncode != 0:
438
+ raise ValueError(f"Failed to start container: {result.stderr}")
439
+
440
+
441
+ def exec_command(
442
+ container_name: str,
443
+ command: list[str],
444
+ timeout: int | None = None,
445
+ ) -> tuple[int, str, str]:
446
+ """
447
+ Execute a command inside a running container.
448
+
449
+ Parameters
450
+ ----------
451
+ container_name : str
452
+ Name of the running container.
453
+ command : list[str]
454
+ Command and arguments to execute.
455
+ timeout : int | None
456
+ Timeout in seconds. None means no timeout.
457
+
458
+ Returns
459
+ -------
460
+ tuple[int, str, str]
461
+ Tuple of (exit_code, stdout, stderr).
462
+ """
463
+ cmd: list[str] = ["docker", "exec", container_name] + command
464
+
465
+ result: subprocess.CompletedProcess[str] = subprocess.run(
466
+ cmd,
467
+ capture_output=True,
468
+ text=True,
469
+ timeout=timeout,
470
+ )
471
+
472
+ return result.returncode, result.stdout, result.stderr
473
+
474
+
475
+ def stop_container(container_name: str) -> None:
476
+ """
477
+ Stop and remove a Docker container.
478
+
479
+ Parameters
480
+ ----------
481
+ container_name : str
482
+ Name of the container to stop and remove.
483
+
484
+ Notes
485
+ -----
486
+ This function is idempotent - it silently succeeds if the container
487
+ doesn't exist or is already stopped.
488
+ """
489
+ # Stop the container (ignore errors if already stopped)
490
+ subprocess.run(
491
+ ["docker", "stop", container_name],
492
+ capture_output=True,
493
+ text=True,
494
+ )
495
+
496
+ # Remove the container (ignore errors if doesn't exist)
497
+ subprocess.run(
498
+ ["docker", "rm", container_name],
499
+ capture_output=True,
500
+ text=True,
501
+ )
502
+
503
+
504
+ def build_runner_script(
505
+ prompt_file: str,
506
+ model: str,
507
+ log_prefix: str,
508
+ ) -> str:
509
+ """
510
+ Build the bash runner script that runs opencode inside a container.
511
+
512
+ Parameters
513
+ ----------
514
+ prompt_file : str
515
+ Path to the prompt file inside the container.
516
+ model : str
517
+ The model to use.
518
+ log_prefix : str
519
+ Prefix for log files.
520
+
521
+ Returns
522
+ -------
523
+ str
524
+ The bash script content.
525
+ """
526
+ return f'''#!/bin/bash
527
+ set -o pipefail
528
+ prompt=$(cat {prompt_file})
529
+ opencode run --format json --log-level DEBUG --model "{model}" "$prompt" 2>&1 | tee /_opencode_logs/{log_prefix}.log
530
+ '''
531
+
532
+
533
+ def run_opencode(
534
+ container_name: str,
535
+ prompt: str,
536
+ model: str,
537
+ timeout: int | None = None,
538
+ log_prefix: str = "main",
539
+ ) -> tuple[int, str, str]:
540
+ """
541
+ Run opencode with a prompt inside a container, logging output to _opencode_logs.
542
+
543
+ Parameters
544
+ ----------
545
+ container_name : str
546
+ Name of the running container.
547
+ prompt : str
548
+ The prompt to send to opencode.
549
+ model : str
550
+ The model to use (e.g., "anthropic/claude-sonnet-4-5").
551
+ timeout : int | None
552
+ Timeout in seconds. None means no timeout.
553
+ log_prefix : str
554
+ Prefix for log files (default: "main").
555
+
556
+ Returns
557
+ -------
558
+ tuple[int, str, str]
559
+ Tuple of (exit_code, stdout, stderr).
560
+ """
561
+ # Write prompt to a file to avoid shell quoting issues
562
+ # (prompts can contain quotes, $, backticks, newlines, etc.)
563
+ # The file is written via docker exec with stdin - completely safe
564
+ prompt_file: str = "/.prompt.txt"
565
+
566
+ # Write prompt file using cat with stdin (bypasses all shell escaping)
567
+ write_result: subprocess.CompletedProcess[bytes] = subprocess.run(
568
+ ["docker", "exec", "-i", container_name, "sh", "-c", f"cat > {prompt_file}"],
569
+ input=prompt.encode(),
570
+ capture_output=True,
571
+ )
572
+ if write_result.returncode != 0:
573
+ return write_result.returncode, "", write_result.stderr.decode()
574
+
575
+ # Build the runner script that reads the prompt file and runs opencode
576
+ # This avoids any shell expansion of the prompt content
577
+ runner_script: str = build_runner_script(prompt_file, model, log_prefix)
578
+ runner_file: str = "/.run_opencode.sh"
579
+
580
+ write_runner: subprocess.CompletedProcess[bytes] = subprocess.run(
581
+ ["docker", "exec", "-i", container_name, "sh", "-c", f"cat > {runner_file} && chmod +x {runner_file}"],
582
+ input=runner_script.encode(),
583
+ capture_output=True,
584
+ )
585
+ if write_runner.returncode != 0:
586
+ return write_runner.returncode, "", write_runner.stderr.decode()
587
+
588
+ # Run the script
589
+ command: list[str] = ["bash", runner_file]
590
+
591
+ return exec_command(container_name, command, timeout=timeout)