alibaba-cloud-ops-mcp-server 0.9.26__tar.gz → 0.9.27__tar.gz

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 (46) hide show
  1. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/PKG-INFO +1 -1
  2. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/pyproject.toml +1 -1
  3. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/tools/application_management_tools.py +572 -41
  4. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/.github/workflows/python-ci.yml +0 -0
  5. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/.gitignore +0 -0
  6. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/Dockerfile +0 -0
  7. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/LICENSE +0 -0
  8. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/README.md +0 -0
  9. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/README_mcp_args.md +0 -0
  10. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/README_mcp_args_zh.md +0 -0
  11. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/README_zh.md +0 -0
  12. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/examples/openapi_mcp_quickstart/server.py +0 -0
  13. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/image/Alibaba-Cloud-Ops-MCP-User-Group-en.png +0 -0
  14. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/image/Alibaba-Cloud-Ops-MCP-User-Group-zh.png +0 -0
  15. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/image/alibaba-cloud.png +0 -0
  16. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/image/qoder.svg +0 -0
  17. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/__init__.py +0 -0
  18. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/__init__.py +0 -0
  19. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/__main__.py +0 -0
  20. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/alibabacloud/__init__.py +0 -0
  21. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/alibabacloud/api_meta_client.py +0 -0
  22. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/alibabacloud/exception.py +0 -0
  23. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/alibabacloud/static/PROMPT_UNDERSTANDING.md +0 -0
  24. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/alibabacloud/static/__init__.py +0 -0
  25. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/alibabacloud/utils.py +0 -0
  26. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/config.py +0 -0
  27. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/server.py +0 -0
  28. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/settings.py +0 -0
  29. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/tools/__init__.py +0 -0
  30. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/tools/api_tools.py +0 -0
  31. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/tools/cms_tools.py +0 -0
  32. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/tools/common_api_tools.py +0 -0
  33. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/tools/local_tools.py +0 -0
  34. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/tools/oos_tools.py +0 -0
  35. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/src/alibaba_cloud_ops_mcp_server/tools/oss_tools.py +0 -0
  36. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/tests/alibabacloud/test_api_meta_client.py +0 -0
  37. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/tests/alibabacloud/test_exception.py +0 -0
  38. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/tests/alibabacloud/test_utils.py +0 -0
  39. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/tests/test_init.py +0 -0
  40. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/tests/test_server.py +0 -0
  41. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/tests/tools/test_api_tools.py +0 -0
  42. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/tests/tools/test_application_management_tools.py +0 -0
  43. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/tests/tools/test_cms_tools.py +0 -0
  44. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/tests/tools/test_local_tools.py +0 -0
  45. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/tests/tools/test_oos_tools.py +0 -0
  46. {alibaba_cloud_ops_mcp_server-0.9.26 → alibaba_cloud_ops_mcp_server-0.9.27}/tests/tools/test_oss_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alibaba-cloud-ops-mcp-server
3
- Version: 0.9.26
3
+ Version: 0.9.27
4
4
  Summary: A MCP server for Alibaba Cloud
5
5
  Author-email: Zheng Dayu <dayu.zdy@alibaba-inc.com>, Zhao Shuaibo <zhaoshuaibo.zsb@alibaba-inc.com>
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alibaba-cloud-ops-mcp-server"
3
- version = "0.9.26"
3
+ version = "0.9.27"
4
4
  description = "A MCP server for Alibaba Cloud"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,11 +1,15 @@
1
1
  import re
2
2
  import logging
3
+ import tarfile
4
+ import zipfile
5
+ import tempfile
6
+ import shutil
3
7
 
4
8
  from alibaba_cloud_ops_mcp_server.tools.api_tools import _tools_api_call
5
9
  from pathlib import Path
6
10
  import alibabacloud_oss_v2 as oss
7
11
  from pydantic import Field
8
- from typing import Optional, Tuple, List
12
+ from typing import Optional, Tuple, List, Dict, Set
9
13
  import json
10
14
  import time
11
15
  from alibabacloud_oos20190601.client import Client as oos20190601Client
