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,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
|
+
)
|