Upstream pelican/panel state of the touched files before 2a80f9bf21

This commit is contained in:
upstream committed 2026-10-10 11:52:35 +02:00
1 parent 8860a5c5e5
commit 72779c7ea6
6 files changed
+1204

No files matched your search

@@ -0,0 +1,208 @@
<?php
namespace App\Filament\Admin\Resources\Servers\RelationManagers;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Servers\Pages\CreateServer;
use App\Models\Allocation;
use App\Models\Server;
use App\Services\Allocations\AssignmentService;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\AssociateAction;
use Filament\Actions\CreateAction;
use Filament\Actions\DissociateAction;
use Filament\Actions\DissociateBulkAction;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
/**
* @method Server getOwnerRecord()
*/
class AllocationsRelationManager extends RelationManager
{
protected static string $relationship = 'allocations';
protected static string|BackedEnum|null $icon = TablerIcon::Network;
public function table(Table $table): Table
{
return $table
->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]]);
}
}),
]);
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Http\Controllers\Api\Client\Servers;
use App\Data\Api\Client\AllocationData;
use App\Exceptions\DisplayException;
use App\Exceptions\Model\DataValidationException;
use App\Facades\Activity;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\Network\DeleteAllocationRequest;
use App\Http\Requests\Api\Client\Servers\Network\GetNetworkRequest;
use App\Http\Requests\Api\Client\Servers\Network\NewAllocationRequest;
use App\Http\Requests\Api\Client\Servers\Network\SetPrimaryAllocationRequest;
use App\Http\Requests\Api\Client\Servers\Network\UpdateAllocationRequest;
use App\Models\Allocation;
use App\Models\Server;
use App\Services\Allocations\FindAssignableAllocationService;
use Dedoc\Scramble\Attributes\Group;
use Illuminate\Http\JsonResponse;
#[Group('Server - Allocation')]
class NetworkAllocationController extends ClientApiController
{
/**
* NetworkAllocationController constructor.
*/
public function __construct(
private FindAssignableAllocationService $assignableAllocationService,
) {
parent::__construct();
}
/**
* List allocations
*
* Lists all the allocations available to a server and whether
* they are currently assigned as the primary for this server.
*
* @return array<array-key, mixed>
*/
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<array-key, mixed>
*
* @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<array-key, mixed>
*
* @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<array-key, mixed>
*
* @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);
}
}
+572
View File
@@ -0,0 +1,572 @@
<?php
namespace App\Models;
use App\Contracts\Validatable;
use App\Enums\ContainerStatus;
use App\Enums\ServerResourceType;
use App\Enums\ServerState;
use App\Enums\WebhookScope;
use App\Exceptions\Http\Server\ServerStateConflictException;
use App\Models\Traits\HasIcon;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Services\Subusers\SubuserDeletionService;
use App\Traits\HasValidation;
use Carbon\CarbonInterface;
use Exception;
use Filament\Models\Contracts\HasAvatar;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\DatabaseNotificationCollection;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Psr\Http\Message\ResponseInterface;
/**
* \App\Models\Server.
*
* @property int $id
* @property string $uuid
* @property string $uuid_short
* @property int $node_id
* @property string $name
* @property int $owner_id
* @property int $memory
* @property int $swap
* @property int $disk
* @property int $io
* @property int $cpu
* @property int $egg_id
* @property string $startup
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property int|null $allocation_id
* @property string $image
* @property string|null $description
* @property bool $skip_scripts
* @property string|null $external_id
* @property int|null $database_limit
* @property int|null $allocation_limit
* @property string|null $threads
* @property int $backup_limit
* @property ServerState|null $status
* @property Carbon|null $installed_at
* @property bool $oom_killer
* @property array<array-key, mixed>|null $docker_labels
* @property-read Collection<int, ActivityLog> $activity
* @property-read int|null $activity_count
* @property-read Allocation|null $allocation
* @property-read Collection<int, Allocation> $allocations
* @property-read int|null $allocations_count
* @property-read Collection<int, Backup> $backups
* @property-read int|null $backups_count
* @property-read ServerState|ContainerStatus $condition
* @property-read Collection<int, Database> $databases
* @property-read int|null $databases_count
* @property-read Egg $egg
* @property-read Collection<int, EggVariable> $eggVariables
* @property-read int|null $egg_variables_count
* @property-read string|null $icon
* @property-read Collection<int, Mount> $mounts
* @property-read int|null $mounts_count
* @property-read Node $node
* @property-read DatabaseNotificationCollection<int, DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @property-read Collection<int, Schedule> $schedules
* @property-read int|null $schedules_count
* @property-read Collection<int, ServerVariable> $serverVariables
* @property-read int|null $server_variables_count
* @property-read Collection<int, Subuser> $subusers
* @property-read int|null $subusers_count
* @property-read Collection<int, ServerUserSettings> $userSettings
* @property-read int|null $user_settings_count
* @property-read ServerTransfer|null $transfer
* @property-read User $user
* @property-read Collection<int, EggVariable> $variables
* @property-read int|null $variables_count
*
* @method static \Database\Factories\ServerFactory factory($count = null, $state = [])
* @method static Builder<static>|Server newModelQuery()
* @method static Builder<static>|Server newQuery()
* @method static Builder<static>|Server query()
* @method static Builder<static>|Server whereAllocationId($value)
* @method static Builder<static>|Server whereAllocationLimit($value)
* @method static Builder<static>|Server whereBackupLimit($value)
* @method static Builder<static>|Server whereCpu($value)
* @method static Builder<static>|Server whereCreatedAt($value)
* @method static Builder<static>|Server whereDatabaseLimit($value)
* @method static Builder<static>|Server whereDescription($value)
* @method static Builder<static>|Server whereDisk($value)
* @method static Builder<static>|Server whereDockerLabels($value)
* @method static Builder<static>|Server whereEggId($value)
* @method static Builder<static>|Server whereExternalId($value)
* @method static Builder<static>|Server whereId($value)
* @method static Builder<static>|Server whereImage($value)
* @method static Builder<static>|Server whereInstalledAt($value)
* @method static Builder<static>|Server whereIo($value)
* @method static Builder<static>|Server whereMemory($value)
* @method static Builder<static>|Server whereName($value)
* @method static Builder<static>|Server whereNodeId($value)
* @method static Builder<static>|Server whereOomKiller($value)
* @method static Builder<static>|Server whereOwnerId($value)
* @method static Builder<static>|Server whereSkipScripts($value)
* @method static Builder<static>|Server whereStartup($value)
* @method static Builder<static>|Server whereStatus($value)
* @method static Builder<static>|Server whereSwap($value)
* @method static Builder<static>|Server whereThreads($value)
* @method static Builder<static>|Server whereUpdatedAt($value)
* @method static Builder<static>|Server whereUuid($value)
* @method static Builder<static>|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<array-key, string[]> */
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<string, array<int>>
*/
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<Subuser, $this>
*/
public function subusers(): HasMany
{
return $this->hasMany(Subuser::class, 'server_id', 'id');
}
/**
* Gets the per-user settings associated with a server.
*
* @return HasMany<ServerUserSettings, $this>
*/
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<Allocation, $this>
*/
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<EggVariable, $this>
*/
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<Backup, $this>
*/
public function backups(): HasMany
{
return $this->hasMany(Backup::class);
}
public function mounts(): MorphToMany
{
return $this->morphToMany(Mount::class, 'mountable');
}
/**
* @return HasMany<WebhookConfiguration, $this>
*/
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<mixed>
*/
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;
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
use App\Enums\RolePermissionModels;
use App\Filament\Admin\Resources\Servers\Pages\CreateServer;
use App\Filament\Admin\Resources\Servers\Pages\EditServer;
use App\Models\Allocation;
use App\Models\Egg;
use App\Models\Node;
use App\Models\Role;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use Filament\Actions\Testing\TestAction;
use Filament\Facades\Filament;
use Spatie\Permission\Models\Permission;
use function Pest\Livewire\livewire;
beforeEach(function () {
Filament::setCurrentPanel(Filament::getPanel('admin'));
[$this->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();
});
@@ -0,0 +1,115 @@
<?php
namespace App\Tests\Integration\Api\Client\Server\Allocation;
use App\Enums\SubuserPermission;
use App\Models\Allocation;
use App\Models\Server;
use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
use Illuminate\Http\Response;
use PHPUnit\Framework\Attributes\DataProvider;
class DeleteAllocationTest extends ClientApiIntegrationTestCase
{
/**
* Test that an allocation is deleted from the server and the notes are properly reset
* to an empty value on assignment.
*/
#[DataProvider('permissionDataProvider')]
public function test_allocation_can_be_deleted_from_server(array $permission): void
{
/** @var Server $server */
[$user, $server] = $this->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]], [[]]];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Tests\Unit\Models;
use App\Models\Node;
use App\Models\Server;
use App\Tests\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
class ServerTest extends TestCase
{
#[DataProvider('sftpUrlDataProvider')]
public function test_sftp_url_points_at_the_given_directory(?string $directory, string $expectedPath): void
{
$server = $this->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;
}
}