@@ -48,33 +52,13 @@ def OOS_CodeDeploy(
48
52
  deploy_region_id: str = Field(description='Region ID for deployment'),
49
53
  application_group_name: str = Field(description='name of the application group'),
50
54
  object_name: str = Field(description='OSS object name'),
51
- file_path: str = Field(description='Local file path to upload. If the file is not in '
52
- '.code_deploy/release directory, it will be copied there.'),
53
- application_start: str = Field(
54
- description='Application start command script. IMPORTANT: If the uploaded artifact '
55
- 'is a tar archive or compressed package (e.g., .tar, .tar.gz, .zip), '
56
- 'you MUST first extract it and navigate into the corresponding directory'
57
- ' before executing the start command. The start command must correspond '
58
- 'to the actual structure of the extracted artifact. For example, if you '
59
- 'upload a tar.gz file containing a Java application, first extract it '
60
- 'with "tar -xzf <filename>.tar.gz", then cd into the extracted '
61
- 'directory, and then run the start command (e.g., "java -jar app.jar" '
62
- 'or "./start.sh"). Ensure the start command matches the actual '
63
- 'executable or script in the extracted artifact to avoid deployment '
64
- 'failures. Do not blindly use the `cd` command; always verify that the corresponding file '
65
- 'and path exist before using it.'),
66
- application_stop: str = Field(description='Application stop command script, Defensive stop command - checks if '
67
- 'the service exists and if the CD path exists, preventing errors '
68
- 'caused by blindly using `cd` or due to non-existent commands.'),
55
+ file_path: str = Field(description='Local file path to upload. If the file is not in .code_deploy/release directory, it will be copied there.'),
69
56
  deploy_language: str = Field(description='Deploy language, like:docker, java, python, nodejs, golang'),
70
57
  port: int = Field(description='Application listening port'),
71
- project_path: Optional[str] = Field(description='Root path of the project. The .code_deploy '
72
- 'directory will be created in this path. '
73
- 'If not provided, will try to infer from file_path '
74
- 'or use current working directory.'),
75
- instance_ids: list = Field(description='AlibabaCloud ECS instance ID List. If empty or not provided, user '
76
- 'will be prompted to create ECS instances.', default=None)
77
-
58
+ project_path: Optional[str] = Field(description='Root path of the project. The .code_deploy directory will be created in this path. If not provided, will try to infer from file_path or use current working directory.'),
59
+ application_start: Optional[str] = Field(default=None, description='**OPTIONAL - DO NOT PROVIDE UNLESS USER EXPLICITLY REQUIRES IT** Application start command script. In normal cases, you should NOT provide this parameter. The system will automatically generate the start command using rule engine based on deployment file analysis and deploy_language. Only provide this parameter when: (1) User explicitly specifies a custom start command, or (2) User provides specific instructions about the start command. The auto-generated command includes defensive checks for file/path existence and proper archive extraction. If you must provide a custom command, ensure it includes defensive checks like "[ -f file ] && command" and handles archive extraction properly.'),
60
+ application_stop: Optional[str] = Field( default=None, description='**OPTIONAL - DO NOT PROVIDE UNLESS USER EXPLICITLY REQUIRES IT** Application stop command script. In normal cases, you should NOT provide this parameter. The system will automatically generate the stop command using rule engine based on deploy_language. Only provide this parameter when: (1) User explicitly specifies a custom stop command, or (2) User provides specific instructions about the stop command. The auto-generated command includes defensive checks and proper process termination.'),
61
+ instance_ids: list = Field(description='AlibabaCloud ECS instance ID List. If empty or not provided, user will be prompted to create ECS instances.', default=None)
78
62
  ):
