⚠️ Warning: This is a journal, meaning it's not polished or edited like regular blog posts. You can follow along with these unstructured thoughts as I build Rafter. I'll probably follow them up with nicer, succinct blog posts in the future.
This is heavily inspired by Laravel Vapor, where you can fire off a command to a worker service. It hooks into framework-specific CLI utilities like php artisan
for Laravel, rails
for Rails, and wp
for WordPress.
Command
model, which is then dispatched via HTTP to the worker serviceApplicationDefaultCredentials
class to take care of all the messy JWT signing. All you have to do is pass an audience
parameter (which, in this case, is the URL of the worker Cloud Run service), and you get an OIDC token ✨.Let’s start with the Laravel Rafter Core integration, since that’s the easiest. In the service provider for the Laravel package, I register the worker-specific routes:
Route::group(['middleware' => [VerifyGoogleOidcToken::class, EnsureRafterWorker::class]], function () {
Route::post(Rafter::QUEUE_ROUTE, 'Rafter\Http\Controllers\RafterQueueWorkerController');
Route::post(Rafter::SCHEDULE_ROUTE, 'Rafter\Http\Controllers\RafterScheduleRunController');
});
VerifyGoogleOidcToken
middleware is custom-built to verify an incoming request contains the proper Authorization header…Anyway, carrying on. I’m adding a new route to the Rafter
class:
class Rafter
{
const QUEUE_ROUTE = '/_rafter/queue/work';
const SCHEDULE_ROUTE = '/_rafter/schedule/run';
const COMMAND_ROUTE = '/_rafter/command/run';
As well as a new route in the service provider, pointing to a new RafterCommandRunController
:
namespace Rafter\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Artisan;
class RafterCommandRunController extends Controller
{
public function __invoke(Request $request)
{
Artisan::call($request->command);
return Artisan::output();
}
}
It’s pretty straightforward:
Artisan
Facade to call a given command that is passed in through a request parameterArtisan
facade.Time to test this baby out! I have a Rafter Laravel Example project locally that I’ll use.
composer-link
bash alias to mount the local package into the project:composer-link ../laravel-rafter-core
dev-{branch}
e.g. dev-commands
as the version name, and then run composer update
. Thanks to this blog post!And my curl test worked great 🎉:
curl -d '{"command": "list"}' -H "Content-Type: application/json" -X POST http://rafter-example-laravel.test/_rafter/command/run
Laravel Framework 7.9.2
Usage:
command [options] [arguments]
Options:
-h, --help Display this help message
-q, --quiet Do not output any message
-V, --version Display this application version
--ansi Force ANSI output
--no-ansi Disable ANSI output
-n, --no-interaction Do not ask any interactive question
--env[=ENV] The environment the command should run under
-v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
Available commands:
clear-compiled Remove the compiled class file
...
⚡️ I’ve opened a PR for the Laravel Core Package here.
Next, let’s focus on the Rafter integration. This will be a little more work:
environments.show
partial:projects.environments.commands.index
? I’m really not digging these nested paths. I’m not married to this setup. I’m also not a huge fan of having to create a controller and a view just to render a couple lines of a component, probably Livewire.Meh, I’ll do an inline function:
// Commands
Route::get('projects/{project}/environments/{environment}/commands', function (\App\Project $project, \App\Environment $environment) {
return view('environments.commands', [
'project' => $project,
'environment' => $environment,
]);
})
->name('projects.environments.commands.index');
I hate it already. Onward!
CommandsList
a Livewire component 😈php artisan make:livewire CommandsList
Then let’s try simplifying the route:
Route::livewire('projects/{project}/environments/{environment}/commands', 'commands-list')
->layout('components.environment')
->name('projects.environments.commands.index');
@yield('content')
. I’m using a newer component-based layout approach, using {{ $slot }}
.Here’s environments/commands.blade.php
<x-layout>
<x-environment :project="$project" :environment="$environment">
<x-subtitle>Commands</x-subtitle>
<livewire:commands-list :environment="$environment" />
</x-environment>
</x-layout>
And the CommandsList.php
Livewire component:
<?php
namespace App\Http\Livewire;
use App\Environment;
use Livewire\Component;
class CommandsList extends Component
{
public $environment;
public function mount(Environment $environment)
{
$this->environment = $environment;
}
public function render()
{
return view('livewire.commands-list');
}
}
And here’s some inital UI, stubbed out with fake data:
I dig it so far!
show
page with the results.Several hours later…
DispatchCommand
job.commands.show
page. This will be another Livewire component which polls for updates to the output.OK - with these plans in mind, let’s start in on a new view for New Commands.
php artisan
for the user alongside the input.commands.show
.~ NOPE… let’s just add the input to the top of commands.index
for simplicity.At the top of my existing CommandsList
Livewire component, I’ve added a new form:
<div class="mb-4">
<form wire:submit.prevent="runCommand">
<label for="command" class="sr-only">
Run a new command
</label>
<div class="mt-1 flex rounded-l-md shadow-sm">
<span
class="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 border-r-0"
>
php artisan
</span>
<input
wire:model="newCommand"
id="command"
class="form-input flex-1 block w-full px-3 py-2 rounded-none sm:leading-5 autofocus"
placeholder="command"
/>
<span class="inline-flex rounded-r-md shadow-sm">
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent leading-5 font-medium rounded-r-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition ease-in-out duration-150"
>
Run
</button>
</span>
</div>
</form>
</div>
Looks pretty spiffy:
Command
model, controller, and other fun stuff:php artisan make:model Command -a
Here’s my migration:
Schema::create('commands', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('environment_id')->index();
$table->unsignedBigInteger('user_id');
$table->string('command');
$table->longText('output')->nullable();
$table->string('status')->default('pending');
$table->timestamps();
$table->foreign('environment_id')
->references('id')->on('environments')
->onDelete('cascade');
});
And let’s generate a CommandOutput
Livewire component:
php artisan make:livewire CommandOutput
CommandOutput.php
is going to accept the Command
as its only argument:
<?php
namespace App\Http\Livewire;
use App\Command;
use Livewire\Component;
class CommandOutput extends Component
{
public $command;
public function mount(Command $command)
{
$this->command = $command;
}
public function render()
{
return view('livewire.command-output');
}
}
Let’s add a view, environments.commands.show
, to house the new component. Something like this:
<x-layout>
<x-environment :project="$project" :environment="$environment">
<x-subtitle>Command: <code>{{ $command->command }}</code></x-subtitle>
<livewire:command-output :command="$command" />
</x-environment>
</x-layout>
Now let’s chat about the flow for actually creating and dispatching a command:
runCommand
method, which creates a new Command
model, and fires the $command->dispatch()
method on the model.commands.show
which will show the CommandOutput
component. It will poll for the status and display the output when ready.$this->markPending()
, and dispatches a new DispatchCommand
job.command
payload of the current Command
.$command->update([ 'output' => $responseBodyText ])
$command->markFinished()
. We also want to handle any error cases, designating with $command->markFailed()
.So all of these things are pretty straightforward, except for this:
Inside the job, a signed request is made to the worker URL with the
command
payload of the currentCommand
.
It’s gonna involve several moving pieces. Let’s start from the top, though.
Moving back to our creation form, let’s define some logic inside the runCommand
method:
public function runCommand()
{
$this->authorize('update', $this->environment);
$this->validate([
'command' => ['required', 'string'],
]);
$command = $this->environment->commands()->create([
'command' => $this->command,
'user_id' => request()->user()->id,
]);
$command->dispatch();
return redirect()->route('projects.environments.commands.show', [
$this->environment->project,
$this->environment,
$command
]);
}
Gosh I really dislike these nested routes. I might change them sometime to be flat.
Cool. Time to run my migrations and… try this out?
💥 Issue: Mass-assignment. Sigh.
Let’s update our Command
model to remove mass-assignment protection and add some helper methods while we’re at it:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Command extends Model
{
const STATUS_PENDING = 'pending';
const STATUS_RUNNING = 'running';
const STATUS_FINISHED = 'finished';
const STATUS_FAILED = 'failed';
protected $guarded = [];
public function dispatch()
{
# code...
}
public function markRunning()
{
$this->update(['status' => static::STATUS_RUNNING]);
}
public function markFinished(string $output)
{
$this->update([
'status' => static::STATUS_FINISHED,
'output' => $output,
]);
}
public function markFailed(string $output)
{
$this->update([
'status' => static::STATUS_FAILED,
'output' => $output,
]);
}
}
✨OK my redirect is working at least. I’ll move on to new steps tomorrow 🌄.
Good morning! Let’s keep going.
command.show
layout, since right now it’s blank.<div>
<div class="flex justify-between items-center mb-4">
<span>
{{ $this->label }} {{ $command->updated_at->diffForHumans() }} @if
($command->isFinished()) - {{ $command->elapsedTime() }} @endif
</span>
<span>
<x-status :status="$command->status" />
</span>
</div>
<div class="font-mono p-4 text-sm bg-white">{{ $command->output }}</div>
</div>
label
so we can say “Ran 10s ago” or “Started running 1s ago”, etcpublic function getLabelProperty(): string
{
if ($this->command->isRunning()) {
return 'Started running';
} elseif ($this->command->isFinished()) {
return 'Ran';
} elseif ($this->command->isFailed()) {
return 'Failed';
}
return 'Created';
}
$command->elapsedTime()
to get the total runtime of the command.public function elapsedTime(): string
{
return $this->updated_at->longAbsoluteDiffForHumans($this->created_at);
}
🎉 I did a livestream of the next part! You can watch it on YouTube.
So I created a new method on the Command
model:
public function runCommandOnWorker(): string
{
$workerUrl = $this->environment->worker_url . '/_rafter/command/run';
$jsonKey = $this->environment->project->googleProject->service_account_json;
/**
* Google helps us out by creating a middleware to sign the outgoing request to the
* worker service with an OIDC token based on the audience (which is the $workerUrl).
*/
$creds = new ServiceAccountCredentials(null, $jsonKey, null, $workerUrl);
$middleware = new AuthTokenMiddleware($creds);
$stack = HandlerStack::create();
$stack->push($middleware);
$client = new Client([
'handler' => $stack,
'auth' => 'google_auth'
]);
try {
$response = $client->post($workerUrl, [
'form_params' => [
'command' => $this->command,
],
]);
$output = $response->getBody()->getContents();
$this->markFinished($output);
return $output;
} catch (Exception $e) {
$this->markFailed($e->getMessage());
}
}
CallMethodOnWorker
, once I need to use it in multiple places.php artisan make:job DispatchCommand
Here’s what that looks like:
<?php
namespace App\Jobs;
use App\Command;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class DispatchCommand implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $command;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Command $command)
{
$this->command = $command;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$this->command->markRunning();
$this->command->runCommandOnWorker();
}
}
And here’s how I’m dispatching it inside Command
:
public function dispatch()
{
DispatchCommand::dispatch($this);
}
Let’s… see how it works? I’m gonna record the first attempt. No polish on this: you’ll see the real thing!
🎉🎉🎉
Nice. I had to manually refresh, because I forgot I haven’t added live polling yet.
Let’s go ahead and add live polling by adding wire:poll
to the root of the component:
<div wire:poll>
<div class="flex justify-between items-center mb-4">
<span>
{{ $this->label }} {{ $command->updated_at->diffForHumans() }} @if
($command->isFinished()) - {{ $command->elapsedTime() }} @endif
</span>
<span>
<x-status :status="$command->status" />
</span>
</div>
<div class="font-mono p-4 text-sm bg-white">
<pre>{{ $command->output }}</pre>
</div>
</div>
Easy as that!
livewire:poll
using Blade directives, but it looks like the component keeps trying to fetch a new partial at the poll interval after it’s mounted, regardless of whether the new partial has the poll directive.⚠️ I’ve now come across some things we should tidy up:
laravel-rafter-core
result in 500 errors. We probably do not want this; rather, we want to catch the errors and return them in a 200 response.CommandPolicy
to ensure rando users cannot view other users’ commands.output
as the stream comes back?Let’s start with the command cleanup. Inside the RafterCommandRunController
:
public function __invoke(Request $request)
{
try {
Artisan::call($request->command);
return Artisan::output();
} catch (Exception $e) {
return $e->getMessage();
}
}
Now, let’s add a policy and a test to authorize the Command routes:
php artisan make:test CommandControllerTest
In this test, we’ll create a few commands for a given user’s environment, and then an additional command that doesn’t belong to the user. We’ll assert that the user can only see their commands:
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
class CommandControllerTest extends TestCase
{
use RefreshDatabase;
protected $user;
protected $other;
public function setUp(): void
{
parent::setUp();
$this->user = factory('App\User')->create();
$this->other = factory('App\Command')->create();
}
public function test_user_can_view_their_commands()
{
$project = factory('App\Project')->create([
'team_id' => $this->user->currentTeam->id,
]);
$environment = factory('App\Environment')->create([
'project_id' => $project->id,
]);
$commands = factory('App\Command', 3)->create([
'environment_id' => $environment->id,
'user_id' => $this->user->id,
]);
// Also ensure command created by another user on the same team is visible
$otherTeamMember = factory('App\User')->create();
$this->user->currentTeam->users()->attach($otherTeamMember);
$otherCommandOnTeam = factory('App\Command')->create([
'environment_id' => $environment->id,
'user_id' => $otherTeamMember->id,
]);
$this->user->currentTeam->refresh();
$response = $this->actingAs($this->user)
->get(route('projects.environments.commands.index', [$project, $environment]))
->assertSuccessful();
$commands->each(function ($command) use ($response) {
$response->assertSee($command->command);
});
$response->assertSee($otherCommandOnTeam->command);
$response->assertDontSee($this->other->command);
$this->get(route('projects.environments.commands.show', [$project, $environment, $commands->first()]))
->assertSuccessful();
$this->get(route('projects.environments.commands.show', [
$this->other->environment->project,
$this->other->environment,
$this->other
]))
->assertForbidden();
}
}
Of course, we’ll need to update the Command factory with some real stuff first:
use App\Command;
use Faker\Generator as Faker;
$factory->define(Command::class, function (Faker $faker) {
return [
'command' => 'route:list',
'user_id' => factory('App\User'),
'environment_id' => factory('App\Environment'),
'status' => 'pending',
];
});
Now let’s run the test and see what we get!
We got one failing test, here:
$ php artisan test
There was 1 failure:
1) Tests\Feature\CommandControllerTest::test_user_can_view_their_commands
Response status code [200] is not a forbidden status code.
Failed asserting that false is true.
This means that the user was able to view a different user’s commands. Let’s fix that using a Policy:
php artisan make:policy CommandPolicy -m Command
And let’s define a policy for the viewAny
and view
methods, since those will correspond to the index
and show
controller methods:
/**
* Determine whether the user can view any commands.
*
* @param \App\User $user
* @return mixed
*/
public function viewAny(User $user)
{
return true;
}
/**
* Determine whether the user can view the command.
*
* @param \App\User $user
* @param \App\Command $command
* @return mixed
*/
public function view(User $user, Command $command)
{
return $user->currentTeam->is($command->environment->project->team);
}
Finally, let’s instruct the CommandController
to authorize its methods against a policy in the constructor:
public function __construct()
{
$this->authorizeResource('App\Command');
}
Cool! Running the test again… and everything passes! 🎉
One final thing I want to do before shipping: DRY up the php artisan
prefix.
rails
or wp
etc.Project.php
:/**
* Get the prefix for running Commands for a given project type.
*
* @return string
*/
public function commandPrefix(): string
{
if ($this->type == 'laravel') {
return 'php artisan';
}
return '';
}
ProjectType
eventually.Project
:public function usesCommands(): bool
{
return $this->type == 'laravel';
}
Thanks for following along 😀 I’ll try to write up a proper blog post soon.
A blog about building Rafter, a serverless deployment platform. Posts by Josh Larson. Follow Josh on Twitter for updates.