Skip to content

Commit baf1f7e

Browse files
committed
feat(vefaas): enhance deployment workflow and add comprehensive tests
## Summary This update improves the Python deployment process, adds comprehensive E2E and unit tests, and refines tool behavior based on real-world usage. ## Deployment Enhancements ### Python Dependency Installation - Handle 'Dequeued' status correctly as an intermediate state - Explicitly trigger CreateDependencyInstallTask before polling - Increase timeout to 300 seconds for dependency installation - Refactor release_function to reuse SDK wait_for_dependency_install logic ### Script Permissions - Preserve original file permissions when packaging - Proactively grant 755 to script files (.sh, .bash, .py, .pl, .rb) - Fixes 'Permission denied' errors for run.sh ### Configuration Management - Add vefaas.yaml to DEFAULT_VEFAASIGNORE - Save config in update_function when project_path is provided - Fix NAME_CONFLICT error messages to require user decision ## Tool Behavior Improvements ### update_function - Refined next_step message to not auto-trigger release_function - Gives user control over when to publish changes ### release_function - Enhanced to correctly handle Dequeued status - Reuses SDK dependency waiting logic for consistency ## Code Quality ### Cleanup - Removed all debugging logger.debug statements - Translated Chinese comments to English - Fixed deprecated pathspec 'gitwildmatch' -> 'gitignore' ## Tests ### E2E Tests (tests/test_e2e.py) - Application Workflow (Python/Node.js): Deploy + Update + Redeploy - Function Local Dev (Python/Node.js): Deploy + Update Code + Release ### Unit Tests (tests/test_unit.py) - 18 tests - Package directory and ignore patterns - Caddyfile generation - Project detection (FastAPI, Flask, Vite) - Configuration read/write - App name generation
1 parent c1f2092 commit baf1f7e

File tree

6 files changed

+816
-73
lines changed

6 files changed

+816
-73
lines changed

server/mcp_server_vefaas_function/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-server-vefaas-function"
3-
version = "0.0.6"
3+
version = "0.0.7"
44
description = "MCP server for managing veFaaS (Volc Engine Function as a Service) functions"
55
readme = "README.md"
66
requires-python = ">=3.12"

server/mcp_server_vefaas_function/src/mcp_server_vefaas_function/vefaas_cli_sdk/deploy.py

