Skip to content

Conversation

@notAreYouScared
Copy link
Member

This pull request introduces a comprehensive server configuration import/export feature. It allows server configurations to be exported to YAML files (including settings, allocations, and variables) and imported from such files, both via the Filament admin UI and a new API controller.
Import/Export Actions (UI):

  • Added ExportServerConfigAction and ImportServerConfigAction to the Filament admin UI, enabling users to export a server's configuration as a YAML file and import a server from a YAML file, respectively. These actions include options for customizing what is included in the export and error handling for imports.

API Endpoints:

  • Introduced ServerConfigController with endpoints to export a server’s configuration as a downloadable YAML file and to create a server from an uploaded YAML configuration. Includes validation and error handling for both operations.

@notAreYouScared notAreYouScared self-assigned this Jan 27, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 27, 2026

📝 Walkthrough

Walkthrough

Adds YAML-based server import/export: new Filament actions, services for exporting and importing/creating servers, an API controller and routes, translations, and UI integration on server list/edit pages to enable exporting server configs and creating servers from YAML.

Changes

Cohort / File(s) Summary
Filament Actions
app/Filament/Components/Actions/ExportServerConfigAction.php, app/Filament/Components/Actions/ImportServerConfigAction.php
New UI Action classes: ExportServerConfigAction streams YAML export with toggles (description, allocations, variable values). ImportServerConfigAction handles YAML file upload, optional node selection, validation, invokes creator service, notifies user, and redirects on success or shows errors.
Filament Admin Pages
app/Filament/Admin/Resources/Servers/Pages/EditServer.php, app/Filament/Admin/Resources/Servers/Pages/ListServers.php
EditServer: injects ExportServerConfigAction UI block into the tabs grid near transfer controls. ListServers: adds ImportServerConfigAction to toolbar actions.
Server Configuration Services
app/Services/Servers/Sharing/ServerConfigExporterService.php, app/Services/Servers/Sharing/ServerConfigCreatorService.php, app/Services/Servers/Sharing/ServerConfigImporterService.php
New services: Exporter builds YAML from Server (conditional description, allocations, variables, base64 icon). Creator builds Server from uploaded YAML (egg/node validation, allocations, variables, icon import). Importer applies YAML to existing Server (limits, allocations, variables, conflict resolution).
API Controller & Routes
app/Http/Controllers/Api/Application/Servers/ServerConfigController.php, routes/api-application.php
New controller with export() (returns YAML download) and create() (validates upload and node, creates server via creator service, returns JSON 201). Routes added: GET /{server:id}/config/export, POST /config/create.
Translations
lang/en/admin/server.php
Added import/export UI labels, hints, descriptions, notifications, and detailed import error messages used by actions and services.
Minor UI adjustments
app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php, app/Filament/Admin/Resources/Plugins/PluginResource.php
Reordered/relocated toolbar/header actions (minor UI structure changes only).

Sequence Diagram(s)

sequenceDiagram
    actor Admin
    participant ListUI as ListServers UI
    participant ImportAction as ImportServerConfigAction
    participant Creator as ServerConfigCreatorService
    participant DB as Database
    participant Storage as Storage/Disk

    Admin->>ListUI: Click "Import config", upload YAML + select node
    ListUI->>ImportAction: submit file + node_id
    ImportAction->>Creator: fromFile(uploadedFile, nodeId)
    Creator->>Creator: parse YAML, validate egg UUID
    Creator->>DB: fetch Egg, resolve Node & allocations
    Creator->>DB: create Server record and ServerVariables
    Creator->>Storage: decode & store icon (if provided)
    Creator-->>ImportAction: created Server
    ImportAction->>Admin: notify success and redirect to edit page
