From 72779c7ea66f3d36045be0fa9f47fb435a5ed1e6 Mon Sep 17 00:00:00 2001 From: upstream Date: Sat, 10 Oct 2026 11:52:35 +0200 Subject: [PATCH] Upstream pelican/panel state of the touched files before 2a80f9bf21 --- .../AllocationsRelationManager.php | 208 +++++++ .../Servers/NetworkAllocationController.php | 149 +++++ app/Models/Server.php | 572 ++++++++++++++++++ tests/Filament/Admin/ServerCrudTest.php | 100 +++ .../Allocation/DeleteAllocationTest.php | 115 ++++ tests/Unit/Models/ServerTest.php | 60 ++ 6 files changed, 1204 insertions(+) create mode 100644 app/Filament/Admin/Resources/Servers/RelationManagers/AllocationsRelationManager.php create mode 100644 app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php create mode 100644 app/Models/Server.php create mode 100644 tests/Filament/Admin/ServerCrudTest.php create mode 100644 tests/Integration/Api/Client/Server/Allocation/DeleteAllocationTest.php create mode 100644 tests/Unit/Models/ServerTest.php diff --git a/app/Filament/Admin/Resources/Servers/RelationManagers/AllocationsRelationManager.php b/app/Filament/Admin/Resources/Servers/RelationManagers/AllocationsRelationManager.php new file mode 100644 index 0000000..3b3bf0c --- /dev/null +++ b/app/Filament/Admin/Resources/Servers/RelationManagers/AllocationsRelationManager.php @@ -0,0 +1,208 @@ +heading(null) + ->selectCurrentPageOnly() + ->recordTitleAttribute('address') + ->recordTitle(fn (Allocation $allocation) => $allocation->address) + ->inverseRelationship('server') + ->columns([ + TextColumn::make('ip') + ->label(trans('admin/server.ip_address')) + ->searchable() + ->sortable(), + TextColumn::make('port') + ->label(trans('admin/server.port')) + ->searchable() + ->sortable(), + ToggleColumn::make('show_port') + ->label(trans('admin/server.show_port')), + TextInputColumn::make('ip_alias') + ->label(trans('admin/server.alias')) + ->placeholder(trans('admin/server.no_alias')) + ->searchable() + ->sortable() + ->disabled(fn () => $this->isReadOnly()), + TextInputColumn::make('notes') + ->label(trans('admin/server.notes')) + ->placeholder(trans('admin/server.no_notes')) + ->disabled(fn () => $this->isReadOnly()), + IconColumn::make('primary') + ->icon(fn ($state) => match ($state) { + true => TablerIcon::StarFilled, + default => TablerIcon::Star, + }) + ->color(fn ($state) => match ($state) { + true => 'warning', + default => 'gray', + }) + ->tooltip(fn (Allocation $allocation) => trans('admin/server.' . ($allocation->id === $this->getOwnerRecord()->allocation_id ? 'already' : 'make') . '_primary')) + ->action(function (Allocation $allocation) { + if ($this->isReadOnly()) { + return; + } + + $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords(); + }) + ->default(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id) + ->label(trans('admin/server.primary')), + IconColumn::make('is_locked') + ->label(trans('admin/server.locked')) + ->tooltip(trans('admin/server.locked_helper')) + ->trueIcon(TablerIcon::Lock) + ->falseIcon(TablerIcon::LockOpen), + ]) + ->emptyStateHeading(trans('admin/server.no_allocations')) + ->recordActions([ + Action::make('make-primary') + ->authorize(fn (Allocation $allocation) => user()?->can('update', $allocation)) + ->tooltip(trans('admin/server.make_primary')) + ->icon(TablerIcon::Star) + ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords()) + ->hidden(fn (Allocation $allocation) => $this->isReadOnly() || $allocation->id === $this->getOwnerRecord()->allocation_id), + Action::make('lock') + ->authorize(fn (Allocation $allocation) => user()?->can('update', $allocation)) + ->tooltip(trans('admin/server.lock')) + ->icon(TablerIcon::Lock) + ->action(fn (Allocation $allocation) => $allocation->update(['is_locked' => true]) && $this->deselectAllTableRecords()) + ->hidden(fn (Allocation $allocation) => $this->isReadOnly() || $allocation->is_locked), + Action::make('unlock') + ->authorize(fn (Allocation $allocation) => user()?->can('update', $allocation)) + ->tooltip(trans('admin/server.unlock')) + ->icon(TablerIcon::LockOpen) + ->action(fn (Allocation $allocation) => $allocation->update(['is_locked' => false]) && $this->deselectAllTableRecords()) + ->visible(fn (Allocation $allocation) => !$this->isReadOnly() && $allocation->is_locked), + DissociateAction::make() + ->authorize(fn (Allocation $allocation) => user()?->can('update', $allocation)) + ->tooltip(trans('admin/server.remove_allocation')) + ->hidden(fn () => $this->isReadOnly()) + ->after(function (Allocation $allocation) { + $allocation->update(Allocation::RELEASE_ATTRIBUTES); + + if (!$this->getOwnerRecord()->allocation_id) { + $this->getOwnerRecord()->update(['allocation_id' => $this->getOwnerRecord()->allocations()->first()?->id]); + } + }), + ]) + ->toolbarActions([ + DissociateBulkAction::make() + ->hidden(fn () => $this->isReadOnly()) + ->after(function (Collection $records) { + Allocation::whereIn('id', $records->pluck('id'))->update(Allocation::RELEASE_ATTRIBUTES); + + if (!$this->getOwnerRecord()->allocation_id) { + $this->getOwnerRecord()->update(['allocation_id' => $this->getOwnerRecord()->allocations()->first()?->id]); + } + }), + CreateAction::make() + ->hidden(fn () => $this->isReadOnly()) + ->hiddenLabel() + ->tooltip(trans('admin/server.create_allocation')) + ->icon(TablerIcon::WorldPlus) + ->createAnother(false) + ->schema(fn () => [ + Select::make('allocation_ip') + ->options(fn (Get $get) => collect($this->getOwnerRecord()->node->ipAddresses()) + ->when($get('allocation_ip'), fn ($ips, $current) => $ips->push($current)) + ->unique() + ->mapWithKeys(fn (string $ip) => [$ip => $ip])) + ->label(trans('admin/server.ip_address')) + ->inlineLabel() + ->ip() + ->live() + ->hintAction( + Action::make('refresh') + ->icon(TablerIcon::Refresh) + ->tooltip(trans('admin/node.refresh')) + ->action(function () { + cache()->forget("nodes.{$this->getOwnerRecord()->node->id}.ips"); + }) + ) + ->suffixAction( + Action::make('custom_ip') + ->icon(TablerIcon::Keyboard) + ->tooltip(trans('admin/node.custom_ip')) + ->schema([ + TextInput::make('custom_ip') + ->label(trans('admin/node.ip_address')) + ->ip() + ->required(), + ]) + ->action(fn (array $data, Set $set) => $set('allocation_ip', $data['custom_ip'])) + ) + ->afterStateUpdated(fn (Set $set) => $set('allocation_ports', [])) + ->required(), + TextInput::make('allocation_alias') + ->label(trans('admin/server.alias')) + ->inlineLabel() + ->default(null) + ->helperText(trans('admin/server.alias_helper')), + TagsInput::make('allocation_ports') + ->placeholder('27015, 27017-27019') + ->label(trans('admin/server.ports')) + ->inlineLabel() + ->live() + ->disabled(fn (Get $get) => empty($get('allocation_ip'))) + ->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports', CreateServer::retrieveValidPorts($this->getOwnerRecord()->node, $state, $get('allocation_ip')))) + ->splitKeys(['Tab', ' ', ',']) + ->required(), + Hidden::make('is_locked') + ->default(true), + ]) + ->action(fn (array $data, AssignmentService $service) => $service->handle($this->getOwnerRecord()->node, $data, $this->getOwnerRecord())), + AssociateAction::make() + ->hidden(fn () => $this->isReadOnly()) + ->multiple() + ->associateAnother(false) + ->preloadRecordSelect() + ->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node)->whereNull('server_id')) + ->recordSelectSearchColumns(['ip', 'port']) + ->tooltip(trans('admin/server.add_allocation')) + ->after(function (array $data) { + Allocation::whereIn('id', array_values(array_unique($data['recordId'])))->update(['is_locked' => true]); + + if (!$this->getOwnerRecord()->allocation_id) { + $this->getOwnerRecord()->update(['allocation_id' => $data['recordId'][0]]); + } + }), + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php b/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php new file mode 100644 index 0000000..de38e2a --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php @@ -0,0 +1,149 @@ + + */ + public function index(GetNetworkRequest $request, Server $server): array + { + return $this->response->collection($server->allocations) + ->transformWith(AllocationData::class) + ->toArray(); + } + + /** + * Update allocation + * + * Set the primary allocation for a server. + * + * @return array + * + * @throws DataValidationException + */ + public function update(UpdateAllocationRequest $request, Server $server, Allocation $allocation): array + { + $original = $allocation->notes; + + $allocation->forceFill(['notes' => $request->input('notes')])->save(); + + if ($original !== $allocation->notes) { + Activity::event('server:allocation.notes') + ->subject($allocation) + ->property(['allocation' => $allocation->address, 'old' => $original, 'new' => $allocation->notes]) + ->log(); + } + + return $this->response->item($allocation) + ->transformWith(AllocationData::class) + ->toArray(); + } + + /** + * Set primary allocation + * + * Set the primary allocation for a server. + * + * @return array + * + * @throws DataValidationException + */ + public function setPrimary(SetPrimaryAllocationRequest $request, Server $server, Allocation $allocation): array + { + $server->allocation()->associate($allocation); + $server->save(); + + Activity::event('server:allocation.primary') + ->subject($allocation) + ->property('allocation', $allocation->address) + ->log(); + + return $this->response->item($allocation) + ->transformWith(AllocationData::class) + ->toArray(); + } + + /** + * Create allocation + * + * Set the notes for the allocation for a server. + * + * @return array + * + * @throws DisplayException + */ + public function store(NewAllocationRequest $request, Server $server): array + { + $allocation = Activity::event('server:allocation.create')->transaction(function ($log) use ($server) { + $server->allocations()->lockForUpdate(); + + throw_if($server->allocations->count() >= $server->allocation_limit, new DisplayException('Cannot assign additional allocations to this server: limit has been reached.')); + + $allocation = $this->assignableAllocationService->handle($server); + + $log->subject($allocation)->property('allocation', $allocation->address); + + return $allocation; + }); + + return $this->response->item($allocation) + ->transformWith(AllocationData::class) + ->toArray(); + } + + /** + * Delete allocation + * + * Delete an allocation from a server. + * + * @throws DisplayException + */ + public function delete(DeleteAllocationRequest $request, Server $server, Allocation $allocation): JsonResponse + { + // Don't allow the deletion of allocations if the server does not have an + // allocation limit set. + throw_if(empty($server->allocation_limit), new DisplayException('You cannot delete allocations for this server: no allocation limit is set.')); + + Allocation::query()->where('id', $allocation->id)->update(Allocation::RELEASE_ATTRIBUTES); + + Activity::event('server:allocation.delete') + ->subject($allocation) + ->property('allocation', $allocation->address) + ->log(); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php new file mode 100644 index 0000000..1ed2e15 --- /dev/null +++ b/app/Models/Server.php @@ -0,0 +1,572 @@ +|null $docker_labels + * @property-read Collection $activity + * @property-read int|null $activity_count + * @property-read Allocation|null $allocation + * @property-read Collection $allocations + * @property-read int|null $allocations_count + * @property-read Collection $backups + * @property-read int|null $backups_count + * @property-read ServerState|ContainerStatus $condition + * @property-read Collection $databases + * @property-read int|null $databases_count + * @property-read Egg $egg + * @property-read Collection $eggVariables + * @property-read int|null $egg_variables_count + * @property-read string|null $icon + * @property-read Collection $mounts + * @property-read int|null $mounts_count + * @property-read Node $node + * @property-read DatabaseNotificationCollection $notifications + * @property-read int|null $notifications_count + * @property-read Collection $schedules + * @property-read int|null $schedules_count + * @property-read Collection $serverVariables + * @property-read int|null $server_variables_count + * @property-read Collection $subusers + * @property-read int|null $subusers_count + * @property-read Collection $userSettings + * @property-read int|null $user_settings_count + * @property-read ServerTransfer|null $transfer + * @property-read User $user + * @property-read Collection $variables + * @property-read int|null $variables_count + * + * @method static \Database\Factories\ServerFactory factory($count = null, $state = []) + * @method static Builder|Server newModelQuery() + * @method static Builder|Server newQuery() + * @method static Builder|Server query() + * @method static Builder|Server whereAllocationId($value) + * @method static Builder|Server whereAllocationLimit($value) + * @method static Builder|Server whereBackupLimit($value) + * @method static Builder|Server whereCpu($value) + * @method static Builder|Server whereCreatedAt($value) + * @method static Builder|Server whereDatabaseLimit($value) + * @method static Builder|Server whereDescription($value) + * @method static Builder|Server whereDisk($value) + * @method static Builder|Server whereDockerLabels($value) + * @method static Builder|Server whereEggId($value) + * @method static Builder|Server whereExternalId($value) + * @method static Builder|Server whereId($value) + * @method static Builder|Server whereImage($value) + * @method static Builder|Server whereInstalledAt($value) + * @method static Builder|Server whereIo($value) + * @method static Builder|Server whereMemory($value) + * @method static Builder|Server whereName($value) + * @method static Builder|Server whereNodeId($value) + * @method static Builder|Server whereOomKiller($value) + * @method static Builder|Server whereOwnerId($value) + * @method static Builder|Server whereSkipScripts($value) + * @method static Builder|Server whereStartup($value) + * @method static Builder|Server whereStatus($value) + * @method static Builder|Server whereSwap($value) + * @method static Builder|Server whereThreads($value) + * @method static Builder|Server whereUpdatedAt($value) + * @method static Builder|Server whereUuid($value) + * @method static Builder|Server whereUuidShort($value) + */ +class Server extends Model implements HasAvatar, Validatable +{ + use HasFactory; + use HasIcon; + use HasValidation; + use Notifiable; + + /** + * The resource name for this model when it is transformed into an + * API representation using fractal. Also used as name for api key permissions. + */ + public const RESOURCE_NAME = 'server'; + + /** + * Default values when creating the model. We want to switch to disabling OOM killer + * on server instances unless the user specifies otherwise in the request. + */ + protected $attributes = [ + 'status' => ServerState::Installing, + 'oom_killer' => false, + 'installed_at' => null, + ]; + + /** + * The default relationships to load for all server models. + */ + protected $with = ['allocation']; + + /** + * Fields that are not mass assignable. + */ + protected $guarded = ['id', self::CREATED_AT, self::UPDATED_AT, 'installed_at']; + + /** @var array */ + public static array $validationRules = [ + 'external_id' => ['sometimes', 'nullable', 'string', 'between:1,255', 'unique:servers'], + 'owner_id' => ['required', 'integer', 'exists:users,id'], + 'name' => ['required', 'string', 'min:1', 'max:255'], + 'node_id' => ['required', 'exists:nodes,id'], + 'description' => ['string'], + 'status' => ['nullable', 'string'], + 'memory' => ['required', 'numeric', 'min:0'], + 'swap' => ['required', 'numeric', 'min:-1'], + 'io' => ['required', 'numeric', 'between:0,1000'], + 'cpu' => ['required', 'numeric', 'min:0'], + 'threads' => ['nullable', 'regex:/^[0-9-,]+$/'], + 'oom_killer' => ['sometimes', 'boolean'], + 'disk' => ['required', 'numeric', 'min:0'], + 'allocation_id' => ['sometimes', 'nullable', 'unique:servers', 'exists:allocations,id'], + 'egg_id' => ['required', 'exists:eggs,id'], + 'startup' => ['required', 'string'], + 'skip_scripts' => ['sometimes', 'boolean'], + 'image' => ['required', 'string', 'max:255'], + 'database_limit' => ['present', 'nullable', 'integer', 'min:0'], + 'allocation_limit' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'backup_limit' => ['present', 'nullable', 'integer', 'min:0'], + ]; + + protected function casts(): array + { + return [ + 'node_id' => 'integer', + 'status' => ServerState::class, + 'skip_scripts' => 'boolean', + 'owner_id' => 'integer', + 'memory' => 'integer', + 'swap' => 'integer', + 'disk' => 'integer', + 'io' => 'integer', + 'cpu' => 'integer', + 'oom_killer' => 'boolean', + 'allocation_id' => 'integer', + 'egg_id' => 'integer', + 'database_limit' => 'integer', + 'allocation_limit' => 'integer', + 'backup_limit' => 'integer', + self::CREATED_AT => 'datetime', + self::UPDATED_AT => 'datetime', + 'installed_at' => 'datetime', + 'docker_labels' => 'array', + ]; + } + + protected static function booted(): void + { + static::saved(function (self $server) { + $subuser = $server->subusers()->where('user_id', $server->owner_id)->first(); + if ($subuser) { + // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions + app(SubuserDeletionService::class)->handle($subuser, $server); + } + }); + } + + /** + * Returns the format for server allocations when communicating with the Daemon. + * + * @return array> + */ + public function getAllocationMappings(): array + { + if (!$this->allocation) { + return ['' => []]; + } + + return $this->allocations->where('node_id', $this->node_id)->groupBy('ip')->map(function ($item) { + return $item->pluck('port'); + })->toArray(); + } + + public function isInstalled(): bool + { + return $this->status !== ServerState::Installing && !$this->isFailedInstall(); + } + + public function isFailedInstall(): bool + { + return $this->status === ServerState::InstallFailed || $this->status === ServerState::ReinstallFailed; + } + + public function isSuspended(): bool + { + return $this->status === ServerState::Suspended; + } + + /** + * Gets the user who owns the server. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_id'); + } + + /** + * Gets the subusers associated with a server. + * + * @return HasMany + */ + public function subusers(): HasMany + { + return $this->hasMany(Subuser::class, 'server_id', 'id'); + } + + /** + * Gets the per-user settings associated with a server. + * + * @return HasMany + */ + public function userSettings(): HasMany + { + return $this->hasMany(ServerUserSettings::class); + } + + /** + * Gets the default allocation for a server. + */ + public function allocation(): BelongsTo + { + return $this->belongsTo(Allocation::class); + } + + /** + * Gets all allocations associated with this server. + * + * @return HasMany + */ + public function allocations(): HasMany + { + return $this->hasMany(Allocation::class); + } + + /** + * Gets information for the egg associated with this server. + */ + public function egg(): BelongsTo + { + return $this->belongsTo(Egg::class); + } + + public function eggVariables(): HasMany + { + return $this->hasMany(EggVariable::class, 'egg_id', 'egg_id'); + } + + /** + * Gets information for the egg variables associated with this server. + * + * @return HasMany + */ + public function variables(): HasMany + { + return $this->hasMany(EggVariable::class, 'egg_id', 'egg_id') + ->select(['egg_variables.*', 'server_variables.variable_value as server_value']) + ->leftJoin('server_variables', function (JoinClause $join) { + // Don't forget to join against the server ID as well since the way we're using this relationship + // would actually return all the variables and their values for _all_ servers using that egg, + // rather than only the server for this model. + $join->on('server_variables.variable_id', 'egg_variables.id') + ->where('server_variables.server_id', $this->id); + }); + } + + public function serverVariables(): HasMany + { + return $this->hasMany(ServerVariable::class); + } + + public function ensureVariablesExist(): void + { + foreach ($this->eggVariables as $variable) { + ServerVariable::firstOrCreate([ + 'server_id' => $this->id, + 'variable_id' => $variable->id, + ], [ + 'variable_value' => $variable->default_value, + ]); + } + } + + /** + * Gets information for the node associated with this server. + */ + public function node(): BelongsTo + { + return $this->belongsTo(Node::class); + } + + /** + * Gets information for the tasks associated with this server. + */ + public function schedules(): HasMany + { + return $this->hasMany(Schedule::class); + } + + /** + * Gets all databases associated with a server. + */ + public function databases(): HasMany + { + return $this->hasMany(Database::class); + } + + /** + * Returns the associated server transfer. + */ + public function transfer(): HasOne + { + return $this->hasOne(ServerTransfer::class)->whereNull('successful')->orderByDesc('id'); + } + + /** + * @return HasMany + */ + public function backups(): HasMany + { + return $this->hasMany(Backup::class); + } + + public function mounts(): MorphToMany + { + return $this->morphToMany(Mount::class, 'mountable'); + } + + /** + * @return HasMany + */ + public function webhookConfigurations(): HasMany + { + return $this->hasMany(WebhookConfiguration::class, 'server_id', 'id') + ->where('scope', WebhookScope::Server); + } + + /** + * Returns all the activity log entries where the server is the subject. + */ + public function activity(): MorphToMany + { + return $this->morphToMany(ActivityLog::class, 'subject', 'activity_log_subjects'); + } + + public function resolveRouteBinding($value, $field = null): ?self + { + return match ($field) { + 'uuid', 'uuid_short' => $this->where('uuid_short', $value)->orWhere('uuid', $value)->firstOrFail(), + default => $this->where('id', $value)->firstOrFail(), + }; + } + + public function resolveChildRouteBinding($childType, $value, $field) + { + return match ($childType) { + 'user' => $this->subusers()->whereRelation('user', 'uuid', $value)->firstOrFail()->user, + default => parent::resolveChildRouteBinding($childType, $value, $field), + }; + } + + public function isInConflictState(): bool + { + return $this->isSuspended() || $this->node->isUnderMaintenance() || !$this->isInstalled() || $this->status === ServerState::RestoringBackup || !is_null($this->transfer); + } + + /** + * Checks if the server is currently in a user-accessible state. If not, an + * exception is raised. This should be called whenever something needs to make + * sure the server is not in a weird state that should block user access. + * + * @throws ServerStateConflictException + */ + public function validateCurrentState(): void + { + throw_if($this->isInConflictState(), new ServerStateConflictException($this)); + } + + /** + * Checks if the server is currently in a transferable state. If not, an + * exception is raised. This should be called whenever something needs to make + * sure the server can be transferred and is not currently being transferred + * or installed. + */ + public function validateTransferState(): void + { + if (!$this->isInstalled() || + $this->status === ServerState::RestoringBackup || + !is_null($this->transfer)) { + throw new ServerStateConflictException($this); + } + } + + /** + * Sends a command or multiple commands to a running server instance. + * + * @param string[]|string $command + * + * @throws ConnectionException + */ + public function send(array|string $command): ResponseInterface + { + return Http::daemon($this->node)->post("/api/servers/{$this->uuid}/commands", [ + 'commands' => is_array($command) ? $command : [$command], + ])->toPsrResponse(); + } + + public function retrieveStatus(): ContainerStatus + { + if ($this->node->isUnderMaintenance()) { + return ContainerStatus::Missing; + } + + return cache()->remember("servers.$this->uuid.status", now()->addSeconds(15), function () { + // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions + $details = app(DaemonServerRepository::class)->setServer($this)->getDetails(); + + return ContainerStatus::tryFrom(Arr::get($details, 'state')) ?? ContainerStatus::Missing; + }); + } + + /** + * @return array + */ + public function retrieveResources(): array + { + if ($this->node->isUnderMaintenance()) { + return []; + } + + return cache()->remember("servers.$this->uuid.resources", now()->addSeconds(15), function () { + // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions + $details = app(DaemonServerRepository::class)->setServer($this)->getDetails(); + + return Arr::get($details, 'utilization', []); + }); + } + + public function formatResource(ServerResourceType $resourceType, int $precision = 2): string + { + $resourceAmount = $resourceType->getResourceAmount($this); + + if ($resourceType->isTime()) { + if (!is_null($this->status)) { + return $this->status->getLabel(); + } + + if ($resourceAmount === 0) { + return ContainerStatus::Offline->getLabel(); + } + + return now()->subMillis($resourceAmount)->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE, short: true, parts: 4); + } + + if ($resourceAmount === 0 & $resourceType->isLimit()) { + // Unlimited symbol + return "\u{221E}"; + } + + if ($resourceType->isPercentage()) { + return format_number($resourceAmount, precision: $precision) . '%'; + } + + return convert_bytes_to_readable($resourceAmount, decimals: $precision, base: 3); + } + + public function condition(): Attribute + { + return Attribute::make( + get: fn () => $this->status ?? $this->retrieveStatus(), + ); + } + + public function getFilamentAvatarUrl(): ?string + { + return $this->icon ?? $this->egg->icon; + } + + public function getSftpUrl(?string $username = null, ?string $directory = null): string + { + $username ??= user()?->username; + + if (!is_null($directory)) { + $directory = explode('/', trim($directory, '/')); + $directory = array_map(fn (string $part) => rawurlencode($part), $directory); + $directory = '/' . implode('/', $directory); + + // SFTP clients only treat a URL path as a directory when it ends with a + // slash, otherwise they prompt to download the path as a file instead of + // opening it. See #2577. + if ($directory !== '/') { + $directory .= '/'; + } + } + + if ($directory === '/') { + $directory = null; + } + + $fqdn = $this->node->daemon_sftp_alias ?? $this->node->fqdn; + + return 'sftp://' . rawurlencode($username) . '.' . $this->uuid_short . '@' . $fqdn . ':' . $this->node->daemon_sftp . $directory; + } +} diff --git a/tests/Filament/Admin/ServerCrudTest.php b/tests/Filament/Admin/ServerCrudTest.php new file mode 100644 index 0000000..b199e26 --- /dev/null +++ b/tests/Filament/Admin/ServerCrudTest.php @@ -0,0 +1,100 @@ +admin] = generateTestAccount(); + $this->admin->syncRoles(Role::getRootAdmin()); + $this->actingAs($this->admin); +}); +afterEach(fn () => Filament::setCurrentPanel(null)); + +it('can create a server', function () { + $daemon = Mockery::mock(DaemonServerRepository::class); + $daemon->expects('setServer')->andReturnSelf(); + $daemon->expects('create')->andReturnUndefined(); + $this->swap(DaemonServerRepository::class, $daemon); + + $node = Node::factory()->create(); + $allocation = Allocation::factory()->create(['node_id' => $node->id, 'server_id' => null]); + // A factory egg has no variables, so no required environment values get in the way. + $egg = Egg::factory()->create(); + + livewire(CreateServer::class) + ->fillForm([ + 'name' => 'test-server', + 'node_id' => $node->id, + 'owner_id' => $this->admin->id, + 'allocation_id' => $allocation->id, + 'egg_id' => $egg->id, + 'startup' => 'java -jar test.jar', + 'image' => 'ghcr.io/pelican-eggs/yolks:java_21', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas('servers', [ + 'name' => 'test-server', + 'node_id' => $node->id, + 'owner_id' => $this->admin->id, + 'egg_id' => $egg->id, + ]); +}); + +it('can edit a server', function () { + $server = Server::factory()->withNode()->create(); + + // Renaming is excluded from the daemon sync diff, so no daemon call happens. + livewire(EditServer::class, ['record' => $server->getKey()]) + ->fillForm([ + 'name' => 'renamed-server', + 'allocation_limit' => 0, + 'database_limit' => 0, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas('servers', ['id' => $server->id, 'name' => 'renamed-server']); +}); + +it('can delete a server', function () { + $daemon = Mockery::mock(DaemonServerRepository::class); + $daemon->shouldReceive('setServer')->andReturnSelf(); + // Rendering the edit page polls the server's container status. + $daemon->shouldReceive('getDetails')->andReturn(['state' => 'offline']); + $daemon->shouldReceive('delete'); + $this->swap(DaemonServerRepository::class, $daemon); + + $server = Server::factory()->withNode()->create(); + + livewire(EditServer::class, ['record' => $server->getKey()]) + ->callAction(TestAction::make('Delete')); + + $this->assertDatabaseMissing('servers', ['id' => $server->id]); +}); + +it('non root admin without permission cannot create servers', function () { + $role = Role::factory()->create(['name' => 'Egg Viewer', 'guard_name' => 'web']); + // Egg permission is on purpose, we check the wrong permissions. + $role->givePermissionTo(Permission::findOrCreate(RolePermissionModels::Egg->viewAny(), 'web')); + [$user] = generateTestAccount(); + $user->syncRoles($role); + + $this->actingAs($user); + livewire(CreateServer::class)->assertForbidden(); +}); diff --git a/tests/Integration/Api/Client/Server/Allocation/DeleteAllocationTest.php b/tests/Integration/Api/Client/Server/Allocation/DeleteAllocationTest.php new file mode 100644 index 0000000..271d30a --- /dev/null +++ b/tests/Integration/Api/Client/Server/Allocation/DeleteAllocationTest.php @@ -0,0 +1,115 @@ +generateTestAccount($permission); + $server->update(['allocation_limit' => 2]); + + /** @var Allocation $allocation */ + $allocation = Allocation::factory()->create([ + 'server_id' => $server->id, + 'node_id' => $server->node_id, + 'is_locked' => true, + 'notes' => 'hodor', + 'show_port' => false, + ]); + + $this->actingAs($user)->deleteJson($this->link($allocation))->assertStatus(Response::HTTP_NO_CONTENT); + + $this->assertDatabaseHas('allocations', [ + 'id' => $allocation->id, + 'is_locked' => false, + 'server_id' => null, + 'notes' => null, + 'show_port' => true, + ]); + } + + /** + * Test that an allocation is deleted if it is currently marked as the primary allocation + * for the server. + */ + public function test_primary_allocation_can_be_deleted_from_server(): void + { + /** @var Server $server */ + [$user, $server] = $this->generateTestAccount(); + $server->update(['allocation_limit' => 2]); + + $allocation = $server->allocation; + + $this->actingAs($user)->deleteJson($this->link($allocation))->assertStatus(Response::HTTP_NO_CONTENT); + + $this->assertDatabaseHas('allocations', ['id' => $allocation->id, 'server_id' => null, 'notes' => null]); + } + + /** + * Test that an error is returned if the user does not have permissiont to delete an allocation. + */ + public function test_error_is_returned_if_user_does_not_have_permission(): void + { + /** @var Server $server */ + [$user, $server] = $this->generateTestAccount([SubuserPermission::AllocationCreate]); + + /** @var Allocation $allocation */ + $allocation = Allocation::factory()->create([ + 'server_id' => $server->id, + 'node_id' => $server->node_id, + 'notes' => 'hodor', + ]); + + $this->actingAs($user)->deleteJson($this->link($allocation))->assertForbidden(); + + $this->assertDatabaseHas('allocations', ['id' => $allocation->id, 'server_id' => $server->id]); + } + + public function test_allocation_cannot_be_deleted_if_server_limit_is_not_defined(): void + { + [$user, $server] = $this->generateTestAccount(); + + /** @var Allocation $allocation */ + $allocation = Allocation::factory()->forServer($server)->create(['notes' => 'Test notes']); + + $this->actingAs($user)->deleteJson($this->link($allocation)) + ->assertStatus(400) + ->assertJsonPath('errors.0.detail', 'You cannot delete allocations for this server: no allocation limit is set.'); + + $allocation->refresh(); + $this->assertNotNull($allocation->notes); + $this->assertEquals($server->id, $allocation->server_id); + } + + /** + * Test that an allocation cannot be deleted if it does not belong to the server instance. + */ + public function test_error_is_returned_if_allocation_does_not_belong_to_server(): void + { + /** @var Server $server */ + [$user, $server] = $this->generateTestAccount(); + [, $server2] = $this->generateTestAccount(); + + $this->actingAs($user)->deleteJson($this->link($server2->allocation))->assertNotFound(); + $this->actingAs($user)->deleteJson($this->link($server, "/network/allocations/{$server2->allocation_id}"))->assertNotFound(); + } + + public static function permissionDataProvider(): array + { + return [[[SubuserPermission::AllocationDelete]], [[]]]; + } +} diff --git a/tests/Unit/Models/ServerTest.php b/tests/Unit/Models/ServerTest.php new file mode 100644 index 0000000..b7c2722 --- /dev/null +++ b/tests/Unit/Models/ServerTest.php @@ -0,0 +1,60 @@ +serverWithNode(); + + $this->assertSame("sftp://user.abcdefgh@node.example.com:2022{$expectedPath}", $server->getSftpUrl('user', $directory)); + } + + public static function sftpUrlDataProvider(): array + { + return [ + 'no directory' => [null, ''], + 'root' => ['/', ''], + 'single directory' => ['/data', '/data/'], + 'directory without a leading slash' => ['data', '/data/'], + 'directory with a trailing slash' => ['data/', '/data/'], + 'nested directory' => ['/data/world', '/data/world/'], + 'directory with encoded parts' => ['/data/my world', '/data/my%20world/'], + ]; + } + + public function test_sftp_url_uses_the_node_sftp_alias_when_set(): void + { + $server = $this->serverWithNode(sftpAlias: 'sftp.example.com'); + + $this->assertSame('sftp://user.abcdefgh@sftp.example.com:2022/data/', $server->getSftpUrl('user', '/data')); + } + + public function test_sftp_url_encodes_the_username(): void + { + $server = $this->serverWithNode(); + + $this->assertSame('sftp://user%20name.abcdefgh@node.example.com:2022/data/', $server->getSftpUrl('user name', '/data')); + } + + private function serverWithNode(string $fqdn = 'node.example.com', ?string $sftpAlias = null): Server + { + $node = new Node(); + $node->fqdn = $fqdn; + $node->daemon_sftp_alias = $sftpAlias; + $node->daemon_sftp = 2022; + + $server = new Server(); + $server->uuid_short = 'abcdefgh'; + $server->setRelation('node', $node); + + return $server; + } +}