agentscope-runtime 0.1.4__py3-none-any.whl → 0.1.5b1__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.
- agentscope_runtime/engine/agents/agentscope_agent/agent.py +3 -0
- agentscope_runtime/engine/deployers/__init__.py +13 -0
- agentscope_runtime/engine/deployers/adapter/responses/__init__.py +0 -0
- agentscope_runtime/engine/deployers/adapter/responses/response_api_adapter_utils.py +2886 -0
- agentscope_runtime/engine/deployers/adapter/responses/response_api_agent_adapter.py +51 -0
- agentscope_runtime/engine/deployers/adapter/responses/response_api_protocol_adapter.py +314 -0
- agentscope_runtime/engine/deployers/cli_fc_deploy.py +143 -0
- agentscope_runtime/engine/deployers/kubernetes_deployer.py +265 -0
- agentscope_runtime/engine/deployers/local_deployer.py +356 -501
- agentscope_runtime/engine/deployers/modelstudio_deployer.py +626 -0
- agentscope_runtime/engine/deployers/utils/__init__.py +0 -0
- agentscope_runtime/engine/deployers/utils/deployment_modes.py +14 -0
- agentscope_runtime/engine/deployers/utils/docker_image_utils/__init__.py +8 -0
- agentscope_runtime/engine/deployers/utils/docker_image_utils/docker_image_builder.py +429 -0
- agentscope_runtime/engine/deployers/utils/docker_image_utils/dockerfile_generator.py +240 -0
- agentscope_runtime/engine/deployers/utils/docker_image_utils/runner_image_factory.py +297 -0
- agentscope_runtime/engine/deployers/utils/package_project_utils.py +932 -0
- agentscope_runtime/engine/deployers/utils/service_utils/__init__.py +9 -0
- agentscope_runtime/engine/deployers/utils/service_utils/fastapi_factory.py +504 -0
- agentscope_runtime/engine/deployers/utils/service_utils/fastapi_templates.py +157 -0
- agentscope_runtime/engine/deployers/utils/service_utils/process_manager.py +268 -0
- agentscope_runtime/engine/deployers/utils/service_utils/service_config.py +75 -0
- agentscope_runtime/engine/deployers/utils/service_utils/service_factory.py +220 -0
- agentscope_runtime/engine/deployers/utils/wheel_packager.py +389 -0
- agentscope_runtime/engine/helpers/agent_api_builder.py +651 -0
- agentscope_runtime/engine/runner.py +36 -10
- agentscope_runtime/engine/schemas/agent_schemas.py +70 -2
- agentscope_runtime/engine/schemas/embedding.py +37 -0
- agentscope_runtime/engine/schemas/modelstudio_llm.py +310 -0
- agentscope_runtime/engine/schemas/oai_llm.py +538 -0
- agentscope_runtime/engine/schemas/realtime.py +254 -0
- agentscope_runtime/engine/services/mem0_memory_service.py +124 -0
- agentscope_runtime/engine/services/memory_service.py +2 -1
- agentscope_runtime/engine/services/redis_session_history_service.py +4 -3
- agentscope_runtime/engine/services/session_history_service.py +4 -3
- agentscope_runtime/sandbox/manager/container_clients/kubernetes_client.py +555 -10
- agentscope_runtime/version.py +1 -1
- {agentscope_runtime-0.1.4.dist-info → agentscope_runtime-0.1.5b1.dist-info}/METADATA +21 -4
- {agentscope_runtime-0.1.4.dist-info → agentscope_runtime-0.1.5b1.dist-info}/RECORD +43 -16
- {agentscope_runtime-0.1.4.dist-info → agentscope_runtime-0.1.5b1.dist-info}/entry_points.txt +1 -0
- {agentscope_runtime-0.1.4.dist-info → agentscope_runtime-0.1.5b1.dist-info}/WHEEL +0 -0
- {agentscope_runtime-0.1.4.dist-info → agentscope_runtime-0.1.5b1.dist-info}/licenses/LICENSE +0 -0
- {agentscope_runtime-0.1.4.dist-info → agentscope_runtime-0.1.5b1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# pylint:disable=too-many-branches
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Optional, Dict
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RegistryConfig(BaseModel):
|
|
15
|
+
"""Container registry configuration"""
|
|
16
|
+
|
|
17
|
+
registry_url: str = ""
|
|
18
|
+
username: str = None
|
|
19
|
+
password: str = None
|
|
20
|
+
namespace: str = "agentscope-runtime"
|
|
21
|
+
image_pull_secret: str = None
|
|
22
|
+
|
|
23
|
+
def get_full_url(self) -> str:
|
|
24
|
+
# Handle different registry URL formats
|
|
25
|
+
return f"{self.registry_url}/{self.namespace}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BuildConfig(BaseModel):
|
|
29
|
+
"""Configuration for Docker image building"""
|
|
30
|
+
|
|
31
|
+
no_cache: bool = False
|
|
32
|
+
quiet: bool = False
|
|
33
|
+
build_args: Dict[str, str] = {}
|
|
34
|
+
platform: Optional[str] = None
|
|
35
|
+
target: Optional[str] = None
|
|
36
|
+
source_updated: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DockerImageBuilder:
|
|
40
|
+
"""
|
|
41
|
+
Responsible solely for building and managing Docker images.
|
|
42
|
+
Separated from project packaging for better separation of concerns.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self):
|
|
46
|
+
"""
|
|
47
|
+
Initialize Docker image builder.
|
|
48
|
+
"""
|
|
49
|
+
self._ensure_docker_available()
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _ensure_docker_available():
|
|
53
|
+
"""Ensure Docker is available on the system"""
|
|
54
|
+
try:
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["docker", "--version"],
|
|
57
|
+
check=True,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
)
|
|
61
|
+
logger.debug(f"Docker available: {result.stdout.strip()}")
|
|
62
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
"Docker is not installed or not available in PATH. "
|
|
65
|
+
"Please install Docker to use this functionality.",
|
|
66
|
+
) from e
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def get_full_name(
|
|
70
|
+
image_name: str,
|
|
71
|
+
image_tag: str = "latest",
|
|
72
|
+
):
|
|
73
|
+
return f"{image_name}:{image_tag}"
|
|
74
|
+
|
|
75
|
+
def build_image(
|
|
76
|
+
self,
|
|
77
|
+
build_context: str,
|
|
78
|
+
image_name: str,
|
|
79
|
+
image_tag: str = "latest",
|
|
80
|
+
dockerfile_path: Optional[str] = None,
|
|
81
|
+
config: Optional[BuildConfig] = None,
|
|
82
|
+
source_updated: bool = False,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Build Docker image from build context.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
build_context: Path to build context directory
|
|
89
|
+
image_name: Name for the Docker image
|
|
90
|
+
image_tag: Tag for the Docker image
|
|
91
|
+
dockerfile_path: Optional path to Dockerfile
|
|
92
|
+
(defaults to Dockerfile in context)
|
|
93
|
+
config: Build configuration
|
|
94
|
+
source_updated: Optional flag to determine if source image
|
|
95
|
+
should be updated.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
str: Full image name with tag
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
subprocess.CalledProcessError: If docker build fails
|
|
102
|
+
ValueError: If build context doesn't exist
|
|
103
|
+
"""
|
|
104
|
+
if not os.path.exists(build_context):
|
|
105
|
+
raise ValueError(f"Build context does not exist: {build_context}")
|
|
106
|
+
|
|
107
|
+
config = config or BuildConfig()
|
|
108
|
+
full_image_name = self.get_full_name(image_name, image_tag)
|
|
109
|
+
|
|
110
|
+
if not source_updated:
|
|
111
|
+
return full_image_name
|
|
112
|
+
|
|
113
|
+
# Prepare docker build command
|
|
114
|
+
build_cmd = ["docker", "build", "-t", full_image_name]
|
|
115
|
+
|
|
116
|
+
# Add dockerfile path if specified
|
|
117
|
+
if dockerfile_path:
|
|
118
|
+
if not os.path.isabs(dockerfile_path):
|
|
119
|
+
dockerfile_path = os.path.join(build_context, dockerfile_path)
|
|
120
|
+
build_cmd.extend(["-f", dockerfile_path])
|
|
121
|
+
|
|
122
|
+
# Add build arguments
|
|
123
|
+
if config.build_args:
|
|
124
|
+
for key, value in config.build_args.items():
|
|
125
|
+
build_cmd.extend(["--build-arg", f"{key}={value}"])
|
|
126
|
+
|
|
127
|
+
# Add platform if specified
|
|
128
|
+
if config.platform:
|
|
129
|
+
build_cmd.extend(["--platform", config.platform])
|
|
130
|
+
|
|
131
|
+
# Add target if specified
|
|
132
|
+
if config.target:
|
|
133
|
+
build_cmd.extend(["--target", config.target])
|
|
134
|
+
|
|
135
|
+
# Add additional options
|
|
136
|
+
if config.no_cache:
|
|
137
|
+
build_cmd.append("--no-cache")
|
|
138
|
+
|
|
139
|
+
if config.quiet:
|
|
140
|
+
build_cmd.append("--quiet")
|
|
141
|
+
|
|
142
|
+
# Add build context path
|
|
143
|
+
build_cmd.append(build_context)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
if config.quiet:
|
|
147
|
+
# Capture output for quiet mode
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
build_cmd,
|
|
150
|
+
check=True,
|
|
151
|
+
capture_output=True,
|
|
152
|
+
text=True,
|
|
153
|
+
cwd=build_context,
|
|
154
|
+
)
|
|
155
|
+
logger.info(f"Built image: {full_image_name}")
|
|
156
|
+
if result.stdout:
|
|
157
|
+
logger.debug(f"Build output: {result.stdout}")
|
|
158
|
+
else:
|
|
159
|
+
# Stream output for non-quiet mode
|
|
160
|
+
logger.info(f"Building image: {full_image_name}")
|
|
161
|
+
logger.debug(f"Build command: {' '.join(build_cmd)}")
|
|
162
|
+
|
|
163
|
+
with subprocess.Popen(
|
|
164
|
+
build_cmd,
|
|
165
|
+
stdout=subprocess.PIPE,
|
|
166
|
+
stderr=subprocess.STDOUT,
|
|
167
|
+
text=True,
|
|
168
|
+
bufsize=1,
|
|
169
|
+
universal_newlines=True,
|
|
170
|
+
cwd=build_context,
|
|
171
|
+
) as process:
|
|
172
|
+
# Stream output in real-time
|
|
173
|
+
while True:
|
|
174
|
+
output = process.stdout.readline()
|
|
175
|
+
if output == "" and process.poll() is not None:
|
|
176
|
+
break
|
|
177
|
+
if output:
|
|
178
|
+
print(output.strip())
|
|
179
|
+
|
|
180
|
+
process.wait()
|
|
181
|
+
|
|
182
|
+
if process.returncode != 0:
|
|
183
|
+
raise subprocess.CalledProcessError(
|
|
184
|
+
process.returncode,
|
|
185
|
+
build_cmd,
|
|
186
|
+
"Docker build failed",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
logger.info(f"Successfully built image: {full_image_name}")
|
|
190
|
+
|
|
191
|
+
return full_image_name
|
|
192
|
+
|
|
193
|
+
except subprocess.CalledProcessError as e:
|
|
194
|
+
error_msg = f"Docker build failed for image {full_image_name}"
|
|
195
|
+
if hasattr(e, "output") and e.output:
|
|
196
|
+
error_msg += f"\nError output: {e.output}"
|
|
197
|
+
logger.error(error_msg)
|
|
198
|
+
raise subprocess.CalledProcessError(
|
|
199
|
+
e.returncode,
|
|
200
|
+
e.cmd,
|
|
201
|
+
error_msg,
|
|
202
|
+
) from e
|
|
203
|
+
|
|
204
|
+
def push_image(
|
|
205
|
+
self,
|
|
206
|
+
image_name: str,
|
|
207
|
+
registry_config: Optional[RegistryConfig] = None,
|
|
208
|
+
quiet: bool = False,
|
|
209
|
+
) -> str:
|
|
210
|
+
"""
|
|
211
|
+
Push image to registry.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
image_name: Full image name to push
|
|
215
|
+
registry_config: Optional registry config
|
|
216
|
+
(uses instance config if None)
|
|
217
|
+
quiet: Whether to suppress output
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
str: Full image name that was pushed
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
subprocess.CalledProcessError: If push fails
|
|
224
|
+
ValueError: If no registry configuration is available
|
|
225
|
+
"""
|
|
226
|
+
config = registry_config
|
|
227
|
+
if not config:
|
|
228
|
+
raise ValueError("No registry configuration provided")
|
|
229
|
+
|
|
230
|
+
# Construct full registry image name
|
|
231
|
+
if config.registry_url and not image_name.startswith(
|
|
232
|
+
config.registry_url,
|
|
233
|
+
):
|
|
234
|
+
registry_image_name = f"{config.get_full_url()}/{image_name}"
|
|
235
|
+
# Tag the image with registry prefix
|
|
236
|
+
subprocess.run(
|
|
237
|
+
["docker", "tag", image_name, registry_image_name],
|
|
238
|
+
check=True,
|
|
239
|
+
capture_output=True,
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
registry_image_name = image_name
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
push_cmd = ["docker", "push", registry_image_name]
|
|
246
|
+
|
|
247
|
+
if quiet:
|
|
248
|
+
result = subprocess.run(
|
|
249
|
+
push_cmd,
|
|
250
|
+
check=True,
|
|
251
|
+
capture_output=True,
|
|
252
|
+
text=True,
|
|
253
|
+
)
|
|
254
|
+
logger.info(f"Pushed image: {registry_image_name}")
|
|
255
|
+
if result.stdout:
|
|
256
|
+
logger.debug(f"Push output: {result.stdout}")
|
|
257
|
+
else:
|
|
258
|
+
logger.info(f"Pushing image: {registry_image_name}")
|
|
259
|
+
|
|
260
|
+
with subprocess.Popen(
|
|
261
|
+
push_cmd,
|
|
262
|
+
stdout=subprocess.PIPE,
|
|
263
|
+
stderr=subprocess.STDOUT,
|
|
264
|
+
text=True,
|
|
265
|
+
bufsize=1,
|
|
266
|
+
universal_newlines=True,
|
|
267
|
+
) as process:
|
|
268
|
+
# Stream output in real-time
|
|
269
|
+
while True:
|
|
270
|
+
output = process.stdout.readline()
|
|
271
|
+
if output == "" and process.poll() is not None:
|
|
272
|
+
break
|
|
273
|
+
if output:
|
|
274
|
+
print(output.strip())
|
|
275
|
+
|
|
276
|
+
process.wait()
|
|
277
|
+
|
|
278
|
+
if process.returncode != 0:
|
|
279
|
+
raise subprocess.CalledProcessError(
|
|
280
|
+
process.returncode,
|
|
281
|
+
push_cmd,
|
|
282
|
+
"Docker push failed",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
logger.info(
|
|
286
|
+
f"Successfully pushed image: {registry_image_name}",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return registry_image_name
|
|
290
|
+
|
|
291
|
+
except subprocess.CalledProcessError as e:
|
|
292
|
+
error_msg = f"Docker push failed for image {registry_image_name}"
|
|
293
|
+
if hasattr(e, "stderr") and e.stderr:
|
|
294
|
+
error_msg += f"\nError output: {e.stderr}"
|
|
295
|
+
logger.error(error_msg)
|
|
296
|
+
raise subprocess.CalledProcessError(
|
|
297
|
+
e.returncode,
|
|
298
|
+
e.cmd,
|
|
299
|
+
error_msg,
|
|
300
|
+
) from e
|
|
301
|
+
|
|
302
|
+
def build_and_push(
|
|
303
|
+
self,
|
|
304
|
+
build_context: str,
|
|
305
|
+
image_name: str,
|
|
306
|
+
image_tag: str = "latest",
|
|
307
|
+
dockerfile_path: Optional[str] = None,
|
|
308
|
+
build_config: Optional[BuildConfig] = None,
|
|
309
|
+
registry_config: Optional[RegistryConfig] = None,
|
|
310
|
+
source_updated: bool = False,
|
|
311
|
+
) -> str:
|
|
312
|
+
"""
|
|
313
|
+
Build and push image in one operation.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
build_context: Path to build context directory
|
|
317
|
+
image_name: Name for the Docker image
|
|
318
|
+
image_tag: Tag for the Docker image
|
|
319
|
+
dockerfile_path: Optional path to Dockerfile
|
|
320
|
+
build_config: Build configuration
|
|
321
|
+
registry_config: Registry configuration
|
|
322
|
+
source_updated: Whether the source image was updated or not
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
str: Full registry image name
|
|
326
|
+
"""
|
|
327
|
+
# Build the image
|
|
328
|
+
built_image = self.build_image(
|
|
329
|
+
build_context=build_context,
|
|
330
|
+
image_name=image_name,
|
|
331
|
+
image_tag=image_tag,
|
|
332
|
+
dockerfile_path=dockerfile_path,
|
|
333
|
+
config=build_config,
|
|
334
|
+
source_updated=source_updated,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Push to registry
|
|
338
|
+
registry_image = self.push_image(
|
|
339
|
+
image_name=built_image,
|
|
340
|
+
registry_config=registry_config,
|
|
341
|
+
quiet=build_config.quiet if build_config else False,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# make sure return the built name without registry
|
|
345
|
+
return registry_image.split("/")[-1]
|
|
346
|
+
|
|
347
|
+
def remove_image(
|
|
348
|
+
self,
|
|
349
|
+
image_name: str,
|
|
350
|
+
force: bool = False,
|
|
351
|
+
quiet: bool = True,
|
|
352
|
+
) -> bool:
|
|
353
|
+
"""
|
|
354
|
+
Remove Docker image.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
image_name: Name of image to remove
|
|
358
|
+
force: Force removal
|
|
359
|
+
quiet: Suppress output
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
bool: True if successful
|
|
363
|
+
"""
|
|
364
|
+
try:
|
|
365
|
+
cmd = ["docker", "rmi"]
|
|
366
|
+
if force:
|
|
367
|
+
cmd.append("-f")
|
|
368
|
+
cmd.append(image_name)
|
|
369
|
+
|
|
370
|
+
subprocess.run(
|
|
371
|
+
cmd,
|
|
372
|
+
check=True,
|
|
373
|
+
capture_output=quiet,
|
|
374
|
+
text=True,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
if not quiet:
|
|
378
|
+
logger.info(f"Removed image: {image_name}")
|
|
379
|
+
|
|
380
|
+
return True
|
|
381
|
+
|
|
382
|
+
except subprocess.CalledProcessError as e:
|
|
383
|
+
if not quiet:
|
|
384
|
+
logger.warning(f"Failed to remove image {image_name}: {e}")
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
def get_image_info(self, image_name: str) -> Dict:
|
|
388
|
+
"""
|
|
389
|
+
Get information about a Docker image.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
image_name: Name of the Docker image
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Dict: Image information from docker inspect
|
|
396
|
+
|
|
397
|
+
Raises:
|
|
398
|
+
ValueError: If image not found or info invalid
|
|
399
|
+
"""
|
|
400
|
+
try:
|
|
401
|
+
result = subprocess.run(
|
|
402
|
+
["docker", "inspect", image_name],
|
|
403
|
+
check=True,
|
|
404
|
+
capture_output=True,
|
|
405
|
+
text=True,
|
|
406
|
+
)
|
|
407
|
+
image_info = json.loads(result.stdout)[0]
|
|
408
|
+
return image_info
|
|
409
|
+
|
|
410
|
+
except subprocess.CalledProcessError as e:
|
|
411
|
+
raise ValueError(f"Image not found: {image_name}") from e
|
|
412
|
+
except (json.JSONDecodeError, IndexError) as e:
|
|
413
|
+
raise ValueError(f"Invalid image info for: {image_name}") from e
|
|
414
|
+
|
|
415
|
+
def image_exists(self, image_name: str) -> bool:
|
|
416
|
+
"""
|
|
417
|
+
Check if Docker image exists locally.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
image_name: Name of image to check
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
bool: True if image exists
|
|
424
|
+
"""
|
|
425
|
+
try:
|
|
426
|
+
self.get_image_info(image_name)
|
|
427
|
+
return True
|
|
428
|
+
except ValueError:
|
|
429
|
+
return False
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from typing import Optional, Dict, List
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DockerfileConfig(BaseModel):
|
|
13
|
+
"""Configuration for Dockerfile generation"""
|
|
14
|
+
|
|
15
|
+
base_image: str = "python:3.10-slim-bookworm"
|
|
16
|
+
port: int = 8000
|
|
17
|
+
working_dir: str = "/app"
|
|
18
|
+
user: str = "appuser"
|
|
19
|
+
additional_packages: List[str] = []
|
|
20
|
+
env_vars: Dict[str, str] = {}
|
|
21
|
+
startup_command: Optional[str] = None
|
|
22
|
+
health_check_endpoint: str = "/health"
|
|
23
|
+
custom_template: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DockerfileGenerator:
|
|
27
|
+
"""
|
|
28
|
+
Responsible for generating Dockerfiles from templates.
|
|
29
|
+
Separated from image building for better modularity.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# Default Dockerfile template for Python applications
|
|
33
|
+
DEFAULT_TEMPLATE = """# Use official Python runtime as base image
|
|
34
|
+
FROM {base_image}
|
|
35
|
+
|
|
36
|
+
# Set working directory in container
|
|
37
|
+
WORKDIR /app
|
|
38
|
+
|
|
39
|
+
# Set environment variables
|
|
40
|
+
ENV PYTHONDONTWRITEBYTECODE=1
|
|
41
|
+
ENV PYTHONUNBUFFERED=1
|
|
42
|
+
|
|
43
|
+
# Configure package sources for better performance
|
|
44
|
+
RUN rm -f /etc/apt/sources.list.d/*.list
|
|
45
|
+
|
|
46
|
+
# 替换主源为阿里云
|
|
47
|
+
RUN echo "deb https://mirrors.aliyun.com/debian/ bookworm main contrib " \\
|
|
48
|
+
"non-free non-free-firmware" > /etc/apt/sources.list && \\
|
|
49
|
+
echo "deb https://mirrors.aliyun.com/debian/ bookworm-updates main " \\
|
|
50
|
+
"contrib non-free non-free-firmware" >> /etc/apt/sources.list && \\
|
|
51
|
+
echo "deb https://mirrors.aliyun.com/debian-security/ " \\
|
|
52
|
+
"bookworm-security main contrib non-free " \\
|
|
53
|
+
"non-free-firmware" >> /etc/apt/sources.list
|
|
54
|
+
|
|
55
|
+
# Clean up package lists
|
|
56
|
+
RUN rm -rf /var/lib/apt/lists/*
|
|
57
|
+
|
|
58
|
+
# Install system dependencies
|
|
59
|
+
RUN apt-get update && apt-get install -y \\
|
|
60
|
+
gcc \\
|
|
61
|
+
curl \\
|
|
62
|
+
{additional_packages_section} && rm -rf /var/lib/apt/lists/*
|
|
63
|
+
|
|
64
|
+
# Copy project files
|
|
65
|
+
COPY . {working_dir}/
|
|
66
|
+
|
|
67
|
+
# Install Python dependencies
|
|
68
|
+
RUN pip install --no-cache-dir --upgrade pip
|
|
69
|
+
RUN if [ -f requirements.txt ]; then \\
|
|
70
|
+
pip install --no-cache-dir -r requirements.txt \\
|
|
71
|
+
-i https://pypi.tuna.tsinghua.edu.cn/simple; fi
|
|
72
|
+
|
|
73
|
+
# Create non-root user for security
|
|
74
|
+
RUN adduser --disabled-password --gecos '' {user} && \\
|
|
75
|
+
chown -R {user} {working_dir}
|
|
76
|
+
USER {user}
|
|
77
|
+
|
|
78
|
+
{env_vars_section}
|
|
79
|
+
# Expose port
|
|
80
|
+
EXPOSE {port}
|
|
81
|
+
|
|
82
|
+
# Health check
|
|
83
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\
|
|
84
|
+
CMD curl -f http://localhost:{port}{health_check_endpoint} || exit 1
|
|
85
|
+
|
|
86
|
+
# Command to run the application
|
|
87
|
+
{startup_command_section}"""
|
|
88
|
+
|
|
89
|
+
def __init__(self):
|
|
90
|
+
self.temp_files: List[str] = []
|
|
91
|
+
|
|
92
|
+
def generate_dockerfile_content(self, config: DockerfileConfig) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Generate Dockerfile content from configuration.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
config: Dockerfile configuration
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
str: Generated Dockerfile content
|
|
101
|
+
"""
|
|
102
|
+
template = config.custom_template or self.DEFAULT_TEMPLATE
|
|
103
|
+
|
|
104
|
+
# Prepare additional packages section
|
|
105
|
+
additional_packages_section = ""
|
|
106
|
+
if config.additional_packages:
|
|
107
|
+
packages_line = " \\\n ".join(config.additional_packages)
|
|
108
|
+
additional_packages_section = f" {packages_line} \\\n"
|
|
109
|
+
|
|
110
|
+
# Prepare environment variables section
|
|
111
|
+
env_vars_section = ""
|
|
112
|
+
if config.env_vars:
|
|
113
|
+
env_vars_section = "\n# Additional environment variables\n"
|
|
114
|
+
for key, value in config.env_vars.items():
|
|
115
|
+
env_vars_section += f"ENV {key}={value}\n"
|
|
116
|
+
env_vars_section += "\n"
|
|
117
|
+
|
|
118
|
+
# Prepare startup command section
|
|
119
|
+
if config.startup_command:
|
|
120
|
+
if config.startup_command.startswith("["):
|
|
121
|
+
# JSON array format
|
|
122
|
+
startup_command_section = f"CMD {config.startup_command}"
|
|
123
|
+
else:
|
|
124
|
+
# Shell format
|
|
125
|
+
startup_command_section = f'CMD ["{config.startup_command}"]'
|
|
126
|
+
else:
|
|
127
|
+
# Default uvicorn command
|
|
128
|
+
startup_command_section = (
|
|
129
|
+
f'CMD ["uvicorn", "main:app", "--host", "0.0.0.0", '
|
|
130
|
+
f'"--port", "{config.port}"]'
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Format template with configuration values
|
|
134
|
+
content = template.format(
|
|
135
|
+
base_image=config.base_image,
|
|
136
|
+
working_dir=config.working_dir,
|
|
137
|
+
port=config.port,
|
|
138
|
+
user=config.user,
|
|
139
|
+
health_check_endpoint=config.health_check_endpoint,
|
|
140
|
+
additional_packages_section=additional_packages_section,
|
|
141
|
+
env_vars_section=env_vars_section,
|
|
142
|
+
startup_command_section=startup_command_section,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return content
|
|
146
|
+
|
|
147
|
+
def create_dockerfile(
|
|
148
|
+
self,
|
|
149
|
+
config: DockerfileConfig,
|
|
150
|
+
output_dir: Optional[str] = None,
|
|
151
|
+
) -> str:
|
|
152
|
+
"""
|
|
153
|
+
Create Dockerfile in specified directory.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
config: Dockerfile configuration
|
|
157
|
+
output_dir: Directory to create Dockerfile (temp dir if None)
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
str: Path to created Dockerfile
|
|
161
|
+
"""
|
|
162
|
+
# Create output directory if not provided
|
|
163
|
+
if output_dir is None:
|
|
164
|
+
output_dir = tempfile.mkdtemp(prefix="dockerfile_")
|
|
165
|
+
self.temp_files.append(output_dir)
|
|
166
|
+
else:
|
|
167
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
168
|
+
|
|
169
|
+
# Generate Dockerfile content
|
|
170
|
+
dockerfile_content = self.generate_dockerfile_content(config)
|
|
171
|
+
|
|
172
|
+
# Write Dockerfile
|
|
173
|
+
dockerfile_path = os.path.join(output_dir, "Dockerfile")
|
|
174
|
+
try:
|
|
175
|
+
with open(dockerfile_path, "w", encoding="utf-8") as f:
|
|
176
|
+
f.write(dockerfile_content)
|
|
177
|
+
|
|
178
|
+
logger.info(f"Created Dockerfile: {dockerfile_path}")
|
|
179
|
+
return dockerfile_path
|
|
180
|
+
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.error(f"Failed to create Dockerfile: {e}")
|
|
183
|
+
if output_dir in self.temp_files and os.path.exists(output_dir):
|
|
184
|
+
import shutil
|
|
185
|
+
|
|
186
|
+
shutil.rmtree(output_dir)
|
|
187
|
+
self.temp_files.remove(output_dir)
|
|
188
|
+
raise
|
|
189
|
+
|
|
190
|
+
def validate_config(self, config: DockerfileConfig) -> bool:
|
|
191
|
+
"""
|
|
192
|
+
Validate Dockerfile configuration.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
config: Configuration to validate
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
bool: True if valid
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
ValueError: If configuration is invalid
|
|
202
|
+
"""
|
|
203
|
+
if not config.base_image:
|
|
204
|
+
raise ValueError("Base image cannot be empty")
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
not isinstance(config.port, int)
|
|
208
|
+
or config.port <= 0
|
|
209
|
+
or config.port > 65535
|
|
210
|
+
):
|
|
211
|
+
raise ValueError(f"Invalid port: {config.port}")
|
|
212
|
+
|
|
213
|
+
if not config.working_dir.startswith("/"):
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"Working directory must be absolute path: "
|
|
216
|
+
f"{config.working_dir}",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
def cleanup(self):
|
|
222
|
+
"""Clean up temporary files"""
|
|
223
|
+
import shutil
|
|
224
|
+
|
|
225
|
+
for temp_path in self.temp_files:
|
|
226
|
+
if os.path.exists(temp_path):
|
|
227
|
+
try:
|
|
228
|
+
shutil.rmtree(temp_path)
|
|
229
|
+
logger.debug(f"Cleaned up temp path: {temp_path}")
|
|
230
|
+
except OSError as e:
|
|
231
|
+
logger.warning(f"Failed to cleanup {temp_path}: {e}")
|
|
232
|
+
self.temp_files.clear()
|
|
233
|
+
|
|
234
|
+
def __enter__(self):
|
|
235
|
+
"""Context manager entry"""
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
239
|
+
"""Context manager exit with cleanup"""
|
|
240
|
+
self.cleanup()
|