Last week, my colleague Ruben and I released a package called 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.

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

Do you want to map some ports? No problem!

$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.

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

Executing a command on an instance is straightforward too.

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

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

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

The execute method returns an instance of Symfony/Process .

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

$process->isSuccessful();

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.

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

Files can be added to an instance with addFiles .

$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. That makes it possible to add methods to it in realtime.

Spatie\Docker\DockerContainerInstance::macro( 'whoAmI' , function () { $process = $containerInstance->run( 'whoami' ); return $process->getOutput(); }); $containerInstance = DockerContainer::create($imageName)->start(); $containerInstace->whoAmI();

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. 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.

FROM ubuntu:latest MAINTAINER Freek Van der Herten <freek@spatie.be> 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

Spin up a docker container Copy some files directly into the container Start the backup procedure Assert that the files we put on the docker container are backed up.

Here's the setup method of the test.

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.

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.

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.

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 is useful. It has some more features, not mentioned in this post. Head over to the readme of the package on GitHub to learn more. This isn't the first package our team has built. Here's a list with all the stuff we released previously.