dlab-cli 0.1.2__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,592 @@
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
+ output_lines: list[str] = []
247
+ for line in proc.stdout: # type: ignore[union-attr]
248
+ line = line.rstrip("\n")
249
+ output_lines.append(line)
250
+ if on_output:
251
+ on_output(line)
252
+ proc.wait()
253
+ return proc.returncode, "\n".join(output_lines)
254
+
255
+
256
+ def build_image(
257
+ config_dir: str,
258
+ image_name: str,
259
+ opencode_version: str = "latest",
260
+ on_output: Callable[[str], None] | None = None,
261
+ ) -> None:
262
+ """
263
+ Build a Docker image from a decision-pack's docker/ directory with opencode installed.
264
+
265
+ This function:
266
+ 1. Builds the decision-pack's Dockerfile as a base image
267
+ 2. Creates a wrapper Dockerfile that adds opencode
268
+ 3. Builds the final image with opencode installed
269
+ 4. Removes previous image IDs if they became dangling
270
+
271
+ Parameters
272
+ ----------
273
+ config_dir : str
274
+ Path to the decision-pack config directory (contains docker/ subdirectory).
275
+ image_name : str
276
+ Name to tag the built image with.
277
+ opencode_version : str
278
+ Version of opencode to install (default: "latest").
279
+ on_output : Callable[[str], None] | None
280
+ Optional callback invoked for each line of build output.
281
+
282
+ Raises
283
+ ------
284
+ ValueError
285
+ If the docker/ directory doesn't exist or build fails.
286
+ """
287
+ docker_dir: Path = Path(config_dir) / "docker"
288
+
289
+ if not docker_dir.exists():
290
+ raise ValueError(f"docker/ directory not found in: {config_dir}")
291
+
292
+ base_image_name: str = f"{image_name}-base"
293
+
294
+ # Capture old image IDs so we can clean up dangling images after build
295
+ old_base_id: str | None = _get_image_id(base_image_name)
296
+ old_wrapper_id: str | None = _get_image_id(image_name)
297
+
298
+ # Step 1: Build the base image from decision-pack's Dockerfile
299
+ returncode, stderr = _run_docker_build(
300
+ ["docker", "build", "-t", base_image_name, str(docker_dir)],
301
+ on_output=on_output,
302
+ )
303
+
304
+ if returncode != 0:
305
+ raise ValueError(f"Docker build failed: {stderr}")
306
+
307
+ # Step 2: Create wrapper Dockerfile that adds opencode
308
+ if opencode_version == "latest":
309
+ opencode_package: str = "opencode-ai@latest"
310
+ else:
311
+ opencode_package = f"opencode-ai@{opencode_version}"
312
+
313
+ wrapper_dockerfile: str = OPENCODE_WRAPPER_DOCKERFILE.format(
314
+ base_image=base_image_name,
315
+ opencode_package=opencode_package,
316
+ )
317
+
318
+ # Step 3: Compute source hash for auto-rebuild detection
319
+ source_hash: str = compute_docker_dir_hash(docker_dir, opencode_version)
320
+
321
+ # Step 4: Build final image with opencode and source hash label
322
+ with tempfile.TemporaryDirectory() as tmpdir:
323
+ dockerfile_path: Path = Path(tmpdir) / "Dockerfile"
324
+ dockerfile_path.write_text(wrapper_dockerfile)
325
+
326
+ returncode, stderr = _run_docker_build(
327
+ [
328
+ "docker", "build",
329
+ "-t", image_name,
330
+ "--label", f"{SOURCE_HASH_LABEL}={source_hash}",
331
+ tmpdir,
332
+ ],
333
+ on_output=on_output,
334
+ )
335
+
336
+ if returncode != 0:
337
+ raise ValueError(f"Docker build (opencode wrapper) failed: {stderr}")
338
+
339
+ # Step 5: Remove old images if they became dangling after re-tagging
340
+ _remove_dangling_image(old_base_id, base_image_name)
341
+ _remove_dangling_image(old_wrapper_id, image_name)
342
+
343
+
344
+ def container_exists(container_name: str) -> bool:
345
+ """
346
+ Check if a Docker container exists (running or stopped).
347
+
348
+ Parameters
349
+ ----------
350
+ container_name : str
351
+ Name of the container to check.
352
+
353
+ Returns
354
+ -------
355
+ bool
356
+ True if container exists, False otherwise.
357
+ """
358
+ result: subprocess.CompletedProcess[str] = subprocess.run(
359
+ ["docker", "ps", "-a", "-q", "-f", f"name=^{container_name}$"],
360
+ capture_output=True,
361
+ text=True,
362
+ )
363
+ # docker ps -q returns container ID if found, empty string if not
364
+ return bool(result.stdout.strip())
365
+
366
+
367
+ def start_container(
368
+ image_name: str,
369
+ work_dir: str,
370
+ container_name: str,
371
+ env_file: str | None = None,
372
+ extra_env: dict[str, str] | None = None,
373
+ ) -> None:
374
+ """
375
+ Start a new Docker container with volume mounts.
376
+
377
+ The container runs in detached mode with `tail -f /dev/null` to keep it
378
+ alive for subsequent `docker exec` commands.
379
+
380
+ Parameters
381
+ ----------
382
+ image_name : str
383
+ Name of the Docker image to use.
384
+ work_dir : str
385
+ Path to the work directory to mount at /workspace.
386
+ container_name : str
387
+ Name to give the container.
388
+ env_file : str | None
389
+ Optional path to an environment file to pass to the container.
390
+ extra_env : dict[str, str] | None
391
+ Additional environment variables to pass via -e flags.
392
+
393
+ Raises
394
+ ------
395
+ ValueError
396
+ If the container already exists, env file not found, or fails to start.
397
+ """
398
+ if container_exists(container_name):
399
+ raise ValueError(f"Container already exists: {container_name}")
400
+
401
+ if env_file is not None:
402
+ env_path: Path = Path(env_file).resolve()
403
+ if not env_path.exists():
404
+ raise ValueError(f"Environment file not found: {env_file}")
405
+
406
+ work_path: Path = Path(work_dir).resolve()
407
+
408
+ # Build the docker run command
409
+ cmd: list[str] = [
410
+ "docker", "run",
411
+ "-d", # Detached mode
412
+ "--name", container_name, # Container name
413
+ "-v", f"{work_path}:/workspace", # Mount work_dir at /workspace
414
+ "-v", f"{work_path}/_opencode_logs:/_opencode_logs", # Mount logs at /_opencode_logs
415
+ "-w", "/workspace", # Set working directory
416
+ ]
417
+
418
+ # Add env file if provided
419
+ if env_file is not None:
420
+ cmd.extend(["--env-file", str(env_path)])
421
+
422
+ # Add extra environment variables
423
+ if extra_env:
424
+ for key, value in extra_env.items():
425
+ cmd.extend(["-e", f"{key}={value}"])
426
+
427
+ cmd.extend([
428
+ image_name,
429
+ "tail", "-f", "/dev/null", # Keep container running
430
+ ])
431
+
432
+ result: subprocess.CompletedProcess[str] = subprocess.run(
433
+ cmd,
434
+ capture_output=True,
435
+ text=True,
436
+ )
437
+
438
+ if result.returncode != 0:
439
+ raise ValueError(f"Failed to start container: {result.stderr}")
440
+
441
+
442
+ def exec_command(
443
+ container_name: str,
444
+ command: list[str],
445
+ timeout: int | None = None,
446
+ ) -> tuple[int, str, str]:
447
+ """
448
+ Execute a command inside a running container.
449
+
450
+ Parameters
451
+ ----------
452
+ container_name : str
453
+ Name of the running container.
454
+ command : list[str]
455
+ Command and arguments to execute.
456
+ timeout : int | None
457
+ Timeout in seconds. None means no timeout.
458
+
459
+ Returns
460
+ -------
461
+ tuple[int, str, str]
462
+ Tuple of (exit_code, stdout, stderr).
463
+ """
464
+ cmd: list[str] = ["docker", "exec", container_name] + command
465
+
466
+ result: subprocess.CompletedProcess[str] = subprocess.run(
467
+ cmd,
468
+ capture_output=True,
469
+ text=True,
470
+ timeout=timeout,
471
+ )
472
+
473
+ return result.returncode, result.stdout, result.stderr
474
+
475
+
476
+ def stop_container(container_name: str) -> None:
477
+ """
478
+ Stop and remove a Docker container.
479
+
480
+ Parameters
481
+ ----------
482
+ container_name : str
483
+ Name of the container to stop and remove.
484
+
485
+ Notes
486
+ -----
487
+ This function is idempotent - it silently succeeds if the container
488
+ doesn't exist or is already stopped.
489
+ """
490
+ # Stop the container (ignore errors if already stopped)
491
+ subprocess.run(
492
+ ["docker", "stop", container_name],
493
+ capture_output=True,
494
+ text=True,
495
+ )
496
+
497
+ # Remove the container (ignore errors if doesn't exist)
498
+ subprocess.run(
499
+ ["docker", "rm", container_name],
500
+ capture_output=True,
501
+ text=True,
502
+ )
503
+
504
+
505
+ def build_runner_script(
506
+ prompt_file: str,
507
+ model: str,
508
+ log_prefix: str,
509
+ ) -> str:
510
+ """
511
+ Build the bash runner script that runs opencode inside a container.
512
+
513
+ Parameters
514
+ ----------
515
+ prompt_file : str
516
+ Path to the prompt file inside the container.
517
+ model : str
518
+ The model to use.
519
+ log_prefix : str
520
+ Prefix for log files.
521
+
522
+ Returns
523
+ -------
524
+ str
525
+ The bash script content.
526
+ """
527
+ return f'''#!/bin/bash
528
+ set -o pipefail
529
+ prompt=$(cat {prompt_file})
530
+ opencode run --format json --log-level DEBUG --model "{model}" "$prompt" 2>&1 | tee /_opencode_logs/{log_prefix}.log
531
+ '''
532
+
533
+
534
+ def run_opencode(
535
+ container_name: str,
536
+ prompt: str,
537
+ model: str,
538
+ timeout: int | None = None,
539
+ log_prefix: str = "main",
540
+ ) -> tuple[int, str, str]:
541
+ """
542
+ Run opencode with a prompt inside a container, logging output to _opencode_logs.
543
+
544
+ Parameters
545
+ ----------
546
+ container_name : str
547
+ Name of the running container.
548
+ prompt : str
549
+ The prompt to send to opencode.
550
+ model : str
551
+ The model to use (e.g., "anthropic/claude-sonnet-4-5").
552
+ timeout : int | None
553
+ Timeout in seconds. None means no timeout.
554
+ log_prefix : str
555
+ Prefix for log files (default: "main").
556
+
557
+ Returns
558
+ -------
559
+ tuple[int, str, str]
560
+ Tuple of (exit_code, stdout, stderr).
561
+ """
562
+ # Write prompt to a file to avoid shell quoting issues
563
+ # (prompts can contain quotes, $, backticks, newlines, etc.)
564
+ # The file is written via docker exec with stdin - completely safe
565
+ prompt_file: str = "/.prompt.txt"
566
+
567
+ # Write prompt file using cat with stdin (bypasses all shell escaping)
568
+ write_result: subprocess.CompletedProcess[bytes] = subprocess.run(
569
+ ["docker", "exec", "-i", container_name, "sh", "-c", f"cat > {prompt_file}"],
570
+ input=prompt.encode(),
571
+ capture_output=True,
572
+ )
573
+ if write_result.returncode != 0:
574
+ return write_result.returncode, "", write_result.stderr.decode()
575
+
576
+ # Build the runner script that reads the prompt file and runs opencode
577
+ # This avoids any shell expansion of the prompt content
578
+ runner_script: str = build_runner_script(prompt_file, model, log_prefix)
579
+ runner_file: str = "/.run_opencode.sh"
580
+
581
+ write_runner: subprocess.CompletedProcess[bytes] = subprocess.run(
582
+ ["docker", "exec", "-i", container_name, "sh", "-c", f"cat > {runner_file} && chmod +x {runner_file}"],
583
+ input=runner_script.encode(),
584
+ capture_output=True,
585
+ )
586
+ if write_runner.returncode != 0:
587
+ return write_runner.returncode, "", write_runner.stderr.decode()
588
+
589
+ # Run the script
590
+ command: list[str] = ["bash", runner_file]
591
+
592
+ return exec_command(container_name, command, timeout=timeout)
dlab/js/__init__.py ADDED
File without changes