Loading
sequenceDiagram
    actor Admin
    participant EditUI as EditServer UI
    participant ExportAction as ExportServerConfigAction
    participant Exporter as ServerConfigExporterService
    participant Storage as Storage/Disk

    Admin->>EditUI: Click "Export config", choose options
    EditUI->>ExportAction: trigger with options
    ExportAction->>Exporter: handle(server, options)
    Exporter->>Storage: read server icon (if present)
    Exporter->>Exporter: assemble YAML with requested sections
    Exporter-->>ExportAction: YAML string
    ExportAction->>Admin: stream YAML download (server-<name>.yaml)
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main functionality introduced: server import/export capabilities. It is specific, directly related to the primary changes, and avoids vague or generic phrasing.
Description check ✅ Passed The description is well-written and directly related to the changeset. It explains the key features added (import/export actions in UI and API endpoints), what they do, and mentions error handling, all of which align with the actual code changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@app/Filament/Admin/Resources/Servers/Pages/EditServer.php`:
- Around line 1014-1016: Replace the hardcoded hint string on
ToggleButtons::make('export_help') with a trans() call (e.g.,
trans('admin/server.import_export.export_help')) and ensure the corresponding
English translation key import_export.export_help is added to the admin server
translations with the text "Export server configuration to a YAML file that can
be imported on another panel".

In `@app/Filament/Components/Actions/ImportServerConfigAction.php`:
- Around line 51-58: The Select field may be hidden so its default can be
missing; in ImportServerConfigAction (the action handler method that processes
$data in this class) ensure you fall back to the user's first accessible node
when node_id is null: if $data['node_id'] is empty, set it to
user()?->accessibleNodes()->first()?->id (and validate existence and throw a
clear error if no accessible node exists) before proceeding with the import
logic.

In `@app/Http/Controllers/Api/Application/Servers/ServerConfigController.php`:
- Around line 60-65: The create method in ServerConfigController lacks
authorization and relies on Request and user()?->accessibleNodes(), which can
fail for API token auth; replace the generic Request with a new FormRequest
(e.g., CreateServerFromConfigRequest extending ApplicationApiRequest) that sets
protected ?string $resource = Server::RESOURCE_NAME and protected int
$permission = AdminAcl::WRITE and implements the same rules for 'file' and
'node_id'; update the controller create(Request $request) signature to
create(CreateServerFromConfigRequest $request) and ensure
ServerConfigCreatorService is fed the authorized user or their accessible node
IDs from the validated request (not via user() helper) so node access is
enforced for API token requests.

In `@app/Services/Servers/Sharing/ServerConfigCreatorService.php`:
- Around line 225-239: The importVariables method can dereference a null
$eggVariable when calling ServerVariable::create; add a null-check after
retrieving $eggVariable in importVariables
(ServerConfigCreatorService::importVariables) and skip (continue) creating the
ServerVariable if $eggVariable is null, optionally logging a warning or debug
message with the env variable name; mirror the null-handling approach used in
ServerConfigImporterService to avoid the null pointer dereference when an egg
variable is missing.
- Around line 45-186: Wrap the entire createServer workflow in a single DB
transaction to ensure atomicity: move the allocation creation logic
(Allocation::create and any findNextAvailablePort calls), the Server::create
call, the allocation->update calls that set server_id, and the subsequent
importVariables($server, ...) and importServerIcon($server, ...) into a
DB::transaction closure (or use DB::beginTransaction/commit/rollback) so any
exception will rollback all changes; ensure you reference the existing
createServer method, Allocation::create, Server::create,
$primaryAllocation->update, and the importVariables/importServerIcon calls and
let exceptions bubble up to trigger the rollback.
- Around line 144-146: The uuid_short is being set to a full UUID (36 chars) in
Server::create, which will violate the varchar(8) UNIQUE constraint; update
ServerConfigCreatorService so uuid_short uses the first 8 characters of the
generated UUID (consistent with ServerCreationService) instead of the full
string when creating the server record (i.e., generate the UUID once and set
'uuid_short' to its first 8 chars prior to calling Server::create).

In `@app/Services/Servers/Sharing/ServerConfigImporterService.php`:
- Around line 190-193: Replace the hardcoded error string thrown in
ServerConfigImporterService (the throw new InvalidFileUploadException("Could not
find an available port for IP {$ip} starting from port {$startPort}")) with a
translation lookup using the appropriate translation key and passing the ip and
startPort as parameters (e.g. use trans()/__() with placeholders for ip and
startPort) so the exception message is localized and consistent with other
messages.
- Around line 22-29: Replace the hardcoded exception messages in
ServerConfigImporterService (the upload error check that throws
InvalidFileUploadException and the YAML parse catch that throws
InvalidFileUploadException) with trans() calls using translation keys consistent
with ServerConfigCreatorService; e.g. throw new
InvalidFileUploadException(trans('servers.import.upload_failed')) for the
$file->getError() case and throw new
InvalidFileUploadException(trans('servers.import.parse_failed', ['message' =>
$exception->getMessage()])) in the catch block, and add those keys to the locale
files so localization works the same way as ServerConfigCreatorService.
- Around line 53-65: Replace the hard-coded exception messages in
ServerConfigImporterService (the checks around $eggUuid and the $egg lookup)
with trans() calls using the existing admin/server.import_errors keys; throw
InvalidFileUploadException with
trans('admin/server.import_errors.egg_uuid_required') when $eggUuid is missing,
and when $egg is not found throw InvalidFileUploadException with
trans('admin/server.import_errors.egg_not_found', ['uuid' => $eggUuid, 'name' =>
$eggName ?? '']) (or omit name if null) so the UUID and optional name are passed
as placeholders to the translation.
🧹 Nitpick comments (5)
app/Services/Servers/Sharing/ServerConfigExporterService.php (1)

14-18: Potential N+1 query issue when Server instance is passed directly.

When a Server instance is passed (rather than an ID), the relationships (egg, allocations, serverVariables.variable) may not be loaded, causing lazy loading and potential N+1 queries. Consider ensuring relationships are loaded regardless of input type.

♻️ Suggested fix
 public function handle(Server|int $server, array $options = []): string
 {
     if (!$server instanceof Server) {
         $server = Server::with(['egg', 'allocations', 'serverVariables.variable'])->findOrFail($server);
+    } else {
+        $server->loadMissing(['egg', 'allocations', 'serverVariables.variable']);
     }
app/Services/Servers/Sharing/ServerConfigCreatorService.php (1)

245-264: Consider optimizing port search with a single query.

The current implementation makes one database query per port, which could be slow if many ports are allocated. A single query to find the first available port would be more efficient.

♻️ Optimized approach
protected function findNextAvailablePort(int $nodeId, string $ip, int $startPort): int
{
    $maxPort = 65535;
    
    $usedPorts = Allocation::where('node_id', $nodeId)
        ->where('ip', $ip)
        ->whereBetween('port', [$startPort + 1, $maxPort])
        ->pluck('port')
        ->toArray();
    
    for ($port = $startPort + 1; $port <= $maxPort; $port++) {
        if (!in_array($port, $usedPorts, true)) {
            return $port;
        }
    }
    
    throw new InvalidFileUploadException(trans('admin/server.import_errors.port_exhausted_desc', ['ip' => $ip, 'port' => $startPort]));
}
app/Services/Servers/Sharing/ServerConfigImporterService.php (1)

67-86: Consolidate description into the main update call.

The description is updated in a separate call (lines 84-86), which could be merged into the main update to reduce database operations.

♻️ Proposed fix
         $server->update([
             'egg_id' => $egg->id,
             'startup' => Arr::get($config, 'settings.startup', $server->startup),
             'image' => Arr::get($config, 'settings.image', $server->image),
             'skip_scripts' => Arr::get($config, 'settings.skip_scripts', $server->skip_scripts),
             'memory' => Arr::get($config, 'limits.memory', $server->memory),
             'swap' => Arr::get($config, 'limits.swap', $server->swap),
             'disk' => Arr::get($config, 'limits.disk', $server->disk),
             'io' => Arr::get($config, 'limits.io', $server->io),
             'cpu' => Arr::get($config, 'limits.cpu', $server->cpu),
             'threads' => Arr::get($config, 'limits.threads', $server->threads),
             'oom_killer' => Arr::get($config, 'limits.oom_killer', $server->oom_killer),
             'database_limit' => Arr::get($config, 'feature_limits.databases', $server->database_limit),
             'allocation_limit' => Arr::get($config, 'feature_limits.allocations', $server->allocation_limit),
             'backup_limit' => Arr::get($config, 'feature_limits.backups', $server->backup_limit),
+            'description' => Arr::get($config, 'description', $server->description),
         ]);
-
-        if (isset($config['description'])) {
-            $server->update(['description' => $config['description']]);
-        }
app/Http/Controllers/Api/Application/Servers/ServerConfigController.php (1)

62-63: YAML MIME type validation may reject valid files.

The mimes:yaml,yml validation relies on file extension mapping and may not work reliably. YAML files can be reported with various MIME types (text/plain, text/yaml, application/x-yaml, text/x-yaml).

Compare with ImportServerConfigAction which uses explicit MIME types:

->acceptedFileTypes(['application/x-yaml', 'text/yaml', 'text/x-yaml', '.yaml', '.yml'])
Suggested fix
         $request->validate([
-            'file' => 'required|file|mimes:yaml,yml|max:1024',
+            'file' => 'required|file|max:1024',
             'node_id' => 'sometimes|integer|exists:nodes,id',
         ]);
+
+        $file = $request->file('file');
+        $extension = strtolower($file->getClientOriginalExtension());
+        if (!in_array($extension, ['yaml', 'yml'])) {
+            throw new InvalidFileUploadException('File must be a YAML file (.yaml or .yml)');
+        }
app/Filament/Components/Actions/ExportServerConfigAction.php (1)

52-60: Filename prefix inconsistent with API controller.

The Filament action generates filenames with 'server-' prefix (Line 56), while ServerConfigController::export() uses 'server-config-' prefix. This inconsistency may confuse users who use both the UI and API.

Suggested fix for consistency
         $this->action(fn (ServerConfigExporterService $service, Server $server, array $data) => response()->streamDownload(
             function () use ($service, $server, $data) {
                 echo $service->handle($server, $data);
             },
-            'server-' . str($server->name)->kebab()->lower()->trim() . '.yaml',
+            'server-config-' . str($server->name)->kebab()->lower()->trim() . '.yaml',
             [
                 'Content-Type' => 'application/x-yaml',
             ]
         ));

Comment on lines 60 to 65
public function create(Request $request): JsonResponse
{
$request->validate([
'file' => 'required|file|mimes:yaml,yml|max:1024',
'node_id' => 'sometimes|integer|exists:nodes,id',
]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing authorization check for server creation.

The create method uses a generic Request instead of a custom FormRequest with proper authorization. Unlike the export method which uses GetServerRequest (with AdminAcl::READ), the create endpoint lacks permission verification.

Additionally, the node_id validation only checks existence but doesn't verify the API user has access to that node. While ServerConfigCreatorService uses user()?->accessibleNodes(), this relies on the web session's user() helper which may not work correctly for API token-based requests.

Suggested fix

Create a dedicated request class with proper authorization:

// app/Http/Requests/Api/Application/Servers/CreateServerFromConfigRequest.php
class CreateServerFromConfigRequest extends ApplicationApiRequest
{
    protected ?string $resource = Server::RESOURCE_NAME;
    protected int $permission = AdminAcl::WRITE;

    public function rules(): array
    {
        return [
            'file' => 'required|file|max:1024',
            'node_id' => 'sometimes|integer|exists:nodes,id',
        ];
    }
}

Then use it in the controller:

-    public function create(Request $request): JsonResponse
+    public function create(CreateServerFromConfigRequest $request): JsonResponse
     {
-        $request->validate([
-            'file' => 'required|file|mimes:yaml,yml|max:1024',
-            'node_id' => 'sometimes|integer|exists:nodes,id',
-        ]);
🤖 Prompt for AI Agents
In `@app/Http/Controllers/Api/Application/Servers/ServerConfigController.php`
around lines 60 - 65, The create method in ServerConfigController lacks
authorization and relies on Request and user()?->accessibleNodes(), which can
fail for API token auth; replace the generic Request with a new FormRequest
(e.g., CreateServerFromConfigRequest extending ApplicationApiRequest) that sets
protected ?string $resource = Server::RESOURCE_NAME and protected int
$permission = AdminAcl::WRITE and implements the same rules for 'file' and
'node_id'; update the controller create(Request $request) signature to
create(CreateServerFromConfigRequest $request) and ensure
ServerConfigCreatorService is fed the authorized user or their accessible node
IDs from the validated request (not via user() helper) so node access is
enforced for API token requests.

Comment on lines 45 to 186
protected function createServer(array $config, ?int $nodeId = null): Server
{
$eggUuid = Arr::get($config, 'egg.uuid');
$eggName = Arr::get($config, 'egg.name');

if (!$eggUuid) {
throw new InvalidFileUploadException(trans('admin/server.import_errors.egg_uuid_required'));
}

$egg = Egg::where('uuid', $eggUuid)->first();

if (!$egg) {
throw new InvalidFileUploadException(
trans('admin/server.import_errors.egg_not_found_desc', [
'uuid' => $eggUuid,
'name' => $eggName ?: trans('admin/server.none'),
])
);
}

if ($nodeId) {
$node = Node::whereIn('id', user()?->accessibleNodes()->pluck('id'))
->where('id', $nodeId)
->first();

if (!$node) {
throw new InvalidFileUploadException(trans('admin/server.import_errors.node_not_accessible'));
}
} else {
$node = Node::whereIn('id', user()?->accessibleNodes()->pluck('id'))->first();

if (!$node) {
throw new InvalidFileUploadException(trans('admin/server.import_errors.no_nodes'));
}
}

$allocations = Arr::get($config, 'allocations', []);
$primaryAllocation = null;
$createdAllocations = [];

if (!empty($allocations)) {
foreach ($allocations as $allocationData) {
$ip = Arr::get($allocationData, 'ip');
$port = Arr::get($allocationData, 'port');
$isPrimary = Arr::get($allocationData, 'is_primary', false);

$allocation = Allocation::where('node_id', $node->id)
->where('ip', $ip)
->where('port', $port)
->whereNull('server_id')
->first();

if (!$allocation) {
$existingAllocation = Allocation::where('node_id', $node->id)
->where('ip', $ip)
->where('port', $port)
->first();

if ($existingAllocation) {
$port = $this->findNextAvailablePort($node->id, $ip, $port);
}

$allocation = Allocation::create([
'node_id' => $node->id,
'ip' => $ip,
'port' => $port,
]);
}

$createdAllocations[] = $allocation;

if ($isPrimary && !$primaryAllocation) {
$primaryAllocation = $allocation;
}
}

if (!$primaryAllocation && !empty($createdAllocations)) {
$primaryAllocation = $createdAllocations[0];
}
}

$owner = user();

if (!$owner) {
throw new InvalidFileUploadException(trans('admin/server.import_errors.no_user'));
}

$serverName = Arr::get($config, 'name', 'Imported Server');

$startupCommand = Arr::get($config, 'settings.startup');
if ($startupCommand === null) {
$startupCommand = array_values($egg->startup_commands)[0];
}

$dockerImage = Arr::get($config, 'settings.image');
if ($dockerImage === null) {
$dockerImage = array_values($egg->docker_images)[0];
}

$server = Server::create([
'uuid' => Str::uuid()->toString(),
'uuid_short' => Str::uuid()->toString(),
'name' => $serverName,
'description' => Arr::get($config, 'description', ''),
'owner_id' => $owner->id,
'node_id' => $node->id,
'allocation_id' => $primaryAllocation?->id,
'egg_id' => $egg->id,
'startup' => $startupCommand,
'image' => $dockerImage,
'skip_scripts' => Arr::get($config, 'settings.skip_scripts', false),
'memory' => Arr::get($config, 'limits.memory', 512),
'swap' => Arr::get($config, 'limits.swap', 0),
'disk' => Arr::get($config, 'limits.disk', 1024),
'io' => Arr::get($config, 'limits.io', 500),
'cpu' => Arr::get($config, 'limits.cpu', 0),
'threads' => Arr::get($config, 'limits.threads'),
'oom_killer' => Arr::get($config, 'limits.oom_killer', false),
'database_limit' => Arr::get($config, 'feature_limits.databases', 0),
'allocation_limit' => Arr::get($config, 'feature_limits.allocations', 0),
'backup_limit' => Arr::get($config, 'feature_limits.backups', 0),
]);

if ($primaryAllocation) {
$primaryAllocation->update(['server_id' => $server->id]);
}

foreach ($createdAllocations as $allocation) {
if ($allocation->id !== $primaryAllocation?->id) {
$allocation->update(['server_id' => $server->id]);
}
}

if (isset($config['variables'])) {
$this->importVariables($server, $config['variables']);
}

if (isset($config['icon'])) {
$this->importServerIcon($server, $config['icon']);
}

return $server;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Consider wrapping server creation in a database transaction.

The createServer method creates allocations, a server, updates allocations, and imports variables/icons across multiple database operations. If any step fails mid-way, orphan allocations or inconsistent state could result. Wrapping in DB::transaction() would ensure atomicity.

♻️ Suggested approach
+use Illuminate\Support\Facades\DB;
+
 protected function createServer(array $config, ?int $nodeId = null): Server
 {
+    return DB::transaction(function () use ($config, $nodeId) {
         // ... existing implementation ...
+    });
 }
🤖 Prompt for AI Agents
In `@app/Services/Servers/Sharing/ServerConfigCreatorService.php` around lines 45
- 186, Wrap the entire createServer workflow in a single DB transaction to
ensure atomicity: move the allocation creation logic (Allocation::create and any
findNextAvailablePort calls), the Server::create call, the allocation->update
calls that set server_id, and the subsequent importVariables($server, ...) and
importServerIcon($server, ...) into a DB::transaction closure (or use
DB::beginTransaction/commit/rollback) so any exception will rollback all
changes; ensure you reference the existing createServer method,
Allocation::create, Server::create, $primaryAllocation->update, and the
importVariables/importServerIcon calls and let exceptions bubble up to trigger
the rollback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants