From c5ffaf40e9b8a36f4628a162d68efa8367bc946f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 2 Nov 2025 11:46:42 +0200 Subject: [PATCH 1/5] feat(playbooks): add reusable playbook execution infrastructure Add PlaybookHelpersTrait providing executePlaybook() method for: - SSH-based playbook execution on remote servers - Environment variable injection - YAML output parsing and error handling - Integration with IOService for user feedback --- app/Traits/PlaybookHelpersTrait.php | 123 ++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 app/Traits/PlaybookHelpersTrait.php diff --git a/app/Traits/PlaybookHelpersTrait.php b/app/Traits/PlaybookHelpersTrait.php new file mode 100644 index 00000000..8621aeaa --- /dev/null +++ b/app/Traits/PlaybookHelpersTrait.php @@ -0,0 +1,123 @@ + $playbookVars Playbook variables to pass to the playbook (don't pass sensitive data) + * @return array|int Returns parsed YAML on success or Command::FAILURE on error + */ + protected function executePlaybook( + ServerDTO $server, + string $playbookName, + string $spinnerMessage, + array $playbookVars = [] + ): array|int { + $projectRoot = dirname(__DIR__, 2); + $playbookPath = $projectRoot . '/playbooks/' . $playbookName . '.sh'; + $scriptContents = $this->fs->readFile($playbookPath); + + // Build variable prefix + $varsPrefix = ''; + foreach ($playbookVars as $key => $value) { + $varsPrefix .= sprintf('%s=%s ', $key, escapeshellarg((string) $value)); + } + + // Wrap script with environment and heredoc + $scriptWithVars = sprintf( + "%sbash <<'DEPLOYER_SCRIPT_EOF'\n%s\nDEPLOYER_SCRIPT_EOF", + $varsPrefix, + $scriptContents + ); + + // Resolve SSH key path + $privateKeyPath = $this->resolvePrivateKeyPath($server->privateKeyPath); + + if ($privateKeyPath === null) { + throw new \RuntimeException('No valid SSH private key found'); + } + + // Execute command + try { + $result = $this->io->promptSpin( + callback: fn () => $this->ssh->executeCommand( + $server->host, + $server->port, + $server->username, + $scriptWithVars, + $privateKeyPath + ), + message: $spinnerMessage + ); + } catch (\RuntimeException $e) { + $this->io->error($e->getMessage()); + + return Command::FAILURE; + } + + // Check exit code + if ($result['exit_code'] !== 0) { + $this->io->error('Playbook execution failed:'); + $this->io->writeln([ + '', + ''.$result['output'].'', + '', + ]); + + return Command::FAILURE; + } + + // Parse YAML output + try { + $parsed = Yaml::parse($result['output']); + + if (!is_array($parsed)) { + throw new \RuntimeException('Expected playbook output to be YAML array'); + } + + /** @var array $parsed */ + return $parsed; + } catch (\Throwable $e) { + $this->io->error('Failed to parse YAML output: ' . $e->getMessage()); + $this->io->writeln([ + '', + 'Raw output:', + '', + $result['output'], + '', + ]); + + return Command::FAILURE; + } + } + +} From 917a209f729e8e4685cffcb128484efc71896c23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 2 Nov 2025 11:46:44 +0200 Subject: [PATCH 2/5] feat(server): add server info command Add server:info command to display server information: - Gathers distro, permissions, and listening ports via playbook - Displays formatted server details and services - Uses server-info.sh playbook for remote execution - Integrates with existing server selection flow --- app/Console/Server/ServerInfoCommand.php | 84 ++++++++++++ app/SymfonyApp.php | 2 + app/Traits/ServerInfoTrait.php | 81 +++++++++++ playbooks/server-info.sh | 164 +++++++++++++++++++++++ 4 files changed, 331 insertions(+) create mode 100644 app/Console/Server/ServerInfoCommand.php create mode 100644 app/Traits/ServerInfoTrait.php create mode 100755 playbooks/server-info.sh diff --git a/app/Console/Server/ServerInfoCommand.php b/app/Console/Server/ServerInfoCommand.php new file mode 100644 index 00000000..1e19bb85 --- /dev/null +++ b/app/Console/Server/ServerInfoCommand.php @@ -0,0 +1,84 @@ +addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name'); + } + + // + // Execution + // ------------------------------------------------------------------------------- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->io->hr(); + $this->io->h1('Server Information'); + + // + // Select server + + $server = $this->selectServer(); + + if (is_int($server)) { + return $server; + } + + // Get sites for this server + $serverSites = $this->sites->findByServer($server->name); + + // + // Display server details + + $this->io->hr(); + + $this->displayServerDeets($server, $serverSites); + + // + // Display server information + + $info = $this->getServerInfo($server); + + if (is_int($info)) { + return $info; + } + + $this->displayServerInfo($info); + + // + // Show command hint + + $this->io->showCommandHint('server:info', [ + 'server' => $server->name, + ]); + + return Command::SUCCESS; + } + +} diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index 5dd6fd5c..86ed1112 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -10,6 +10,7 @@ use Bigpixelrocket\DeployerPHP\Console\Key\KeyListDigitalOceanCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerAddCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerDeleteCommand; +use Bigpixelrocket\DeployerPHP\Console\Server\ServerInfoCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerProvisionDigitalOceanCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteAddCommand; @@ -138,6 +139,7 @@ private function registerCommands(): void ServerAddCommand::class, ServerDeleteCommand::class, ServerListCommand::class, + ServerInfoCommand::class, // Providers ServerProvisionDigitalOceanCommand::class, diff --git a/app/Traits/ServerInfoTrait.php b/app/Traits/ServerInfoTrait.php new file mode 100644 index 00000000..bb52bed0 --- /dev/null +++ b/app/Traits/ServerInfoTrait.php @@ -0,0 +1,81 @@ +|int Returns parsed server info or failure code on failure + */ + protected function getServerInfo(ServerDTO $server): array|int + { + return $this->executePlaybook( + $server, + 'server-info', + 'Gathering server information...' + ); + } + + /** + * Display formatted server information. + * + * @param array $info + */ + protected function displayServerInfo(array $info): void + { + $distroName = match ($info['distro'] ?? 'unknown') { + 'debian' => 'Debian/Ubuntu', + 'redhat' => 'RedHat/CentOS/Fedora', + 'amazon' => 'Amazon Linux', + default => '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/playbooks/server-info.sh b/playbooks/server-info.sh new file mode 100755 index 00000000..1016355b --- /dev/null +++ b/playbooks/server-info.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +set -o pipefail + +# +# Gather Server Information +# ------------------------------------------------------------------------------- + +# +# Detect Linux Distribution +# +# Returns: debian|redhat|amazon|unknown +detect_distro() { + local distro='unknown' + + if [[ -f /etc/os-release ]]; then + if grep -qi 'amazon' /etc/os-release; then + distro='amazon' + elif grep -qi 'debian\|ubuntu' /etc/os-release; then + distro='debian' + elif grep -qi 'fedora\|centos\|rhel' /etc/os-release; then + distro='redhat' + fi + elif [[ -f /etc/redhat-release ]]; then + distro='redhat' + elif [[ -f /etc/debian_version ]]; then + distro='debian' + fi + + echo "$distro" +} + +# +# Check User Permissions +# +# Returns: root|sudo|none +check_permissions() { + if [[ $EUID -eq 0 ]]; then + echo 'root' + elif sudo -n true 2> /dev/null; then + echo 'sudo' + else + echo 'none' + fi +} + +# +# Execute Command with Appropriate Permissions +# +run_cmd() { + [[ $DEPLOYER_PERMS == 'root' ]] && "$@" || sudo "$@" +} + +# +# Ensure Required Tools are Installed +# +ensure_tools() { + local distro=$1 perms=$2 + export DEPLOYER_PERMS=$perms + + [[ $perms == 'none' ]] && return 0 + command -v ss > /dev/null 2>&1 && return 0 + command -v netstat > /dev/null 2>&1 && return 0 + + case $distro in + debian) + export DEBIAN_FRONTEND=noninteractive + run_cmd apt-get update -q 2> /dev/null + run_cmd apt-get install -y -q iproute2 2> /dev/null + ;; + redhat | amazon) + run_cmd yum install -y -q iproute 2> /dev/null \ + || run_cmd dnf install -y -q iproute 2> /dev/null + ;; + esac +} + +# +# Get All Listening Ports +# +get_listening_ports() { + local cmd port process + + if command -v ss > /dev/null 2>&1; then + if [[ $DEPLOYER_PERMS == 'root' ]]; then + cmd='ss' + elif [[ $DEPLOYER_PERMS == 'sudo' ]]; then + cmd='sudo ss' + else + cmd='ss' + fi + + while read -r line; do + [[ $line =~ ^State ]] && continue + [[ ! $line =~ LISTEN ]] && continue + + if [[ $line =~ :([0-9]+)[[:space:]] ]]; then + port="${BASH_REMATCH[1]}" + if [[ $line =~ users:\(\(\"([^\"]+)\" ]]; then + process="${BASH_REMATCH[1]}" + else + process="unknown" + fi + echo "${port}:${process}" + fi + done < <($cmd -tlnp 2> /dev/null) | sort -t: -k1 -n | uniq + + elif command -v netstat > /dev/null 2>&1; then + if [[ $DEPLOYER_PERMS == 'root' ]]; then + cmd='netstat' + elif [[ $DEPLOYER_PERMS == 'sudo' ]]; then + cmd='sudo netstat' + else + cmd='netstat' + fi + + while read -r proto recvq sendq local foreign state program; do + [[ $state != "LISTEN" ]] && continue + + if [[ $local =~ :([0-9]+)$ ]]; then + port="${BASH_REMATCH[1]}" + if [[ $program =~ /(.+)$ ]]; then + process="${BASH_REMATCH[1]}" + else + process="unknown" + fi + echo "${port}:${process}" + fi + done < <($cmd -tlnp 2> /dev/null | tail -n +3) | sort -t: -k1 -n | uniq + fi +} + +# +# Main Execution +# ---- + +main() { + local distro permissions + + # Gather basic info + distro=$(detect_distro) + permissions=$(check_permissions) + ensure_tools "$distro" "$permissions" + + # Output YAML + cat <<- EOF + distro: $distro + permissions: $permissions + ports: + EOF + + # Inline ports formatting + local port process has_ports=false + while IFS=: read -r port process; do + echo " ${port}: ${process}" + has_ports=true + done < <(get_listening_ports) + + if [[ $has_ports == false ]]; then + echo " {}" + fi +} + +main "$@" From 0f6d7d4276d0cfa96edf37960954816cbdb2e9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 2 Nov 2025 11:46:46 +0200 Subject: [PATCH 3/5] style(console): add spacing after server/site details display --- app/Console/Server/ServerAddCommand.php | 1 + app/Console/Site/SiteAddCommand.php | 1 + 2 files changed, 2 insertions(+) diff --git a/app/Console/Server/ServerAddCommand.php b/app/Console/Server/ServerAddCommand.php index ba716a81..314ec717 100644 --- a/app/Console/Server/ServerAddCommand.php +++ b/app/Console/Server/ServerAddCommand.php @@ -149,6 +149,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->hr(); $this->displayServerDeets($server); + $this->io->writeln(''); // // Save to repository diff --git a/app/Console/Site/SiteAddCommand.php b/app/Console/Site/SiteAddCommand.php index 5e984600..f5df36aa 100644 --- a/app/Console/Site/SiteAddCommand.php +++ b/app/Console/Site/SiteAddCommand.php @@ -156,6 +156,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->hr(); $this->displaySiteDeets($site); + $this->io->writeln(''); // // Save to repository From 8b332b371fc0d03ff1b8ff1283ac83a0a1868ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 2 Nov 2025 11:46:48 +0200 Subject: [PATCH 4/5] docs(playbooks): document YAML output and error handling patterns - Clarify playbooks must return parsable YAML as final output - Document error detection via YAML parsing failure - Add progress message guidelines (stderr vs stdout) - Update bash rules with quality gates section --- .cursor/rules/05-bash.mdc | 12 ++++++ .cursor/rules/06-playbooks.mdc | 72 ++++++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/.cursor/rules/05-bash.mdc b/.cursor/rules/05-bash.mdc index 59a56557..a6bdf1c6 100644 --- a/.cursor/rules/05-bash.mdc +++ b/.cursor/rules/05-bash.mdc @@ -114,3 +114,15 @@ for f in $(ls); do ... # ❌ WRONG - unsafe - Semicolons only in control statements (`if true; then`) - Max 1 blank line between sections - Shebang: `#!/usr/bin/env bash` + +### Quality Gates + +ALWAYS run before completing task, fix all issues: + +```bash +# Format all bash scripts +composer bash + +# Check formatting without modifying +composer bash:check +``` diff --git a/.cursor/rules/06-playbooks.mdc b/.cursor/rules/06-playbooks.mdc index 3c09258e..d58a766d 100644 --- a/.cursor/rules/06-playbooks.mdc +++ b/.cursor/rules/06-playbooks.mdc @@ -15,6 +15,7 @@ Playbooks are idempotent, non-interactive bash scripts that: - Receive context via environment variables - Never prompt for user input - Run completely unattended +- Return parsable YAML output (empty array `[]` if no data) ### Non-Interactive Operation @@ -43,7 +44,7 @@ Use `DEPLOYER_` prefix for all context variables: set -o pipefail export DEBIAN_FRONTEND=noninteractive -# Validation +# Validation - errors go to stdout (not YAML = error detected) if [[ -z $DEPLOYER_DISTRO ]]; then echo "Error: DEPLOYER_DISTRO environment variable is required" exit 1 @@ -77,17 +78,26 @@ Enables recovery, resumption, drift correction. ### Error Handling -Fail fast with clear messages: +Fail fast with clear error messages to stdout. Errors are NOT YAML, so parsing will fail and the error message will be displayed. ```bash set -o pipefail # Fail on pipe errors +# Validation errors - output plain text (not YAML) if [[ -z $DEPLOYER_DISTRO ]]; then echo "Error: DEPLOYER_DISTRO environment variable is required" exit 1 fi + +# Runtime errors - output plain text (not YAML) +if ! command -v required_tool >/dev/null 2>&1; then + echo "Error: required_tool is not installed" + exit 1 +fi ``` +**Key Principle:** Error messages are plain text to stdout. Success results are YAML to stdout. This makes error detection automatic - if YAML parsing fails, it's an error. + ### Helper Functions ```bash @@ -106,10 +116,36 @@ run_cmd apt-get install -y -q package-name ### Output -- Use YAML for structured data output -- Echo progress messages for logs -- Use `✓` for success, `✗` for failure -- Keep output clean and scannable +**ALL playbooks MUST return parsable YAML as their final output.** + +This is critical for error detection: if YAML parsing fails, we know the playbook encountered an error and can display the raw output as an error message. + +**Rules:** + +- Final output to stdout MUST be valid YAML +- If no data to return, output empty YAML array: `[]` or empty object: `{}` +- Progress messages during execution go to stderr (use `>&2`) +- YAML output should be the last thing printed to stdout +- Use `✓` for success, `✗` for failure in progress messages + +**Example Pattern:** + +```bash +# Progress messages to stderr +echo "✓ Processing..." >&2 +echo "✓ Task complete" >&2 + +# Final YAML output to stdout +cat </dev/null 2>&1; then run_cmd apt-get install -y -q nginx - echo "✓ Nginx installed" + echo "✓ Nginx installed" >&2 else - echo "✓ Nginx already installed" + echo "✓ Nginx already installed" >&2 fi # Task 2: Create directory (idempotent) if [[ ! -d /var/www/app ]]; then run_cmd mkdir -p /var/www/app - echo "✓ Directory created" + echo "✓ Directory created" >&2 else - echo "✓ Directory already exists" + echo "✓ Directory already exists" >&2 fi # Task 3: Configure service (idempotent) if ! systemctl is-enabled --quiet nginx; then run_cmd systemctl enable --quiet nginx - echo "✓ Nginx enabled" + echo "✓ Nginx enabled" >&2 else - echo "✓ Nginx already enabled" + echo "✓ Nginx already enabled" >&2 fi -echo "✓ Setup complete" +echo "✓ Setup complete" >&2 + +# Output YAML result +cat <&1 | cut -d/ -f2) +tasks_completed: + - install_nginx + - create_directory + - enable_service +EOF ``` See: server-info.sh From 9d2281db0f2e002f59bb59d8ce7fe3856d110a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 2 Nov 2025 11:46:50 +0200 Subject: [PATCH 5/5] chore(deps): add bash formatting scripts to composer and npm - Add composer bash and bash:check scripts for shfmt - Add npm format:bash and check:bash scripts - Update .editorconfig for bash formatting consistency --- .editorconfig | 4 ++++ composer.json | 6 ++++++ package.json | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/.editorconfig b/.editorconfig index 8f0de65c..0fe629db 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,3 +16,7 @@ indent_size = 2 [docker-compose.yml] indent_size = 4 + +[*.sh] +indent_style = tab +indent_size = 0 diff --git a/composer.json b/composer.json index a258354e..25bde140 100644 --- a/composer.json +++ b/composer.json @@ -61,6 +61,12 @@ ], "rect": [ "vendor/bin/rector process" + ], + "bash": [ + "shfmt -i 0 -bn -ci -sr -w playbooks/*.sh" + ], + "bash:check": [ + "shfmt -i 0 -bn -ci -sr -d playbooks/*.sh" ] }, "config": { diff --git a/package.json b/package.json index fb4160b0..a2a2b3f9 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,9 @@ { "dependencies": { "prettier": "^3.6.2" + }, + "scripts": { + "format:bash": "shfmt -i 0 -bn -ci -sr -w playbooks/*.sh", + "check:bash": "shfmt -i 0 -bn -ci -sr -d playbooks/*.sh" } }