79
63
  """
80
64
  将应用部署到阿里云ECS实例。使用阿里云OOS(运维编排服务)的CodeDeploy功能实现自动化部署。
@@ -85,31 +69,41 @@ def OOS_CodeDeploy(
85
69
  2. **构建部署产物**:执行构建命令生成压缩包(tar.gz、zip等),保存到 `.code_deploy/release` 目录
86
70
  3. **准备ECS实例**:确保目标ECS实例已创建,获取实例ID列表
87
71
 
88
- ## 核心要求
72
+ ## ⚠️ 重要提示:启动和停止命令参数
73
+
74
+ **正常情况下,不需要提供 application_start 和 application_stop 参数!**
75
+
76
+ - 系统会自动通过规则引擎分析部署文件和 deploy_language,自动生成启动和停止命令
77
+ - 自动生成的命令包含所有必要的防御性检查(文件存在性、路径存在性等)
78
+ - 自动生成的命令正确处理压缩包解压、目录切换、后台运行、日志重定向等
79
+ - 对于 Node.js 应用,自动生成的命令会先执行 `npm install` 安装依赖
80
+
81
+ **只有在以下情况下才需要手动提供这两个参数:**
82
+ 1. 用户明确指定了自定义的启动/停止命令
83
+ 2. 用户提供了关于启动/停止命令的特殊要求或说明
84
+
85
+ **调用建议:直接调用工具,不传 application_start 和 application_stop 参数即可。**
89
86
 
90
- ### 1. 防御性命令设计(必须)
91
- 启动和停止命令必须包含存在性检查,避免因路径/文件/命令不存在导致失败:
87
+ ## 自动生成的命令规范(供参考,了解即可)
88
+
89
+ ### 1. 防御性命令设计
90
+ 自动生成的命令包含存在性检查,避免因路径/文件/命令不存在导致失败:
92
91
  - 压缩包:先检查文件存在再解压 `[ -f app.tar.gz ] && tar -xzf app.tar.gz || exit 1`
93
92
  - 可执行文件:检查文件存在再执行 `[ -f start.sh ] && chmod +x start.sh && ./start.sh || exit 1`
94
93
  - 命令可用性:检查命令是否存在 `command -v npm >/dev/null 2>&1 || exit 1`
95
- - 禁止直接使用 `cd`,必须先验证路径存在
94
+ - 目录切换:先验证路径存在再切换 `[ -d dir ] && cd dir || exit 1`
96
95
 
97
- ### 2. 压缩包处理规范(必须)
98
- 如果产物是压缩包,启动命令必须先解压:
96
+ ### 2. 压缩包处理规范
97
+ 如果产物是压缩包,自动生成的命令会先解压:
99
98
  - 使用非交互式命令:`tar -xzf`、`unzip -o`(自动覆盖,无需确认)
100
99
  - 解压后执行启动命令,确保路径对应
101
- - 示例:`tar -xzf app.tar.gz && nohup java -jar app.jar > /root/app.log 2>&1 &`
100
+ - 示例:`tar -xzf app.tar.gz && [ -d app ] && cd app && nohup java -jar app.jar > /root/app.log 2>&1 &`
102
101
 
103
- ### 3. 后台运行与日志(必须)
104
- 启动命令必须使用后台运行并重定向日志:
102
+ ### 3. 后台运行与日志
103
+ 自动生成的启动命令使用后台运行并重定向日志:
105
104
  - 格式:`nohup <command> > /root/app.log 2>&1 &`
106
105
  - 说明:nohup保持后台运行,`>` 重定向标准输出,`2>&1` 合并错误输出,`&` 后台执行
107
106
 
108
- ### 4. 停止命令规范
109
- 停止命令需检查服务/进程是否存在:
110
- - systemctl服务:`systemctl list-units | grep -q "service" && systemctl stop service`
111
- - 进程名:`pkill -f "process_pattern" || true`
112
-
113
107
  ## 注意事项
114
108
 
115
109
  - 应用和应用分组会自动检查,已存在则跳过创建
@@ -238,13 +232,60 @@ def OOS_CodeDeploy(
238
232
  file_path = str(release_path_resolved)
239
233
  else:
240
234
  logger.info(f"[code_deploy] File already in release directory: {file_path}")
235
+
236
+ # 如果未提供启动/停止命令,尝试通过规则引擎生成
237
+ if not application_start or not application_stop:
238
+ logger.info(f"[code_deploy] Attempting to auto-generate commands using rule engine. "
239
+ f"Provided: start={application_start is not None}, stop={application_stop is not None}")
240
+
241
+ generated_start, generated_stop = _generate_start_stop_commands_by_rules(
242
+ file_path, deploy_language, name, port
243
+ )
244
+
245
+ if not application_start and generated_start:
246
+ application_start = generated_start
247
+ logger.info(f"[code_deploy] Auto-generated start command successfully")
248
+ elif not application_start:
249
+ logger.warning(f"[code_deploy] Failed to generate start command automatically, manual input required")
250
+ return {
251
+ 'error': 'START_COMMAND_REQUIRED',
252
+ 'message': '无法通过规则引擎自动生成启动命令,请手动提供 application_start 参数并再次调用OOS_CodeDeploy',
253
+ 'file_path': file_path,
254
+ 'deploy_language': deploy_language,
255
+ 'instructions': f'''
256
+ ## 启动命令生成失败
257
+
258
+ 系统尝试通过规则引擎自动生成启动命令,但未能成功识别。
259
+
260
+ **文件路径**: {file_path}
261
+ **部署语言**: {deploy_language}
262
+
263
+ 请根据以下信息手动提供启动命令:
264
+ 1. 如果文件是压缩包(tar.gz、zip等),需要先解压
265
+ 2. 启动命令必须包含防御性检查(检查文件/路径是否存在)
266
+ 3. 启动命令必须使用后台运行并重定向日志:`nohup <command> > /root/app.log 2>&1 &`
267
+
268
+ 示例:
269
+ - Java: `[ -f app.tar.gz ] && tar -xzf app.tar.gz && [ -f app/app.jar ] && nohup java -jar app/app.jar > /root/app.log 2>&1 &`
270
+ - Python: `[ -f app.tar.gz ] && tar -xzf app.tar.gz && [ -f app/app.py ] && nohup python app/app.py > /root/app.log 2>&1 &`
271
+ - Node.js: `[ -f app.tar.gz ] && tar -xzf app.tar.gz && [ -f app/package.json ] && nohup npm start > /root/app.log 2>&1 &`
272
+ '''
273
+ }
274
+
275
+ if not application_stop and generated_stop:
276
+ application_stop = generated_stop
277
+ logger.info(f"[code_deploy] Auto-generated stop command successfully")
278
+
279
+ logger.info(f"[code_deploy] Deployment commands ready - start: {bool(application_start)}, stop: {bool(application_stop)}")
280
+
241
281
  region_id_oss = 'cn-hangzhou'
242
282
  is_internal_oss = True if deploy_region_id.lower() == 'cn-hangzhou' else False
243
283
  # Log input parameters
244
284
  logger.info(f"[code_deploy] Input parameters: name={name}, deploy_region_id={deploy_region_id}, "
245
285
  f"application_group_name={application_group_name}, instance_ids={instance_ids}, "
246
286
  f"region_id_oss={region_id_oss}, object_name={object_name}, "
247
- f"is_internal_oss={is_internal_oss}, port={port}")
287
+ f"is_internal_oss={is_internal_oss}, port={port}, "
288
+ f"application_start={application_start}, application_stop={application_stop}")
248
289
 
249
290
  # Upload file to OSS
250
291
  try:
@@ -808,3 +849,493 @@ def _create_revision_deploy_parameters():
808
849
  "Mode": "FailurePause"
809
850
  }
810
851
  }
852
+
853
+
854
+ def _extract_top_level_dir(members: List[str]) -> Optional[str]:
855
+ """
856
+ 从压缩包成员列表中提取顶级目录名
857
+
858
+ 如果所有文件都在同一个顶级目录下,返回该目录名;否则返回 None
859
+ """
860
+ if not members:
861
+ return None
862
+
863
+ top_level = set()
864
+ for member in members:
865
+ parts = member.split('/')
866
+ if parts[0]:
867
+ top_level.add(parts[0])
868
+
869
+ if len(top_level) == 1:
870
+ return list(top_level)[0]
871
+ return None
872
+
873
+
874
+ def _analyze_deployment_file(file_path: str) -> Dict:
875
+ """
876
+ 分析部署文件,返回文件类型和内容列表
877
+
878
+ Returns:
879
+ {
880
+ 'file_type': 'archive' | 'directory' | 'file',
881
+ 'archive_type': 'tar.gz' | 'tar' | 'zip' | None,
882
+ 'file_name': str,
883
+ 'files_in_archive': List[str], # 压缩包内的文件列表
884
+ 'extracted_dir_name': Optional[str], # 解压后的目录名(如果有)
885
+ }
886
+ """
887
+ file_path_obj = Path(file_path)
888
+
889
+ if not file_path_obj.exists():
890
+ logger.warning(f"[_analyze_deployment_file] File does not exist: {file_path}")
891
+ return {'file_type': 'unknown', 'file_name': file_path_obj.name}
892
+
893
+ result = {
894
+ 'file_type': 'file',
895
+ 'archive_type': None,
896
+ 'file_name': file_path_obj.name,
897
+ 'files_in_archive': [],
898
+ 'extracted_dir_name': None,
899
+ }
900
+
901
+ file_name_lower = file_path_obj.name.lower()
902
+
903
+ # 处理 tar.gz 和 tar 文件
904
+ if file_name_lower.endswith('.tar.gz') or file_name_lower.endswith('.tgz'):
905
+ result['file_type'] = 'archive'
906
+ result['archive_type'] = 'tar.gz'
907
+ try:
908
+ with tarfile.open(file_path, 'r:gz') as tar:
909
+ members = tar.getnames()
910
+ result['files_in_archive'] = members
911
+ result['extracted_dir_name'] = _extract_top_level_dir(members)
912
+ logger.info(f"[_analyze_deployment_file] Analyzed tar.gz archive: {len(members)} files, "
913
+ f"extracted_dir: {result['extracted_dir_name']}")
914
+ except Exception as e:
915
+ logger.warning(f"[_analyze_deployment_file] Failed to read tar.gz: {e}")
916
+
917
+ elif file_name_lower.endswith('.tar'):
918
+ result['file_type'] = 'archive'
919
+ result['archive_type'] = 'tar'
920
+ try:
921
+ with tarfile.open(file_path, 'r') as tar:
922
+ members = tar.getnames()
923
+ result['files_in_archive'] = members
924
+ result['extracted_dir_name'] = _extract_top_level_dir(members)
925
+ logger.info(f"[_analyze_deployment_file] Analyzed tar archive: {len(members)} files, "
926
+ f"extracted_dir: {result['extracted_dir_name']}")
927
+ except Exception as e:
928
+ logger.warning(f"[_analyze_deployment_file] Failed to read tar: {e}")
929
+
930
+ elif file_name_lower.endswith('.zip'):
931
+ result['file_type'] = 'archive'
932
+ result['archive_type'] = 'zip'
933
+ try:
934
+ with zipfile.ZipFile(file_path, 'r') as zip_ref:
935
+ members = zip_ref.namelist()
936
+ result['files_in_archive'] = members
937
+ result['extracted_dir_name'] = _extract_top_level_dir(members)
938
+ logger.info(f"[_analyze_deployment_file] Analyzed zip archive: {len(members)} files, "
939
+ f"extracted_dir: {result['extracted_dir_name']}")
940
+ except Exception as e:
941
+ logger.warning(f"[_analyze_deployment_file] Failed to read zip: {e}")
942
+
943
+ return result
944
+
945
+
946
+ def _find_executable_files(files_list: List[str], deploy_language: str) -> Dict[str, List[str]]:
947
+ """
948
+ 在文件列表中查找可执行文件
949
+
950
+ Returns:
951
+ {
952
+ 'jar_files': List[str],
953
+ 'py_files': List[str],
954
+ 'js_files': List[str],
955
+ 'go_binaries': List[str],
956
+ 'shell_scripts': List[str],
957
+ 'package_json': Optional[str],
958
+ 'requirements_txt': Optional[str],
959
+ 'dockerfile': Optional[str],
960
+ }
961
+ """
962
+ result = {
963
+ 'jar_files': [],
964
+ 'py_files': [],
965
+ 'js_files': [],
966
+ 'go_binaries': [],
967
+ 'shell_scripts': [],
968
+ 'package_json': None,
969
+ 'requirements_txt': None,
970
+ 'dockerfile': None,
971
+ }
972
+
973
+ for file_path in files_list:
974
+ file_name = file_path.split('/')[-1].lower()
975
+
976
+ # Java
977
+ if file_name.endswith('.jar'):
978
+ result['jar_files'].append(file_path)
979
+ # Python
980
+ elif file_name.endswith('.py'):
981
+ result['py_files'].append(file_path)
982
+ # Node.js
983
+ elif file_name == 'package.json':
984
+ result['package_json'] = file_path
985
+ elif file_name.endswith('.js'):
986
+ result['js_files'].append(file_path)
987
+ # Go - 只在 golang 语言时查找潜在的二进制文件
988
+ # 排除常见的非二进制文件(文档、配置等)
989
+ elif deploy_language == 'golang':
990
+ # 排除列表:常见的非二进制文件名
991
+ non_binary_names = {
992
+ 'readme', 'license', 'makefile', 'dockerfile', 'changelog',
993
+ 'contributing', 'authors', 'version', 'manifest', 'config',
994
+ 'gitignore', 'dockerignore', 'editorconfig', 'env'
995
+ }
996
+ file_name_no_ext = file_name.split('.')[0] if '.' in file_name else file_name
997
+ # 检查是否是潜在的 Go 二进制:没有扩展名(或只有一个点),且不在排除列表中
998
+ has_extension = '.' in file_name and not file_name.startswith('.')
999
+ if not has_extension and file_name_no_ext.lower() not in non_binary_names:
1000
+ result['go_binaries'].append(file_path)
1001
+ # Shell scripts
1002
+ elif file_name.endswith('.sh'):
1003
+ result['shell_scripts'].append(file_path)
1004
+ # Python requirements
1005
+ elif file_name == 'requirements.txt':
1006
+ result['requirements_txt'] = file_path
1007
+ # Dockerfile
1008
+ elif file_name == 'dockerfile' and not file_path.lower().endswith('.dockerignore'):
1009
+ result['dockerfile'] = file_path
1010
+
1011
+ logger.info(f"[_find_executable_files] Found executable files for {deploy_language}: "
1012
+ f"jars={len(result['jar_files'])}, python={len(result['py_files'])}, "
1013
+ f"js={len(result['js_files'])}, go_bins={len(result['go_binaries'])}, "
1014
+ f"shells={len(result['shell_scripts'])}")
1015
+
1016
+ return result
1017
+
1018
+
1019
+ def _generate_start_command_by_rules(
1020
+ file_path: str,
1021
+ deploy_language: str,
1022
+ file_analysis: Dict,
1023
+ extracted_dir_name: Optional[str] = None,
1024
+ application_name: Optional[str] = None,
1025
+ port: Optional[int] = None
1026
+ ) -> Optional[str]:
1027
+ """
1028
+ 根据规则生成启动命令
1029
+
1030
+ Returns:
1031
+ 生成的启动命令,如果无法通过规则生成则返回 None
1032
+ """
1033
+ file_name = Path(file_path).name
1034
+ file_name_lower = file_name.lower()
1035
+
1036
+ # 判断是否需要解压
1037
+ is_archive = file_analysis['file_type'] == 'archive'
1038
+ extract_cmd = ""
1039
+ work_dir = ""
1040
+
1041
+ if is_archive:
1042
+ archive_type = file_analysis['archive_type']
1043
+ if archive_type == 'tar.gz':
1044
+ extract_cmd = f"[ -f {file_name} ] && tar -xzf {file_name} || exit 1"
1045
+ elif archive_type == 'tar':
1046
+ extract_cmd = f"[ -f {file_name} ] && tar -xf {file_name} || exit 1"
1047
+ elif archive_type == 'zip':
1048
+ extract_cmd = f"[ -f {file_name} ] && unzip -o {file_name} || exit 1"
1049
+
1050
+ # 如果有明确的解压目录,使用它
1051
+ if extracted_dir_name:
1052
+ work_dir = f" && [ -d {extracted_dir_name} ] && cd {extracted_dir_name} || exit 1"
1053
+
1054
+ # 根据语言类型生成命令
1055
+ files_in_archive = file_analysis.get('files_in_archive', [])
1056
+ executable_files = _find_executable_files(files_in_archive, deploy_language)
1057
+
1058
+ start_cmd = ""
1059
+
1060
+ if deploy_language == 'java':
1061
+ # Java 应用:查找 jar 文件
1062
+ jar_files = executable_files['jar_files']
1063
+ if jar_files:
1064
+ # 使用第一个找到的 jar 文件
1065
+ jar_file = jar_files[0].split('/')[-1] # 只取文件名
1066
+ if is_archive and extracted_dir_name:
1067
+ jar_file = f"{extracted_dir_name}/{jar_file}"
1068
+ elif is_archive:
1069
+ jar_file = jar_file # 假设解压到当前目录
1070
+ start_cmd = f"[ -f {jar_file} ] && nohup java -jar {jar_file} > /root/app.log 2>&1 &"
1071
+ logger.info(f"[_generate_start_command_by_rules] Generated Java start command for {jar_file}")
1072
+ else:
1073
+ # 尝试第一个常见的 jar 文件名(带防御性检查,如果不存在会跳过)
1074
+ common_jar_names = ['app.jar', 'application.jar', 'main.jar', 'server.jar']
1075
+ jar_name = common_jar_names[0] # 使用第一个作为默认
1076
+ if is_archive and extracted_dir_name:
1077
+ test_path = f"{extracted_dir_name}/{jar_name}"
1078
+ else:
1079
+ test_path = jar_name
1080
+ # 命令已包含防御性检查,如果文件不存在会失败
1081
+ start_cmd = f"[ -f {test_path} ] && nohup java -jar {test_path} > /root/app.log 2>&1 &"
1082
+
1083
+ elif deploy_language == 'python':
1084
+ # Python 应用:查找 py 文件或 requirements.txt
1085
+ py_files = executable_files['py_files']
1086
+ if py_files:
1087
+ # 优先查找 main.py, app.py, run.py, server.py
1088
+ preferred_names = ['main.py', 'app.py', 'run.py', 'server.py', 'application.py']
1089
+ py_file = None
1090
+ for preferred in preferred_names:
1091
+ for py_path in py_files:
1092
+ if py_path.endswith(preferred):
1093
+ py_file = py_path.split('/')[-1]
1094
+ break
1095
+ if py_file:
1096
+ break
1097
+
1098
+ if not py_file and py_files:
1099
+ py_file = py_files[0].split('/')[-1]
1100
+
1101
+ if py_file:
1102
+ if is_archive and extracted_dir_name:
1103
+ py_file = f"{extracted_dir_name}/{py_file}"
1104
+ start_cmd = f"[ -f {py_file} ] && nohup python {py_file} > /root/app.log 2>&1 &"
1105
+ logger.info(f"[_generate_start_command_by_rules] Generated Python start command for {py_file}")
1106
+ else:
1107
+ # 尝试第一个常见的 Python 文件名(带防御性检查,如果不存在会跳过)
1108
+ common_py_names = ['app.py', 'main.py', 'run.py', 'server.py']
1109
+ py_name = common_py_names[0] # 使用第一个作为默认
1110
+ if is_archive and extracted_dir_name:
1111
+ test_path = f"{extracted_dir_name}/{py_name}"
1112
+ else:
1113
+ test_path = py_name
1114
+ # 命令已包含防御性检查,如果文件不存在会失败
1115
+ start_cmd = f"[ -f {test_path} ] && nohup python {test_path} > /root/app.log 2>&1 &"
1116
+
1117
+ elif deploy_language == 'nodejs':
1118
+ # Node.js 应用:查找 package.json
1119
+ if executable_files['package_json']:
1120
+ package_json_path = executable_files['package_json']
1121
+ if is_archive and extracted_dir_name:
1122
+ package_json_dir = extracted_dir_name
1123
+ else:
1124
+ package_json_dir = "."
1125
+
1126
+ # 检查是否有 start.sh
1127
+ shell_scripts = executable_files['shell_scripts']
1128
+ start_script = None
1129
+ for script in shell_scripts:
1130
+ if 'start' in script.lower():
1131
+ start_script = script.split('/')[-1]
1132
+ break
1133
+
1134
+ if start_script:
1135
+ # 即使有 start.sh,也先执行 npm install 确保依赖已安装
1136
+ script_path_for_cmd = start_script # 使用相对路径(文件名),因为 work_dir 会处理 cd 或已在当前目录
1137
+ start_cmd = f"command -v npm >/dev/null 2>&1 && [ -f package.json ] && npm install && [ -f {script_path_for_cmd} ] && chmod +x {script_path_for_cmd} && nohup ./{script_path_for_cmd} > /root/app.log 2>&1 &"
1138
+ logger.info(f"[_generate_start_command_by_rules] Generated Node.js start command with script: {start_script}")
1139
+ else:
1140
+ # 使用 npm start,必须先执行 npm install
1141
+ start_cmd = f"command -v npm >/dev/null 2>&1 && [ -f package.json ] && npm install && nohup npm start > /root/app.log 2>&1 &"
1142
+ logger.info(f"[_generate_start_command_by_rules] Generated Node.js start command with npm start")
1143
+ else:
1144
+ # 尝试查找 js 文件
1145
+ js_files = executable_files['js_files']
1146
+ if js_files:
1147
+ # 优先查找 index.js, app.js, server.js
1148
+ preferred_names = ['index.js', 'app.js', 'server.js', 'main.js']
1149
+ js_file = None
1150
+ for preferred in preferred_names:
1151
+ for js_path in js_files:
1152
+ if js_path.endswith(preferred):
1153
+ js_file = js_path.split('/')[-1]
1154
+ break
1155
+ if js_file:
1156
+ break
1157
+
1158
+ if not js_file and js_files:
1159
+ js_file = js_files[0].split('/')[-1]
1160
+
1161
+ if js_file:
1162
+ if is_archive and extracted_dir_name:
1163
+ js_file = f"{extracted_dir_name}/{js_file}"
1164
+ start_cmd = f"[ -f {js_file} ] && nohup node {js_file} > /root/app.log 2>&1 &"
1165
+ logger.info(f"[_generate_start_command_by_rules] Generated Node.js start command for {js_file}")
1166
+ else:
1167
+ return None
1168
+ else:
1169
+ return None
1170
+
1171
+ elif deploy_language == 'golang':
1172
+ # Go 应用:查找二进制文件
1173
+ go_binaries = executable_files['go_binaries']
1174
+ # Go 二进制文件通常没有扩展名,且名称可能包含路径
1175
+ # 尝试查找常见的二进制文件名
1176
+ common_bin_names = ['app', 'main', 'server', 'application']
1177
+ binary_file = None
1178
+
1179
+ for bin_name in common_bin_names:
1180
+ for file_path in files_in_archive:
1181
+ file_name_only = file_path.split('/')[-1]
1182
+ if file_name_only == bin_name or file_name_only.startswith(bin_name):
1183
+ binary_file = file_path.split('/')[-1]
1184
+ break
1185
+ if binary_file:
1186
+ break
1187
+
1188
+ if binary_file:
1189
+ if is_archive and extracted_dir_name:
1190
+ binary_file = f"{extracted_dir_name}/{binary_file}"
1191
+ start_cmd = f"[ -f {binary_file} ] && chmod +x {binary_file} && nohup ./{binary_file} > /root/app.log 2>&1 &"
1192
+ logger.info(f"[_generate_start_command_by_rules] Generated Golang start command for {binary_file}")
1193
+ else:
1194
+ # 尝试直接使用文件名(去掉扩展名)
1195
+ base_name = file_name.rsplit('.', 1)[0] if '.' in file_name else file_name
1196
+ if is_archive and extracted_dir_name:
1197
+ test_path = f"{extracted_dir_name}/{base_name}"
1198
+ else:
1199
+ test_path = base_name
1200
+ start_cmd = f"[ -f {test_path} ] && chmod +x {test_path} && nohup ./{test_path} > /root/app.log 2>&1 & || true"
1201
+ if not start_cmd:
1202
+ return None
1203
+
1204
+ elif deploy_language == 'docker':
1205
+ # Docker 应用:检查是否有 Dockerfile,然后生成 docker build 和 docker run 命令
1206
+ dockerfile_path = executable_files.get('dockerfile')
1207
+
1208
+ if not dockerfile_path:
1209
+ return None
1210
+
1211
+ # 生成 Docker 命令
1212
+ # 使用应用名称作为镜像和容器名称的基础(如果没有,使用默认值)
1213
+ image_name = (application_name or 'app').lower().replace(' ', '-').replace('_', '-')
1214
+ container_name = image_name
1215
+
1216
+ # 确定 Dockerfile 的路径
1217
+ if is_archive and extracted_dir_name:
1218
+ # 如果 Dockerfile 在解压目录中
1219
+ dockerfile_dir = extracted_dir_name
1220
+ dockerfile_for_build = "Dockerfile" # 在解压目录中,使用相对路径
1221
+ elif is_archive:
1222
+ # 解压到当前目录
1223
+ dockerfile_dir = "."
1224
+ dockerfile_for_build = "Dockerfile"
1225
+ else:
1226
+ # 不是压缩包,使用文件所在目录
1227
+ dockerfile_dir = "."
1228
+ dockerfile_for_build = "Dockerfile"
1229
+
1230
+ # 构建 Docker 命令
1231
+ port_mapping = f"-p {port}:{port} " if port else ""
1232
+
1233
+ # 检查 Docker 是否安装
1234
+ docker_check = "command -v docker >/dev/null 2>&1 && "
1235
+
1236
+ # 停止并删除旧容器(如果存在)
1237
+ stop_old_container = f"docker stop {container_name} 2>/dev/null || true && docker rm {container_name} 2>/dev/null || true && "
1238
+
1239
+ # 构建镜像(检查 Dockerfile 是否存在)
1240
+ build_cmd = f"[ -f {dockerfile_for_build} ] && docker build -t {image_name}:latest . && "
1241
+
1242
+ # 运行容器
1243
+ run_cmd = f"docker run -d --name {container_name} {port_mapping}{image_name}:latest"
1244
+
1245
+ # 组合命令
1246
+ start_cmd = f"{docker_check}{stop_old_container}{build_cmd}{run_cmd}"
1247
+ logger.info(f"[_generate_start_command_by_rules] Generated Docker start command for {container_name}")
1248
+
1249
+ else:
1250
+ # 未知语言类型,返回 None
1251
+ return None
1252
+
1253
+ # 组合命令
1254
+ if is_archive:
1255
+ if work_dir:
1256
+ final_cmd = f"{extract_cmd}{work_dir} && {start_cmd}"
1257
+ else:
1258
+ final_cmd = f"{extract_cmd} && {start_cmd}"
1259
+ else:
1260
+ final_cmd = start_cmd
1261
+
1262
+ return final_cmd
1263
+
1264
+
1265
+ def _generate_stop_command_by_rules(
1266
+ deploy_language: str,
1267
+ file_analysis: Dict,
1268
+ extracted_dir_name: Optional[str] = None,
1269
+ application_name: Optional[str] = None
1270
+ ) -> str:
1271
+ """
1272
+ 根据规则生成停止命令
1273
+
1274
+ Returns:
1275
+ 生成的停止命令(总是返回有效命令,未知语言使用通用命令)
1276
+ """
1277
+ # 语言到停止命令的映射
1278
+ stop_commands = {
1279
+ 'java': "pkill -f 'java -jar' || true",
1280
+ 'python': "pkill -f 'python.*\\.py' || true",
1281
+ 'nodejs': "pkill -f 'node.*\\.js' || pkill -f 'npm start' || true",
1282
+ 'golang': "pkill -f './app' || pkill -f './main' || pkill -f './server' || true",
1283
+ }
1284
+
1285
+ # Docker 需要特殊处理(使用容器名)
1286
+ if deploy_language == 'docker':
1287
+ container_name = (application_name or 'app').lower().replace(' ', '-').replace('_', '-')
1288
+ stop_cmd = f"docker stop {container_name} 2>/dev/null || true && docker rm {container_name} 2>/dev/null || true"
1289
+ else:
1290
+ # 使用映射表,未知语言使用通用命令
1291
+ stop_cmd = stop_commands.get(deploy_language, "pkill -f 'app' || true")
1292
+
1293
+ return stop_cmd
1294
+
1295
+
1296
+ def _generate_start_stop_commands_by_rules(
1297
+ file_path: str,
1298
+ deploy_language: str,
1299
+ application_name: Optional[str] = None,
1300
+ port: Optional[int] = None
1301
+ ) -> Tuple[Optional[str], Optional[str]]:
1302
+ """
1303
+ 根据工程规则生成启动和停止命令
1304
+
1305
+ Args:
1306
+ file_path: 部署文件路径
1307
+ deploy_language: 部署语言类型
1308
+ application_name: 应用名称(用于 Docker 容器命名等)
1309
+ port: 应用端口(用于 Docker 端口映射等)
1310
+
1311
+ Returns:
1312
+ (start_command, stop_command): 如果无法通过规则生成则返回 (None, None)
1313
+ """
1314
+ try:
1315
+ # 分析部署文件
1316
+ file_analysis = _analyze_deployment_file(file_path)
1317
+ extracted_dir_name = file_analysis.get('extracted_dir_name')
1318
+
1319
+ # 生成启动命令
1320
+ start_cmd = _generate_start_command_by_rules(
1321
+ file_path, deploy_language, file_analysis, extracted_dir_name, application_name, port
1322
+ )
1323
+
1324
+ # 生成停止命令
1325
+ stop_cmd = _generate_stop_command_by_rules(
1326
+ deploy_language, file_analysis, extracted_dir_name, application_name
1327
+ )
1328
+
1329
+ # 记录结果
1330
+ if start_cmd:
1331
+ logger.info(f"[_generate_start_stop_commands_by_rules] Successfully generated start command for {deploy_language}")
1332
+ else:
1333
+ logger.warning(f"[_generate_start_stop_commands_by_rules] Failed to generate start command for {deploy_language}")
1334
+
1335
+ logger.info(f"[_generate_start_stop_commands_by_rules] Generated stop command for {deploy_language}")
1336
+
1337
+ return (start_cmd, stop_cmd)
1338
+
1339
+ except Exception as e:
1340
+ logger.warning(f"[_generate_start_stop_commands_by_rules] Failed to generate commands: {e}")
1341
+ return (None, None)