serversinc/ssh-runner

Actions-based SSH Runner

Maintainers

Package info

github.com/serversinc/ssh-runner

pkg:composer/serversinc/ssh-runner

Transparency log

Statistics

Installs: 22

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

0.3.1 2026-07-03 15:49 UTC

This package is auto-updated.

Last update: 2026-07-03 15:51:45 UTC


README

Latest Version on Packagist Total Downloads GitHub Actions

A pipeline-based SSH runner for Laravel that executes commands on remote servers with support for action composition, failure strategies, automatic rollback, and execution logging.

This package provides a fluent API for building SSH command pipelines using the Spatie SSH library under the hood.

Installation

You can install the package via Composer:

composer require serversinc/ssh-runner

Configuration

Publish the configuration file:

php artisan vendor:publish --provider="Serversinc\SshRunner\SshRunnerServiceProvider" --tag="ssh-runner-config"

Publish the migrations:

php artisan vendor:publish --provider="Serversinc\SshRunner\SshRunnerServiceProvider" --tag="ssh-runner-migrations"

Run the migrations to create the logging tables:

php artisan migrate

Note: UUID / ULID Primary Keys

The package's migration uses $table->morphs('server') which creates server_id as an unsignedBigInteger. If your server model uses UUID or ULID primary keys, create a migration in your application to change the column type:

Schema::table('ssh_pipeline_logs', function (Blueprint $table) {
    $table->string('server_id')->change();
});

Basic Usage

1. Implement the SshServer Interface

Your server model must implement the SshServer contract:

use Serversinc\SshRunner\Contracts\SshServer;

class Server extends Model implements SshServer
{
    public function getSshHost(): string
    {
        return $this->ip_address;
    }

    public function getSshPort(): int
    {
        return $this->ssh_port ?? 22;
    }

    public function getSshUser(): string
    {
        return $this->ssh_user;
    }

    public function getSshKeyPath(): ?string
    {
        return $this->ssh_key_path;
    }

    public function getSshKeyContents(): ?string
    {
        return $this->ssh_key_contents;
    }

    public function getSshPassword(): ?string
    {
        return $this->ssh_password;
    }

    public function getSshJumpHost(): ?string
    {
        return $this->ssh_jump_host; // e.g. "user@bastion.example.com"
    }
}

2. Create an Action

Actions are reusable, testable units of work:

use Serversinc\SshRunner\Actions\BaseSshAction;
use Serversinc\SshRunner\Contracts\SshServer;
use Serversinc\SshRunner\Results\ActionResult;
use Spatie\Ssh\Ssh;

class InstallPackage extends BaseSshAction
{
    public function __construct(private string $packageName)
    {
    }

    public function handle(SshServer $server, Ssh $ssh): ActionResult
    {
        return $this->run($ssh, ["apt-get install -y {$this->packageName}"]);
    }

    public function undo(SshServer $server, Ssh $ssh): void
    {
        // Called automatically on rollback
        $ssh->execute(["apt-get remove -y {$this->packageName}"]);
    }
}

3. Execute a Pipeline

There are several ways to execute pipelines:

Using the Facade (Recommended)

use SshRunner;
use Serversinc\SshRunner\Enums\FailureStrategy;

$result = SshRunner::pipeline($server)
    ->run(new UpdatePackageList)
    ->run(new InstallPackage('nginx'))
    ->run(new InstallPackage('nginx'))
    ->run(new RestartService('nginx'))
    ->execute();

if ($result->success) {
    echo "Pipeline completed in {$result->duration()} seconds";
} else {
    foreach ($result->failedActions() as $action) {
        echo "Failed: {$action->action}\n";
        echo "Error: {$action->errorOutput}\n";
    }
}

Using SshConnection

use Serversinc\SshRunner\SshConnection;

$connection = SshConnection::for($server);

$result = $connection->pipeline()
    ->run(new UpdatePackageList)
    ->run(new InstallPackage('nginx'))
    ->execute();

Using the Factory Class

use Serversinc\SshRunner\SshRunner;

$result = SshRunner::pipeline($server)
    ->run(new UpdatePackageList)
    ->run(new InstallPackage('nginx'))
    ->execute();

// Or execute a script directly
$result = SshRunner::script($server, new DeployWordPressSite(
    path: '/var/www/example.com',
    domain: 'example.com',
    dbName: 'wordpress_example',
    dbUser: 'wp_example',
    dbPassword: 'secure-password',
));

Failure Strategies

Control what happens when an action fails:

use Serversinc\SshRunner\Enums\FailureStrategy;

// STOP (default) - Stop execution on first failure
$pipeline->onFailure(FailureStrategy::STOP);

// CONTINUE - Keep executing remaining actions
$pipeline->onFailure(FailureStrategy::CONTINUE);

// ROLLBACK - Undo completed actions in reverse order
$pipeline->onFailure(FailureStrategy::ROLLBACK)
    ->run(new CreateDatabase)
    ->run(new CreateUser) // If this fails, CreateDatabase->undo() is called
    ->execute();

Execution Logging

All pipeline runs are automatically logged to the database:

use Serversinc\SshRunner\Models\SshPipelineLog;

// Get all runs for a server
$runs = SshPipelineLog::where('server_id', $server->id)->get();

// Check if a specific run failed
$run = SshPipelineLog::find(1);
if ($run->failed()) {
    foreach ($run->actionLogs as $log) {
        echo "{$log->action}: {$log->exit_code}\n";
    }
}

Jump Host / Bastion Support

Route SSH connections through a bastion (jump) host by implementing getSshJumpHost() on your server model:

public function getSshJumpHost(): ?string
{
    return $this->ssh_jump_host; // e.g. "deploy@bastion.example.com"
}

