diff --git a/app/Console/Server/ServerInstallCommand.php b/app/Console/Server/ServerInstallCommand.php index 84d5f0dd..fe7fcc75 100644 --- a/app/Console/Server/ServerInstallCommand.php +++ b/app/Console/Server/ServerInstallCommand.php @@ -6,7 +6,6 @@ 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; @@ -17,7 +16,7 @@ #[AsCommand( name: 'server:install', - description: 'Install and prepare server for hosting PHP applications' + description: 'Install the server so it can host PHP applications' )] class ServerInstallCommand extends BaseCommand { diff --git a/app/Console/Site/SiteAddCommand.php b/app/Console/Site/SiteAddCommand.php index 809d97f0..2cd8307c 100644 --- a/app/Console/Site/SiteAddCommand.php +++ b/app/Console/Site/SiteAddCommand.php @@ -5,8 +5,8 @@ namespace Bigpixelrocket\DeployerPHP\Console\Site; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; +use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; @@ -15,9 +15,13 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -#[AsCommand(name: 'site:add', description: 'Add a new site to the inventory')] +#[AsCommand( + name: 'site:add', + description: 'Set up a new site on the server and add it to the inventory' +)] class SiteAddCommand extends BaseCommand { + use PlaybooksTrait; use ServersTrait; use SitesTrait; @@ -33,7 +37,8 @@ protected function configure(): void ->addOption('domain', null, InputOption::VALUE_REQUIRED, 'Domain name') ->addOption('repo', null, InputOption::VALUE_REQUIRED, 'Git repository URL') ->addOption('branch', null, InputOption::VALUE_REQUIRED, 'Git branch name') - ->addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name'); + ->addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name') + ->addOption('php-version', null, InputOption::VALUE_REQUIRED, 'PHP version to use'); } // ---- @@ -46,13 +51,63 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->heading('Add New Site'); + // + // Select server + // ---- + + $server = $this->selectServer(); + + if (is_int($server)) { + return $server; + } + + $this->displayServerDeets($server); + + // + // Get server info (verifies SSH connection and validates distribution & permissions) + // ---- + + $info = $this->serverInfo($server); + + if (is_int($info)) { + return $info; + } + + [ + 'distro' => $distro, + 'permissions' => $permissions, + ] = $info; + + /** @var string $distro */ + /** @var string $permissions */ + + // + // Validate server is ready for site provisioning + // ---- + + $validationResult = $this->validateServerReady($info); + + if (is_int($validationResult)) { + return $validationResult; + } + + // + // Select PHP version + // ---- + + $phpVersion = $this->selectPhpVersion($info); + + if (is_int($phpVersion)) { + return $phpVersion; + } + // // Gather site details // ---- - $deets = $this->gatherSiteDeets(); + $siteInfo = $this->gatherSiteInfo(); - if ($deets === null) { + if ($siteInfo === null) { return Command::FAILURE; } @@ -60,8 +115,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'domain' => $domain, 'repo' => $repo, 'branch' => $branch, - 'server' => $server, - ] = $deets; + ] = $siteInfo; // // Display site details @@ -76,6 +130,29 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->displaySiteDeets($site); + // + // Provision site on server + // ---- + + $result = $this->executePlaybook( + $server, + 'site-add', + 'Provisioning site...', + [ + 'DEPLOYER_DISTRO' => $distro, + 'DEPLOYER_PERMS' => $permissions, + 'DEPLOYER_SITE_DOMAIN' => $domain, + 'DEPLOYER_PHP_VERSION' => $phpVersion, + ], + true + ); + + if (is_int($result)) { + return $result; + } + + $this->yay('Site provisioned successfully'); + // // Save to inventory // ---- @@ -90,6 +167,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->yay('Site added to inventory'); + // + // Display next steps + // ---- + + $this->io->writeln([ + '', + 'Next steps:', + ' • Site is accessible at https://' . $domain . '', + ' • Update DNS records to point ' . $domain . ' to ' . $server->host . '', + ' • Deploy your application with site:deploy', + '', + ]); + // // Show command replay // ---- @@ -99,36 +189,124 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'repo' => $repo, 'branch' => $branch, 'server' => $server->name, + 'php-version' => $phpVersion, ]); return Command::SUCCESS; } - // ---- - // Helpers + // + // Validation // ---- /** - * Gather site details from user input or CLI options. + * Validate that server is ready for site provisioning. + * + * Checks for: + * - Caddy web server installed + * - PHP installed * - * @return array{domain: string, repo: string, branch: string, server: ServerDTO}|null + * @param array $info Server information from serverInfo() + * @return int|null Returns Command::FAILURE if validation fails, null if successful */ - protected function gatherSiteDeets(): ?array + private function validateServerReady(array $info): ?int { - // - // Select server - // ---- + // Check if Caddy is installed + $caddyInstalled = isset($info['caddy']) && is_array($info['caddy']) && ($info['caddy']['available'] ?? false) === true; - $server = $this->selectServer(); + // Check if PHP is installed + $phpInstalled = isset($info['php']) && is_array($info['php']) && isset($info['php']['versions']) && is_array($info['php']['versions']) && count($info['php']['versions']) > 0; - if (is_int($server)) { - return null; + if (!$caddyInstalled || !$phpInstalled) { + $this->nay('Looks like the server was not installed as expected'); + $this->io->writeln([ + 'Run server:install to install required software.', + '', + ]); + + return Command::FAILURE; } - // - // Gather site details - // ---- + return null; + } + + /** + * Select PHP version to use for the site. + * + * If multiple PHP versions are installed, prompts user to select. + * If only one version is installed, uses that automatically. + * + * @param array $info Server information from serverInfo() + * @return string|int Returns PHP version string or Command::FAILURE on error + */ + private function selectPhpVersion(array $info): string|int + { + // Extract installed PHP versions + $installedPhpVersions = []; + if (isset($info['php']) && is_array($info['php']) && isset($info['php']['versions']) && is_array($info['php']['versions'])) { + foreach ($info['php']['versions'] as $version) { + // Handle both new format (array with version/extensions) and old format (string) + if (is_array($version) && isset($version['version'])) { + /** @var string $versionStr */ + $versionStr = $version['version']; + $installedPhpVersions[] = $versionStr; + } elseif (is_string($version) || is_numeric($version)) { + $installedPhpVersions[] = (string) $version; + } + } + } + + if (empty($installedPhpVersions)) { + $this->nay('No PHP versions found on server'); + return Command::FAILURE; + } + + // If only one version, use it automatically + if (count($installedPhpVersions) === 1) { + return $installedPhpVersions[0]; + } + + // Multiple versions available - prompt user to select + rsort($installedPhpVersions, SORT_NATURAL); // Newest first + + /** @var array{default?: string|int|float}|null $phpInfo */ + $phpInfo = $info['php'] ?? null; + $defaultVersion = is_array($phpInfo) ? ($phpInfo['default'] ?? null) : null; + $defaultVersionStr = $defaultVersion !== null ? (string) $defaultVersion : $installedPhpVersions[0]; + + $phpVersion = (string) $this->io->getOptionOrPrompt( + 'php-version', + fn () => $this->io->promptSelect( + label: 'PHP version for this site:', + options: $installedPhpVersions, + default: $defaultVersionStr + ) + ); + + // Validate CLI-provided version exists in available versions + if (!in_array($phpVersion, $installedPhpVersions, true)) { + $this->nay( + "PHP version {$phpVersion} is not installed on this server. Available: " . implode(', ', $installedPhpVersions) + ); + + return Command::FAILURE; + } + + return $phpVersion; + } + + // ---- + // Helpers + // ---- + + /** + * Gather site details from user input or CLI options. + * + * @return array{domain: string, repo: string, branch: string}|null + */ + protected function gatherSiteInfo(): ?array + { /** @var string|null $domain */ $domain = $this->io->getValidatedOptionOrPrompt( 'domain', @@ -191,7 +369,6 @@ protected function gatherSiteDeets(): ?array 'domain' => $domain, 'repo' => $repo, 'branch' => $branch, - 'server' => $server, ]; } } diff --git a/app/Console/Site/SiteDeleteCommand.php b/app/Console/Site/SiteDeleteCommand.php index 112129c8..60957b49 100644 --- a/app/Console/Site/SiteDeleteCommand.php +++ b/app/Console/Site/SiteDeleteCommand.php @@ -5,6 +5,8 @@ namespace Bigpixelrocket\DeployerPHP\Console\Site; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; +use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; +use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -12,9 +14,14 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -#[AsCommand(name: 'site:delete', description: 'Delete a site from the inventory')] +#[AsCommand( + name: 'site:delete', + description: 'Remove a site from the server and delete it from the inventory' +)] class SiteDeleteCommand extends BaseCommand { + use PlaybooksTrait; + use ServersTrait; use SitesTrait; // ---- @@ -26,7 +33,7 @@ protected function configure(): void parent::configure(); $this - ->addOption('site', null, InputOption::VALUE_REQUIRED, 'Site domain') + ->addOption('domain', null, InputOption::VALUE_REQUIRED, 'Domain name') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip typing the site domain to confirm') ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip Yes/No confirmation prompt'); } @@ -91,20 +98,93 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + // + // Attempt to remove site from server + // ---- + + $removedFromServer = false; + $server = $this->servers->findByName($site->server); + + if ($server === null) { + $this->io->warning("Server '{$site->server}' not found in inventory"); + $this->io->writeln([ + '', + 'The server may have been deleted.', + '', + ]); + } else { + // Get server info (verifies SSH connection) + $info = $this->serverInfo($server); + + if (is_int($info)) { + $this->io->warning('Could not connect to server'); + } else { + [ + 'distro' => $distro, + 'permissions' => $permissions, + ] = $info; + + /** @var string $distro */ + /** @var string $permissions */ + + // Execute site deletion playbook + $result = $this->executePlaybook( + $server, + 'site-delete', + 'Removing site from server...', + [ + 'DEPLOYER_DISTRO' => $distro, + 'DEPLOYER_PERMS' => $permissions, + 'DEPLOYER_SITE_DOMAIN' => $site->domain, + ], + true + ); + + if (is_int($result)) { + $this->io->warning('Failed to remove site from server'); + } else { + $removedFromServer = true; + } + } + } + + // + // Confirm inventory deletion if server removal failed + // ---- + + if (!$removedFromServer) { + /** @var bool $proceedAnyway */ + $proceedAnyway = $this->io->getOptionOrPrompt( + 'yes', + fn (): bool => $this->io->promptConfirm( + label: 'Remove site from inventory anyway?', + default: false + ) + ); + + if (!$proceedAnyway) { + return Command::FAILURE; + } + } + // // Delete site from inventory // ---- $this->sites->delete($site->domain); - $this->yay("Site '{$site->domain}' deleted successfully"); + if ($removedFromServer) { + $this->yay("Site '{$site->domain}' deleted successfully"); + } else { + $this->yay("Site '{$site->domain}' deleted from inventory"); + } // // Show command replay // ---- $this->showCommandReplay('site:delete', [ - 'site' => $site->domain, + 'domain' => $site->domain, 'yes' => $confirmed, 'force' => true, ]); diff --git a/app/Traits/SitesTrait.php b/app/Traits/SitesTrait.php index ebb3f12d..42d1c59f 100644 --- a/app/Traits/SitesTrait.php +++ b/app/Traits/SitesTrait.php @@ -84,7 +84,7 @@ protected function selectSite(?array $sites = null): SiteDTO|int $siteDomains = array_map(fn (SiteDTO $site) => $site->domain, $allSites); $domain = (string) $this->io->getOptionOrPrompt( - 'site', + 'domain', fn () => $this->io->promptSelect( label: 'Select site:', options: $siteDomains, diff --git a/playbooks/site-add.sh b/playbooks/site-add.sh new file mode 100644 index 00000000..ee6c668c --- /dev/null +++ b/playbooks/site-add.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash + +# +# Site Add Playbook - Ubuntu/Debian Only +# +# Provision new site with Capistrano-style directory structure +# ---- +# +# This playbook only supports Ubuntu and Debian distributions (debian family). +# Requires server-install to have run first (creates deployer user, Caddy, PHP). +# +# Required Environment Variables: +# DEPLOYER_OUTPUT_FILE - Output file path +# DEPLOYER_DISTRO - Exact distribution: ubuntu|debian +# DEPLOYER_PERMS - Permissions: root|sudo +# DEPLOYER_SITE_DOMAIN - Site domain name +# DEPLOYER_PHP_VERSION - PHP version to use for this site +# +# Returns YAML with: +# - status: success +# - site_path: /home/deployer/sites/{domain} +# - site_configured: true +# - php_version: {version} +# + +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_PHP_VERSION ]] && echo "Error: DEPLOYER_PHP_VERSION required" && exit 1 +export DEPLOYER_PERMS + +# Shared helpers are automatically inlined when executing playbooks remotely +# source "$(dirname "$0")/helpers.sh" + +# ---- +# Setup Functions +# ---- + +# +# Directory Structure Setup +# ---- + +# +# Create Capistrano-style directory structure for site + +setup_site_directories() { + local domain=$1 + local site_path="/home/deployer/sites/${domain}" + + echo "→ Creating directory structure for ${domain}..." + + # Create main site directory + if ! run_cmd test -d "$site_path"; then + if ! run_cmd mkdir -p "$site_path"; then + echo "Error: Failed to create directory: ${site_path}" >&2 + exit 1 + fi + fi + + # Create Capistrano structure + local dirs=( + "${site_path}/releases" + "${site_path}/shared" + "${site_path}/repo" + "${site_path}/current/public" + ) + + for dir in "${dirs[@]}"; do + if ! run_cmd test -d "$dir"; then + if ! run_cmd mkdir -p "$dir"; then + echo "Error: Failed to create directory: ${dir}" >&2 + exit 1 + fi + fi + done + + # Set ownership + if ! run_cmd chown -R deployer:deployer "$site_path"; then + echo "Error: Failed to set ownership on site directory" >&2 + 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 + echo "Error: Failed to set directory permissions" >&2 + exit 1 + fi +} + +# +# Demo Site Setup +# ---- + +# +# Create demo hello-world page for site + +setup_demo_page() { + local domain=$1 + local index_file="/home/deployer/sites/${domain}/current/public/index.php" + + echo "→ Creating default page for ${domain}..." + + if ! run_cmd test -f "$index_file"; then + if ! run_cmd tee "$index_file" > /dev/null <<- 'EOF'; then + site:deploy to deploy your application'; + EOF + echo "Error: Failed to create index.php" >&2 + exit 1 + fi + fi + + # Set file permissions (640 - owner+group read) + if ! run_cmd chmod 640 "$index_file"; then + echo "Error: Failed to set permissions on index.php" >&2 + exit 1 + fi + + # Set ownership + if ! run_cmd chown deployer:deployer "$index_file"; then + echo "Error: Failed to set ownership on index.php" >&2 + exit 1 + fi +} + +# +# Caddy Configuration +# ---- + +# +# Configure Caddy vhost for site + +configure_caddy_vhost() { + local domain=$1 + local site_path="/home/deployer/sites/${domain}" + + echo "→ Creating Caddy configuration for ${domain}..." + + # Use specified PHP version + local php_version=$DEPLOYER_PHP_VERSION + + echo "→ Using PHP ${php_version}" + + # PHP-FPM socket path (debian family) + local php_fpm_socket="/run/php/php${php_version}-fpm.sock" + + # Create log directory if it doesn't exist + 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 vhost configuration file + local vhost_file="/etc/caddy/conf.d/sites/${domain}.caddy" + + if ! run_cmd tee "$vhost_file" > /dev/null <<- EOF; then + # Site: ${domain} + ${domain} { + root * ${site_path}/current/public + encode gzip + + log { + output file /var/log/caddy/${domain}-access.log { + roll_size 100mb + roll_keep 5 + roll_keep_for 720h + } + format json + } + + # Serve PHP files through FPM + php_fastcgi unix//${php_fpm_socket} + + # Serve static files directly (more efficient than passing through PHP-FPM) + file_server + } + EOF + echo "Error: Failed to create Caddy configuration" >&2 + exit 1 + fi +} + +# +# Service Management +# ---- + +# +# Reload or start Caddy and restart PHP-FPM + +reload_services() { + local php_version=$DEPLOYER_PHP_VERSION + + echo "→ Reloading services..." + + # Enable and reload/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 + + # Restart PHP-FPM just to be sure + if systemctl is-active --quiet "php${php_version}-fpm" 2> /dev/null; then + if ! run_cmd systemctl restart "php${php_version}-fpm"; then + echo "Warning: Failed to restart PHP-FPM service" + fi + fi +} + +# ---- +# Main Execution +# ---- + +main() { + local domain=$DEPLOYER_SITE_DOMAIN + local site_path="/home/deployer/sites/${domain}" + + # Execute setup tasks + setup_site_directories "$domain" + setup_demo_page "$domain" + configure_caddy_vhost "$domain" + reload_services + + # Use specified PHP version for output + local php_version=$DEPLOYER_PHP_VERSION + + # Write output YAML + if ! cat > "$DEPLOYER_OUTPUT_FILE" <<- EOF; then + status: success + site_path: ${site_path} + site_configured: true + php_version: ${php_version} + EOF + echo "Error: Failed to write output file" >&2 + exit 1 + fi +} + +main "$@" diff --git a/playbooks/site-delete.sh b/playbooks/site-delete.sh new file mode 100644 index 00000000..614b0090 --- /dev/null +++ b/playbooks/site-delete.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash + +# +# Site Delete Playbook - Ubuntu/Debian Only +# +# Remove site files and Caddy configuration from server +# ---- +# +# This playbook only supports Ubuntu and Debian distributions (debian family). +# Removes site directory and Caddy vhost configuration, then reloads Caddy. +# +# Required Environment Variables: +# DEPLOYER_OUTPUT_FILE - Output file path +# DEPLOYER_DISTRO - Exact distribution: ubuntu|debian +# DEPLOYER_PERMS - Permissions: root|sudo +# DEPLOYER_SITE_DOMAIN - Site domain name +# +# Returns YAML with: +# - status: success +# + +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" + +# ---- +# Cleanup Functions +# ---- + +# +# Remove Caddy vhost configuration +# ---- + +remove_caddy_vhost() { + local domain=$1 + local vhost_file="/etc/caddy/conf.d/sites/${domain}.caddy" + + if run_cmd test -f "$vhost_file"; then + echo "→ Removing Caddy configuration for ${domain}..." + if ! run_cmd rm -f "$vhost_file"; then + echo "Error: Failed to remove Caddy configuration" >&2 + exit 1 + fi + fi +} + +# +# Reload Caddy service +# ---- + +reload_caddy() { + if systemctl is-active --quiet caddy 2> /dev/null; then + echo "→ Reloading services..." + if ! run_cmd systemctl reload caddy; then + echo "Error: Failed to reload services" >&2 + exit 1 + fi + fi +} + +# +# Remove site files +# ---- + +remove_site_files() { + local domain=$1 + local site_path="/home/deployer/sites/${domain}" + + if run_cmd test -d "$site_path"; then + echo "→ Removing files for ${domain}..." + if ! run_cmd rm -rf "$site_path"; then + echo "Error: Failed to remove files" >&2 + exit 1 + fi + fi +} + +# ---- +# Main Execution +# ---- + +main() { + local domain=$DEPLOYER_SITE_DOMAIN + + # Execute cleanup tasks + remove_caddy_vhost "$domain" + reload_caddy + remove_site_files "$domain" + + # Write output YAML + if ! cat > "$DEPLOYER_OUTPUT_FILE" << EOF; then +status: success +EOF + echo "Error: Failed to write output file" >&2 + exit 1 + fi +} + +main "$@"