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.
- ccproxy/__init__.py +4 -0
- ccproxy/__main__.py +7 -0
- ccproxy/_version.py +21 -0
- ccproxy/adapters/__init__.py +11 -0
- ccproxy/adapters/base.py +80 -0
- ccproxy/adapters/openai/__init__.py +43 -0
- ccproxy/adapters/openai/adapter.py +915 -0
- ccproxy/adapters/openai/models.py +412 -0
- ccproxy/adapters/openai/streaming.py +449 -0
- ccproxy/api/__init__.py +28 -0
- ccproxy/api/app.py +225 -0
- ccproxy/api/dependencies.py +140 -0
- ccproxy/api/middleware/__init__.py +11 -0
- ccproxy/api/middleware/auth.py +0 -0
- ccproxy/api/middleware/cors.py +55 -0
- ccproxy/api/middleware/errors.py +703 -0
- ccproxy/api/middleware/headers.py +51 -0
- ccproxy/api/middleware/logging.py +175 -0
- ccproxy/api/middleware/request_id.py +69 -0
- ccproxy/api/middleware/server_header.py +62 -0
- ccproxy/api/responses.py +84 -0
- ccproxy/api/routes/__init__.py +16 -0
- ccproxy/api/routes/claude.py +181 -0
- ccproxy/api/routes/health.py +489 -0
- ccproxy/api/routes/metrics.py +1033 -0
- ccproxy/api/routes/proxy.py +238 -0
- ccproxy/auth/__init__.py +75 -0
- ccproxy/auth/bearer.py +68 -0
- ccproxy/auth/credentials_adapter.py +93 -0
- ccproxy/auth/dependencies.py +229 -0
- ccproxy/auth/exceptions.py +79 -0
- ccproxy/auth/manager.py +102 -0
- ccproxy/auth/models.py +118 -0
- ccproxy/auth/oauth/__init__.py +26 -0
- ccproxy/auth/oauth/models.py +49 -0
- ccproxy/auth/oauth/routes.py +396 -0
- ccproxy/auth/oauth/storage.py +0 -0
- ccproxy/auth/storage/__init__.py +12 -0
- ccproxy/auth/storage/base.py +57 -0
- ccproxy/auth/storage/json_file.py +159 -0
- ccproxy/auth/storage/keyring.py +192 -0
- ccproxy/claude_sdk/__init__.py +20 -0
- ccproxy/claude_sdk/client.py +169 -0
- ccproxy/claude_sdk/converter.py +331 -0
- ccproxy/claude_sdk/options.py +120 -0
- ccproxy/cli/__init__.py +14 -0
- ccproxy/cli/commands/__init__.py +8 -0
- ccproxy/cli/commands/auth.py +553 -0
- ccproxy/cli/commands/config/__init__.py +14 -0
- ccproxy/cli/commands/config/commands.py +766 -0
- ccproxy/cli/commands/config/schema_commands.py +119 -0
- ccproxy/cli/commands/serve.py +630 -0
- ccproxy/cli/docker/__init__.py +34 -0
- ccproxy/cli/docker/adapter_factory.py +157 -0
- ccproxy/cli/docker/params.py +278 -0
- ccproxy/cli/helpers.py +144 -0
- ccproxy/cli/main.py +193 -0
- ccproxy/cli/options/__init__.py +14 -0
- ccproxy/cli/options/claude_options.py +216 -0
- ccproxy/cli/options/core_options.py +40 -0
- ccproxy/cli/options/security_options.py +48 -0
- ccproxy/cli/options/server_options.py +117 -0
- ccproxy/config/__init__.py +40 -0
- ccproxy/config/auth.py +154 -0
- ccproxy/config/claude.py +124 -0
- ccproxy/config/cors.py +79 -0
- ccproxy/config/discovery.py +87 -0
- ccproxy/config/docker_settings.py +265 -0
- ccproxy/config/loader.py +108 -0
- ccproxy/config/observability.py +158 -0
- ccproxy/config/pricing.py +88 -0
- ccproxy/config/reverse_proxy.py +31 -0
- ccproxy/config/scheduler.py +89 -0
- ccproxy/config/security.py +14 -0
- ccproxy/config/server.py +81 -0
- ccproxy/config/settings.py +534 -0
- ccproxy/config/validators.py +231 -0
- ccproxy/core/__init__.py +274 -0
- ccproxy/core/async_utils.py +675 -0
- ccproxy/core/constants.py +97 -0
- ccproxy/core/errors.py +256 -0
- ccproxy/core/http.py +328 -0
- ccproxy/core/http_transformers.py +428 -0
- ccproxy/core/interfaces.py +247 -0
- ccproxy/core/logging.py +189 -0
- ccproxy/core/middleware.py +114 -0
- ccproxy/core/proxy.py +143 -0
- ccproxy/core/system.py +38 -0
- ccproxy/core/transformers.py +259 -0
- ccproxy/core/types.py +129 -0
- ccproxy/core/validators.py +288 -0
- ccproxy/docker/__init__.py +67 -0
- ccproxy/docker/adapter.py +588 -0
- ccproxy/docker/docker_path.py +207 -0
- ccproxy/docker/middleware.py +103 -0
- ccproxy/docker/models.py +228 -0
- ccproxy/docker/protocol.py +192 -0
- ccproxy/docker/stream_process.py +264 -0
- ccproxy/docker/validators.py +173 -0
- ccproxy/models/__init__.py +123 -0
- ccproxy/models/errors.py +42 -0
- ccproxy/models/messages.py +243 -0
- ccproxy/models/requests.py +85 -0
- ccproxy/models/responses.py +227 -0
- ccproxy/models/types.py +102 -0
- ccproxy/observability/__init__.py +51 -0
- ccproxy/observability/access_logger.py +400 -0
- ccproxy/observability/context.py +447 -0
- ccproxy/observability/metrics.py +539 -0
- ccproxy/observability/pushgateway.py +366 -0
- ccproxy/observability/sse_events.py +303 -0
- ccproxy/observability/stats_printer.py +755 -0
- ccproxy/observability/storage/__init__.py +1 -0
- ccproxy/observability/storage/duckdb_simple.py +665 -0
- ccproxy/observability/storage/models.py +55 -0
- ccproxy/pricing/__init__.py +19 -0
- ccproxy/pricing/cache.py +212 -0
- ccproxy/pricing/loader.py +267 -0
- ccproxy/pricing/models.py +106 -0
- ccproxy/pricing/updater.py +309 -0
- ccproxy/scheduler/__init__.py +39 -0
- ccproxy/scheduler/core.py +335 -0
- ccproxy/scheduler/exceptions.py +34 -0
- ccproxy/scheduler/manager.py +186 -0
- ccproxy/scheduler/registry.py +150 -0
- ccproxy/scheduler/tasks.py +484 -0
- ccproxy/services/__init__.py +10 -0
- ccproxy/services/claude_sdk_service.py +614 -0
- ccproxy/services/credentials/__init__.py +55 -0
- ccproxy/services/credentials/config.py +105 -0
- ccproxy/services/credentials/manager.py +562 -0
- ccproxy/services/credentials/oauth_client.py +482 -0
- ccproxy/services/proxy_service.py +1536 -0
- ccproxy/static/.keep +0 -0
- ccproxy/testing/__init__.py +34 -0
- ccproxy/testing/config.py +148 -0
- ccproxy/testing/content_generation.py +197 -0
- ccproxy/testing/mock_responses.py +262 -0
- ccproxy/testing/response_handlers.py +161 -0
- ccproxy/testing/scenarios.py +241 -0
- ccproxy/utils/__init__.py +6 -0
- ccproxy/utils/cost_calculator.py +210 -0
- ccproxy/utils/streaming_metrics.py +199 -0
- ccproxy_api-0.1.0.dist-info/METADATA +253 -0
- ccproxy_api-0.1.0.dist-info/RECORD +148 -0
- ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
- ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|