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,192 @@
1
+ """Protocol definition for Docker operations."""
2
+
3
+ from collections.abc import Awaitable
4
+ from pathlib import Path
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Any,
8
+ Protocol,
9
+ TypeAlias,
10
+ TypeVar,
11
+ runtime_checkable,
12
+ )
13
+
14
+ from .models import DockerUserContext
15
+ from .stream_process import OutputMiddleware, ProcessResult, T
16
+
17
+
18
+ # Type aliases for Docker operations
19
+ DockerVolume: TypeAlias = tuple[str, str] # (host_path, container_path)
20
+ DockerEnv: TypeAlias = dict[str, str] # Environment variables
21
+ DockerPortSpec: TypeAlias = str # Port specification (e.g., "8080:80", "localhost:8080:80", "127.0.0.1:8080:80/tcp")
22
+ DockerResult: TypeAlias = tuple[
23
+ int, list[str], list[str]
24
+ ] # (return_code, stdout, stderr)
25
+
26
+
27
+ # TODO: add get_version, image_info,
28
+ @runtime_checkable
29
+ class DockerAdapterProtocol(Protocol):
30
+ """Protocol for Docker operations."""
31
+
32
+ def is_available(self) -> Awaitable[bool]:
33
+ """Check if Docker is available on the system.
34
+
35
+ Returns:
36
+ True if Docker is available, False otherwise
37
+ """
38
+ ...
39
+
40
+ def run(
41
+ self,
42
+ image: str,
43
+ volumes: list[DockerVolume],
44
+ environment: DockerEnv,
45
+ command: list[str] | None = None,
46
+ middleware: OutputMiddleware[T] | None = None,
47
+ user_context: DockerUserContext | None = None,
48
+ entrypoint: str | None = None,
49
+ ports: list[DockerPortSpec] | None = None,
50
+ ) -> Awaitable[ProcessResult[T]]:
51
+ """Run a Docker container with specified configuration.
52
+
53
+ Alias for run_container method.
54
+
55
+ Args:
56
+ image: Docker image name/tag to run
57
+ volumes: List of volume mounts (host_path, container_path)
58
+ environment: Dictionary of environment variables
59
+ command: Optional command to run in the container
60
+ middleware: Optional middleware for processing output
61
+ user_context: Optional user context for Docker --user flag
62
+ entrypoint: Optional custom entrypoint to override the image's default
63
+ ports: Optional port specifications (e.g., ["8080:80", "localhost:9000:9000"])
64
+
65
+ Returns:
66
+ Tuple containing (return_code, stdout_lines, stderr_lines)
67
+
68
+ Raises:
69
+ DockerError: If the container fails to run
70
+ """
71
+ ...
72
+
73
+ def run_container(
74
+ self,
75
+ image: str,
76
+ volumes: list[DockerVolume],
77
+ environment: DockerEnv,
78
+ command: list[str] | None = None,
79
+ middleware: OutputMiddleware[T] | None = None,
80
+ user_context: DockerUserContext | None = None,
81
+ entrypoint: str | None = None,
82
+ ports: list[DockerPortSpec] | None = None,
83
+ ) -> Awaitable[ProcessResult[T]]:
84
+ """Run a Docker container with specified configuration.
85
+
86
+ Args:
87
+ image: Docker image name/tag to run
88
+ volumes: List of volume mounts (host_path, container_path)
89
+ environment: Dictionary of environment variables
90
+ command: Optional command to run in the container
91
+ middleware: Optional middleware for processing output
92
+ user_context: Optional user context for Docker --user flag
93
+ entrypoint: Optional custom entrypoint to override the image's default
94
+ ports: Optional port specifications (e.g., ["8080:80", "localhost:9000:9000"])
95
+
96
+ Returns:
97
+ Tuple containing (return_code, stdout_lines, stderr_lines)
98
+
99
+ Raises:
100
+ DockerError: If the container fails to run
101
+ """
102
+ ...
103
+
104
+ def exec_container(
105
+ self,
106
+ image: str,
107
+ volumes: list[DockerVolume],
108
+ environment: DockerEnv,
109
+ command: list[str] | None = None,
110
+ user_context: DockerUserContext | None = None,
111
+ entrypoint: str | None = None,
112
+ ports: list[DockerPortSpec] | None = None,
113
+ ) -> None:
114
+ """Execute a Docker container by replacing the current process.
115
+
116
+ This method builds the Docker command and replaces the current process
117
+ with the Docker command using os.execvp, effectively handing over control to Docker.
118
+
119
+ Args:
120
+ image: Docker image name/tag to run
121
+ volumes: List of volume mounts (host_path, container_path)
122
+ environment: Dictionary of environment variables
123
+ command: Optional command to run in the container
124
+ user_context: Optional user context for Docker --user flag
125
+ entrypoint: Optional custom entrypoint to override the image's default
126
+ ports: Optional port specifications (e.g., ["8080:80", "localhost:9000:9000"])
127
+
128
+ Raises:
129
+ DockerError: If the container fails to execute
130
+ OSError: If the command cannot be executed
131
+ """
132
+ ...
133
+
134
+ def build_image(
135
+ self,
136
+ dockerfile_dir: Path,
137
+ image_name: str,
138
+ image_tag: str = "latest",
139
+ no_cache: bool = False,
140
+ middleware: OutputMiddleware[T] | None = None,
141
+ ) -> Awaitable[ProcessResult[T]]:
142
+ """Build a Docker image from a Dockerfile.
143
+
144
+ Args:
145
+ dockerfile_dir: Directory containing the Dockerfile
146
+ image_name: Name to tag the built image with
147
+ image_tag: Tag to use for the image
148
+ no_cache: Whether to use Docker's cache during build
149
+ middleware: Optional middleware for processing output
150
+
151
+ Returns:
152
+ ProcessResult containing (return_code, stdout_lines, stderr_lines)
153
+
154
+ Raises:
155
+ DockerError: If the image fails to build
156
+ """
157
+ ...
158
+
159
+ def image_exists(
160
+ self, image_name: str, image_tag: str = "latest"
161
+ ) -> Awaitable[bool]:
162
+ """Check if a Docker image exists locally.
163
+
164
+ Args:
165
+ image_name: Name of the image to check
166
+ image_tag: Tag of the image to check
167
+
168
+ Returns:
169
+ True if the image exists locally, False otherwise
170
+ """
171
+ ...
172
+
173
+ def pull_image(
174
+ self,
175
+ image_name: str,
176
+ image_tag: str = "latest",
177
+ middleware: OutputMiddleware[T] | None = None,
178
+ ) -> Awaitable[ProcessResult[T]]:
179
+ """Pull a Docker image from registry.
180
+
181
+ Args:
182
+ image_name: Name of the image to pull
183
+ image_tag: Tag of the image to pull
184
+ middleware: Optional middleware for processing output
185
+
186
+ Returns:
187
+ ProcessResult containing (return_code, stdout_lines, stderr_lines)
188
+
189
+ Raises:
190
+ DockerError: If the image fails to pull
191
+ """
192
+ ...
@@ -0,0 +1,264 @@
1
+ """Process execution and streaming output handling.
2
+
3
+ This module provides tools for running subprocesses and handling their output streams.
4
+ It supports custom output processing through middleware components, making it suitable
5
+ for real-time output handling in CLI applications.
6
+
7
+ Example:
8
+ ```python
9
+ from ccproxy.docker.stream_process import run_command, DefaultOutputMiddleware
10
+
11
+ # Create custom middleware to add timestamps
12
+ from datetime import datetime
13
+ class TimestampMiddleware(DefaultOutputMiddleware):
14
+ async def process(self, line: str, stream_type: str) -> str:
15
+ timestamp = datetime.now().strftime('%H:%M:%S')
16
+ return f"[{timestamp}] {await super().process(line, stream_type)}"
17
+
18
+ # Run a command with custom output handling
19
+ return_code, stdout, stderr = await run_command(
20
+ "ls -la", middleware=TimestampMiddleware()
21
+ )
22
+ ```
23
+ """
24
+
25
+ import asyncio
26
+ import shlex
27
+ from typing import Any, Generic, TypeAlias, TypeVar, cast
28
+
29
+
30
+ T = TypeVar("T") # Type of processed output
31
+
32
+ # Type alias for the result of run_command
33
+ ProcessResult: TypeAlias = tuple[int, list[T], list[T]] # (return_code, stdout, stderr)
34
+
35
+
36
+ class OutputMiddleware(Generic[T]):
37
+ """Base class for processing command output streams.
38
+
39
+ OutputMiddleware provides a way to intercept and process output lines
40
+ from subprocesses. Implementations can format, filter, or transform
41
+ the output as needed.
42
+
43
+ Type parameter T represents the return type of the process method,
44
+ allowing middleware to transform strings into other types if needed.
45
+ """
46
+
47
+ async def process(self, line: str, stream_type: str) -> T:
48
+ """Process a line of output from a subprocess stream.
49
+
50
+ Args:
51
+ line: A line of text from the process output
52
+ stream_type: Either "stdout" or "stderr"
53
+
54
+ Returns:
55
+ Processed output of type T
56
+
57
+ Raises:
58
+ NotImplementedError: Subclasses must implement this method
59
+ """
60
+ raise NotImplementedError()
61
+
62
+
63
+ class DefaultOutputMiddleware(OutputMiddleware[str]):
64
+ """Simple middleware that prints output with optional prefixes.
65
+
66
+ This middleware prints each line to the console with configurable
67
+ prefixes for stdout and stderr streams.
68
+ """
69
+
70
+ def __init__(self, stdout_prefix: str = "", stderr_prefix: str = "ERROR: ") -> None:
71
+ """Initialize middleware with custom prefixes.
72
+
73
+ Args:
74
+ stdout_prefix: Prefix for stdout lines (default: "")
75
+ stderr_prefix: Prefix for stderr lines (default: "ERROR: ")
76
+ """
77
+ self.stdout_prefix = stdout_prefix
78
+ self.stderr_prefix = stderr_prefix
79
+
80
+ async def process(self, line: str, stream_type: str) -> str:
81
+ """Process and print a line with the appropriate prefix.
82
+
83
+ Args:
84
+ line: Output line to process
85
+ stream_type: Either "stdout" or "stderr"
86
+
87
+ Returns:
88
+ The original line (unmodified)
89
+ """
90
+ prefix = self.stdout_prefix if stream_type == "stdout" else self.stderr_prefix
91
+ print(f"{prefix}{line}")
92
+ return line
93
+
94
+
95
+ class ChainedOutputMiddleware(OutputMiddleware[T]):
96
+ """Middleware that chains multiple middleware components together.
97
+
98
+ Processes output through a sequence of middleware components, where each
99
+ middleware processes the output from the previous one. The final output
100
+ type T is determined by the last middleware in the chain.
101
+
102
+ Example:
103
+ ```python
104
+ # Chain progress tracking with logging
105
+ progress_middleware = CompilationProgressMiddleware(callback)
106
+ logger_middleware = LoggerOutputMiddleware(logger)
107
+
108
+ chained = ChainedOutputMiddleware([progress_middleware, logger_middleware])
109
+
110
+ # Process: line -> progress_middleware -> logger_middleware -> final result
111
+ result = docker_adapter.run_container("image", [], {}, middleware=chained)
112
+ ```
113
+ """
114
+
115
+ def __init__(self, middleware_chain: list[OutputMiddleware[Any]]) -> None:
116
+ """Initialize chained middleware.
117
+
118
+ Args:
119
+ middleware_chain: List of middleware components to chain together.
120
+ Output flows from first to last middleware.
121
+
122
+ Raises:
123
+ ValueError: If middleware_chain is empty
124
+ """
125
+ if not middleware_chain:
126
+ raise ValueError("Middleware chain cannot be empty")
127
+
128
+ self.middleware_chain = middleware_chain
129
+
130
+ async def process(self, line: str, stream_type: str) -> T:
131
+ """Process line through the middleware chain.
132
+
133
+ Args:
134
+ line: Output line to process
135
+ stream_type: Either "stdout" or "stderr"
136
+
137
+ Returns:
138
+ Output from the final middleware in the chain
139
+ """
140
+ current_output: Any = line
141
+
142
+ # Process through each middleware in sequence
143
+ for middleware in self.middleware_chain:
144
+ current_output = await middleware.process(current_output, stream_type)
145
+
146
+ return cast(T, current_output)
147
+
148
+
149
+ def create_chained_middleware(
150
+ middleware_chain: list[OutputMiddleware[Any]],
151
+ ) -> ChainedOutputMiddleware[Any]:
152
+ """Factory function to create a chained middleware.
153
+
154
+ Args:
155
+ middleware_chain: List of middleware components to chain together
156
+
157
+ Returns:
158
+ ChainedOutputMiddleware instance
159
+
160
+ Raises:
161
+ ValueError: If middleware_chain is empty
162
+
163
+ Example:
164
+ ```python
165
+ from ccproxy.docker.stream_process import create_chained_middleware
166
+ from ccproxy.docker.adapter import LoggerOutputMiddleware
167
+
168
+ # Create individual middleware components
169
+ logger_middleware = LoggerOutputMiddleware(logger)
170
+
171
+ # Chain them together
172
+ chained = create_chained_middleware([logger_middleware])
173
+
174
+ # Use with docker adapter
175
+ result = docker_adapter.run_container("image", [], {}, middleware=chained)
176
+ ```
177
+ """
178
+ return ChainedOutputMiddleware(middleware_chain)
179
+
180
+
181
+ async def run_command(
182
+ cmd: str | list[str],
183
+ middleware: OutputMiddleware[T] | None = None,
184
+ ) -> ProcessResult[T]:
185
+ """Run a command and process its output through middleware.
186
+
187
+ This function executes a command as a subprocess and streams its output
188
+ through the provided middleware for real-time processing. The processed
189
+ outputs are collected and returned along with the exit code.
190
+
191
+ Args:
192
+ cmd: Command to run, either as a string or list of arguments
193
+ middleware: Optional middleware for processing output (uses DefaultOutputMiddleware if None)
194
+
195
+ Returns:
196
+ Tuple containing:
197
+ - Return code from the process (0 for success)
198
+ - List of processed stdout lines
199
+ - List of processed stderr lines
200
+
201
+ Example:
202
+ ```python
203
+ # Simple command execution
204
+ rc, stdout, stderr = await run_command("ls -l")
205
+
206
+ # With custom middleware
207
+ class CustomMiddleware(OutputMiddleware[str]):
208
+ async def process(self, line: str, stream_type: str) -> str:
209
+ return f"[{stream_type}] {line}"
210
+
211
+ rc, stdout, stderr = await run_command("ls -l", CustomMiddleware())
212
+ ```
213
+ """
214
+ if middleware is None:
215
+ # Cast is needed because T is unbound at this point
216
+ middleware = cast(OutputMiddleware[T], DefaultOutputMiddleware())
217
+
218
+ # Parse string commands into argument lists
219
+ if isinstance(cmd, str):
220
+ cmd = shlex.split(cmd)
221
+
222
+ # Start the async process with pipes for stdout and stderr
223
+ process = await asyncio.create_subprocess_exec(
224
+ *cmd,
225
+ stdout=asyncio.subprocess.PIPE,
226
+ stderr=asyncio.subprocess.PIPE,
227
+ )
228
+
229
+ async def stream_output(stream: asyncio.StreamReader, stream_type: str) -> list[T]:
230
+ """Process output from a stream and capture results.
231
+
232
+ Args:
233
+ stream: Async stream to read from (stdout or stderr)
234
+ stream_type: Type of the stream ("stdout" or "stderr")
235
+
236
+ Returns:
237
+ List of processed output lines
238
+ """
239
+ captured: list[T] = []
240
+ while True:
241
+ line_bytes = await stream.readline()
242
+ if not line_bytes:
243
+ break
244
+ line = line_bytes.decode().rstrip()
245
+ if line:
246
+ processed = await middleware.process(line, stream_type)
247
+ if processed is not None:
248
+ captured.append(processed)
249
+ return captured
250
+
251
+ # Create async tasks for concurrent output processing
252
+ # Ensure stdout and stderr are available
253
+ if process.stdout is None or process.stderr is None:
254
+ raise RuntimeError("Process stdout or stderr is None")
255
+
256
+ stdout_task = asyncio.create_task(stream_output(process.stdout, "stdout"))
257
+ stderr_task = asyncio.create_task(stream_output(process.stderr, "stderr"))
258
+
259
+ # Wait for process to complete and collect output
260
+ return_code = await process.wait()
261
+ stdout_lines = await stdout_task
262
+ stderr_lines = await stderr_task
263
+
264
+ return return_code, stdout_lines, stderr_lines
@@ -0,0 +1,173 @@
1
+ """Docker validation utilities and error creation."""
2
+
3
+ from typing import Any
4
+
5
+ from ccproxy.core.errors import DockerError
6
+
7
+
8
+ def validate_port_spec(port_spec: str) -> str:
9
+ """Validate a Docker port specification string.
10
+
11
+ Supports formats like:
12
+ - "8080:80"
13
+ - "localhost:8080:80"
14
+ - "127.0.0.1:8080:80"
15
+ - "8080:80/tcp"
16
+ - "localhost:8080:80/udp"
17
+ - "[::1]:8080:80"
18
+
19
+ Args:
20
+ port_spec: Port specification string
21
+
22
+ Returns:
23
+ Validated port specification string
24
+
25
+ Raises:
26
+ DockerError: If port specification is invalid
27
+ """
28
+ if not port_spec or not isinstance(port_spec, str):
29
+ raise create_docker_error(
30
+ f"Invalid port specification: {port_spec!r}",
31
+ details={"port_spec": port_spec},
32
+ )
33
+
34
+ # Remove protocol suffix for validation if present
35
+ port_part = port_spec
36
+ protocol = None
37
+ if "/" in port_spec:
38
+ port_part, protocol = port_spec.rsplit("/", 1)
39
+ if protocol not in ("tcp", "udp"):
40
+ raise create_docker_error(
41
+ f"Invalid protocol in port specification: {protocol}",
42
+ details={"port_spec": port_spec, "protocol": protocol},
43
+ )
44
+
45
+ # Handle IPv6 address format specially
46
+ if port_part.startswith("["):
47
+ # IPv6 format like [::1]:8080:80
48
+ ipv6_end = port_part.find("]:")
49
+ if ipv6_end == -1:
50
+ raise create_docker_error(
51
+ f"Invalid IPv6 port specification format: {port_spec}",
52
+ details={
53
+ "port_spec": port_spec,
54
+ "expected_format": "[ipv6]:host_port:container_port",
55
+ },
56
+ )
57
+
58
+ host_ip = port_part[: ipv6_end + 1] # Include the closing ]
59
+ remaining = port_part[ipv6_end + 2 :] # Skip ]:
60
+ port_parts = remaining.split(":")
61
+
62
+ if len(port_parts) != 2:
63
+ raise create_docker_error(
64
+ f"Invalid IPv6 port specification format: {port_spec}",
65
+ details={
66
+ "port_spec": port_spec,
67
+ "expected_format": "[ipv6]:host_port:container_port",
68
+ },
69
+ )
70
+
71
+ host_port, container_port = port_parts
72
+ parts = [host_ip, host_port, container_port]
73
+ else:
74
+ # Regular format
75
+ parts = port_part.split(":")
76
+
77
+ if len(parts) == 2:
78
+ # Format: "host_port:container_port"
79
+ host_port, container_port = parts
80
+ try:
81
+ host_port_num = int(host_port)
82
+ container_port_num = int(container_port)
83
+ if not (1 <= host_port_num <= 65535) or not (
84
+ 1 <= container_port_num <= 65535
85
+ ):
86
+ raise ValueError("Port numbers must be between 1 and 65535")
87
+ except ValueError as e:
88
+ raise create_docker_error(
89
+ f"Invalid port numbers in specification: {port_spec}",
90
+ details={"port_spec": port_spec, "error": str(e)},
91
+ ) from e
92
+
93
+ elif len(parts) == 3:
94
+ # Format: "host_ip:host_port:container_port"
95
+ host_ip, host_port, container_port = parts
96
+
97
+ # Basic IP validation (simplified)
98
+ if not host_ip or host_ip in (
99
+ "localhost",
100
+ "127.0.0.1",
101
+ "0.0.0.0",
102
+ "::1",
103
+ "[::1]",
104
+ ):
105
+ pass # Common valid values
106
+ elif host_ip.startswith("[") and host_ip.endswith("]"):
107
+ pass # IPv6 format like [::1]
108
+ else:
109
+ # Basic check for IPv4-like format
110
+ ip_parts = host_ip.split(".")
111
+ if len(ip_parts) == 4:
112
+ try:
113
+ for part in ip_parts:
114
+ num = int(part)
115
+ if not (0 <= num <= 255):
116
+ raise ValueError("Invalid IPv4 address")
117
+ except ValueError as e:
118
+ raise create_docker_error(
119
+ f"Invalid host IP in port specification: {host_ip}",
120
+ details={
121
+ "port_spec": port_spec,
122
+ "host_ip": host_ip,
123
+ "error": str(e),
124
+ },
125
+ ) from e
126
+
127
+ try:
128
+ host_port_num = int(host_port)
129
+ container_port_num = int(container_port)
130
+ if not (1 <= host_port_num <= 65535) or not (
131
+ 1 <= container_port_num <= 65535
132
+ ):
133
+ raise ValueError("Port numbers must be between 1 and 65535")
134
+ except ValueError as e:
135
+ raise create_docker_error(
136
+ f"Invalid port numbers in specification: {port_spec}",
137
+ details={"port_spec": port_spec, "error": str(e)},
138
+ ) from e
139
+ else:
140
+ raise create_docker_error(
141
+ f"Invalid port specification format: {port_spec}",
142
+ details={
143
+ "port_spec": port_spec,
144
+ "expected_format": "host_port:container_port or host_ip:host_port:container_port",
145
+ },
146
+ )
147
+
148
+ return port_spec
149
+
150
+
151
+ def create_docker_error(
152
+ message: str,
153
+ command: str | None = None,
154
+ cause: Exception | None = None,
155
+ details: dict[str, Any] | None = None,
156
+ ) -> DockerError:
157
+ """Create a DockerError with standardized context.
158
+
159
+ Args:
160
+ message: Human-readable error message
161
+ command: Docker command that failed (optional)
162
+ cause: Original exception that caused this error (optional)
163
+ details: Additional context details (optional)
164
+
165
+ Returns:
166
+ DockerError instance with all context information
167
+ """
168
+ return DockerError(
169
+ message=message,
170
+ command=command,
171
+ cause=cause,
172
+ details=details,
173
+ )