From 58304d5aaa719c8fac86a78eff6b30fe4aebffca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 20 Nov 2025 17:55:20 +0200 Subject: [PATCH 01/10] feat(site-deploy): add site deployment command with Capistrano-style releases - Add site:deploy command for orchestrating deployments - Implement site-deploy.sh playbook with git-based releases - Create timestamped releases with shared resources - Add deployment hooks support (1-building, 2-releasing, 3-finishing) - Register command in SymfonyApp - Include PHP version selection and release cleanup --- app/Console/Site/SiteDeployCommand.php | 365 ++++++++++++++++++++++++ app/SymfonyApp.php | 2 + playbooks/site-deploy.sh | 376 +++++++++++++++++++++++++ 3 files changed, 743 insertions(+) create mode 100644 app/Console/Site/SiteDeployCommand.php create mode 100644 playbooks/site-deploy.sh diff --git a/app/Console/Site/SiteDeployCommand.php b/app/Console/Site/SiteDeployCommand.php new file mode 100644 index 00000000..1b070bf7 --- /dev/null +++ b/app/Console/Site/SiteDeployCommand.php @@ -0,0 +1,365 @@ + */ + private const REQUIRED_HOOKS = [ + '1-building.sh', + '2-releasing.sh', + '3-finishing.sh', + ]; + + // ---- + // Configuration + // ---- + + protected function configure(): void + { + parent::configure(); + + $this + ->addOption('domain', null, InputOption::VALUE_REQUIRED, 'Site domain') + ->addOption('keep-releases', null, InputOption::VALUE_REQUIRED, 'Number of releases to keep (default: 5)') + ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Deploy without confirmation prompt'); + } + + // ---- + // Execution + // ---- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->heading('Deploy Site'); + + // + // Select site & display details + // ---- + + $site = $this->selectSite(); + + if (is_int($site)) { + return $site; + } + + $this->displaySiteDeets($site); + + // + // Check for deployment hooks in remote repository + // ---- + + try { + $missingHooks = $this->checkRemoteHooksExist($site); + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + + return Command::FAILURE; + } + + if ($missingHooks !== []) { + $this->io->warning('Missing deployment hooks in repository:'); + foreach ($missingHooks as $hook) { + $this->io->writeln(' • ' . $hook); + } + + $this->io->writeln([ + ' • Run scaffold:hooks to create them', + ' • Or continue deployment anyway...', + '', + ]); + + $skipConfirm = $this->io->getOptionOrPrompt( + 'yes', + fn () => $this->io->promptConfirm('Continue deployment anyway?', default: false) + ); + + if (! $skipConfirm) { + return Command::FAILURE; + } + + $this->io->writeln(''); + } + + // + // Get server for site + // ---- + + $server = $this->getServerForSite($site); + if (is_int($server)) { + return $server; + } + + // + // Get server info (verifies SSH and validates distro & permissions) + // ---- + + $info = $this->serverInfo($server); + if (is_int($info)) { + return $info; + } + + [ + 'distro' => $distro, + 'permissions' => $permissions, + ] = $info; + + /** @var string $distro */ + /** @var string $permissions */ + + // + // Validate site is provisioned on server + // ---- + + $validationResult = $this->validateSiteProvisioned($server, $site); + + if (is_int($validationResult)) { + return $validationResult; + } + + // + // Resolve deployment parameters + // ---- + + $branch = $site->branch; + $keepReleases = $this->resolveKeepReleases($input); + if ($keepReleases === null) { + return Command::FAILURE; + } + + $phpVersion = $this->resolvePhpVersion($info); + if ($phpVersion === null) { + return Command::FAILURE; + } + + // + // Confirm deployment + // ---- + + /** @var bool $confirmed */ + $confirmed = $this->io->getOptionOrPrompt( + 'yes', + fn (): bool => $this->io->promptConfirm( + label: 'Deploy now?', + default: true + ) + ); + + if (! $confirmed) { + $this->io->warning('Deployment cancelled.'); + $this->io->writeln(''); + + return Command::SUCCESS; + } + + // + // Execute deployment playbook + // ---- + + $result = $this->executePlaybook( + $server, + 'site-deploy', + 'Deploying site...', + [ + 'DEPLOYER_DISTRO' => $distro, + 'DEPLOYER_PERMS' => $permissions, + 'DEPLOYER_SITE_DOMAIN' => $site->domain, + 'DEPLOYER_SITE_REPO' => $site->repo, + 'DEPLOYER_SITE_BRANCH' => $branch, + 'DEPLOYER_PHP_VERSION' => (string) $phpVersion, + 'DEPLOYER_KEEP_RELEASES' => (string) $keepReleases, + ], + true + ); + + if (is_int($result)) { + return $result; + } + + // + // Display results + // ---- + + $this->yay('Deployment completed'); + $this->displayDeploymentSummary($result, $branch, (string) $phpVersion); + + $this->io->writeln([ + 'Next steps:', + ' • Run site:shared:push to upload shared files (e.g. .env)', + ' • View deployment logs with server:logs', + '', + ]); + + // + // Show command replay + // ---- + + $this->showCommandReplay('site:deploy', [ + 'domain' => $site->domain, + 'keep-releases' => $keepReleases, + 'yes' => true, + ]); + + return Command::SUCCESS; + } + + // ---- + // Helpers + // ---- + + /** + * Display deployment summary details. + * + * @param array $result + */ + private function displayDeploymentSummary(array $result, string $branch, string $phpVersion): void + { + $lines = [ + 'Branch' => $branch, + 'PHP' => $phpVersion, + ]; + + if (isset($result['release_name']) && is_string($result['release_name'])) { + $lines['Release'] = $result['release_name']; + } + + if (isset($result['release_path']) && is_string($result['release_path'])) { + $lines['Release Path'] = $result['release_path']; + } + + if (isset($result['current_path']) && is_string($result['current_path'])) { + $lines['Current Symlink'] = $result['current_path']; + } + + $this->io->displayDeets($lines); + $this->io->writeln(''); + } + + private function resolveKeepReleases(InputInterface $input): ?int + { + /** @var string|null $value */ + $value = $input->getOption('keep-releases'); + if ($value === null || trim($value) === '') { + return self::DEFAULT_KEEP_RELEASES; + } + + if (! ctype_digit($value)) { + $this->nay('The --keep-releases option must be a positive integer.'); + + return null; + } + + $intValue = (int) $value; + if ($intValue < 1) { + $this->nay('The --keep-releases option must be at least 1.'); + + return null; + } + + return $intValue; + } + + /** + * Resolve PHP version from server info, prompting user if multiple exist. + * + * @param array $info + */ + private function resolvePhpVersion(array $info): ?string + { + $versions = []; + $phpInfo = $info['php'] ?? null; + if (is_array($phpInfo) && isset($phpInfo['versions']) && is_array($phpInfo['versions'])) { + foreach ($phpInfo['versions'] as $version) { + if (is_array($version) && isset($version['version']) && (is_string($version['version']) || is_numeric($version['version']))) { + $versions[] = (string) $version['version']; + } elseif (is_string($version) || is_numeric($version)) { + $versions[] = (string) $version; + } + } + } + + if ($versions === []) { + $this->nay('No PHP versions found on the server. Run server:install first.'); + + return null; + } + + $default = null; + if (is_array($phpInfo) && isset($phpInfo['default']) && (is_string($phpInfo['default']) || is_numeric($phpInfo['default']))) { + $default = (string) $phpInfo['default']; + } + + if (count($versions) === 1) { + /** @var string $only */ + $only = $versions[0]; + + return $only; + } + + rsort($versions, SORT_NATURAL); + $defaultSelection = $default ?? $versions[0]; + + /** @var string $selected */ + $selected = (string) $this->io->promptSelect( + label: 'PHP version for this deployment:', + options: $versions, + default: $defaultSelection + ); + + return $selected; + } + + /** + * Check if deployment hooks exist in remote repository. + * + * @return list List of missing hook names + * @throws \RuntimeException If git operations fail + */ + private function checkRemoteHooksExist(SiteDTO $site): array + { + $hookPaths = array_map( + fn ($hook) => ".deployer/hooks/{$hook}", + self::REQUIRED_HOOKS + ); + + $remoteHooks = $this->git->checkRemoteFilesExist( + $site->repo, + $site->branch, + $hookPaths + ); + + $missingHooks = []; + foreach ($remoteHooks as $path => $exists) { + if (! $exists) { + $missingHooks[] = basename($path); + } + } + + return $missingHooks; + } +} diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index cfcfb8b1..34bbc6a1 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -19,6 +19,7 @@ use Bigpixelrocket\DeployerPHP\Console\Server\ServerRunCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteAddCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteDeleteCommand; +use Bigpixelrocket\DeployerPHP\Console\Site\SiteDeployCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteHttpsCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteListCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteSharedPullCommand; @@ -168,6 +169,7 @@ private function registerCommands(): void SiteSharedPushCommand::class, SiteSharedPullCommand::class, SiteHttpsCommand::class, + SiteDeployCommand::class, ]; foreach ($commands as $command) { diff --git a/playbooks/site-deploy.sh b/playbooks/site-deploy.sh new file mode 100644 index 00000000..061490ba --- /dev/null +++ b/playbooks/site-deploy.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash + +# +# Site Deploy Playbook - Ubuntu/Debian Only +# +# Deploy site using Capistrano-style releases with deployment hooks +# ---- +# +# This playbook orchestrates the complete deployment process for a site: +# - Clones or updates the git repository +# - Creates a timestamped release directory +# - Exports code from the repository to the release +# - Runs deployment hooks at key stages (1-building, 2-releasing, 3-finishing) +# - Activates the new release by updating the current symlink +# - Cleans up old releases beyond the retention limit +# +# The deployment follows a Capistrano-style structure: +# /home/deployer/sites/{domain}/ +# ├── releases/ - Timestamped release directories +# ├── current/ - Symlink to active release +# ├── shared/ - Shared files across releases +# └── repo/ - Bare git repository +# +# Required Environment Variables: +# DEPLOYER_OUTPUT_FILE - Output file path (YAML) +# DEPLOYER_DISTRO - Server distribution (ubuntu|debian) +# DEPLOYER_PERMS - Permissions (root|sudo) +# DEPLOYER_SITE_DOMAIN - Site domain +# DEPLOYER_SITE_REPO - Git repository URL +# DEPLOYER_SITE_BRANCH - Git branch to deploy +# DEPLOYER_PHP_VERSION - PHP version to expose to hooks +# +# Optional Environment Variables: +# DEPLOYER_KEEP_RELEASES - Number of releases to keep (default: 5) +# +# Returns YAML with: +# - status: success +# - domain: {domain} +# - branch: {branch} +# - release_name: {timestamp} +# - release_path: {path} +# - current_path: {path} +# - keep_releases: {number} +# + +set -o pipefail +export DEBIAN_FRONTEND=noninteractive + +[[ -z $DEPLOYER_OUTPUT_FILE ]] && echo "Error: DEPLOYER_OUTPUT_FILE required" && exit 1 +[[ -z $DEPLOYER_DISTRO ]] && echo "Error: DEPLOYER_DISTRO required" && exit 1 +[[ -z $DEPLOYER_PERMS ]] && echo "Error: DEPLOYER_PERMS required" && exit 1 +[[ -z $DEPLOYER_SITE_DOMAIN ]] && echo "Error: DEPLOYER_SITE_DOMAIN required" && exit 1 +[[ -z $DEPLOYER_SITE_REPO ]] && echo "Error: DEPLOYER_SITE_REPO required" && exit 1 +[[ -z $DEPLOYER_SITE_BRANCH ]] && echo "Error: DEPLOYER_SITE_BRANCH required" && exit 1 +[[ -z $DEPLOYER_PHP_VERSION ]] && echo "Error: DEPLOYER_PHP_VERSION required" && exit 1 + +DEPLOYER_KEEP_RELEASES=${DEPLOYER_KEEP_RELEASES:-5} +if ! [[ $DEPLOYER_KEEP_RELEASES =~ ^[0-9]+$ ]] || ((DEPLOYER_KEEP_RELEASES < 1)); then + DEPLOYER_KEEP_RELEASES=5 +fi +export DEPLOYER_PERMS + +# Shared helpers are automatically inlined when executing playbooks remotely +# source "$(dirname "$0")/helpers.sh" + +SITE_ROOT="/home/deployer/sites/${DEPLOYER_SITE_DOMAIN}" +RELEASE_NAME=$(date +%Y%m%d_%H%M%S) +RELEASE_PATH="${SITE_ROOT}/releases/${RELEASE_NAME}" +SHARED_PATH="${SITE_ROOT}/shared" +CURRENT_PATH="${SITE_ROOT}/current" +REPO_PATH="${SITE_ROOT}/repo" + +export DEPLOYER_RELEASE_PATH="$RELEASE_PATH" +export DEPLOYER_SHARED_PATH="$SHARED_PATH" +export DEPLOYER_CURRENT_PATH="$CURRENT_PATH" +export DEPLOYER_REPO_PATH="$REPO_PATH" +export DEPLOYER_DOMAIN="$DEPLOYER_SITE_DOMAIN" +export DEPLOYER_BRANCH="$DEPLOYER_SITE_BRANCH" +export DEPLOYER_KEEP_RELEASES + +PRESERVE_ENV_VARS="DEPLOYER_RELEASE_PATH,DEPLOYER_SHARED_PATH,DEPLOYER_CURRENT_PATH,DEPLOYER_REPO_PATH,DEPLOYER_DOMAIN,DEPLOYER_BRANCH,DEPLOYER_PHP_VERSION,DEPLOYER_PHP,DEPLOYER_KEEP_RELEASES,DEPLOYER_DISTRO,DEPLOYER_PERMS" + +# ---- +# Helper Functions +# ---- + +# +# Hook Management +# ---- + +# +# Execute deployment hook if it exists +# +# Arguments: +# $1 - Hook name (1-building.sh, 2-releasing.sh, 3-finishing.sh) + +run_hook() { + local hook_name=$1 + local hook_path="${DEPLOYER_RELEASE_PATH}/.deployer/hooks/${hook_name}" + + if [[ ! -f $hook_path ]]; then + return 0 + fi + + if [[ ! -x $hook_path ]]; then + chmod +x "$hook_path" || fail "Failed to make ${hook_name} hook executable" + run_as_deployer chmod +x "$hook_path" > /dev/null 2>&1 || true + fi + + echo "→ Running ${hook_name} hook..." + + cd "$DEPLOYER_RELEASE_PATH" || fail "Failed to change directory to release path" + + if ! run_as_deployer "$hook_path"; then + fail "${hook_name} hook failed" + fi +} + +# +# PHP Detection +# ---- + +# +# Detect and export PHP binary path for specified version +# +# Side effects: +# Sets DEPLOYER_PHP environment variable with binary path + +detect_php_binary() { + local candidate + if command -v "php${DEPLOYER_PHP_VERSION}" > /dev/null 2>&1; then + candidate=$(command -v "php${DEPLOYER_PHP_VERSION}") + elif command -v php > /dev/null 2>&1; then + candidate=$(command -v php) + else + fail "Unable to locate PHP ${DEPLOYER_PHP_VERSION} binary on the server" + fi + + export DEPLOYER_PHP="$candidate" +} + +# ---- +# Deployment Functions +# ---- + +# +# Directory Management +# ---- + +# +# Prepare site directory structure +# +# Creates releases, shared, and repo directories if they don't exist. +# Cleans up any non-symlink current path. + +prepare_directories() { + echo "→ Preparing directories..." + + run_cmd mkdir -p "${SITE_ROOT}/releases" || fail "Failed to create releases directory" + run_cmd mkdir -p "$SHARED_PATH" || fail "Failed to create shared directory" + run_cmd mkdir -p "$REPO_PATH" || fail "Failed to create repo directory" + + # Ensure directories are owned by deployer + run_cmd chown deployer:deployer "${SITE_ROOT}/releases" "$SHARED_PATH" "$REPO_PATH" || fail "Failed to set directory ownership" + + if [[ -e $CURRENT_PATH && ! -L $CURRENT_PATH ]]; then + run_cmd rm -rf "$CURRENT_PATH" || fail "Failed to clean existing current path" + fi +} + +# +# Repository Management +# ---- + +# +# Ensure git host is in known_hosts +# +# Parses the repo domain and adds its host key if missing. +# Supports git@domain: and ssh://user@domain/ formats. + +ensure_git_host_known() { + local repo_domain="" + + if [[ $DEPLOYER_SITE_REPO =~ ^git@([^:]+): ]]; then + repo_domain="${BASH_REMATCH[1]}" + elif [[ $DEPLOYER_SITE_REPO =~ ^ssh://[^@]+@([^/]+)/ ]]; then + repo_domain="${BASH_REMATCH[1]}" + fi + + if [[ -z $repo_domain ]]; then + return 0 + fi + + if [[ ! -d /home/deployer/.ssh ]]; then + run_cmd mkdir -p /home/deployer/.ssh + run_cmd chown deployer:deployer /home/deployer/.ssh + run_cmd chmod 700 /home/deployer/.ssh + fi + + if [[ ! -f /home/deployer/.ssh/known_hosts ]]; then + run_as_deployer touch /home/deployer/.ssh/known_hosts + run_cmd chmod 600 /home/deployer/.ssh/known_hosts + fi + + if ! run_as_deployer ssh-keygen -F "$repo_domain" > /dev/null 2>&1; then + echo "→ Adding host key for ${repo_domain}..." + run_as_deployer ssh-keyscan -H "$repo_domain" >> /home/deployer/.ssh/known_hosts 2> /dev/null || true + fi +} + +# +# Clone or update git repository +# +# Clones the repository as a bare repo if it doesn't exist, otherwise fetches updates. +# Verifies the specified branch exists in the repository. + +clone_or_update_repo() { + ensure_git_host_known + + if [[ ! -d "${REPO_PATH}/objects" ]]; then + echo "→ Cloning repository..." + run_cmd rm -rf "$REPO_PATH" || true + run_cmd mkdir -p "$(dirname "$REPO_PATH")" || fail "Failed to prepare repo parent" + if ! run_as_deployer git clone --bare "$DEPLOYER_SITE_REPO" "$REPO_PATH"; then + fail "Failed to clone repository" + fi + else + echo "→ Fetching latest changes..." + if ! run_as_deployer git --git-dir="$REPO_PATH" fetch --prune; then + fail "Failed to fetch repository updates" + fi + fi + + if ! run_as_deployer git --git-dir="$REPO_PATH" rev-parse --verify "$DEPLOYER_SITE_BRANCH" > /dev/null 2>&1; then + fail "Branch '${DEPLOYER_SITE_BRANCH}' not found in repository" + fi +} + +# +# Release Management +# ---- + +# +# Build new release from repository +# +# Creates timestamped release directory and exports code from the git repository. +# Sets proper ownership for deployer user. + +build_release() { + echo "→ Creating release ${RELEASE_NAME}..." + run_cmd mkdir -p "$RELEASE_PATH" || fail "Failed to create release directory" + run_cmd chown deployer:deployer "$RELEASE_PATH" || fail "Failed to set release ownership" + + local repo_q branch_q release_q + printf -v repo_q "%q" "$REPO_PATH" + printf -v branch_q "%q" "$DEPLOYER_SITE_BRANCH" + printf -v release_q "%q" "$RELEASE_PATH" + + if ! run_as_deployer bash -c "set -euo pipefail; git --git-dir=${repo_q} archive ${branch_q} | tar -x -C ${release_q}"; then + fail "Failed to export code for ${DEPLOYER_SITE_BRANCH}" + fi + + # Ensure strict ownership and permissions on the release directory after extraction + run_cmd chown -R deployer:deployer "$RELEASE_PATH" || fail "Failed to set release ownership" + run_cmd chmod -R 755 "$RELEASE_PATH" || fail "Failed to set release permissions" +} + +# +# Activate new release +# +# Updates the current symlink to point to the new release directory. + +activate_release() { + echo "→ Activating release..." + + run_as_deployer ln -sfn "$RELEASE_PATH" "$CURRENT_PATH" || fail "Failed to update current symlink" +} + +# +# Remove old releases beyond retention limit +# +# Keeps only the specified number of most recent releases. + +cleanup_releases() { + local releases=() + local total=0 + + mapfile -t releases < <(find "${SITE_ROOT}/releases" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort) || true + total=${#releases[@]} + + if ((total <= DEPLOYER_KEEP_RELEASES)); then + return + fi + + local remove_count=$((total - DEPLOYER_KEEP_RELEASES)) + for ((i = 0; i < remove_count; i++)); do + local old_release="${SITE_ROOT}/releases/${releases[i]}" + echo "→ Removing old release ${releases[i]}..." + run_cmd rm -rf "$old_release" || fail "Failed to remove old release ${releases[i]}" + done +} + +# +# Reload PHP-FPM service +# +# Reloads the PHP-FPM service to apply changes (clears opcode cache etc) + +reload_php_fpm() { + echo "→ Reloading PHP-FPM..." + run_cmd systemctl reload "php${DEPLOYER_PHP_VERSION}-fpm" || fail "Failed to reload PHP-FPM" +} + +# +# Output Generation +# ---- + +# +# Write YAML output file with deployment results + +write_output() { + cat > "$DEPLOYER_OUTPUT_FILE" <<- EOF + status: success + domain: ${DEPLOYER_SITE_DOMAIN} + branch: ${DEPLOYER_SITE_BRANCH} + release_name: ${RELEASE_NAME} + release_path: ${RELEASE_PATH} + current_path: ${CURRENT_PATH} + keep_releases: ${DEPLOYER_KEEP_RELEASES} + EOF +} + +# +# Deployment Orchestration +# ---- + +# +# Execute full deployment hook sequence +# +# Runs hooks in order: build release -> link shared -> 1-building -> 2-releasing -> activate -> 3-finishing -> cleanup + +run_hooks_sequence() { + clone_or_update_repo + + build_release + + link_shared_resources + + run_hook '1-building.sh' + run_hook '2-releasing.sh' + + activate_release + + run_hook '3-finishing.sh' + + reload_php_fpm + + cleanup_releases +} + +# ---- +# Main Execution +# ---- + +# +# Main entry point +# +# Orchestrates the complete deployment process + +main() { + detect_php_binary + prepare_directories + run_hooks_sequence + write_output +} + +main "$@" From 15ffe1a77d25f61466ea6f3e49ea149bbf01723e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 20 Nov 2025 17:55:28 +0200 Subject: [PATCH 02/10] feat(shared-resources): add shared resources linking functionality - Add site-link-shared.sh playbook for linking shared files - Enhance site:shared:push to automatically link uploaded files - Add link_shared_resources() helper function in helpers.sh - Support symlinking shared resources to current release --- app/Console/Site/SiteSharedPushCommand.php | 27 ++++++++ playbooks/helpers.sh | 75 ++++++++++++++++++++++ playbooks/site-link-shared.sh | 60 +++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 playbooks/site-link-shared.sh diff --git a/app/Console/Site/SiteSharedPushCommand.php b/app/Console/Site/SiteSharedPushCommand.php index f5f9beca..8bcbab2f 100644 --- a/app/Console/Site/SiteSharedPushCommand.php +++ b/app/Console/Site/SiteSharedPushCommand.php @@ -85,6 +85,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $info; } + [ + 'distro' => $distro, + 'permissions' => $permissions, + ] = $info; + + /** @var string $distro */ + /** @var string $permissions */ + // // Validate site is provisioned on server // ---- @@ -139,6 +147,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->yay('Shared file uploaded'); + // + // Link shared file + // ---- + + $result = $this->executePlaybook( + $server, + 'site-link-shared', + 'Linking shared file...', + [ + 'DEPLOYER_DISTRO' => $distro, + 'DEPLOYER_PERMS' => $permissions, + 'DEPLOYER_SITE_DOMAIN' => $site->domain, + ] + ); + + if (is_int($result)) { + return $result; + } + // // Show command replay // ---- diff --git a/playbooks/helpers.sh b/playbooks/helpers.sh index fa44faf5..40038eec 100644 --- a/playbooks/helpers.sh +++ b/playbooks/helpers.sh @@ -14,6 +14,7 @@ # # Execute command with appropriate permissions +# run_cmd() { if [[ $DEPLOYER_PERMS == 'root' ]]; then @@ -23,12 +24,42 @@ run_cmd() { fi } +# +# Execute command as deployer user with environment preservation +# +# Arguments: +# $@ - Command and arguments to execute + +run_as_deployer() { + if [[ $DEPLOYER_PERMS == 'root' || $DEPLOYER_PERMS == 'sudo' ]]; then + sudo -n -u deployer --preserve-env="$PRESERVE_ENV_VARS" "$@" + else + "$@" + fi +} + +# ---- +# Error Handling +# ---- + +# +# Print error message and exit +# +# Arguments: +# $1 - Error message to display + +fail() { + echo "Error: $1" >&2 + exit 1 +} + # ---- # PHP Detection # ---- # # Detect default PHP version +# detect_php_default() { local default_version @@ -58,6 +89,7 @@ detect_php_default() { # # Wait for dpkg lock to be released +# wait_for_dpkg_lock() { local max_wait=60 @@ -93,6 +125,7 @@ wait_for_dpkg_lock() { # # apt-get with retry +# apt_get_with_retry() { local max_attempts=5 @@ -131,3 +164,45 @@ apt_get_with_retry() { return 1 } + +# ---- +# Shared Resources Management +# ---- + +# +# Link shared resources to release +# +# Iterates through all items in shared directory and creates symlinks in the release. +# Removes conflicting files/directories from the release before linking. +# Requires environment variables: +# $SHARED_PATH - Path to shared directory +# $RELEASE_PATH - Path to release directory +# + +link_shared_resources() { + if [[ ! -d $SHARED_PATH ]]; then + return 0 + fi + + local shared_items=() + mapfile -t shared_items < <(find "$SHARED_PATH" -mindepth 1 -maxdepth 1 -printf '%f\n') || true + + if ((${#shared_items[@]} == 0)); then + return 0 + fi + + echo "→ Linking shared resources..." + + for item in "${shared_items[@]}"; do + local shared_item="${SHARED_PATH}/${item}" + local release_item="${RELEASE_PATH}/${item}" + + # Remove conflicting item from release if it exists + if [[ -e $release_item ]]; then + run_cmd rm -rf "$release_item" || fail "Failed to remove ${item} from release" + fi + + # Create symlink + run_as_deployer ln -sf "$shared_item" "$release_item" || fail "Failed to link shared ${item}" + done +} diff --git a/playbooks/site-link-shared.sh b/playbooks/site-link-shared.sh new file mode 100644 index 00000000..7d54c795 --- /dev/null +++ b/playbooks/site-link-shared.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +# +# Site Link Shared Playbook +# ---- +# Links shared resources to the current release +# +# Required Environment Variables: +# DEPLOYER_OUTPUT_FILE - Output file path (YAML) +# DEPLOYER_DISTRO - Server distribution (ubuntu|debian) +# DEPLOYER_PERMS - Permissions (root|sudo) +# DEPLOYER_SITE_DOMAIN - Site domain +# + +set -o pipefail +export DEBIAN_FRONTEND=noninteractive + +[[ -z $DEPLOYER_OUTPUT_FILE ]] && echo "Error: DEPLOYER_OUTPUT_FILE required" && exit 1 +[[ -z $DEPLOYER_DISTRO ]] && echo "Error: DEPLOYER_DISTRO required" && exit 1 +[[ -z $DEPLOYER_PERMS ]] && echo "Error: DEPLOYER_PERMS required" && exit 1 +[[ -z $DEPLOYER_SITE_DOMAIN ]] && echo "Error: DEPLOYER_SITE_DOMAIN required" && exit 1 + +export DEPLOYER_PERMS + +# Shared helpers are automatically inlined when executing playbooks remotely +# source "$(dirname "$0")/helpers.sh" + +SITE_ROOT="/home/deployer/sites/${DEPLOYER_SITE_DOMAIN}" +SHARED_PATH="${SITE_ROOT}/shared" +CURRENT_PATH="${SITE_ROOT}/current" + +# Check if current release exists +if [[ ! -L $CURRENT_PATH ]]; then + fail "No current release found (symlink missing)" +fi + +RELEASE_PATH=$(readlink -f "$CURRENT_PATH") +if [[ -z $RELEASE_PATH || ! -d $RELEASE_PATH ]]; then + fail "Current release path not found: $RELEASE_PATH" +fi + +export DEPLOYER_SHARED_PATH="$SHARED_PATH" +export DEPLOYER_RELEASE_PATH="$RELEASE_PATH" + +PRESERVE_ENV_VARS="DEPLOYER_SHARED_PATH,DEPLOYER_RELEASE_PATH,DEPLOYER_DISTRO,DEPLOYER_PERMS" + +main() { + link_shared_resources + + if ! cat > "$DEPLOYER_OUTPUT_FILE" << EOF; then +status: success +domain: $DEPLOYER_SITE_DOMAIN +release_path: $RELEASE_PATH +EOF + echo "Error: Failed to write output file" >&2 + exit 1 + fi +} + +main "$@" From 1062bd8ebe1f33faf5a702e1d64d103895bbb062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 20 Nov 2025 17:55:36 +0200 Subject: [PATCH 03/10] feat(server-logs): enhance logging with PHP-FPM and site logs - Add PHP-FPM log viewing for all detected PHP versions - Add site access log viewing for Caddy sites - Enhance service detection to include PHP and sites - Add error highlighting in log output - Update command description to reflect expanded capabilities --- app/Console/Server/ServerLogsCommand.php | 183 ++++++++++++++++++++--- 1 file changed, 166 insertions(+), 17 deletions(-) diff --git a/app/Console/Server/ServerLogsCommand.php b/app/Console/Server/ServerLogsCommand.php index d091f281..13e9f8f5 100644 --- a/app/Console/Server/ServerLogsCommand.php +++ b/app/Console/Server/ServerLogsCommand.php @@ -16,7 +16,7 @@ #[AsCommand( name: 'server:logs', - description: 'View server logs (system and detected services)' + description: 'View server logs (system, PHP-FPM, sites, and detected services)' )] class ServerLogsCommand extends BaseCommand { @@ -121,9 +121,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // ---- /** - * Process detected services and build options for user selection. - * - * Consolidates Docker-related processes and caches results for reuse. + * Process detected services, PHP versions, and sites to build options. * * @param array $info Server information from server-info playbook * @return array @@ -134,6 +132,7 @@ protected function getProcessedServices(array $info): array return $this->processedServices; } + // 1. Detected listening services /** @var array $ports */ $ports = $info['ports'] ?? []; $detected = array_unique(array_values($ports)); @@ -156,22 +155,60 @@ protected function getProcessedServices(array $info): array $services[] = $service; } + // 2. PHP Versions (PHP-FPM) + $phpVersions = []; + if (isset($info['php']) && is_array($info['php']) && isset($info['php']['versions']) && is_array($info['php']['versions'])) { + foreach ($info['php']['versions'] as $versionData) { + $version = null; + if (is_array($versionData) && isset($versionData['version'])) { + $version = (string) $versionData['version']; + } elseif (is_string($versionData) || is_numeric($versionData)) { + $version = (string) $versionData; + } + + if ($version !== null) { + $phpVersions[] = $version; + } + } + } + + // 3. Sites + $sites = []; + if (isset($info['sites_config']) && is_array($info['sites_config'])) { + $sites = array_keys($info['sites_config']); + } + + // Build options $options = [ - 'all' => 'All services', + 'all' => 'All logs (System, Services, PHP, Sites)', 'system' => 'System logs', ]; + // Detected services (Caddy, SSH, etc.) foreach ($services as $service) { $options[strtolower($service)] = $service; } + // PHP-FPM services + foreach ($phpVersions as $version) { + $serviceName = "php{$version}-fpm"; + $options[$serviceName] = "PHP {$version} FPM"; + } + + // Sites + foreach ($sites as $site) { + $options[$site] = "Site: {$site}"; + } + if ($hasDocker) { - $options['docker'] = 'docker'; + $options['docker'] = 'Docker'; } return $this->processedServices = [ 'options' => $options, 'services' => $services, + 'phpVersions' => $phpVersions, + 'sites' => $sites, 'hasDocker' => $hasDocker, ]; } @@ -183,25 +220,72 @@ protected function getProcessedServices(array $info): array */ protected function displayServiceLogs(ServerDTO $server, string $service, int $lines, array $info): void { - if ($service === 'all') { - $processed = $this->getProcessedServices($info); + $processed = $this->getProcessedServices($info); + /** @var array $detectedServices */ + $detectedServices = $processed['services']; + /** @var array $phpVersions */ + $phpVersions = $processed['phpVersions']; + /** @var array $sites */ + $sites = $processed['sites']; + /** @var bool $hasDocker */ + $hasDocker = $processed['hasDocker']; + if ($service === 'all') { + // 1. System Logs $this->retrieveServiceLogs($server, 'System', '', $lines); - /** @var array $services */ - $services = $processed['services']; - foreach ($services as $serviceName) { + // 2. Detected Services (Caddy, SSH, etc.) + foreach ($detectedServices as $serviceName) { $this->retrieveServiceLogs($server, $serviceName, $serviceName, $lines); } - /** @var bool $hasDocker */ - $hasDocker = $processed['hasDocker']; + // 3. PHP-FPM Logs + foreach ($phpVersions as $version) { + $this->retrieveFileLogs( + $server, + "PHP {$version} FPM", + "/var/log/php{$version}-fpm.log", + $lines + ); + } + + // 4. Site Logs + foreach ($sites as $site) { + $this->retrieveFileLogs( + $server, + "Site: {$site}", + "/var/log/caddy/{$site}-access.log", + $lines + ); + } + + // 5. Docker if ($hasDocker) { - $this->retrieveServiceLogs($server, 'docker', 'docker', $lines); + $this->retrieveServiceLogs($server, 'Docker', 'docker', $lines); } + } elseif ($service === 'system') { $this->retrieveServiceLogs($server, 'System', '', $lines); + } elseif (in_array($service, $sites, true)) { + // Specific Site Log + $this->retrieveFileLogs( + $server, + "Site: {$service}", + "/var/log/caddy/{$service}-access.log", + $lines + ); + } elseif (str_starts_with($service, 'php') && str_ends_with($service, '-fpm')) { + // Specific PHP-FPM Log + // Service name format: php8.3-fpm + // Check if it's a file log or system service (usually both, but we prefer file for PHP-FPM) + $this->retrieveFileLogs( + $server, + $service, + "/var/log/{$service}.log", + $lines + ); } else { + // Generic Service (journalctl) $this->retrieveServiceLogs($server, $service, $service, $lines); } } @@ -236,7 +320,7 @@ protected function retrieveServiceLogs(ServerDTO $server, string $service, strin if ($result['exit_code'] !== 0 && !$serviceNotFound) { $this->nay("Failed to retrieve {$service} logs"); - $this->io->writeln($output); + $this->io->writeln($this->highlightErrors($output)); $this->io->writeln(''); return; @@ -249,7 +333,7 @@ protected function retrieveServiceLogs(ServerDTO $server, string $service, strin return; } - $this->io->writeln($output); + $this->io->writeln($this->highlightErrors($output)); } catch (\RuntimeException $e) { $this->nay($e->getMessage()); } @@ -257,6 +341,28 @@ protected function retrieveServiceLogs(ServerDTO $server, string $service, strin $this->io->writeln(''); } + /** + * Retrieve logs from a specific file. + */ + protected function retrieveFileLogs(ServerDTO $server, string $title, string $filepath, int $lines): void + { + $this->io->writeln([ + "{$title} Logs", + "File: {$filepath}", + '', + ]); + + $content = $this->readLogFile($server, $filepath, $lines); + + if ($content !== null) { + $this->io->writeln($this->highlightErrors($content)); + } else { + $this->io->writeln('No logs found or file does not exist.'); + } + + $this->io->writeln(''); + } + /** * Try to find and display traditional log files in /var/log/. */ @@ -282,7 +388,7 @@ protected function tryTraditionalLogs(ServerDTO $server, string $service, int $l $this->io->writeln([ "From {$logFile}:", '', - $logContent, + $this->highlightErrors($logContent), ]); return; @@ -313,6 +419,49 @@ protected function readLogFile(ServerDTO $server, string $logFile, int $lines): } } + /** + * Highlight error keywords in log content. + */ + protected function highlightErrors(string $content): string + { + $keywords = [ + 'error', + 'exception', + 'fail', + 'failed', + 'fatal', + 'panic', + ' 500 ', // HTTP 500 + ' 502 ', // HTTP 502 + ' 503 ', // HTTP 503 + ' 504 ', // HTTP 504 + ]; + + $lines = explode("\n", $content); + $processedLines = []; + + foreach ($lines as $line) { + $lowerLine = strtolower($line); + $hasError = false; + + foreach ($keywords as $keyword) { + if (str_contains($lowerLine, strtolower($keyword))) { + $hasError = true; + break; + } + } + + if ($hasError) { + // Highlight the entire line in red + $processedLines[] = "{$line}"; + } else { + $processedLines[] = $line; + } + } + + return implode("\n", $processedLines); + } + /** * Get common systemd service name patterns for a process. * From 210bdfa7a2146de8020585ae1d748784c148f70a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 20 Nov 2025 17:55:41 +0200 Subject: [PATCH 04/10] feat(deployment-hooks): improve hooks with proper PHP binary and SQLite support - Use DEPLOYER_PHP environment variable in 1-building.sh hook - Add SQLite database setup example in 2-releasing.sh hook - Ensure hooks use correct PHP binary for composer/artisan commands --- scaffolds/hooks/1-building.sh | 2 +- scaffolds/hooks/2-releasing.sh | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scaffolds/hooks/1-building.sh b/scaffolds/hooks/1-building.sh index 689afff8..31abdfb0 100755 --- a/scaffolds/hooks/1-building.sh +++ b/scaffolds/hooks/1-building.sh @@ -22,7 +22,7 @@ echo "→ Building release..." if [[ -f composer.json ]]; then echo "→ Installing Composer dependencies..." - composer install --no-interaction --no-dev --optimize-autoloader + "${DEPLOYER_PHP}" composer install --no-interaction --no-dev --optimize-autoloader fi if [[ -f package.json ]]; then diff --git a/scaffolds/hooks/2-releasing.sh b/scaffolds/hooks/2-releasing.sh index 115eb871..8c61af80 100755 --- a/scaffolds/hooks/2-releasing.sh +++ b/scaffolds/hooks/2-releasing.sh @@ -47,6 +47,18 @@ if [[ $framework == "laravel" ]]; then mkdir -p "${DEPLOYER_SHARED_PATH}/storage/framework/"{cache,sessions,views} "${DEPLOYER_PHP}" artisan storage:link + # + # SQLite Database (Optional) + # ---- + # Uncomment if using SQLite + # + # echo "→ Ensuring shared SQLite database..." + # mkdir -p "${DEPLOYER_SHARED_PATH}/database" + # if [[ ! -f "${DEPLOYER_SHARED_PATH}/database/database.sqlite" ]]; then + # touch "${DEPLOYER_SHARED_PATH}/database/database.sqlite" + # fi + # ln -sf "${DEPLOYER_SHARED_PATH}/database/database.sqlite" "${DEPLOYER_RELEASE_PATH}/database/database.sqlite" + # # Run migrations # ---- From 68bc19faf1dfb163d1e8d7ec5c2a51be47e5004f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 20 Nov 2025 17:55:48 +0200 Subject: [PATCH 05/10] feat(infrastructure): add Composer installation and improve permissions - Install Composer automatically in install-php.sh playbook - Improve directory permissions in site-add.sh (755 for proper execution) - Ensure deployment infrastructure supports PHP tooling --- playbooks/install-php.sh | 8 ++++++++ playbooks/site-add.sh | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/playbooks/install-php.sh b/playbooks/install-php.sh index 5295d414..52a48e33 100644 --- a/playbooks/install-php.sh +++ b/playbooks/install-php.sh @@ -67,6 +67,14 @@ install_php_packages() { packages+=("php${DEPLOYER_PHP_VERSION}-${ext}") done + # Ensure composer is installed if not already present + if ! command -v composer > /dev/null 2>&1; then + echo "→ Installing Composer..." + run_cmd curl -sS https://getcomposer.org/installer -o /tmp/composer-setup.php + run_cmd php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer + run_cmd rm /tmp/composer-setup.php + fi + # Install selected packages if ! apt_get_with_retry install -y "${packages[@]}" 2>&1; then echo "Error: Failed to install PHP ${DEPLOYER_PHP_VERSION} packages" >&2 diff --git a/playbooks/site-add.sh b/playbooks/site-add.sh index 38270058..8663fec1 100644 --- a/playbooks/site-add.sh +++ b/playbooks/site-add.sh @@ -85,8 +85,9 @@ setup_site_directories() { exit 1 fi - # Set permissions on all directories (750 - owner+group read/execute) - if ! run_cmd find "$site_path" -type d -exec chmod 750 {} +; then + # Set permissions on all directories (755 - owner rwx, group+others rx) + # This ensures git and other tools can traverse and execute properly + if ! run_cmd find "$site_path" -type d -exec chmod 755 {} +; then echo "Error: Failed to set directory permissions" >&2 exit 1 fi From d7d13c84e840c34e881b39f08c67622f005b5887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 20 Nov 2025 17:57:17 +0200 Subject: [PATCH 06/10] refactor(playbooks): remove Capistrano references --- playbooks/site-add.sh | 6 +++--- playbooks/site-deploy.sh | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/playbooks/site-add.sh b/playbooks/site-add.sh index 8663fec1..f5cf796e 100644 --- a/playbooks/site-add.sh +++ b/playbooks/site-add.sh @@ -3,7 +3,7 @@ # # Site Add Playbook - Ubuntu/Debian Only # -# Provision new site with Capistrano-style directory structure +# Provision new site with atomic deployment directory structure # ---- # # This playbook only supports Ubuntu and Debian distributions (debian family). @@ -46,7 +46,7 @@ export DEPLOYER_PERMS # ---- # -# Create Capistrano-style directory structure for site +# Create atomic deployment directory structure for site setup_site_directories() { local domain=$1 @@ -62,7 +62,7 @@ setup_site_directories() { fi fi - # Create Capistrano structure + # Create atomic deployment structure local dirs=( "${site_path}/releases" "${site_path}/shared" diff --git a/playbooks/site-deploy.sh b/playbooks/site-deploy.sh index 061490ba..41e9800c 100644 --- a/playbooks/site-deploy.sh +++ b/playbooks/site-deploy.sh @@ -3,7 +3,7 @@ # # Site Deploy Playbook - Ubuntu/Debian Only # -# Deploy site using Capistrano-style releases with deployment hooks +# Deploy site using atomic releases with deployment hooks # ---- # # This playbook orchestrates the complete deployment process for a site: @@ -14,7 +14,7 @@ # - Activates the new release by updating the current symlink # - Cleans up old releases beyond the retention limit # -# The deployment follows a Capistrano-style structure: +# The deployment follows an atomic release structure: # /home/deployer/sites/{domain}/ # ├── releases/ - Timestamped release directories # ├── current/ - Symlink to active release From 01bbd1fbb09ba5940632d020c8188fd56eed8b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 20 Nov 2025 18:08:28 +0200 Subject: [PATCH 07/10] fix: phpstan --- app/Console/Server/ServerLogsCommand.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Console/Server/ServerLogsCommand.php b/app/Console/Server/ServerLogsCommand.php index 13e9f8f5..0f7e7fcb 100644 --- a/app/Console/Server/ServerLogsCommand.php +++ b/app/Console/Server/ServerLogsCommand.php @@ -161,7 +161,9 @@ protected function getProcessedServices(array $info): array foreach ($info['php']['versions'] as $versionData) { $version = null; if (is_array($versionData) && isset($versionData['version'])) { - $version = (string) $versionData['version']; + /** @var string|int|float $rawVersion */ + $rawVersion = $versionData['version']; + $version = (string) $rawVersion; } elseif (is_string($versionData) || is_numeric($versionData)) { $version = (string) $versionData; } From 8ee93ce04b53bedff2b5c0efca0bcb3ba1aac6de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 20 Nov 2025 18:16:29 +0200 Subject: [PATCH 08/10] fix: improve deployment reliability and user feedback - Fix composer execution in build hooks to use proper PHP binary - Add error handling for Composer installation in PHP playbook - Move success message in site shared push to after complete operation --- app/Console/Site/SiteSharedPushCommand.php | 4 ++-- playbooks/install-php.sh | 18 ++++++++++++++++-- scaffolds/hooks/1-building.sh | 4 +++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/Console/Site/SiteSharedPushCommand.php b/app/Console/Site/SiteSharedPushCommand.php index 8bcbab2f..f8e24241 100644 --- a/app/Console/Site/SiteSharedPushCommand.php +++ b/app/Console/Site/SiteSharedPushCommand.php @@ -145,8 +145,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $this->yay('Shared file uploaded'); - // // Link shared file // ---- @@ -166,6 +164,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $result; } + $this->yay('Shared file uploaded and linked'); + // // Show command replay // ---- diff --git a/playbooks/install-php.sh b/playbooks/install-php.sh index 52a48e33..14b0f105 100644 --- a/playbooks/install-php.sh +++ b/playbooks/install-php.sh @@ -70,9 +70,23 @@ install_php_packages() { # Ensure composer is installed if not already present if ! command -v composer > /dev/null 2>&1; then echo "→ Installing Composer..." - run_cmd curl -sS https://getcomposer.org/installer -o /tmp/composer-setup.php - run_cmd php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer + if ! run_cmd curl -sS https://getcomposer.org/installer -o /tmp/composer-setup.php; then + echo "Error: Failed to download Composer installer" >&2 + exit 1 + fi + + if ! run_cmd php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer; then + echo "Error: Failed to install Composer" >&2 + run_cmd rm -f /tmp/composer-setup.php + exit 1 + fi + run_cmd rm /tmp/composer-setup.php + + if ! command -v composer > /dev/null 2>&1; then + echo "Error: Composer installation failed (command not found)" >&2 + exit 1 + fi fi # Install selected packages diff --git a/scaffolds/hooks/1-building.sh b/scaffolds/hooks/1-building.sh index 31abdfb0..66a02871 100755 --- a/scaffolds/hooks/1-building.sh +++ b/scaffolds/hooks/1-building.sh @@ -22,7 +22,9 @@ echo "→ Building release..." if [[ -f composer.json ]]; then echo "→ Installing Composer dependencies..." - "${DEPLOYER_PHP}" composer install --no-interaction --no-dev --optimize-autoloader + + composer_bin="$(command -v composer || true)" + "${DEPLOYER_PHP}" "${composer_bin}" install --no-interaction --no-dev --optimize-autoloader fi if [[ -f package.json ]]; then From ded49d675deb97d9bf1af0582a9a5fdaa2b3138f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 20 Nov 2025 20:22:40 +0200 Subject: [PATCH 09/10] fix(logs): improve HTTP status code detection in server logs Use regex word boundaries for HTTP status codes (500, 502, 503, 504) to match edge cases like 'Status: 500', '[500]', or 'error_code:500' while avoiding false positives for numbers containing these codes. --- app/Console/Server/ServerLogsCommand.php | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/app/Console/Server/ServerLogsCommand.php b/app/Console/Server/ServerLogsCommand.php index 0f7e7fcb..ddd64dd4 100644 --- a/app/Console/Server/ServerLogsCommand.php +++ b/app/Console/Server/ServerLogsCommand.php @@ -426,19 +426,20 @@ protected function readLogFile(ServerDTO $server, string $logFile, int $lines): */ protected function highlightErrors(string $content): string { - $keywords = [ + // 1. Text keywords (substring match) + $textKeywords = [ 'error', 'exception', 'fail', 'failed', 'fatal', 'panic', - ' 500 ', // HTTP 500 - ' 502 ', // HTTP 502 - ' 503 ', // HTTP 503 - ' 504 ', // HTTP 504 ]; + // 2. Numeric status codes (regex word boundary match) + // Matches 500, 502, 503, 504 as distinct words + $statusPattern = '/\b(500|502|503|504)\b/'; + $lines = explode("\n", $content); $processedLines = []; @@ -446,13 +447,19 @@ protected function highlightErrors(string $content): string $lowerLine = strtolower($line); $hasError = false; - foreach ($keywords as $keyword) { - if (str_contains($lowerLine, strtolower($keyword))) { + // Check text keywords + foreach ($textKeywords as $keyword) { + if (str_contains($lowerLine, $keyword)) { $hasError = true; break; } } + // Check numeric status codes if no text error found yet + if (!$hasError && preg_match($statusPattern, $line)) { + $hasError = true; + } + if ($hasError) { // Highlight the entire line in red $processedLines[] = "{$line}"; From 9b0937c50b97dcd522b87e87c006658bce2cd46d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 20 Nov 2025 21:29:07 +0200 Subject: [PATCH 10/10] fix(server:logs): address PR review feedback and improve robustness - Update help text to mention PHP-FPM and site log options - Improve PHPDoc with structured array shape for better type safety - Prevent duplicate PHP-FPM logs in 'all' mode by filtering detected services - Secure file paths using escapeshellarg to prevent shell injection - Apply rector fix for first-class callable syntax --- app/Console/Server/ServerLogsCommand.php | 34 +++++++++++++++++++----- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/app/Console/Server/ServerLogsCommand.php b/app/Console/Server/ServerLogsCommand.php index ddd64dd4..e9d3101c 100644 --- a/app/Console/Server/ServerLogsCommand.php +++ b/app/Console/Server/ServerLogsCommand.php @@ -24,7 +24,13 @@ class ServerLogsCommand extends BaseCommand use ServersTrait; /** - * @var array|null + * @var array{ + * options: array, + * services: list, + * phpVersions: list, + * sites: list, + * hasDocker: bool + * }|null */ private ?array $processedServices = null; @@ -37,7 +43,7 @@ protected function configure(): void $this->addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name'); $this->addOption('lines', 'n', InputOption::VALUE_REQUIRED, 'Number of lines to retrieve'); - $this->addOption('service', 's', InputOption::VALUE_REQUIRED, 'Service name (all|system|detected service name)'); + $this->addOption('service', 's', InputOption::VALUE_REQUIRED, 'Service name (all|system|php-fpm|site|detected service name)'); } // ---- Execution @@ -124,7 +130,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int * Process detected services, PHP versions, and sites to build options. * * @param array $info Server information from server-info playbook - * @return array + * @return array{ + * options: array, + * services: list, + * phpVersions: list, + * sites: list, + * hasDocker: bool + * } */ protected function getProcessedServices(array $info): array { @@ -141,7 +153,7 @@ protected function getProcessedServices(array $info): array $hasDocker = false; foreach ($detected as $service) { - $lower = strtolower($service); + $lower = strtolower((string) $service); if ($lower === 'unknown') { continue; @@ -152,7 +164,7 @@ protected function getProcessedServices(array $info): array continue; } - $services[] = $service; + $services[] = (string) $service; } // 2. PHP Versions (PHP-FPM) @@ -175,9 +187,10 @@ protected function getProcessedServices(array $info): array } // 3. Sites + /** @var list $sites */ $sites = []; if (isset($info['sites_config']) && is_array($info['sites_config'])) { - $sites = array_keys($info['sites_config']); + $sites = array_map(strval(...), array_keys($info['sites_config'])); } // Build options @@ -238,6 +251,12 @@ protected function displayServiceLogs(ServerDTO $server, string $service, int $l // 2. Detected Services (Caddy, SSH, etc.) foreach ($detectedServices as $serviceName) { + $lower = strtolower($serviceName); + if (str_starts_with($lower, 'php') && str_ends_with($lower, '-fpm')) { + // Prefer file-based PHP-FPM logs handled below + continue; + } + $this->retrieveServiceLogs($server, $serviceName, $serviceName, $lines); } @@ -409,7 +428,8 @@ protected function tryTraditionalLogs(ServerDTO $server, string $service, int $l protected function readLogFile(ServerDTO $server, string $logFile, int $lines): ?string { try { - $result = $this->ssh->executeCommand($server, "tail -n {$lines} {$logFile} 2>/dev/null"); + $safeLogFile = escapeshellarg($logFile); + $result = $this->ssh->executeCommand($server, "tail -n {$lines} {$safeLogFile} 2>/dev/null"); if ($result['exit_code'] === 0 && trim($result['output']) !== '') { return trim($result['output']);