ccproxy-api 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.
Files changed (148) hide show
  1. ccproxy/__init__.py +4 -0
  2. ccproxy/__main__.py +7 -0
  3. ccproxy/_version.py +21 -0
  4. ccproxy/adapters/__init__.py +11 -0
  5. ccproxy/adapters/base.py +80 -0
  6. ccproxy/adapters/openai/__init__.py +43 -0
  7. ccproxy/adapters/openai/adapter.py +915 -0
  8. ccproxy/adapters/openai/models.py +412 -0
  9. ccproxy/adapters/openai/streaming.py +449 -0
  10. ccproxy/api/__init__.py +28 -0
  11. ccproxy/api/app.py +225 -0
  12. ccproxy/api/dependencies.py +140 -0
  13. ccproxy/api/middleware/__init__.py +11 -0
  14. ccproxy/api/middleware/auth.py +0 -0
  15. ccproxy/api/middleware/cors.py +55 -0
  16. ccproxy/api/middleware/errors.py +703 -0
  17. ccproxy/api/middleware/headers.py +51 -0
  18. ccproxy/api/middleware/logging.py +175 -0
  19. ccproxy/api/middleware/request_id.py +69 -0
  20. ccproxy/api/middleware/server_header.py +62 -0
  21. ccproxy/api/responses.py +84 -0
  22. ccproxy/api/routes/__init__.py +16 -0
  23. ccproxy/api/routes/claude.py +181 -0
  24. ccproxy/api/routes/health.py +489 -0
  25. ccproxy/api/routes/metrics.py +1033 -0
  26. ccproxy/api/routes/proxy.py +238 -0
  27. ccproxy/auth/__init__.py +75 -0
  28. ccproxy/auth/bearer.py +68 -0
  29. ccproxy/auth/credentials_adapter.py +93 -0
  30. ccproxy/auth/dependencies.py +229 -0
  31. ccproxy/auth/exceptions.py +79 -0
  32. ccproxy/auth/manager.py +102 -0
  33. ccproxy/auth/models.py +118 -0
  34. ccproxy/auth/oauth/__init__.py +26 -0
  35. ccproxy/auth/oauth/models.py +49 -0
  36. ccproxy/auth/oauth/routes.py +396 -0
  37. ccproxy/auth/oauth/storage.py +0 -0
  38. ccproxy/auth/storage/__init__.py +12 -0
  39. ccproxy/auth/storage/base.py +57 -0
  40. ccproxy/auth/storage/json_file.py +159 -0
  41. ccproxy/auth/storage/keyring.py +192 -0
  42. ccproxy/claude_sdk/__init__.py +20 -0
  43. ccproxy/claude_sdk/client.py +169 -0
  44. ccproxy/claude_sdk/converter.py +331 -0
  45. ccproxy/claude_sdk/options.py +120 -0
  46. ccproxy/cli/__init__.py +14 -0
  47. ccproxy/cli/commands/__init__.py +8 -0
  48. ccproxy/cli/commands/auth.py +553 -0
  49. ccproxy/cli/commands/config/__init__.py +14 -0
  50. ccproxy/cli/commands/config/commands.py +766 -0
  51. ccproxy/cli/commands/config/schema_commands.py +119 -0
  52. ccproxy/cli/commands/serve.py +630 -0
  53. ccproxy/cli/docker/__init__.py +34 -0
  54. ccproxy/cli/docker/adapter_factory.py +157 -0
  55. ccproxy/cli/docker/params.py +278 -0
  56. ccproxy/cli/helpers.py +144 -0
  57. ccproxy/cli/main.py +193 -0
  58. ccproxy/cli/options/__init__.py +14 -0
  59. ccproxy/cli/options/claude_options.py +216 -0
  60. ccproxy/cli/options/core_options.py +40 -0
  61. ccproxy/cli/options/security_options.py +48 -0
  62. ccproxy/cli/options/server_options.py +117 -0
  63. ccproxy/config/__init__.py +40 -0
  64. ccproxy/config/auth.py +154 -0
  65. ccproxy/config/claude.py +124 -0
  66. ccproxy/config/cors.py +79 -0
  67. ccproxy/config/discovery.py +87 -0
  68. ccproxy/config/docker_settings.py +265 -0
  69. ccproxy/config/loader.py +108 -0
  70. ccproxy/config/observability.py +158 -0
  71. ccproxy/config/pricing.py +88 -0
  72. ccproxy/config/reverse_proxy.py +31 -0
  73. ccproxy/config/scheduler.py +89 -0
  74. ccproxy/config/security.py +14 -0
  75. ccproxy/config/server.py +81 -0
  76. ccproxy/config/settings.py +534 -0
  77. ccproxy/config/validators.py +231 -0
  78. ccproxy/core/__init__.py +274 -0
  79. ccproxy/core/async_utils.py +675 -0
  80. ccproxy/core/constants.py +97 -0
  81. ccproxy/core/errors.py +256 -0
  82. ccproxy/core/http.py +328 -0
  83. ccproxy/core/http_transformers.py +428 -0
  84. ccproxy/core/interfaces.py +247 -0
  85. ccproxy/core/logging.py +189 -0
  86. ccproxy/core/middleware.py +114 -0
  87. ccproxy/core/proxy.py +143 -0
  88. ccproxy/core/system.py +38 -0
  89. ccproxy/core/transformers.py +259 -0
  90. ccproxy/core/types.py +129 -0
  91. ccproxy/core/validators.py +288 -0
  92. ccproxy/docker/__init__.py +67 -0
  93. ccproxy/docker/adapter.py +588 -0
  94. ccproxy/docker/docker_path.py +207 -0
  95. ccproxy/docker/middleware.py +103 -0
  96. ccproxy/docker/models.py +228 -0
  97. ccproxy/docker/protocol.py +192 -0
  98. ccproxy/docker/stream_process.py +264 -0
  99. ccproxy/docker/validators.py +173 -0
  100. ccproxy/models/__init__.py +123 -0
  101. ccproxy/models/errors.py +42 -0
  102. ccproxy/models/messages.py +243 -0
  103. ccproxy/models/requests.py +85 -0
  104. ccproxy/models/responses.py +227 -0
  105. ccproxy/models/types.py +102 -0
  106. ccproxy/observability/__init__.py +51 -0
  107. ccproxy/observability/access_logger.py +400 -0
  108. ccproxy/observability/context.py +447 -0
  109. ccproxy/observability/metrics.py +539 -0
  110. ccproxy/observability/pushgateway.py +366 -0
  111. ccproxy/observability/sse_events.py +303 -0
  112. ccproxy/observability/stats_printer.py +755 -0
  113. ccproxy/observability/storage/__init__.py +1 -0
  114. ccproxy/observability/storage/duckdb_simple.py +665 -0
  115. ccproxy/observability/storage/models.py +55 -0
  116. ccproxy/pricing/__init__.py +19 -0
  117. ccproxy/pricing/cache.py +212 -0
  118. ccproxy/pricing/loader.py +267 -0
  119. ccproxy/pricing/models.py +106 -0
  120. ccproxy/pricing/updater.py +309 -0
  121. ccproxy/scheduler/__init__.py +39 -0
  122. ccproxy/scheduler/core.py +335 -0
  123. ccproxy/scheduler/exceptions.py +34 -0
  124. ccproxy/scheduler/manager.py +186 -0
  125. ccproxy/scheduler/registry.py +150 -0
  126. ccproxy/scheduler/tasks.py +484 -0
  127. ccproxy/services/__init__.py +10 -0
  128. ccproxy/services/claude_sdk_service.py +614 -0
  129. ccproxy/services/credentials/__init__.py +55 -0
  130. ccproxy/services/credentials/config.py +105 -0
  131. ccproxy/services/credentials/manager.py +562 -0
  132. ccproxy/services/credentials/oauth_client.py +482 -0
  133. ccproxy/services/proxy_service.py +1536 -0
  134. ccproxy/static/.keep +0 -0
  135. ccproxy/testing/__init__.py +34 -0
  136. ccproxy/testing/config.py +148 -0
  137. ccproxy/testing/content_generation.py +197 -0
  138. ccproxy/testing/mock_responses.py +262 -0
  139. ccproxy/testing/response_handlers.py +161 -0
  140. ccproxy/testing/scenarios.py +241 -0
  141. ccproxy/utils/__init__.py +6 -0
  142. ccproxy/utils/cost_calculator.py +210 -0
  143. ccproxy/utils/streaming_metrics.py +199 -0
  144. ccproxy_api-0.1.0.dist-info/METADATA +253 -0
  145. ccproxy_api-0.1.0.dist-info/RECORD +148 -0
  146. ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
  147. ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
  148. ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,67 @@
