feat: add ServiceDatabase restore/import support (#7540)

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-01-02 16:32:27 +01:00
commit f53229b7af
18 files changed
+1102 -882

No files matched your search

+8 -4
View File
@@ -13,7 +13,8 @@ class UploadController extends BaseController
{
public function upload(Request $request)
{
$resource = getResourceByUuid(request()->route('databaseUuid'), data_get(auth()->user()->currentTeam(), 'id'));
$databaseIdentifier = request()->route('databaseUuid');
$resource = getResourceByUuid($databaseIdentifier, data_get(auth()->user()->currentTeam(), 'id'));
if (is_null($resource)) {
return response()->json(['error' => 'You do not have permission for this database'], 500);
}
@@ -26,7 +27,10 @@ class UploadController extends BaseController
$save = $receiver->receive();
if ($save->isFinished()) {
return $this->saveFile($save->getFile(), $resource);
// Use the original identifier from the route to maintain path consistency
// For ServiceDatabase: {name}-{service_uuid}
// For standalone databases: {uuid}
return $this->saveFile($save->getFile(), $databaseIdentifier);
}
$handler = $save->handler();
@@ -57,10 +61,10 @@ class UploadController extends BaseController
// 'mime_type' => $mime
// ]);
// }
protected function saveFile(UploadedFile $file, $resource)
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{
$mime = str_replace('/', '-', $file->getMimeType());
$filePath = "upload/{$resource->uuid}";
$filePath = "upload/{$resourceIdentifier}";
$finalPath = storage_path('app/'.$filePath);
$file->move($finalPath, 'restore');
+7 -7
View File
@@ -8,7 +8,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class BackupEdit extends Component
{
@@ -184,13 +183,14 @@ class BackupEdit extends Component
$this->backup->delete();
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$previousUrl = url()->previous();
$url = Url::fromString($previousUrl);
$url = $url->withoutQueryParameter('selectedBackupId');
$url = $url->withFragment('backups');
$url = $url->getPath()."#{$url->getFragment()}";
$serviceDatabase = $this->backup->database;
return redirect($url);
return redirect()->route('project.service.database.backups', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $serviceDatabase->service->uuid,
'stack_service_uuid' => $serviceDatabase->uuid,
]);
} else {
return redirect()->route('project.database.backup.index', $this->parameters);
}
+197 -34
View File
@@ -4,9 +4,11 @@ namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Import extends Component
@@ -101,11 +103,23 @@ class Import extends Component
public bool $unsupported = false;
public $resource;
// Store IDs instead of models for proper Livewire serialization
public ?int $resourceId = null;
public $parameters;
public ?string $resourceType = null;
public $containers;
public ?int $serverId = null;
// View-friendly properties to avoid computed property access in Blade
public string $resourceUuid = '';
public string $resourceStatus = '';
public string $resourceDbType = '';
public array $parameters = [];
public array $containers = [];
public bool $scpInProgress = false;
@@ -121,8 +135,6 @@ class Import extends Component
public bool $error = false;
public Server $server;
public string $container;
public array $importCommands = [];
@@ -144,7 +156,7 @@ class Import extends Component
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
// S3 Restore properties
public $availableS3Storages = [];
public array $availableS3Storages = [];
public ?int $s3StorageId = null;
@@ -152,6 +164,26 @@ class Import extends Component
public ?int $s3FileSize = null;
#[Computed]
public function resource()
{
if ($this->resourceId === null || $this->resourceType === null) {
return null;
}
return $this->resourceType::find($this->resourceId);
}
#[Computed]
public function server()
{
if ($this->serverId === null) {
return null;
}
return Server::find($this->serverId);
}
public function getListeners()
{
$userId = Auth::id();
@@ -176,8 +208,23 @@ class Import extends Component
public function updatedDumpAll($value)
{
switch ($this->resource->getMorphClass()) {
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
}
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
if ($value === true) {
$this->mariadbRestoreCommand = <<<'EOD'
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
@@ -193,6 +240,7 @@ EOD;
}
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
if ($value === true) {
$this->mysqlRestoreCommand = <<<'EOD'
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
@@ -208,6 +256,7 @@ EOD;
}
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
if ($value === true) {
$this->postgresqlRestoreCommand = <<<'EOD'
psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
@@ -225,30 +274,99 @@ EOD;
public function getContainers()
{
$this->containers = collect();
if (! data_get($this->parameters, 'database_uuid')) {
abort(404);
}
$resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id'));
if (is_null($resource)) {
$this->containers = [];
$teamId = data_get(auth()->user()->currentTeam(), 'id');
// Try to find resource by route parameter
$databaseUuid = data_get($this->parameters, 'database_uuid');
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
$resource = null;
if ($databaseUuid) {
// Standalone database route
$resource = getResourceByUuid($databaseUuid, $teamId);
if (is_null($resource)) {
abort(404);
}
} elseif ($stackServiceUuid) {
// ServiceDatabase route - look up the service database
$serviceUuid = data_get($this->parameters, 'service_uuid');
$service = Service::whereUuid($serviceUuid)->first();
if (! $service) {
abort(404);
}
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if (is_null($resource)) {
abort(404);
}
} else {
abort(404);
}
$this->authorize('view', $resource);
$this->resource = $resource;
$this->server = $this->resource->destination->server;
$this->container = $this->resource->uuid;
if (str(data_get($this, 'resource.status'))->startsWith('running')) {
$this->containers->push($this->container);
// Store IDs for Livewire serialization
$this->resourceId = $resource->id;
$this->resourceType = get_class($resource);
// Store view-friendly properties
$this->resourceStatus = $resource->status ?? '';
// Handle ServiceDatabase server access differently
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$server = $resource->service?->server;
if (! $server) {
abort(404, 'Server not found for this service database.');
}
$this->serverId = $server->id;
$this->container = $resource->name.'-'.$resource->service->uuid;
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
// Determine database type for ServiceDatabase
$dbType = $resource->databaseType();
if (str_contains($dbType, 'postgres')) {
$this->resourceDbType = 'standalone-postgresql';
} elseif (str_contains($dbType, 'mysql')) {
$this->resourceDbType = 'standalone-mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$this->resourceDbType = 'standalone-mariadb';
} elseif (str_contains($dbType, 'mongo')) {
$this->resourceDbType = 'standalone-mongodb';
} else {
$this->resourceDbType = $dbType;
}
} else {
$server = $resource->destination?->server;
if (! $server) {
abort(404, 'Server not found for this database.');
}
$this->serverId = $server->id;
$this->container = $resource->uuid;
$this->resourceUuid = $resource->uuid;
$this->resourceDbType = $resource->type();
}
if (str($resource->status)->startsWith('running')) {
$this->containers[] = $this->container;
}
if (
$this->resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
$resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
$resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
$resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
$resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
) {
$this->unsupported = true;
}
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$dbType = $resource->databaseType();
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
$this->unsupported = true;
}
}
}
public function checkFile()
@@ -261,6 +379,12 @@ EOD;
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
@@ -286,15 +410,22 @@ EOD;
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$this->importRunning = true;
$this->importCommands = [];
$backupFileName = "upload/{$this->resource->uuid}/restore";
$backupFileName = "upload/{$this->resourceUuid}/restore";
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
@@ -305,7 +436,7 @@ EOD;
return;
}
$tmpPath = '/tmp/restore_'.$this->resource->uuid;
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else {
@@ -315,7 +446,7 @@ EOD;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$restoreCommand = $this->buildRestoreCommand($tmpPath);
@@ -355,9 +486,11 @@ EOD;
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true)
->get();
->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
->toArray();
} catch (\Throwable $e) {
$this->availableS3Storages = collect();
$this->availableS3Storages = [];
}
}
@@ -460,6 +593,12 @@ EOD;
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$this->importRunning = true;
@@ -493,14 +632,18 @@ EOD;
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
}
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resource->uuid}";
$containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
// Prepare all commands in sequence
$commands = [];
@@ -575,8 +718,25 @@ EOD;
public function buildRestoreCommand(string $tmpPath): string
{
switch ($this->resource->getMorphClass()) {
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
} elseif (str_contains($dbType, 'mongo')) {
$morphClass = 'mongodb';
}
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
@@ -585,6 +745,7 @@ EOD;
}
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
@@ -593,6 +754,7 @@ EOD;
}
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
@@ -601,6 +763,7 @@ EOD;
}
break;
case \App\Models\StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {
$restoreCommand .= "{$tmpPath}";
-228
View File
@@ -1,228 +0,0 @@
<?php
namespace App\Livewire\Project\Service;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Database extends Component
{
use AuthorizesRequests;
public ServiceDatabase $database;
public ?string $db_url_public = null;
public $fileStorages;
public $parameters;
public ?string $humanName = null;
public ?string $description = null;
public ?string $image = null;
public bool $excludeFromStatus = false;
public ?int $publicPort = null;
public bool $isPublic = false;
public bool $isLogDrainEnabled = false;
protected $listeners = ['refreshFileStorages'];
protected $rules = [
'humanName' => 'nullable',
'description' => 'nullable',
'image' => 'required',
'excludeFromStatus' => 'required|boolean',
'publicPort' => 'nullable|integer',
'isPublic' => 'required|boolean',
'isLogDrainEnabled' => 'required|boolean',
];
public function render()
{
return view('livewire.project.service.database');
}
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->database);
if ($this->database->is_public) {
$this->db_url_public = $this->database->getServiceDatabaseUrl();
}
$this->refreshFileStorages();
$this->syncData(false);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->database->human_name = $this->humanName;
$this->database->description = $this->description;
$this->database->image = $this->image;
$this->database->exclude_from_status = $this->excludeFromStatus;
$this->database->public_port = $this->publicPort;
$this->database->is_public = $this->isPublic;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
} else {
$this->humanName = $this->database->human_name;
$this->description = $this->database->description;
$this->image = $this->database->image;
$this->excludeFromStatus = $this->database->exclude_from_status ?? false;
$this->publicPort = $this->database->public_port;
$this->isPublic = $this->database->is_public ?? false;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled ?? false;
}
}
public function delete($password)
{
try {
$this->authorize('delete', $this->database);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->database->delete();
$this->dispatch('success', 'Database deleted.');
return redirectRoute($this, 'project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveExclude()
{
try {
$this->authorize('update', $this->database);
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveLogDrain()
{
try {
$this->authorize('update', $this->database);
if (! $this->database->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->submit();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function convertToApplication()
{
try {
$this->authorize('update', $this->database);
$service = $this->database->service;
$serviceDatabase = $this->database;
// Check if application with same name already exists
if ($service->applications()->where('name', $serviceDatabase->name)->exists()) {
throw new \Exception('An application with this name already exists.');
}
// Create new parameters removing database_uuid
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceDatabase) {
$service->applications()->create([
'name' => $serviceDatabase->name,
'human_name' => $serviceDatabase->human_name,
'description' => $serviceDatabase->description,
'exclude_from_status' => $serviceDatabase->exclude_from_status,
'is_log_drain_enabled' => $serviceDatabase->is_log_drain_enabled,
'image' => $serviceDatabase->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceDatabase->delete();
});
return redirectRoute($this, 'project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->authorize('update', $this->database);
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
return;
}
$this->syncData(true);
if ($this->database->is_public) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
$this->database->is_public = false;
return;
}
StartDatabaseProxy::run($this->database);
$this->db_url_public = $this->database->getServiceDatabaseUrl();
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->db_url_public = null;
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function refreshFileStorages()
{
$this->fileStorages = $this->database->fileStorages()->get();
}
public function submit()
{
try {
$this->authorize('update', $this->database);
$this->validate();
$this->syncData(true);
$this->database->save();
$this->database->refresh();
$this->syncData(false);
updateCompose($this->database);
$this->dispatch('success', 'Database saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('generateDockerCompose');
}
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class DatabaseBackups extends Component
{
use AuthorizesRequests;
public ?Service $service = null;
public ?ServiceDatabase $serviceDatabase = null;
public array $parameters;
public array $query;
public bool $isImportSupported = false;
protected $listeners = ['refreshScheduledBackups' => '$refresh'];
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
}
$this->authorize('view', $this->service);
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
if (! $this->serviceDatabase) {
return redirect()->route('project.service.configuration', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $this->parameters['service_uuid'],
]);
}
// Check if backups are supported for this database
if (! $this->serviceDatabase->isBackupSolutionAvailable() && ! $this->serviceDatabase->is_migrated) {
return redirect()->route('project.service.index', $this->parameters);
}
// Check if import is supported for this database type
$dbType = $this->serviceDatabase->databaseType();
$supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo'];
$this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type));
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.service.database-backups');
}
}
+488 -1
View File
@@ -2,12 +2,17 @@
namespace App\Livewire\Project\Service;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
use Spatie\Url\Url;
class Index extends Component
{
@@ -19,6 +24,10 @@ class Index extends Component
public ?ServiceDatabase $serviceDatabase = null;
public ?string $resourceType = null;
public ?string $currentRoute = null;
public array $parameters;
public array $query;
@@ -27,7 +36,67 @@ class Index extends Component
public $s3s;
protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh'];
public ?Server $server = null;
// Database-specific properties
public ?string $db_url_public = null;
public $fileStorages;
public ?string $humanName = null;
public ?string $description = null;
public ?string $image = null;
public bool $excludeFromStatus = false;
public ?int $publicPort = null;
public bool $isPublic = false;
public bool $isLogDrainEnabled = false;
public bool $isImportSupported = false;
// Application-specific properties
public $docker_cleanup = true;
public $delete_volumes = true;
public $domainConflicts = [];
public $showDomainConflictModal = false;
public $forceSaveDomains = false;
public $showPortWarningModal = false;
public $forceRemovePort = false;
public $requiredPort = null;
public ?string $fqdn = null;
public bool $isGzipEnabled = false;
public bool $isStripprefixEnabled = false;
protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh', 'refreshFileStorages'];
protected $rules = [
'humanName' => 'nullable',
'description' => 'nullable',
'image' => 'required',
'excludeFromStatus' => 'required|boolean',
'publicPort' => 'nullable|integer',
'isPublic' => 'required|boolean',
'isLogDrainEnabled' => 'required|boolean',
// Application-specific rules
'fqdn' => 'nullable',
'isGzipEnabled' => 'nullable|boolean',
'isStripprefixEnabled' => 'nullable|boolean',
];
public function mount()
{
@@ -35,6 +104,7 @@ class Index extends Component
$this->services = collect([]);
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->currentRoute = request()->route()->getName();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
@@ -43,10 +113,21 @@ class Index extends Component
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) {
$this->serviceApplication = $service;
$this->resourceType = 'application';
$this->serviceApplication->getFilesFromServer();
$this->initializeApplicationProperties();
} else {
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
if (! $this->serviceDatabase) {
return redirect()->route('project.service.configuration', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $this->parameters['service_uuid'],
]);
}
$this->resourceType = 'database';
$this->serviceDatabase->getFilesFromServer();
$this->initializeDatabaseProperties();
}
$this->s3s = currentTeam()->s3s;
} catch (\Throwable $e) {
@@ -54,6 +135,42 @@ class Index extends Component
}
}
private function initializeDatabaseProperties(): void
{
$this->server = $this->serviceDatabase->service->destination->server;
if ($this->serviceDatabase->is_public) {
$this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl();
}
$this->refreshFileStorages();
$this->syncDatabaseData(false);
// Check if import is supported for this database type
$dbType = $this->serviceDatabase->databaseType();
$supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo'];
$this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type));
}
private function syncDatabaseData(bool $toModel = false): void
{
if ($toModel) {
$this->serviceDatabase->human_name = $this->humanName;
$this->serviceDatabase->description = $this->description;
$this->serviceDatabase->image = $this->image;
$this->serviceDatabase->exclude_from_status = $this->excludeFromStatus;
$this->serviceDatabase->public_port = $this->publicPort;
$this->serviceDatabase->is_public = $this->isPublic;
$this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled;
} else {
$this->humanName = $this->serviceDatabase->human_name;
$this->description = $this->serviceDatabase->description;
$this->image = $this->serviceDatabase->image;
$this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false;
$this->publicPort = $this->serviceDatabase->public_port;
$this->isPublic = $this->serviceDatabase->is_public ?? false;
$this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false;
}
}
public function generateDockerCompose()
{
try {
@@ -64,6 +181,376 @@ class Index extends Component
}
}
// Database-specific methods
public function refreshFileStorages()
{
if ($this->serviceDatabase) {
$this->fileStorages = $this->serviceDatabase->fileStorages()->get();
}
}
public function deleteDatabase($password)
{
try {
$this->authorize('delete', $this->serviceDatabase);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->serviceDatabase->delete();
$this->dispatch('success', 'Database deleted.');
return redirectRoute($this, 'project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveExclude()
{
try {
$this->authorize('update', $this->serviceDatabase);
$this->submitDatabase();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveLogDrain()
{
try {
$this->authorize('update', $this->serviceDatabase);
if (! $this->serviceDatabase->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->submitDatabase();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function convertToApplication()
{
try {
$this->authorize('update', $this->serviceDatabase);
$service = $this->serviceDatabase->service;
$serviceDatabase = $this->serviceDatabase;
// Check if application with same name already exists
if ($service->applications()->where('name', $serviceDatabase->name)->exists()) {
throw new \Exception('An application with this name already exists.');
}
// Create new parameters removing database_uuid
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceDatabase) {
$service->applications()->create([
'name' => $serviceDatabase->name,
'human_name' => $serviceDatabase->human_name,
'description' => $serviceDatabase->description,
'exclude_from_status' => $serviceDatabase->exclude_from_status,
'is_log_drain_enabled' => $serviceDatabase->is_log_drain_enabled,
'image' => $serviceDatabase->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceDatabase->delete();
});
return redirectRoute($this, 'project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->authorize('update', $this->serviceDatabase);
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
return;
}
$this->syncDatabaseData(true);
if ($this->serviceDatabase->is_public) {
if (! str($this->serviceDatabase->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
$this->serviceDatabase->is_public = false;
return;
}
StartDatabaseProxy::run($this->serviceDatabase);
$this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl();
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->serviceDatabase);
$this->db_url_public = null;
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->submitDatabase();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitDatabase()
{
try {
$this->authorize('update', $this->serviceDatabase);
$this->validate();
$this->syncDatabaseData(true);
$this->serviceDatabase->save();
$this->serviceDatabase->refresh();
$this->syncDatabaseData(false);
updateCompose($this->serviceDatabase);
$this->dispatch('success', 'Database saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('generateDockerCompose');
}
}
// Application-specific methods
private function initializeApplicationProperties(): void
{
$this->requiredPort = $this->serviceApplication->getRequiredPort();
$this->syncApplicationData(false);
}
private function syncApplicationData(bool $toModel = false): void
{
if ($toModel) {
$this->serviceApplication->human_name = $this->humanName;
$this->serviceApplication->description = $this->description;
$this->serviceApplication->fqdn = $this->fqdn;
$this->serviceApplication->image = $this->image;
$this->serviceApplication->exclude_from_status = $this->excludeFromStatus;
$this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled;
$this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled;
} else {
$this->humanName = $this->serviceApplication->human_name;
$this->description = $this->serviceApplication->description;
$this->fqdn = $this->serviceApplication->fqdn;
$this->image = $this->serviceApplication->image;
$this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false);
$this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false);
$this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true);
$this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true);
}
}
public function instantSaveApplication()
{
try {
$this->authorize('update', $this->serviceApplication);
$this->submitApplication();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveApplicationSettings()
{
try {
$this->authorize('update', $this->serviceApplication);
$this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled;
$this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->serviceApplication->exclude_from_status = $this->excludeFromStatus;
$this->serviceApplication->save();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveApplicationAdvanced()
{
try {
$this->authorize('update', $this->serviceApplication);
if (! $this->serviceApplication->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->syncApplicationData(true);
$this->serviceApplication->save();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deleteApplication($password)
{
try {
$this->authorize('delete', $this->serviceApplication);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->serviceApplication->delete();
$this->dispatch('success', 'Application deleted.');
return redirect()->route('project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function convertToDatabase()
{
try {
$this->authorize('update', $this->serviceApplication);
$service = $this->serviceApplication->service;
$serviceApplication = $this->serviceApplication;
if ($service->databases()->where('name', $serviceApplication->name)->exists()) {
throw new \Exception('A database with this name already exists.');
}
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceApplication) {
$service->databases()->create([
'name' => $serviceApplication->name,
'human_name' => $serviceApplication->human_name,
'description' => $serviceApplication->description,
'exclude_from_status' => $serviceApplication->exclude_from_status,
'is_log_drain_enabled' => $serviceApplication->is_log_drain_enabled,
'image' => $serviceApplication->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceApplication->delete();
});
return redirect()->route('project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function confirmDomainUsage()
{
$this->forceSaveDomains = true;
$this->showDomainConflictModal = false;
$this->submitApplication();
}
public function confirmRemovePort()
{
$this->forceRemovePort = true;
$this->showPortWarningModal = false;
$this->submitApplication();
}
public function cancelRemovePort()
{
$this->showPortWarningModal = false;
$this->syncApplicationData(false);
}
public function submitApplication()
{
try {
$this->authorize('update', $this->serviceApplication);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
$this->syncApplicationData(true);
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->serviceApplication);
if ($result['hasConflicts']) {
$this->domainConflicts = $result['conflicts'];
$this->showDomainConflictModal = true;
return;
}
} else {
$this->forceSaveDomains = false;
}
if (! $this->forceRemovePort) {
$requiredPort = $this->serviceApplication->getRequiredPort();
if ($requiredPort !== null) {
$fqdns = str($this->fqdn)->trim()->explode(',');
$missingPort = false;
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
$this->forceRemovePort = false;
}
$this->validate();
$this->serviceApplication->save();
$this->serviceApplication->refresh();
$this->syncApplicationData(false);
updateCompose($this->serviceApplication);
if (str($this->serviceApplication->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
} else {
! $warning && $this->dispatch('success', 'Service saved.');
}
$this->dispatch('generateDockerCompose');
} catch (\Throwable $e) {
$originalFqdn = $this->serviceApplication->getOriginal('fqdn');
if ($originalFqdn !== $this->serviceApplication->fqdn) {
$this->serviceApplication->fqdn = $originalFqdn;
$this->syncApplicationData(false);
}
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.service.index');
@@ -1,345 +0,0 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class ServiceApplicationView extends Component
{
use AuthorizesRequests;
public ServiceApplication $application;
public $parameters;
public $docker_cleanup = true;
public $delete_volumes = true;
public $domainConflicts = [];
public $showDomainConflictModal = false;
public $forceSaveDomains = false;
public $showPortWarningModal = false;
public $forceRemovePort = false;
public $requiredPort = null;
#[Validate(['nullable'])]
public ?string $humanName = null;
#[Validate(['nullable'])]
public ?string $description = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
#[Validate(['string', 'nullable'])]
public ?string $image = null;
#[Validate(['required', 'boolean'])]
public bool $excludeFromStatus = false;
#[Validate(['nullable', 'boolean'])]
public bool $isLogDrainEnabled = false;
#[Validate(['nullable', 'boolean'])]
public bool $isGzipEnabled = false;
#[Validate(['nullable', 'boolean'])]
public bool $isStripprefixEnabled = false;
protected $rules = [
'humanName' => 'nullable',
'description' => 'nullable',
'fqdn' => 'nullable',
'image' => 'string|nullable',
'excludeFromStatus' => 'required|boolean',
'application.required_fqdn' => 'required|boolean',
'isLogDrainEnabled' => 'nullable|boolean',
'isGzipEnabled' => 'nullable|boolean',
'isStripprefixEnabled' => 'nullable|boolean',
];
public function instantSave()
{
try {
$this->authorize('update', $this->application);
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveSettings()
{
try {
$this->authorize('update', $this->application);
// Save checkbox states without port validation
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->save();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveAdvanced()
{
try {
$this->authorize('update', $this->application);
if (! $this->application->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
// Sync component properties to model
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->save();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function delete($password)
{
try {
$this->authorize('delete', $this->application);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->application->delete();
$this->dispatch('success', 'Application deleted.');
return redirect()->route('project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
$this->requiredPort = $this->application->getRequiredPort();
$this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function confirmRemovePort()
{
$this->forceRemovePort = true;
$this->showPortWarningModal = false;
$this->submit();
}
public function cancelRemovePort()
{
$this->showPortWarningModal = false;
$this->syncData(); // Reset to original FQDN
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync to model
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->save();
} else {
// Sync from model
$this->humanName = $this->application->human_name;
$this->description = $this->application->description;
$this->fqdn = $this->application->fqdn;
$this->image = $this->application->image;
$this->excludeFromStatus = data_get($this->application, 'exclude_from_status', false);
$this->isLogDrainEnabled = data_get($this->application, 'is_log_drain_enabled', false);
$this->isGzipEnabled = data_get($this->application, 'is_gzip_enabled', true);
$this->isStripprefixEnabled = data_get($this->application, 'is_stripprefix_enabled', true);
}
}
public function convertToDatabase()
{
try {
$this->authorize('update', $this->application);
$service = $this->application->service;
$serviceApplication = $this->application;
// Check if database with same name already exists
if ($service->databases()->where('name', $serviceApplication->name)->exists()) {
throw new \Exception('A database with this name already exists.');
}
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceApplication) {
$service->databases()->create([
'name' => $serviceApplication->name,
'human_name' => $serviceApplication->human_name,
'description' => $serviceApplication->description,
'exclude_from_status' => $serviceApplication->exclude_from_status,
'is_log_drain_enabled' => $serviceApplication->is_log_drain_enabled,
'image' => $serviceApplication->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceApplication->delete();
});
return redirect()->route('project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function confirmDomainUsage()
{
$this->forceSaveDomains = true;
$this->showDomainConflictModal = false;
$this->submit();
}
public function submit()
{
try {
$this->authorize('update', $this->application);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
// Sync to model for domain conflict check (without validation)
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
if ($result['hasConflicts']) {
$this->domainConflicts = $result['conflicts'];
$this->showDomainConflictModal = true;
return;
}
} else {
// Reset the force flag after using it
$this->forceSaveDomains = false;
}
// Check for required port
if (! $this->forceRemovePort) {
$requiredPort = $this->application->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port
$fqdns = str($this->fqdn)->trim()->explode(',');
$missingPort = false;
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
// Reset the force flag after using it
$this->forceRemovePort = false;
}
$this->validate();
$this->application->save();
$this->application->refresh();
$this->syncData();
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
} else {
! $warning && $this->dispatch('success', 'Service saved.');
}
$this->dispatch('generateDockerCompose');
} catch (\Throwable $e) {
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncData();
}
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.service.service-application-view', [
'checkboxes' => [
['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')],
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],
// ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'],
// ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'],
// ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.']
],
]);
}
}