Skip to content

Commit

Permalink
Custom command system (#96)
Browse files Browse the repository at this point in the history
* Config code clean up

* WIP: "docker-compose exec" to "docker exec" conversion

* Update XdebugTest.php

* Update WpTest.php

* Add DockerTest.php

* add strict types

* Add spatie dto

* Fix grammar in comment

* composer update

* WIP: custom command system

* Output can't be dependency injected this early, call directly

* Don't add half created commands

* Rename use statement

* Remove done statement from composer command

* Fixes #89

* Add support for both yaml array syntaxes for docker env vars

* Move runners into their own collection

* Don't sort Yaml data, we need it in the order it came in

* Add args/options to the command definition

* Refactor custom command running to use pipelines

* Fix YamlTests

* Add CommandFactoryTest.php

* Add CommandCollectionTest.php

* Set proper method visibility on runners

* Add HostCommandRunnerTest.php

* Add ServiceCommandRunnerTest.php

* Add MultiCommandRunnerTest.php

* Add RunnerCollectionTest.php

* Update auto completion

* Fix env var bug found during testing, update service command to test both env var syntaxes.

* Add CustomCommandRunnerTest.php

* Allow testing of symfony commands in addition to laravel commands

* Run host commands as a string to force Process to use fromShellCommandline()

* Add a test command to the tests config file

* Add CommandLoaderTest, fix bug in closure command

* Add custom command docs and examples

* Fix environment variables in example

* Remove commented code

* Fix test name

* Fix strict comparison

* composer update

* Use sprintf for consistency

* Fix line formatting

* composer update

* Try running directly with php

* composer install instead of update

* rollback

* Pass config file, enable debug, no logging

* Remove debug, remove autoloader optimization

* Remove @runTestsInSeparateProcesses, only isolate a single test

* code/type clean up

* Disable global state in other separate processes
  • Loading branch information
defunctl authored Dec 23, 2021
1 parent 2e6c3e8 commit 96c30e7
Show file tree
Hide file tree
Showing 41 changed files with 1,423 additions and 126 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ jobs:
coverage: pcov

- name: Install Composer dependencies
run: composer update --prefer-stable --no-interaction --prefer-dist --no-suggest --optimize-autoloader
run: composer update --prefer-stable --no-interaction --prefer-dist --no-suggest

# Github workflows/actions have no tty, the script command captures the output as a work around
- name: PHPUnit Testing
run: script -e -c vendor/bin/phpunit
run: php vendor/bin/phpunit -c ./phpunit.xml.dist

- name: Build
run: php so app:build --build-version=0.0
Expand Down
103 changes: 100 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,15 +193,112 @@ You may be running a project using a non-standard domain, in which case you'll n
### Stop all running docker containers

1. To stop just the global containers run `so global:stop`.
1. To stop **all** running docker containers on your system, not just created from SquareOne, run `so global:stop-all`.
2. To stop **all** running docker containers on your system, not just created from SquareOne, run `so global:stop-all`.

### Custom Commands

Projects often contain unique services and features. `so` allows developers to create custom commands on a per-project basis, extending `so`'s core commands that can run on the host computer or inside one of the project's docker service containers.

> All custom commands are prefixed with "project:", if a project has custom commands, cd into the project folder and run `so` and they will appear in the command list.
#### Usage

A custom command is added to a project's `squareone.yml` file, the configuration is as follows:

> The "signature" for the command is a Laravel command signature, see the [docs](https://laravel.com/docs/8.x/artisan#defining-input-expectations) for examples. Arguments and options are then passed on to the command when its executed.
```yaml
commands:
listdir: # The command key
signature: 'listdir {file : The filename to output to} {--color=}' # The Laravel command signature.
service: php-fpm # Optional: The docker compose service to run the command in. If left blank, runs on the host machine
description: Outputs a directory listing to a file # Appears in the output if you run "so" in the project folder
cmd: ls -al # The actual command that is run. The arguments and options from the signature above are passed to the end of this.
user: squareone # Optional: the user to run as in the container. You could pass "root" for more permissions.
tty: true # Optional: Allocate a pseudo-TTY, via docker exec
interactive: true # Optional: Keep STDIN open even if not attached, via docker exec
env: # Environment variables to pass to docker compose
VAR1: value1
VAR2: value2
# A second command
whoami:
signature: whoami
service: php-fpm
description: Shows which user I am in the FPM docker container
cmd: whoami
# More commands here...
```

#### Running a Sequence of Commands

You can create a single `so` custom command to run a sequence of commands. If you specify the service, all commands will be run in that service, but if you leave it out, you can set the service as the yaml key on the command level.

```yaml
commands:
printenv:
signature: printenv
description: Displays environment variables from multiple locations
cmd: # Run a sequence of commands in different containers
- php-fpm: printenv # Runs in the php-fpm container
- php-tests: printenv # Runs in the php-tests container
- printenv # Runs on the host machine

```
Run `so project:printenv` in the project folder.

#### Example Custom Commands

**Index ElasticPress**

If your project uses ElasticPress and you want to provide a simple way to fully re-index the data:

```yaml
commands:
index-es:
signature: index
service: php-fpm
description: Re-index ElasticPress
cmd: 'wp elasticpress index --setup'
```
Run `so project:index` in the project folder.

**Create Pass Through Commands**

Although this already exists in `so`, it's a good example of how you can create a pass through command to a docker service:

```yaml
commands:
wp:
signature: 'wp {args?* : arguments and options passed to wp}'
service: php-fpm
description: 'Runs a WP CLI command'
cmd: 'wp'
```
Run `so project:wp option get home` in the project folder.

**Reload PHP in the FPM container**

Reloads PHP inside the container. Note the user must be "root" to perform this action.

```yaml
commands:
reload:
signature: reload
service: php-fpm
user: root
cmd:
- 'kill -USR2 1'
- 'echo PHP Reloaded!'
```
Run `so project:reload` in the project folder.

### Updating "so"

This tool checks for updates automatically, however this is cached for some time.

1. Check for an update (cached): `so self:update-check`.
1. Check for an uncached update: `so self:update-check --force`.
1. Update `so` to the latest version with: `so self:update`.
2. Check for an uncached update: `so self:update-check --force`.
3. Update `so` to the latest version with: `so self:update`.

### Add additional Top Level Domains (TLDs)

Expand Down
15 changes: 11 additions & 4 deletions app/Commands/LocalDocker/Composer.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,20 @@ class Composer extends BaseLocalDocker implements ArgumentRewriter {
*
* @param \App\Services\Docker\Container $container
*
* @return void
* @return int
*/
public function handle( Container $container ): void {
public function handle( Container $container ): int {
$containerId = $container->getId();

if ( empty( $containerId ) ) {
$this->error( 'Unable to find container. Has this project been started?' );
return self::EXIT_ERROR;
}

$params = [
'exec',
'--tty',
$container->getId(),
$containerId,
$this->arguments()['command'],
];

Expand All @@ -55,7 +62,7 @@ public function handle( Container $container ): void {

Artisan::call( Docker::class, $params );

$this->info( 'Done.' );
return self::EXIT_SUCCESS;
}

}
74 changes: 74 additions & 0 deletions app/Contracts/CustomCommandRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php declare(strict_types=1);

namespace App\Contracts;

use App\Services\CustomCommands\CommandDefinition;
use App\Services\Docker\Container;
use Closure;

/**
* Custom Command Runner run as a Pipeline stage.
*/
abstract class CustomCommandRunner {

/**
* The default "docker" arguments.
*
* @var string[]
*/
protected $execArgs = [
'exec',
];

/**
* @var \App\Services\Docker\Container
*/
protected $container;

public function __construct( Container $container ) {
$this->container = $container;
}

/**
* Configure a command before execution.
*
* @param \App\Services\CustomCommands\CommandDefinition $command
* @param \Closure $next
*/
public function run( CommandDefinition $command, Closure $next ) {
$this->execArgs = array_merge( $this->execArgs, array_filter( [
$command->interactive ? '--interactive' : '',
$command->tty ? '--tty' : '',
'--user',
$command->user,
] ) );

// Add environment variables to pass to the container
if ( ! empty( $command->env ) ) {
foreach ( $command->env as $var => $value ) {

// Support hyphen yaml syntax, e.g. - VAR: value
if ( is_array( $value ) ) {
foreach ( $value as $subVar => $subValue ) {
$this->execArgs[] = '--env';
$this->execArgs[] = "$subVar=$subValue";
}
} else {
$this->execArgs[] = '--env';
$this->execArgs[] = "$var=$value";
}
}
}

return $this->execute( $command, $next );
}

/**
* Execute the pipe in the pipeline.
*
* @param \App\Services\CustomCommands\CommandDefinition $command
* @param \Closure $next
*/
abstract protected function execute( CommandDefinition $command, Closure $next );

}
2 changes: 1 addition & 1 deletion app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?php
<?php declare(strict_types=1);

namespace App\Providers;

Expand Down
50 changes: 50 additions & 0 deletions app/Providers/CustomCommandsServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php declare(strict_types=1);

namespace App\Providers;

use App\Services\CustomCommands\CommandCollection;
use App\Services\CustomCommands\CommandFactory;
use App\Services\CustomCommands\CommandLoader;
use App\Services\CustomCommands\Runners\HostCommandRunner;
use App\Services\CustomCommands\Runners\MultiCommandRunner;
use App\Services\CustomCommands\Runners\RunnerCollection;
use App\Services\CustomCommands\Runners\ServiceCommandRunner;
use Illuminate\Pipeline\Pipeline;
use Illuminate\Support\ServiceProvider;

/**
* Custom command service.
*/
class CustomCommandsServiceProvider extends ServiceProvider {

public function register(): void {
$this->app->bind(
\Illuminate\Contracts\Pipeline\Pipeline::class,
Pipeline::class
);

// The command runner pipes for the pipeline
$this->app->bind( RunnerCollection::class, function () {
return new RunnerCollection( [
MultiCommandRunner::class,
HostCommandRunner::class,
ServiceCommandRunner::class,
] );
} );

// Custom commands from a project's squareone.yml
$this->app->when( CommandFactory::class )
->needs( '$commands' )
->give( config( 'squareone.commands', [] ) );

$this->app->when( CommandLoader::class )
->needs( CommandCollection::class )
->give( function () {
return $this->app->get( CommandFactory::class )->make();
} );

// Register custom commands
$this->app->make( CommandLoader::class )->register();
}

}
32 changes: 32 additions & 0 deletions app/Services/CustomCommands/ClosureCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php declare(strict_types=1);

namespace App\Services\CustomCommands;

use Illuminate\Foundation\Console\ClosureCommand as ConsoleClosureCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class ClosureCommand extends ConsoleClosureCommand {

/**
* Overload the existing execute method and pass all inputs to the
* callback.
*
* @param \Symfony\Component\Console\Input\InputInterface $input
* @param \Symfony\Component\Console\Output\OutputInterface $output
*
* @return int
*/
protected function execute( InputInterface $input, OutputInterface $output ): int {
$inputs = array_merge( $input->getArguments(), $input->getOptions() );

// Sometimes we're receiving a duplicated command name at index 0.
if ( isset( $inputs[0] ) ) {
unset( $inputs[0] );
}

return (int) $this->laravel->call(
$this->callback->bindTo( $this, $this ), $inputs
);
}
}
16 changes: 16 additions & 0 deletions app/Services/CustomCommands/CommandCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php declare(strict_types=1);

namespace App\Services\CustomCommands;

use Spatie\DataTransferObject\DataTransferObjectCollection;

/**
* A collection of custom command definitions.
*/
class CommandCollection extends DataTransferObjectCollection {

public function current(): CommandDefinition {
return parent::current();
}

}
Loading

0 comments on commit 96c30e7

Please sign in to comment.