Return null to connect directly (no jump host). When a non-null value is returned, SshConnection automatically passes it to Spatie SSH's useJumpHost() so all pipelines and scripts on that server are routed through the bastion transparently.

Single Action Execution

Execute a single action without the pipeline:

// Using the Facade
$result = SshRunner::run($server, new UpdatePackageList);

// Or using SshConnection
$connection = SshConnection::for($server);
$result = $connection->execute(new UpdatePackageList);

if ($result->success) {
    echo $result->output;
} else {
    echo $result->errorOutput;
}

Script Execution

Scripts allow you to group multiple related commands into a single action with built-in step-by-step execution, optional rollback per step, and critical/non-critical step handling.

Creating a Script

Extend BaseScript and define your steps:

use Serversinc\SshRunner\Scripts\BaseScript;
use Serversinc\SshRunner\Scripts\ScriptStep;

class DeployWordPressSite extends BaseScript
{
    public function __construct(
        private string $path,
        private string $domain,
        private string $dbName,
        private string $dbUser,
        private string $dbPassword,
    ) {}

    public function steps(): array
    {
        return [
            new ScriptStep(
                name: 'Create application directory',
                command: "mkdir -p {$this->path}",
                rollback: "rm -rf {$this->path}",
            ),
            new ScriptStep(
                name: 'Download WordPress',
                command: "cd {$this->path} && wget https://wordpress.org/latest.tar.gz",
                rollback: "rm -f {$this->path}/latest.tar.gz",
            ),
            new ScriptStep(
                name: 'Extract archive',
                command: "cd {$this->path} && tar -xzf latest.tar.gz",
            ),
            new ScriptStep(
                name: 'Create database',
                command: "mysql -e \"CREATE DATABASE IF NOT EXISTS {$this->dbName};\"",
                rollback: "mysql -e \"DROP DATABASE IF EXISTS {$this->dbName};\"",
            ),
            new ScriptStep(
                name: 'Create database user',
                command: "mysql -e \"CREATE USER IF NOT EXISTS '{$this->dbUser}'@'localhost' IDENTIFIED BY '{$this->dbPassword}'; GRANT ALL PRIVILEGES ON {$this->dbName}.* TO '{$this->dbUser}'@'localhost'; FLUSH PRIVILEGES;\"",
                rollback: "mysql -e \"DROP USER IF EXISTS '{$this->dbUser}'@'localhost';\"",
            ),
            new ScriptStep(
                name: 'Set permissions',
                command: "chown -R www-data:www-data {$this->path}",
                critical: false, // Non-critical: failure here won't stop the script
            ),
        ];
    }

    public function validate(): void
    {
        if (empty($this->path) || empty($this->domain)) {
            throw new \InvalidArgumentException('Path and domain are required');
        }
    }
}

Using Scripts in Pipelines

Scripts work seamlessly inside pipelines:

use SshRunner;

$result = SshRunner::pipeline($server)
    ->run(new UpdatePackageList)
    ->script(new DeployWordPressSite(
        path: '/var/www/example.com',
        domain: 'example.com',
        dbName: 'wordpress_example',
        dbUser: 'wp_example',
        dbPassword: 'secure-password',
    ))
    ->run(new RestartService('nginx'))
    ->onFailure(FailureStrategy::ROLLBACK)
    ->execute();

Executing a Script Directly

Run a script as a single action without a pipeline:

use SshRunner;

$result = SshRunner::script($server, new DeployWordPressSite(
    path: '/var/www/example.com',
    domain: 'example.com',
    dbName: 'wordpress_example',
    dbUser: 'wp_example',
    dbPassword: 'secure-password',
));

if ($result->success) {
    echo $result->output;
} else {
    echo $result->errorOutput;
}

Script Behavior

  • SSH connection reuse — Scripts automatically enable SSH multiplexing (ControlMaster=auto) so all steps share the same underlying TCP connection, avoiding repeated authentication overhead.
  • Critical steps (critical: true, the default) trigger automatic rollback of all previously completed steps on failure.
  • Non-critical steps (critical: false) log a failure but continue to the next step.
  • Step-level rollback commands are executed in reverse order when a critical step fails or when the pipeline's ROLLBACK failure strategy is triggered.
  • Filesystem state persists between steps (files created in one step are available in the next).
  • Scripts integrate with the existing logging and failure strategy infrastructure.

Note: While the SSH network connection is reused between steps, each ScriptStep still runs in its own shell process. This means environment variables set in one step (e.g. export VAR=value) are not available in subsequent steps. If you need to share data between steps, use files on the remote filesystem.

Advanced Usage

Creating a Connection for Reuse

use Serversinc\SshRunner\SshConnection;
use Serversinc\SshRunner\SshPipeline;

$connection = SshConnection::for($server);

// Execute multiple pipelines on the same connection
$result1 = $connection->pipeline()
    ->run(new Action1())
    ->execute();

$result2 = $connection->pipeline()
    ->run(new Action2())
    ->execute();

Using SshRunner Factory Methods

use Serversinc\SshRunner\SshRunner;

// Create a connection
$connection = SshRunner::connect($server);

// Create a pipeline directly
$pipeline = SshRunner::pipeline($server);

// Execute a single action
$result = SshRunner::run($server, new SomeAction());

// Execute a script directly
$result = SshRunner::script($server, new DeployWordPressSite(
    path: '/var/www/example.com',
    domain: 'example.com',
    dbName: 'wordpress_example',
    dbUser: 'wp_example',
    dbPassword: 'secure-password',
));

Testing

composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security

If you discover any security-related issues, please use the issue tracker and mark it as a security concern.

Credits

License

The MIT License (MIT). Please see License File for more information.