1
+ """Docker integration module for Claude Code Proxy.
2
+
3
+ This module provides a comprehensive Docker integration system with support for:
4
+ - Protocol-based adapter design for better testing and flexibility
5
+ - Enhanced error handling with contextual information
6
+ - Real-time output streaming with middleware support
7
+ - Comprehensive port publishing (including host interface binding)
8
+ - Unified path management using DockerPath
9
+ - User context management with proper UID/GID mapping
10
+ """
11
+
12
+ from .adapter import DockerAdapter, create_docker_adapter
13
+ from .docker_path import DockerPath, DockerPathSet
14
+ from .middleware import (
15
+ LoggerOutputMiddleware,
16
+ create_chained_docker_middleware,
17
+ create_logger_middleware,
18
+ )
19
+ from .models import DockerUserContext
20
+ from .protocol import (
21
+ DockerAdapterProtocol,
22
+ DockerEnv,
23
+ DockerPortSpec,
24
+ DockerResult,
25
+ DockerVolume,
26
+ )
27
+ from .stream_process import (
28
+ ChainedOutputMiddleware,
29
+ DefaultOutputMiddleware,
30
+ OutputMiddleware,
31
+ ProcessResult,
32
+ create_chained_middleware,
33
+ run_command,
34
+ )
35
+ from .validators import create_docker_error, validate_port_spec
36
+
37
+
38
+ __all__ = [
39
+ # Main adapter classes
40
+ "DockerAdapter",
41
+ "DockerAdapterProtocol",
42
+ # Path management
43
+ "DockerPath",
44
+ "DockerPathSet",
45
+ # User context
46
+ "DockerUserContext",
47
+ # Type aliases
48
+ "DockerEnv",
49
+ "DockerPortSpec",
50
+ "DockerResult",
51
+ "DockerVolume",
52
+ # Streaming and middleware
53
+ "OutputMiddleware",
54
+ "DefaultOutputMiddleware",
55
+ "ChainedOutputMiddleware",
56
+ "LoggerOutputMiddleware",
57
+ "ProcessResult",
58
+ # Factory functions
59
+ "create_docker_adapter",
60
+ "create_docker_error",
61
+ "create_logger_middleware",
62
+ "create_chained_docker_middleware",
63
+ "create_chained_middleware",
64
+ # Utility functions
65
+ "run_command",
66
+ "validate_port_spec",
67
+ ]
@@ -0,0 +1,588 @@
1
+ """Docker adapter for container operations."""
2
+
3
+ import asyncio
4
+ import os
5
+ import shlex
6
+ from pathlib import Path
7
+ from typing import cast
8
+
9
+ from structlog import get_logger
10
+
11
+ from .middleware import LoggerOutputMiddleware
12
+ from .models import DockerUserContext
13
+ from .protocol import (
14
+ DockerAdapterProtocol,
15
+ DockerEnv,
16
+ DockerPortSpec,
17
+ DockerVolume,
18
+ )
19
+ from .stream_process import (
20
+ OutputMiddleware,
21
+ ProcessResult,
22
+ T,
23
+ run_command,
24
+ )
25
+ from .validators import create_docker_error, validate_port_spec
26
+
27
+
28
+ logger = get_logger(__name__)
29
+
30
+
31
+ class DockerAdapter:
32
+ """Implementation of Docker adapter."""
33
+
34
+ async def _needs_sudo(self) -> bool:
35
+ """Check if Docker requires sudo by testing docker info command."""
36
+ try:
37
+ process = await asyncio.create_subprocess_exec(
38
+ "docker",
39
+ "info",
40
+ stdout=asyncio.subprocess.PIPE,
41
+ stderr=asyncio.subprocess.PIPE,
42
+ )
43
+ _, stderr = await process.communicate()
44
+ if process.returncode == 0:
45
+ return False
46
+ # Check if error suggests permission issues
47
+ stderr_text = stderr.decode() if stderr else ""
48
+ return (
49
+ "permission denied" in stderr_text.lower()
50
+ or "dial unix" in stderr_text.lower()
51
+ or "connect: permission denied" in stderr_text.lower()
52
+ )
53
+ except Exception:
54
+ return False
55
+
56
+ async def is_available(self) -> bool:
57
+ """Check if Docker is available on the system."""
58
+ docker_cmd = ["docker", "--version"]
59
+ cmd_str = " ".join(docker_cmd)
60
+
61
+ try:
62
+ process = await asyncio.create_subprocess_exec(
63
+ *docker_cmd,
64
+ stdout=asyncio.subprocess.PIPE,
65
+ stderr=asyncio.subprocess.PIPE,
66
+ )
67
+ stdout, stderr = await process.communicate()
68
+
69
+ if process.returncode == 0:
70
+ docker_version = stdout.decode().strip()
71
+ logger.debug("docker_available", version=docker_version)
72
+ return True
73
+ else:
74
+ stderr_text = stderr.decode() if stderr else "unknown error"
75
+ logger.warning(
76
+ "docker_command_failed", command=cmd_str, error=stderr_text
77
+ )
78
+ return False
79
+
80
+ except FileNotFoundError:
81
+ logger.warning("docker_executable_not_found")
82
+ return False
83
+
84
+ except Exception as e:
85
+ logger.warning("docker_availability_check_error", error=str(e))
86
+ return False
87
+
88
+ async def _run_with_sudo_fallback(
89
+ self, docker_cmd: list[str], middleware: OutputMiddleware[T]
90
+ ) -> ProcessResult[T]:
91
+ # Try without sudo first
92
+ try:
93
+ result = await run_command(docker_cmd, middleware)
94
+ return result
95
+ except Exception as e:
96
+ # Check if this might be a permission error
97
+ error_text = str(e).lower()
98
+ if any(
99
+ phrase in error_text
100
+ for phrase in [
101
+ "permission denied",
102
+ "dial unix",
103
+ "connect: permission denied",
104
+ ]
105
+ ):
106
+ logger.info("docker_permission_denied_using_sudo")
107
+ sudo_cmd = ["sudo"] + docker_cmd
108
+ return await run_command(sudo_cmd, middleware)
109
+ # Re-raise if not a permission error
110
+ raise
111
+
112
+ async def run_container(
113
+ self,
114
+ image: str,
115
+ volumes: list[DockerVolume],
116
+ environment: DockerEnv,
117
+ command: list[str] | None = None,
118
+ middleware: OutputMiddleware[T] | None = None,
119
+ user_context: DockerUserContext | None = None,
120
+ entrypoint: str | None = None,
121
+ ports: list[DockerPortSpec] | None = None,
122
+ ) -> ProcessResult[T]:
123
+ """Run a Docker container with specified configuration."""
124
+
125
+ docker_cmd = ["docker", "run", "--rm"]
126
+
127
+ # Add user context if provided and should be used
128
+ if user_context and user_context.should_use_user_mapping():
129
+ docker_user_flag = user_context.get_docker_user_flag()
130
+ docker_cmd.extend(["--user", docker_user_flag])
131
+ logger.debug("docker_user_mapping", user_flag=docker_user_flag)
132
+
133
+ # Add custom entrypoint if specified
134
+ if entrypoint:
135
+ docker_cmd.extend(["--entrypoint", entrypoint])
136
+ logger.debug("docker_custom_entrypoint", entrypoint=entrypoint)
137
+
138
+ # Add port publishing if specified
139
+ if ports:
140
+ for port_spec in ports:
141
+ validated_port = validate_port_spec(port_spec)
142
+ docker_cmd.extend(["-p", validated_port])
143
+ logger.debug("docker_port_mapping", port=validated_port)
144
+
145
+ # Add volume mounts
146
+ for host_path, container_path in volumes:
147
+ docker_cmd.extend(["-v", f"{host_path}:{container_path}"])
148
+
149
+ # Add environment variables
150
+ for key, value in environment.items():
151
+ docker_cmd.extend(["-e", f"{key}={value}"])
152
+
153
+ # Add image
154
+ docker_cmd.append(image)
155
+
156
+ # Add command if specified
157
+ if command:
158
+ docker_cmd.extend(command)
159
+
160
+ cmd_str = " ".join(shlex.quote(arg) for arg in docker_cmd)
161
+ logger.debug("docker_command", command=cmd_str)
162
+
163
+ try:
164
+ if middleware is None:
165
+ # Cast is needed because T is unbound at this point
166
+ middleware = cast(OutputMiddleware[T], LoggerOutputMiddleware(logger))
167
+
168
+ # Try with sudo fallback if needed
169
+ result = await self._run_with_sudo_fallback(docker_cmd, middleware)
170
+
171
+ return result
172
+
173
+ except FileNotFoundError as e:
174
+ error = create_docker_error(f"Docker executable not found: {e}", cmd_str, e)
175
+ logger.error("docker_executable_not_found", error=str(e))
176
+ raise error from e
177
+
178
+ except Exception as e:
179
+ error = create_docker_error(
180
+ f"Failed to run Docker container: {e}",
181
+ cmd_str,
182
+ e,
183
+ {
184
+ "image": image,
185
+ "volumes_count": len(volumes),
186
+ "env_vars_count": len(environment),
187
+ },
188
+ )
189
+ logger.error("docker_container_run_error", error=str(e))
190
+ raise error from e
191
+
192
+ async def run(
193
+ self,
194
+ image: str,
195
+ volumes: list[DockerVolume],
196
+ environment: DockerEnv,
197
+ command: list[str] | None = None,
198
+ middleware: OutputMiddleware[T] | None = None,
199
+ user_context: DockerUserContext | None = None,
200
+ entrypoint: str | None = None,
201
+ ports: list[DockerPortSpec] | None = None,
202
+ ) -> ProcessResult[T]:
203
+ """Run a Docker container with specified configuration.
204
+
205
+ This is an alias for run_container method.
206
+ """
207
+ return await self.run_container(
208
+ image=image,
209
+ volumes=volumes,
210
+ environment=environment,
211
+ command=command,
212
+ middleware=middleware,
213
+ user_context=user_context,
214
+ entrypoint=entrypoint,
215
+ ports=ports,
216
+ )
217
+
218
+ def exec_container(
219
+ self,
220
+ image: str,
221
+ volumes: list[DockerVolume],
222
+ environment: DockerEnv,
223
+ command: list[str] | None = None,
224
+ user_context: DockerUserContext | None = None,
225
+ entrypoint: str | None = None,
226
+ ports: list[DockerPortSpec] | None = None,
227
+ ) -> None:
228
+ """Execute a Docker container by replacing the current process.
229
+
230
+ This method builds the Docker command and replaces the current process
231
+ with the Docker command using os.execvp, effectively handing over control to Docker.
232
+
233
+ Args:
234
+ image: Docker image name/tag to run
235
+ volumes: List of volume mounts (host_path, container_path)
236
+ environment: Dictionary of environment variables
237
+ command: Optional command to run in the container
238
+ user_context: Optional user context for Docker --user flag
239
+ entrypoint: Optional custom entrypoint to override the image's default
240
+ ports: Optional port specifications (e.g., ["8080:80", "localhost:9000:9000"])
241
+
242
+ Raises:
243
+ DockerError: If the container fails to execute
244
+ OSError: If the command cannot be executed
245
+ """
246
+ docker_cmd = ["docker", "run", "--rm", "-it"]
247
+
248
+ # Add user context if provided and should be used
249
+ if user_context and user_context.should_use_user_mapping():
250
+ docker_user_flag = user_context.get_docker_user_flag()
251
+ docker_cmd.extend(["--user", docker_user_flag])
252
+ logger.debug("docker_user_mapping", user_flag=docker_user_flag)
253
+
254
+ # Add custom entrypoint if specified
255
+ if entrypoint:
256
+ docker_cmd.extend(["--entrypoint", entrypoint])
257
+ logger.debug("docker_custom_entrypoint", entrypoint=entrypoint)
258
+
259
+ # Add port publishing if specified
260
+ if ports:
261
+ for port_spec in ports:
262
+ validated_port = validate_port_spec(port_spec)
263
+ docker_cmd.extend(["-p", validated_port])
264
+ logger.debug("docker_port_mapping", port=validated_port)
265
+
266
+ # Add volume mounts
267
+ for host_path, container_path in volumes:
268
+ docker_cmd.extend(["-v", f"{host_path}:{container_path}"])
269
+
270
+ # Add environment variables
271
+ for key, value in environment.items():
272
+ docker_cmd.extend(["-e", f"{key}={value}"])
273
+
274
+ # Add image
275
+ docker_cmd.append(image)
276
+
277
+ # Add command if specified
278
+ if command:
279
+ docker_cmd.extend(command)
280
+
281
+ cmd_str = " ".join(shlex.quote(arg) for arg in docker_cmd)
282
+ logger.info("docker_execvp", command=cmd_str)
283
+
284
+ try:
285
+ # Check if we need sudo (without running the actual command)
286
+ # Note: We can't use await here since this method replaces the process
287
+ # Use a simple check instead
288
+ try:
289
+ import subprocess
290
+
291
+ subprocess.run(
292
+ ["docker", "info"], check=True, capture_output=True, text=True
293
+ )
294
+ needs_sudo = False
295
+ except subprocess.CalledProcessError as e:
296
+ needs_sudo = e.stderr and (
297
+ "permission denied" in e.stderr.lower()
298
+ or "dial unix" in e.stderr.lower()
299
+ or "connect: permission denied" in e.stderr.lower()
300
+ )
301
+ except Exception:
302
+ needs_sudo = False
303
+
304
+ if needs_sudo:
305
+ logger.info("docker_using_sudo_for_execution")
306
+ docker_cmd = ["sudo"] + docker_cmd
307
+
308
+ # Replace current process with Docker command
309
+ os.execvp(docker_cmd[0], docker_cmd)
310
+
311
+ except FileNotFoundError as e:
312
+ error = create_docker_error(f"Docker executable not found: {e}", cmd_str, e)
313
+ logger.error("docker_execvp_executable_not_found", error=str(e))
314
+ raise error from e
315
+
316
+ except OSError as e:
317
+ error = create_docker_error(
318
+ f"Failed to execute Docker command: {e}", cmd_str, e
319
+ )
320
+ logger.error("docker_execvp_os_error", error=str(e))
321
+ raise error from e
322
+
323
+ except Exception as e:
324
+ error = create_docker_error(
325
+ f"Unexpected error executing Docker container: {e}",
326
+ cmd_str,
327
+ e,
328
+ {
329
+ "image": image,
330
+ "volumes_count": len(volumes),
331
+ "env_vars_count": len(environment),
332
+ },
333
+ )
334
+ logger.error("docker_execvp_unexpected_error", error=str(e))
335
+ raise error from e
336
+
337
+ async def build_image(
338
+ self,
339
+ dockerfile_dir: Path,
340
+ image_name: str,
341
+ image_tag: str = "latest",
342
+ no_cache: bool = False,
343
+ middleware: OutputMiddleware[T] | None = None,
344
+ ) -> ProcessResult[T]:
345
+ """Build a Docker image from a Dockerfile."""
346
+
347
+ image_full_name = f"{image_name}:{image_tag}"
348
+
349
+ # Check Docker availability
350
+ if not await self.is_available():
351
+ error = create_docker_error(
352
+ "Docker is not available or not properly installed",
353
+ None,
354
+ None,
355
+ {"image": image_full_name},
356
+ )
357
+ logger.error("docker_not_available_for_build", image=image_full_name)
358
+ raise error
359
+
360
+ # Validate dockerfile directory
361
+ dockerfile_dir = Path(dockerfile_dir).resolve()
362
+ if not dockerfile_dir.exists() or not dockerfile_dir.is_dir():
363
+ error = create_docker_error(
364
+ f"Dockerfile directory not found: {dockerfile_dir}",
365
+ None,
366
+ None,
367
+ {"dockerfile_dir": str(dockerfile_dir), "image": image_full_name},
368
+ )
369
+ logger.error(
370
+ "dockerfile_directory_invalid", dockerfile_dir=str(dockerfile_dir)
371
+ )
372
+ raise error
373
+
374
+ # Check for Dockerfile
375
+ dockerfile_path = dockerfile_dir / "Dockerfile"
376
+ if not dockerfile_path.exists():
377
+ error = create_docker_error(
378
+ f"Dockerfile not found: {dockerfile_path}",
379
+ None,
380
+ None,
381
+ {"dockerfile_path": str(dockerfile_path), "image": image_full_name},
382
+ )
383
+ logger.error("dockerfile_not_found", dockerfile_path=str(dockerfile_path))
384
+ raise error
385
+
386
+ # Build the Docker command
387
+ docker_cmd = [
388
+ "docker",
389
+ "build",
390
+ "-t",
391
+ image_full_name,
392
+ ]
393
+
394
+ if no_cache:
395
+ docker_cmd.append("--no-cache")
396
+
397
+ docker_cmd.append(str(dockerfile_dir))
398
+
399
+ # Format command for logging
400
+ cmd_str = " ".join(shlex.quote(arg) for arg in docker_cmd)
401
+ logger.info("docker_build_starting", image=image_full_name)
402
+ logger.debug("docker_command", command=cmd_str)
403
+
404
+ try:
405
+ if middleware is None:
406
+ # Cast is needed because T is unbound at this point
407
+ middleware = cast(OutputMiddleware[T], LoggerOutputMiddleware(logger))
408
+
409
+ result = await self._run_with_sudo_fallback(docker_cmd, middleware)
410
+
411
+ return result
412
+
413
+ except FileNotFoundError as e:
414
+ error = create_docker_error(f"Docker executable not found: {e}", cmd_str, e)
415
+ logger.error("docker_build_executable_not_found", error=str(e))
416
+ raise error from e
417
+
418
+ except Exception as e:
419
+ error = create_docker_error(
420
+ f"Unexpected error building Docker image: {e}",
421
+ cmd_str,
422
+ e,
423
+ {"image": image_full_name, "dockerfile_dir": str(dockerfile_dir)},
424
+ )
425
+
426
+ logger.error(
427
+ "docker_build_unexpected_error", image=image_full_name, error=str(e)
428
+ )
429
+ raise error from e
430
+
431
+ async def image_exists(self, image_name: str, image_tag: str = "latest") -> bool:
432
+ """Check if a Docker image exists locally."""
433
+ image_full_name = f"{image_name}:{image_tag}"
434
+
435
+ # Check Docker availability
436
+ if not await self.is_available():
437
+ logger.warning(
438
+ "docker_not_available_for_image_check", image=image_full_name
439
+ )
440
+ return False
441
+
442
+ # Build the Docker command to check image existence
443
+ docker_cmd = ["docker", "inspect", image_full_name]
444
+ cmd_str = " ".join(shlex.quote(arg) for arg in docker_cmd)
445
+
446
+ try:
447
+ # Run Docker inspect command
448
+ process = await asyncio.create_subprocess_exec(
449
+ *docker_cmd,
450
+ stdout=asyncio.subprocess.PIPE,
451
+ stderr=asyncio.subprocess.PIPE,
452
+ )
453
+ _, stderr = await process.communicate()
454
+
455
+ if process.returncode == 0:
456
+ logger.debug("docker_image_exists", image=image_full_name)
457
+ return True
458
+
459
+ # Check if this is a permission error, try with sudo
460
+ stderr_text = stderr.decode() if stderr else ""
461
+ if any(
462
+ phrase in stderr_text.lower()
463
+ for phrase in [
464
+ "permission denied",
465
+ "dial unix",
466
+ "connect: permission denied",
467
+ ]
468
+ ):
469
+ try:
470
+ logger.debug("docker_image_check_permission_denied_using_sudo")
471
+ sudo_cmd = ["sudo"] + docker_cmd
472
+ sudo_process = await asyncio.create_subprocess_exec(
473
+ *sudo_cmd,
474
+ stdout=asyncio.subprocess.PIPE,
475
+ stderr=asyncio.subprocess.PIPE,
476
+ )
477
+ await sudo_process.communicate()
478
+ if sudo_process.returncode == 0:
479
+ logger.debug(
480
+ "docker_image_exists_with_sudo", image=image_full_name
481
+ )
482
+ return True
483
+ else:
484
+ # Image doesn't exist even with sudo
485
+ logger.debug(
486
+ "docker_image_does_not_exist", image=image_full_name
487
+ )
488
+ return False
489
+ except Exception:
490
+ # Image doesn't exist even with sudo
491
+ logger.debug("Docker image does not exist: %s", image_full_name)
492
+ return False
493
+ else:
494
+ # Image doesn't exist (inspect returns non-zero exit code)
495
+ logger.debug("Docker image does not exist: %s", image_full_name)
496
+ return False
497
+
498
+ except FileNotFoundError:
499
+ logger.warning("docker_image_check_executable_not_found")
500
+ return False
501
+
502
+ except Exception as e:
503
+ logger.warning("docker_image_check_unexpected_error", error=str(e))
504
+ return False
505
+
506
+ async def pull_image(
507
+ self,
508
+ image_name: str,
509
+ image_tag: str = "latest",
510
+ middleware: OutputMiddleware[T] | None = None,
511
+ ) -> ProcessResult[T]:
512
+ """Pull a Docker image from registry."""
513
+
514
+ image_full_name = f"{image_name}:{image_tag}"
515
+
516
+ # Check Docker availability
517
+ if not await self.is_available():
518
+ error = create_docker_error(
519
+ "Docker is not available or not properly installed",
520
+ None,
521
+ None,
522
+ {"image": image_full_name},
523
+ )
524
+ logger.error("docker_not_available_for_pull", image=image_full_name)
525
+ raise error
526
+
527
+ # Build the Docker command
528
+ docker_cmd = ["docker", "pull", image_full_name]
529
+
530
+ # Format command for logging
531
+ cmd_str = " ".join(shlex.quote(arg) for arg in docker_cmd)
532
+ logger.info("docker_pull_starting", image=image_full_name)
533
+ logger.debug("docker_command", command=cmd_str)
534
+
535
+ try:
536
+ if middleware is None:
537
+ # Cast is needed because T is unbound at this point
538
+ middleware = cast(OutputMiddleware[T], LoggerOutputMiddleware(logger))
539
+
540
+ result = await self._run_with_sudo_fallback(docker_cmd, middleware)
541
+
542
+ return result
543
+
544
+ except FileNotFoundError as e:
545
+ error = create_docker_error(f"Docker executable not found: {e}", cmd_str, e)
546
+ logger.error("docker_pull_executable_not_found", error=str(e))
547
+ raise error from e
548
+
549
+ except Exception as e:
550
+ error = create_docker_error(
551
+ f"Unexpected error pulling Docker image: {e}",
552
+ cmd_str,
553
+ e,
554
+ {"image": image_full_name},
555
+ )
556
+
557
+ logger.error(
558
+ "docker_pull_unexpected_error", image=image_full_name, error=str(e)
559
+ )
560
+ raise error from e
561
+
562
+
563
+ def create_docker_adapter(
564
+ image: str | None = None,
565
+ volumes: list[DockerVolume] | None = None,
566
+ environment: DockerEnv | None = None,
567
+ additional_args: list[str] | None = None,
568
+ user_context: DockerUserContext | None = None,
569
+ ) -> DockerAdapterProtocol:
570
+ """
571
+ Factory function to create a DockerAdapter instance.
572
+
573
+ Args:
574
+ image: Docker image to use (optional)
575
+ volumes: Optional list of volume mappings
576
+ environment: Optional environment variables
577
+ additional_args: Optional additional Docker arguments
578
+ user_context: Optional user context for container
579
+
580
+ Returns:
581
+ Configured DockerAdapter instance
582
+
583
+ Example:
584
+ >>> adapter = create_docker_adapter()
585
+ >>> if adapter.is_available():
586
+ ... adapter.run_container("ubuntu:latest", [], {})
587
+ """
588
+ return DockerAdapter()