From 2edd63774920651e13788f6e84dd90d8b9135a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 8 Nov 2025 12:32:24 +0200 Subject: [PATCH 01/10] feat(playbook): add server installation and demo site playbooks - server-install.sh: Install Caddy, PHP 8.4, PHP-FPM, Git, Bun on Debian/Ubuntu - demo-site.sh: Create deployer user, configure permissions, setup demo site --- playbooks/demo-site.sh | 284 +++++++++++++++++++++++++ playbooks/server-install.sh | 410 ++++++++++++++++++++++++++++++++++++ 2 files changed, 694 insertions(+) create mode 100644 playbooks/demo-site.sh create mode 100644 playbooks/server-install.sh diff --git a/playbooks/demo-site.sh b/playbooks/demo-site.sh new file mode 100644 index 00000000..ad6547b8 --- /dev/null +++ b/playbooks/demo-site.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash + +# +# Demo Site Setup Playbook +# +# Create deployer user, configure permissions, setup demo site +# ---- +# +# Required Environment Variables: +# DEPLOYER_OUTPUT_FILE - Output file path +# DEPLOYER_FAMILY - Distribution family: debian|fedora|redhat|amazon +# DEPLOYER_PERMS - Permissions: root|sudo +# +# Returns YAML with: +# - status: success +# - demo_site_path: /home/deployer/demo/public +# - deployer_user: created +# - caddy_configured: true +# + +set -o pipefail +export DEBIAN_FRONTEND=noninteractive + +[[ -z $DEPLOYER_OUTPUT_FILE ]] && echo "Error: DEPLOYER_OUTPUT_FILE required" && exit 1 +[[ -z $DEPLOYER_FAMILY ]] && echo "Error: DEPLOYER_FAMILY required" && exit 1 +[[ -z $DEPLOYER_PERMS ]] && echo "Error: DEPLOYER_PERMS required" && exit 1 +export DEPLOYER_PERMS + +# +# Helpers +# ---- + +# +# Execute command with appropriate permissions + +run_cmd() { + if [[ $DEPLOYER_PERMS == 'root' ]]; then + "$@" + else + sudo -n "$@" + fi +} + +# +# Get PHP-FPM user dynamically + +get_php_fpm_user() { + if [[ $DEPLOYER_FAMILY == 'debian' ]]; then + echo 'www-data' + else + local config_file='/etc/php-fpm.d/www.conf' + if [[ -f $config_file ]]; then + local user + user=$(grep -E '^\s*user\s*=' "$config_file" | awk '{print $3}' | tr -d ';') + if [[ -n $user ]]; then + echo "$user" + else + echo 'apache' + fi + else + echo 'apache' + fi + fi +} + +# +# Get PHP-FPM service name + +get_php_fpm_service() { + if [[ $DEPLOYER_FAMILY == 'debian' ]]; then + echo 'php8.4-fpm' + else + echo 'php-fpm' + fi +} + +# +# Setup Functions + +create_deployer_user() { + if id -u deployer > /dev/null 2>&1; then + echo "✓ Deployer user already exists" + else + echo "✓ Creating deployer user..." + if ! run_cmd useradd -m -s /bin/bash deployer; then + echo "Error: Failed to create deployer user" >&2 + exit 1 + fi + fi + + # Add caddy user to deployer group so it can access deployer's files + if ! id -nG caddy 2> /dev/null | grep -qw deployer; then + echo "✓ Adding caddy user to deployer group..." + if ! run_cmd usermod -aG deployer caddy; then + echo "Error: Failed to add caddy to deployer group" >&2 + exit 1 + fi + + # Restart Caddy so it picks up the new group membership + if systemctl is-active --quiet caddy 2> /dev/null; then + echo "✓ Restarting Caddy to apply group membership..." + if ! run_cmd systemctl restart caddy; then + echo "Error: Failed to restart Caddy" >&2 + exit 1 + fi + fi + fi + + # Add PHP-FPM user to deployer group so it can access files + local php_fpm_user php_fpm_service + php_fpm_user=$(get_php_fpm_user) + php_fpm_service=$(get_php_fpm_service) + + if id -u "$php_fpm_user" > /dev/null 2>&1; then + if ! id -nG "$php_fpm_user" 2> /dev/null | grep -qw deployer; then + echo "✓ Adding $php_fpm_user user to deployer group..." + if ! run_cmd usermod -aG deployer "$php_fpm_user"; then + echo "Error: Failed to add $php_fpm_user to deployer group" >&2 + exit 1 + fi + + # Restart PHP-FPM so it picks up the new group membership + if systemctl is-active --quiet "$php_fpm_service" 2> /dev/null; then + echo "✓ Restarting PHP-FPM to apply group membership..." + if ! run_cmd systemctl restart "$php_fpm_service"; then + echo "Error: Failed to restart PHP-FPM" >&2 + exit 1 + fi + fi + fi + else + echo "Warning: PHP-FPM user '$php_fpm_user' not found, skipping group assignment" + fi +} + +setup_demo_site() { + echo "✓ Setting up demo site..." + + # Create directory structure + if [[ ! -d /home/deployer/demo/public ]]; then + if ! run_cmd mkdir -p /home/deployer/demo/public; then + echo "Error: Failed to create demo site directory" >&2 + exit 1 + fi + fi + + # Create index.php + if [[ ! -f /home/deployer/demo/public/index.php ]]; then + if ! run_cmd tee /home/deployer/demo/public/index.php > /dev/null <<- 'EOF'; then + &2 + exit 1 + fi + fi + + # Set ownership and group permissions + # Caddy user is in deployer group, so set group-readable permissions + if ! run_cmd chown -R deployer:deployer /home/deployer/demo; then + echo "Error: Failed to set ownership on demo site" >&2 + exit 1 + fi + + # Set permissions: owner+group can read/execute dirs, read files (750 dirs, 640 files) + if ! run_cmd chmod 750 /home/deployer; then + echo "Error: Failed to set permissions on deployer home" >&2 + exit 1 + fi + + if ! run_cmd chmod 750 /home/deployer/demo; then + echo "Error: Failed to set permissions on demo directory" >&2 + exit 1 + fi + + if ! run_cmd chmod 750 /home/deployer/demo/public; then + echo "Error: Failed to set permissions on public directory" >&2 + exit 1 + fi + + if ! run_cmd chmod 640 /home/deployer/demo/public/index.php; then + echo "Error: Failed to set permissions on index.php" >&2 + exit 1 + fi +} + +configure_caddy() { + echo "✓ Configuring Caddy..." + + # Determine PHP-FPM socket path + local php_fpm_socket + if [[ $DEPLOYER_FAMILY == 'debian' ]]; then + php_fpm_socket='/run/php/php8.4-fpm.sock' + else + php_fpm_socket='/run/php-fpm/www.sock' + fi + + # Create log directory + if [[ ! -d /var/log/caddy ]]; then + if ! run_cmd mkdir -p /var/log/caddy; then + echo "Error: Failed to create Caddy log directory" >&2 + exit 1 + fi + fi + + if ! run_cmd chown -R caddy:caddy /var/log/caddy; then + echo "Error: Failed to set ownership on Caddy log directory" >&2 + exit 1 + fi + + # Create Caddyfile with logging - using the simplest PHP configuration + if ! run_cmd tee /etc/caddy/Caddyfile > /dev/null <<- EOF; then + { + log { + output file /var/log/caddy/access.log + format json + } + } + + # Demo site - HTTP only (can't get HTTPS cert for IP address) + http://:80 { + root * /home/deployer/demo/public + encode gzip + + log { + output file /var/log/caddy/demo-access.log { + roll_size 100mb + roll_keep 5 + roll_keep_for 720h + } + format json + } + + # This single line handles everything: PHP files, index.php routing, and static files + php_fastcgi unix${php_fpm_socket} + } + EOF + echo "Error: Failed to create Caddyfile" >&2 + exit 1 + fi + + # Enable and start Caddy + if ! systemctl is-enabled --quiet caddy 2> /dev/null; then + if ! run_cmd systemctl enable --quiet caddy; then + echo "Error: Failed to enable Caddy service" >&2 + exit 1 + fi + fi + + if systemctl is-active --quiet caddy 2> /dev/null; then + if ! run_cmd systemctl reload caddy; then + echo "Error: Failed to reload Caddy service" >&2 + exit 1 + fi + else + if ! run_cmd systemctl start caddy; then + echo "Error: Failed to start Caddy service" >&2 + exit 1 + fi + fi +} + +# +# Main Execution +# ---- + +main() { + # Execute setup tasks + create_deployer_user + setup_demo_site + configure_caddy + + # Write output YAML + if ! cat > "$DEPLOYER_OUTPUT_FILE" <<- EOF; then + status: success + demo_site_path: /home/deployer/demo/public + deployer_user: created + caddy_configured: true + EOF + echo "Error: Failed to write output file" >&2 + exit 1 + fi +} + +main "$@" diff --git a/playbooks/server-install.sh b/playbooks/server-install.sh new file mode 100644 index 00000000..91cdba1f --- /dev/null +++ b/playbooks/server-install.sh @@ -0,0 +1,410 @@ +#!/usr/bin/env bash + +# +# Server Installation Playbook - Debian Family (Ubuntu, Debian) +# +# Install Caddy, PHP 8.4, PHP-FPM, Git, Bun +# ---- +# +# Required Environment Variables: +# DEPLOYER_OUTPUT_FILE - Output file path +# DEPLOYER_DISTRO - Exact distribution: ubuntu|debian +# DEPLOYER_FAMILY - Distribution family: debian +# DEPLOYER_PERMS - Permissions: root|sudo +# +# Returns YAML with: +# - status: success +# - distro: detected distribution +# - php_version: installed PHP version +# - caddy_version: installed Caddy version +# - git_version: installed Git version +# - bun_version: installed Bun version +# - tasks_completed: list of completed tasks +# + +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_FAMILY ]] && echo "Error: DEPLOYER_FAMILY required" && exit 1 +[[ -z $DEPLOYER_PERMS ]] && echo "Error: DEPLOYER_PERMS required" && exit 1 +export DEPLOYER_PERMS + +# +# Helpers +# ---- + +# +# Execute command with appropriate permissions + +run_cmd() { + if [[ $DEPLOYER_PERMS == 'root' ]]; then + "$@" + else + sudo -n "$@" + fi +} + +# +# Wait for dpkg lock to be released + +wait_for_dpkg_lock() { + local max_wait=60 + local waited=0 + local lock_found=false + + # Check multiple times to catch the lock even in race conditions + while ((waited < max_wait)); do + # Try to acquire the lock by checking if we can open it + if fuser /var/lib/dpkg/lock-frontend > /dev/null 2>&1 \ + || fuser /var/lib/dpkg/lock > /dev/null 2>&1 \ + || fuser /var/lib/apt/lists/lock > /dev/null 2>&1; then + lock_found=true + echo "✓ Waiting for package manager lock to be released..." + sleep 2 + waited=$((waited + 2)) + else + # Lock not held, but wait a bit to ensure it's really released + if [[ $lock_found == true ]]; then + # Was locked before, give it extra time + sleep 2 + else + # Never saw lock, just a small delay + sleep 1 + fi + return 0 + fi + done + + echo "Error: Timeout waiting for dpkg lock to be released" >&2 + return 1 +} + +# +# apt-get with retry + +apt_get_with_retry() { + local max_attempts=5 + local attempt=1 + local wait_time=10 + local output + + while ((attempt <= max_attempts)); do + # Capture output to check for lock errors + output=$(run_cmd apt-get "$@" 2>&1) + local exit_code=$? + + if ((exit_code == 0)); then + [[ -n $output ]] && echo "$output" + return 0 + fi + + # Only retry on lock-related errors + if echo "$output" | grep -qE 'Could not get lock|dpkg.*lock|Unable to acquire'; then + if ((attempt < max_attempts)); then + echo "✓ Package manager locked, waiting ${wait_time}s before retry (attempt ${attempt}/${max_attempts})..." + sleep "$wait_time" + wait_time=$((wait_time + 5)) + attempt=$((attempt + 1)) + wait_for_dpkg_lock || true + else + echo "$output" >&2 + return "$exit_code" + fi + else + # Non-lock error, fail immediately + echo "$output" >&2 + return "$exit_code" + fi + done + + return 1 +} + +# +# Installation Functions +# ---- + +install_all_packages() { + echo "✓ Installing all packages..." + + case $DEPLOYER_DISTRO in + ubuntu) + # Update package lists FIRST + echo "✓ Updating package lists..." + if ! apt_get_with_retry update -q; then + echo "Error: Failed to update package lists" >&2 + exit 1 + fi + + # Install prerequisites (now that package lists are updated) + echo "✓ Installing prerequisites..." + if ! apt_get_with_retry install -y -q curl software-properties-common; then + echo "Error: Failed to install prerequisites" >&2 + exit 1 + fi + + # Add all repositories (now that prerequisites are installed) + echo "✓ Setting up repositories..." + + # Caddy repository (curl is now available) + if ! [[ -f /usr/share/keyrings/caddy-stable-archive-keyring.gpg ]]; then + if ! curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | run_cmd gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg; then + echo "Error: Failed to add Caddy GPG key" >&2 + exit 1 + fi + fi + + if ! [[ -f /etc/apt/sources.list.d/caddy-stable.list ]]; then + if ! curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | run_cmd tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null; then + echo "Error: Failed to add Caddy repository" >&2 + exit 1 + fi + fi + + # PHP PPA (Ubuntu only) + if ! grep -qr "ondrej/php" /etc/apt/sources.list /etc/apt/sources.list.d/ 2> /dev/null; then + if ! run_cmd env DEBIAN_FRONTEND=noninteractive add-apt-repository -y ppa:ondrej/php 2>&1; then + echo "Error: Failed to add PHP PPA" >&2 + exit 1 + fi + fi + + # Update package lists again (after adding repositories) + echo "✓ Updating package lists..." + if ! apt_get_with_retry update -q; then + echo "Error: Failed to update package lists" >&2 + exit 1 + fi + + # Install remaining packages in batched groups + echo "✓ Installing system utilities..." + if ! apt_get_with_retry install -y -q unzip; then + echo "Error: Failed to install system utilities" >&2 + exit 1 + fi + + echo "✓ Installing main packages..." + if ! apt_get_with_retry install -y -q caddy git rsync; then + echo "Error: Failed to install main packages" >&2 + exit 1 + fi + + echo "✓ Installing PHP 8.4..." + if ! apt_get_with_retry install -y -q --no-install-recommends \ + php8.4-cli \ + php8.4-fpm \ + php8.4-common \ + php8.4-opcache \ + php8.4-bcmath \ + php8.4-curl \ + php8.4-mbstring \ + php8.4-xml \ + php8.4-zip \ + php8.4-gd \ + php8.4-intl \ + php8.4-soap 2>&1; then + echo "Error: Failed to install PHP 8.4 packages" >&2 + exit 1 + fi + ;; + debian) + # Update package lists FIRST + echo "✓ Updating package lists..." + if ! apt_get_with_retry update -q; then + echo "Error: Failed to update package lists" >&2 + exit 1 + fi + + # Install prerequisites (now that package lists are updated) + echo "✓ Installing prerequisites..." + if ! apt_get_with_retry install -y -q curl apt-transport-https lsb-release ca-certificates; then + echo "Error: Failed to install prerequisites" >&2 + exit 1 + fi + + # Add all repositories (now that prerequisites are installed) + echo "✓ Setting up repositories..." + + # Caddy repository (curl is now available) + if ! [[ -f /usr/share/keyrings/caddy-stable-archive-keyring.gpg ]]; then + if ! curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | run_cmd gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg; then + echo "Error: Failed to add Caddy GPG key" >&2 + exit 1 + fi + fi + + if ! [[ -f /etc/apt/sources.list.d/caddy-stable.list ]]; then + if ! curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | run_cmd tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null; then + echo "Error: Failed to add Caddy repository" >&2 + exit 1 + fi + fi + + # Sury PHP repository (Debian native - NOT a PPA) + if ! [[ -f /usr/share/keyrings/php-sury-archive-keyring.gpg ]]; then + if ! curl -fsSL 'https://packages.sury.org/php/apt.gpg' | run_cmd gpg --batch --yes --dearmor -o /usr/share/keyrings/php-sury-archive-keyring.gpg; then + echo "Error: Failed to add Sury PHP GPG key" >&2 + exit 1 + fi + fi + + if ! [[ -f /etc/apt/sources.list.d/php-sury.list ]]; then + local debian_codename + debian_codename=$(lsb_release -sc) + if ! echo "deb [signed-by=/usr/share/keyrings/php-sury-archive-keyring.gpg] https://packages.sury.org/php/ ${debian_codename} main" | run_cmd tee /etc/apt/sources.list.d/php-sury.list > /dev/null; then + echo "Error: Failed to add Sury PHP repository" >&2 + exit 1 + fi + fi + + # Update package lists again (after adding repositories) + echo "✓ Updating package lists..." + if ! apt_get_with_retry update -q; then + echo "Error: Failed to update package lists" >&2 + exit 1 + fi + + # Install remaining packages in batched groups + echo "✓ Installing system utilities..." + if ! apt_get_with_retry install -y -q unzip; then + echo "Error: Failed to install system utilities" >&2 + exit 1 + fi + + echo "✓ Installing main packages..." + if ! apt_get_with_retry install -y -q caddy git rsync; then + echo "Error: Failed to install main packages" >&2 + exit 1 + fi + + echo "✓ Installing PHP 8.4..." + if ! apt_get_with_retry install -y -q --no-install-recommends \ + php8.4-cli \ + php8.4-fpm \ + php8.4-common \ + php8.4-opcache \ + php8.4-bcmath \ + php8.4-curl \ + php8.4-mbstring \ + php8.4-xml \ + php8.4-zip \ + php8.4-gd \ + php8.4-intl \ + php8.4-soap 2>&1; then + echo "Error: Failed to install PHP 8.4 packages" >&2 + exit 1 + fi + ;; + esac + + # Configure PHP-FPM + echo "✓ Configuring PHP-FPM..." + + # Set socket ownership so Caddy can access it + if ! run_cmd sed -i 's/^;listen.owner = .*/listen.owner = caddy/' /etc/php/8.4/fpm/pool.d/www.conf; then + echo "Error: Failed to set PHP-FPM socket owner" >&2 + exit 1 + fi + if ! run_cmd sed -i 's/^;listen.group = .*/listen.group = caddy/' /etc/php/8.4/fpm/pool.d/www.conf; then + echo "Error: Failed to set PHP-FPM socket group" >&2 + exit 1 + fi + if ! run_cmd sed -i 's/^;listen.mode = .*/listen.mode = 0660/' /etc/php/8.4/fpm/pool.d/www.conf; then + echo "Error: Failed to set PHP-FPM socket mode" >&2 + exit 1 + fi + + if ! systemctl is-enabled --quiet php8.4-fpm 2> /dev/null; then + if ! run_cmd systemctl enable --quiet php8.4-fpm; then + echo "Error: Failed to enable PHP-FPM service" >&2 + exit 1 + fi + fi + if ! systemctl is-active --quiet php8.4-fpm 2> /dev/null; then + if ! run_cmd systemctl start php8.4-fpm; then + echo "Error: Failed to start PHP-FPM service" >&2 + exit 1 + fi + fi +} + +install_bun() { + if command -v bun > /dev/null 2>&1; then + echo "✓ Bun already installed" + return 0 + fi + + echo "✓ Installing Bun..." + + # Install Bun system-wide to /usr/local (unzip is now installed in batched packages) + if ! curl -fsSL https://bun.sh/install | run_cmd env BUN_INSTALL=/usr/local bash; then + echo "Error: Failed to install Bun" >&2 + exit 1 + fi +} + +validate_php_version() { + local php_version + php_version=$(php -r "echo PHP_VERSION;" 2> /dev/null || echo "unknown") + + if [[ $php_version == "unknown" ]]; then + echo "Error: PHP installation failed or PHP not in PATH" >&2 + exit 1 + fi + + # Extract major.minor version + local php_major_minor + php_major_minor=$(echo "$php_version" | cut -d. -f1,2) + + # Check if below 8.3 using awk + if awk "BEGIN {exit !($php_major_minor < 8.3)}"; then + echo "Error: PHP $php_version is below minimum required version 8.3" >&2 + exit 1 + fi + + echo "✓ PHP $php_version installed (meets minimum 8.3)" +} + +# +# Main Execution +# ---- + +main() { + local php_version caddy_version bun_version git_version + + # Execute installation tasks + install_all_packages + install_bun + validate_php_version + + # Get versions + php_version=$(php -r "echo PHP_VERSION;" 2> /dev/null || echo "unknown") + caddy_version=$(caddy version 2> /dev/null | head -n1 | awk '{print $1}' || echo "unknown") + git_version=$(git --version 2> /dev/null | awk '{print $3}' || echo "unknown") + bun_version=$(bun --version 2> /dev/null || echo "unknown") + + # Write output YAML + if ! cat > "$DEPLOYER_OUTPUT_FILE" <<- EOF; then + status: success + distro: $DEPLOYER_DISTRO + php_version: $php_version + caddy_version: $caddy_version + git_version: $git_version + bun_version: $bun_version + tasks_completed: + - install_caddy + - install_php + - install_extensions + - configure_php_fpm + - install_git + - install_rsync + - install_bun + EOF + echo "Error: Failed to write output file" >&2 + exit 1 + fi +} + +main "$@" From 8841b600417d44958e9772db9572bcc54d6d60ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 8 Nov 2025 12:32:27 +0200 Subject: [PATCH 02/10] feat(server): add install, logs, and run commands - server:install - Install and prepare server for PHP applications - server:logs - View server logs (system and detected services) - server:run - Execute arbitrary commands on remote servers --- app/Console/Server/ServerInstallCommand.php | 226 +++++++++++++ app/Console/Server/ServerLogsCommand.php | 335 ++++++++++++++++++++ app/Console/Server/ServerRunCommand.php | 118 +++++++ 3 files changed, 679 insertions(+) create mode 100644 app/Console/Server/ServerInstallCommand.php create mode 100644 app/Console/Server/ServerLogsCommand.php create mode 100644 app/Console/Server/ServerRunCommand.php diff --git a/app/Console/Server/ServerInstallCommand.php b/app/Console/Server/ServerInstallCommand.php new file mode 100644 index 00000000..6e9ef93e --- /dev/null +++ b/app/Console/Server/ServerInstallCommand.php @@ -0,0 +1,226 @@ +addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name'); + } + + // + // Execution + // ---- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->heading('Install Server'); + + // + // Select server & display details + // ---- + + $server = $this->selectServer(); + + if (is_int($server)) { + return $server; + } + + $this->displayServerDeets($server); + + // + // Get server info (verifies SSH connection and validates distribution) + // ---- + + $info = $this->getServerInfo($server); + + if (is_int($info)) { + return $info; + } + + // + // Validate server info + // ---- + + /** @var string $distro */ + $distro = $info['distro'] ?? 'unknown'; + $distribution = Distribution::tryFrom($distro); + if ($distribution === null) { + $this->nay("Distribution validation failed: {$distro}"); + + return Command::FAILURE; + } + + $permissions = $info['permissions'] ?? null; + if (!is_string($permissions) || !in_array($permissions, ['root', 'sudo'])) { + $this->nay('Server requires root or sudo permissions to install software'); + + return Command::FAILURE; + } + + $family = $distribution->family()->value; + + // + // Execute installation playbook + // --- + + $result = $this->executePlaybook( + $server, + 'server-install', + 'Installing server...', + [ + 'DEPLOYER_DISTRO' => $distro, + 'DEPLOYER_FAMILY' => $family, + 'DEPLOYER_PERMS' => $permissions, + ], + true + ); + + if (is_int($result)) { + $this->io->error('Server installation failed'); + + return $result; + } + + $this->yay('Server installed successfully'); + + // + // Setup demo site + // ---- + + $demoResult = $this->executePlaybook( + $server, + 'demo-site', + 'Setting up demo site...', + [ + 'DEPLOYER_FAMILY' => $family, + 'DEPLOYER_PERMS' => $permissions, + ], + true + ); + + if (is_int($demoResult)) { + $this->io->error('Demo site setup failed'); + + return Command::FAILURE; + } + + $this->yay('Demo site setup successful'); + + // + // Verify installation + // ---- + + $url = 'http://' . $server->host; + $verification = $this->io->promptSpin( + fn () => $this->verifyInstallation($url), + 'Verifying installation...' + ); + + if ($verification['status'] === 'success') { + $this->yay($verification['message']); + } else { + $this->io->warning($verification['message']); + } + + if ($verification['lines'] !== []) { + $this->io->writeln($verification['lines']); + } + + // + // Show command replay + // ---- + + $this->showCommandReplay('server:install', [ + 'server' => $server->name, + ]); + + return Command::SUCCESS; + } + + // + // HTTP Verification + // ---- + + /** + * Verify demo site is responding with expected content. + * + * @return array{status: 'success'|'warning', message: string, lines: array} + */ + private function verifyInstallation(string $url): array + { + try { + $client = new Client([ + 'timeout' => 10, + 'http_errors' => false, + ]); + + $response = $client->get($url); + $statusCode = $response->getStatusCode(); + $body = (string) $response->getBody(); + + if ($statusCode !== 200) { + return [ + 'status' => 'warning', + 'message' => "Demo site returned HTTP {$statusCode} (expected 200)", + 'lines' => [], + ]; + } + + if (!str_contains($body, 'hello, world')) { + return [ + 'status' => 'warning', + 'message' => 'Demo site is responding but content verification failed', + 'lines' => [], + ]; + } + + return [ + 'status' => 'success', + 'message' => 'Server installation completed successfully', + 'lines' => [ + 'Next steps:', + ' • Caddy running at ' . $url . '', + ' • Run site:add to deploy your first application', + '', + ], + ]; + } catch (\Throwable $e) { + return [ + 'status' => 'warning', + 'message' => 'Could not verify demo site: ' . $e->getMessage(), + 'lines' => [], + ]; + } + } + +} diff --git a/app/Console/Server/ServerLogsCommand.php b/app/Console/Server/ServerLogsCommand.php new file mode 100644 index 00000000..0eb76bfe --- /dev/null +++ b/app/Console/Server/ServerLogsCommand.php @@ -0,0 +1,335 @@ +|null + */ + private ?array $processedServices = null; + + // ---- + // Configuration + // ---- + + protected function configure(): void + { + parent::configure(); + + $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)'); + } + + // ---- + // Execution + // ---- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->heading('Server Logs'); + + // + // Select server & display details + // ---- + + $server = $this->selectServer(); + + if (is_int($server)) { + return $server; + } + + $this->displayServerDeets($server); + + // + // Get server info (verifies SSH connection and validates distribution) + // ---- + + $info = $this->getServerInfo($server); + + if (is_int($info)) { + return $info; + } + + // + // Get user input + // ---- + + $lines = $this->io->getOptionOrPrompt( + 'lines', + fn () => $this->io->promptText( + label: 'Number of lines:', + default: '10', + validate: fn ($value) => is_numeric($value) && (int) $value > 0 ? null : 'Must be a positive number' + ) + ); + + $processed = $this->getProcessedServices($info); + + /** @var array $options */ + $options = $processed['options']; + + $service = $this->io->getOptionOrPrompt( + 'service', + fn () => $this->io->promptSelect( + label: 'Which service logs?', + options: $options, + default: 'all' + ) + ); + + // + // Retrieve logs + // ---- + + $this->displayServiceLogs($server, (string) $service, (int) $lines, $info); + + // + // Show command replay + // ---- + + $this->showCommandReplay('server:logs', [ + 'server' => $server->name, + 'lines' => $lines, + 'service' => $service, + ]); + + return Command::SUCCESS; + } + + // ---- + // Helpers + // ---- + + /** + * Process detected services and build options for user selection. + * + * Consolidates Docker-related processes and caches results for reuse. + * + * @param array $info Server information from server-info playbook + * @return array + */ + protected function getProcessedServices(array $info): array + { + if ($this->processedServices !== null) { + return $this->processedServices; + } + + /** @var array $ports */ + $ports = $info['ports'] ?? []; + $detected = array_unique(array_values($ports)); + + $services = []; + $hasDocker = false; + + foreach ($detected as $service) { + $lower = strtolower($service); + + if ($lower === 'unknown') { + continue; + } + + if (str_contains($lower, 'docker') || $lower === 'private-network') { + $hasDocker = true; + continue; + } + + $services[] = $service; + } + + $options = [ + 'all' => 'All services', + 'system' => 'System logs', + ]; + + foreach ($services as $service) { + $options[strtolower($service)] = $service; + } + + if ($hasDocker) { + $options['docker'] = 'docker'; + } + + return $this->processedServices = [ + 'options' => $options, + 'services' => $services, + 'hasDocker' => $hasDocker, + ]; + } + + /** + * Display logs for selected service(s). + * + * @param array $info Server information + */ + protected function displayServiceLogs(ServerDTO $server, string $service, int $lines, array $info): void + { + if ($service === 'all') { + $processed = $this->getProcessedServices($info); + + $this->retrieveServiceLogs($server, 'System', '', $lines); + + /** @var array $services */ + $services = $processed['services']; + foreach ($services as $serviceName) { + $this->retrieveServiceLogs($server, $serviceName, $serviceName, $lines); + } + + /** @var bool $hasDocker */ + $hasDocker = $processed['hasDocker']; + if ($hasDocker) { + $this->retrieveServiceLogs($server, 'docker', 'docker', $lines); + } + } elseif ($service === 'system') { + $this->retrieveServiceLogs($server, 'System', '', $lines); + } else { + $this->retrieveServiceLogs($server, $service, $service, $lines); + } + } + + /** + * Retrieve service logs via journalctl. + */ + protected function retrieveServiceLogs(ServerDTO $server, string $service, string $unit, int $lines): void + { + $this->io->writeln([ + "{$service} Logs", + '', + ]); + + try { + $command = $unit === '' + ? "journalctl -n {$lines} --no-pager" + : "journalctl -u " . implode(' -u ', $this->getServiceNamePatterns($unit)) . " -n {$lines} --no-pager 2>&1"; + + $result = $this->ssh->executeCommand($server, $command); + $output = trim($result['output']); + + $serviceNotFound = str_contains($output, 'No data available') || + str_contains($output, 'Failed to add filter'); + $noData = $output === '' || $output === '-- No entries --'; + + if ($result['exit_code'] !== 0 && !$serviceNotFound) { + $this->nay("Failed to retrieve {$service} logs"); + $this->io->writeln($output); + $this->io->writeln(''); + + return; + } + + if ($serviceNotFound || $noData) { + $this->tryTraditionalLogs($server, $service, $lines); + $this->io->writeln(''); + + return; + } + + $this->io->writeln($output); + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + } + + $this->io->writeln(''); + } + + /** + * Try to find and display traditional log files in /var/log/. + */ + protected function tryTraditionalLogs(ServerDTO $server, string $service, int $lines): void + { + try { + $serviceLower = strtolower($service); + $findCommand = "find /var/log -type f -iname '*{$serviceLower}*' 2>/dev/null | head -5"; + $result = $this->ssh->executeCommand($server, $findCommand); + + if ($result['exit_code'] !== 0 || trim($result['output']) === '') { + $this->io->writeln("No {$service} logs found"); + + return; + } + + $logFiles = array_filter(array_map(trim(...), explode("\n", trim($result['output'])))); + + foreach ($logFiles as $logFile) { + $logContent = $this->readLogFile($server, $logFile, $lines); + + if ($logContent !== null) { + $this->io->writeln([ + "From {$logFile}:", + '', + $logContent, + ]); + + return; + } + } + + $this->io->writeln("No {$service} logs found"); + } catch (\RuntimeException) { + $this->io->writeln("No {$service} logs found"); + } + } + + /** + * Attempt to read a log file from the server. + */ + protected function readLogFile(ServerDTO $server, string $logFile, int $lines): ?string + { + try { + $result = $this->ssh->executeCommand($server, "tail -n {$lines} {$logFile} 2>/dev/null"); + + if ($result['exit_code'] === 0 && trim($result['output']) !== '') { + return trim($result['output']); + } + + return null; + } catch (\RuntimeException) { + return null; + } + } + + /** + * Get common systemd service name patterns for a process. + * + * @return array + */ + protected function getServiceNamePatterns(string $process): array + { + $patterns = [ + $process, // As-is + "{$process}.service", // With .service + ]; + + // Handle common naming variations + $variations = match ($process) { + 'sshd' => ['ssh', 'ssh.service'], + 'systemd-resolve' => ['systemd-resolved', 'systemd-resolved.service'], + 'docker', 'docker-proxy' => ['docker', 'docker.service', 'dockerd', 'dockerd.service'], + default => [], + }; + + return array_unique(array_merge($patterns, $variations)); + } +} diff --git a/app/Console/Server/ServerRunCommand.php b/app/Console/Server/ServerRunCommand.php new file mode 100644 index 00000000..bbb33264 --- /dev/null +++ b/app/Console/Server/ServerRunCommand.php @@ -0,0 +1,118 @@ +addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name'); + $this->addOption('command', null, InputOption::VALUE_REQUIRED, 'Command to execute'); + } + + // ---- + // Execution + // ---- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->heading('Run Command on Server'); + + // + // Select server & display details + // ---- + + $server = $this->selectServer(); + + if (is_int($server)) { + return $server; + } + + $this->displayServerDeets($server); + + // + // Get command to execute + // ---- + + $command = $this->io->getOptionOrPrompt( + 'command', + fn () => $this->io->promptText( + label: 'Command to execute:', + placeholder: 'ls -la', + required: true + ) + ); + + if (!is_string($command) || trim($command) === '') { + $this->nay('Command cannot be empty'); + + return Command::FAILURE; + } + + // + // Execute command with real-time output streaming + // ---- + + $this->io->writeln([ + "Executing command...", + '', + ]); + + try { + $result = $this->ssh->executeCommand( + $server, + $command, + fn (string $chunk) => $this->io->write($chunk) + ); + + $this->io->writeln(''); + + if ($result['exit_code'] !== 0) { + $this->nay("Command failed with exit code {$result['exit_code']}"); + + return Command::FAILURE; + } + + $this->yay('Command executed successfully'); + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + + return Command::FAILURE; + } + + // + // Show command replay + // ---- + + $this->showCommandReplay('server:run', [ + 'server' => $server->name, + 'command' => $command, + ]); + + return Command::SUCCESS; + } +} From 73d003e77b190904a1f8971006fec9ac9e63dad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 8 Nov 2025 12:32:30 +0200 Subject: [PATCH 03/10] feat(server): enhance server traits and distribution support - Add server management methods to ServersTrait - Extend Distribution enum with family() and isSupported() methods - Update DistributionFamily enum for better organization --- app/Enums/Distribution.php | 22 +++++++ app/Enums/DistributionFamily.php | 12 +++- app/Traits/PlaybooksTrait.php | 4 +- app/Traits/ServersTrait.php | 108 +++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 6 deletions(-) diff --git a/app/Enums/Distribution.php b/app/Enums/Distribution.php index 1f4fea28..50bb5f42 100644 --- a/app/Enums/Distribution.php +++ b/app/Enums/Distribution.php @@ -20,6 +20,28 @@ enum Distribution: string case RHEL = 'rhel'; case AMAZON = 'amazon'; + /** + * Get the distribution family. + */ + public function family(): DistributionFamily + { + return match ($this) { + self::UBUNTU, self::DEBIAN => DistributionFamily::DEBIAN, + default => DistributionFamily::DEBIAN, // Fallback for unsupported distributions + }; + } + + /** + * Check if distribution is supported. + */ + public function isSupported(): bool + { + return match ($this) { + self::UBUNTU, self::DEBIAN => true, + default => false, + }; + } + /** * Get human-readable display name. */ diff --git a/app/Enums/DistributionFamily.php b/app/Enums/DistributionFamily.php index 70f41b9b..7a4cde91 100644 --- a/app/Enums/DistributionFamily.php +++ b/app/Enums/DistributionFamily.php @@ -12,8 +12,14 @@ enum DistributionFamily: string { case DEBIAN = 'debian'; - case FEDORA = 'fedora'; - case REDHAT = 'redhat'; - case AMAZON = 'amazon'; + /** + * Get all family names as array. + * + * @return array + */ + public static function names(): array + { + return array_map(fn (self $family) => $family->value, self::cases()); + } } diff --git a/app/Traits/PlaybooksTrait.php b/app/Traits/PlaybooksTrait.php index d7c18d3c..3695ae25 100644 --- a/app/Traits/PlaybooksTrait.php +++ b/app/Traits/PlaybooksTrait.php @@ -111,9 +111,7 @@ protected function executePlaybook( $this->nay($e->getMessage()); $this->io->writeln([ '', - 'The process took longer than expected to complete.', - '', - 'Package downloads or installation are taking longer than expected. Either:', + 'The process took longer than expected to complete. Either:', ' • Server has a slow network connection', ' • Or the server is under heavy load', '', diff --git a/app/Traits/ServersTrait.php b/app/Traits/ServersTrait.php index 093d4a46..3279eccc 100644 --- a/app/Traits/ServersTrait.php +++ b/app/Traits/ServersTrait.php @@ -6,6 +6,7 @@ use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; +use Bigpixelrocket\DeployerPHP\Enums\Distribution; use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Services\IOService; use Bigpixelrocket\DeployerPHP\Services\SSHService; @@ -15,6 +16,7 @@ * Reusable server things. * * Requires classes using this trait to have IOService, ServerRepository, SSHService, and SiteRepository properties. + * Also requires PlaybooksTrait for getServerInfo() method. * * @property IOService $io * @property ServerRepository $servers @@ -23,12 +25,118 @@ */ trait ServersTrait { + use PlaybooksTrait; + // ------------------------------------------------------------------------------- // // Helpers // // ------------------------------------------------------------------------------- + // + // Server info + // ------------------------------------------------------------------------------- + + /** + * Get server information by executing server-info playbook. + * + * Automatically displays server info and validates that the server is running a supported distribution (Debian/Ubuntu). + * + * @param ServerDTO $server Server to get information for + * @return array|int Returns parsed server info or failure code on failure + */ + protected function getServerInfo(ServerDTO $server): array|int + { + $info = $this->executePlaybook( + $server, + 'server-info', + 'Retrieving server information...' + ); + + if (is_int($info)) { + return $info; + } + + // Display server information before validation + $this->displayServerInfo($info); + + return $this->validateServerDistribution($info); + } + + /** + * Validate that server is running a supported distribution. + * + * @param array $info Server information array from server-info playbook + * @return array|int Returns validated server info or failure code + */ + protected function validateServerDistribution(array $info): array|int + { + /** @var string $distro */ + $distro = $info['distro'] ?? 'unknown'; + $distribution = Distribution::tryFrom($distro); + + if ($distribution === null) { + $this->nay("Unknown distribution: {$distro}"); + + return Command::FAILURE; + } + + $distroName = $distribution->displayName(); + + if (!$distribution->isSupported()) { + $this->nay("Unsupported distribution: {$distroName}. Only Debian and Ubuntu are supported."); + + return Command::FAILURE; + } + + return $info; + } + + /** + * Display formatted server information. + * + * @param array $info + */ + protected function displayServerInfo(array $info): void + { + /** @var string $distroSlug */ + $distroSlug = $info['distro'] ?? 'unknown'; + $distribution = Distribution::tryFrom($distroSlug); + $distroName = $distribution?->displayName() ?? 'Unknown'; + + $permissionsText = match ($info['permissions'] ?? 'none') { + 'root' => 'root', + 'sudo' => 'sudo', + default => 'insufficient', + }; + + $deets = [ + 'Distro' => $distroName, + 'User' => $permissionsText, + ]; + + $this->io->displayDeets($deets); + $this->io->writeln(''); + + $services = []; + + // Add listening ports if any + if (isset($info['ports']) && is_array($info['ports']) && count($info['ports']) > 0) { + $portsList = []; + foreach ($info['ports'] as $port => $process) { + if (is_numeric($port) && is_string($process)) { + $portsList[] = "Port {$port}: {$process}"; + } + } + if (count($portsList) > 0) { + $services = $portsList; + } + } + + $this->io->displayDeets(['Services' => $services]); + $this->io->writeln(''); + } + // // UI // ------------------------------------------------------------------------------- From f09b16ad63206e2dbb07db2c9bb2752dcb6fc9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 8 Nov 2025 12:32:33 +0200 Subject: [PATCH 04/10] feat(server): register new server commands in SymfonyApp Register ServerInstallCommand, ServerLogsCommand, and ServerRunCommand in the Symfony application command registry --- app/SymfonyApp.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index 86ed1112..c81d711f 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -11,8 +11,11 @@ use Bigpixelrocket\DeployerPHP\Console\Server\ServerAddCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerDeleteCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerInfoCommand; +use Bigpixelrocket\DeployerPHP\Console\Server\ServerInstallCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand; +use Bigpixelrocket\DeployerPHP\Console\Server\ServerLogsCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerProvisionDigitalOceanCommand; +use Bigpixelrocket\DeployerPHP\Console\Server\ServerRunCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteAddCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteDeleteCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteListCommand; @@ -140,6 +143,9 @@ private function registerCommands(): void ServerDeleteCommand::class, ServerListCommand::class, ServerInfoCommand::class, + ServerInstallCommand::class, + ServerLogsCommand::class, + ServerRunCommand::class, // Providers ServerProvisionDigitalOceanCommand::class, From f597a7eef64d0b6e969b106a84894c4bf87ee919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 8 Nov 2025 12:32:36 +0200 Subject: [PATCH 05/10] refactor(server): update existing commands for enhanced server management - Modify ServerAddCommand, ServerDeleteCommand, ServerInfoCommand - Update ServerProvisionDigitalOceanCommand integration - Enhance DigitalOceanAccountService for better server provisioning --- app/Console/Server/ServerAddCommand.php | 16 ++++- app/Console/Server/ServerDeleteCommand.php | 14 +++- app/Console/Server/ServerInfoCommand.php | 72 +------------------ .../ServerProvisionDigitalOceanCommand.php | 15 ++-- .../DigitalOceanAccountService.php | 5 +- 5 files changed, 41 insertions(+), 81 deletions(-) diff --git a/app/Console/Server/ServerAddCommand.php b/app/Console/Server/ServerAddCommand.php index c33124f1..14b8b7c9 100644 --- a/app/Console/Server/ServerAddCommand.php +++ b/app/Console/Server/ServerAddCommand.php @@ -7,6 +7,7 @@ use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; use Bigpixelrocket\DeployerPHP\Traits\KeysTrait; +use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -21,6 +22,7 @@ class ServerAddCommand extends BaseCommand { use KeysTrait; + use PlaybooksTrait; use ServersTrait; // ------------------------------------------------------------------------------- @@ -86,15 +88,23 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->displayServerDeets($server); // - // Verify SSH connection & add to inventory + // Get server info (verifies SSH connection and validates distribution) // ------------------------------------------------------------------------------- - $this->verifySSHConnection($server); // SSH failure is not a blocker + $info = $this->getServerInfo($server); + + if (is_int($info)) { + return $info; + } + + // + // Add to inventory + // ------------------------------------------------------------------------------- try { $this->servers->create($server); } catch (\RuntimeException $e) { - $this->nay('Failed to add server to inventory: ' . $e->getMessage()); + $this->nay($e->getMessage()); return Command::FAILURE; } diff --git a/app/Console/Server/ServerDeleteCommand.php b/app/Console/Server/ServerDeleteCommand.php index a33d86eb..eef71962 100644 --- a/app/Console/Server/ServerDeleteCommand.php +++ b/app/Console/Server/ServerDeleteCommand.php @@ -150,6 +150,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Destroy cloud provider resources // ------------------------------------------------------------------------------- + $destroyed = false; + if ($isDigitalOceanServer && $server->dropletId !== null) { try { $this->io->promptSpin( @@ -158,6 +160,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); $this->yay('Droplet destroyed (ID: ' . $server->dropletId . ')'); + $destroyed = true; } catch (\RuntimeException $e) { $this->nay($e->getMessage()); $this->io->writeln(''); @@ -179,7 +182,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->servers->delete($server->name); - $this->yay("Server '{$server->name}' deleted successfully"); + $this->yay("Server '{$server->name}' deleted from inventory"); + + if (!$destroyed) { + $this->io->writeln([ + '', + 'Your server may still be running and incurring costs:', + ' • Double-check with your cloud provider to ensure it is fully terminated.', + '', + ]); + } // // Show command replay diff --git a/app/Console/Server/ServerInfoCommand.php b/app/Console/Server/ServerInfoCommand.php index df5a6138..dadd0443 100644 --- a/app/Console/Server/ServerInfoCommand.php +++ b/app/Console/Server/ServerInfoCommand.php @@ -5,8 +5,6 @@ namespace Bigpixelrocket\DeployerPHP\Console\Server; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Enums\Distribution; use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; @@ -62,7 +60,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->displayServerDeets($server); // - // Get and display server information + // Get server info (verifies SSH connection and validates distribution) // ------------------------------------------------------------------------------- $info = $this->getServerInfo($server); @@ -71,8 +69,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $info; } - $this->displayServerInfo($info); - // // Show command replay // ------------------------------------------------------------------------------- @@ -84,70 +80,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - // ------------------------------------------------------------------------------- - // - // Helpers - // - // ------------------------------------------------------------------------------- - - /** - * Get server information by executing server-info playbook. - * - * @param ServerDTO $server Server to get information for - * @return array|int Returns parsed server info or failure code on failure - */ - protected function getServerInfo(ServerDTO $server): array|int - { - return $this->executePlaybook( - $server, - 'server-info', - 'Retrieving server information...', - ); - } - - /** - * Display formatted server information. - * - * @param array $info - */ - protected function displayServerInfo(array $info): void - { - /** @var string $distroSlug */ - $distroSlug = $info['distro'] ?? 'unknown'; - $distribution = Distribution::tryFrom($distroSlug); - $distroName = $distribution?->displayName() ?? 'Unknown'; - - $permissionsText = match ($info['permissions'] ?? 'none') { - 'root' => 'root', - 'sudo' => 'sudo', - default => 'insufficient', - }; - - $deets = [ - 'Distro' => $distroName, - 'User' => $permissionsText, - ]; - - $this->io->displayDeets($deets); - $this->io->writeln(''); - - $services = []; - - // Add listening ports if any - if (isset($info['ports']) && is_array($info['ports']) && count($info['ports']) > 0) { - $portsList = []; - foreach ($info['ports'] as $port => $process) { - if (is_numeric($port) && is_string($process)) { - $portsList[] = "Port {$port}: {$process}"; - } - } - if (count($portsList) > 0) { - $services = $portsList; - } - } - - $this->io->displayDeets(['Services' => $services]); - $this->io->writeln(''); - } - } diff --git a/app/Console/Server/ServerProvisionDigitalOceanCommand.php b/app/Console/Server/ServerProvisionDigitalOceanCommand.php index 2663528a..43ee2e0a 100644 --- a/app/Console/Server/ServerProvisionDigitalOceanCommand.php +++ b/app/Console/Server/ServerProvisionDigitalOceanCommand.php @@ -180,16 +180,23 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->displayServerDeets($server); // - // Verify SSH connection & add to inventory + // Get server info (verifies SSH connection and validates distribution) // ------------------------------------------------------------------------------- - $this->verifySSHConnection($server); // SSH failure is not a blocker + $info = $this->getServerInfo($server); + + if (is_int($info)) { + return $info; + } + + // + // Add to inventory + // ------------------------------------------------------------------------------- try { $this->servers->create($server); } catch (\RuntimeException $e) { - $this->nay('Failed to add server to inventory: ' . $e->getMessage()); - $this->rollbackDroplet($dropletId); + $this->nay($e->getMessage()); return Command::FAILURE; } diff --git a/app/Services/DigitalOcean/DigitalOceanAccountService.php b/app/Services/DigitalOcean/DigitalOceanAccountService.php index 0fd4afb2..c4c16e80 100644 --- a/app/Services/DigitalOcean/DigitalOceanAccountService.php +++ b/app/Services/DigitalOcean/DigitalOceanAccountService.php @@ -97,11 +97,12 @@ public function getAvailableImages(): array $options = []; foreach ($images as $image) { /** @var ImageEntity $image */ - // Filter to supported distributions + // Filter to supported distributions only (Debian/Ubuntu) if ($image->status === 'available' && $image->public === true) { $distribution = strtolower($image->distribution ?? ''); + $distEnum = Distribution::tryFrom($distribution); - if (in_array($distribution, Distribution::slugs(), true)) { + if ($distEnum !== null && $distEnum->isSupported()) { $slug = $image->slug; if ($slug !== null && $slug !== '') { $options[$slug] = "{$image->distribution} {$image->name}"; From 4ddb15e63bc5f82c5d729ac5861b17c40d26b7b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 8 Nov 2025 12:32:38 +0200 Subject: [PATCH 06/10] docs: update architecture and exception handling rules - Refine comment structure guidelines in architecture rules - Update exception handling patterns and layer responsibilities - Improve documentation consistency across codebase --- .cursor/rules/01-architecture.mdc | 12 ++++++++---- .cursor/rules/04-exceptions.mdc | 31 ++++++++++++++++++------------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/.cursor/rules/01-architecture.mdc b/.cursor/rules/01-architecture.mdc index 83d87481..13289df6 100644 --- a/.cursor/rules/01-architecture.mdc +++ b/.cursor/rules/01-architecture.mdc @@ -124,14 +124,18 @@ $command = $container->build(ServerAddCommand::class); // Gets mock **Comment structure:** ``` +// ---- +// {h1} +// ---- + // -// {Section Header} -// ------------------------------------------------------------------------------- +// {h2} +// ---- // -// {Section Subheader} +// {h3} -// {Paragraph} +// {p} ``` Separate sections visually. One newline between headers/subheaders/paragraphs. No obvious comments. Remove comments when removing code. diff --git a/.cursor/rules/04-exceptions.mdc b/.cursor/rules/04-exceptions.mdc index c9696551..15c3c8dd 100644 --- a/.cursor/rules/04-exceptions.mdc +++ b/.cursor/rules/04-exceptions.mdc @@ -1,6 +1,7 @@ --- alwaysApply: true --- + ## Exception Handling & Error Display All rules MANDATORY. @@ -36,6 +37,7 @@ throw new \RuntimeException("does not exist"); ``` **Rules:** + - Messages must be user-facing and complete (not fragments) - Include relevant context (paths, names, IDs, hosts) - Use `previous: $e` to preserve exception chains for debugging @@ -94,6 +96,7 @@ protected function validateGitRepo(string $repo): void ``` **Naming Convention:** + - `validate*Input()` - Returns `?string` (for prompts) - `validate*()` - Throws exceptions (for I/O) @@ -108,12 +111,12 @@ protected function getServerInfo(ServerDTO $server): array|int try { $result = $this->executePlaybook($server, 'server-info', 'Gathering...'); } catch (\RuntimeException $e) { - $this->io->error($e->getMessage()); // No "Failed to..." prefix + $this->nay($e->getMessage()); // No "Failed to..." prefix return Command::FAILURE; } if ($result['exit_code'] !== 0) { - $this->io->error('Failed to gather server information'); + $this->nay('Failed to gather server information'); return Command::FAILURE; } @@ -122,12 +125,13 @@ protected function getServerInfo(ServerDTO $server): array|int // ❌ WRONG - Adding redundant prefix } catch (\RuntimeException $e) { - $this->io->error('Failed to gather server information: ' . $e->getMessage()); + $this->nay('Failed to gather server information: ' . $e->getMessage()); // Results in: "Failed to gather server information: SSH authentication failed..." } ``` **When to add context:** + - Displaying raw output for debugging - Adding actionable troubleshooting steps - Exception message is too technical/generic @@ -135,7 +139,7 @@ protected function getServerInfo(ServerDTO $server): array|int ```php // ✅ CORRECT - Adding helpful context, not redundant prefix } catch (\RuntimeException $e) { - $this->io->error($e->getMessage()); + $this->nay($e->getMessage()); $this->io->writeln([ '', 'Troubleshooting:', @@ -156,13 +160,13 @@ Catch exceptions, display directly, return status: try { $this->servers->create($server); } catch (\RuntimeException $e) { - $this->io->error($e->getMessage()); // Already complete: "Server 'web1' already exists" + $this->nay($e->getMessage()); // Already complete: "Server 'web1' already exists" return Command::FAILURE; } // ❌ WRONG - Adding redundant prefix } catch (\RuntimeException $e) { - $this->io->error('Failed to add server: ' . $e->getMessage()); + $this->nay('Failed to add server: ' . $e->getMessage()); // Results in: "Failed to add server: Server 'web1' already exists" } ``` @@ -197,6 +201,7 @@ public function executeCommand(string $host, ...): ?array ### Exception Message Quality Every exception message must be: + - Complete (not: "does not exist", but: "SSH key does not exist: /path/to/key") - User-facing (not: "PDO error 2002", but: "Cannot connect to database. Check host and port.") - Actionable with context (paths, names, IDs, hosts) @@ -206,10 +211,10 @@ Exception chains preserved via `previous: $e` for debugging. ### Layer Responsibility Summary -| Layer | Display Errors? | Pattern | -|-------|----------------|---------| -| Services | ❌ No | Throw complete exceptions | -| Repositories | ❌ No | Throw complete exceptions | -| Validation Traits | ❌ No | Return `?string` or throw | -| Orchestration Traits | ✅ Yes | Catch & display without prefix | -| Commands | ✅ Yes | Catch & display without prefix | +| Layer | Display Errors? | Pattern | +| -------------------- | --------------- | ------------------------------ | +| Services | ❌ No | Throw complete exceptions | +| Repositories | ❌ No | Throw complete exceptions | +| Validation Traits | ❌ No | Return `?string` or throw | +| Orchestration Traits | ✅ Yes | Catch & display without prefix | +| Commands | ✅ Yes | Catch & display without prefix | From 1c66e0d1fff4b07cbf21f2772d5bf531af18b2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 8 Nov 2025 12:51:11 +0200 Subject: [PATCH 07/10] fix(provision): add droplet rollback on server creation failure Add cleanup of DigitalOcean droplet when server creation fails after droplet provisioning, preventing orphaned cloud resources. --- app/Console/Server/ServerProvisionDigitalOceanCommand.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Console/Server/ServerProvisionDigitalOceanCommand.php b/app/Console/Server/ServerProvisionDigitalOceanCommand.php index 43ee2e0a..1f5e42da 100644 --- a/app/Console/Server/ServerProvisionDigitalOceanCommand.php +++ b/app/Console/Server/ServerProvisionDigitalOceanCommand.php @@ -197,6 +197,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->servers->create($server); } catch (\RuntimeException $e) { $this->nay($e->getMessage()); + $this->rollbackDroplet($dropletId); return Command::FAILURE; } From 995bc9a1497498cd903646321a6bfdb7223e439b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 8 Nov 2025 12:53:33 +0200 Subject: [PATCH 08/10] fix(distro): throw exception instead of falling back to Debian --- app/Enums/Distribution.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Enums/Distribution.php b/app/Enums/Distribution.php index 50bb5f42..9d1c34c3 100644 --- a/app/Enums/Distribution.php +++ b/app/Enums/Distribution.php @@ -27,7 +27,7 @@ public function family(): DistributionFamily { return match ($this) { self::UBUNTU, self::DEBIAN => DistributionFamily::DEBIAN, - default => DistributionFamily::DEBIAN, // Fallback for unsupported distributions + default => throw new \RuntimeException("Distribution '{$this->value}' is not supported. Use isSupported() to check before calling family()"), }; } From 922c619e9bffe7d2af06b21eae11e792c496f309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 8 Nov 2025 12:55:50 +0200 Subject: [PATCH 09/10] fix(playbook): correct Caddy php_fastcgi Unix socket path syntax The php_fastcgi directive requires a double slash (unix//) prefix for Unix socket paths. Without the second slash, Caddy would fail to connect to PHP-FPM, causing all PHP requests to return 502 errors. This ensures the socket path is correctly formatted as unix//run/php/... instead of unix/run/php/... --- playbooks/demo-site.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playbooks/demo-site.sh b/playbooks/demo-site.sh index ad6547b8..b8caea52 100644 --- a/playbooks/demo-site.sh +++ b/playbooks/demo-site.sh @@ -231,7 +231,7 @@ configure_caddy() { } # This single line handles everything: PHP files, index.php routing, and static files - php_fastcgi unix${php_fpm_socket} + php_fastcgi unix/${php_fpm_socket} } EOF echo "Error: Failed to create Caddyfile" >&2 From 27020de1e634c984a0abfb6a7f065618b6cfe8ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 8 Nov 2025 13:04:43 +0200 Subject: [PATCH 10/10] fix(provisioning): consolidate droplet rollback to single finally block Refactor post-provisioning steps into a single try-finally block with automatic rollback on any failure. This ensures droplets are never left orphaned if errors occur during: - Waiting for droplet activation - Retrieving IP address - Verifying server connectivity and distribution - Adding server to inventory Uses a $shouldKeepDroplet flag that only becomes true when all steps complete successfully. Converts getServerInfo() int returns to exceptions to ensure they trigger the catch block and rollback. Consolidates 4 separate rollback calls into 1 location for easier maintenance and guaranteed cleanup. --- .../ServerProvisionDigitalOceanCommand.php | 75 ++++++++----------- 1 file changed, 31 insertions(+), 44 deletions(-) diff --git a/app/Console/Server/ServerProvisionDigitalOceanCommand.php b/app/Console/Server/ServerProvisionDigitalOceanCommand.php index 1f5e42da..745a29a0 100644 --- a/app/Console/Server/ServerProvisionDigitalOceanCommand.php +++ b/app/Console/Server/ServerProvisionDigitalOceanCommand.php @@ -137,73 +137,60 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // - // Wait for droplet to become active + // Configure droplet with automatic rollback on failure // ------------------------------------------------------------------------------- + $shouldKeepDroplet = false; + try { + // Wait for droplet to become active $this->io->promptSpin( fn () => $this->digitalOcean->droplet->waitForDropletReady($dropletId), 'Waiting for droplet to become active...' ); $this->yay('Droplet is active'); - } catch (\RuntimeException $e) { - $this->nay($e->getMessage()); - $this->rollbackDroplet($dropletId); - - return Command::FAILURE; - } - - // - // Get droplet IP address & display server details - // ------------------------------------------------------------------------------- - try { + // Get droplet IP address $ipAddress = $this->digitalOcean->droplet->getDropletIp($dropletId); - } catch (\RuntimeException $e) { - $this->nay('Failed to get droplet IP address: ' . $e->getMessage()); - $this->rollbackDroplet($dropletId); - - return Command::FAILURE; - } - $server = new ServerDTO( - name: $name, - host: $ipAddress, - port: 22, - username: 'root', - privateKeyPath: $privateKeyPath, - provider: 'digitalocean', - dropletId: $dropletId - ); + // Create server DTO + $server = new ServerDTO( + name: $name, + host: $ipAddress, + port: 22, + username: 'root', + privateKeyPath: $privateKeyPath, + provider: 'digitalocean', + dropletId: $dropletId + ); - $this->displayServerDeets($server); + $this->displayServerDeets($server); - // - // Get server info (verifies SSH connection and validates distribution) - // ------------------------------------------------------------------------------- + // Get server info (verifies SSH connection and validates distribution) + $info = $this->getServerInfo($server); - $info = $this->getServerInfo($server); + if (is_int($info)) { + throw new \RuntimeException('Failed to validate server distribution'); + } - if (is_int($info)) { - return $info; - } - - // - // Add to inventory - // ------------------------------------------------------------------------------- - - try { + // Add to inventory $this->servers->create($server); + $this->yay('Server added to inventory'); + + $shouldKeepDroplet = true; } catch (\RuntimeException $e) { $this->nay($e->getMessage()); - $this->rollbackDroplet($dropletId); + } finally { + if (!$shouldKeepDroplet) { + $this->rollbackDroplet($dropletId); + } + } + if (!$shouldKeepDroplet) { return Command::FAILURE; } - $this->yay('Server added to inventory'); - // // Show command replay // -------------------------------------------------------------------------------