Lines changed: 119 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,66 @@
7272
7373
# veFaaS CLI config
7474
.vefaas/
75+
vefaas.yaml
7576
"""
7677

7778
# Default Caddyfile name for static sites
7879
DEFAULT_CADDYFILE_NAME = "DefaultCaddyFile"
7980

8081

82+
def generate_app_name_from_path(project_path: str) -> str:
83+
"""
84+
Generate application name from project path.
85+
86+
Rules:
87+
1. Get project folder name
88+
2. Convert to lowercase
89+
3. Replace non-alphanumeric characters with hyphens
90+
4. Remove consecutive hyphens
91+
5. Remove leading/trailing hyphens
92+
6. Truncate if too long
93+
7. Add random suffix to avoid conflicts
94+
95+
Args:
96+
project_path: Absolute path to project
97+
98+
Returns:
99+
Processed app name, e.g., "my-project-abc123"
100+
"""
101+
import re
102+
import random
103+
import string
104+
105+
# Get folder name
106+
folder_name = os.path.basename(os.path.normpath(project_path))
107+
108+
# Convert to lowercase
109+
name = folder_name.lower()
110+
111+
# Replace non-alphanumeric with hyphens
112+
name = re.sub(r'[^a-z0-9]', '-', name)
113+
114+
# Remove consecutive hyphens
115+
name = re.sub(r'-+', '-', name)
116+
117+
# Remove leading/trailing hyphens
118+
name = name.strip('-')
119+
120+
# Use default if empty
121+
if not name:
122+
name = "app"
123+
124+
# Truncate to reasonable length (reserve space for suffix)
125+
max_base_len = 20
126+
if len(name) > max_base_len:
127+
name = name[:max_base_len].rstrip('-')
128+
129+
# Add random suffix to avoid conflicts
130+
suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
131+
132+
return f"{name}-{suffix}"
133+
134+
81135
def read_gitignore_patterns(base_dir: str) -> List[str]:
82136
"""Read .gitignore file patterns. Ported from vefaas-cli."""
83137
gitignore_path = os.path.join(base_dir, ".gitignore")
@@ -117,7 +171,7 @@ def create_ignore_filter(
117171
) -> pathspec.PathSpec:
118172
"""Create a pathspec filter from gitignore/vefaasignore patterns. Ported from vefaas-cli."""
119173
all_patterns = gitignore_patterns + vefaasignore_patterns + (additional_patterns or [])
120-
return pathspec.PathSpec.from_lines("gitwildmatch", all_patterns)
174+
return pathspec.PathSpec.from_lines("gitignore", all_patterns)
121175

122176

123177
def render_default_caddyfile_content() -> str:
@@ -245,10 +299,12 @@ def _check_api_error(self, result: dict, action: str) -> None:
245299
# Check for common error patterns and provide clear guidance
246300
if "already exists" in message.lower() or "duplicate" in message.lower():
247301
raise ValueError(
248-
f"[{action}] Name already exists: {message}\n"
249-
"To update an existing application, get the application_id from `.vefaas/config.json` or console, "
250-
"then call deploy_application with application_id parameter. "
251-
"Do NOT use function_id directly - always use application_id for updates."
302+
f"[{action}] NAME_CONFLICT: {message}\n"
303+
"**YOU MUST ASK THE USER** to choose one of the following options:\n"
304+
" 1. Update existing application: Get application_id from `.vefaas/config.json` or console, "
305+
"then call deploy_application with application_id parameter\n"
306+
" 2. Deploy as new application: Call deploy_application with a different name\n"
307+
"DO NOT automatically choose an option. Present both choices to the user and wait for their decision."
252308
)
253309
elif "not found" in message.lower():
254310
raise ValueError(f"[{action}] Resource not found: {message}")
@@ -382,6 +438,10 @@ def get_dependency_install_status(self, function_id: str) -> dict:
382438
"""Get dependency installation task status"""
383439
return self.call("GetDependencyInstallTaskStatus", {"FunctionId": function_id})
384440

441+
def create_dependency_install_task(self, function_id: str) -> dict:
442+
"""Create dependency installation task for Python projects"""
443+
return self.call("CreateDependencyInstallTask", {"FunctionId": function_id})
444+
385445
# ========== Application Operations ==========
386446

387447
def get_application(self, app_id: str) -> dict:
@@ -547,7 +607,7 @@ def wait_for_application_deploy(
547607
return {"success": True, "access_url": access_url}
548608

549609
if status.lower() in ("deploy_fail", "deleted", "delete_fail"):
550-
# Try to get detailed error from GetReleaseStatus (like vefaas-cli)
610+
# Try to get detailed error from GetReleaseStatus
551611
error_details = {}
552612
function_id = None
553613
try:
@@ -617,8 +677,10 @@ def wait_for_dependency_install(
617677
"""Wait for Python dependency installation to complete."""
618678
start_time = time.time()
619679
last_status = ""
680+
poll_count = 0
620681

621682
while time.time() - start_time < timeout_seconds:
683+
poll_count += 1
622684
try:
623685
result = client.get_dependency_install_status(function_id)
624686
status = result.get("Result", {}).get("Status", "")
@@ -627,16 +689,29 @@ def wait_for_dependency_install(
627689
logger.info(f"[dependency] Installation status: {status}")
628690
last_status = status
629691

692+
# Success status
630693
if status.lower() in ("succeeded", "success", "done"):
631694
return {"success": True, "status": status}
632695

696+
# Failed status
633697
if status.lower() == "failed":
634698
raise ValueError("Dependency installation failed")
635699

700+
# In-progress status (Dequeued = queued, InProgress = installing)
701+
if status.lower() in ("dequeued", "inprogress", "in_progress", "pending"):
702+
# Normal intermediate status, continue polling
703+
pass
704+
elif not status and poll_count > 3:
705+
# Empty status may indicate no dependencies to install
706+
return {"success": True, "status": "no_dependency"}
707+
636708
except ValueError:
637709
raise
638710
except Exception as e:
639711
logger.warning(f"[dependency] Error checking status: {e}")
712+
# Multiple failures may indicate no dependency install task
713+
if poll_count > 5:
714+
return {"success": True, "status": "skipped"}
640715

641716
time.sleep(poll_interval_seconds)
642717

@@ -702,7 +777,30 @@ def package_directory(directory: str, base_dir: Optional[str] = None, include_gi
702777
continue
703778

704779
file_path = os.path.join(root, file)
705-
zf.write(file_path, arcname)
780+
781+
# Use ZipInfo to preserve file permissions (especially executable)
782+
info = zipfile.ZipInfo(arcname)
783+
784+
# Get original file permissions
785+
file_stat = os.stat(file_path)
786+
original_mode = file_stat.st_mode & 0o777
787+
788+
# Grant execute permission (755) for script files
789+
script_extensions = ('.sh', '.bash', '.py', '.pl', '.rb')
790+
if file.lower().endswith(script_extensions):
791+
# Ensure execute permission: original | 0o755
792+
final_mode = original_mode | 0o755
793+
else:
794+
final_mode = original_mode
795+
796+
# Unix permissions stored in high 16 bits of external_attr
797+
# Format: (permissions << 16) | (file_type << 28)
798+
# 0o100000 = regular file
799+
info.external_attr = (final_mode << 16) | (0o100000 << 16)
800+
801+
# Read file content and write to zip
802+
with open(file_path, 'rb') as f:
803+
zf.writestr(info, f.read())
706804

707805
buffer.seek(0)
708806
zip_bytes = buffer.read()
@@ -757,16 +855,21 @@ def log(msg: str):
757855
else:
758856
log(f"[config] Config region ({config_region}) differs from target region ({client.region}), will create new application")
759857

858+
# Auto-generate app name from project path if not provided for new app
760859
if not config.name and not config.application_id:
761-
raise ValueError("Must provide name or application_id")
860+
config.name = generate_app_name_from_path(config.project_path)
861+
log(f"[config] Auto-generated app name: {config.name}")
762862

763863
# 0. Early check for duplicate application name
764864
if config.name and not config.application_id:
765865
existing_app_id = client.find_application_by_name(config.name)
766866
if existing_app_id:
767867
raise ValueError(
768-
f"Application name '{config.name}' already exists (ID: {existing_app_id}). "
769-
f"To update this application, pass application_id='{existing_app_id}' parameter."
868+
f"NAME_CONFLICT: Application name '{config.name}' already exists (existing_application_id: {existing_app_id}). "
869+
f"**YOU MUST ASK THE USER** to choose one of the following options:\n"
870+
f" 1. Update existing application: Call deploy_application with application_id='{existing_app_id}'\n"
871+
f" 2. Deploy as new application: Call deploy_application with a different name\n"
872+
f"DO NOT automatically choose an option. Present both choices to the user and wait for their decision."
770873
)
771874

772875
# 0.5 Early check: if updating existing app and deployment is in progress, return early
@@ -963,8 +1066,13 @@ def log(msg: str):
9631066

9641067
# 6. Wait for dependency installation (Python)
9651068
if is_python:
966-
log("[6/7] Waiting for dependency installation...")
1069+
log("[6/7] Installing dependencies...")
9671070
try:
1071+
# Trigger dependency install task
1072+
client.create_dependency_install_task(target_function_id)
1073+
log(" → Dependency installation task created")
1074+
1075+
# Wait for installation to complete
9681076
wait_for_dependency_install(client, target_function_id)
9691077
log(" → Dependencies installed")
9701078
except Exception as e:

0 commit comments

Comments
 (0)