Manage Docker containers using PHP
Tags: docker, php, opensource
Published: 2020-02-13 12:07:09

Last week, my colleague [Ruben](https://rubenvanassche.com) and I released [a package called spatie/docker](https://github.com/spatie/docker), that makes it easy to spin up docker containers and execute commands on them. In this blog post, I'd like to introduce what you can do with it and why we built this.

## Using the package

Spinning up a docker container is pretty straightforward.

```php
$containerInstance = DockerContainer::create($imageName)->start();
```

Do you want to map some ports? No problem!

```php
$containerInstance = DockerContainer::create($imageName)
    ->mapPort($portOnHost, $portOnContainer)
    ->mapPort($anotherPortOnHost, $anotherPortOnContainer)
    ->start();
```

When using this package in a testing environment, it can be handy that the docker container is stopped after `__destruct` is called on it (mostly this will happen when the PHP script ends). You can enable this behavior with the `stopOnDestruct` method.

```php
$containerInstance = DockerContainer::create($imageName)
    ->stopOnDestruct()
    ->start();
```

Executing a command on an instance is straightforward too.

```php
$process = $instance->execute($command);
```

You can execute multiple commands in one go by passing an array.

```php
$process = $instance->execute([$command, $anotherCommand]);
```

The execute method returns an instance of [`Symfony/Process`](https://symfony.com/doc/current/components/process.html).

You can check if your command ran successfully using the `isSuccessful` method.

```php
$process->isSuccessful(); // returns a boolean
```

You can get to the output using `getOutput()`. If the command did not run successfully, you can use `getErrorOutput()`. For more information on how to work with a `Process`, head over to [the Symfony docs](https://symfony.com/doc/current/components/process.html).

In addition to executing a command, you can also add files to the instance.

Files can be added to an instance with `addFiles`.

```php
$instance->addFiles($fileOrDirectoryOnHost, $pathInContainer);
```

I can imagine that users would wish that they could add methods of their own to a docker instance. Our package grants that wish. The `Spatie\Docker\ContainerInstance` class is [macroable](https://github.com/spatie/macroable). That makes it possible to add methods to it in realtime.

```php
Spatie\Docker\DockerContainerInstance::macro('whoAmI', function () {
    $process = $containerInstance->run('whoami');


    return $process->getOutput();
});

$containerInstance = DockerContainer::create($imageName)->start();

$containerInstace->whoAmI(); // returns of name of user in the docker container
```

## Why we built this

I'm currently building a package called spatie/laravel-backup-server. It will be the spiritual successor of [spatie/laravel-backup](https://github.com/spatie/laravel-backup). Laravel Backup is usually installed into the Laravel app, and it will copy that app to some other storage. Laravel Backup Server will take a different approach: it will SSH into each server that needs to be backed up and will copy all the files on to itself.

To test Laravel Backup Server, I needed to have some local servers with SSH capabilities. Docker was the perfect solution for this.

Here's the content of the dockerfile that we use to create the `spatie/laravel-backup-server-tests` image. In short, this docker container will contain an SSH server.

```docker
FROM ubuntu:latest
MAINTAINER Freek Van der Herten <freek@spatie.be>

# add openssh and clean
RUN apt-get update && apt-get install -y openssh-server sed nano rsync

RUN mkdir /var/run/sshd

COPY baseSshKeys /etc/ssh
RUN chmod 600 /etc/ssh/ssh_host_dsa_key
RUN chmod 600 /etc/ssh/ssh_host_ecdsa_key
RUN chmod 600 /etc/ssh/ssh_host_ed25519_key
RUN chmod 600 /etc/ssh/ssh_host_rsa_key

RUN echo "root:root" | chpasswd

RUN sed -ri 's/^#?PermitRootLogin\s+.*/PermitRootLogin yes/' /etc/ssh/sshd_config
RUN sed -ri 's/^#?PasswordAuthentication yes/PasswordAuthentication no/g' /etc/ssh/sshd_config
RUN sed -ri 's/^#?SyslogFacility AUTH/SyslogFacility AUTH/g' /etc/ssh/sshd_config
RUN sed -ri 's/^#?LogLevel INFO/LogLevel INFO/g' /etc/ssh/sshd_config

RUN mkdir /root/.ssh
RUN touch /root/.ssh/authorized_keys
RUN chmod 600 /root/.ssh/authorized_keys

EXPOSE 22
CMD    ["/usr/sbin/sshd", "-D"]
```

Let's take a look at the test that makes sure the backup procedure works. The test will do these things
1. Spin up a docker container
2. Copy some files directly into the container
3. Start the backup procedure
4. Assert that the files we put on the docker container are backed up.

Here's the setup method of the test.

```php
 public function setUp(): void
{
    parent::setUp();

    Storage::fake('backups');
    
    $this->source = factory(Source::class)->create([
        'host' => '0.0.0.0',
        'ssh_port' => '4848',
        'ssh_user' => 'root',
        'ssh_private_key_file' => $this->privateKeyPath(),
        'includes' => ['/src']
    ]);

    $this->container = DockerContainer::create('spatie/laravel-backup-server-tests')
        ->name('laravel-backup-server-tests')
        ->mapPort(4848, 22)
        ->stopOnDestruct()
        ->start()
        ->addPublicKey($this->publicKeyPath());
}
```

In the `setUp` we create a `Source` model. In our package, a source represents something that needs to be backed up. We also spin up a docker container.  That `stopOnDestruct` will make sure that the docker container will be stopped and destroyed as soon as our test is over. In our test suite, we also have stored a private/public key pair. The public key is copied to the docker container. On the `Source` model, we saved the path to the private part in the `ssh_private_key_file` attribute, so the backup procedure will use that one when logging in to the docker container (which will run on IP 0.0.0.0 aka localhost on port 4848).

With that out of the way, let's take a look at the test itself.

```php
/** @test */
public function it_can_perform_a_backup()
{
    $this->container->addFiles(__DIR__ . '/stubs/serverContent/testServer', '/src');

    $this->artisan('backup-server:backup')->assertExitCode(0);

    $this->assertTrue($this->source->backups()->first()->has('src/1.txt'));

    $this->assertEquals(Backup::STATUS_COMPLETED, $this->source->backups()->first()->status);
}
```

The first thing that we do in the test is to copy a file directly onto the container. Next, we run the backup procedure (we assert it ran ok), and after that, we assert that the backup contains the file that we just copied onto the container.

I love these kinds of tests that cover a lot of functionality without testing the implementation details. The test verifies the result, and it does not care about how the result was achieved. I can still refactor all the internal of the package, and this test should still pass.

Of course, more tests are needed to cover the entire behavior of the backup procedure fully. Let's take a look at a couple more. In this one, we don't use a private key file, simulating a login problem. The backup command should run without errors, but the backup it produces should be marked as failed.

```php
/** @test */
public function it_will_fail_if_it_cannot_login()
{
    $this->source->update(['ssh_private_key_file' => null]);

    $this->artisan('backup-server:backup')->assertExitCode(0);

    $this->assertEquals(Backup::STATUS_FAILED, $this->source->backups()->first()->status);
}
```

Laravel-backup-server can also perform commands via ssh before the actual backup runs. We test that out in this test.

```php
/** @test */
public function it_can_perform_a_pre_backup_command()
{
    $this->container->addFiles(__DIR__ . '/stubs/serverContent/testServer', '/src');

    $this->source->update(['pre_backup_commands' => ['cd /src', 'touch newfile.txt']]);

    $this->artisan('backup-server:backup')->assertExitCode(0);

    $this->assertTrue($this->source->backups()->first()->has('src/newfile.txt'));

    $this->assertEquals(Backup::STATUS_COMPLETED, $this->source->backups()->first()->status);
}
```

Here we create a file using `touch` inside the directory we wish to backup, and we assert that that file exists in the created backup.

## In closing

Controlling Docker containers sure is handy. We built to package to easily test the behavior of our upcoming laravel-backup-server package. I'll share more about that package soon.

I'm pretty sure you can come up with other scenarios in which [our spatie/docker package](https://github.com/spatie/docker) is useful. It has some more features, not mentioned in this post. Head over to [the readme of the package on GitHub](https://github.com/spatie/docker) to learn more. This isn't the first package our team has built. Here's a list with [all the stuff we released previously](https://spatie.be/open-source/packages).