diff --git a/app/Actions/LineBreaksToArray.php b/app/Actions/LineBreaksToArray.php new file mode 100644 index 0000000..e1f4373 --- /dev/null +++ b/app/Actions/LineBreaksToArray.php @@ -0,0 +1,23 @@ +explode("\n") + ->map(Str::squish(...)) + ->filter() + ->values() + ->all(); + } +} diff --git a/app/Actions/ParseDaemonCommands.php b/app/Actions/ParseDaemonCommands.php new file mode 100644 index 0000000..7698bb1 --- /dev/null +++ b/app/Actions/ParseDaemonCommands.php @@ -0,0 +1,44 @@ +definition($this->signature); + + return array_map(function (string $command) use ($definition) { + $input = $this->input($command, $definition); + + if (blank($input->getArgument('command'))) { + $this->failCommand("No command was specified for daemon creation: {$command}"); + + return []; + } + + return array_filter([ + 'command' => implode(' ', $input->getArgument('command')), + ...$input->getOptions(), + ], fn ($value) => ! is_null($value)); + }, $commands); + } +} diff --git a/app/Actions/ParseQueueCommands.php b/app/Actions/ParseQueueCommands.php index 53e7afc..61dfff2 100644 --- a/app/Actions/ParseQueueCommands.php +++ b/app/Actions/ParseQueueCommands.php @@ -2,16 +2,16 @@ namespace App\Actions; +use App\Services\Syntax\CommandParser; +use App\Traits\CommandSyntax; use App\Traits\Outputifier; -use Illuminate\Console\Parser; use Lorisleiva\Actions\Concerns\AsAction; -use Symfony\Component\Console\Input\InputDefinition; -use Symfony\Component\Console\Input\StringInput; class ParseQueueCommands { use AsAction, - Outputifier; + Outputifier, + CommandSyntax; protected string $signature = '{connection : The name of the queue connection to work} {--queue= : The names of the queues to work} @@ -30,11 +30,10 @@ class ParseQueueCommands public function handle(array $commands): array { - $definition = $this->definition(); + $definition = $this->definition($this->signature); - return array_map(function ($command) use ($definition) { - $input = new StringInput($command); - $input->bind($definition); + return array_map(function (string $command) use ($definition) { + $input = $this->input($command, $definition); if (blank($input->getArgument('connection'))) { $this->failCommand("No queue connection was specified for command: {$command}"); @@ -48,15 +47,4 @@ public function handle(array $commands): array ], fn ($value) => ! is_null($value)); }, $commands); } - - protected function definition(): InputDefinition - { - [, $arguments, $options] = Parser::parse($this->signature); - - $definition = new InputDefinition(); - $definition->setArguments($arguments); - $definition->setOptions($options); - - return $definition; - } } diff --git a/app/Commands/ProvisionCommand.php b/app/Commands/ProvisionCommand.php index 2788d35..0312ea3 100644 --- a/app/Commands/ProvisionCommand.php +++ b/app/Commands/ProvisionCommand.php @@ -15,6 +15,7 @@ use App\Services\Forge\ForgeService; use App\Services\Forge\Pipeline\AnnounceSiteOnSlack; +use App\Services\Forge\Pipeline\CreateDaemons; use App\Services\Forge\Pipeline\CreateDatabase; use App\Services\Forge\Pipeline\CreateQueueWorkers; use App\Services\Forge\Pipeline\CreateWebhook; @@ -62,6 +63,7 @@ public function handle(ForgeService $service): void CreateWebhook::class, RunOptionalCommands::class, EnsureJobScheduled::class, + CreateDaemons::class, CreateQueueWorkers::class, PutCommentOnPullRequest::class, AnnounceSiteOnSlack::class, diff --git a/app/Commands/TearDownCommand.php b/app/Commands/TearDownCommand.php index 857ed4c..083bda6 100644 --- a/app/Commands/TearDownCommand.php +++ b/app/Commands/TearDownCommand.php @@ -17,6 +17,7 @@ use App\Services\Forge\Pipeline\DestroySite; use App\Services\Forge\Pipeline\FindServer; use App\Services\Forge\Pipeline\FindSiteOrFail; +use App\Services\Forge\Pipeline\RemoveDaemons; use App\Services\Forge\Pipeline\RemoveDatabaseUser; use App\Services\Forge\Pipeline\RemoveExistingDeployKey; use App\Services\Forge\Pipeline\RemoveInertiaSupport; @@ -43,6 +44,7 @@ public function handle(ForgeService $service): void RunOptionalCommands::class, RemoveDatabaseUser::class, RemoveExistingDeployKey::class, + RemoveDaemons::class, DestroySite::class, ]) ->then(fn () => $this->success('Environment teardown successful! All provisioned resources have been removed.')); diff --git a/app/Services/Forge/ForgeService.php b/app/Services/Forge/ForgeService.php index 74ea564..b76e3a6 100644 --- a/app/Services/Forge/ForgeService.php +++ b/app/Services/Forge/ForgeService.php @@ -156,6 +156,9 @@ public function getSiteLink(): string public function siteDirectory(): string { - return sprintf('/home/%s/%s', $this->site->username, $this->site->name); + return Str::chopEnd( + subject: $this->site->attributes['web_directory'], + needle: $this->site->directory // usually only contains /public + ); } } diff --git a/app/Services/Forge/ForgeSetting.php b/app/Services/Forge/ForgeSetting.php index afa3c6d..ab03b51 100644 --- a/app/Services/Forge/ForgeSetting.php +++ b/app/Services/Forge/ForgeSetting.php @@ -214,6 +214,11 @@ class ForgeSetting */ public ?string $queueWorkers; + /** + * The daemons to create on new site installation + */ + public ?string $daemons; + public function __construct() { $this->init(config('forge')); @@ -271,6 +276,7 @@ protected function validate(array $configurations): \Illuminate\Validation\Valid 'inertia_ssr_enabled' => ['required', 'boolean'], 'github_create_deploy_key' => ['required', 'boolean'], 'queue_workers' => ['nullable', 'string'], + 'daemons' => ['nullable', 'string'], ])->sometimes('git_provider', 'in:custom', function (Fluent $input) { return $input->github_create_deploy_key === true; }); diff --git a/app/Services/Forge/Pipeline/CreateDaemons.php b/app/Services/Forge/Pipeline/CreateDaemons.php new file mode 100644 index 0000000..cf386ac --- /dev/null +++ b/app/Services/Forge/Pipeline/CreateDaemons.php @@ -0,0 +1,45 @@ +setting->daemons || ! $service->siteNewlyMade) { + return $next($service); + } + + $daemons = ParseDaemonCommands::run( + LineBreaksToArray::run($service->setting->daemons), + ); + + $this->information('Creating daemons.'); + + foreach ($daemons as $daemon) { + $service->forge->createDaemon( + serverId: $service->server->id, + data: array_merge( + [ + // Defaults a daemon to run under the same user and directory as the current site. + 'user' => $service->site->username, + 'directory' => $service->siteDirectory(), + ], + $daemon + ) + ); + } + + return $next($service); + } +} diff --git a/app/Services/Forge/Pipeline/CreateQueueWorkers.php b/app/Services/Forge/Pipeline/CreateQueueWorkers.php index 075a3ef..1caa955 100644 --- a/app/Services/Forge/Pipeline/CreateQueueWorkers.php +++ b/app/Services/Forge/Pipeline/CreateQueueWorkers.php @@ -4,11 +4,11 @@ namespace App\Services\Forge\Pipeline; +use App\Actions\LineBreaksToArray; use App\Actions\ParseQueueCommands; use App\Services\Forge\ForgeService; use App\Traits\Outputifier; use Closure; -use Illuminate\Support\Str; class CreateQueueWorkers { @@ -21,12 +21,7 @@ public function __invoke(ForgeService $service, Closure $next) } $workers = ParseQueueCommands::run( - str($service->setting->queueWorkers) - ->explode("\n") - ->map(Str::squish(...)) - ->filter() - ->values() - ->all() + LineBreaksToArray::run($service->setting->queueWorkers), ); $this->information('Creating queue workers.'); diff --git a/app/Services/Forge/Pipeline/RemoveDaemons.php b/app/Services/Forge/Pipeline/RemoveDaemons.php new file mode 100644 index 0000000..dbbaf49 --- /dev/null +++ b/app/Services/Forge/Pipeline/RemoveDaemons.php @@ -0,0 +1,72 @@ +forge->daemons($service->server->id); + + $this->information('Deleting daemons'); + + foreach ($daemons as $daemon) { + // If the daemon is running under the same user as the site in user isolation mode. + if ($service->setting->siteIsolationRequired && $daemon->user === $service->site->username) { + $service->forge->deleteDaemon($service->server->id, $daemon->id); + $this->information("--> Deleted daemon under user: {$daemon->command}"); + + continue; + } + + // If the daemon is running from the same directory as the site. + if (Str::contains(haystack: $daemon->directory, needles: $service->siteDirectory())) { + $service->forge->deleteDaemon($service->server->id, $daemon->id); + $this->information("--> Deleted daemon under directory: {$daemon->command}"); + + continue; + } + + // If a daemon can be detected as being one that was configured by us. + if ($this->daemonWasAddedForThisSite($daemon, $service->setting)) { + $service->forge->deleteDaemon($service->server->id, $daemon->id); + $this->information("--> Deleted daemon created with site: {$daemon->command}"); + } + } + + return $next($service); + } + + protected function daemonWasAddedForThisSite(Daemon $daemon, ForgeSetting $setting): bool + { + if (empty($setting->daemons)) { + return false; + } + + $configuredDaemons = ParseDaemonCommands::run( + LineBreaksToArray::run($setting->daemons), + ); + + return collect($configuredDaemons) + ->whereNotNull('user') + ->whereNotNull('directory') + ->contains( + fn (array $configuredDaemon) => $configuredDaemon['command'] === $daemon->command + && $configuredDaemon['user'] === $daemon->user + && $configuredDaemon['directory'] === $daemon->directory + ); + } +} diff --git a/app/Traits/CommandSyntax.php b/app/Traits/CommandSyntax.php new file mode 100644 index 0000000..9bc9b4a --- /dev/null +++ b/app/Traits/CommandSyntax.php @@ -0,0 +1,29 @@ +bind($definition); + + return $input; + } + + protected function definition(string $signature): InputDefinition + { + [, $arguments, $options] = Parser::parse($signature); + + $definition = new InputDefinition(); + $definition->setArguments($arguments); + $definition->setOptions($options); + + return $definition; + } +} diff --git a/config/forge.php b/config/forge.php index e1b6dc4..4b25727 100644 --- a/config/forge.php +++ b/config/forge.php @@ -111,4 +111,7 @@ // The queue workers to be added to Forge site 'queue_workers' => env('FORGE_QUEUE_WORKERS'), + + // The daemons to create on a Forge server + 'daemons' => env('FORGE_DAEMONS'), ]; diff --git a/tests/Feature/Actions/LineBreaksToArrayTest.php b/tests/Feature/Actions/LineBreaksToArrayTest.php new file mode 100644 index 0000000..9d6d007 --- /dev/null +++ b/tests/Feature/Actions/LineBreaksToArrayTest.php @@ -0,0 +1,42 @@ +toBe($expected); +}) + ->with([ + [ + "First line\nSecond line", + [ + 'First line', + 'Second line', + ], + ], + [ + "\nFirst line\n\nSecond line\n", + [ + 'First line', + 'Second line', + ], + ], + [ + " \nOnly 2 lines\n3rd line has invisible space\n‎ ", + [ + 'Only 2 lines', + '3rd line has invisible space', + ], + ], + [ + null, + [], + ], + [ + '', + [], + ], + ]); diff --git a/tests/Feature/Actions/ParseDaemonCommandsTest.php b/tests/Feature/Actions/ParseDaemonCommandsTest.php new file mode 100644 index 0000000..d283cc3 --- /dev/null +++ b/tests/Feature/Actions/ParseDaemonCommandsTest.php @@ -0,0 +1,64 @@ +toBe($expected); +}) + ->with([ + [ + [ + 'php artisan horizon', + 'custom-script --directory=/usr/local/bin', + ], + [ + [ + 'command' => 'php artisan horizon', + 'processes' => '1', + 'stopwaitsecs' => '10', + 'stopsignal' => 'SIGTERM', + ], + [ + 'command' => 'custom-script', + 'directory' => '/usr/local/bin', + 'processes' => '1', + 'stopwaitsecs' => '10', + 'stopsignal' => 'SIGTERM', + ], + ], + ], + [ + [ + "'script-with-parameters -e --foo=bar' --directory=/path/to/daemon --user=root --processes=2 --startsecs=10 --stopwaitsecs=30 --stopsignal=SIGKILL", + ], + [ + [ + 'command' => 'script-with-parameters -e --foo=bar', + 'directory' => '/path/to/daemon', + 'user' => 'root', + 'processes' => '2', + 'startsecs' => '10', + 'stopwaitsecs' => '30', + 'stopsignal' => 'SIGKILL', + ], + ], + ], + ]); + +it('shows failure message if command is not specified', function () { + renderUsing($output = new BufferedOutput()); + + ParseDaemonCommands::run(['--user=anthony']); + + expect($output->fetch()) + ->toContain('FAIL') + ->toContain('No command was specified for daemon creation: --user=anthony'); + + renderUsing(null); +});