| Server IP : 3.147.158.171 / Your IP : 216.73.216.216 Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.21 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tsai/repo/builder/backend/app/services/ |
Upload File : |
"""
Builder Service
Core service for executing build steps. Supports both CLI and streaming API usage
via optional progress callbacks.
"""
import asyncio
import json
import logging
import os
import re
import subprocess
import time
import uuid
from datetime import datetime
from typing import Optional, Callable, Awaitable, List, Dict
from app.types import (
BuildConfig,
BuildStep,
BuildProgressEvent,
BuildResult,
StepResult,
StepStatus,
StepType,
)
# Directory for build logs
LOGS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs', 'builds')
# Directory for config files
CONFIGS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'configs')
class BuilderService:
"""
Service for executing build configurations.
Supports:
- Sequential step execution
- Multiple step types (shell, git, server control, scripts)
- Progress callbacks for streaming
- Detailed logging
- Timeout handling
"""
def __init__(self):
self._build_logger: Optional[logging.Logger] = None
self._build_log_handler: Optional[logging.FileHandler] = None
async def run_build(
self,
config: BuildConfig,
dry_run: bool = False,
progress_callback: Optional[Callable[[BuildProgressEvent], Awaitable[None]]] = None
) -> BuildResult:
"""
Execute a build configuration.
Args:
config: Build configuration with steps to execute
dry_run: If true, validate without executing
progress_callback: Optional async callback for progress updates
Returns:
BuildResult with execution details
"""
build_id = str(uuid.uuid4())[:8]
overall_start = time.time()
# Set up logging
log_path = self._setup_build_logger(build_id, config.name)
self._log('info', f"Build {build_id} started: {config.name}")
self._log('info', f"Log file: {log_path}")
self._log('info', f"Total steps: {len(config.steps)}")
if dry_run:
self._log('info', "DRY RUN MODE - steps will be validated but not executed")
step_results: List[StepResult] = []
completed_steps = 0
failed_steps = 0
skipped_steps = 0
disabled_steps = 0
build_error: Optional[str] = None
try:
# Emit starting event
await self._emit_progress(
progress_callback,
BuildProgressEvent(
status='starting',
progress=0,
message=f'Starting build: {config.name}',
build_id=build_id
)
)
total_steps = len(config.steps)
should_continue = True
for i, step in enumerate(config.steps):
# Skip disabled steps
if not step.enabled:
result = StepResult(
step_name=step.name,
status=StepStatus.SKIPPED,
output="Step disabled in configuration"
)
step_results.append(result)
disabled_steps += 1
self._log('info', f"Step {i+1}/{total_steps} DISABLED: {step.name}")
continue
if not should_continue:
# Skip remaining steps
result = StepResult(
step_name=step.name,
status=StepStatus.SKIPPED,
output="Skipped due to previous failure"
)
step_results.append(result)
skipped_steps += 1
self._log('info', f"Step {i+1}/{total_steps} SKIPPED: {step.name}")
continue
# Calculate progress (reserve 0% for start, 100% for complete)
progress = int(5 + (i / total_steps) * 90)
# Emit step starting event
await self._emit_progress(
progress_callback,
BuildProgressEvent(
status='running_step',
progress=progress,
message=f'Running step {i+1}/{total_steps}: {step.name}',
current_step=step.name,
build_id=build_id
)
)
self._log('info', f"Step {i+1}/{total_steps} RUNNING: {step.name} ({step.step_type.value})")
# Execute the step
if dry_run:
result = await self._validate_step(step)
else:
result = await self._execute_step(step)
step_results.append(result)
# Log result
if result.status == StepStatus.SUCCESS:
completed_steps += 1
self._log('info', f"Step {i+1}/{total_steps} SUCCESS: {step.name} ({result.duration:.2f}s)")
else:
failed_steps += 1
self._log('error', f"Step {i+1}/{total_steps} FAILED: {step.name} - {result.error}")
# Check if we should continue
if config.stop_on_failure and not step.continue_on_failure:
should_continue = False
build_error = f"Build stopped at step '{step.name}': {result.error}"
self._log('error', build_error)
# Emit step complete event
step_progress = int(5 + ((i + 1) / total_steps) * 90)
await self._emit_progress(
progress_callback,
BuildProgressEvent(
status='step_complete',
progress=step_progress,
message=f'Step {i+1}/{total_steps} complete: {step.name}',
current_step=step.name,
step_result=result,
build_id=build_id
)
)
total_duration = time.time() - overall_start
success = failed_steps == 0
self._log('info', f"Build {build_id} {'SUCCEEDED' if success else 'FAILED'}")
self._log('info', f"Completed: {completed_steps}, Failed: {failed_steps}, Skipped: {skipped_steps}, Disabled: {disabled_steps}")
self._log('info', f"Total duration: {total_duration:.2f}s")
return BuildResult(
build_id=build_id,
config_name=config.name,
success=success,
total_steps=total_steps,
completed_steps=completed_steps,
failed_steps=failed_steps,
skipped_steps=skipped_steps,
disabled_steps=disabled_steps,
step_results=step_results,
total_duration=total_duration,
error=build_error
)
except Exception as e:
total_duration = time.time() - overall_start
self._log('error', f"Build {build_id} error: {str(e)}")
return BuildResult(
build_id=build_id,
config_name=config.name,
success=False,
total_steps=len(config.steps),
completed_steps=completed_steps,
failed_steps=failed_steps + 1,
skipped_steps=len(config.steps) - completed_steps - failed_steps - disabled_steps - 1,
disabled_steps=disabled_steps,
step_results=step_results,
total_duration=total_duration,
error=str(e)
)
finally:
self._cleanup_build_logger()
async def _execute_step(self, step: BuildStep) -> StepResult:
"""Execute a single build step."""
start_time = time.time()
try:
if step.step_type == StepType.SHELL:
return await self._execute_shell(step, start_time)
elif step.step_type == StepType.GIT_PULL:
return await self._execute_git_pull(step, start_time)
elif step.step_type == StepType.SERVER_START:
return await self._execute_server_start(step, start_time)
elif step.step_type == StepType.SERVER_STOP:
return await self._execute_server_stop(step, start_time)
elif step.step_type == StepType.SCRIPT:
return await self._execute_script(step, start_time)
elif step.step_type == StepType.WAIT:
return await self._execute_wait(step, start_time)
elif step.step_type == StepType.INCLUDE:
return await self._execute_include(step, start_time)
else:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error=f"Unknown step type: {step.step_type}",
duration=time.time() - start_time
)
except asyncio.TimeoutError:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error=f"Step timed out after {step.timeout} seconds",
duration=time.time() - start_time
)
except Exception as e:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error=str(e),
duration=time.time() - start_time
)
async def _validate_step(self, step: BuildStep) -> StepResult:
"""Validate a step without executing (for dry run)."""
start_time = time.time()
# Basic validation
errors = []
if step.step_type in [StepType.SHELL, StepType.SCRIPT, StepType.GIT_PULL]:
if not step.command:
errors.append("Missing command")
if step.step_type == StepType.INCLUDE:
if not step.config:
errors.append("Missing config file for include step")
else:
config_path = os.path.join(CONFIGS_DIR, step.config)
if not os.path.exists(config_path):
errors.append(f"Config file not found: {config_path}")
if step.working_dir and not os.path.exists(step.working_dir):
errors.append(f"Working directory does not exist: {step.working_dir}")
if errors:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error="; ".join(errors),
duration=time.time() - start_time
)
return StepResult(
step_name=step.name,
status=StepStatus.SUCCESS,
output="[DRY RUN] Step validated successfully",
duration=time.time() - start_time
)
async def _execute_shell(self, step: BuildStep, start_time: float) -> StepResult:
"""Execute a shell command."""
if not step.command:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error="No command specified",
duration=time.time() - start_time
)
self._log('info', f" Executing: {step.command}")
env = os.environ.copy()
if step.environment:
env.update(step.environment)
try:
process = await asyncio.wait_for(
asyncio.create_subprocess_shell(
step.command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=step.working_dir,
env=env
),
timeout=step.timeout
)
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=step.timeout
)
output = stdout.decode('utf-8', errors='replace').strip()
error_output = stderr.decode('utf-8', errors='replace').strip()
if output:
self._log('info', f" Output: {output[:500]}{'...' if len(output) > 500 else ''}")
if error_output:
self._log('warning', f" Stderr: {error_output[:500]}{'...' if len(error_output) > 500 else ''}")
if process.returncode == 0:
return StepResult(
step_name=step.name,
status=StepStatus.SUCCESS,
output=output,
return_code=process.returncode,
duration=time.time() - start_time
)
else:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
output=output,
error=error_output or f"Command exited with code {process.returncode}",
return_code=process.returncode,
duration=time.time() - start_time
)
except asyncio.TimeoutError:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error=f"Command timed out after {step.timeout} seconds",
duration=time.time() - start_time
)
async def _execute_git_pull(self, step: BuildStep, start_time: float) -> StepResult:
"""Execute a git pull operation."""
working_dir = step.working_dir or step.command or os.getcwd()
command = f"git -C {working_dir} pull"
self._log('info', f" Git pull in: {working_dir}")
# Reuse shell execution
shell_step = BuildStep(
name=step.name,
step_type=StepType.SHELL,
command=command,
working_dir=None, # Already in command
timeout=step.timeout,
environment=step.environment
)
return await self._execute_shell(shell_step, start_time)
async def _execute_server_start(self, step: BuildStep, start_time: float) -> StepResult:
"""Start a server (runs command in background)."""
if not step.command:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error="No server start command specified",
duration=time.time() - start_time
)
self._log('info', f" Starting server: {step.command}")
env = os.environ.copy()
if step.environment:
env.update(step.environment)
try:
# Start process in background (don't wait for completion)
process = subprocess.Popen(
step.command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=step.working_dir,
env=env,
start_new_session=True # Detach from parent
)
# Give it a moment to start
await asyncio.sleep(2)
# Check if it's still running
if process.poll() is None:
return StepResult(
step_name=step.name,
status=StepStatus.SUCCESS,
output=f"Server started with PID {process.pid}",
duration=time.time() - start_time
)
else:
stdout, stderr = process.communicate()
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error=f"Server exited immediately: {stderr.decode('utf-8', errors='replace')}",
return_code=process.returncode,
duration=time.time() - start_time
)
except Exception as e:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error=str(e),
duration=time.time() - start_time
)
async def _execute_server_stop(self, step: BuildStep, start_time: float) -> StepResult:
"""Stop a server (kill by name or port)."""
if not step.command:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error="No stop command/pattern specified",
duration=time.time() - start_time
)
self._log('info', f" Stopping server: {step.command}")
# Use pkill or the provided command
shell_step = BuildStep(
name=step.name,
step_type=StepType.SHELL,
command=step.command,
working_dir=step.working_dir,
timeout=step.timeout,
environment=step.environment
)
result = await self._execute_shell(shell_step, start_time)
# For server stop, exit code 1 might just mean "not running" which is okay
if result.status == StepStatus.FAILED and result.return_code == 1:
return StepResult(
step_name=step.name,
status=StepStatus.SUCCESS,
output="Server was not running or has been stopped",
duration=result.duration
)
return result
async def _execute_script(self, step: BuildStep, start_time: float) -> StepResult:
"""Execute a Python script."""
if not step.command:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error="No script path specified",
duration=time.time() - start_time
)
# Check if script exists
script_path = step.command
if step.working_dir:
script_path = os.path.join(step.working_dir, step.command)
if not os.path.exists(script_path):
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error=f"Script not found: {script_path}",
duration=time.time() - start_time
)
self._log('info', f" Running script: {script_path}")
# Execute with Python
shell_step = BuildStep(
name=step.name,
step_type=StepType.SHELL,
command=f"python {step.command}",
working_dir=step.working_dir,
timeout=step.timeout,
environment=step.environment
)
return await self._execute_shell(shell_step, start_time)
async def _execute_wait(self, step: BuildStep, start_time: float) -> StepResult:
"""Wait for a specified duration or condition."""
# Simple implementation: wait for the timeout value as seconds
wait_seconds = step.timeout if step.timeout > 0 else 5
self._log('info', f" Waiting {wait_seconds} seconds...")
await asyncio.sleep(wait_seconds)
return StepResult(
step_name=step.name,
status=StepStatus.SUCCESS,
output=f"Waited {wait_seconds} seconds",
duration=time.time() - start_time
)
async def _execute_include(self, step: BuildStep, start_time: float) -> StepResult:
"""Execute an included config file."""
if not step.config:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error="No config file specified for include step",
duration=time.time() - start_time
)
config_path = os.path.join(CONFIGS_DIR, step.config)
self._log('info', f" Including config: {step.config}")
if not os.path.exists(config_path):
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error=f"Config file not found: {config_path}",
duration=time.time() - start_time
)
try:
# Load and parse the config file
with open(config_path, 'r') as f:
config_data = json.load(f)
# Substitute parameters first (before env vars, so params aren't
# wiped out by missing env vars with the same name)
if step.parameters:
config_data = self._substitute_parameters(config_data, step.parameters)
# Substitute environment variables
config_data = self._substitute_env_vars(config_data)
# Parse the config into BuildConfig
included_config = BuildConfig(**config_data)
self._log('info', f" Executing {len(included_config.steps)} steps from {step.config}")
# Execute each step from the included config
completed = 0
failed = 0
outputs = []
for i, included_step in enumerate(included_config.steps):
if not included_step.enabled:
self._log('info', f" [{i+1}/{len(included_config.steps)}] DISABLED: {included_step.name}")
continue
self._log('info', f" [{i+1}/{len(included_config.steps)}] Running: {included_step.name}")
result = await self._execute_step(included_step)
if result.status == StepStatus.SUCCESS:
completed += 1
outputs.append(f"[OK] {included_step.name}")
self._log('info', f" [{i+1}/{len(included_config.steps)}] SUCCESS: {included_step.name}")
else:
failed += 1
outputs.append(f"[FAIL] {included_step.name}: {result.error}")
self._log('error', f" [{i+1}/{len(included_config.steps)}] FAILED: {included_step.name} - {result.error}")
# Stop if the included config says to stop on failure
if included_config.stop_on_failure and not included_step.continue_on_failure:
break
total_duration = time.time() - start_time
if failed > 0:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
output="\n".join(outputs),
error=f"{failed} step(s) failed in included config",
duration=total_duration
)
return StepResult(
step_name=step.name,
status=StepStatus.SUCCESS,
output=f"Completed {completed} steps from {step.config}\n" + "\n".join(outputs),
duration=total_duration
)
except json.JSONDecodeError as e:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error=f"Invalid JSON in config file: {e}",
duration=time.time() - start_time
)
except Exception as e:
return StepResult(
step_name=step.name,
status=StepStatus.FAILED,
error=f"Error executing included config: {e}",
duration=time.time() - start_time
)
def _substitute_parameters(self, data: dict, params: Dict[str, str]) -> dict:
"""Recursively substitute ${PARAM_NAME} placeholders in config data."""
def substitute_value(value):
if isinstance(value, str):
# Replace ${PARAM_NAME} patterns with parameter values
for param_name, param_value in params.items():
value = value.replace(f"${{{param_name}}}", param_value)
return value
elif isinstance(value, dict):
return {k: substitute_value(v) for k, v in value.items()}
elif isinstance(value, list):
return [substitute_value(item) for item in value]
return value
return substitute_value(data)
def _substitute_env_vars(self, data: dict) -> dict:
"""Recursively substitute ${VAR_NAME} placeholders with environment variable values."""
def substitute_value(value):
if isinstance(value, str):
# Replace ${VAR_NAME} patterns with env values
import re
def replace_var(match):
var_name = match.group(1)
return os.getenv(var_name, '')
return re.sub(r'\$\{(\w+)\}', replace_var, value)
elif isinstance(value, dict):
return {k: substitute_value(v) for k, v in value.items()}
elif isinstance(value, list):
return [substitute_value(item) for item in value]
return value
return substitute_value(data)
async def _emit_progress(
self,
callback: Optional[Callable[[BuildProgressEvent], Awaitable[None]]],
event: BuildProgressEvent
):
"""Emit progress event via callback if provided."""
if callback:
await callback(event)
await asyncio.sleep(0) # Force flush
def _setup_build_logger(self, build_id: str, config_name: str) -> str:
"""Set up a dedicated logger for this build."""
os.makedirs(LOGS_DIR, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_name = ''.join(c if c.isalnum() or c in '-_' else '_' for c in config_name)
log_filename = f'{timestamp}_{build_id}_{safe_name}.log'
log_path = os.path.join(LOGS_DIR, log_filename)
self._build_logger = logging.getLogger(f'build_{build_id}')
self._build_logger.setLevel(logging.INFO)
self._build_logger.handlers.clear()
self._build_log_handler = logging.FileHandler(log_path, encoding='utf-8')
self._build_log_handler.setLevel(logging.INFO)
formatter = logging.Formatter(
'%(levelname)s %(asctime)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
self._build_log_handler.setFormatter(formatter)
self._build_logger.addHandler(self._build_log_handler)
self._build_logger.propagate = False
return log_path
def _log(self, level: str, message: str) -> None:
"""Log to both main logger and build-specific logger."""
log_func = getattr(logging, level)
log_func(message)
if self._build_logger:
build_log_func = getattr(self._build_logger, level)
build_log_func(message)
def _cleanup_build_logger(self) -> None:
"""Clean up the build-specific logger."""
if self._build_log_handler:
self._build_log_handler.close()
self._build_log_handler = None
if self._build_logger:
self._build_logger.handlers.clear()
self._build_logger = None