Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .cursor/rules/05-bash.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
72 changes: 59 additions & 13 deletions .cursor/rules/06-playbooks.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 <<EOF
distro: debian
status: success
result: []
EOF
```

**Error Detection Pattern:**

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.

### Complete Example

Expand All @@ -127,28 +163,38 @@ run_cmd() { [[ $DEPLOYER_PERMS == 'root' ]] && "$@" || sudo "$@"; }
# 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"
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 <<EOF
status: success
nginx_version: $(nginx -v 2>&1 | cut -d/ -f2)
tasks_completed:
- install_nginx
- create_directory
- enable_service
EOF
```

See: server-info.sh
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ indent_size = 2

[docker-compose.yml]
indent_size = 4

[*.sh]
indent_style = tab
indent_size = 0
1 change: 1 addition & 0 deletions app/Console/Server/ServerAddCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->io->hr();

$this->displayServerDeets($server);
$this->io->writeln('');

//
// Save to repository
Expand Down
84 changes: 84 additions & 0 deletions app/Console/Server/ServerInfoCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\Console\Server;

use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand;
use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait;
use Bigpixelrocket\DeployerPHP\Traits\ServerInfoTrait;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'server:info', description: 'Display server information')]
class ServerInfoCommand extends BaseCommand
{
use ServerHelpersTrait;
use ServerInfoTrait;

//
// Configuration
// -------------------------------------------------------------------------------

protected function configure(): void
{
parent::configure();

$this->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;
}

}
1 change: 1 addition & 0 deletions app/Console/Site/SiteAddCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->io->hr();

$this->displaySiteDeets($site);
$this->io->writeln('');

//
// Save to repository
Expand Down
2 changes: 2 additions & 0 deletions app/SymfonyApp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -138,6 +139,7 @@ private function registerCommands(): void
ServerAddCommand::class,
ServerDeleteCommand::class,
ServerListCommand::class,
ServerInfoCommand::class,

// Providers
ServerProvisionDigitalOceanCommand::class,
Expand Down
123 changes: 123 additions & 0 deletions app/Traits/PlaybookHelpersTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\Traits;

use Bigpixelrocket\DeployerPHP\Container;
use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO;
use Bigpixelrocket\DeployerPHP\Services\FilesystemService;
use Bigpixelrocket\DeployerPHP\Services\IOService;
use Bigpixelrocket\DeployerPHP\Services\SSHService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Yaml\Yaml;

/**
* Reusable helpers for executing playbooks on remote servers.
*
* Requires classes using this trait to have Container, IOService, SSHService, and FilesystemService properties.
*
* @property Container $container
* @property FilesystemService $fs
* @property IOService $io
* @property SSHService $ssh
*/
trait PlaybookHelpersTrait
{
use KeyHelpersTrait;

/**
* Execute a playbook on a server.
*
* Handles SSH execution, error display, and YAML parsing.
* 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)
* @param array<string, string> $playbookVars Playbook variables to pass to the playbook (don't pass sensitive data)
Comment thread
loadinglucian marked this conversation as resolved.
* @return array<string, mixed>|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([
'',
'<fg=red>'.$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<string, mixed> $parsed */
return $parsed;
} catch (\Throwable $e) {
$this->io->error('Failed to parse YAML output: ' . $e->getMessage());
$this->io->writeln([
'',
'<fg=yellow>Raw output:</>',
'',
$result['output'],
'',
]);

return Command::FAILURE;
}
}

}
Loading