Implement Webhooks (#548)

* feat: First Webhook PoC draft

* feat: Dispatch Webhooks PoC

* fix: typo in webhook configuration scope

* Update 2024_04_21_162552_create_webhooks_table.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update 2024_04_21_162552_create_webhooks_table.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update 2024_04_21_162544_create_webhook_configurations_table.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update 2024_04_21_162544_create_webhook_configurations_table.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhooks.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhooksJob.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhookForConfiguration.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhookForConfiguration.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhookForConfiguration.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhooksJob.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhooksJob.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhooksJob.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* chore: Implement Webhook Event Discovery

* we got a test working for webhooks

* WIP

* Something is working!

* More tests

* clean up the tests now that they are passing

* WIP

* Don't use model specific events

* WIP

* WIP

* WIP

* WIP

* WIP

* Do it sync

* Reset these

* Don't need restored event type

* Deleted some unused jobs

* Find custom Events

* Remove observers

* Add custom event test

* Run Pint

* Add caching

* Don't cache every single event

* Fix tests

* Run Pint

* Phpstan fixes

* Pint fix

* Test fixes

* Middleware unit test fix

* Pint fixes

* Remove index not working for older dbs

* Use facade instead

---------

Co-authored-by: Pascale Beier <mail@pascalebeier.de>
Co-authored-by: Lance Pioch <lancepioch@gmail.com>
Co-authored-by: Vehikl <go@vehikl.com>
This commit is contained in:
Colin DeCarlo 2024-10-26 20:35:25 -04:00 committed by GitHub
parent 5f77deb1fd
commit 86c369d7ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 781 additions and 536 deletions

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Created extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Creating extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Deleted extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Deleting extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Saved extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Saving extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Updated extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Updating extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Subuser;
use App\Events\Event;
use App\Models\Subuser;
use Illuminate\Queue\SerializesModels;
class Created extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Subuser $subuser)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Subuser;
use App\Events\Event;
use App\Models\Subuser;
use Illuminate\Queue\SerializesModels;
class Creating extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Subuser $subuser)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Subuser;
use App\Events\Event;
use App\Models\Subuser;
use Illuminate\Queue\SerializesModels;
class Deleted extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Subuser $subuser)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\Subuser;
use App\Events\Event;
use App\Models\Subuser;
use Illuminate\Queue\SerializesModels;
class Deleting extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Subuser $subuser)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\User;
use App\Models\User;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class Created extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\User;
use App\Models\User;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class Creating extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\User;
use App\Models\User;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class Deleted extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user)
{
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Events\User;
use App\Models\User;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class Deleting extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user)
{
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\WebhookResource\Pages;
use App\Models\WebhookConfiguration;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class WebhookResource extends Resource
{
protected static ?string $model = WebhookConfiguration::class;
protected static ?string $navigationIcon = 'tabler-webhook';
protected static ?string $label = 'Webhooks';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('endpoint')->activeUrl()->required(),
Forms\Components\TextInput::make('description')->nullable(),
Forms\Components\CheckboxList::make('events')->lazy()->options(
fn () => WebhookConfiguration::filamentCheckboxList()
)
->columns(3)
->columnSpanFull()
->gridDirection('row')
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
//
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListWebhookConfigurations::route('/'),
'create' => Pages\CreateWebhookConfiguration::route('/create'),
'edit' => Pages\EditWebhookConfiguration::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\WebhookResource\Pages;
use App\Filament\Resources\WebhookResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWebhookConfiguration extends CreateRecord
{
protected static string $resource = WebhookResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WebhookResource\Pages;
use App\Filament\Resources\WebhookResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditWebhookConfiguration extends EditRecord
{
protected static string $resource = WebhookResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WebhookResource\Pages;
use App\Filament\Resources\WebhookResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListWebhookConfigurations extends ListRecords
{
protected static string $resource = WebhookResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Models\User;
use App\Notifications\RemovedFromServer;
use Illuminate\Http\Request;
use App\Models\Server;
use Illuminate\Http\JsonResponse;
@ -144,6 +145,11 @@ class SubuserController extends ClientApiController
$log->transaction(function ($instance) use ($server, $subuser) {
$subuser->delete();
$subuser->user->notify(new RemovedFromServer([
'user' => $subuser->user->name_first,
'name' => $subuser->server->name,
]));
try {
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
} catch (DaemonConnectionException $exception) {

View File

@ -0,0 +1,40 @@
<?php
namespace App\Jobs;
use App\Models\WebhookConfiguration;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class ProcessWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private WebhookConfiguration $webhookConfiguration,
private string $eventName,
private array $data
) {
}
public function handle(): void
{
try {
Http::post($this->webhookConfiguration->endpoint, $this->data)->throw();
$successful = now();
} catch (\Exception) {
$successful = null;
}
$this->webhookConfiguration->webhooks()->create([
'payload' => $this->data,
'successful_at' => $successful,
'event' => $this->eventName,
'endpoint' => $this->webhookConfiguration->endpoint,
]);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Listeners;
use App\Jobs\ProcessWebhook;
use App\Models\WebhookConfiguration;
class DispatchWebhooks
{
public function handle(string $eventName, array $data): void
{
if (!$this->eventIsWatched($eventName)) {
return;
}
$matchingHooks = cache()->rememberForever("webhooks.$eventName", function () use ($eventName) {
return WebhookConfiguration::query()->whereJsonContains('events', $eventName)->get();
});
foreach ($matchingHooks ?? [] as $webhookConfig) {
if (in_array($eventName, $webhookConfig->events)) {
ProcessWebhook::dispatch($webhookConfig, $eventName, $data);
}
}
}
protected function eventIsWatched(string $eventName): bool
{
$watchedEvents = cache()->rememberForever('watchedWebhooks', function () {
return WebhookConfiguration::pluck('events')
->flatten()
->unique()
->values()
->all();
});
return in_array($eventName, $watchedEvents);
}
}

21
app/Models/Webhook.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Webhook extends Model
{
use HasFactory;
protected $fillable = ['payload', 'successful_at', 'event', 'endpoint'];
public function casts()
{
return [
'payload' => 'array',
'successful_at' => 'datetime',
];
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\File;
class WebhookConfiguration extends Model
{
use HasFactory;
protected $fillable = [
'endpoint',
'description',
'events',
];
protected function casts(): array
{
return [
'events' => 'json',
];
}
protected static function booted(): void
{
self::saved(static function (self $webhookConfiguration): void {
$changedEvents = collect([
...((array) $webhookConfiguration->events),
...$webhookConfiguration->getOriginal('events', '[]'),
])->unique();
$changedEvents->each(function (string $event) {
cache()->forever("webhooks.$event", WebhookConfiguration::query()->whereJsonContains('events', $event)->get());
});
cache()->forever('watchedWebhooks', WebhookConfiguration::pluck('events')->flatten()->unique()->values()->all());
});
}
public function webhooks(): HasMany
{
return $this->hasMany(Webhook::class);
}
public static function allPossibleEvents(): array
{
return static::discoverCustomEvents() + static::allModelEvents();
}
public static function filamentCheckboxList(): array
{
$list = [];
$events = static::allPossibleEvents();
foreach ($events as $event) {
$list[$event] = static::transformClassName($event);
}
return $list;
}
public static function transformClassName(string $event): string
{
return str($event)
->after('eloquent.')
->replace('App\\Models\\', '')
->replace('App\\Events\\', 'event: ')
->toString();
}
public static function allModelEvents(): array
{
$eventTypes = ['created', 'updated', 'deleted'];
$models = static::discoverModels();
$events = [];
foreach ($models as $model) {
foreach ($eventTypes as $eventType) {
$events[] = "eloquent.$eventType: $model";
}
}
return $events;
}
public static function discoverModels(): array
{
$namespace = 'App\\Models\\';
$directory = app_path('Models');
$models = [];
foreach (File::allFiles($directory) as $file) {
$models[] = $namespace . str($file->getFilename())
->replace([DIRECTORY_SEPARATOR, '.php'], ['\\', '']);
}
return $models;
}
public static function discoverCustomEvents(): array
{
$directory = app_path('Events');
$events = [];
foreach (File::allFiles($directory) as $file) {
$namespace = str($file->getPath())
->after(base_path())
->replace(DIRECTORY_SEPARATOR, '\\')
->replace('\\app\\', 'App\\')
->toString();
$events[] = $namespace . '\\' . str($file->getFilename())
->replace([DIRECTORY_SEPARATOR, '.php'], ['\\', '']);
}
return $events;
}
}

View File

@ -1,22 +0,0 @@
<?php
namespace App\Observers;
use App\Models\EggVariable;
class EggVariableObserver
{
public function creating(EggVariable $variable): void
{
if (isset($variable->field_type)) {
unset($variable->field_type);
}
}
public function updating(EggVariable $variable): void
{
if (isset($variable->field_type)) {
unset($variable->field_type);
}
}
}

View File

@ -1,76 +0,0 @@
<?php
namespace App\Observers;
use App\Events;
use App\Models\Server;
use Illuminate\Foundation\Bus\DispatchesJobs;
class ServerObserver
{
use DispatchesJobs;
/**
* Listen to the Server creating event.
*/
public function creating(Server $server): void
{
event(new Events\Server\Creating($server));
}
/**
* Listen to the Server created event.
*/
public function created(Server $server): void
{
event(new Events\Server\Created($server));
}
/**
* Listen to the Server deleting event.
*/
public function deleting(Server $server): void
{
event(new Events\Server\Deleting($server));
}
/**
* Listen to the Server deleted event.
*/
public function deleted(Server $server): void
{
event(new Events\Server\Deleted($server));
}
/**
* Listen to the Server saving event.
*/
public function saving(Server $server): void
{
event(new Events\Server\Saving($server));
}
/**
* Listen to the Server saved event.
*/
public function saved(Server $server): void
{
event(new Events\Server\Saved($server));
}
/**
* Listen to the Server updating event.
*/
public function updating(Server $server): void
{
event(new Events\Server\Updating($server));
}
/**
* Listen to the Server saved event.
*/
public function updated(Server $server): void
{
event(new Events\Server\Updated($server));
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Observers;
use App\Events;
use App\Models\Subuser;
use App\Notifications\AddedToServer;
use App\Notifications\RemovedFromServer;
class SubuserObserver
{
/**
* Listen to the Subuser creating event.
*/
public function creating(Subuser $subuser): void
{
event(new Events\Subuser\Creating($subuser));
}
/**
* Listen to the Subuser created event.
*/
public function created(Subuser $subuser): void
{
event(new Events\Subuser\Created($subuser));
$subuser->user->notify(new AddedToServer([
'user' => $subuser->user->name_first,
'name' => $subuser->server->name,
'uuid_short' => $subuser->server->uuid_short,
]));
}
/**
* Listen to the Subuser deleting event.
*/
public function deleting(Subuser $subuser): void
{
event(new Events\Subuser\Deleting($subuser));
}
/**
* Listen to the Subuser deleted event.
*/
public function deleted(Subuser $subuser): void
{
event(new Events\Subuser\Deleted($subuser));
$subuser->user->notify(new RemovedFromServer([
'user' => $subuser->user->name_first,
'name' => $subuser->server->name,
]));
}
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Observers;
use App\Events;
use App\Models\User;
class UserObserver
{
protected string $uuid;
/**
* Listen to the User creating event.
*/
public function creating(User $user): void
{
event(new Events\User\Creating($user));
}
/**
* Listen to the User created event.
*/
public function created(User $user): void
{
event(new Events\User\Created($user));
}
/**
* Listen to the User deleting event.
*/
public function deleting(User $user): void
{
event(new Events\User\Deleting($user));
}
/**
* Listen to the User deleted event.
*/
public function deleted(User $user): void
{
event(new Events\User\Deleted($user));
}
}

View File

@ -2,16 +2,7 @@
namespace App\Providers;
use App\Models\User;
use App\Models\Server;
use App\Models\Subuser;
use App\Models\EggVariable;
use App\Observers\UserObserver;
use App\Observers\ServerObserver;
use App\Observers\SubuserObserver;
use App\Observers\EggVariableObserver;
use App\Events\Server\Installed as ServerInstalledEvent;
use App\Notifications\ServerInstalled as ServerInstalledNotification;
use App\Listeners\DispatchWebhooks;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
@ -20,19 +11,9 @@ class EventServiceProvider extends ServiceProvider
* The event to listener mappings for the application.
*/
protected $listen = [
ServerInstalledEvent::class => [ServerInstalledNotification::class],
'App\\*' => [DispatchWebhooks::class],
'eloquent.created*' => [DispatchWebhooks::class],
'eloquent.deleted*' => [DispatchWebhooks::class],
'eloquent.updated*' => [DispatchWebhooks::class],
];
/**
* Register any events for your application.
*/
public function boot(): void
{
parent::boot();
User::observe(UserObserver::class);
Server::observe(ServerObserver::class);
Subuser::observe(SubuserObserver::class);
EggVariable::observe(EggVariableObserver::class);
}
}

View File

@ -48,8 +48,7 @@ class EggExporterService
],
'variables' => $egg->variables->map(function (EggVariable $eggVariable) {
return Collection::make($eggVariable->toArray())
->except(['id', 'egg_id', 'created_at', 'updated_at'])
->merge(['field_type' => 'text']);
->except(['id', 'egg_id', 'created_at', 'updated_at']);
}),
];

View File

@ -48,6 +48,11 @@ class EggImporterService
'copy_script_from' => null,
]);
// Don't check for this anymore
for ($i = 0; $i < count($parsed['variables']); $i++) {
unset($parsed['variables'][$i]['field_type']);
}
$egg = $this->fillFromParsed($egg, $parsed);
$egg->save();
@ -157,17 +162,13 @@ class EggImporterService
$images = $parsed['images'];
}
unset($parsed['images'], $parsed['image']);
unset($parsed['images'], $parsed['image'], $parsed['field_type']);
$parsed['docker_images'] = [];
foreach ($images as $image) {
$parsed['docker_images'][$image] = $image;
}
$parsed['variables'] = array_map(function ($value) {
return array_merge($value, ['field_type' => 'text']);
}, $parsed['variables']);
return $parsed;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Services\Subusers;
use App\Models\User;
use App\Notifications\AddedToServer;
use Illuminate\Support\Str;
use App\Models\Server;
use App\Models\Subuser;
@ -59,11 +60,19 @@ class SubuserCreationService
throw new ServerSubuserExistsException(trans('exceptions.subusers.subuser_exists'));
}
return Subuser::query()->create([
$subuser = Subuser::query()->create([
'user_id' => $user->id,
'server_id' => $server->id,
'permissions' => array_unique($permissions),
]);
$subuser->user->notify(new AddedToServer([
'user' => $subuser->user->name_first,
'name' => $subuser->server->name,
'uuid_short' => $subuser->server->uuid_short,
]));
return $subuser;
});
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Traits\Services;
trait HasWebhookPayload
{
public function getPayload(): array
{
if (method_exists($this, '__serialize')) {
return $this->__serialize();
}
return [];
}
}

View File

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Models\Node;
use App\Models\Server;
use App\Models\Allocation;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -23,6 +24,7 @@ class AllocationFactory extends Factory
return [
'ip' => $this->faker->unique()->ipv4(),
'port' => $this->faker->unique()->numberBetween(1024, 65535),
'node_id' => Node::factory(),
];
}

View File

@ -22,6 +22,12 @@ class EggFactory extends Factory
{
return [
'uuid' => Uuid::uuid4()->toString(),
'author' => $this->faker->email(),
'docker_images' => ['a', 'b', 'c'],
'config_logs' => '{}',
'config_startup' => '{}',
'config_stop' => '{}',
'config_files' => '{}',
'name' => $this->faker->name(),
'description' => implode(' ', $this->faker->sentences()),
'startup' => 'java -jar test.jar',

View File

@ -40,6 +40,7 @@ class NodeFactory extends Factory
'daemon_listen' => 8080,
'daemon_sftp' => 2022,
'daemon_base' => '/var/lib/panel/volumes',
'maintenance_mode' => false,
];
}
}

View File

@ -2,6 +2,10 @@
namespace Database\Factories;
use App\Models\Allocation;
use App\Models\Egg;
use App\Models\Node;
use App\Models\User;
use Carbon\Carbon;
use Ramsey\Uuid\Uuid;
use Illuminate\Support\Str;
@ -17,12 +21,28 @@ class ServerFactory extends Factory
*/
protected $model = Server::class;
public function withNode(?Node $node = null): static
{
$node ??= Node::factory()->create();
return $this->state(fn () => [
'node_id' => $node->id,
'allocation_id' => Allocation::factory([
'node_id' => $node->id,
]),
]);
}
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'owner_id' => User::factory(),
'node_id' => Node::factory(),
'allocation_id' => Allocation::factory(),
'egg_id' => Egg::factory(),
'uuid' => Uuid::uuid4()->toString(),
'uuid_short' => Str::lower(Str::random(8)),
'name' => $this->faker->firstName(),

View File

@ -0,0 +1,20 @@
<?php
namespace Database\Factories;
use App\Models\WebhookConfiguration;
use Illuminate\Database\Eloquent\Factories\Factory;
class WebhookConfigurationFactory extends Factory
{
protected $model = WebhookConfiguration::class;
public function definition(): array
{
return [
'endpoint' => fake()->url(),
'description' => fake()->sentence(),
'events' => [],
];
}
}

View File

@ -37,7 +37,6 @@ class EggSeeder extends Seeder
public function run(): void
{
foreach (static::$imports as $import) {
/* @noinspection PhpParamsInspection */
$this->parseEggFiles($import);
}
}

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('webhook_configurations', function (Blueprint $table) {
$table->id();
$table->string('endpoint');
$table->string('description');
$table->json('events');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('webhook_configurations');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('webhooks', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(\App\Models\WebhookConfiguration::class)->constrained();
$table->string('event');
$table->string('endpoint');
$table->timestamp('successful_at')->nullable();
$table->json('payload');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('webhooks');
}
};

View File

@ -0,0 +1,69 @@
<?php
namespace App\Tests\Feature;
use App\Jobs\ProcessWebhook;
use App\Models\Server;
use App\Models\WebhookConfiguration;
use App\Tests\TestCase;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Queue;
class DispatchWebhooksTest extends TestCase
{
use LazilyRefreshDatabase;
public function setUp(): void
{
parent::setUp();
Queue::fake();
}
public function test_it_sends_a_single_webhook(): void
{
WebhookConfiguration::factory()->create([
'events' => ['eloquent.created: '.Server::class],
]);
$this->createServer();
Queue::assertPushed(ProcessWebhook::class);
}
public function test_sends_multiple_webhooks()
{
WebhookConfiguration::factory(2)
->create(['events' => ['eloquent.created: '.Server::class]]);
$this->createServer();
Queue::assertPushed(ProcessWebhook::class, 2);
}
public function test_it_sends_no_webhooks()
{
WebhookConfiguration::factory()->create();
$this->createServer();
Queue::assertNothingPushed();
}
public function test_it_sends_some_webhooks()
{
WebhookConfiguration::factory(2)
->sequence(
['events' => ['eloquent.created: '.Server::class]],
['events' => ['eloquent.deleted: '.Server::class]]
)->create();
$this->createServer();
Queue::assertPushed(ProcessWebhook::class, 1);
}
public function createServer(): Server
{
return Server::factory()->withNode()->create();
}
}

View File

@ -0,0 +1,204 @@
<?php
namespace App\Tests\Feature;
use App\Events\Server\Installed;
use App\Jobs\ProcessWebhook;
use App\Models\Server;
use App\Models\Webhook;
use App\Models\WebhookConfiguration;
use App\Tests\TestCase;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
class ProcessWebhooksTest extends TestCase
{
use LazilyRefreshDatabase;
public function setUp(): void
{
parent::setUp();
Http::preventStrayRequests();
Carbon::setTestNow();
}
public function test_it_sends_a_single_webhook(): void
{
$webhook = WebhookConfiguration::factory()->create([
'events' => [$eventName = 'eloquent.created: '.Server::class],
]);
Http::fake([$webhook->endpoint => Http::response()]);
$data = [
'status' => null,
'oom_killer' => false,
'installed_at' => null,
'owner_id' => 1,
'node_id' => 1,
'allocation_id' => 1,
'egg_id' => 1,
'uuid' => '9ff9885f-ab79-4a6e-a53e-466a84cdb2d8',
'uuid_short' => 'ypk27val',
'name' => 'Delmer',
'description' => 'Est sed quibusdam sed eos quae est. Ut similique non impedit voluptas. Aperiam repellendus impedit voluptas officiis id.',
'skip_scripts' => false,
'memory' => 512,
'swap' => 0,
'disk' => 512,
'io' => 500,
'cpu' => 0,
'threads' => null,
'startup' => '/bin/bash echo "hello world"',
'image' => 'foo/bar:latest',
'allocation_limit' => null,
'database_limit' => null,
'backup_limit' => 0,
'created_at' => '2024-09-12T20:21:29.000000Z',
'updated_at' => '2024-09-12T20:21:29.000000Z',
'id' => 1,
];
ProcessWebhook::dispatchSync(
$webhook,
'eloquent.created: '.Server::class,
$data,
);
$this->assertCount(1, cache()->get("webhooks.$eventName"));
$this->assertEquals($webhook->id, cache()->get("webhooks.$eventName")->first()->id);
Http::assertSentCount(1);
Http::assertSent(function (Request $request) use ($webhook, $data) {
return $webhook->endpoint === $request->url()
&& $request->data() === $data;
});
}
public function test_sends_multiple_webhooks()
{
[$webhook1, $webhook2] = WebhookConfiguration::factory(2)
->create(['events' => [$eventName = 'eloquent.created: '.Server::class]]);
Http::fake([
$webhook1->endpoint => Http::response(),
$webhook2->endpoint => Http::response(),
]);
$this->createServer();
$this->assertCount(2, cache()->get("webhooks.$eventName"));
$this->assertContains($webhook1->id, cache()->get("webhooks.$eventName")->pluck('id'));
$this->assertContains($webhook2->id, cache()->get("webhooks.$eventName")->pluck('id'));
Http::assertSentCount(2);
Http::assertSent(fn (Request $request) => $webhook1->endpoint === $request->url());
Http::assertSent(fn (Request $request) => $webhook2->endpoint === $request->url());
}
public function test_it_sends_no_webhooks()
{
Http::fake();
WebhookConfiguration::factory()->create();
$this->createServer();
Http::assertSentCount(0);
}
public function test_it_sends_some_webhooks()
{
[$webhook1, $webhook2] = WebhookConfiguration::factory(2)
->sequence(
['events' => ['eloquent.created: '.Server::class]],
['events' => ['eloquent.deleted: '.Server::class]]
)->create();
Http::fake([
$webhook1->endpoint => Http::response(),
$webhook2->endpoint => Http::response(),
]);
$this->createServer();
Http::assertSentCount(1);
Http::assertSent(fn (Request $request) => $webhook1->endpoint === $request->url());
Http::assertNotSent(fn (Request $request) => $webhook2->endpoint === $request->url());
}
public function test_it_records_when_a_webhook_is_sent()
{
$webhookConfig = WebhookConfiguration::factory()
->create(['events' => ['eloquent.created: '.Server::class]]);
Http::fake([$webhookConfig->endpoint => Http::response()]);
$this->assertDatabaseCount(Webhook::class, 0);
$server = $this->createServer();
$this->assertDatabaseCount(Webhook::class, 1);
$webhook = Webhook::query()->first();
$this->assertEquals($server->uuid, $webhook->payload[0]['uuid']);
$this->assertDatabaseHas(Webhook::class, [
'endpoint' => $webhookConfig->endpoint,
'successful_at' => now()->startOfSecond(),
'event' => 'eloquent.created: '.Server::class,
]);
}
public function test_it_records_when_a_webhook_fails()
{
$webhookConfig = WebhookConfiguration::factory()->create([
'events' => ['eloquent.created: '.Server::class],
]);
Http::fake([$webhookConfig->endpoint => Http::response(status: 500)]);
$this->assertDatabaseCount(Webhook::class, 0);
$server = $this->createServer();
$this->assertDatabaseCount(Webhook::class, 1);
$this->assertDatabaseHas(Webhook::class, [
'payload' => json_encode([$server->toArray()]),
'endpoint' => $webhookConfig->endpoint,
'successful_at' => null,
'event' => 'eloquent.created: '.Server::class,
]);
}
public function test_it_is_triggered_on_custom_events()
{
$webhookConfig = WebhookConfiguration::factory()->create([
'events' => [Installed::class],
]);
Http::fake([$webhookConfig->endpoint => Http::response()]);
$this->assertDatabaseCount(Webhook::class, 0);
$server = $this->createServer();
event(new Installed($server));
$this->assertDatabaseCount(Webhook::class, 1);
$this->assertDatabaseHas(Webhook::class, [
// 'payload' => json_encode([['server' => $server->toArray()]]),
'endpoint' => $webhookConfig->endpoint,
'successful_at' => now()->startOfSecond(),
'event' => Installed::class,
]);
}
public function createServer(): Server
{
return Server::factory()->withNode()->create();
}
}

View File

@ -19,6 +19,9 @@ abstract class TestCase extends BaseTestCase
Carbon::setTestNow(Carbon::now());
CarbonImmutable::setTestNow(Carbon::now());
// TODO: if unit tests suite, then force set DB_HOST=UNIT_NO_DB
// env('DB_DATABASE', 'UNIT_NO_DB');
// Why, you ask? If we don't force this to false it is possible for certain exceptions
// to show their error message properly in the integration test output, but not actually
// be setup correctly to display their message in production.

View File

@ -29,10 +29,14 @@ class MaintenanceMiddlewareTest extends MiddlewareTestCase
*/
public function testHandle(): void
{
$server = Server::factory()->make();
$node = Node::factory()->make(['maintenance' => 0]);
// maintenance mode is off by default
$server = new Server();
$node = new Node([
'maintenance_mode' => false,
]);
$server->setRelation('node', $node);
$this->setRequestAttribute('server', $server);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
@ -43,10 +47,13 @@ class MaintenanceMiddlewareTest extends MiddlewareTestCase
*/
public function testHandleInMaintenanceMode(): void
{
$server = Server::factory()->make();
$node = Node::factory()->make(['maintenance_mode' => 1]);
$server = new Server();
$node = new Node([
'maintenance_mode' => true,
]);
$server->setRelation('node', $node);
$this->setRequestAttribute('server', $server);
$this->response->shouldReceive('view')