diff --git a/.cursor/rules/06-playbooks.mdc b/.cursor/rules/06-playbooks.mdc index d58a766d..e2fe1125 100644 --- a/.cursor/rules/06-playbooks.mdc +++ b/.cursor/rules/06-playbooks.mdc @@ -15,50 +15,115 @@ 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) +- Return parsable YAML output -### Non-Interactive Operation +### Structure -All playbooks MUST run without user interaction: +Standard playbook structure using `main()` function: -- Set `DEBIAN_FRONTEND=noninteractive` for Debian/Ubuntu -- Use `-y` flag for package managers (apt-get, yum) -- Use `-q` flag to suppress unnecessary output -- Use `--batch --yes` for GPG operations -- Use `--quiet` for systemctl where appropriate -- Never use `read`, confirm dialogs, or interactive prompts +```bash +#!/usr/bin/env bash +set -o pipefail +export DEBIAN_FRONTEND=noninteractive + +# Validation +[[ -z $DEPLOYER_OUTPUT_FILE ]] && echo "Error: DEPLOYER_OUTPUT_FILE required" && exit 1 + +# +# Helper Functions +# ---- +# (see "Helper Functions" section below for run_cmd implementation) + +# +# Main Execution +# ---- + +main() { + echo "✓ Starting..." + + # Tasks go here + + if ! cat > "$DEPLOYER_OUTPUT_FILE" <&2 + exit 1 + fi +} + +main "$@" +``` + +**Pattern Requirements:** + +- Shebang: `#!/usr/bin/env bash` +- Always set `set -o pipefail` (NOT `set -e`) +- Export `DEBIAN_FRONTEND=noninteractive` +- Validate `$DEPLOYER_OUTPUT_FILE` before any work +- Use `main()` function with `main "$@"` at bottom +- Group related functions with comment headers +- Check errors on YAML writes ### Environment Variables -Use `DEPLOYER_` prefix for all context variables: +Use `DEPLOYER_` prefix. Standard variables: + +- `DEPLOYER_OUTPUT_FILE` - YAML output path (provided automatically) +- `DEPLOYER_DISTRO` - Distribution: `debian|redhat|amazon` (if needed) +- `DEPLOYER_PERMS` - Permissions: `root|sudo|none` (if needed) + +**Validation:** + +Detection playbooks only validate `DEPLOYER_OUTPUT_FILE`. Provisioning playbooks additionally validate `DEPLOYER_DISTRO` and `DEPLOYER_PERMS`, then export `DEPLOYER_PERMS` for subshell availability. See Complete Example section for full pattern. + +### Distribution Support + +Support Debian, RedHat, Amazon Linux. Use `case` statements for package operations: ```bash -#!/usr/bin/env bash -# -# Install Package -# ------------------------------------------------------------------------------- -# Required Environment Variables: -# DEPLOYER_DISTRO - Distribution (debian|redhat|amazon) -# DEPLOYER_PERMS - Permission level (root|sudo) +# ✅ CORRECT - case statement for package managers +case $DEPLOYER_DISTRO in + debian) + run_cmd apt-get update -q + run_cmd apt-get install -y -q "$package" + ;; + redhat|amazon) + run_cmd yum install -y -q "$package" + ;; +esac + +# ❌ WRONG - Unnecessary branching for universal operations +case $DEPLOYER_DISTRO in + debian|redhat|amazon) + run_cmd systemctl start service # Same everywhere! + ;; +esac +``` -set -o pipefail -export DEBIAN_FRONTEND=noninteractive +**Universal operations (no branching needed):** -# Validation - errors go to stdout (not YAML = error detected) -if [[ -z $DEPLOYER_DISTRO ]]; then - echo "Error: DEPLOYER_DISTRO environment variable is required" - exit 1 -fi +```bash +run_cmd systemctl start caddy +run_cmd systemctl enable caddy +run_cmd mkdir -p /var/www/app ``` -### Idempotency +### Non-Interactive Operation + +Never prompt for input. Use non-interactive flags: + +- `export DEBIAN_FRONTEND=noninteractive` (always set at top) +- Package managers: `-y -q` flags +- GPG operations: `--batch --yes` +- systemctl: `--quiet` (where appropriate) +- Never use `read`, confirm dialogs, or interactive prompts -ALL playbooks MUST be idempotent - safe to run multiple times without side effects. +### Idempotency -Check before acting - don't fail if resource exists: +Check before acting. Don't fail if resource already exists: ```bash -# ✅ CORRECT - Idempotent +# ✅ CORRECT - Idempotent patterns if ! command -v caddy >/dev/null 2>&1; then run_cmd apt-get install -y -q caddy fi @@ -67,85 +132,97 @@ if [[ ! -d /var/www/app ]]; then run_cmd mkdir -p /var/www/app fi -# ❌ WRONG - Not idempotent (fails on second run) -run_cmd useradd deployer +if ! systemctl is-enabled --quiet caddy; then + run_cmd systemctl enable --quiet caddy +fi -# ❌ WRONG - Not idempotent (duplicates each run) -echo "export PATH=\$PATH:/usr/local/bin" >> ~/.bashrc +# ❌ WRONG - Not idempotent +run_cmd useradd deployer # Fails second time +echo "export PATH=\$PATH:/usr/local/bin" >> ~/.bashrc # Duplicates each run ``` -Enables recovery, resumption, drift correction. - ### Error Handling -Fail fast with clear error messages to stdout. Errors are NOT YAML, so parsing will fail and the error message will be displayed. +Use `set -o pipefail` but NOT `set -e`. Check exit codes explicitly: ```bash -set -o pipefail # Fail on pipe errors +# Validation errors (before any work) → stdout +[[ -z $DEPLOYER_OUTPUT_FILE ]] && echo "Error: DEPLOYER_OUTPUT_FILE required" && exit 1 -# Validation errors - output plain text (not YAML) -if [[ -z $DEPLOYER_DISTRO ]]; then - echo "Error: DEPLOYER_DISTRO environment variable is required" +# Runtime errors (during execution) → stderr +if ! mkdir -p /var/www/app 2>&1; then + echo "Error: Failed to create directory" >&2 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" +# Silent checks (expected to sometimes fail) +if ! command -v nginx >/dev/null 2>&1; then + echo "✓ Installing nginx..." + run_cmd apt-get install -y -q nginx +fi + +# Check YAML writes +if ! cat > "$DEPLOYER_OUTPUT_FILE" <&2 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. +**Error Detection:** + +If playbook exits before creating `$DEPLOYER_OUTPUT_FILE`, framework treats all output as error message. ### Helper Functions +Standard helper for permission-aware command execution: + ```bash -# Execute with appropriate permissions run_cmd() { if [[ $DEPLOYER_PERMS == 'root' ]]; then "$@" else - sudo "$@" + sudo -n "$@" fi } - -# Usage -run_cmd apt-get install -y -q package-name ``` -### Output - -**ALL playbooks MUST return parsable YAML as their final output.** +The `-n` flag ensures sudo fails fast without prompting for a password, maintaining non-interactive operation. -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:** +### Output -- 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 +Write YAML to `$DEPLOYER_OUTPUT_FILE`. Progress messages to stdout/stderr. -**Example Pattern:** +**Pattern:** ```bash -# Progress messages to stderr -echo "✓ Processing..." >&2 -echo "✓ Task complete" >&2 +# Progress messages (stdout) +echo "✓ Processing..." +echo "✓ Task complete" -# Final YAML output to stdout -cat < "$DEPLOYER_OUTPUT_FILE" <&2 + exit 1 +fi + +# Error messages (stderr) +if ! some_command; then + echo "Error: Command failed" >&2 + exit 1 +fi ``` -**Error Detection Pattern:** +**Progress indicators:** -Commands parse playbook output as YAML. If parsing fails, the raw output is an error message to display to the user. This eliminates the need for explicit error codes or special error handling. +- `✓` for success messages +- `✗` for failure messages (before exit) +- Never write progress to output file ### Complete Example @@ -155,46 +232,70 @@ set -o pipefail export DEBIAN_FRONTEND=noninteractive # Validation +[[ -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 +export DEPLOYER_PERMS -# Helpers -run_cmd() { [[ $DEPLOYER_PERMS == 'root' ]] && "$@" || sudo "$@"; } +# +# Helper Functions +# ---- -# Task 1: Install package (idempotent) -if ! command -v nginx >/dev/null 2>&1; then - run_cmd apt-get install -y -q nginx - echo "✓ Nginx installed" >&2 -else - echo "✓ Nginx already installed" >&2 -fi +run_cmd() { + if [[ $DEPLOYER_PERMS == 'root' ]]; then + "$@" + else + sudo -n "$@" + fi +} -# Task 2: Create directory (idempotent) -if [[ ! -d /var/www/app ]]; then - run_cmd mkdir -p /var/www/app - echo "✓ Directory created" >&2 -else - echo "✓ Directory already exists" >&2 -fi +# +# Main Execution +# ---- + +main() { + local caddy_version + + echo "✓ Installing Caddy..." + if ! command -v caddy >/dev/null 2>&1; then + case $DEPLOYER_DISTRO in + debian) + run_cmd apt-get update -q + run_cmd apt-get install -y -q caddy + ;; + redhat|amazon) + run_cmd yum install -y -q caddy + ;; + esac + fi -# Task 3: Configure service (idempotent) -if ! systemctl is-enabled --quiet nginx; then - run_cmd systemctl enable --quiet nginx - echo "✓ Nginx enabled" >&2 -else - echo "✓ Nginx already enabled" >&2 -fi + echo "✓ Creating directory..." + if [[ ! -d /var/www/app ]]; then + run_cmd mkdir -p /var/www/app + fi -echo "✓ Setup complete" >&2 + echo "✓ Enabling service..." + if ! systemctl is-enabled --quiet caddy; then + run_cmd systemctl enable --quiet caddy + fi + + caddy_version=$(caddy version 2>&1 | cut -d' ' -f1) -# Output YAML result -cat < "$DEPLOYER_OUTPUT_FILE" <&1 | cut -d/ -f2) +distro: $DEPLOYER_DISTRO +caddy_version: $caddy_version tasks_completed: - - install_nginx + - install_caddy - create_directory - enable_service EOF + echo "Error: Failed to write output file" >&2 + exit 1 + fi +} + +main "$@" ``` -See: server-info.sh +See: `playbooks/server-info.sh` diff --git a/app/Console/Server/ServerInfoCommand.php b/app/Console/Server/ServerInfoCommand.php index 1e19bb85..66f45d3e 100644 --- a/app/Console/Server/ServerInfoCommand.php +++ b/app/Console/Server/ServerInfoCommand.php @@ -69,6 +69,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $info; } + $this->io->writeln(''); $this->displayServerInfo($info); // diff --git a/app/Traits/PlaybookHelpersTrait.php b/app/Traits/PlaybookHelpersTrait.php index 8621aeaa..e9db3e4f 100644 --- a/app/Traits/PlaybookHelpersTrait.php +++ b/app/Traits/PlaybookHelpersTrait.php @@ -30,6 +30,7 @@ trait PlaybookHelpersTrait * Execute a playbook on a server. * * Handles SSH execution, error display, and YAML parsing. + * Playbooks write YAML output to a temp file (DEPLOYER_OUTPUT_FILE). * Displays errors via IOService and returns Command::FAILURE on any error. * * @param string $playbookName Playbook name without .sh extension (e.g., 'server-info', 'install-php', etc) @@ -46,8 +47,11 @@ protected function executePlaybook( $playbookPath = $projectRoot . '/playbooks/' . $playbookName . '.sh'; $scriptContents = $this->fs->readFile($playbookPath); - // Build variable prefix - $varsPrefix = ''; + // Generate unique output filename + $outputFile = sprintf('/tmp/deployer-output-%d-%s.yml', time(), bin2hex(random_bytes(8))); + + // Build variable prefix with DEPLOYER_OUTPUT_FILE + $varsPrefix = sprintf('DEPLOYER_OUTPUT_FILE=%s ', escapeshellarg($outputFile)); foreach ($playbookVars as $key => $value) { $varsPrefix .= sprintf('%s=%s ', $key, escapeshellarg((string) $value)); } @@ -84,35 +88,58 @@ protected function executePlaybook( return Command::FAILURE; } + // Display all output as progress messages + $output = trim((string) $result['output']); + if (!empty($output)) { + $this->io->writeln(explode("\n", $output)); + } + // Check exit code if ($result['exit_code'] !== 0) { - $this->io->error('Playbook execution failed:'); - $this->io->writeln([ - '', - ''.$result['output'].'', - '', - ]); + $this->io->error('Playbook execution failed'); return Command::FAILURE; } - // Parse YAML output + // Read YAML output from file and clean up try { - $parsed = Yaml::parse($result['output']); + $yamlResult = $this->io->promptSpin( + callback: fn () => $this->ssh->executeCommand( + $server->host, + $server->port, + $server->username, + sprintf('cat %s 2>/dev/null && rm -f %s', escapeshellarg($outputFile), escapeshellarg($outputFile)), + $privateKeyPath + ), + message: $spinnerMessage + ); + + $yamlContent = trim((string) $yamlResult['output']); + + if (empty($yamlContent)) { + throw new \RuntimeException('Something went wrong while trying to read ' . $outputFile); + } + } catch (\RuntimeException $e) { + $this->io->error($e->getMessage()); + + return Command::FAILURE; + } + + // Parse YAML + try { + $parsed = Yaml::parse($yamlContent); if (!is_array($parsed)) { - throw new \RuntimeException('Expected playbook output to be YAML array'); + throw new \RuntimeException('Unexpected format'); } /** @var array $parsed */ return $parsed; } catch (\Throwable $e) { - $this->io->error('Failed to parse YAML output: ' . $e->getMessage()); + $this->io->error($e->getMessage()); $this->io->writeln([ '', - 'Raw output:', - '', - $result['output'], + ''.$yamlContent.'', '', ]); diff --git a/playbooks/server-info.sh b/playbooks/server-info.sh index 1016355b..b53f1ddf 100755 --- a/playbooks/server-info.sh +++ b/playbooks/server-info.sh @@ -1,15 +1,31 @@ #!/usr/bin/env bash +# +# Gather Server Information +# ---- +# This playbook detects distribution, permissions, and listening services. +# +# Required Environment Variables: +# DEPLOYER_OUTPUT_FILE - Output file path (provided automatically) +# +# Returns YAML with: +# - distro: debian|redhat|amazon|unknown +# - permissions: root|sudo|none +# - ports: map of port numbers to process names set -o pipefail +export DEBIAN_FRONTEND=noninteractive -# -# Gather Server Information -# ------------------------------------------------------------------------------- +# Validation +if [[ -z $DEPLOYER_OUTPUT_FILE ]]; then + echo "Error: DEPLOYER_OUTPUT_FILE environment variable is required" + exit 1 +fi # # Detect Linux Distribution -# +# ---- # Returns: debian|redhat|amazon|unknown + detect_distro() { local distro='unknown' @@ -32,8 +48,9 @@ detect_distro() { # # Check User Permissions -# +# ---- # Returns: root|sudo|none + check_permissions() { if [[ $EUID -eq 0 ]]; then echo 'root' @@ -46,25 +63,30 @@ check_permissions() { # # Execute Command with Appropriate Permissions -# +# ---- + run_cmd() { - [[ $DEPLOYER_PERMS == 'root' ]] && "$@" || sudo "$@" + if [[ $DEPLOYER_PERMS == 'root' ]]; then + "$@" + else + sudo -n "$@" + fi } # # Ensure Required Tools are Installed -# +# ---- + ensure_tools() { local distro=$1 perms=$2 export DEPLOYER_PERMS=$perms - [[ $perms == 'none' ]] && return 0 + # If the command is already installed, return 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 ;; @@ -76,20 +98,13 @@ ensure_tools() { } # -# Get All Listening Ports -# -get_listening_ports() { - local cmd port process +# Get All Listening Services +# ---- - 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 +get_listening_services() { + local port process + if command -v ss > /dev/null 2>&1; then while read -r line; do [[ $line =~ ^State ]] && continue [[ ! $line =~ LISTEN ]] && continue @@ -103,17 +118,9 @@ get_listening_ports() { fi echo "${port}:${process}" fi - done < <($cmd -tlnp 2> /dev/null) | sort -t: -k1 -n | uniq + done < <(run_cmd ss -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 @@ -126,7 +133,7 @@ get_listening_ports() { fi echo "${port}:${process}" fi - done < <($cmd -tlnp 2> /dev/null | tail -n +3) | sort -t: -k1 -n | uniq + done < <(run_cmd netstat -tlnp 2> /dev/null | tail -n +3) | sort -t: -k1 -n | uniq fi } @@ -137,27 +144,44 @@ get_listening_ports() { main() { local distro permissions + # # Gather basic info + + echo "✓ Detecting distribution..." distro=$(detect_distro) + + echo "✓ Checking permissions..." permissions=$(check_permissions) + + echo "✓ Cataloging services..." ensure_tools "$distro" "$permissions" - # Output YAML - cat <<- EOF + # + # Output YAML to file + + if ! cat > "$DEPLOYER_OUTPUT_FILE" <<- EOF; then distro: $distro permissions: $permissions ports: EOF + echo "Error: Failed to write $DEPLOYER_OUTPUT_FILE" >&2 + exit 1 + fi - # Inline ports formatting local port process has_ports=false while IFS=: read -r port process; do - echo " ${port}: ${process}" + if ! echo " ${port}: ${process}" >> "$DEPLOYER_OUTPUT_FILE"; then + echo "Error: Failed to write services list to $DEPLOYER_OUTPUT_FILE" >&2 + exit 1 + fi has_ports=true - done < <(get_listening_ports) + done < <(get_listening_services) if [[ $has_ports == false ]]; then - echo " {}" + if ! echo " {}" >> "$DEPLOYER_OUTPUT_FILE"; then + echo "Error: Failed to write empty services lists to $DEPLOYER_OUTPUT_FILE" >&2 + exit 1 + fi fi }