403Webshell
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 :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /tsai/repo/builder/backend/app/services/builder_service.py
"""
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

Youez - 2016 - github.com/yon3zu
LinuXploit