From de002324d72aad71f99de97cdbd70d11e1435146 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Mon, 21 Oct 2024 12:27:23 -0400 Subject: [PATCH 01/24] Deselect all table records when switching primary allocation (#645) --- .../RelationManagers/AllocationsRelationManager.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php b/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php index aa6658b4d3..c43132b585 100644 --- a/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php +++ b/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php @@ -57,13 +57,13 @@ public function table(Table $table): Table true => 'warning', default => 'gray', }) - ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id])) + ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords()) ->default(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id) ->label('Primary'), ]) ->actions([ Action::make('make-primary') - ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id])) + ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords()) ->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : 'Make Primary'), ]) ->headerActions([ From 3d5c8d14bd28a95e9d438479d6e1cd8991eb8d8a Mon Sep 17 00:00:00 2001 From: Boy132 Date: Mon, 21 Oct 2024 18:43:05 +0200 Subject: [PATCH 02/24] Add back `trustedproxy` config (#651) --- config/trustedproxy.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 config/trustedproxy.php diff --git a/config/trustedproxy.php b/config/trustedproxy.php new file mode 100644 index 0000000000..7e0166af8e --- /dev/null +++ b/config/trustedproxy.php @@ -0,0 +1,28 @@ +getClientIp() + * always gets the originating client IP, no matter + * how many proxies that client's request has + * subsequently passed through. + */ + 'proxies' => in_array(env('TRUSTED_PROXIES', []), ['*', '**']) ? + env('TRUSTED_PROXIES') : explode(',', env('TRUSTED_PROXIES') ?? ''), +]; From a193b4f5ab2b8d2e0a53dcbf81e84368956f1309 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Mon, 21 Oct 2024 18:43:16 +0200 Subject: [PATCH 03/24] Installer: fix argument types for `testConnection` & return type for `submit` (#650) * fix argument types for `testConnection` * fix return type of submit --- app/Filament/Pages/Installer/PanelInstaller.php | 3 ++- app/Filament/Pages/Installer/Steps/DatabaseStep.php | 2 +- app/Filament/Pages/Installer/Steps/RedisStep.php | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Filament/Pages/Installer/PanelInstaller.php b/app/Filament/Pages/Installer/PanelInstaller.php index 10bb32ccf8..fba27eade8 100644 --- a/app/Filament/Pages/Installer/PanelInstaller.php +++ b/app/Filament/Pages/Installer/PanelInstaller.php @@ -25,6 +25,7 @@ use Filament\Support\Enums\MaxWidth; use Filament\Support\Exceptions\Halt; use Illuminate\Http\RedirectResponse; +use Illuminate\Routing\Redirector; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Blade; use Illuminate\Support\HtmlString; @@ -94,7 +95,7 @@ protected function getFormStatePath(): ?string return 'data'; } - public function submit(): RedirectResponse + public function submit(): Redirector|RedirectResponse { // Disable installer $this->writeToEnvironment(['APP_INSTALLED' => 'true']); diff --git a/app/Filament/Pages/Installer/Steps/DatabaseStep.php b/app/Filament/Pages/Installer/Steps/DatabaseStep.php index 04803352da..3a4449777e 100644 --- a/app/Filament/Pages/Installer/Steps/DatabaseStep.php +++ b/app/Filament/Pages/Installer/Steps/DatabaseStep.php @@ -72,7 +72,7 @@ public static function make(PanelInstaller $installer): Step }); } - private static function testConnection(string $driver, string $host, string $port, string $database, string $username, string $password): bool + private static function testConnection(string $driver, ?string $host, null|string|int $port, ?string $database, ?string $username, ?string $password): bool { if ($driver === 'sqlite') { return true; diff --git a/app/Filament/Pages/Installer/Steps/RedisStep.php b/app/Filament/Pages/Installer/Steps/RedisStep.php index d160e0b700..2f257c4016 100644 --- a/app/Filament/Pages/Installer/Steps/RedisStep.php +++ b/app/Filament/Pages/Installer/Steps/RedisStep.php @@ -56,7 +56,7 @@ public static function make(PanelInstaller $installer): Step }); } - private static function testConnection(string $host, string $port, string $username, string $password): bool + private static function testConnection(string $host, null|string|int $port, ?string $username, ?string $password): bool { try { config()->set('database.redis._panel_install_test', [ From 6655ccca6e8a17a068bca558127b971eeed506cd Mon Sep 17 00:00:00 2001 From: Fredrik Falk Date: Mon, 21 Oct 2024 18:46:42 +0200 Subject: [PATCH 04/24] Speed up docker start (#647) Starting the docker container is hampered due to setting `chown -R www-data:www-data /var/www/html/` on every start, causing it to traverse the entire directory which in our use case is very slow. This PR instead changes it to set permissions as part of the build process. Sidenote: Is `LE_EMAIL` supposed to be used in addition to `ADMIN_EMAIL`? --- .github/docker/entrypoint.sh | 2 +- Dockerfile | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/docker/entrypoint.sh b/.github/docker/entrypoint.sh index 9a801e2d94..ad1b359b64 100644 --- a/.github/docker/entrypoint.sh +++ b/.github/docker/entrypoint.sh @@ -58,7 +58,7 @@ else echo "Starting PHP-FPM only" fi -chown -R www-data:www-data . /pelican-data/.env /pelican-data/database +chown -R www-data:www-data /pelican-data/.env /pelican-data/database echo "Starting Supervisord" exec "$@" diff --git a/Dockerfile b/Dockerfile index c759649f16..3e2da64825 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,8 @@ RUN touch .env RUN composer install --no-dev --optimize-autoloader # Set file permissions -RUN chmod -R 755 storage bootstrap/cache +RUN chmod -R 755 storage bootstrap/cache \ + && chown -R www-data:www-data ./ # Add scheduler to cron RUN echo "* * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1" | crontab -u www-data - From 94420d06bebec1ae5ae7ccff138423fa9cff6e93 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Tue, 22 Oct 2024 23:34:46 +0200 Subject: [PATCH 05/24] Add UI for cpu pinning (#652) * add ui for cpu pinning * create "advanced" section --- .../ServerResource/Pages/CreateServer.php | 42 ++++++++++++++++- .../ServerResource/Pages/EditServer.php | 46 ++++++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/app/Filament/Resources/ServerResource/Pages/CreateServer.php b/app/Filament/Resources/ServerResource/Pages/CreateServer.php index 99fd1d4337..6ad8c11c29 100644 --- a/app/Filament/Resources/ServerResource/Pages/CreateServer.php +++ b/app/Filament/Resources/ServerResource/Pages/CreateServer.php @@ -627,14 +627,24 @@ public function form(Form $form): Form ->minValue(0) ->helperText('100% equals one CPU core.'), ]), + ]), + Fieldset::make('Advanced Limits') + ->columnSpan(6) + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ Grid::make() ->columns(4) ->columnSpanFull() ->schema([ ToggleButtons::make('swap_support') ->live() - ->label('Enable Swap Memory') + ->label('Swap Memory') ->inlineLabel() ->inline() ->columnSpan(2) @@ -681,6 +691,36 @@ public function form(Form $form): Form ->label('Block IO Proportion') ->default(500), + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('cpu_pinning') + ->label('CPU Pinning')->inlineLabel()->inline() + ->default(false) + ->afterStateUpdated(fn (Set $set) => $set('threads', [])) + ->live() + ->options([ + false => 'Disabled', + true => 'Enabled', + ]) + ->colors([ + false => 'success', + true => 'warning', + ]) + ->columnSpan(2), + + TagsInput::make('threads') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => !$get('cpu_pinning')) + ->label('Pinned Threads')->inlineLabel() + ->required() + ->columnSpan(2) + ->separator() + ->splitKeys([',']) + ->placeholder('Add pinned thread, e.g. 0 or 2-4'), + ]), + Grid::make() ->columns(4) ->columnSpanFull() diff --git a/app/Filament/Resources/ServerResource/Pages/EditServer.php b/app/Filament/Resources/ServerResource/Pages/EditServer.php index fe1be62764..8ce1c99804 100644 --- a/app/Filament/Resources/ServerResource/Pages/EditServer.php +++ b/app/Filament/Resources/ServerResource/Pages/EditServer.php @@ -24,10 +24,12 @@ use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Grid; +use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\Tabs; use Filament\Forms\Components\Tabs\Tab; +use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\ToggleButtons; @@ -263,14 +265,23 @@ public function form(Form $form): Form ->numeric() ->minValue(0), ]), + ]), + Fieldset::make('Advanced Limits') + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ Grid::make() ->columns(4) ->columnSpanFull() ->schema([ ToggleButtons::make('swap_support') ->live() - ->label('Enable Swap Memory')->inlineLabel()->inline() + ->label('Swap Memory')->inlineLabel()->inline() ->columnSpan(2) ->afterStateUpdated(function ($state, Set $set) { $value = match ($state) { @@ -315,10 +326,41 @@ public function form(Form $form): Form ->integer(), ]), - Forms\Components\Hidden::make('io') + Hidden::make('io') ->helperText('The IO performance relative to other running containers') ->label('Block IO Proportion'), + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('cpu_pinning') + ->label('CPU Pinning')->inlineLabel()->inline() + ->default(false) + ->afterStateUpdated(fn (Set $set) => $set('threads', [])) + ->formatStateUsing(fn (Get $get) => !empty($get('threads'))) + ->live() + ->options([ + false => 'Disabled', + true => 'Enabled', + ]) + ->colors([ + false => 'success', + true => 'warning', + ]) + ->columnSpan(2), + + TagsInput::make('threads') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => !$get('cpu_pinning')) + ->label('Pinned Threads')->inlineLabel() + ->required() + ->columnSpan(2) + ->separator() + ->splitKeys([',']) + ->placeholder('Add pinned thread, e.g. 0 or 2-4'), + ]), + Grid::make() ->columns(4) ->columnSpanFull() From 60792c05c2b6c313dc36e7de3707919b2508fdff Mon Sep 17 00:00:00 2001 From: Boy132 Date: Wed, 23 Oct 2024 12:50:09 +0200 Subject: [PATCH 06/24] Fix `required` for pinned threads input (#656) --- app/Filament/Resources/ServerResource/Pages/CreateServer.php | 2 +- app/Filament/Resources/ServerResource/Pages/EditServer.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Filament/Resources/ServerResource/Pages/CreateServer.php b/app/Filament/Resources/ServerResource/Pages/CreateServer.php index 6ad8c11c29..c5a5997177 100644 --- a/app/Filament/Resources/ServerResource/Pages/CreateServer.php +++ b/app/Filament/Resources/ServerResource/Pages/CreateServer.php @@ -714,7 +714,7 @@ public function form(Form $form): Form ->dehydratedWhenHidden() ->hidden(fn (Get $get) => !$get('cpu_pinning')) ->label('Pinned Threads')->inlineLabel() - ->required() + ->required(fn (Get $get) => $get('cpu_pinning')) ->columnSpan(2) ->separator() ->splitKeys([',']) diff --git a/app/Filament/Resources/ServerResource/Pages/EditServer.php b/app/Filament/Resources/ServerResource/Pages/EditServer.php index 8ce1c99804..5c0ff49185 100644 --- a/app/Filament/Resources/ServerResource/Pages/EditServer.php +++ b/app/Filament/Resources/ServerResource/Pages/EditServer.php @@ -354,7 +354,7 @@ public function form(Form $form): Form ->dehydratedWhenHidden() ->hidden(fn (Get $get) => !$get('cpu_pinning')) ->label('Pinned Threads')->inlineLabel() - ->required() + ->required(fn (Get $get) => $get('cpu_pinning')) ->columnSpan(2) ->separator() ->splitKeys([',']) From c53ef78d89bba4373a8ae8f8d6a4445af001d545 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Wed, 23 Oct 2024 21:59:13 +0200 Subject: [PATCH 07/24] Make sure schedules run with UTC (#657) * make sure schedules use UTC for `next_run_at` * use function from Utilities --- .../Commands/Schedule/ProcessRunnableCommand.php | 3 +-- app/Helpers/Utilities.php | 2 +- app/Models/Schedule.php | 11 +++-------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/app/Console/Commands/Schedule/ProcessRunnableCommand.php b/app/Console/Commands/Schedule/ProcessRunnableCommand.php index c11ade6c35..d773cb2b2f 100644 --- a/app/Console/Commands/Schedule/ProcessRunnableCommand.php +++ b/app/Console/Commands/Schedule/ProcessRunnableCommand.php @@ -6,7 +6,6 @@ use App\Models\Schedule; use Illuminate\Database\Eloquent\Builder; use App\Services\Schedules\ProcessScheduleService; -use Carbon\Carbon; class ProcessRunnableCommand extends Command { @@ -24,7 +23,7 @@ public function handle(): int ->whereRelation('server', fn (Builder $builder) => $builder->whereNull('status')) ->where('is_active', true) ->where('is_processing', false) - ->where('next_run_at', '<=', Carbon::now()->toDateTimeString()) + ->where('next_run_at', '<=', now('UTC')->toDateTimeString()) ->get(); if ($schedules->count() < 1) { diff --git a/app/Helpers/Utilities.php b/app/Helpers/Utilities.php index e0c824d01a..c810788cc2 100644 --- a/app/Helpers/Utilities.php +++ b/app/Helpers/Utilities.php @@ -40,7 +40,7 @@ public static function getScheduleNextRunDate(string $minute, string $hour, stri { return Carbon::instance((new CronExpression( sprintf('%s %s %s %s %s', $minute, $hour, $dayOfMonth, $month, $dayOfWeek) - ))->getNextRunDate()); + ))->getNextRunDate(now('UTC'))); } public static function checked(string $name, mixed $default): string diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php index ae7b9baecf..d6d19a8e14 100644 --- a/app/Models/Schedule.php +++ b/app/Models/Schedule.php @@ -2,8 +2,7 @@ namespace App\Models; -use Cron\CronExpression; -use Carbon\CarbonImmutable; +use App\Helpers\Utilities; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -112,13 +111,9 @@ public function getRouteKeyName(): string * * @throws \Exception */ - public function getNextRunDate(): CarbonImmutable + public function getNextRunDate(): string { - $formatted = sprintf('%s %s %s %s %s', $this->cron_minute, $this->cron_hour, $this->cron_day_of_month, $this->cron_month, $this->cron_day_of_week); - - return CarbonImmutable::createFromTimestamp( - (new CronExpression($formatted))->getNextRunDate()->getTimestamp() - ); + return Utilities::getScheduleNextRunDate($this->cron_minute, $this->cron_hour, $this->cron_day_of_month, $this->cron_month, $this->cron_day_of_week)->toDateTimeString(); } /** From 3933222d98eb18fd85e951c493ca4fa6fc356363 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Wed, 23 Oct 2024 21:36:48 -0400 Subject: [PATCH 08/24] Make sure the .env can be accessed --- .github/docker/entrypoint.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/docker/entrypoint.sh b/.github/docker/entrypoint.sh index ad1b359b64..670cce9d65 100644 --- a/.github/docker/entrypoint.sh +++ b/.github/docker/entrypoint.sh @@ -28,6 +28,7 @@ fi mkdir /pelican-data/database ln -s /pelican-data/.env /var/www/html/ +chown -h www-data:www-data /var/www/html/.env ln -s /pelican-data/database/database.sqlite /var/www/html/database/ if ! grep -q "APP_KEY=" .env || grep -q "APP_KEY=$" .env; then From 741252e395df61b7d8c44071fb6037d40cdef03a Mon Sep 17 00:00:00 2001 From: BlockyBlockling <51190350+BlockyBlockling@users.noreply.github.com> Date: Thu, 24 Oct 2024 21:15:03 +0200 Subject: [PATCH 09/24] Update supervisord.conf Adding username and password dummy to get rid of critical error message --- .github/docker/supervisord.conf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/docker/supervisord.conf b/.github/docker/supervisord.conf index 237c0dbdb0..1094f0d23d 100644 --- a/.github/docker/supervisord.conf +++ b/.github/docker/supervisord.conf @@ -1,5 +1,7 @@ [unix_http_server] file=/tmp/supervisor.sock ; path to your socket file +username=dummy +password=dummy [supervisord] logfile=/var/log/supervisord/supervisord.log ; supervisord log file @@ -18,6 +20,8 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface [supervisorctl] serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket +username=dummy +password=dummy [program:php-fpm] command=/usr/local/sbin/php-fpm -F From 86e8a6371ea8d96833e1e9e30db82390d824182f Mon Sep 17 00:00:00 2001 From: BlockyBlockling <51190350+BlockyBlockling@users.noreply.github.com> Date: Thu, 24 Oct 2024 22:05:46 +0200 Subject: [PATCH 10/24] Update docker-publish.yml Adding fix for forks to use a variable for Docker image reference Source of information: https://docs.github.com/en/actions/use-cases-and-examples/publishing-packages/publishing-docker-images --- .github/workflows/docker-publish.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 8f202d53f0..41b2cdd424 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -9,6 +9,10 @@ on: types: - published +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + jobs: build-and-push: name: Build and Push @@ -26,7 +30,7 @@ jobs: id: docker_meta uses: docker/metadata-action@v5 with: - images: ghcr.io/pelican-dev/panel + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} flavor: | latest=false tags: | From 1df3e8d5b0f10c1d38153e4eedc80a3458922f09 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Sat, 26 Oct 2024 18:53:32 -0400 Subject: [PATCH 11/24] Don't allow NodeStatisticsJob to be in the queue multiple times (#664) * Make job unique * Pint fix --- app/Jobs/NodeStatistics.php | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/app/Jobs/NodeStatistics.php b/app/Jobs/NodeStatistics.php index 9546f0ba0d..d6815ce6c3 100644 --- a/app/Jobs/NodeStatistics.php +++ b/app/Jobs/NodeStatistics.php @@ -4,26 +4,16 @@ use App\Models\Node; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class NodeStatistics implements ShouldQueue +class NodeStatistics implements ShouldBeUnique, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - /** - * Create a new job instance. - */ - public function __construct() - { - // - } - - /** - * Execute the job. - */ public function handle(): void { foreach (Node::all() as $node) { From 5f4429e2c378c504b6f4ad18a6a09903f8904c45 Mon Sep 17 00:00:00 2001 From: Charles Date: Sat, 26 Oct 2024 18:59:06 -0400 Subject: [PATCH 12/24] Remove Bulk Delete from Nodes (#665) * Remove Bulk Delete from Nodes Removes bulk delete option from nodes. * pint --- app/Filament/Resources/NodeResource/Pages/ListNodes.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/Filament/Resources/NodeResource/Pages/ListNodes.php b/app/Filament/Resources/NodeResource/Pages/ListNodes.php index ab794ca296..d7d043c9a2 100644 --- a/app/Filament/Resources/NodeResource/Pages/ListNodes.php +++ b/app/Filament/Resources/NodeResource/Pages/ListNodes.php @@ -6,9 +6,7 @@ use App\Models\Node; use Filament\Actions; use Filament\Resources\Pages\ListRecords; -use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\CreateAction; -use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\EditAction; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; @@ -83,12 +81,6 @@ public function table(Table $table): Table ->actions([ EditAction::make(), ]) - ->bulkActions([ - BulkActionGroup::make([ - DeleteBulkAction::make() - ->authorize(fn () => auth()->user()->can('delete node')), - ]), - ]) ->emptyStateIcon('tabler-server-2') ->emptyStateDescription('') ->emptyStateHeading('No Nodes') From 5f77deb1fd7f899a2bde148270d3835f6f5e5691 Mon Sep 17 00:00:00 2001 From: Quinten <67589015+QuintenQVD0@users.noreply.github.com> Date: Sun, 27 Oct 2024 01:21:14 +0200 Subject: [PATCH 13/24] Panel: Fix wings stoplogic (#407) * Panel: FIx wings stoplogic * do not make an expetion for `^C` let wings handle this * remove withspaces --- app/Services/Eggs/EggConfigurationService.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/Services/Eggs/EggConfigurationService.php b/app/Services/Eggs/EggConfigurationService.php index 90c9427e42..85fd07cb0b 100644 --- a/app/Services/Eggs/EggConfigurationService.php +++ b/app/Services/Eggs/EggConfigurationService.php @@ -64,12 +64,6 @@ protected function convertStopToNewFormat(string $stop): array } $signal = substr($stop, 1); - if (strtoupper($signal) === 'C') { - return [ - 'type' => 'stop', - 'value' => null, - ]; - } return [ 'type' => 'signal', From 86c369d7ce96d5e44533be9e26130feca9cf5ca2 Mon Sep 17 00:00:00 2001 From: Colin DeCarlo Date: Sat, 26 Oct 2024 20:35:25 -0400 Subject: [PATCH 14/24] 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 * Update 2024_04_21_162552_create_webhooks_table.php Co-authored-by: Lance Pioch * Update 2024_04_21_162544_create_webhook_configurations_table.php Co-authored-by: Lance Pioch * Update 2024_04_21_162544_create_webhook_configurations_table.php Co-authored-by: Lance Pioch * Update DispatchWebhooks.php Co-authored-by: Lance Pioch * Update DispatchWebhooksJob.php Co-authored-by: Lance Pioch * Update DispatchWebhookForConfiguration.php Co-authored-by: Lance Pioch * Update DispatchWebhookForConfiguration.php Co-authored-by: Lance Pioch * Update DispatchWebhookForConfiguration.php Co-authored-by: Lance Pioch * Update DispatchWebhooksJob.php Co-authored-by: Lance Pioch * Update DispatchWebhooksJob.php Co-authored-by: Lance Pioch * Update DispatchWebhooksJob.php Co-authored-by: Lance Pioch * 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 Co-authored-by: Lance Pioch Co-authored-by: Vehikl --- app/Events/Server/Created.php | 19 -- app/Events/Server/Creating.php | 19 -- app/Events/Server/Deleted.php | 19 -- app/Events/Server/Deleting.php | 19 -- app/Events/Server/Saved.php | 19 -- app/Events/Server/Saving.php | 19 -- app/Events/Server/Updated.php | 19 -- app/Events/Server/Updating.php | 19 -- app/Events/Subuser/Created.php | 19 -- app/Events/Subuser/Creating.php | 19 -- app/Events/Subuser/Deleted.php | 19 -- app/Events/Subuser/Deleting.php | 19 -- app/Events/User/Created.php | 19 -- app/Events/User/Creating.php | 19 -- app/Events/User/Deleted.php | 19 -- app/Events/User/Deleting.php | 19 -- app/Filament/Resources/WebhookResource.php | 71 ++++++ .../Pages/CreateWebhookConfiguration.php | 11 + .../Pages/EditWebhookConfiguration.php | 19 ++ .../Pages/ListWebhookConfigurations.php | 19 ++ .../Api/Client/Servers/SubuserController.php | 6 + app/Jobs/ProcessWebhook.php | 40 ++++ app/Listeners/DispatchWebhooks.php | 39 ++++ app/Models/Webhook.php | 21 ++ app/Models/WebhookConfiguration.php | 120 +++++++++++ app/Observers/EggVariableObserver.php | 22 -- app/Observers/ServerObserver.php | 76 ------- app/Observers/SubuserObserver.php | 54 ----- app/Observers/UserObserver.php | 43 ---- app/Providers/EventServiceProvider.php | 29 +-- .../Eggs/Sharing/EggExporterService.php | 3 +- .../Eggs/Sharing/EggImporterService.php | 11 +- .../Subusers/SubuserCreationService.php | 11 +- app/Traits/Services/HasWebhookPayload.php | 15 ++ database/Factories/AllocationFactory.php | 2 + database/Factories/EggFactory.php | 6 + database/Factories/NodeFactory.php | 1 + database/Factories/ServerFactory.php | 20 ++ .../Factories/WebhookConfigurationFactory.php | 20 ++ database/Seeders/EggSeeder.php | 1 - ...44_create_webhook_configurations_table.php | 30 +++ ...024_04_21_162552_create_webhooks_table.php | 32 +++ tests/Feature/DispatchWebhooksTest.php | 69 ++++++ tests/Feature/ProcessWebhooksTest.php | 204 ++++++++++++++++++ tests/TestCase.php | 3 + .../Middleware/MaintenanceMiddlewareTest.php | 15 +- 46 files changed, 781 insertions(+), 536 deletions(-) delete mode 100644 app/Events/Server/Created.php delete mode 100644 app/Events/Server/Creating.php delete mode 100644 app/Events/Server/Deleted.php delete mode 100644 app/Events/Server/Deleting.php delete mode 100644 app/Events/Server/Saved.php delete mode 100644 app/Events/Server/Saving.php delete mode 100644 app/Events/Server/Updated.php delete mode 100644 app/Events/Server/Updating.php delete mode 100644 app/Events/Subuser/Created.php delete mode 100644 app/Events/Subuser/Creating.php delete mode 100644 app/Events/Subuser/Deleted.php delete mode 100644 app/Events/Subuser/Deleting.php delete mode 100644 app/Events/User/Created.php delete mode 100644 app/Events/User/Creating.php delete mode 100644 app/Events/User/Deleted.php delete mode 100644 app/Events/User/Deleting.php create mode 100644 app/Filament/Resources/WebhookResource.php create mode 100644 app/Filament/Resources/WebhookResource/Pages/CreateWebhookConfiguration.php create mode 100644 app/Filament/Resources/WebhookResource/Pages/EditWebhookConfiguration.php create mode 100644 app/Filament/Resources/WebhookResource/Pages/ListWebhookConfigurations.php create mode 100644 app/Jobs/ProcessWebhook.php create mode 100644 app/Listeners/DispatchWebhooks.php create mode 100644 app/Models/Webhook.php create mode 100644 app/Models/WebhookConfiguration.php delete mode 100644 app/Observers/EggVariableObserver.php delete mode 100644 app/Observers/ServerObserver.php delete mode 100644 app/Observers/SubuserObserver.php delete mode 100644 app/Observers/UserObserver.php create mode 100644 app/Traits/Services/HasWebhookPayload.php create mode 100644 database/Factories/WebhookConfigurationFactory.php create mode 100644 database/migrations/2024_04_21_162544_create_webhook_configurations_table.php create mode 100644 database/migrations/2024_04_21_162552_create_webhooks_table.php create mode 100644 tests/Feature/DispatchWebhooksTest.php create mode 100644 tests/Feature/ProcessWebhooksTest.php diff --git a/app/Events/Server/Created.php b/app/Events/Server/Created.php deleted file mode 100644 index 8692791044..0000000000 --- a/app/Events/Server/Created.php +++ /dev/null @@ -1,19 +0,0 @@ -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'), + ]; + } +} diff --git a/app/Filament/Resources/WebhookResource/Pages/CreateWebhookConfiguration.php b/app/Filament/Resources/WebhookResource/Pages/CreateWebhookConfiguration.php new file mode 100644 index 0000000000..96f45742c5 --- /dev/null +++ b/app/Filament/Resources/WebhookResource/Pages/CreateWebhookConfiguration.php @@ -0,0 +1,11 @@ +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) { diff --git a/app/Jobs/ProcessWebhook.php b/app/Jobs/ProcessWebhook.php new file mode 100644 index 0000000000..2206e0028f --- /dev/null +++ b/app/Jobs/ProcessWebhook.php @@ -0,0 +1,40 @@ +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, + ]); + } +} diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php new file mode 100644 index 0000000000..102782083e --- /dev/null +++ b/app/Listeners/DispatchWebhooks.php @@ -0,0 +1,39 @@ +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); + } +} diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php new file mode 100644 index 0000000000..28c029122d --- /dev/null +++ b/app/Models/Webhook.php @@ -0,0 +1,21 @@ + 'array', + 'successful_at' => 'datetime', + ]; + } +} diff --git a/app/Models/WebhookConfiguration.php b/app/Models/WebhookConfiguration.php new file mode 100644 index 0000000000..092eac334c --- /dev/null +++ b/app/Models/WebhookConfiguration.php @@ -0,0 +1,120 @@ + '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; + } +} diff --git a/app/Observers/EggVariableObserver.php b/app/Observers/EggVariableObserver.php deleted file mode 100644 index 6fd6b12e6d..0000000000 --- a/app/Observers/EggVariableObserver.php +++ /dev/null @@ -1,22 +0,0 @@ -field_type)) { - unset($variable->field_type); - } - } - - public function updating(EggVariable $variable): void - { - if (isset($variable->field_type)) { - unset($variable->field_type); - } - } -} diff --git a/app/Observers/ServerObserver.php b/app/Observers/ServerObserver.php deleted file mode 100644 index 1804511a0c..0000000000 --- a/app/Observers/ServerObserver.php +++ /dev/null @@ -1,76 +0,0 @@ -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, - ])); - } -} diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php deleted file mode 100644 index 6ec0888303..0000000000 --- a/app/Observers/UserObserver.php +++ /dev/null @@ -1,43 +0,0 @@ - [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); - } } diff --git a/app/Services/Eggs/Sharing/EggExporterService.php b/app/Services/Eggs/Sharing/EggExporterService.php index 19c9a5e123..b03b509289 100644 --- a/app/Services/Eggs/Sharing/EggExporterService.php +++ b/app/Services/Eggs/Sharing/EggExporterService.php @@ -48,8 +48,7 @@ public function handle(int $egg): string ], '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']); }), ]; diff --git a/app/Services/Eggs/Sharing/EggImporterService.php b/app/Services/Eggs/Sharing/EggImporterService.php index e539a7f282..41b56537d9 100644 --- a/app/Services/Eggs/Sharing/EggImporterService.php +++ b/app/Services/Eggs/Sharing/EggImporterService.php @@ -48,6 +48,11 @@ public function fromFile(UploadedFile $file, ?Egg $egg = null): Egg '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 @@ protected function convertToV2(array $parsed): array $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; } } diff --git a/app/Services/Subusers/SubuserCreationService.php b/app/Services/Subusers/SubuserCreationService.php index 0f9508ba72..e0c5ac39bf 100644 --- a/app/Services/Subusers/SubuserCreationService.php +++ b/app/Services/Subusers/SubuserCreationService.php @@ -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 @@ public function handle(Server $server, string $email, array $permissions): Subus 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; }); } } diff --git a/app/Traits/Services/HasWebhookPayload.php b/app/Traits/Services/HasWebhookPayload.php new file mode 100644 index 0000000000..54768dc289 --- /dev/null +++ b/app/Traits/Services/HasWebhookPayload.php @@ -0,0 +1,15 @@ +__serialize(); + } + + return []; + } +} diff --git a/database/Factories/AllocationFactory.php b/database/Factories/AllocationFactory.php index 19405b7024..3cbcc3e33b 100644 --- a/database/Factories/AllocationFactory.php +++ b/database/Factories/AllocationFactory.php @@ -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 @@ public function definition(): array return [ 'ip' => $this->faker->unique()->ipv4(), 'port' => $this->faker->unique()->numberBetween(1024, 65535), + 'node_id' => Node::factory(), ]; } diff --git a/database/Factories/EggFactory.php b/database/Factories/EggFactory.php index 36ec942fa8..219a179b05 100644 --- a/database/Factories/EggFactory.php +++ b/database/Factories/EggFactory.php @@ -22,6 +22,12 @@ public function definition(): array { 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', diff --git a/database/Factories/NodeFactory.php b/database/Factories/NodeFactory.php index 19a1fbf7b1..030bd3e385 100644 --- a/database/Factories/NodeFactory.php +++ b/database/Factories/NodeFactory.php @@ -40,6 +40,7 @@ public function definition(): array 'daemon_listen' => 8080, 'daemon_sftp' => 2022, 'daemon_base' => '/var/lib/panel/volumes', + 'maintenance_mode' => false, ]; } } diff --git a/database/Factories/ServerFactory.php b/database/Factories/ServerFactory.php index e0ced8de5d..2f1d8c6526 100644 --- a/database/Factories/ServerFactory.php +++ b/database/Factories/ServerFactory.php @@ -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(), diff --git a/database/Factories/WebhookConfigurationFactory.php b/database/Factories/WebhookConfigurationFactory.php new file mode 100644 index 0000000000..e1a70954e4 --- /dev/null +++ b/database/Factories/WebhookConfigurationFactory.php @@ -0,0 +1,20 @@ + fake()->url(), + 'description' => fake()->sentence(), + 'events' => [], + ]; + } +} diff --git a/database/Seeders/EggSeeder.php b/database/Seeders/EggSeeder.php index d6a3743b7b..e3f4103484 100644 --- a/database/Seeders/EggSeeder.php +++ b/database/Seeders/EggSeeder.php @@ -37,7 +37,6 @@ public function __construct( public function run(): void { foreach (static::$imports as $import) { - /* @noinspection PhpParamsInspection */ $this->parseEggFiles($import); } } diff --git a/database/migrations/2024_04_21_162544_create_webhook_configurations_table.php b/database/migrations/2024_04_21_162544_create_webhook_configurations_table.php new file mode 100644 index 0000000000..c39805f081 --- /dev/null +++ b/database/migrations/2024_04_21_162544_create_webhook_configurations_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('endpoint'); + $table->string('description'); + $table->json('events'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_configurations'); + } +}; diff --git a/database/migrations/2024_04_21_162552_create_webhooks_table.php b/database/migrations/2024_04_21_162552_create_webhooks_table.php new file mode 100644 index 0000000000..a7565e041a --- /dev/null +++ b/database/migrations/2024_04_21_162552_create_webhooks_table.php @@ -0,0 +1,32 @@ +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'); + } +}; diff --git a/tests/Feature/DispatchWebhooksTest.php b/tests/Feature/DispatchWebhooksTest.php new file mode 100644 index 0000000000..5c032761c4 --- /dev/null +++ b/tests/Feature/DispatchWebhooksTest.php @@ -0,0 +1,69 @@ +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(); + } +} diff --git a/tests/Feature/ProcessWebhooksTest.php b/tests/Feature/ProcessWebhooksTest.php new file mode 100644 index 0000000000..7eb7a8d9ae --- /dev/null +++ b/tests/Feature/ProcessWebhooksTest.php @@ -0,0 +1,204 @@ +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(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index abcd9d84d8..71242d56e7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -19,6 +19,9 @@ protected function setUp(): void 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. diff --git a/tests/Unit/Http/Middleware/MaintenanceMiddlewareTest.php b/tests/Unit/Http/Middleware/MaintenanceMiddlewareTest.php index abdcab33c7..8d1237294d 100644 --- a/tests/Unit/Http/Middleware/MaintenanceMiddlewareTest.php +++ b/tests/Unit/Http/Middleware/MaintenanceMiddlewareTest.php @@ -29,10 +29,14 @@ protected function setUp(): void */ 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 @@ public function testHandle(): void */ 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') From 291b514e2498a9f6f1aa9584dfb9f810656743af Mon Sep 17 00:00:00 2001 From: Charles Date: Sat, 26 Oct 2024 20:40:19 -0400 Subject: [PATCH 15/24] Webhook updates (#666) --- app/Filament/Resources/WebhookResource.php | 25 +++++++++++----------- app/Traits/Services/HasWebhookPayload.php | 15 ------------- 2 files changed, 12 insertions(+), 28 deletions(-) delete mode 100644 app/Traits/Services/HasWebhookPayload.php diff --git a/app/Filament/Resources/WebhookResource.php b/app/Filament/Resources/WebhookResource.php index e05d09fff8..9c30330e3f 100644 --- a/app/Filament/Resources/WebhookResource.php +++ b/app/Filament/Resources/WebhookResource.php @@ -4,10 +4,12 @@ use App\Filament\Resources\WebhookResource\Pages; use App\Models\WebhookConfiguration; -use Filament\Forms; +use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; class WebhookResource extends Resource @@ -16,17 +18,21 @@ class WebhookResource extends Resource protected static ?string $navigationIcon = 'tabler-webhook'; + protected static ?string $navigationGroup = 'Advanced'; + 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( + TextInput::make('endpoint')->activeUrl()->required(), + TextInput::make('description')->nullable(), + CheckboxList::make('events')->lazy()->options( fn () => WebhookConfiguration::filamentCheckboxList() ) + ->searchable() + ->bulkToggleable() ->columns(3) ->columnSpanFull() ->gridDirection('row') @@ -38,18 +44,11 @@ public static function table(Table $table): Table { return $table ->columns([ - // - ]) - ->filters([ - // + TextColumn::make('description'), + TextColumn::make('endpoint'), ]) ->actions([ Tables\Actions\EditAction::make(), - ]) - ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), - ]), ]); } diff --git a/app/Traits/Services/HasWebhookPayload.php b/app/Traits/Services/HasWebhookPayload.php deleted file mode 100644 index 54768dc289..0000000000 --- a/app/Traits/Services/HasWebhookPayload.php +++ /dev/null @@ -1,15 +0,0 @@ -__serialize(); - } - - return []; - } -} From f3de1855083601cf78b09f7b5f5afc86de698b9b Mon Sep 17 00:00:00 2001 From: MartinOscar <40749467+RMartinOscar@users.noreply.github.com> Date: Sun, 27 Oct 2024 02:43:19 +0200 Subject: [PATCH 16/24] Add back auto deploy (#627) * Add Docker, Refactor, Fix Notification Co-authored-by: notCharles * Pint * Required adjustments * Remove deprecated * Third time's the charm --------- Co-authored-by: notCharles --- app/Filament/Resources/ApiKeyResource.php | 2 +- .../Resources/NodeResource/Pages/EditNode.php | 211 ++++++++++++++---- .../Admin/NodeAutoDeployController.php | 44 +--- .../Api/Client/ApiKeyController.php | 2 +- app/Models/ApiKey.php | 9 +- app/Services/Nodes/NodeAutoDeployService.php | 58 +++++ config/panel.php | 5 + phpunit.xml | 8 +- .../Api/Client/ApiKeyControllerTest.php | 4 +- 9 files changed, 245 insertions(+), 98 deletions(-) create mode 100644 app/Services/Nodes/NodeAutoDeployService.php diff --git a/app/Filament/Resources/ApiKeyResource.php b/app/Filament/Resources/ApiKeyResource.php index 282ef6ecbb..46350714eb 100644 --- a/app/Filament/Resources/ApiKeyResource.php +++ b/app/Filament/Resources/ApiKeyResource.php @@ -19,7 +19,7 @@ class ApiKeyResource extends Resource public static function getNavigationBadge(): ?string { - return static::getModel()::where('key_type', '2')->count() ?: null; + return static::getModel()::where('key_type', ApiKey::TYPE_APPLICATION)->count() ?: null; } public static function canEdit(Model $record): bool diff --git a/app/Filament/Resources/NodeResource/Pages/EditNode.php b/app/Filament/Resources/NodeResource/Pages/EditNode.php index 10d5d10626..b5f3804e1e 100644 --- a/app/Filament/Resources/NodeResource/Pages/EditNode.php +++ b/app/Filament/Resources/NodeResource/Pages/EditNode.php @@ -4,9 +4,11 @@ use App\Filament\Resources\NodeResource; use App\Models\Node; +use App\Services\Nodes\NodeAutoDeployService; use App\Services\Nodes\NodeUpdateService; use Filament\Actions; use Filament\Forms; +use Filament\Forms\Components\Actions as FormActions; use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Grid; use Filament\Forms\Components\Placeholder; @@ -21,6 +23,7 @@ use Filament\Forms\Set; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use Filament\Support\Enums\Alignment; use Illuminate\Support\HtmlString; use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction; @@ -149,19 +152,9 @@ public function form(Forms\Form $form): Forms\Form true => 'success', false => 'danger', ]) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 1, - ]), + ->columnSpan(1), TextInput::make('daemon_listen') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 1, - ]) + ->columnSpan(1) ->label(trans('strings.port')) ->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.') ->minValue(1) @@ -182,12 +175,7 @@ public function form(Forms\Form $form): Forms\Form ->maxLength(100), ToggleButtons::make('scheme') ->label('Communicate over SSL') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 1, - ]) + ->columnSpan(1) ->inline() ->helperText(function (Get $get) { if (request()->isSecure()) { @@ -215,23 +203,48 @@ public function form(Forms\Form $form): Forms\Form ]) ->default(fn () => request()->isSecure() ? 'https' : 'http'), ]), Tab::make('Advanced Settings') - ->columns(['default' => 1, 'sm' => 1, 'md' => 4, 'lg' => 6]) + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 4, + 'lg' => 6, + ]) ->icon('tabler-server-cog') ->schema([ TextInput::make('id') ->label('Node ID') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) ->disabled(), TextInput::make('uuid') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) ->label('Node UUID') ->hintAction(CopyAction::make()) ->disabled(), TagsInput::make('tags') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) ->placeholder('Add Tags'), TextInput::make('upload_size') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) ->label('Upload Limit') ->hintIcon('tabler-question-mark') ->hintIconTooltip('Enter the maximum size of files that can be uploaded through the web-based file manager.') @@ -240,7 +253,12 @@ public function form(Forms\Form $form): Forms\Form ->maxValue(1024) ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'), TextInput::make('daemon_sftp') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('SFTP Port') ->minValue(1) ->maxValue(65535) @@ -248,11 +266,21 @@ public function form(Forms\Form $form): Forms\Form ->required() ->integer(), TextInput::make('daemon_sftp_alias') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('SFTP Alias') ->helperText('Display alias for the SFTP address. Leave empty to use the Node FQDN.'), ToggleButtons::make('public') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('Use Node for deployment?')->inline() ->options([ true => 'Yes', @@ -263,7 +291,12 @@ public function form(Forms\Form $form): Forms\Form false => 'danger', ]), ToggleButtons::make('maintenance_mode') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('Maintenance Mode')->inline() ->hinticon('tabler-question-mark') ->hintIconTooltip("If the node is marked 'Under Maintenance' users won't be able to access servers that are on this node.") @@ -276,7 +309,12 @@ public function form(Forms\Form $form): Forms\Form true => 'danger', ]), Grid::make() - ->columns(['default' => 1, 'sm' => 1, 'md' => 3, 'lg' => 6]) + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 3, + 'lg' => 6, + ]) ->columnSpanFull() ->schema([ ToggleButtons::make('unlimited_mem') @@ -293,14 +331,24 @@ public function form(Forms\Form $form): Forms\Form true => 'primary', false => 'warning', ]) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]), + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]), TextInput::make('memory') ->dehydratedWhenHidden() ->hidden(fn (Get $get) => $get('unlimited_mem')) ->label('Memory Limit')->inlineLabel() ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->required() - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->numeric() ->minValue(0), TextInput::make('memory_overallocate') @@ -310,14 +358,24 @@ public function form(Forms\Form $form): Forms\Form ->hidden(fn (Get $get) => $get('unlimited_mem')) ->hintIcon('tabler-question-mark') ->hintIconTooltip('The % allowable to go over the set limit.') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->numeric() ->minValue(-1) ->maxValue(100) ->suffix('%'), ]), Grid::make() - ->columns(['default' => 1, 'sm' => 1, 'md' => 3, 'lg' => 6]) + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 3, + 'lg' => 6, + ]) ->schema([ ToggleButtons::make('unlimited_disk') ->label('Disk')->inlineLabel()->inline() @@ -333,14 +391,24 @@ public function form(Forms\Form $form): Forms\Form true => 'primary', false => 'warning', ]) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]), + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]), TextInput::make('disk') ->dehydratedWhenHidden() ->hidden(fn (Get $get) => $get('unlimited_disk')) ->label('Disk Limit')->inlineLabel() ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->required() - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->numeric() ->minValue(0), TextInput::make('disk_overallocate') @@ -349,7 +417,12 @@ public function form(Forms\Form $form): Forms\Form ->label('Overallocate')->inlineLabel() ->hintIcon('tabler-question-mark') ->hintIconTooltip('The % allowable to go over the set limit.') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->required() ->numeric() ->minValue(-1) @@ -412,19 +485,61 @@ public function form(Forms\Form $form): Forms\Form ->rows(19) ->hintAction(CopyAction::make()) ->columnSpanFull(), - Forms\Components\Actions::make([ - Forms\Components\Actions\Action::make('resetKey') - ->label('Reset Daemon Token') - ->color('danger') - ->requiresConfirmation() - ->modalHeading('Reset Daemon Token?') - ->modalDescription('Resetting the daemon token will void any request coming from the old token. This token is used for all sensitive operations on the daemon including server creation and deletion. We suggest changing this token regularly for security.') - ->action(function (NodeUpdateService $nodeUpdateService, Node $node) { - $nodeUpdateService->handle($node, [], true); - Notification::make()->success()->title('Daemon Key Reset')->send(); - $this->fillForm(); - }), - ]), + Grid::make() + ->columns() + ->schema([ + FormActions::make([ + FormActions\Action::make('autoDeploy') + ->label('Auto Deploy Command') + ->color('primary') + ->modalHeading('Auto Deploy Command') + ->icon('tabler-rocket') + ->modalSubmitAction(false) + ->modalCancelAction(false) + ->modalFooterActionsAlignment(Alignment::Center) + ->form([ + ToggleButtons::make('docker') + ->label('Type') + ->live() + ->helperText('Choose between Standalone and Docker install.') + ->inline() + ->default(false) + ->afterStateUpdated(fn (bool $state, NodeAutoDeployService $service, Node $node, Set $set) => $set('generatedToken', $service->handle(request(), $node, $state))) + ->options([ + false => 'Standalone', + true => 'Docker', + ]) + ->colors([ + false => 'primary', + true => 'success', + ]) + ->columnSpan(1), + Textarea::make('generatedToken') + ->label('To auto-configure your node run the following command:') + ->readOnly() + ->autosize() + ->hintAction(fn (string $state) => CopyAction::make()->copyable($state)) + ->formatStateUsing(fn (NodeAutoDeployService $service, Node $node, Set $set, Get $get) => $set('generatedToken', $service->handle(request(), $node, $get('docker')))), + ]) + ->mountUsing(function (Forms\Form $form) { + Notification::make()->success()->title('Autodeploy Generated')->send(); + $form->fill(); + }), + ])->fullWidth(), + FormActions::make([ + FormActions\Action::make('resetKey') + ->label('Reset Daemon Token') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Reset Daemon Token?') + ->modalDescription('Resetting the daemon token will void any request coming from the old token. This token is used for all sensitive operations on the daemon including server creation and deletion. We suggest changing this token regularly for security.') + ->action(function (NodeUpdateService $nodeUpdateService, Node $node) { + $nodeUpdateService->handle($node, [], true); + Notification::make()->success()->title('Daemon Key Reset')->send(); + $this->fillForm(); + }), + ])->fullWidth(), + ]), ]), ]), ]); diff --git a/app/Http/Controllers/Admin/NodeAutoDeployController.php b/app/Http/Controllers/Admin/NodeAutoDeployController.php index 1029706c35..e2f89e4c85 100644 --- a/app/Http/Controllers/Admin/NodeAutoDeployController.php +++ b/app/Http/Controllers/Admin/NodeAutoDeployController.php @@ -2,12 +2,11 @@ namespace App\Http\Controllers\Admin; -use Illuminate\Http\Request; use App\Models\Node; -use App\Models\ApiKey; -use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; -use App\Services\Api\KeyCreationService; +use App\Services\Nodes\NodeAutoDeployService; +use Illuminate\Http\Request; +use Illuminate\Http\JsonResponse; class NodeAutoDeployController extends Controller { @@ -15,48 +14,19 @@ class NodeAutoDeployController extends Controller * NodeAutoDeployController constructor. */ public function __construct( - private KeyCreationService $keyCreationService + private readonly NodeAutoDeployService $nodeAutoDeployService ) { } /** - * Generates a new API key for the logged-in user with only permission to read - * nodes, and returns that as the deployment key for a node. + * Handles the API request and returns the deployment command. * * @throws \App\Exceptions\Model\DataValidationException */ public function __invoke(Request $request, Node $node): JsonResponse { - $keys = $request->user()->apiKeys() - ->where('key_type', ApiKey::TYPE_APPLICATION) - ->get(); - - /** @var ApiKey|null $key */ - $key = $keys - ->filter(function (ApiKey $key) { - foreach ($key->getAttributes() as $permission => $value) { - if ($permission === 'r_nodes' && $value === 1) { - return true; - } - } - - return false; - }) - ->first(); - - // We couldn't find a key that exists for this user with only permission for - // reading nodes. Go ahead and create it now. - if (!$key) { - $key = $this->keyCreationService->setKeyType(ApiKey::TYPE_APPLICATION)->handle([ - 'user_id' => $request->user()->id, - 'memo' => 'Automatically generated node deployment key.', - 'allowed_ips' => [], - ], ['r_nodes' => 1]); - } + $command = $this->nodeAutoDeployService->handle($request, $node); - return new JsonResponse([ - 'node' => $node->id, - 'token' => $key->identifier . $key->token, - ]); + return new JsonResponse(['command' => $command]); } } diff --git a/app/Http/Controllers/Api/Client/ApiKeyController.php b/app/Http/Controllers/Api/Client/ApiKeyController.php index 0a1026ad59..7889b3e000 100644 --- a/app/Http/Controllers/Api/Client/ApiKeyController.php +++ b/app/Http/Controllers/Api/Client/ApiKeyController.php @@ -29,7 +29,7 @@ public function index(ClientApiRequest $request): array */ public function store(StoreApiKeyRequest $request): array { - if ($request->user()->apiKeys->count() >= 25) { + if ($request->user()->apiKeys->count() >= config('panel.api.key_limit')) { throw new DisplayException('You have reached the account limit for number of API keys.'); } diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php index 96c714f246..09914483e1 100644 --- a/app/Models/ApiKey.php +++ b/app/Models/ApiKey.php @@ -71,15 +71,8 @@ class ApiKey extends Model public const TYPE_ACCOUNT = 1; - /* @deprecated */ public const TYPE_APPLICATION = 2; - /* @deprecated */ - public const TYPE_DAEMON_USER = 3; - - /* @deprecated */ - public const TYPE_DAEMON_APPLICATION = 4; - /** * The length of API key identifiers. */ @@ -138,7 +131,7 @@ class ApiKey extends Model */ public static array $validationRules = [ 'user_id' => 'required|exists:users,id', - 'key_type' => 'present|integer|min:0|max:4', + 'key_type' => 'present|integer|min:0|max:2', 'identifier' => 'required|string|size:16|unique:api_keys,identifier', 'token' => 'required|string', 'memo' => 'required|nullable|string|max:500', diff --git a/app/Services/Nodes/NodeAutoDeployService.php b/app/Services/Nodes/NodeAutoDeployService.php new file mode 100644 index 0000000000..5e9c55b223 --- /dev/null +++ b/app/Services/Nodes/NodeAutoDeployService.php @@ -0,0 +1,58 @@ +where('key_type', ApiKey::TYPE_APPLICATION) + ->where('r_nodes', true) + ->first(); + + // We couldn't find a key that exists for this user with only permission for + // reading nodes. Go ahead and create it now. + if (!$key) { + $key = $this->keyCreationService->setKeyType(ApiKey::TYPE_APPLICATION)->handle([ + 'memo' => 'Automatically generated node deployment key.', + 'user_id' => $request->user()->id, + ], ['r_nodes' => true]); + } + + $token = $key->identifier . $key->token; + + if (!$token) { + return null; + } + + return sprintf( + '%s wings configure --panel-url %s --token %s --node %d%s', + $docker ? 'docker compose exec -it' : 'sudo', + config('app.url'), + $token, + $node->id, + $request->isSecure() ? '' : ' --allow-insecure' + ); + } +} diff --git a/config/panel.php b/config/panel.php index 487ee354b5..5d0cabd0c1 100644 --- a/config/panel.php +++ b/config/panel.php @@ -167,4 +167,9 @@ 'use_binary_prefix' => env('PANEL_USE_BINARY_PREFIX', true), 'editable_server_descriptions' => env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', true), + + 'api' => [ + 'key_limit' => env('API_KEYS_LIMIT', 25), + 'key_expire_time' => env('API_KEYS_EXPIRE_TIME', 720), + ], ]; diff --git a/phpunit.xml b/phpunit.xml index 610eac74ed..191be938c9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,11 @@ - + ./tests/Integration diff --git a/tests/Integration/Api/Client/ApiKeyControllerTest.php b/tests/Integration/Api/Client/ApiKeyControllerTest.php index 462875c59b..aa87f62c6a 100644 --- a/tests/Integration/Api/Client/ApiKeyControllerTest.php +++ b/tests/Integration/Api/Client/ApiKeyControllerTest.php @@ -96,14 +96,14 @@ public function testApiKeyCannotSpecifyMoreThanFiftyIps(): void } /** - * Test that no more than 25 API keys can exist at any one time for an account. This prevents + * Test that no more than the Max number of API keys can exist at one time for an account. This prevents * a DoS attack vector against the panel. */ public function testApiKeyLimitIsApplied(): void { /** @var \App\Models\User $user */ $user = User::factory()->create(); - ApiKey::factory()->times(25)->for($user)->create([ + ApiKey::factory()->times(config('panel.api.key_limit', 25))->for($user)->create([ 'key_type' => ApiKey::TYPE_ACCOUNT, ]); From 7acc8782bb3ed364633361afcba161390a03ce8e Mon Sep 17 00:00:00 2001 From: Charles Date: Sat, 26 Oct 2024 22:06:34 -0400 Subject: [PATCH 17/24] Make description required. (#667) --- app/Filament/Resources/WebhookResource.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/Filament/Resources/WebhookResource.php b/app/Filament/Resources/WebhookResource.php index 9c30330e3f..804c0e078a 100644 --- a/app/Filament/Resources/WebhookResource.php +++ b/app/Filament/Resources/WebhookResource.php @@ -26,11 +26,14 @@ public static function form(Form $form): Form { return $form ->schema([ - TextInput::make('endpoint')->activeUrl()->required(), - TextInput::make('description')->nullable(), - CheckboxList::make('events')->lazy()->options( - fn () => WebhookConfiguration::filamentCheckboxList() - ) + TextInput::make('endpoint') + ->activeUrl() + ->required(), + TextInput::make('description') + ->required(), + CheckboxList::make('events') + ->lazy() + ->options(fn () => WebhookConfiguration::filamentCheckboxList()) ->searchable() ->bulkToggleable() ->columns(3) From 590569a13150666593bd6347a3be240be2ce0a91 Mon Sep 17 00:00:00 2001 From: MartinOscar <40749467+RMartinOscar@users.noreply.github.com> Date: Sun, 27 Oct 2024 04:25:21 +0100 Subject: [PATCH 18/24] Remove duplicated spa in AdminPanelProvider (#668) --- app/Providers/Filament/AdminPanelProvider.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index f6b7c568b0..3df1b101cb 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -49,7 +49,6 @@ public function panel(Panel $panel): Panel ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') - ->spa() ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->widgets([ Widgets\AccountWidget::class, From a70a0603500427dd500ef0c810fc0428527f6e01 Mon Sep 17 00:00:00 2001 From: MartinOscar <40749467+RMartinOscar@users.noreply.github.com> Date: Sun, 27 Oct 2024 05:42:08 +0100 Subject: [PATCH 19/24] Add Soft Deletes to webhooks config table (#670) --- app/Models/WebhookConfiguration.php | 3 +- ...date_webhook_configurations_softdelete.php | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2024_10_27_033218_update_webhook_configurations_softdelete.php diff --git a/app/Models/WebhookConfiguration.php b/app/Models/WebhookConfiguration.php index 092eac334c..1f38030460 100644 --- a/app/Models/WebhookConfiguration.php +++ b/app/Models/WebhookConfiguration.php @@ -5,11 +5,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\File; class WebhookConfiguration extends Model { - use HasFactory; + use HasFactory, SoftDeletes; protected $fillable = [ 'endpoint', diff --git a/database/migrations/2024_10_27_033218_update_webhook_configurations_softdelete.php b/database/migrations/2024_10_27_033218_update_webhook_configurations_softdelete.php new file mode 100644 index 0000000000..2f95aee58c --- /dev/null +++ b/database/migrations/2024_10_27_033218_update_webhook_configurations_softdelete.php @@ -0,0 +1,28 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('webhook_configurations', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; From 288cbee32f4db0990bb7e226218a28e1ea4f17cd Mon Sep 17 00:00:00 2001 From: Charles Date: Sun, 27 Oct 2024 11:22:12 -0400 Subject: [PATCH 20/24] Fix Docker image selection (#674) * Fix Docker image selection Should address issue 672 Closes #672 * Fix Docker image selection in CreateServer page --------- Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com> --- .../Resources/ServerResource/Pages/CreateServer.php | 3 ++- .../Resources/ServerResource/Pages/EditServer.php | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/Filament/Resources/ServerResource/Pages/CreateServer.php b/app/Filament/Resources/ServerResource/Pages/CreateServer.php index c5a5997177..dcdda99220 100644 --- a/app/Filament/Resources/ServerResource/Pages/CreateServer.php +++ b/app/Filament/Resources/ServerResource/Pages/CreateServer.php @@ -787,6 +787,7 @@ public function form(Form $form): Form ->schema([ Select::make('select_image') ->label('Image Name') + ->live() ->afterStateUpdated(fn (Set $set, $state) => $set('image', $state)) ->options(function ($state, Get $get, Set $set) { $egg = Egg::query()->find($get('egg_id')); @@ -811,7 +812,7 @@ public function form(Form $form): Form TextInput::make('image') ->label('Image') - ->debounce(500) + ->required() ->afterStateUpdated(function ($state, Get $get, Set $set) { $egg = Egg::query()->find($get('egg_id')); $images = $egg->docker_images ?? []; diff --git a/app/Filament/Resources/ServerResource/Pages/EditServer.php b/app/Filament/Resources/ServerResource/Pages/EditServer.php index 5c0ff49185..535fc08ef7 100644 --- a/app/Filament/Resources/ServerResource/Pages/EditServer.php +++ b/app/Filament/Resources/ServerResource/Pages/EditServer.php @@ -5,6 +5,7 @@ use App\Enums\ContainerStatus; use App\Enums\ServerState; use App\Filament\Resources\ServerResource; +use App\Filament\Resources\ServerResource\RelationManagers\AllocationsRelationManager; use App\Http\Controllers\Admin\ServersController; use App\Models\Database; use App\Models\Egg; @@ -25,6 +26,7 @@ use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Grid; use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\Tabs; @@ -417,6 +419,7 @@ public function form(Form $form): Form ->schema([ Select::make('select_image') ->label('Image Name') + ->live() ->afterStateUpdated(fn (Set $set, $state) => $set('image', $state)) ->options(function ($state, Get $get, Set $set) { $egg = Egg::query()->find($get('egg_id')); @@ -436,7 +439,7 @@ public function form(Form $form): Form TextInput::make('image') ->label('Image') - ->debounce(500) + ->required() ->afterStateUpdated(function ($state, Get $get, Set $set) { $egg = Egg::query()->find($get('egg_id')); $images = $egg->docker_images ?? []; @@ -450,7 +453,7 @@ public function form(Form $form): Form ->placeholder('Enter a custom Image') ->columnSpan(2), - Forms\Components\KeyValue::make('docker_labels') + KeyValue::make('docker_labels') ->label('Container Labels') ->keyLabel('Label Name') ->valueLabel('Label Description') @@ -826,7 +829,7 @@ protected function mutateFormDataBeforeSave(array $data): array public function getRelationManagers(): array { return [ - ServerResource\RelationManagers\AllocationsRelationManager::class, + AllocationsRelationManager::class, ]; } From fdd1b3798c00f1bf1c0258a6d7af98f19c253f87 Mon Sep 17 00:00:00 2001 From: Charles Date: Sun, 27 Oct 2024 18:01:09 -0400 Subject: [PATCH 21/24] add whereNull (#680) Add where null to not include allocations already assigned to a server. --- .../RelationManagers/AllocationsRelationManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php b/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php index c43132b585..99bc6bb482 100644 --- a/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php +++ b/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php @@ -149,7 +149,7 @@ public function table(Table $table): Table ->multiple() ->associateAnother(false) ->preloadRecordSelect() - ->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node)) + ->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node)->whereNull('server_id')) ->label('Add Allocation'), ]) ->bulkActions([ From 1a3dc5c74354409466edb24e05aba17fb2ea8d8d Mon Sep 17 00:00:00 2001 From: "Michael (Parker) Parker" Date: Sun, 27 Oct 2024 18:04:21 -0400 Subject: [PATCH 22/24] Update Egg Export Version to PLCN_V1 (#676) * Update Egg Export Version to PLCN_V1 resolves #675 * correct version tag * remove trailing space --- app/Models/Egg.php | 2 +- app/Services/Eggs/Sharing/EggImporterService.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Models/Egg.php b/app/Models/Egg.php index 001be85e3f..1ca0da55b1 100644 --- a/app/Models/Egg.php +++ b/app/Models/Egg.php @@ -58,7 +58,7 @@ class Egg extends Model /** * Defines the current egg export version. */ - public const EXPORT_VERSION = 'PTDL_v2'; + public const EXPORT_VERSION = 'PLCN_v1'; /** * Different features that can be enabled on any given egg. These are used internally diff --git a/app/Services/Eggs/Sharing/EggImporterService.php b/app/Services/Eggs/Sharing/EggImporterService.php index 41b56537d9..56942b2311 100644 --- a/app/Services/Eggs/Sharing/EggImporterService.php +++ b/app/Services/Eggs/Sharing/EggImporterService.php @@ -110,6 +110,7 @@ protected function parseFile(UploadedFile $file): array $parsed = match ($version) { 'PTDL_v1' => $this->convertToV2($parsed), 'PTDL_v2' => $parsed, + 'PLCN_V1' => $parsed, default => throw new InvalidFileUploadException('The JSON file provided is not in a format that can be recognized.') }; From bc2df22d78e2aca5a44966b4f3656956ad87a61c Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 28 Oct 2024 18:23:29 -0400 Subject: [PATCH 23/24] Add unique (#685) Usernames have to be unique, trying to make a new user with an existing username results in a 500, this fixes it. --- app/Filament/Resources/UserResource/Pages/ListUsers.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Filament/Resources/UserResource/Pages/ListUsers.php b/app/Filament/Resources/UserResource/Pages/ListUsers.php index 5105302369..81939cc078 100644 --- a/app/Filament/Resources/UserResource/Pages/ListUsers.php +++ b/app/Filament/Resources/UserResource/Pages/ListUsers.php @@ -91,6 +91,7 @@ protected function getHeaderActions(): array TextInput::make('username') ->alphaNum() ->required() + ->unique() ->maxLength(255), TextInput::make('email') ->email() From 3f9c1dbc3c3438f17ab09aa5b8d8f2362eec8efc Mon Sep 17 00:00:00 2001 From: MartinOscar <40749467+RMartinOscar@users.noreply.github.com> Date: Mon, 28 Oct 2024 23:44:32 +0100 Subject: [PATCH 24/24] Add prune & event blacklist (#682) * Add prune & event blacklist * Pinted 3times with --dirty bruh * Add to Settings * Fix prune & description * Prune Logs not Configuration --- app/Console/Kernel.php | 5 +++++ app/Filament/Pages/Settings.php | 16 +++++++++++++++- app/Models/Webhook.php | 10 +++++++++- app/Models/WebhookConfiguration.php | 13 ++++++++++++- config/panel.php | 20 ++++++++++++++++++++ 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 3c2fc96fad..8b49a43d81 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -9,6 +9,7 @@ use App\Console\Commands\Schedule\ProcessRunnableCommand; use App\Jobs\NodeStatistics; use App\Models\ActivityLog; +use App\Models\Webhook; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Database\Console\PruneCommand; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -48,5 +49,9 @@ protected function schedule(Schedule $schedule): void if (config('activity.prune_days')) { $schedule->command(PruneCommand::class, ['--model' => [ActivityLog::class]])->daily(); } + + if (config('panel.webhook.prune_days')) { + $schedule->command(PruneCommand::class, ['--model' => [Webhook::class]])->daily(); + } } } diff --git a/app/Filament/Pages/Settings.php b/app/Filament/Pages/Settings.php index f8d18a01c2..7043fe9d7d 100644 --- a/app/Filament/Pages/Settings.php +++ b/app/Filament/Pages/Settings.php @@ -540,7 +540,21 @@ private function miscSettings(): array ->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_EDITABLE_SERVER_DESCRIPTIONS', (bool) $state)) ->default(env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', config('panel.editable_server_descriptions'))), ]), - + Section::make('Webhook') + ->description('Configure how often old webhook logs should be pruned.') + ->columns() + ->collapsible() + ->collapsed() + ->schema([ + TextInput::make('APP_WEBHOOK_PRUNE_DAYS') + ->label('Prune age') + ->required() + ->numeric() + ->minValue(1) + ->maxValue(365) + ->suffix('Days') + ->default(env('APP_WEBHOOK_PRUNE_DAYS', config('panel.webhook.prune_days'))), + ]), ]; } diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php index 28c029122d..7a8e78ca8c 100644 --- a/app/Models/Webhook.php +++ b/app/Models/Webhook.php @@ -2,12 +2,15 @@ namespace App\Models; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\MassPrunable; use Illuminate\Database\Eloquent\Model; class Webhook extends Model { - use HasFactory; + use HasFactory, MassPrunable; protected $fillable = ['payload', 'successful_at', 'event', 'endpoint']; @@ -18,4 +21,9 @@ public function casts() 'successful_at' => 'datetime', ]; } + + public function prunable(): Builder + { + return static::where('created_at', '<=', Carbon::now()->subDays(config('panel.webhook.prune_days'))); + } } diff --git a/app/Models/WebhookConfiguration.php b/app/Models/WebhookConfiguration.php index 1f38030460..d5a1b0ba0e 100644 --- a/app/Models/WebhookConfiguration.php +++ b/app/Models/WebhookConfiguration.php @@ -12,6 +12,13 @@ class WebhookConfiguration extends Model { use HasFactory, SoftDeletes; + /** + * Blacklisted events. + */ + protected static array $eventBlacklist = [ + 'eloquent.created: App\Models\Webhook', + ]; + protected $fillable = [ 'endpoint', 'description', @@ -48,7 +55,11 @@ public function webhooks(): HasMany public static function allPossibleEvents(): array { - return static::discoverCustomEvents() + static::allModelEvents(); + return collect(static::discoverCustomEvents()) + ->merge(static::allModelEvents()) + ->unique() + ->filter(fn ($event) => !in_array($event, static::$eventBlacklist)) + ->all(); } public static function filamentCheckboxList(): array diff --git a/config/panel.php b/config/panel.php index 5d0cabd0c1..83c1859e2d 100644 --- a/config/panel.php +++ b/config/panel.php @@ -168,8 +168,28 @@ 'editable_server_descriptions' => env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', true), + /* + |-------------------------------------------------------------------------- + | API Settings + |-------------------------------------------------------------------------- + | + | This section controls Api Key configurations + */ + 'api' => [ 'key_limit' => env('API_KEYS_LIMIT', 25), 'key_expire_time' => env('API_KEYS_EXPIRE_TIME', 720), ], + + /* + |-------------------------------------------------------------------------- + | Webhook Settings + |-------------------------------------------------------------------------- + | + | This section controls Webhook configurations + */ + + 'webhook' => [ + 'prune_days' => env('APP_WEBHOOK_PRUNE_DAYS', 30), + ], ];