mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
feat: add Hostinger VPS, multi-destination backups and maintenance pages
- Add Hostinger server provisioning, API endpoints and lifecycle controls. - Support multiple S3 destinations for database and volume backups. - Add proxy error pages and application and service maintenance mode. - Detect repository configuration and support environment imports. - Add HTTP basic auth hash options and Redis-compatible database flushing. - Improve Railpack builder cleanup and development instance backups.
This commit is contained in:
commit
5cece62545
194 files changed
+13145
-1603
No files matched your search
@@ -12,6 +12,7 @@
|
||||
- SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f <name> || true` before `docker run --name <name>`).
|
||||
- Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open.
|
||||
- Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`.
|
||||
- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error.
|
||||
- Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only.
|
||||
- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`.
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ class CleanupCancelledDeployment
|
||||
if (str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process(["docker rm -f {$deploymentUuid}"], $server);
|
||||
$containerStopped = true;
|
||||
|
||||
// A removed helper cannot stop the Railpack builder, which keeps the memory of its builds.
|
||||
if ($deployment->application?->build_pack === 'railpack') {
|
||||
$helperImage = coolifyHelperImage().':'.getHelperVersion();
|
||||
instant_remote_process([railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImage, 'true')], $server, false);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$firstError ??= $e;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use RuntimeException;
|
||||
|
||||
class FlushDatabase
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): void
|
||||
{
|
||||
$cli = $database instanceof StandaloneKeydb ? 'keydb-cli' : 'redis-cli';
|
||||
if ($database instanceof StandaloneDragonfly && $database->enable_ssl) {
|
||||
$cli .= ' --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key';
|
||||
}
|
||||
|
||||
$output = instant_remote_process(
|
||||
["docker exec {$database->uuid} sh -c 'REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'"],
|
||||
$database->destination->server,
|
||||
);
|
||||
|
||||
if ($output !== 'OK') {
|
||||
throw new RuntimeException($output ?: 'Failed to flush the database.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Development;
|
||||
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
/**
|
||||
* Starts a `coolify-db` container on a development VM that acts as the localhost server.
|
||||
*
|
||||
* The instance backup reads the credentials from this container and dumps it, like on a self-hosted install.
|
||||
* The real development database runs on the host, so the VM cannot reach it.
|
||||
*/
|
||||
class StartDevelopmentInstanceDatabase
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server): void
|
||||
{
|
||||
$environment = collect([
|
||||
'POSTGRES_USER' => config('database.connections.pgsql.username'),
|
||||
'POSTGRES_PASSWORD' => config('database.connections.pgsql.password'),
|
||||
'POSTGRES_DB' => config('database.connections.pgsql.database'),
|
||||
])->map(fn ($value, string $key): string => '-e '.escapeshellarg("{$key}={$value}"))->implode(' ');
|
||||
|
||||
instant_remote_process([
|
||||
'if ! docker inspect coolify-db >/dev/null 2>&1; then',
|
||||
"docker run -d --name coolify-db --restart unless-stopped {$environment} postgres:15-alpine",
|
||||
'fi',
|
||||
'docker start coolify-db',
|
||||
], $server, disableMultiplexing: true);
|
||||
}
|
||||
}
|
||||
@@ -56,10 +56,11 @@ class StartProxy
|
||||
"echo 'Successfully started coolify-proxy.'",
|
||||
]);
|
||||
} else {
|
||||
$caddyfile = 'import /dynamic/*.caddy';
|
||||
// The base Caddyfile also holds the maintenance blocks (see Server::caddyBaseCaddyfile()).
|
||||
$caddyfile = base64_encode($server->caddyBaseCaddyfile());
|
||||
$commands = $commands->merge([
|
||||
"mkdir -p $proxy_path/dynamic",
|
||||
"echo '$caddyfile' | tee $proxy_path/dynamic/Caddyfile > /dev/null",
|
||||
"echo '$caddyfile' | base64 -d | tee $proxy_path/dynamic/Caddyfile > /dev/null",
|
||||
"echo 'Creating required Docker Compose file.'",
|
||||
"echo 'Pulling docker image.'",
|
||||
"docker compose -f $compose_file pull",
|
||||
|
||||
@@ -23,11 +23,16 @@ class StopProxy
|
||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||
}
|
||||
|
||||
$errorPageCommands = $server->isSwarm() ? [] : [
|
||||
'docker rm -f '.Server::PROXY_ERROR_PAGE_CONTAINER.' 2>/dev/null || true',
|
||||
];
|
||||
|
||||
instant_remote_process(command: [
|
||||
dockerStopCommand($timeout, $containerName, $server).' 2>/dev/null || true',
|
||||
"docker rm -f $containerName 2>/dev/null || true",
|
||||
// The Traefik traffic analytics log rotation sidecar must not outlive the proxy.
|
||||
'docker rm -f '.TRAEFIK_LOGROTATE_CONTAINER.' 2>/dev/null || true',
|
||||
...$errorPageCommands,
|
||||
'# Wait for container to be fully removed',
|
||||
'for i in {1..10}; do',
|
||||
" if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then",
|
||||
|
||||
@@ -135,9 +135,6 @@ class CleanupDocker implements ShouldBeUnique
|
||||
$helperImageWithVersion = "$helperImage:$helperImageVersion";
|
||||
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
|
||||
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
|
||||
$buildxMetadataVolume = isDev() && $server->isLocalhost()
|
||||
? 'coolify-buildx'
|
||||
: '$HOME/.docker/buildx';
|
||||
|
||||
$cleanupLog = [];
|
||||
|
||||
@@ -164,7 +161,8 @@ class CleanupDocker implements ShouldBeUnique
|
||||
$this->buildContainerPruneCommand(),
|
||||
$imagePruneCmd,
|
||||
'docker builder prune -af',
|
||||
"docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
|
||||
// The prune fails on a stopped builder, so start it first.
|
||||
railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImageWithVersion, 'docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; docker buildx prune --builder coolify-railpack -af'),
|
||||
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
];
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Team;
|
||||
use App\Notifications\Server\HetznerDeletionFailed;
|
||||
use App\Services\DigitalOceanService;
|
||||
use App\Services\HetznerService;
|
||||
use App\Services\HostingerService;
|
||||
use App\Services\VultrService;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
@@ -15,7 +16,7 @@ class DeleteServer
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null)
|
||||
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null, bool $deleteFromHostinger = false, ?int $hostingerVirtualMachineId = null)
|
||||
{
|
||||
$server = Server::withTrashed()->find($serverId);
|
||||
|
||||
@@ -44,6 +45,14 @@ class DeleteServer
|
||||
);
|
||||
}
|
||||
|
||||
if ($deleteFromHostinger && ($hostingerVirtualMachineId || ($server && $server->hostinger_virtual_machine_id))) {
|
||||
$this->deleteFromHostingerById(
|
||||
$hostingerVirtualMachineId ?? $server->hostinger_virtual_machine_id,
|
||||
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
|
||||
$teamId ?? $server->team_id
|
||||
);
|
||||
}
|
||||
|
||||
logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
|
||||
|
||||
// If server is already deleted from Coolify, skip this part
|
||||
@@ -182,4 +191,50 @@ class DeleteServer
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteFromHostingerById(int $hostingerVirtualMachineId, ?int $cloudProviderTokenId, int $teamId): void
|
||||
{
|
||||
try {
|
||||
$token = null;
|
||||
|
||||
if ($cloudProviderTokenId) {
|
||||
$token = CloudProviderToken::where('id', $cloudProviderTokenId)
|
||||
->where('team_id', $teamId)
|
||||
->where('provider', 'hostinger')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
$token = CloudProviderToken::where('team_id', $teamId)
|
||||
->where('provider', 'hostinger')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
throw new \RuntimeException('No Hostinger token found for the server team.');
|
||||
}
|
||||
|
||||
$hostingerService = new HostingerService($token->token);
|
||||
$subscriptionId = $hostingerService->getVirtualMachine($hostingerVirtualMachineId)['subscription_id'] ?? null;
|
||||
|
||||
if (! $subscriptionId) {
|
||||
throw new \RuntimeException('The Hostinger VPS has no subscription.');
|
||||
}
|
||||
|
||||
$hostingerService->disableAutoRenewal($subscriptionId);
|
||||
|
||||
logger()->debug('Disabled Hostinger auto-renewal, so Hostinger deletes the VPS at the end of the paid period', [
|
||||
'hostinger_virtual_machine_id' => $hostingerVirtualMachineId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->error('Failed to delete VPS from Hostinger', [
|
||||
'error' => $e->getMessage(),
|
||||
'hostinger_virtual_machine_id' => $hostingerVirtualMachineId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,15 @@ class ValidateServer
|
||||
}
|
||||
}
|
||||
|
||||
if ($server->hostinger_virtual_machine_id) {
|
||||
$status = $server->refreshHostingerState();
|
||||
if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) {
|
||||
$this->error = 'Hostinger VPS is '.($status ?? 'not running').'. Power it on before validating.';
|
||||
$server->update(['validation_logs' => $this->error]);
|
||||
throw new \Exception($this->error);
|
||||
}
|
||||
}
|
||||
|
||||
['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection();
|
||||
if (! $this->uptime) {
|
||||
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||
|
||||
@@ -66,6 +67,14 @@ class StartService
|
||||
}
|
||||
}
|
||||
$commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service));
|
||||
if ($service->is_maintenance_enabled) {
|
||||
// Domains can change with the compose file, so the maintenance routes follow them.
|
||||
try {
|
||||
$service->syncMaintenancePage();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Maintenance page update failed while starting service.', ['service_uuid' => $service->uuid, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: $service->id, queue: deployment_queue());
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Actions\Shared;
|
||||
|
||||
use App\Jobs\VolumeBackupJob;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
@@ -52,23 +53,10 @@ class DeleteScheduledVolumeBackup
|
||||
}
|
||||
|
||||
if ($deleteS3Archives) {
|
||||
$s3Executions = $backup->executions()
|
||||
->with('s3')
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false)
|
||||
->get();
|
||||
|
||||
foreach ($s3Executions->groupBy('s3_storage_id') as $executions) {
|
||||
$s3 = $executions->first()->s3;
|
||||
if (! $s3) {
|
||||
throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.');
|
||||
}
|
||||
|
||||
$filenames = $executions->pluck('filename')->filter()->all();
|
||||
if ($filenames !== []) {
|
||||
deleteBackupsS3($filenames, $s3);
|
||||
}
|
||||
}
|
||||
$backup->executions()
|
||||
->whereHas('s3Replicas', fn ($query) => $query->where('s3_uploaded', true)->where('s3_storage_deleted', false))
|
||||
->get()
|
||||
->each(fn (ScheduledVolumeBackupExecution $execution) => $execution->deleteS3Copies());
|
||||
}
|
||||
|
||||
$backup->delete();
|
||||
|
||||
@@ -17,7 +17,8 @@ class OpenApi extends Command
|
||||
// Generate OpenAPI documentation
|
||||
echo "Generating OpenAPI documentation.\n";
|
||||
// https://github.com/OAI/OpenAPI-Specification/releases
|
||||
$process = Process::run([
|
||||
// swagger-php 6 leaves PHP warnings to PHP itself; with Xdebug each one carries a stack trace and exhausts memory.
|
||||
$process = Process::env(['XDEBUG_MODE' => 'off'])->run([
|
||||
'./vendor/bin/openapi',
|
||||
'app',
|
||||
// Server transfer endpoints are development-only (isDev()), so they stay out of the public docs.
|
||||
@@ -28,11 +29,11 @@ class OpenApi extends Command
|
||||
'--version',
|
||||
'3.1.0',
|
||||
]);
|
||||
$error = $process->errorOutput();
|
||||
$error = preg_replace('/^.*an object literal,.*$/m', '', $error);
|
||||
$error = preg_replace('/^\h*\v+/m', '', $error);
|
||||
echo $error;
|
||||
echo $process->output();
|
||||
foreach ([$process->errorOutput(), $process->output()] as $output) {
|
||||
$output = preg_replace('/^.*an object literal,.*$/m', '', $output);
|
||||
$output = preg_replace('/^\h*\v+/m', '', $output);
|
||||
echo $output;
|
||||
}
|
||||
|
||||
$yaml = file_get_contents('openapi.yaml');
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Development\SeedDevelopmentQemuServer;
|
||||
use App\Actions\Development\StartDevelopmentInstanceDatabase;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class SeedDevelopmentQemuServerCommand extends Command
|
||||
{
|
||||
@@ -22,6 +24,15 @@ class SeedDevelopmentQemuServerCommand extends Command
|
||||
$server = SeedDevelopmentQemuServer::run($this->argument('profile'), ! $this->option('keep-others'), (bool) $this->option('as-localhost'), $this->option('ip') ?: null, (int) $this->option('port'));
|
||||
$this->info("Seeded {$server->name} at {$server->ip}.");
|
||||
|
||||
if ($this->option('as-localhost')) {
|
||||
try {
|
||||
StartDevelopmentInstanceDatabase::run($server);
|
||||
$this->info('Started the coolify-db container for instance backups.');
|
||||
} catch (Throwable $e) {
|
||||
$this->warn("Could not start the coolify-db container for instance backups: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
use App\Enums\BuildPackTypes;
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
class RepositoryDetectionResult extends Data
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $dockerfiles e.g. ['Dockerfile', 'apps/api/Dockerfile']
|
||||
* @param array<int, string> $dockerComposeFiles e.g. ['docker-compose.yml']
|
||||
* @param array<string, string|null> $envFiles e.g. ['.env.example' => 'KEY=val...', '.env.dist' => null]
|
||||
* @param array<string, int|null> $dockerfilePorts e.g. ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080]
|
||||
*/
|
||||
public function __construct(
|
||||
public array $dockerfiles = [],
|
||||
public array $dockerComposeFiles = [],
|
||||
public array $envFiles = [],
|
||||
public array $dockerfilePorts = [],
|
||||
) {}
|
||||
|
||||
public static function none(): self
|
||||
{
|
||||
return new self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build pack for the files in the base directory. Files in subdirectories are often examples,
|
||||
* tests, or other services, so they do not select a build pack.
|
||||
*/
|
||||
public function getSuggestedBuildPack(): ?BuildPackTypes
|
||||
{
|
||||
if ($this->baseDirectoryFile($this->dockerComposeFiles)) {
|
||||
return BuildPackTypes::DOCKERCOMPOSE;
|
||||
}
|
||||
|
||||
if ($this->baseDirectoryFile($this->dockerfiles)) {
|
||||
return BuildPackTypes::DOCKERFILE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first file that is directly in the base directory, not in a subdirectory.
|
||||
*
|
||||
* @param array<int, string> $files
|
||||
*/
|
||||
public function baseDirectoryFile(array $files): ?string
|
||||
{
|
||||
return collect($files)->first(fn (string $file): bool => ! str_contains($file, '/'));
|
||||
}
|
||||
|
||||
public function hasDockerfile(): bool
|
||||
{
|
||||
return count($this->dockerfiles) > 0;
|
||||
}
|
||||
|
||||
public function hasDockerCompose(): bool
|
||||
{
|
||||
return count($this->dockerComposeFiles) > 0;
|
||||
}
|
||||
|
||||
public function hasEnvFiles(): bool
|
||||
{
|
||||
return count($this->envFiles) > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum HttpBasicAuthHashAlgorithm: string
|
||||
{
|
||||
case BCRYPT = 'bcrypt';
|
||||
case ARGON2ID = 'argon2id';
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Actions\Application\LoadComposeFile;
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Enums\BuildPackTypes;
|
||||
use App\Enums\HttpBasicAuthHashAlgorithm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
@@ -445,6 +446,10 @@ class ApplicationsController extends Controller
|
||||
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
|
||||
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
|
||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
|
||||
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'],
|
||||
@@ -640,6 +645,10 @@ class ApplicationsController extends Controller
|
||||
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
|
||||
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
|
||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
|
||||
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'],
|
||||
@@ -835,6 +844,10 @@ class ApplicationsController extends Controller
|
||||
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
|
||||
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
|
||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
|
||||
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'],
|
||||
@@ -1001,6 +1014,10 @@ class ApplicationsController extends Controller
|
||||
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
|
||||
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
|
||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
|
||||
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'],
|
||||
@@ -1163,6 +1180,10 @@ class ApplicationsController extends Controller
|
||||
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
|
||||
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
|
||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
|
||||
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'],
|
||||
@@ -1248,7 +1269,7 @@ class ApplicationsController extends Controller
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
@@ -1260,7 +1281,9 @@ class ApplicationsController extends Controller
|
||||
'destination_uuid' => 'string',
|
||||
'is_http_basic_auth_enabled' => 'boolean',
|
||||
'http_basic_auth_username' => 'string|nullable',
|
||||
'http_basic_auth_password' => 'string|nullable',
|
||||
// bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on
|
||||
// Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily.
|
||||
'http_basic_auth_password' => 'string|nullable|max:72',
|
||||
'autogenerate_domain' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
@@ -3130,6 +3153,10 @@ class ApplicationsController extends Controller
|
||||
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
|
||||
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
|
||||
'use_build_secrets' => ['type' => 'boolean', 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
|
||||
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
|
||||
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
|
||||
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
|
||||
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
|
||||
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
|
||||
@@ -3250,7 +3277,7 @@ class ApplicationsController extends Controller
|
||||
$dnsCleanup = app(ManagedDnsRecordCleanup::class);
|
||||
$previousDnsHostnames = $dnsCleanup->hostnamesOf($application);
|
||||
$server = $application->destination->server;
|
||||
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
|
||||
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
|
||||
|
||||
$validationRules = [
|
||||
'name' => 'string|max:255',
|
||||
@@ -3265,7 +3292,9 @@ class ApplicationsController extends Controller
|
||||
'is_http_basic_auth_enabled' => 'boolean|nullable',
|
||||
'is_preview_deployments_enabled' => 'boolean|nullable',
|
||||
'http_basic_auth_username' => 'string',
|
||||
'http_basic_auth_password' => 'string',
|
||||
// bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on
|
||||
// Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily.
|
||||
'http_basic_auth_password' => 'string|max:72',
|
||||
'include_source_commit_in_build' => 'boolean',
|
||||
'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/',
|
||||
];
|
||||
@@ -3347,9 +3376,15 @@ class ApplicationsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$requestHasHttpBasicAuth = $request->has('is_http_basic_auth_enabled')
|
||||
|| $request->has('http_basic_auth_username')
|
||||
|| $request->has('http_basic_auth_password');
|
||||
$requestHasHttpBasicAuth = $request->hasAny([
|
||||
'is_http_basic_auth_enabled',
|
||||
'http_basic_auth_username',
|
||||
'http_basic_auth_password',
|
||||
'http_basic_auth_hash_algorithm',
|
||||
'http_basic_auth_bcrypt_cost',
|
||||
'http_basic_auth_argon2id_memory_cost',
|
||||
'http_basic_auth_argon2id_time_cost',
|
||||
]);
|
||||
|
||||
if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) {
|
||||
if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) {
|
||||
@@ -4894,6 +4929,15 @@ class ApplicationsController extends Controller
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if ($request->input('http_basic_auth_hash_algorithm') === HttpBasicAuthHashAlgorithm::ARGON2ID->value && ! $server->caddySupportsArgon2idBasicAuth()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'http_basic_auth_hash_algorithm' => 'Argon2id needs a server that runs the Caddy proxy, version 2.11 or newer.',
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate ports_mappings
|
||||
if ($request->has('ports_mappings')) {
|
||||
$ports = [];
|
||||
|
||||
@@ -45,6 +45,10 @@ class CloudProviderTokensController extends Controller
|
||||
'vultr' => Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.vultr.com/v2/account'),
|
||||
'hostinger' => Http::withToken($token)
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
->get('https://developers.hostinger.com/api/vps/v1/virtual-machines'),
|
||||
default => null,
|
||||
};
|
||||
|
||||
@@ -90,7 +94,7 @@ class CloudProviderTokensController extends Controller
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']],
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger']],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'servers_count' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
@@ -208,7 +212,7 @@ class CloudProviderTokensController extends Controller
|
||||
type: 'object',
|
||||
required: ['provider', 'token', 'name'],
|
||||
properties: [
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
|
||||
'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
|
||||
'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
|
||||
],
|
||||
@@ -263,7 +267,7 @@ class CloudProviderTokensController extends Controller
|
||||
$body = $request->json()->all();
|
||||
|
||||
$validator = customApiValidator($body, [
|
||||
'provider' => 'required|string|in:hetzner,digitalocean,vultr',
|
||||
'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger',
|
||||
'token' => 'required|string',
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
@@ -32,6 +32,8 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -152,6 +154,58 @@ class DatabasesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the S3 destinations of a backup request, primary first. `s3_storage_uuids` lists every destination;
|
||||
* `s3_storage_uuid` alone selects a single destination, and sent with `s3_storage_uuids` it must be in the list and
|
||||
* becomes the primary destination.
|
||||
*
|
||||
* @return Collection<int, S3Storage>|JsonResponse|null Null when the request does not select destinations.
|
||||
*/
|
||||
private function requestedBackupS3Storages(Request $request, int|string $teamId): Collection|JsonResponse|null
|
||||
{
|
||||
$hasList = $request->has('s3_storage_uuids');
|
||||
if (! $hasList && ! $request->filled('s3_storage_uuid')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$uuids = collect($hasList ? $request->input('s3_storage_uuids') : [$request->input('s3_storage_uuid')])->unique()->values();
|
||||
$primaryUuid = $request->input('s3_storage_uuid') ?? $uuids->first();
|
||||
$errorField = $hasList ? 's3_storage_uuids' : 's3_storage_uuid';
|
||||
|
||||
if (filled($primaryUuid) && ! $uuids->contains($primaryUuid)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid must be one of the s3_storage_uuids.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storages = S3Storage::ownedByCurrentTeamAPI($teamId)->whereIn('uuid', $uuids->all())->get();
|
||||
if ($storages->count() !== $uuids->count()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$errorField => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
return $storages->sortBy(fn (S3Storage $storage): int => $storage->uuid === $primaryUuid ? 0 : 1)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the S3 destination UUIDs (primary first) to a serialized backup without exposing the storages themselves.
|
||||
*/
|
||||
private function withBackupS3StorageUuids(ScheduledDatabaseBackup $backup): ScheduledDatabaseBackup
|
||||
{
|
||||
$storages = $backup->s3Storages->sortBy(fn (S3Storage $storage): int => $storage->id === $backup->s3_storage_id ? 0 : 1);
|
||||
if ($storages->isEmpty() && $backup->s3) {
|
||||
$storages = collect([$backup->s3]);
|
||||
}
|
||||
|
||||
$backup->setAttribute('s3_storage_uuid', $backup->s3?->uuid);
|
||||
$backup->setAttribute('s3_storage_uuids', $storages->pluck('uuid')->values()->all());
|
||||
|
||||
return $backup->unsetRelation('s3Storages')->unsetRelation('s3');
|
||||
}
|
||||
|
||||
private function removeSensitiveData($database, bool $loadNestedServerSecrets = false)
|
||||
{
|
||||
$database->makeHidden([
|
||||
@@ -302,9 +356,10 @@ class DatabasesController extends Controller
|
||||
|
||||
$databaseIds = $databases->pluck('id')->toArray();
|
||||
|
||||
$backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('latest_log')
|
||||
$backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with(['latest_log', 's3', 's3Storages'])
|
||||
->whereIn('database_id', $databaseIds)
|
||||
->get()
|
||||
->map(fn (ScheduledDatabaseBackup $backup) => $this->withBackupS3StorageUuids($backup))
|
||||
->groupBy(fn (ScheduledDatabaseBackup $backup) => $backup->database_type.':'.$backup->database_id);
|
||||
|
||||
$databases = $databases->map(function ($database) use ($backupConfigs) {
|
||||
@@ -340,7 +395,7 @@ class DatabasesController extends Controller
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all backups for a database',
|
||||
description: 'Get all backups for a database. Each backup includes s3_storage_uuid (primary S3 destination) and s3_storage_uuids (every S3 destination, primary first).',
|
||||
content: new OA\JsonContent(
|
||||
type: 'string',
|
||||
example: 'Content is very complex. Will be implemented later.',
|
||||
@@ -376,7 +431,8 @@ class DatabasesController extends Controller
|
||||
|
||||
$this->authorize('view', $database);
|
||||
|
||||
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('executions')->where('database_id', $database->id)->get();
|
||||
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with(['executions', 's3', 's3Storages'])->where('database_id', $database->id)->get()
|
||||
->map(fn (ScheduledDatabaseBackup $backup) => $this->withBackupS3StorageUuids($backup));
|
||||
|
||||
return response()->json($backupConfig);
|
||||
}
|
||||
@@ -873,7 +929,8 @@ class DatabasesController extends Controller
|
||||
'frequency' => ['type' => 'string', 'description' => 'Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)'],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled', 'default' => true],
|
||||
'save_s3' => ['type' => 'boolean', 'description' => 'Whether to save backups to S3', 'default' => false],
|
||||
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'],
|
||||
's3_storage_uuid' => ['type' => 'string', 'description' => 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true.'],
|
||||
's3_storage_uuids' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.'],
|
||||
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
|
||||
'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false],
|
||||
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
|
||||
@@ -921,7 +978,7 @@ class DatabasesController extends Controller
|
||||
)]
|
||||
public function create_backup(Request $request)
|
||||
{
|
||||
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days'];
|
||||
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 's3_storage_uuids', 'timeout', 'missing_backup_notification_days'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -941,6 +998,8 @@ class DatabasesController extends Controller
|
||||
'dump_all' => 'boolean',
|
||||
'backup_now' => 'boolean|nullable',
|
||||
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
|
||||
's3_storage_uuids' => 'array',
|
||||
's3_storage_uuids.*' => 'string|distinct',
|
||||
'databases_to_backup' => 'string|nullable',
|
||||
'database_backup_retention_amount_locally' => 'integer|min:0',
|
||||
'database_backup_retention_days_locally' => 'integer|min:0',
|
||||
@@ -986,24 +1045,17 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate S3 storage if save_s3 is true
|
||||
if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) {
|
||||
$s3Storages = $this->requestedBackupS3Storages($request, $teamId);
|
||||
if ($s3Storages instanceof JsonResponse) {
|
||||
return $s3Storages;
|
||||
}
|
||||
if ($request->boolean('save_s3') && blank($s3Storages)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']],
|
||||
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid or s3_storage_uuids field is required when save_s3 is true.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->filled('s3_storage_uuid')) {
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
if (! $existsInTeam) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for extra fields
|
||||
$extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']);
|
||||
if (! empty($extraFields)) {
|
||||
@@ -1018,20 +1070,9 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$backupData = $request->only($backupConfigFields);
|
||||
|
||||
// Convert s3_storage_uuid to s3_storage_id
|
||||
if (isset($backupData['s3_storage_uuid'])) {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
if ($s3Storage) {
|
||||
$backupData['s3_storage_id'] = $s3Storage->id;
|
||||
} elseif ($request->boolean('save_s3')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422);
|
||||
}
|
||||
unset($backupData['s3_storage_uuid']);
|
||||
$backupData = Arr::except($request->only($backupConfigFields), ['s3_storage_uuid', 's3_storage_uuids']);
|
||||
if ($s3Storages !== null) {
|
||||
$backupData['s3_storage_id'] = $s3Storages->first()?->id;
|
||||
}
|
||||
|
||||
// Set default databases_to_backup based on database type if not provided
|
||||
@@ -1070,6 +1111,9 @@ class DatabasesController extends Controller
|
||||
}
|
||||
|
||||
$backupConfig = ScheduledDatabaseBackup::create($backupData);
|
||||
if ($s3Storages !== null) {
|
||||
$backupConfig->syncS3Storages($s3Storages->pluck('id')->all());
|
||||
}
|
||||
|
||||
// Trigger immediate backup if requested
|
||||
if ($request->backup_now) {
|
||||
@@ -1129,7 +1173,8 @@ class DatabasesController extends Controller
|
||||
type: 'object',
|
||||
properties: [
|
||||
'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'],
|
||||
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'],
|
||||
's3_storage_uuid' => ['type' => 'string', 'description' => 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay.'],
|
||||
's3_storage_uuids' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.'],
|
||||
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'],
|
||||
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
|
||||
@@ -1172,7 +1217,7 @@ class DatabasesController extends Controller
|
||||
)]
|
||||
public function update_backup(Request $request)
|
||||
{
|
||||
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days'];
|
||||
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 's3_storage_uuids', 'timeout', 'missing_backup_notification_days'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -1189,6 +1234,8 @@ class DatabasesController extends Controller
|
||||
'enabled' => 'boolean',
|
||||
'dump_all' => 'boolean',
|
||||
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
|
||||
's3_storage_uuids' => 'array',
|
||||
's3_storage_uuids.*' => 'string|distinct',
|
||||
'databases_to_backup' => 'string|nullable',
|
||||
'frequency' => 'string',
|
||||
'database_backup_retention_amount_locally' => 'integer|min:0',
|
||||
@@ -1236,20 +1283,9 @@ class DatabasesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']],
|
||||
], 422);
|
||||
}
|
||||
if ($request->filled('s3_storage_uuid')) {
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
if (! $existsInTeam) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422);
|
||||
}
|
||||
$s3Storages = $this->requestedBackupS3Storages($request, $teamId);
|
||||
if ($s3Storages instanceof JsonResponse) {
|
||||
return $s3Storages;
|
||||
}
|
||||
|
||||
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
|
||||
@@ -1259,6 +1295,14 @@ class DatabasesController extends Controller
|
||||
return response()->json(['message' => 'Backup config not found.'], 404);
|
||||
}
|
||||
|
||||
$saveToS3 = $request->has('save_s3') ? $request->boolean('save_s3') : (bool) $backupConfig->save_s3;
|
||||
if (($request->boolean('save_s3') && $s3Storages === null) || ($saveToS3 && $s3Storages?->isEmpty())) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid or s3_storage_uuids field is required when save_s3 is true.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']);
|
||||
if (! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
@@ -1272,20 +1316,9 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$backupData = $request->only($backupConfigFields);
|
||||
|
||||
// Convert s3_storage_uuid to s3_storage_id
|
||||
if (isset($backupData['s3_storage_uuid'])) {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
if ($s3Storage) {
|
||||
$backupData['s3_storage_id'] = $s3Storage->id;
|
||||
} elseif ($request->boolean('save_s3')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422);
|
||||
}
|
||||
unset($backupData['s3_storage_uuid']);
|
||||
$backupData = Arr::except($request->only($backupConfigFields), ['s3_storage_uuid', 's3_storage_uuids']);
|
||||
if ($s3Storages !== null) {
|
||||
$backupData['s3_storage_id'] = $s3Storages->first()?->id;
|
||||
}
|
||||
|
||||
// Validate databases_to_backup input
|
||||
@@ -1300,7 +1333,19 @@ class DatabasesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$storageIds = $s3Storages?->pluck('id');
|
||||
// An older client sends only s3_storage_uuid. When it is already a destination, the other destinations stay.
|
||||
if ($storageIds !== null && ! $request->has('s3_storage_uuids')) {
|
||||
$currentStorageIds = $backupConfig->selectedS3Storages()->pluck('id');
|
||||
if ($currentStorageIds->contains($storageIds->first())) {
|
||||
$storageIds = $currentStorageIds;
|
||||
}
|
||||
}
|
||||
|
||||
$backupConfig->update($backupData);
|
||||
if ($storageIds !== null) {
|
||||
$backupConfig->syncS3Storages($storageIds->all());
|
||||
}
|
||||
|
||||
if ($request->backup_now) {
|
||||
dispatch(new DatabaseBackupJob($backupConfig));
|
||||
@@ -2938,9 +2983,10 @@ class DatabasesController extends Controller
|
||||
if ($execution->filename) {
|
||||
deleteBackupsLocally($execution->filename, $database->destination->server);
|
||||
|
||||
if ($deleteS3 && $backup->s3) {
|
||||
deleteBackupsS3($execution->filename, $backup->s3);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteS3) {
|
||||
$execution->deleteS3Copies();
|
||||
}
|
||||
|
||||
$execution->delete();
|
||||
@@ -3073,9 +3119,10 @@ class DatabasesController extends Controller
|
||||
if ($execution->filename) {
|
||||
deleteBackupsLocally($execution->filename, $database->destination->server);
|
||||
|
||||
if ($deleteS3 && $backup->s3) {
|
||||
deleteBackupsS3($execution->filename, $backup->s3);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteS3) {
|
||||
$execution->deleteS3Copies();
|
||||
}
|
||||
|
||||
$execution->delete();
|
||||
@@ -5195,7 +5242,7 @@ class DatabasesController extends Controller
|
||||
}
|
||||
|
||||
foreach ($database->scheduledBackups()->get() as $backup) {
|
||||
$backup->replicate([
|
||||
$newBackup = $backup->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
@@ -5205,7 +5252,9 @@ class DatabasesController extends Controller
|
||||
'database_id' => $newDatabase->id,
|
||||
'database_type' => $newDatabase->getMorphClass(),
|
||||
'team_id' => $teamId,
|
||||
])->save();
|
||||
]);
|
||||
$newBackup->save();
|
||||
$backup->copyS3StoragesTo($newBackup);
|
||||
}
|
||||
|
||||
foreach ($database->environment_variables()->get() as $environmentVariable) {
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\HostingerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class HostingerController extends Controller
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/hostinger/data-centers',
|
||||
operationId: 'get-hostinger-data-centers',
|
||||
summary: 'Get Hostinger VPS data centers',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Hostinger'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of Hostinger VPS data centers.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
|
||||
]
|
||||
)]
|
||||
public function dataCenters(Request $request): JsonResponse
|
||||
{
|
||||
return $this->providerData($request, 'getDataCenters', 'data centers');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/hostinger/catalog',
|
||||
operationId: 'get-hostinger-catalog',
|
||||
summary: 'Get Hostinger VPS plans and prices',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Hostinger'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of Hostinger VPS plans and prices.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
|
||||
]
|
||||
)]
|
||||
public function catalog(Request $request): JsonResponse
|
||||
{
|
||||
return $this->providerData($request, 'getCatalogItems', 'catalog');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/hostinger/templates',
|
||||
operationId: 'get-hostinger-templates',
|
||||
summary: 'Get Hostinger VPS operating system templates',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Hostinger'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of Hostinger VPS templates.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
|
||||
]
|
||||
)]
|
||||
public function templates(Request $request): JsonResponse
|
||||
{
|
||||
return $this->providerData($request, 'getTemplates', 'templates');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/hostinger/ssh-keys',
|
||||
operationId: 'get-hostinger-ssh-keys',
|
||||
summary: 'Get Hostinger account SSH keys',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Hostinger'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of Hostinger account SSH keys.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
|
||||
]
|
||||
)]
|
||||
public function sshKeys(Request $request): JsonResponse
|
||||
{
|
||||
return $this->providerData($request, 'getPublicKeys', 'SSH keys');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/hostinger/post-install-scripts',
|
||||
operationId: 'get-hostinger-post-install-scripts',
|
||||
summary: 'Get Hostinger post-install scripts',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Hostinger'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of Hostinger post-install scripts.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
|
||||
]
|
||||
)]
|
||||
public function postInstallScripts(Request $request): JsonResponse
|
||||
{
|
||||
return $this->providerData($request, 'getPostInstallScripts', 'post-install scripts');
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/servers/hostinger',
|
||||
operationId: 'create-hostinger-server',
|
||||
summary: 'Purchase a Hostinger VPS and create a linked server',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Hostinger'],
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Hostinger VPS purchased and linked to a Coolify server.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
|
||||
]
|
||||
)]
|
||||
public function createServer(Request $request): JsonResponse
|
||||
{
|
||||
$allowedFields = [
|
||||
'cloud_provider_token_uuid',
|
||||
'cloud_provider_token_id',
|
||||
'item_id',
|
||||
'data_center_id',
|
||||
'template_id',
|
||||
'name',
|
||||
'private_key_uuid',
|
||||
'enable_backups',
|
||||
'public_key_ids',
|
||||
'post_install_script_id',
|
||||
'instant_validate',
|
||||
];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', [Server::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
'item_id' => 'required|string',
|
||||
'data_center_id' => 'required|integer',
|
||||
'template_id' => 'required|integer',
|
||||
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
|
||||
'private_key_uuid' => 'required|string',
|
||||
'enable_backups' => 'nullable|boolean',
|
||||
'public_key_ids' => 'nullable|array',
|
||||
'public_key_ids.*' => 'integer',
|
||||
'post_install_script_id' => 'nullable|integer',
|
||||
'instant_validate' => 'nullable|boolean',
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$team = Team::find($teamId);
|
||||
if (Team::serverLimitReached($team)) {
|
||||
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
|
||||
}
|
||||
|
||||
$token = $this->hostingerToken($request, $teamId);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
|
||||
$virtualMachineId = null;
|
||||
|
||||
try {
|
||||
$hostingerService = new HostingerService($token->token);
|
||||
$normalizedServerName = strtolower(trim($request->name ?: generate_random_name()));
|
||||
$setup = [
|
||||
'data_center_id' => $request->integer('data_center_id'),
|
||||
'template_id' => $request->integer('template_id'),
|
||||
'hostname' => $normalizedServerName,
|
||||
'enable_backups' => $request->boolean('enable_backups'),
|
||||
'public_key' => [
|
||||
'name' => $privateKey->name,
|
||||
'key' => $privateKey->getPublicKey(),
|
||||
],
|
||||
];
|
||||
if ($request->filled('post_install_script_id')) {
|
||||
$setup['post_install_script_id'] = $request->integer('post_install_script_id');
|
||||
}
|
||||
|
||||
$virtualMachine = $hostingerService->purchaseVirtualMachine([
|
||||
'item_id' => $request->item_id,
|
||||
'setup' => $setup,
|
||||
]);
|
||||
$virtualMachineId = (int) $virtualMachine['id'];
|
||||
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'hostinger_virtual_machine_id' => $virtualMachine['id'],
|
||||
'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null,
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
try {
|
||||
if ($request->array('public_key_ids')) {
|
||||
$hostingerService->attachPublicKeys((int) $virtualMachine['id'], $request->array('public_key_ids'));
|
||||
}
|
||||
$virtualMachine = $hostingerService->waitForPublicIp($virtualMachine);
|
||||
$ipAddress = $hostingerService->getPublicIpAddress($virtualMachine);
|
||||
if ($ipAddress) {
|
||||
$server->update([
|
||||
'ip' => $ipAddress,
|
||||
'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
$ipAddress = $server->fresh()->ip;
|
||||
|
||||
if ($request->boolean('instant_validate') && ! $server->hasPlaceholderIp()) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.hostinger_virtual_machine.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'hostinger_virtual_machine_id' => $virtualMachine['id'],
|
||||
'ip' => $ipAddress,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'hostinger_virtual_machine_id' => $virtualMachine['id'],
|
||||
'ip' => $ipAddress,
|
||||
'provisioning' => $ipAddress === Server::PLACEHOLDER_IP,
|
||||
])->setStatusCode(201);
|
||||
} catch (RateLimitException $e) {
|
||||
return $this->rateLimitResponse($e);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->error('Failed to create Hostinger server', [
|
||||
'error' => $e->getMessage(),
|
||||
'team_id' => $teamId,
|
||||
'hostinger_virtual_machine_id' => $virtualMachineId,
|
||||
]);
|
||||
|
||||
if ($e->getCode() === 202) {
|
||||
return response()->json(['message' => $e->getMessage()], 202);
|
||||
}
|
||||
|
||||
return response()->json(array_filter([
|
||||
'message' => $virtualMachineId
|
||||
? 'The Hostinger VPS was purchased but could not be saved in Coolify. Manage it in hPanel.'
|
||||
: 'Failed to create Hostinger server.',
|
||||
'hostinger_virtual_machine_id' => $virtualMachineId,
|
||||
]), 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function providerData(Request $request, string $method, string $resource): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$token = $this->hostingerToken($request, $teamId);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new HostingerService($token->token))->{$method}());
|
||||
} catch (RateLimitException $e) {
|
||||
return $this->rateLimitResponse($e);
|
||||
} catch (\Throwable $e) {
|
||||
$hostingerErrorPrefix = 'Hostinger API error: ';
|
||||
if (in_array($e->getCode(), [401, 403], true) && str_starts_with($e->getMessage(), $hostingerErrorPrefix)) {
|
||||
$hostingerMessage = substr($e->getMessage(), strlen($hostingerErrorPrefix));
|
||||
|
||||
return response()->json(['message' => "Hostinger denied access to {$resource}: {$hostingerMessage}"], 403);
|
||||
}
|
||||
|
||||
return response()->json(['message' => "Failed to fetch Hostinger {$resource}."], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function rateLimitResponse(RateLimitException $e): JsonResponse
|
||||
{
|
||||
$response = response()->json(['message' => $e->getMessage()], 429);
|
||||
if ($e->retryAfter !== null) {
|
||||
$response->header('Retry-After', $e->retryAfter);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function hostingerToken(Request $request, int $teamId): CloudProviderToken|JsonResponse
|
||||
{
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hostinger')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hostinger cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -3399,7 +3399,7 @@ class ServicesController extends Controller
|
||||
}
|
||||
|
||||
foreach ($sourceDatabase->scheduledBackups()->get() as $backup) {
|
||||
$backup->replicate([
|
||||
$newBackup = $backup->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
@@ -3408,7 +3408,9 @@ class ServicesController extends Controller
|
||||
'database_id' => $database->id,
|
||||
'database_type' => $database->getMorphClass(),
|
||||
'team_id' => $teamId,
|
||||
])->save();
|
||||
]);
|
||||
$newBackup->save();
|
||||
$backup->copyS3StoragesTo($newBackup);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\Service;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use OpenApi\Attributes as OA;
|
||||
use RuntimeException;
|
||||
@@ -27,7 +28,8 @@ use RuntimeException;
|
||||
new OA\Property(property: 'save_s3', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'disable_local_backup', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'stop_during_backup', type: 'boolean', default: false),
|
||||
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true),
|
||||
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true, description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination.'),
|
||||
new OA\Property(property: 's3_storage_uuids', type: 'array', items: new OA\Items(type: 'string'), description: 'UUIDs of every S3 storage the backup is uploaded to. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination. save_s3 requires at least one destination.'),
|
||||
new OA\Property(property: 'retention_amount_locally', type: 'integer', default: 7, minimum: 0, maximum: 10000),
|
||||
new OA\Property(property: 'retention_days_locally', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
|
||||
new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
|
||||
@@ -53,7 +55,8 @@ use RuntimeException;
|
||||
new OA\Property(property: 'save_s3', type: 'boolean'),
|
||||
new OA\Property(property: 'disable_local_backup', type: 'boolean'),
|
||||
new OA\Property(property: 'stop_during_backup', type: 'boolean'),
|
||||
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true),
|
||||
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true, description: 'Primary S3 destination.'),
|
||||
new OA\Property(property: 's3_storage_uuids', type: 'array', items: new OA\Items(type: 'string'), description: 'Every S3 destination, primary first.'),
|
||||
new OA\Property(property: 'retention_amount_locally', type: 'integer'),
|
||||
new OA\Property(property: 'retention_days_locally', type: 'integer'),
|
||||
new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float'),
|
||||
@@ -165,7 +168,7 @@ class VolumeBackupsController extends Controller
|
||||
return response()->json(['message' => 'Storage not found.'], 404);
|
||||
}
|
||||
|
||||
['errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId);
|
||||
['errors' => $errors, 's3Storages' => $s3Storages, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId);
|
||||
|
||||
if ($errors->isNotEmpty()) {
|
||||
return response()->json([
|
||||
@@ -174,11 +177,11 @@ class VolumeBackupsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
return $this->persistSchedule($request, $storage, $teamId, $s3Storage, $saveToS3, $resourceType, $resource);
|
||||
return $this->persistSchedule($request, $storage, $teamId, $s3Storages, $saveToS3, $resourceType, $resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{errors: MessageBag, s3Storage: S3Storage|null, saveToS3: bool}
|
||||
* @return array{errors: MessageBag, s3Storages: Collection<int, S3Storage>, saveToS3: bool}
|
||||
*/
|
||||
private function validateUpsertRequest(
|
||||
Request $request,
|
||||
@@ -192,6 +195,8 @@ class VolumeBackupsController extends Controller
|
||||
'disable_local_backup' => 'boolean',
|
||||
'stop_during_backup' => 'boolean',
|
||||
's3_storage_uuid' => 'nullable|string',
|
||||
's3_storage_uuids' => 'array',
|
||||
's3_storage_uuids.*' => 'string|distinct',
|
||||
'retention_amount_locally' => 'integer|min:0|max:10000',
|
||||
'retention_days_locally' => 'integer|min:0|max:2147483647',
|
||||
'retention_max_storage_locally' => 'numeric|min:0|max:9999999999',
|
||||
@@ -209,6 +214,7 @@ class VolumeBackupsController extends Controller
|
||||
'disable_local_backup',
|
||||
'stop_during_backup',
|
||||
's3_storage_uuid',
|
||||
's3_storage_uuids',
|
||||
'retention_amount_locally',
|
||||
'retention_days_locally',
|
||||
'retention_max_storage_locally',
|
||||
@@ -232,16 +238,24 @@ class VolumeBackupsController extends Controller
|
||||
$errors->add('disable_local_backup', 'Local backups can only be disabled when S3 backups are enabled.');
|
||||
}
|
||||
|
||||
$s3Storage = null;
|
||||
if ($saveToS3) {
|
||||
$s3Storage = S3Storage::query()
|
||||
$s3Storages = collect();
|
||||
if ($saveToS3 && ! $errors->hasAny(['s3_storage_uuid', 's3_storage_uuids', 's3_storage_uuids.*'])) {
|
||||
$hasList = $request->has('s3_storage_uuids');
|
||||
$uuids = collect($hasList ? $request->input('s3_storage_uuids') : [$request->input('s3_storage_uuid')])->filter()->unique()->values();
|
||||
$primaryUuid = $request->input('s3_storage_uuid') ?? $uuids->first();
|
||||
|
||||
$s3Storages = S3Storage::query()
|
||||
->where('team_id', $teamId)
|
||||
->where('is_usable', true)
|
||||
->where('uuid', $request->input('s3_storage_uuid'))
|
||||
->first();
|
||||
->whereIn('uuid', $uuids->all())
|
||||
->get()
|
||||
->sortBy(fn (S3Storage $s3Storage): int => $s3Storage->uuid === $primaryUuid ? 0 : 1)
|
||||
->values();
|
||||
|
||||
if (! $s3Storage) {
|
||||
$errors->add('s3_storage_uuid', 'Select a usable S3 storage owned by your team.');
|
||||
if ($hasList && filled($primaryUuid) && ! $uuids->contains($primaryUuid)) {
|
||||
$errors->add('s3_storage_uuid', 'The s3_storage_uuid must be one of the s3_storage_uuids.');
|
||||
} elseif ($uuids->isEmpty() || $s3Storages->count() !== $uuids->count()) {
|
||||
$errors->add($hasList ? 's3_storage_uuids' : 's3_storage_uuid', 'Select a usable S3 storage owned by your team.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +265,7 @@ class VolumeBackupsController extends Controller
|
||||
|
||||
return [
|
||||
'errors' => $errors,
|
||||
's3Storage' => $s3Storage,
|
||||
's3Storages' => $s3Storages,
|
||||
'saveToS3' => $saveToS3,
|
||||
];
|
||||
}
|
||||
@@ -260,7 +274,7 @@ class VolumeBackupsController extends Controller
|
||||
Request $request,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
int|string $teamId,
|
||||
?S3Storage $s3Storage,
|
||||
Collection $s3Storages,
|
||||
bool $saveToS3,
|
||||
string $resourceType,
|
||||
Model $resource,
|
||||
@@ -272,7 +286,7 @@ class VolumeBackupsController extends Controller
|
||||
'save_s3' => $saveToS3,
|
||||
'disable_local_backup' => $saveToS3 && $request->boolean('disable_local_backup'),
|
||||
'stop_during_backup' => $request->boolean('stop_during_backup'),
|
||||
's3_storage_id' => $s3Storage?->id,
|
||||
's3_storage_id' => $s3Storages->first()?->id,
|
||||
'retention_amount_locally' => $request->integer('retention_amount_locally', 7),
|
||||
'retention_days_locally' => $request->integer('retention_days_locally'),
|
||||
'retention_max_storage_locally' => $request->float('retention_max_storage_locally'),
|
||||
@@ -288,6 +302,7 @@ class VolumeBackupsController extends Controller
|
||||
}
|
||||
|
||||
$backup = $storage->scheduledBackups()->updateOrCreate([], $attributes);
|
||||
$backup->syncS3Storages($s3Storages->pluck('id')->all());
|
||||
$created = $backup->wasRecentlyCreated;
|
||||
|
||||
auditLog('api.volume_backup.schedule_set', [
|
||||
@@ -298,7 +313,7 @@ class VolumeBackupsController extends Controller
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
return response()->json($this->responseData($backup, $storage, $s3Storage, $created), $created ? 201 : 200);
|
||||
return response()->json($this->responseData($backup, $storage, $s3Storages, $created), $created ? 201 : 200);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
@@ -431,7 +446,7 @@ class VolumeBackupsController extends Controller
|
||||
private function responseData(
|
||||
ScheduledVolumeBackup $backup,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
?S3Storage $s3Storage,
|
||||
Collection $s3Storages,
|
||||
bool $created,
|
||||
): array {
|
||||
return [
|
||||
@@ -444,7 +459,8 @@ class VolumeBackupsController extends Controller
|
||||
'save_s3' => $backup->save_s3,
|
||||
'disable_local_backup' => $backup->disable_local_backup,
|
||||
'stop_during_backup' => $backup->stop_during_backup,
|
||||
's3_storage_uuid' => $s3Storage?->uuid,
|
||||
's3_storage_uuid' => $s3Storages->first()?->uuid,
|
||||
's3_storage_uuids' => $s3Storages->pluck('uuid')->all(),
|
||||
'retention_amount_locally' => $backup->retention_amount_locally,
|
||||
'retention_days_locally' => $backup->retention_days_locally,
|
||||
'retention_max_storage_locally' => $backup->retention_max_storage_locally,
|
||||
|
||||
@@ -639,6 +639,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
// Domains can change with a deployment, so the maintenance routes follow them.
|
||||
if ($this->pull_request_id === 0 && $this->application->is_maintenance_enabled) {
|
||||
try {
|
||||
$this->application->syncMaintenancePage();
|
||||
} catch (Exception $e) {
|
||||
\Log::warning('Maintenance page update failed for '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function deploy_simple_dockerfile()
|
||||
@@ -3386,12 +3395,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return $variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Without the `...` entry, the list replaces the packages that Railpack generates
|
||||
* (for example libatomic1, which Node.js 25+ needs at runtime).
|
||||
*/
|
||||
private function merge_railpack_deploy_apt_packages(Collection $variables): Collection
|
||||
{
|
||||
$packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: [])
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
if (! $packages->contains('...')) {
|
||||
$packages->prepend('...');
|
||||
}
|
||||
|
||||
foreach (['curl', 'wget'] as $package) {
|
||||
if (! $packages->contains($package)) {
|
||||
$packages->push($package);
|
||||
@@ -3475,7 +3492,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
." {$this->workdir}";
|
||||
|
||||
return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
|
||||
.' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand);
|
||||
."\n".railpackBuilderLockedScript('('.$this->wrap_build_command_with_env_export($buildxBuildCommand).')');
|
||||
}
|
||||
|
||||
private function decode_railpack_config(string $config, string $source): array
|
||||
|
||||
@@ -83,8 +83,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public ?string $mongo_root_password = null;
|
||||
|
||||
public ?S3Storage $s3 = null;
|
||||
|
||||
public $timeout = 3600 + self::WORKER_TIMEOUT_MARGIN_SECONDS;
|
||||
|
||||
public ?string $backup_log_uuid = null;
|
||||
@@ -129,11 +127,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
|
||||
$this->database = data_get($this->backup, 'database');
|
||||
$this->server = $this->database->service->server;
|
||||
$this->s3 = $this->backup->s3;
|
||||
} else {
|
||||
$this->database = data_get($this->backup, 'database');
|
||||
$this->server = $this->database->destination->server;
|
||||
$this->s3 = $this->backup->s3;
|
||||
}
|
||||
if (is_null($this->server)) {
|
||||
throw new \Exception('Server not found?!');
|
||||
@@ -329,6 +325,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->backup_location = null;
|
||||
$this->backup_output = null;
|
||||
$this->error_output = null;
|
||||
$this->s3_uploaded = false;
|
||||
|
||||
// Generate unique UUID for each database backup execution
|
||||
$attempts = 0;
|
||||
@@ -484,10 +481,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$localStorageDeleted = false;
|
||||
if ($this->backup->save_s3 && $localBackupSucceeded) {
|
||||
try {
|
||||
$this->upload_to_s3();
|
||||
$s3UploadError = $this->uploadToS3Destinations();
|
||||
|
||||
// If local backup is disabled, delete the local file immediately after S3 upload
|
||||
if ($this->backup->disable_local_backup) {
|
||||
// If local backup is disabled, delete the local file only when every destination has a copy
|
||||
if ($s3UploadError === null && $this->backup->disable_local_backup) {
|
||||
deleteBackupsLocally($this->backup_location, $this->server);
|
||||
$localStorageDeleted = true;
|
||||
}
|
||||
@@ -843,9 +840,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return devHostDockerPath($this->server, $this->backup_location);
|
||||
}
|
||||
|
||||
private function upload_to_s3(): void
|
||||
/**
|
||||
* Uploads the backup file to every selected S3 destination and records one replica per destination.
|
||||
*
|
||||
* @return string|null The failed destinations and their errors, or null when every upload succeeded.
|
||||
*/
|
||||
private function uploadToS3Destinations(): ?string
|
||||
{
|
||||
if (is_null($this->s3)) {
|
||||
$storages = $this->backup->selectedS3Storages();
|
||||
if ($storages->isEmpty()) {
|
||||
$previousS3StorageId = $this->backup->s3_storage_id;
|
||||
|
||||
$this->backup->update([
|
||||
@@ -856,13 +859,43 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.');
|
||||
}
|
||||
|
||||
$failures = [];
|
||||
foreach ($storages as $storage) {
|
||||
$uploaded = false;
|
||||
$uploadError = null;
|
||||
try {
|
||||
$this->upload_to_s3($storage);
|
||||
$uploaded = true;
|
||||
} catch (Throwable $e) {
|
||||
$uploadError = $e->getMessage();
|
||||
$failures[] = "{$storage->name}: {$uploadError}";
|
||||
}
|
||||
|
||||
try {
|
||||
$this->backup_log->s3Replicas()->create([
|
||||
's3_storage_id' => $storage->id,
|
||||
's3_uploaded' => $uploaded,
|
||||
'message' => $uploadError,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$failures[] = "{$storage->name}: {$e->getMessage()}";
|
||||
}
|
||||
}
|
||||
|
||||
$this->backup_log->refreshS3Summary();
|
||||
$this->s3_uploaded = $failures === [];
|
||||
|
||||
return $failures === [] ? null : implode("\n", $failures);
|
||||
}
|
||||
|
||||
protected function upload_to_s3(S3Storage $s3): void
|
||||
{
|
||||
try {
|
||||
$key = $this->s3->key;
|
||||
$secret = $this->s3->secret;
|
||||
// $region = $this->s3->region;
|
||||
$bucket = $this->s3->bucket;
|
||||
$endpoint = $this->s3->endpoint;
|
||||
$this->s3->testConnection(shouldSave: true);
|
||||
$key = $s3->key;
|
||||
$secret = $s3->secret;
|
||||
$bucket = $s3->bucket;
|
||||
$endpoint = $s3->endpoint;
|
||||
$s3->testConnection(shouldSave: true);
|
||||
if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
|
||||
$network = $this->database->service->destination->network;
|
||||
} else {
|
||||
@@ -883,7 +916,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$escapedSecret = escapeshellarg($secret);
|
||||
$escapedBackupLocation = escapeshellarg($this->backup_location);
|
||||
$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
|
||||
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $this->s3->trustedInternalHosts()))
|
||||
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $s3->trustedInternalHosts()))
|
||||
->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption))
|
||||
->implode(' ');
|
||||
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
|
||||
@@ -892,9 +925,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}";
|
||||
instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true);
|
||||
|
||||
$this->s3_uploaded = true;
|
||||
} catch (Throwable $e) {
|
||||
$this->s3_uploaded = false;
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
} finally {
|
||||
|
||||
@@ -131,8 +131,21 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
||||
]);
|
||||
}
|
||||
|
||||
$maintenanceServers = data_get($this->resource, 'is_maintenance_enabled') ? $this->resource->maintenanceServers() : null;
|
||||
|
||||
$this->deleteLocalResource();
|
||||
|
||||
if ($maintenanceServers) {
|
||||
try {
|
||||
// Removes the maintenance route of the deleted resource.
|
||||
$this->resource->syncMaintenancePage($maintenanceServers);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Maintenance page cleanup failed while deleting resource.', [
|
||||
'resource_id' => $this->resource->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteLocalResource(): void
|
||||
|
||||
@@ -47,6 +47,9 @@ class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueu
|
||||
'digitalocean' => $this->server->digitalocean_droplet_id
|
||||
? $this->server->refreshDigitalOceanState()
|
||||
: null,
|
||||
'hostinger' => $this->server->hostinger_virtual_machine_id
|
||||
? $this->server->refreshHostingerState()
|
||||
: null,
|
||||
default => null,
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -99,7 +99,8 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$servers->each(function (Server $server) {
|
||||
$hasCloudResource = $server->hetzner_server_id
|
||||
|| $server->vultr_instance_id
|
||||
|| $server->digitalocean_droplet_id;
|
||||
|| $server->digitalocean_droplet_id
|
||||
|| $server->hostinger_virtual_machine_id;
|
||||
|
||||
if ($hasCloudResource && $server->cloudProviderToken) {
|
||||
ServerCloudProviderStatusCheckJob::dispatch($server);
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace App\Jobs;
|
||||
|
||||
use App\Events\BackupCreated;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\VolumeBackupS3Replica;
|
||||
use App\Notifications\VolumeBackup\BackupFailed;
|
||||
use App\Notifications\VolumeBackup\BackupSuccess;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
@@ -16,7 +18,6 @@ use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
@@ -71,16 +72,21 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
throw new \RuntimeException('The storage backup resource, team, or server no longer exists.');
|
||||
}
|
||||
|
||||
$destinations = $this->backup->save_s3 ? $this->backup->selectedS3Storages() : collect();
|
||||
$this->execution = $this->backup->executions()->create([
|
||||
's3_storage_id' => $this->backup->save_s3 ? $this->backup->s3_storage_id : null,
|
||||
's3_storage_id' => $destinations->first()?->id,
|
||||
]);
|
||||
foreach ($destinations as $s3) {
|
||||
$this->execution->s3Replicas()->create(['s3_storage_id' => $s3->id]);
|
||||
}
|
||||
BackupCreated::dispatch($team->id);
|
||||
|
||||
$backupDirectory = backup_dir().'/volumes/'.str($team->name)->slug().'-'.$team->id.'/'.$target->uuid;
|
||||
$filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz';
|
||||
$backupLocation = $backupDirectory.'/'.$filename;
|
||||
$this->execution->update(['filename' => $backupLocation]);
|
||||
$streamToS3 = $this->backup->save_s3 && $this->backup->disable_local_backup;
|
||||
// Streaming writes no local archive, so it can upload to one destination only.
|
||||
$streamToS3 = $this->backup->disable_local_backup && $destinations->count() === 1;
|
||||
|
||||
try {
|
||||
$source = $this->backup->sourcePath();
|
||||
@@ -96,7 +102,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume .";
|
||||
if ($streamToS3) {
|
||||
$this->execution->update(['local_storage_deleted' => true]);
|
||||
$archiveCommand = $this->streamToS3Command($archiveScript, $backupLocation, $source, $containerName, $image);
|
||||
$archiveCommand = $this->streamToS3Command($destinations->first(), $archiveScript, $backupLocation, $source, $containerName, $image);
|
||||
$this->execution->update(['s3_cleanup_pending' => true]);
|
||||
} else {
|
||||
$archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName)
|
||||
@@ -141,24 +147,27 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
|
||||
$warning = null;
|
||||
$s3Uploaded = null;
|
||||
$s3Uploaded = false;
|
||||
$s3CleanupPending = false;
|
||||
$localStorageDeleted = $streamToS3;
|
||||
|
||||
if ($streamToS3) {
|
||||
$s3Uploaded = true;
|
||||
$this->execution->s3Replicas()->update(['s3_uploaded' => true]);
|
||||
$this->execution->refreshS3Summary();
|
||||
$this->execution->update(['s3_cleanup_pending' => false]);
|
||||
} elseif ($this->backup->save_s3 && $destinations->isEmpty()) {
|
||||
$warning = 'S3 upload failed: The selected S3 storage no longer exists.';
|
||||
} elseif ($this->backup->save_s3) {
|
||||
$s3CleanupPending = true;
|
||||
$this->execution->update(['s3_cleanup_pending' => true]);
|
||||
$uploadErrors = $this->uploadToDestinations($backupLocation, $backupDirectory, $server);
|
||||
$s3Uploaded = $uploadErrors === [];
|
||||
|
||||
try {
|
||||
$this->uploadToS3($backupLocation, $backupDirectory, $server);
|
||||
$s3Uploaded = true;
|
||||
if ($s3Uploaded) {
|
||||
$s3CleanupPending = false;
|
||||
} catch (Throwable $exception) {
|
||||
$s3Uploaded = false;
|
||||
$warning = 'S3 upload failed: '.$exception->getMessage();
|
||||
} else {
|
||||
$warning = 'S3 upload failed: '.implode(' ', $uploadErrors);
|
||||
|
||||
try {
|
||||
VolumeBackupRecoveryJob::cleanupS3Upload($this->execution);
|
||||
@@ -184,7 +193,6 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
'message' => $warning,
|
||||
'size' => $size,
|
||||
'filename' => $backupLocation,
|
||||
's3_uploaded' => $s3Uploaded,
|
||||
's3_cleanup_pending' => $s3CleanupPending,
|
||||
'local_storage_deleted' => $localStorageDeleted,
|
||||
]);
|
||||
@@ -220,6 +228,8 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
$this->execution->s3Replicas()->whereNull('s3_uploaded')->update(['s3_uploaded' => false]);
|
||||
$this->execution->refreshS3Summary();
|
||||
$s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending;
|
||||
|
||||
$this->execution->update([
|
||||
@@ -347,16 +357,37 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function uploadToS3(string $backupLocation, string $backupDirectory, Server $server): void
|
||||
/**
|
||||
* Uploads the local archive to every destination of the execution and records the result on each copy.
|
||||
*
|
||||
* @return array<int, string> One error message per failed destination.
|
||||
*/
|
||||
private function uploadToDestinations(string $backupLocation, string $backupDirectory, Server $server): array
|
||||
{
|
||||
$s3 = $this->backup->s3;
|
||||
$errors = [];
|
||||
|
||||
if (! $s3) {
|
||||
$this->backup->update(['save_s3' => false, 's3_storage_id' => null]);
|
||||
foreach ($this->execution->s3Replicas()->with('s3')->get() as $replica) {
|
||||
/** @var VolumeBackupS3Replica $replica */
|
||||
try {
|
||||
if (! $replica->s3) {
|
||||
throw new \RuntimeException('The selected S3 storage no longer exists.');
|
||||
}
|
||||
|
||||
throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.');
|
||||
$this->uploadToS3($replica->s3, $backupLocation, $backupDirectory, $server);
|
||||
$replica->update(['s3_uploaded' => true, 'message' => null]);
|
||||
} catch (Throwable $exception) {
|
||||
$replica->update(['s3_uploaded' => false, 'message' => $exception->getMessage()]);
|
||||
$errors[] = ($replica->s3?->name ?? 'Deleted storage').': '.$exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$this->execution->refreshS3Summary();
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private function uploadToS3(S3Storage $s3, string $backupLocation, string $backupDirectory, Server $server): void
|
||||
{
|
||||
$s3->testConnection(shouldSave: true);
|
||||
$containerName = 'volume-upload-'.$this->execution->uuid;
|
||||
$image = coolifyHelperImage().':'.getHelperVersion();
|
||||
@@ -385,16 +416,8 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function streamToS3Command(string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string
|
||||
private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string
|
||||
{
|
||||
$s3 = $this->backup->s3;
|
||||
|
||||
if (! $s3) {
|
||||
$this->backup->update(['save_s3' => false, 's3_storage_id' => null]);
|
||||
|
||||
throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.');
|
||||
}
|
||||
|
||||
$s3->testConnection(shouldSave: true);
|
||||
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts()))
|
||||
->map(fn (string $option): string => '--resolve '.escapeshellarg($option))
|
||||
@@ -462,36 +485,45 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->backup->save_s3 && $this->backup->s3 && $this->hasRetentionLimits(
|
||||
if ($this->backup->save_s3 && $this->hasRetentionLimits(
|
||||
$this->backup->retention_amount_s3,
|
||||
$this->backup->retention_days_s3,
|
||||
$this->backup->retention_max_storage_s3,
|
||||
)) {
|
||||
$liveCopies = fn ($query) => $query->where('s3_uploaded', true)->where('s3_storage_deleted', false);
|
||||
$s3Executions = $this->backup->executions()
|
||||
->with('s3')
|
||||
->with(['s3Replicas' => fn ($query) => $liveCopies($query)->with('s3')])
|
||||
->where('status', 'success')
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false)
|
||||
->whereHas('s3Replicas', $liveCopies)
|
||||
->get();
|
||||
$s3Executions = $this->executionsOutsideRetention(
|
||||
$s3Executions,
|
||||
$this->backup->retention_amount_s3,
|
||||
$this->backup->retention_days_s3,
|
||||
$this->backup->retention_max_storage_s3,
|
||||
);
|
||||
|
||||
foreach ($s3Executions->groupBy('s3_storage_id') as $executions) {
|
||||
$s3 = $executions->first()->s3;
|
||||
// Each destination keeps its own copies within the same retention settings.
|
||||
foreach ($s3Executions->flatMap->s3Replicas->pluck('s3_storage_id')->unique() as $storageId) {
|
||||
$executions = $this->executionsOutsideRetention(
|
||||
$s3Executions->filter(fn (ScheduledVolumeBackupExecution $execution): bool => $execution->s3Replicas->contains('s3_storage_id', $storageId))->values(),
|
||||
$this->backup->retention_amount_s3,
|
||||
$this->backup->retention_days_s3,
|
||||
$this->backup->retention_max_storage_s3,
|
||||
);
|
||||
if ($executions->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replicas = $executions->map(fn (ScheduledVolumeBackupExecution $execution): VolumeBackupS3Replica => $execution->s3Replicas->firstWhere('s3_storage_id', $storageId));
|
||||
$s3 = $replicas->first()->s3;
|
||||
if (! $s3) {
|
||||
throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.');
|
||||
VolumeBackupS3Replica::query()->whereKey($replicas->pluck('id')->all())->update(['s3_storage_deleted' => true]);
|
||||
$executions->each(fn (ScheduledVolumeBackupExecution $execution) => $execution->refreshS3Summary());
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$filenames = $executions->pluck('filename')->filter()->all();
|
||||
if ($filenames !== []) {
|
||||
deleteBackupsS3($filenames, $s3);
|
||||
$this->backup->executions()->whereKey($executions->pluck('id')->all())
|
||||
->update(['s3_storage_deleted' => true]);
|
||||
}
|
||||
VolumeBackupS3Replica::query()->whereKey($replicas->pluck('id')->all())->update(['s3_storage_deleted' => true]);
|
||||
$executions->each(fn (ScheduledVolumeBackupExecution $execution) => $execution->refreshS3Summary());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,9 +531,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
->where('local_storage_deleted', true)
|
||||
->where('stop_recovery_pending', false)
|
||||
->where('s3_cleanup_pending', false)
|
||||
->where(function (Builder $query): void {
|
||||
$query->where('s3_storage_deleted', true)->orWhereNull('s3_uploaded');
|
||||
})
|
||||
->withoutLiveS3Copies()
|
||||
->delete();
|
||||
}
|
||||
|
||||
|
||||
@@ -295,12 +295,19 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the partial upload from every destination whose copy did not finish uploading. Every destination is
|
||||
* tried; the first failure is rethrown afterwards and keeps the cleanup pending.
|
||||
*/
|
||||
public static function cleanupS3Upload(ScheduledVolumeBackupExecution $execution): void
|
||||
{
|
||||
$execution->loadMissing('s3');
|
||||
$s3 = $execution->s3;
|
||||
$replicas = $execution->s3Replicas()
|
||||
->with('s3')
|
||||
->where('s3_storage_deleted', false)
|
||||
->where(fn ($query) => $query->whereNull('s3_uploaded')->orWhere('s3_uploaded', false))
|
||||
->get();
|
||||
|
||||
if (! $s3 || blank($execution->filename)) {
|
||||
if ($replicas->isEmpty() || blank($execution->filename)) {
|
||||
self::skipRecovery(
|
||||
$execution,
|
||||
['s3_cleanup_pending' => false],
|
||||
@@ -310,11 +317,25 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou
|
||||
return;
|
||||
}
|
||||
|
||||
deleteBackupsS3($execution->filename, $s3);
|
||||
$execution->update([
|
||||
's3_cleanup_pending' => false,
|
||||
's3_storage_deleted' => true,
|
||||
]);
|
||||
$failure = null;
|
||||
foreach ($replicas as $replica) {
|
||||
try {
|
||||
if ($replica->s3) {
|
||||
deleteBackupsS3($execution->filename, $replica->s3);
|
||||
}
|
||||
$replica->update(['s3_storage_deleted' => true]);
|
||||
} catch (Throwable $exception) {
|
||||
$failure ??= $exception;
|
||||
}
|
||||
}
|
||||
|
||||
$execution->refreshS3Summary();
|
||||
|
||||
if ($failure) {
|
||||
throw $failure;
|
||||
}
|
||||
|
||||
$execution->update(['s3_cleanup_pending' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
|
||||
|
||||
if ($status === 'running') {
|
||||
$server->setupDefaultRedirect();
|
||||
$server->setupMaintenancePages();
|
||||
$server->setupDynamicProxyConfiguration();
|
||||
$server->proxy->force_stop = false;
|
||||
$server->save();
|
||||
|
||||
@@ -31,7 +31,7 @@ class Index extends Component
|
||||
public function render(): View
|
||||
{
|
||||
$backups = ScheduledVolumeBackup::query()
|
||||
->with(['backupable', 'latestExecution', 's3'])
|
||||
->with(['backupable', 'latestExecution', 's3', 's3Storages'])
|
||||
->withCount('executions')
|
||||
->forApplication($this->application)
|
||||
->latest()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Enums\HttpBasicAuthHashAlgorithm;
|
||||
use App\Enums\StaticImageTypes;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Livewire\Project\Service\Storage;
|
||||
@@ -118,6 +119,14 @@ class General extends Component
|
||||
|
||||
public ?string $httpBasicAuthPassword = null;
|
||||
|
||||
public string $httpBasicAuthHashAlgorithm = HttpBasicAuthHashAlgorithm::BCRYPT->value;
|
||||
|
||||
public int $httpBasicAuthBcryptCost = 10;
|
||||
|
||||
public int $httpBasicAuthArgon2idMemoryCost = 65536;
|
||||
|
||||
public int $httpBasicAuthArgon2idTimeCost = 4;
|
||||
|
||||
public ?string $watchPaths = null;
|
||||
|
||||
public string $redirect;
|
||||
@@ -193,7 +202,13 @@ class General extends Component
|
||||
'isPreserveRepositoryEnabled' => 'boolean|required',
|
||||
'isHttpBasicAuthEnabled' => 'boolean|required',
|
||||
'httpBasicAuthUsername' => 'string|nullable',
|
||||
'httpBasicAuthPassword' => 'string|nullable',
|
||||
// bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on
|
||||
// Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily.
|
||||
'httpBasicAuthPassword' => 'string|nullable|max:72',
|
||||
'httpBasicAuthHashAlgorithm' => ['required', Rule::enum(HttpBasicAuthHashAlgorithm::class)],
|
||||
'httpBasicAuthBcryptCost' => 'required|integer|min:4|max:14',
|
||||
'httpBasicAuthArgon2idMemoryCost' => 'required|integer|min:8192|max:262144',
|
||||
'httpBasicAuthArgon2idTimeCost' => 'required|integer|min:1|max:12',
|
||||
'watchPaths' => 'nullable',
|
||||
'redirect' => 'string|required',
|
||||
];
|
||||
@@ -367,6 +382,9 @@ class General extends Component
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->validateChangedDockerComposeRaw();
|
||||
if ($this->httpBasicAuthHashAlgorithm === HttpBasicAuthHashAlgorithm::ARGON2ID->value && ! $this->application->destination->server->caddySupportsArgon2idBasicAuth()) {
|
||||
throw new Exception('Argon2id needs a server that runs the Caddy proxy, version 2.11 or newer.');
|
||||
}
|
||||
|
||||
// Application properties
|
||||
$this->application->name = $this->name;
|
||||
@@ -407,6 +425,10 @@ class General extends Component
|
||||
$this->application->is_http_basic_auth_enabled = $this->isHttpBasicAuthEnabled;
|
||||
$this->application->http_basic_auth_username = $this->httpBasicAuthUsername;
|
||||
$this->application->http_basic_auth_password = $this->httpBasicAuthPassword;
|
||||
$this->application->http_basic_auth_hash_algorithm = $this->httpBasicAuthHashAlgorithm;
|
||||
$this->application->http_basic_auth_bcrypt_cost = $this->httpBasicAuthBcryptCost;
|
||||
$this->application->http_basic_auth_argon2id_memory_cost = $this->httpBasicAuthArgon2idMemoryCost;
|
||||
$this->application->http_basic_auth_argon2id_time_cost = $this->httpBasicAuthArgon2idTimeCost;
|
||||
$this->application->watch_paths = $this->watchPaths;
|
||||
$this->application->redirect = $this->redirect;
|
||||
|
||||
@@ -438,7 +460,7 @@ class General extends Component
|
||||
$this->portsMappings = $this->application->ports_mappings;
|
||||
$this->customNetworkAliases = $this->application->custom_network_aliases;
|
||||
$this->dockerfile = $this->application->dockerfile;
|
||||
$this->dockerfileLocation = $this->application->dockerfile_location;
|
||||
$this->dockerfileLocation = $this->application->dockerfile_location ?? ($this->buildPack === 'dockerfile' ? '/Dockerfile' : null);
|
||||
$this->dockerfileTargetBuild = $this->application->dockerfile_target_build;
|
||||
$this->dockerRegistryImageName = $this->application->docker_registry_image_name;
|
||||
$this->dockerRegistryImageTag = $this->application->docker_registry_image_tag;
|
||||
@@ -460,6 +482,10 @@ class General extends Component
|
||||
$this->httpBasicAuthPassword = auth()->user()->can('update', $this->application)
|
||||
? $this->application->http_basic_auth_password
|
||||
: null;
|
||||
$this->httpBasicAuthHashAlgorithm = ($this->application->usesArgon2idBasicAuth() ? HttpBasicAuthHashAlgorithm::ARGON2ID : HttpBasicAuthHashAlgorithm::BCRYPT)->value;
|
||||
$this->httpBasicAuthBcryptCost = $this->application->http_basic_auth_bcrypt_cost;
|
||||
$this->httpBasicAuthArgon2idMemoryCost = $this->application->http_basic_auth_argon2id_memory_cost;
|
||||
$this->httpBasicAuthArgon2idTimeCost = $this->application->http_basic_auth_argon2id_time_cost;
|
||||
$this->watchPaths = $this->application->watch_paths;
|
||||
$this->redirect = $this->application->redirect;
|
||||
|
||||
@@ -497,7 +523,7 @@ class General extends Component
|
||||
if ($oldIsSpa !== $this->isSpa) {
|
||||
$this->generateNginxConfiguration($this->isSpa ? 'spa' : 'static');
|
||||
}
|
||||
if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled) {
|
||||
if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled || $this->application->isDirty(['http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost'])) {
|
||||
$this->application->save();
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@ class CloneMe extends Component
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
$newBackup->save();
|
||||
$backup->copyS3StoragesTo($newBackup);
|
||||
}
|
||||
|
||||
$environmentVaribles = $database->environment_variables()->get();
|
||||
@@ -469,6 +470,7 @@ class CloneMe extends Component
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
$newBackup->save();
|
||||
$backup->copyS3StoragesTo($newBackup);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\ServiceDatabase;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -76,8 +77,9 @@ class BackupEdit extends Component
|
||||
#[Validate(['required', 'boolean'])]
|
||||
public bool $disableLocalBackup = false;
|
||||
|
||||
#[Validate(['nullable', 'integer'])]
|
||||
public ?int $s3StorageId = null;
|
||||
/** @var array<int, int|string> */
|
||||
#[Validate(['array'])]
|
||||
public array $s3StorageIds = [];
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $databasesToBackup = null;
|
||||
@@ -113,6 +115,10 @@ class BackupEdit extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->backup->database);
|
||||
$this->availableS3Storages = S3Storage::query()
|
||||
->whereKey($this->availableS3StorageIds()->all())
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->syncData();
|
||||
$this->refreshStatus();
|
||||
@@ -148,7 +154,6 @@ class BackupEdit extends Component
|
||||
$this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3;
|
||||
$this->backup->save_s3 = $this->saveS3;
|
||||
$this->backup->disable_local_backup = $this->disableLocalBackup;
|
||||
$this->backup->s3_storage_id = $this->s3StorageId;
|
||||
|
||||
// Validate databases_to_backup to prevent command injection
|
||||
// Handles all formats including MongoDB's "db:col1,col2|db2:col3"
|
||||
@@ -160,10 +165,16 @@ class BackupEdit extends Component
|
||||
$this->backup->dump_all = $this->dumpAll;
|
||||
$this->backup->timeout = $this->timeout;
|
||||
$this->backup->missing_backup_notification_days = $this->missingBackupNotificationDays;
|
||||
$this->customValidate();
|
||||
$storageIds = $this->customValidate();
|
||||
$changedFields = auditChangedFields($this->backup);
|
||||
$this->backup->save();
|
||||
|
||||
$previousStorageIds = $this->backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all();
|
||||
$this->backup->syncS3Storages($storageIds->all());
|
||||
if ($previousStorageIds !== $storageIds->sort()->values()->all()) {
|
||||
$changedFields[] = 's3_storages';
|
||||
}
|
||||
|
||||
return $changedFields;
|
||||
} else {
|
||||
$this->backupEnabled = $this->backup->enabled;
|
||||
@@ -177,7 +188,8 @@ class BackupEdit extends Component
|
||||
$this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3;
|
||||
$this->saveS3 = $this->backup->save_s3;
|
||||
$this->disableLocalBackup = $this->backup->disable_local_backup ?? false;
|
||||
$this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3StorageIds()->first();
|
||||
$selectedIds = $this->backup->selectedS3Storages()->pluck('id');
|
||||
$this->s3StorageIds = ($selectedIds->isNotEmpty() ? $selectedIds : $this->availableS3StorageIds()->take(1))->all();
|
||||
$this->databasesToBackup = $this->backup->databases_to_backup;
|
||||
$this->dumpAll = $this->backup->dump_all;
|
||||
$this->timeout = $this->backup->timeout;
|
||||
@@ -216,10 +228,10 @@ class BackupEdit extends Component
|
||||
if ($this->delete_associated_backups_locally && $server) {
|
||||
deleteBackupsLocally($filenames, $server);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->delete_associated_backups_s3 && $this->backup->s3) {
|
||||
deleteBackupsS3($filenames, $this->backup->s3);
|
||||
}
|
||||
if ($this->delete_associated_backups_s3) {
|
||||
$this->backup->executions()->each(fn ($execution) => $execution->deleteS3Copies());
|
||||
}
|
||||
|
||||
$backupUuid = $this->backup->uuid;
|
||||
@@ -333,14 +345,14 @@ class BackupEdit extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedS3StorageId(): void
|
||||
public function updatedS3StorageIds(): void
|
||||
{
|
||||
$this->instantSave();
|
||||
}
|
||||
|
||||
public function toggleS3(): void
|
||||
{
|
||||
if (! $this->saveS3 && $this->availableS3StorageIds()->isEmpty()) {
|
||||
if (! $this->saveS3 && $this->selectedAvailableS3StorageIds()->isEmpty()) {
|
||||
$this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.');
|
||||
|
||||
return;
|
||||
@@ -351,21 +363,22 @@ class BackupEdit extends Component
|
||||
$this->instantSave();
|
||||
}
|
||||
|
||||
private function customValidate()
|
||||
/**
|
||||
* @return Collection<int, int> The usable S3 storage IDs to keep as destinations.
|
||||
*/
|
||||
private function customValidate(): Collection
|
||||
{
|
||||
if (! is_numeric($this->backup->s3_storage_id)) {
|
||||
$this->backup->s3_storage_id = null;
|
||||
}
|
||||
// Only usable S3 storages of the database's team can be destinations; others are dropped.
|
||||
$storageIds = $this->selectedAvailableS3StorageIds();
|
||||
$this->s3StorageIds = $storageIds->all();
|
||||
|
||||
// S3 backup cannot be enabled without a valid S3 storage owned by the team
|
||||
$availableS3Ids = $this->availableS3StorageIds();
|
||||
if ($availableS3Ids->isEmpty()) {
|
||||
$this->backup->s3_storage_id = $this->s3StorageId = null;
|
||||
if ($this->backup->save_s3) {
|
||||
$this->backup->save_s3 = $this->saveS3 = false;
|
||||
}
|
||||
} elseif (! $availableS3Ids->contains($this->backup->s3_storage_id)) {
|
||||
$this->backup->s3_storage_id = $this->s3StorageId = $availableS3Ids->first();
|
||||
if ($this->availableS3StorageIds()->isEmpty()) {
|
||||
$this->backup->save_s3 = $this->saveS3 = false;
|
||||
} elseif ($this->backup->save_s3 && $storageIds->isEmpty()) {
|
||||
$message = 'Select at least one usable S3 storage.';
|
||||
$this->addError('s3StorageIds', $message);
|
||||
|
||||
throw new Exception($message);
|
||||
}
|
||||
|
||||
// Validate that disable_local_backup can only be true when S3 backup is enabled
|
||||
@@ -378,6 +391,8 @@ class BackupEdit extends Component
|
||||
throw new Exception('Invalid Cron / Human expression');
|
||||
}
|
||||
$this->validate();
|
||||
|
||||
return $storageIds;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -399,6 +414,20 @@ class BackupEdit extends Component
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function selectedAvailableS3StorageIds(): Collection
|
||||
{
|
||||
$availableIds = $this->availableS3StorageIds();
|
||||
|
||||
return collect($this->s3StorageIds)
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->filter(fn (int $id): bool => $availableIds->contains($id))
|
||||
->unique()
|
||||
->values();
|
||||
}
|
||||
|
||||
private function availableS3StorageIds(): Collection
|
||||
{
|
||||
$database = $this->backup->database;
|
||||
@@ -409,9 +438,12 @@ class BackupEdit extends Component
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Current destinations stay selectable while unusable, so saving other settings does not remove them.
|
||||
$selectedIds = $this->backup->selectedS3Storages()->pluck('id')->all();
|
||||
|
||||
return S3Storage::query()
|
||||
->where('team_id', $teamId)
|
||||
->where('is_usable', true)
|
||||
->where(fn (Builder $query) => $query->where('is_usable', true)->orWhereIn('id', $selectedIds))
|
||||
->pluck('id');
|
||||
}
|
||||
|
||||
@@ -432,7 +464,7 @@ class BackupEdit extends Component
|
||||
return view('livewire.project.database.backup-edit', [
|
||||
'checkboxes' => [
|
||||
['id' => 'delete_associated_backups_locally', 'label' => __('database.delete_backups_locally')],
|
||||
['id' => 'delete_associated_backups_s3', 'label' => 'All backups will be permanently deleted (associated with this backup job) from the selected S3 Storage.'],
|
||||
['id' => 'delete_associated_backups_s3', 'label' => 'All backups will be permanently deleted (associated with this backup job) from every S3 storage of this schedule.'],
|
||||
// ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected SFTP Storage.']
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -109,12 +109,8 @@ class BackupExecutions extends Component
|
||||
deleteBackupsLocally($execution->filename, $server, throwError: true);
|
||||
}
|
||||
|
||||
if ($deleteFromS3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) {
|
||||
if (! $execution->scheduledDatabaseBackup->s3) {
|
||||
throw new \RuntimeException('The S3 storage is unavailable.');
|
||||
}
|
||||
|
||||
deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3);
|
||||
if ($deleteFromS3) {
|
||||
$execution->deleteS3Copies();
|
||||
}
|
||||
|
||||
$execution->delete();
|
||||
|
||||
@@ -73,7 +73,7 @@ class ScheduledBackups extends Component
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$this->database->loadMissing('scheduledBackups.s3');
|
||||
$this->database->loadMissing(['scheduledBackups.s3', 'scheduledBackups.s3Storages']);
|
||||
|
||||
return view('livewire.project.database.scheduled-backups');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\HasRepositoryDetection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -16,6 +17,7 @@ use Livewire\Component;
|
||||
class GithubPrivateRepository extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasRepositoryDetection;
|
||||
|
||||
public $current_step = 'github_apps';
|
||||
|
||||
@@ -151,6 +153,8 @@ class GithubPrivateRepository extends Component
|
||||
$this->selected_branch_name = $this->branches->contains('name', $defaultBranch)
|
||||
? $defaultBranch
|
||||
: data_get($this->branches, '0.name', 'main');
|
||||
|
||||
$this->detectRepository();
|
||||
}
|
||||
|
||||
protected function loadBranchByPage()
|
||||
@@ -173,6 +177,15 @@ class GithubPrivateRepository extends Component
|
||||
$this->branches = $this->branches->concat(collect($json));
|
||||
}
|
||||
|
||||
protected function applicationForDetection(): Application
|
||||
{
|
||||
return $this->unsavedApplicationForDetection(
|
||||
str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(),
|
||||
$this->selected_branch_name,
|
||||
$this->github_app,
|
||||
);
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
@@ -205,7 +218,7 @@ class GithubPrivateRepository extends Component
|
||||
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
|
||||
|
||||
$application = new Application([
|
||||
$application_init = [
|
||||
'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name),
|
||||
'repository_project_id' => $this->selected_repository_id,
|
||||
'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(),
|
||||
@@ -219,23 +232,31 @@ class GithubPrivateRepository extends Component
|
||||
'destination_type' => $destination_class,
|
||||
'source_id' => $this->github_app->id,
|
||||
'source_type' => $this->github_app->getMorphClass(),
|
||||
]);
|
||||
];
|
||||
|
||||
if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') {
|
||||
$application_init['health_check_enabled'] = false;
|
||||
}
|
||||
if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) {
|
||||
$application_init['dockerfile_location'] = $dockerfileLocation;
|
||||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$application_init['docker_compose_location'] = $this->docker_compose_location;
|
||||
}
|
||||
|
||||
$application = new Application($application_init);
|
||||
$application->save();
|
||||
$application->settings->is_static = $this->is_static;
|
||||
$application->settings->save();
|
||||
|
||||
if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') {
|
||||
$application->health_check_enabled = false;
|
||||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$application['docker_compose_location'] = $this->docker_compose_location;
|
||||
}
|
||||
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
|
||||
$application->fqdn = $fqdn;
|
||||
|
||||
$application->name = generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name, $application->uuid);
|
||||
$application->save();
|
||||
|
||||
$this->importDetectedEnvironmentVariables($application);
|
||||
|
||||
return redirect()->route('project.application.configuration', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\Project;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\HasRepositoryDetection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
@@ -18,6 +19,7 @@ use Spatie\Url\Url;
|
||||
class GithubPrivateRepositoryDeployKey extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasRepositoryDetection;
|
||||
|
||||
public $current_step = 'private_keys';
|
||||
|
||||
@@ -132,6 +134,18 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
$this->current_step = 'repository';
|
||||
}
|
||||
|
||||
protected function applicationForDetection(): Application
|
||||
{
|
||||
$this->get_git_source();
|
||||
|
||||
return $this->unsavedApplicationForDetection(
|
||||
$this->git_repository,
|
||||
$this->branch ?? 'main',
|
||||
$this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null,
|
||||
PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id),
|
||||
);
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('create', Application::class);
|
||||
@@ -186,6 +200,9 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') {
|
||||
$application_init['health_check_enabled'] = false;
|
||||
}
|
||||
if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) {
|
||||
$application_init['dockerfile_location'] = $dockerfileLocation;
|
||||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$application_init['docker_compose_location'] = $this->docker_compose_location;
|
||||
$application_init['base_directory'] = $this->base_directory;
|
||||
@@ -200,6 +217,8 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
$application->name = generate_random_name($application->uuid);
|
||||
$application->save();
|
||||
|
||||
$this->importDetectedEnvironmentVariables($application);
|
||||
|
||||
return redirect()->route('project.application.configuration', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Project;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\HasRepositoryDetection;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
@@ -17,6 +18,7 @@ use Spatie\Url\Url;
|
||||
class PublicGitRepository extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasRepositoryDetection;
|
||||
|
||||
public string $repository_url;
|
||||
|
||||
@@ -167,17 +169,17 @@ class PublicGitRepository extends Component
|
||||
$this->git_branch = 'master';
|
||||
}
|
||||
$this->selectedBranch = $this->git_branch;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->rate_limit_remaining == 0) {
|
||||
$this->selectedBranch = $this->git_branch;
|
||||
$this->branchFound = true;
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $this->branchFound && $this->git_branch === 'main') {
|
||||
} elseif (! $this->branchFound && $this->git_branch === 'main') {
|
||||
try {
|
||||
$this->git_branch = 'master';
|
||||
$this->getBranch();
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -185,6 +187,19 @@ class PublicGitRepository extends Component
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
if ($this->branchFound) {
|
||||
$this->selectedBranch = $this->git_branch;
|
||||
$this->detectRepository();
|
||||
}
|
||||
}
|
||||
|
||||
protected function applicationForDetection(): Application
|
||||
{
|
||||
return $this->unsavedApplicationForDetection(
|
||||
$this->git_repository,
|
||||
$this->git_branch,
|
||||
$this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null,
|
||||
);
|
||||
}
|
||||
|
||||
private function getGitSource()
|
||||
@@ -331,6 +346,9 @@ class PublicGitRepository extends Component
|
||||
if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') {
|
||||
$application_init['health_check_enabled'] = false;
|
||||
}
|
||||
if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) {
|
||||
$application_init['dockerfile_location'] = $dockerfileLocation;
|
||||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$application_init['docker_compose_location'] = $this->docker_compose_location;
|
||||
$application_init['base_directory'] = $this->base_directory;
|
||||
@@ -344,6 +362,8 @@ class PublicGitRepository extends Component
|
||||
$application->fqdn = $fqdn;
|
||||
$application->save();
|
||||
|
||||
$this->importDetectedEnvironmentVariables($application);
|
||||
|
||||
return redirect()->route('project.application.configuration', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
||||
@@ -114,7 +114,7 @@ class BackupExecutions extends Component
|
||||
private function formatExecutions(Collection $rows): Collection
|
||||
{
|
||||
$databaseExecutions = ScheduledDatabaseBackupExecution::query()
|
||||
->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3'])
|
||||
->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3', 'scheduledDatabaseBackup.s3Storages'])
|
||||
->whereIn('id', $rows->where('type', 'database')->pluck('id'))
|
||||
->get()->keyBy('id');
|
||||
$volumeExecutions = ScheduledVolumeBackupExecution::query()
|
||||
@@ -128,11 +128,16 @@ class BackupExecutions extends Component
|
||||
$isDatabase = $row->type === 'database';
|
||||
$execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id);
|
||||
$schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup;
|
||||
$storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3;
|
||||
if ($storage?->team_id !== $serviceTeamId) {
|
||||
$storage = null;
|
||||
}
|
||||
$storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : 'Unavailable';
|
||||
$storages = match (true) {
|
||||
! $isDatabase => collect([$execution->s3]),
|
||||
! $schedule->save_s3 => collect(),
|
||||
$schedule->s3Storages->isNotEmpty() => $schedule->s3Storages,
|
||||
default => collect([$schedule->s3]),
|
||||
};
|
||||
$storageLabel = $storages
|
||||
->filter(fn ($storage) => $storage?->team_id === $serviceTeamId)
|
||||
->map(fn ($storage) => $storage->name.' (bucket: '.$storage->bucket.')')
|
||||
->implode(', ') ?: 'Unavailable';
|
||||
if ($isDatabase && ! $schedule->save_s3) {
|
||||
$storageLabel = 'Not configured';
|
||||
} elseif (! $isDatabase && ! $execution->s3_storage_id && ! $execution->s3_uploaded && ! $execution->s3_storage_deleted) {
|
||||
|
||||
@@ -118,7 +118,7 @@ class Index extends Component
|
||||
);
|
||||
|
||||
$databaseBackups = ScheduledDatabaseBackup::query()
|
||||
->with(['database', 'latest_log', 's3'])
|
||||
->with(['database', 'latest_log', 's3', 's3Storages'])
|
||||
->withCount('executions')
|
||||
->where('database_type', (new ServiceDatabase)->getMorphClass())
|
||||
->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id))
|
||||
@@ -126,7 +126,7 @@ class Index extends Component
|
||||
->get();
|
||||
|
||||
$backups = ScheduledVolumeBackup::query()
|
||||
->with(['backupable.resource', 'latestExecution', 's3'])
|
||||
->with(['backupable.resource', 'latestExecution', 's3', 's3Storages'])
|
||||
->withCount('executions')
|
||||
->forService($this->service)
|
||||
->latest()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Actions\Database\FlushDatabase;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
@@ -157,6 +158,31 @@ class Danger extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function flush($password)
|
||||
{
|
||||
try {
|
||||
$this->authorize('manage', $this->resource);
|
||||
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
FlushDatabase::run($this->resource);
|
||||
auditLog('ui.database.flushed', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
'database_uuid' => $this->resource->uuid,
|
||||
'database_name' => $this->resource->name,
|
||||
]);
|
||||
$this->dispatch('success', 'Database flushed.');
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.danger', [
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Maintenance mode of an application or service: the proxy shows a maintenance page with
|
||||
* status 503 instead of the resource, and the containers keep running.
|
||||
*/
|
||||
class Maintenance extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Application|Service $resource;
|
||||
|
||||
public bool $isMaintenanceEnabled = false;
|
||||
|
||||
public ?string $maintenancePage = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->isMaintenanceEnabled = (bool) $this->resource->is_maintenance_enabled;
|
||||
$this->maintenancePage = $this->resource->maintenance_page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Servers that run the resource with a supported proxy (Traefik or Caddy, not Swarm).
|
||||
*/
|
||||
public function getIsSupportedProperty(): bool
|
||||
{
|
||||
return $this->resource->maintenanceServers()->contains(fn (Server $server) => ! $server->isSwarm()
|
||||
&& in_array($server->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true));
|
||||
}
|
||||
|
||||
public function instantSaveMaintenance(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->resource->is_maintenance_enabled = $this->isMaintenanceEnabled;
|
||||
$this->resource->save();
|
||||
$this->resource->syncMaintenancePage();
|
||||
$this->dispatch('success', $this->isMaintenanceEnabled ? 'Maintenance mode enabled.' : 'Maintenance mode disabled.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->isMaintenanceEnabled = (bool) $this->resource->fresh()?->is_maintenance_enabled;
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->validate([
|
||||
'maintenancePage' => ['nullable', 'string', proxyPageSizeRule('maintenance page')],
|
||||
]);
|
||||
$this->resource->maintenance_page = filled($this->maintenancePage) ? $this->maintenancePage : null;
|
||||
$this->resource->save();
|
||||
if ($this->resource->is_maintenance_enabled) {
|
||||
$this->resource->syncMaintenancePage();
|
||||
}
|
||||
$this->dispatch('success', 'Maintenance page saved.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function resetMaintenancePage(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->resource->maintenance_page = null;
|
||||
$this->resource->save();
|
||||
$this->maintenancePage = null;
|
||||
if ($this->resource->is_maintenance_enabled) {
|
||||
$this->resource->syncMaintenancePage();
|
||||
}
|
||||
$this->dispatch('success', 'Default maintenance page restored.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.maintenance');
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,7 @@ class ResourceOperations extends Component
|
||||
'team_id' => $this->resourceTeamId(),
|
||||
]);
|
||||
$newBackup->save();
|
||||
$backup->copyS3StoragesTo($newBackup);
|
||||
}
|
||||
|
||||
$environmentVaribles = $this->resource->environment_variables()->get();
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\LocalPersistentVolume;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Redirector;
|
||||
@@ -43,7 +44,8 @@ class VolumeBackups extends Component
|
||||
|
||||
public bool $stopDuringBackup = false;
|
||||
|
||||
public ?int $s3StorageId = null;
|
||||
/** @var array<int, int|string> */
|
||||
public array $s3StorageIds = [];
|
||||
|
||||
public int $retentionAmountLocally = 7;
|
||||
|
||||
@@ -88,7 +90,7 @@ class VolumeBackups extends Component
|
||||
'saveToS3' => ['required', 'boolean'],
|
||||
'disableLocalBackup' => ['required', 'boolean'],
|
||||
'stopDuringBackup' => ['required', 'boolean'],
|
||||
's3StorageId' => ['nullable', 'integer'],
|
||||
's3StorageIds' => ['array'],
|
||||
'retentionAmountLocally' => ['required', 'integer', 'min:0', 'max:10000'],
|
||||
'retentionDaysLocally' => ['required', 'integer', 'min:0'],
|
||||
'retentionMaxStorageLocally' => ['required', 'numeric', 'min:0'],
|
||||
@@ -103,12 +105,8 @@ class VolumeBackups extends Component
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('view', $this->resource);
|
||||
$this->availableS3Storages = S3Storage::query()
|
||||
->where('team_id', $this->resourceTeamId())
|
||||
->where('is_usable', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$this->backup = $this->storage->scheduledBackups()->first();
|
||||
$this->availableS3Storages = $this->selectableS3Storages()->orderBy('name')->get();
|
||||
$server = $this->backup?->server() ?? data_get($this->resource, 'destination.server');
|
||||
$this->timezone = data_get($server, 'settings.server_timezone', 'Instance timezone');
|
||||
|
||||
@@ -118,7 +116,8 @@ class VolumeBackups extends Component
|
||||
$this->saveToS3 = $this->backup->save_s3;
|
||||
$this->disableLocalBackup = $this->backup->disable_local_backup;
|
||||
$this->stopDuringBackup = $this->backup->stop_during_backup;
|
||||
$this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3Storages->first()?->id;
|
||||
$selectedIds = $this->backup->selectedS3Storages()->pluck('id');
|
||||
$this->s3StorageIds = ($selectedIds->isNotEmpty() ? $selectedIds : $this->availableS3Storages->take(1)->pluck('id'))->all();
|
||||
$this->retentionAmountLocally = $this->backup->retention_amount_locally;
|
||||
$this->retentionDaysLocally = $this->backup->retention_days_locally;
|
||||
$this->retentionMaxStorageLocally = $this->backup->retention_max_storage_locally;
|
||||
@@ -128,7 +127,7 @@ class VolumeBackups extends Component
|
||||
$this->timeout = $this->backup->timeout;
|
||||
$this->missingBackupNotificationDays = $this->backup->missing_backup_notification_days;
|
||||
} else {
|
||||
$this->s3StorageId = $this->availableS3Storages->first()?->id;
|
||||
$this->s3StorageIds = $this->availableS3Storages->take(1)->pluck('id')->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,30 +148,24 @@ class VolumeBackups extends Component
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function updatedS3StorageId(): void
|
||||
public function updatedS3StorageIds(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->hasValidS3Storage()) {
|
||||
$this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resetErrorBag('s3StorageId');
|
||||
if (! $this->validateSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resetErrorBag('s3StorageIds');
|
||||
$this->backup = $this->persistBackup($this->enabled);
|
||||
$this->dispatch('success', 'S3 storage updated.');
|
||||
$this->dispatch('success', 'S3 storages updated.');
|
||||
}
|
||||
|
||||
public function toggleS3(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->saveToS3 && ! $this->hasValidS3Storage()) {
|
||||
if (! $this->saveToS3 && $this->selectedS3StorageIds()->isEmpty()) {
|
||||
$this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.');
|
||||
|
||||
return;
|
||||
@@ -302,6 +295,7 @@ class VolumeBackups extends Component
|
||||
->where('s3_storage_deleted', true)
|
||||
->orWhereNull('s3_uploaded')
|
||||
->orWhere('s3_uploaded', false))
|
||||
->withoutLiveS3Copies()
|
||||
->delete() ?? 0;
|
||||
|
||||
$this->dispatch(
|
||||
@@ -366,12 +360,8 @@ class VolumeBackups extends Component
|
||||
deleteBackupsLocally($execution->filename, $server, throwError: true);
|
||||
}
|
||||
|
||||
if ($this->delete_backup_s3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) {
|
||||
if (! $execution->s3) {
|
||||
throw new \RuntimeException('The S3 storage is unavailable.');
|
||||
}
|
||||
|
||||
deleteBackupsS3($execution->filename, $execution->s3);
|
||||
if ($this->delete_backup_s3) {
|
||||
$execution->deleteS3Copies();
|
||||
}
|
||||
|
||||
$execution->delete();
|
||||
@@ -388,7 +378,7 @@ class VolumeBackups extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
$executions = $this->backup?->executions()->paginate($this->perPage);
|
||||
$executions = $this->backup?->executions()->with('s3Replicas.s3')->paginate($this->perPage);
|
||||
|
||||
return view('livewire.project.shared.storages.volume-backups', [
|
||||
'executions' => $executions ?? collect(),
|
||||
@@ -410,8 +400,8 @@ class VolumeBackups extends Component
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->saveToS3 && ! $this->hasValidS3Storage()) {
|
||||
$this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.');
|
||||
if ($this->saveToS3 && $this->selectedS3StorageIds()->isEmpty()) {
|
||||
$this->addError('s3StorageIds', 'Select at least one usable S3 storage owned by your team.');
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -431,7 +421,6 @@ class VolumeBackups extends Component
|
||||
'save_s3' => $this->saveToS3,
|
||||
'disable_local_backup' => $this->disableLocalBackup,
|
||||
'stop_during_backup' => $this->stopDuringBackup,
|
||||
's3_storage_id' => $this->hasValidS3Storage() ? $this->s3StorageId : $this->backup?->s3_storage_id,
|
||||
'retention_amount_locally' => $this->retentionAmountLocally,
|
||||
'retention_days_locally' => $this->retentionDaysLocally,
|
||||
'retention_max_storage_locally' => $this->retentionMaxStorageLocally,
|
||||
@@ -443,6 +432,15 @@ class VolumeBackups extends Component
|
||||
]);
|
||||
$changedFields = auditChangedFields($backup);
|
||||
$backup->save();
|
||||
|
||||
$storageIds = $this->selectedS3StorageIds();
|
||||
$previousStorageIds = $backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all();
|
||||
$backup->syncS3Storages($storageIds->all());
|
||||
$this->s3StorageIds = $storageIds->all();
|
||||
if (! $backup->wasRecentlyCreated && $previousStorageIds !== $storageIds->sort()->values()->all()) {
|
||||
$changedFields[] = 's3_storages';
|
||||
}
|
||||
|
||||
$this->auditScheduleSet($backup, $changedFields);
|
||||
|
||||
return $backup;
|
||||
@@ -485,14 +483,33 @@ class VolumeBackups extends Component
|
||||
];
|
||||
}
|
||||
|
||||
private function hasValidS3Storage(): bool
|
||||
/**
|
||||
* The submitted destinations that are selectable S3 storages of the resource's team. Others are dropped.
|
||||
*
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function selectedS3StorageIds(): Collection
|
||||
{
|
||||
return $this->s3StorageId !== null
|
||||
&& S3Storage::query()
|
||||
->whereKey($this->s3StorageId)
|
||||
->where('team_id', $this->resourceTeamId())
|
||||
->where('is_usable', true)
|
||||
->exists();
|
||||
$submittedIds = collect($this->s3StorageIds)->map(fn ($id): int => (int) $id)->filter()->unique();
|
||||
|
||||
return $this->selectableS3Storages()
|
||||
->whereKey($submittedIds->all())
|
||||
->pluck('id')
|
||||
->sortBy(fn (int $id): int|false => $submittedIds->search($id))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Usable storages of the resource's team, plus the current destinations. A destination that is unusable for a
|
||||
* while stays selected, so saving other settings does not remove it.
|
||||
*/
|
||||
private function selectableS3Storages(): Builder
|
||||
{
|
||||
$selectedIds = $this->backup?->selectedS3Storages()->pluck('id')->all() ?? [];
|
||||
|
||||
return S3Storage::query()
|
||||
->where('team_id', $this->resourceTeamId())
|
||||
->where(fn (Builder $query) => $query->where('is_usable', true)->orWhereIn('id', $selectedIds));
|
||||
}
|
||||
|
||||
private function resourceTeamId(): int
|
||||
|
||||
@@ -85,6 +85,7 @@ class Show extends Component
|
||||
'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token),
|
||||
'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token),
|
||||
'vultr' => $this->validateVultrToken($this->cloudProviderToken->token),
|
||||
'hostinger' => $this->validateHostingerToken($this->cloudProviderToken->token),
|
||||
default => false,
|
||||
};
|
||||
|
||||
@@ -141,6 +142,7 @@ class Show extends Component
|
||||
return match ($this->cloudProviderToken->provider) {
|
||||
'digitalocean' => 'DigitalOcean',
|
||||
'vultr' => 'Vultr',
|
||||
'hostinger' => 'Hostinger',
|
||||
default => 'Hetzner',
|
||||
};
|
||||
}
|
||||
@@ -181,6 +183,19 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function validateHostingerToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
return Http::withToken($token)
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
->get('https://developers.hostinger.com/api/vps/v1/virtual-machines')
|
||||
->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.cloud-provider-token.show');
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Security;
|
||||
use App\Livewire\Server\CloudProviderToken\Show as ServerCloudProviderTokenShow;
|
||||
use App\Livewire\Server\New\ByDigitalOcean;
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
use App\Livewire\Server\New\ByHostinger;
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -41,7 +42,7 @@ class CloudProviderTokenForm extends Component
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'provider' => 'required|string|in:hetzner,digitalocean,vultr',
|
||||
'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger',
|
||||
'token' => 'required|string',
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
@@ -86,6 +87,15 @@ class CloudProviderTokenForm extends Component
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
if ($provider === 'hostinger') {
|
||||
$response = Http::withToken($token)
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
->get('https://developers.hostinger.com/api/vps/v1/virtual-machines');
|
||||
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
@@ -128,6 +138,7 @@ class CloudProviderTokenForm extends Component
|
||||
|
||||
if ($savedToken->provider === 'digitalocean') {
|
||||
$this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ByDigitalOcean::class);
|
||||
$this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class);
|
||||
}
|
||||
|
||||
if ($savedToken->provider === 'hetzner') {
|
||||
@@ -135,6 +146,11 @@ class CloudProviderTokenForm extends Component
|
||||
$this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class);
|
||||
}
|
||||
|
||||
if ($savedToken->provider === 'hostinger') {
|
||||
$this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ByHostinger::class);
|
||||
$this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class);
|
||||
}
|
||||
|
||||
if ($this->modal_mode) {
|
||||
$this->dispatch('close-modal');
|
||||
}
|
||||
|
||||
@@ -59,6 +59,13 @@ class CloudProviderTokens extends Component
|
||||
} else {
|
||||
$this->dispatch('error', 'Vultr token validation failed. Please check the token.');
|
||||
}
|
||||
} elseif ($token->provider === 'hostinger') {
|
||||
$isValid = $this->validateHostingerToken($token->token);
|
||||
if ($isValid) {
|
||||
$this->dispatch('success', 'Hostinger token is valid.');
|
||||
} else {
|
||||
$this->dispatch('error', 'Hostinger token validation failed. Please check the token.');
|
||||
}
|
||||
} else {
|
||||
$this->dispatch('error', 'Unknown provider.');
|
||||
}
|
||||
@@ -114,6 +121,19 @@ class CloudProviderTokens extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function validateHostingerToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
return Http::withToken($token)
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
->get('https://developers.hostinger.com/api/vps/v1/virtual-machines')
|
||||
->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteToken(int $tokenId)
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -36,13 +36,25 @@ class Show extends Component
|
||||
{
|
||||
return [
|
||||
'tokenAdded.hetzner' => 'handleTokenAdded',
|
||||
'tokenAdded.digitalocean' => 'handleTokenAdded',
|
||||
'tokenAdded.hostinger' => 'handleTokenAdded',
|
||||
];
|
||||
}
|
||||
|
||||
public function loadTokens()
|
||||
{
|
||||
$this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner';
|
||||
$this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner';
|
||||
$this->provider = match (true) {
|
||||
filled($this->server->vultr_instance_id) => 'vultr',
|
||||
filled($this->server->digitalocean_droplet_id) => 'digitalocean',
|
||||
filled($this->server->hostinger_virtual_machine_id) => 'hostinger',
|
||||
default => 'hetzner',
|
||||
};
|
||||
$this->providerName = match ($this->provider) {
|
||||
'vultr' => 'Vultr',
|
||||
'digitalocean' => 'DigitalOcean',
|
||||
'hostinger' => 'Hostinger',
|
||||
default => 'Hetzner',
|
||||
};
|
||||
|
||||
$this->cloudProviderTokens = CloudProviderToken::where('team_id', $this->server->team_id)
|
||||
->where('provider', $this->provider)
|
||||
@@ -96,9 +108,7 @@ class Show extends Component
|
||||
private function validateTokenForServer(CloudProviderToken $token): array
|
||||
{
|
||||
try {
|
||||
$endpoint = $token->provider === 'vultr'
|
||||
? 'https://api.vultr.com/v2/account'
|
||||
: 'https://api.hetzner.cloud/v1/servers';
|
||||
$endpoint = $this->validationEndpoint($token->provider);
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->token,
|
||||
@@ -137,6 +147,33 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->server->digitalocean_droplet_id) {
|
||||
$serverResponse = Http::withToken($token->token)
|
||||
->timeout(10)
|
||||
->get("https://api.digitalocean.com/v2/droplets/{$this->server->digitalocean_droplet_id}");
|
||||
|
||||
if (! $serverResponse->successful()) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'This token cannot access this droplet. It may belong to a different DigitalOcean account.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->server->hostinger_virtual_machine_id) {
|
||||
$serverResponse = Http::withToken($token->token)
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
->get("https://developers.hostinger.com/api/vps/v1/virtual-machines/{$this->server->hostinger_virtual_machine_id}");
|
||||
|
||||
if (! $serverResponse->successful()) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'This token cannot access this VPS. It may belong to a different Hostinger account.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['valid' => true];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
@@ -156,9 +193,7 @@ class Show extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$endpoint = $token->provider === 'vultr'
|
||||
? 'https://api.vultr.com/v2/account'
|
||||
: 'https://api.hetzner.cloud/v1/servers';
|
||||
$endpoint = $this->validationEndpoint($token->provider);
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->token,
|
||||
@@ -184,6 +219,16 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function validationEndpoint(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
'vultr' => 'https://api.vultr.com/v2/account',
|
||||
'digitalocean' => 'https://api.digitalocean.com/v2/account',
|
||||
'hostinger' => 'https://developers.hostinger.com/api/vps/v1/virtual-machines',
|
||||
default => 'https://api.hetzner.cloud/v1/servers',
|
||||
};
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.cloud-provider-token.show');
|
||||
|
||||
@@ -21,7 +21,7 @@ class Create extends Component
|
||||
|
||||
public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void
|
||||
{
|
||||
$this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true)
|
||||
$this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual'], true)
|
||||
? $selectedType
|
||||
: null;
|
||||
$this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null;
|
||||
|
||||
@@ -28,12 +28,14 @@ class CreatePage extends Component
|
||||
'hetzner' => 'hetzner',
|
||||
'vultr' => 'vultr',
|
||||
'digital-ocean' => 'digitalocean',
|
||||
'hostinger' => 'hostinger',
|
||||
default => null,
|
||||
};
|
||||
$this->tokenProviderName = match ($type) {
|
||||
'hetzner' => 'Hetzner',
|
||||
'vultr' => 'Vultr',
|
||||
'digital-ocean' => 'DigitalOcean',
|
||||
'hostinger' => 'Hostinger',
|
||||
default => null,
|
||||
};
|
||||
$this->hasProviderTokens = $this->tokenProvider
|
||||
@@ -43,6 +45,7 @@ class CreatePage extends Component
|
||||
'hetzner' => 'Hetzner',
|
||||
'vultr' => 'Vultr',
|
||||
'digital-ocean' => 'DigitalOcean',
|
||||
'hostinger' => 'Hostinger',
|
||||
'manual' => 'Manual',
|
||||
default => 'New Server',
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ class Delete extends Component
|
||||
|
||||
public bool $delete_from_digitalocean = false;
|
||||
|
||||
public bool $delete_from_hostinger = false;
|
||||
|
||||
public bool $force_delete_resources = false;
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
@@ -41,6 +43,7 @@ class Delete extends Component
|
||||
$this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions);
|
||||
$this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions);
|
||||
$this->delete_from_digitalocean = in_array('delete_from_digitalocean', $selectedActions);
|
||||
$this->delete_from_hostinger = in_array('delete_from_hostinger', $selectedActions);
|
||||
$this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
|
||||
}
|
||||
try {
|
||||
@@ -72,7 +75,9 @@ class Delete extends Component
|
||||
$this->delete_from_vultr,
|
||||
$this->server->vultr_instance_id,
|
||||
$this->delete_from_digitalocean,
|
||||
$this->server->digitalocean_droplet_id
|
||||
$this->server->digitalocean_droplet_id,
|
||||
$this->delete_from_hostinger,
|
||||
$this->server->hostinger_virtual_machine_id
|
||||
);
|
||||
|
||||
return redirectRoute($this, 'server.index');
|
||||
@@ -118,6 +123,14 @@ class Delete extends Component
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->server->hostinger_virtual_machine_id) {
|
||||
$checkboxes[] = [
|
||||
'id' => 'delete_from_hostinger',
|
||||
'label' => 'Also delete VPS from Hostinger (at the end of the paid period)',
|
||||
'default_warning' => 'The VPS on Hostinger will NOT be deleted, and Hostinger will keep billing for it.',
|
||||
];
|
||||
}
|
||||
|
||||
return view('livewire.server.delete', [
|
||||
'checkboxes' => $checkboxes,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server\New;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\HostingerService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class ByHostinger extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public int $current_step = 1;
|
||||
|
||||
#[Locked]
|
||||
public Collection $available_tokens;
|
||||
|
||||
#[Locked]
|
||||
public $private_keys;
|
||||
|
||||
#[Locked]
|
||||
public $limit_reached;
|
||||
|
||||
public ?int $selected_token_id = null;
|
||||
|
||||
public ?string $selectedTokenUuid = null;
|
||||
|
||||
public array $data_centers = [];
|
||||
|
||||
public array $templates = [];
|
||||
|
||||
public array $catalog_items = [];
|
||||
|
||||
public array $hostinger_public_keys = [];
|
||||
|
||||
public array $post_install_scripts = [];
|
||||
|
||||
public ?int $selected_data_center_id = null;
|
||||
|
||||
public ?int $selected_template_id = null;
|
||||
|
||||
public ?int $selected_os_template_id = null;
|
||||
|
||||
public ?int $selected_app_template_id = null;
|
||||
|
||||
public ?string $selected_price_id = null;
|
||||
|
||||
public array $selected_public_key_ids = [];
|
||||
|
||||
public ?int $selected_post_install_script_id = null;
|
||||
|
||||
public string $server_name = '';
|
||||
|
||||
public ?int $private_key_id = null;
|
||||
|
||||
public bool $enable_backups = false;
|
||||
|
||||
public bool $loading_data = false;
|
||||
|
||||
public ?string $provider_data_error = null;
|
||||
|
||||
public bool $from_onboarding = false;
|
||||
|
||||
public function mount(?string $selectedTokenUuid = null): mixed
|
||||
{
|
||||
try {
|
||||
$this->authorize('viewAny', CloudProviderToken::class);
|
||||
$this->loadTokens();
|
||||
$this->selectTokenFromUrl($selectedTokenUuid);
|
||||
$this->server_name = generate_random_name();
|
||||
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
|
||||
|
||||
if ($this->private_keys->isNotEmpty()) {
|
||||
$this->private_key_id = $this->private_keys->first()->id;
|
||||
}
|
||||
|
||||
if ($this->selectedTokenUuid) {
|
||||
$this->current_step = 2;
|
||||
$this->loading_data = true;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
return [
|
||||
'tokenAdded.hostinger' => 'handleTokenAdded',
|
||||
'privateKeyCreated' => 'handlePrivateKeyCreated',
|
||||
'modalClosed' => 'resetSelection',
|
||||
];
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
|
||||
];
|
||||
|
||||
if ($this->current_step === 2) {
|
||||
$rules = array_merge($rules, [
|
||||
'server_name' => ['required', 'string', 'max:253', new ValidHostname],
|
||||
'selected_data_center_id' => 'required|integer',
|
||||
'selected_template_id' => 'required|integer',
|
||||
'selected_price_id' => 'required|string',
|
||||
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
|
||||
'enable_backups' => 'required|boolean',
|
||||
'selected_public_key_ids' => 'array',
|
||||
'selected_public_key_ids.*' => 'integer',
|
||||
'selected_post_install_script_id' => 'nullable|integer',
|
||||
]);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'selected_token_id.required' => 'Please select a Hostinger token.',
|
||||
'selected_token_id.exists' => 'Selected token not found.',
|
||||
];
|
||||
}
|
||||
|
||||
public function loadTokens(): void
|
||||
{
|
||||
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
->where('provider', 'hostinger')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function handleTokenAdded(int $tokenId): mixed
|
||||
{
|
||||
$this->loadTokens();
|
||||
$this->selected_token_id = $tokenId;
|
||||
|
||||
return $this->nextStep();
|
||||
}
|
||||
|
||||
public function handlePrivateKeyCreated(int $keyId): void
|
||||
{
|
||||
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
|
||||
$this->private_key_id = $keyId;
|
||||
$this->resetErrorBag('private_key_id');
|
||||
}
|
||||
|
||||
public function resetSelection(): void
|
||||
{
|
||||
$this->selected_token_id = null;
|
||||
$this->current_step = 1;
|
||||
}
|
||||
|
||||
public function nextStep(): mixed
|
||||
{
|
||||
$this->validate([
|
||||
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
if (! $this->selectedTokenUuid) {
|
||||
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
|
||||
|
||||
if ($token) {
|
||||
return $this->redirectRoute('server.create.token', [
|
||||
'type' => 'hostinger',
|
||||
'token_uuid' => $token->uuid,
|
||||
], navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
$this->current_step = 2;
|
||||
$this->loading_data = true;
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function loadHostingerData(): void
|
||||
{
|
||||
$token = $this->getHostingerToken();
|
||||
|
||||
if (! $token) {
|
||||
$this->loading_data = false;
|
||||
$this->dispatch('error', 'Please select a valid Hostinger token.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loading_data = true;
|
||||
$this->provider_data_error = null;
|
||||
|
||||
try {
|
||||
$hostingerService = new HostingerService($token);
|
||||
$this->data_centers = $hostingerService->getDataCenters();
|
||||
$this->templates = collect($hostingerService->getTemplates())
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->toArray();
|
||||
$this->catalog_items = $hostingerService->getCatalogItems();
|
||||
$this->hostinger_public_keys = $this->optionalProviderData(fn () => $hostingerService->getPublicKeys());
|
||||
$this->post_install_scripts = $this->optionalProviderData(fn () => $hostingerService->getPostInstallScripts());
|
||||
$this->selectDefaultTemplate();
|
||||
} catch (\Throwable $e) {
|
||||
$this->provider_data_error = $e->getMessage();
|
||||
$this->dispatch('error', $this->provider_data_error);
|
||||
} finally {
|
||||
$this->loading_data = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSelectedOsTemplateId(): void
|
||||
{
|
||||
if ($this->selected_os_template_id !== null) {
|
||||
$this->selected_app_template_id = null;
|
||||
}
|
||||
|
||||
$this->selected_template_id = $this->selected_os_template_id ?? $this->selected_app_template_id;
|
||||
}
|
||||
|
||||
public function updatedSelectedAppTemplateId(): void
|
||||
{
|
||||
if ($this->selected_app_template_id !== null) {
|
||||
$this->selected_os_template_id = null;
|
||||
}
|
||||
|
||||
$this->selected_template_id = $this->selected_app_template_id ?? $this->selected_os_template_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain operating system templates.
|
||||
*/
|
||||
public function getOsTemplatesProperty(): array
|
||||
{
|
||||
return collect($this->templates)
|
||||
->reject(fn (array $template): bool => $this->templateIncludesApplication($template))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Operating system templates that also install an application or control panel.
|
||||
*/
|
||||
public function getAppTemplatesProperty(): array
|
||||
{
|
||||
return collect($this->templates)
|
||||
->filter(fn (array $template): bool => $this->templateIncludesApplication($template))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getPriceOptionsProperty(): array
|
||||
{
|
||||
return collect($this->catalog_items)
|
||||
->flatMap(function (array $item): array {
|
||||
$planSize = $this->planSize($item);
|
||||
|
||||
return collect($item['prices'] ?? [])
|
||||
->map(fn (array $price): array => array_merge($price, [
|
||||
'plan_name' => $item['name'] ?? $item['id'] ?? 'VPS',
|
||||
'plan_size' => $planSize,
|
||||
]))
|
||||
->all();
|
||||
})
|
||||
->sort(fn (array $a, array $b): int => [$a['plan_size'], $this->periodInMonths($a)] <=> [$b['plan_size'], $this->periodInMonths($b)])
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getSelectedPriceProperty(): ?array
|
||||
{
|
||||
return collect($this->priceOptions)->firstWhere('id', $this->selected_price_id);
|
||||
}
|
||||
|
||||
public function priceLabel(array $price): string
|
||||
{
|
||||
$currency = strtoupper($price['currency'] ?? 'USD');
|
||||
$firstPeriodPrice = (int) ($price['first_period_price'] ?? $price['price'] ?? 0);
|
||||
$renewalPrice = (int) ($price['price'] ?? $firstPeriodPrice);
|
||||
$period = (int) ($price['period'] ?? 1);
|
||||
$periodUnit = $price['period_unit'] ?? 'month';
|
||||
$periodLabel = $period.' '.str($periodUnit)->plural($period);
|
||||
|
||||
return sprintf(
|
||||
'%s — %s %.2f for %s, then %s %.2f',
|
||||
$price['plan_name'] ?? 'VPS',
|
||||
$currency,
|
||||
$firstPeriodPrice / 100,
|
||||
$periodLabel,
|
||||
$currency,
|
||||
$renewalPrice / 100
|
||||
);
|
||||
}
|
||||
|
||||
public function submit(): mixed
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$virtualMachineId = null;
|
||||
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
|
||||
if (Team::serverLimitReached()) {
|
||||
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
|
||||
}
|
||||
|
||||
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
|
||||
$hostingerService = new HostingerService($this->getHostingerToken());
|
||||
|
||||
if (! $this->providerSelectionsAreValid($hostingerService)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedServerName = strtolower(trim($this->server_name));
|
||||
$setup = [
|
||||
'data_center_id' => $this->selected_data_center_id,
|
||||
'template_id' => $this->selected_template_id,
|
||||
'hostname' => $normalizedServerName,
|
||||
'enable_backups' => $this->enable_backups,
|
||||
'public_key' => [
|
||||
'name' => $privateKey->name,
|
||||
'key' => $privateKey->getPublicKey(),
|
||||
],
|
||||
];
|
||||
if ($this->selected_post_install_script_id) {
|
||||
$setup['post_install_script_id'] = $this->selected_post_install_script_id;
|
||||
}
|
||||
|
||||
$virtualMachine = $hostingerService->purchaseVirtualMachine([
|
||||
'item_id' => $this->selected_price_id,
|
||||
'setup' => $setup,
|
||||
]);
|
||||
$virtualMachineId = (int) $virtualMachine['id'];
|
||||
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => currentTeam()->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $this->selected_token_id,
|
||||
'hostinger_virtual_machine_id' => $virtualMachine['id'],
|
||||
'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null,
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
try {
|
||||
if ($this->selected_public_key_ids) {
|
||||
$hostingerService->attachPublicKeys((int) $virtualMachine['id'], $this->selected_public_key_ids);
|
||||
}
|
||||
$virtualMachine = $hostingerService->waitForPublicIp($virtualMachine);
|
||||
$ipAddress = $hostingerService->getPublicIpAddress($virtualMachine);
|
||||
if ($ipAddress) {
|
||||
$server->update([
|
||||
'ip' => $ipAddress,
|
||||
'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
if ($this->from_onboarding) {
|
||||
currentTeam()->update(['show_boarding' => false]);
|
||||
refreshSession();
|
||||
}
|
||||
|
||||
return redirectRoute($this, 'server.show', [$server->uuid]);
|
||||
} catch (\Throwable $e) {
|
||||
if ($virtualMachineId) {
|
||||
logger()->warning('Hostinger VPS was purchased but could not be saved in Coolify', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'hostinger_virtual_machine_id' => $virtualMachineId,
|
||||
]);
|
||||
$this->dispatch('error', "Hostinger VPS {$virtualMachineId} was purchased but could not be saved in Coolify. Manage it in hPanel.");
|
||||
}
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function selectTokenFromUrl(?string $selectedTokenUuid): void
|
||||
{
|
||||
if (! $selectedTokenUuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid);
|
||||
|
||||
if ($token) {
|
||||
$this->selectedTokenUuid = $selectedTokenUuid;
|
||||
$this->selected_token_id = $token->id;
|
||||
}
|
||||
}
|
||||
|
||||
private function getHostingerToken(): string
|
||||
{
|
||||
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
|
||||
|
||||
return $token?->token ?? '';
|
||||
}
|
||||
|
||||
private function selectDefaultTemplate(): void
|
||||
{
|
||||
if ($this->selected_template_id !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultTemplate = collect($this->osTemplates)->firstWhere('name', 'Ubuntu 24.04 LTS');
|
||||
|
||||
if ($defaultTemplate) {
|
||||
$this->selected_os_template_id = (int) $defaultTemplate['id'];
|
||||
$this->selected_template_id = $this->selected_os_template_id;
|
||||
}
|
||||
}
|
||||
|
||||
private function templateIncludesApplication(array $template): bool
|
||||
{
|
||||
return str_contains((string) ($template['name'] ?? ''), ' with ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan size from the KVM plan id (hostingercom-vps-kvm4 is 4), falling back to its CPU count.
|
||||
*/
|
||||
private function planSize(array $item): int
|
||||
{
|
||||
if (preg_match('/-vps-kvm(\d+)$/', (string) ($item['id'] ?? ''), $matches) === 1) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
return (int) ($item['metadata']['cpus'] ?? 0);
|
||||
}
|
||||
|
||||
private function periodInMonths(array $price): float
|
||||
{
|
||||
$period = (int) ($price['period'] ?? 0);
|
||||
|
||||
return match ($price['period_unit'] ?? 'month') {
|
||||
'day' => $period / 30,
|
||||
'week' => $period * 7 / 30,
|
||||
'year' => $period * 12,
|
||||
'none' => PHP_INT_MAX,
|
||||
default => $period,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an optional Hostinger list. Some tokens cannot read these lists, so a failure must not block the form.
|
||||
*/
|
||||
private function optionalProviderData(callable $callback): array
|
||||
{
|
||||
try {
|
||||
return $callback();
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function providerSelectionsAreValid(HostingerService $hostingerService): bool
|
||||
{
|
||||
$priceIds = collect($hostingerService->getCatalogItems())
|
||||
->flatMap(fn (array $item) => collect($item['prices'] ?? [])->pluck('id'));
|
||||
$dataCenterIds = collect($hostingerService->getDataCenters())->pluck('id')->map(fn ($id) => (int) $id);
|
||||
$templateIds = collect($hostingerService->getTemplates())->pluck('id')->map(fn ($id) => (int) $id);
|
||||
$publicKeyIds = collect($this->selected_public_key_ids ? $hostingerService->getPublicKeys() : [])->pluck('id')->map(fn ($id) => (int) $id);
|
||||
$postInstallScriptIds = collect($this->selected_post_install_script_id ? $hostingerService->getPostInstallScripts() : [])->pluck('id')->map(fn ($id) => (int) $id);
|
||||
|
||||
if (! $priceIds->contains($this->selected_price_id)) {
|
||||
$this->addError('selected_price_id', 'The selected Hostinger plan or billing period is no longer available.');
|
||||
}
|
||||
|
||||
if (! $dataCenterIds->contains($this->selected_data_center_id)) {
|
||||
$this->addError('selected_data_center_id', 'The selected Hostinger data center is no longer available.');
|
||||
}
|
||||
|
||||
if (! $templateIds->contains($this->selected_template_id)) {
|
||||
$this->addError('selected_template_id', 'The selected Hostinger operating system is no longer available.');
|
||||
}
|
||||
|
||||
if (collect($this->selected_public_key_ids)->contains(fn ($id) => ! $publicKeyIds->contains((int) $id))) {
|
||||
$this->addError('selected_public_key_ids', 'One or more selected Hostinger SSH keys are no longer available.');
|
||||
}
|
||||
|
||||
if ($this->selected_post_install_script_id && ! $postInstallScriptIds->contains($this->selected_post_install_script_id)) {
|
||||
$this->addError('selected_post_install_script_id', 'The selected Hostinger post-install script is no longer available.');
|
||||
}
|
||||
|
||||
return ! $this->getErrorBag()->isNotEmpty();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.new.by-hostinger');
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ class Proxy extends Component
|
||||
|
||||
public ?string $redirectUrl = null;
|
||||
|
||||
public ?string $customErrorPage = null;
|
||||
|
||||
public bool $generateExactLabels = false;
|
||||
|
||||
/**
|
||||
@@ -49,6 +51,7 @@ class Proxy extends Component
|
||||
return [
|
||||
'generateExactLabels' => 'required|boolean',
|
||||
'redirectUrl' => ['nullable', new SafeExternalUrl],
|
||||
'customErrorPage' => ['nullable', 'string', proxyPageSizeRule('custom error page')],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -57,6 +60,7 @@ class Proxy extends Component
|
||||
$this->selectedProxy = $this->server->proxyType();
|
||||
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
|
||||
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
|
||||
$this->customErrorPage = $this->server->proxy_error_page;
|
||||
$this->syncData(false);
|
||||
$this->clearAppliedTraefikBranchWarning();
|
||||
}
|
||||
@@ -187,6 +191,20 @@ class Proxy extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function resetCustomErrorPage(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->server->proxy_error_page = null;
|
||||
$this->server->save();
|
||||
$this->customErrorPage = null;
|
||||
$this->server->setupDefaultRedirect();
|
||||
$this->dispatch('success', 'Default error page restored.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
@@ -194,6 +212,7 @@ class Proxy extends Component
|
||||
$this->validate();
|
||||
SaveProxyConfiguration::run($this->server, $this->proxySettings);
|
||||
$this->server->proxy->redirect_url = $this->redirectUrl;
|
||||
$this->server->proxy_error_page = filled($this->customErrorPage) ? $this->customErrorPage : null;
|
||||
$this->server->save();
|
||||
auditLog('ui.server.proxy.configuration_saved', $this->proxyAuditContext());
|
||||
$this->server->setupDefaultRedirect();
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\Server;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Services\DigitalOceanService;
|
||||
use App\Services\HetznerService;
|
||||
use App\Services\HostingerService;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use App\Services\VultrService;
|
||||
use App\Support\ValidationPatterns;
|
||||
@@ -82,12 +83,16 @@ class Show extends Component
|
||||
|
||||
public ?string $digitalOceanDropletStatus = null;
|
||||
|
||||
public ?string $hostingerVirtualMachineStatus = null;
|
||||
|
||||
public bool $hetznerServerManuallyStarted = false;
|
||||
|
||||
public bool $vultrInstanceManuallyStarted = false;
|
||||
|
||||
public bool $digitalOceanDropletManuallyStarted = false;
|
||||
|
||||
public bool $hostingerVirtualMachineManuallyStarted = false;
|
||||
|
||||
public bool $isValidating = false;
|
||||
|
||||
// Hetzner linking properties
|
||||
@@ -127,6 +132,18 @@ class Show extends Component
|
||||
|
||||
public bool $digitalOceanNoMatchFound = false;
|
||||
|
||||
public Collection $availableHostingerTokens;
|
||||
|
||||
public ?int $selectedHostingerTokenId = null;
|
||||
|
||||
public ?string $manualHostingerVirtualMachineId = null;
|
||||
|
||||
public ?array $matchedHostingerVirtualMachine = null;
|
||||
|
||||
public ?string $hostingerSearchError = null;
|
||||
|
||||
public bool $hostingerNoMatchFound = false;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
|
||||
@@ -206,12 +223,14 @@ class Show extends Component
|
||||
$this->hetznerServerStatus = $this->server->hetzner_server_status;
|
||||
$this->vultrInstanceStatus = $this->server->vultr_instance_status;
|
||||
$this->digitalOceanDropletStatus = $this->server->digitalocean_droplet_status;
|
||||
$this->hostingerVirtualMachineStatus = $this->server->hostinger_virtual_machine_status;
|
||||
$this->isValidating = $this->server->is_validating ?? false;
|
||||
|
||||
// Load cloud provider tokens for linking
|
||||
$this->loadHetznerTokens();
|
||||
$this->loadVultrTokens();
|
||||
$this->loadDigitalOceanTokens();
|
||||
$this->loadHostingerTokens();
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
@@ -658,6 +677,30 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function checkHostingerVirtualMachineStatus(bool $manual = false): mixed
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->server);
|
||||
if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) {
|
||||
$this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->hostingerVirtualMachineStatus = $this->server->refreshHostingerState();
|
||||
$this->server->refresh();
|
||||
$this->ip = $this->server->ip;
|
||||
|
||||
if ($manual) {
|
||||
$this->dispatch('success', 'VPS status refreshed: '.ucfirst($this->hostingerVirtualMachineStatus ?? 'unknown'));
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function handleServerValidated($event = null)
|
||||
{
|
||||
// Check if event is for this server
|
||||
@@ -679,6 +722,7 @@ class Show extends Component
|
||||
$this->loadHetznerTokens();
|
||||
$this->loadVultrTokens();
|
||||
$this->loadDigitalOceanTokens();
|
||||
$this->loadHostingerTokens();
|
||||
|
||||
$this->dispatch('refreshServerShow');
|
||||
$this->dispatch('refreshServer');
|
||||
@@ -750,6 +794,30 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function startHostingerVirtualMachine(): mixed
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) {
|
||||
$this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$hostingerService = new HostingerService($this->server->cloudProviderToken->token);
|
||||
$hostingerService->startVirtualMachine((int) $this->server->hostinger_virtual_machine_id);
|
||||
|
||||
$this->hostingerVirtualMachineStatus = 'starting';
|
||||
$this->server->update(['hostinger_virtual_machine_status' => 'starting']);
|
||||
$this->hostingerVirtualMachineManuallyStarted = true;
|
||||
$this->dispatch('success', 'Hostinger VPS is starting...');
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshServerMetadata(): void
|
||||
{
|
||||
try {
|
||||
@@ -804,6 +872,13 @@ class Show extends Component
|
||||
->get();
|
||||
}
|
||||
|
||||
public function loadHostingerTokens(): void
|
||||
{
|
||||
$this->availableHostingerTokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
->where('provider', 'hostinger')
|
||||
->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function limaStartCommand(): ?string
|
||||
{
|
||||
@@ -1077,6 +1152,136 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function searchHostingerVirtualMachine(): void
|
||||
{
|
||||
$this->hostingerSearchError = null;
|
||||
$this->hostingerNoMatchFound = false;
|
||||
$this->matchedHostingerVirtualMachine = null;
|
||||
|
||||
if (! $this->selectedHostingerTokenId) {
|
||||
$this->hostingerSearchError = 'Please select a Hostinger token.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
|
||||
|
||||
if (! $token) {
|
||||
$this->hostingerSearchError = 'Invalid token selected.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$matched = (new HostingerService($token->token))->findVirtualMachineByIp($this->server->ip);
|
||||
|
||||
if ($matched) {
|
||||
$this->matchedHostingerVirtualMachine = $matched;
|
||||
} else {
|
||||
$this->hostingerNoMatchFound = true;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->hostingerSearchError = 'Failed to search Hostinger virtual machines: '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public function searchHostingerVirtualMachineById(): void
|
||||
{
|
||||
$this->hostingerSearchError = null;
|
||||
$this->hostingerNoMatchFound = false;
|
||||
$this->matchedHostingerVirtualMachine = null;
|
||||
|
||||
if (! $this->selectedHostingerTokenId) {
|
||||
$this->hostingerSearchError = 'Please select a Hostinger token first.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->manualHostingerVirtualMachineId) {
|
||||
$this->hostingerSearchError = 'Please enter a Hostinger Virtual Machine ID.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
|
||||
|
||||
if (! $token) {
|
||||
$this->hostingerSearchError = 'Invalid token selected.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$virtualMachine = (new HostingerService($token->token))
|
||||
->getVirtualMachine((int) $this->manualHostingerVirtualMachineId);
|
||||
|
||||
if ($virtualMachine) {
|
||||
$this->matchedHostingerVirtualMachine = $virtualMachine;
|
||||
} else {
|
||||
$this->hostingerNoMatchFound = true;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->hostingerSearchError = 'Failed to fetch Hostinger virtual machine: '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public function linkToHostinger(): mixed
|
||||
{
|
||||
if (! $this->matchedHostingerVirtualMachine) {
|
||||
$this->dispatch('error', 'No Hostinger virtual machine selected.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
|
||||
|
||||
if (! $token) {
|
||||
$this->dispatch('error', 'Invalid token selected.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$hostingerService = new HostingerService($token->token);
|
||||
$virtualMachine = $hostingerService->getVirtualMachine((int) $this->matchedHostingerVirtualMachine['id']);
|
||||
|
||||
if (! $virtualMachine) {
|
||||
$this->dispatch('error', 'Could not find Hostinger virtual machine with ID: '.$this->matchedHostingerVirtualMachine['id']);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$updates = [
|
||||
'cloud_provider_token_id' => $this->selectedHostingerTokenId,
|
||||
'hostinger_virtual_machine_id' => $virtualMachine['id'],
|
||||
'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null,
|
||||
];
|
||||
$ip = $hostingerService->getPublicIpAddress($virtualMachine);
|
||||
if ($ip) {
|
||||
$updates['ip'] = $ip;
|
||||
}
|
||||
|
||||
$this->server->update($updates);
|
||||
$this->hostingerVirtualMachineStatus = $virtualMachine['state'] ?? null;
|
||||
$this->matchedHostingerVirtualMachine = null;
|
||||
$this->selectedHostingerTokenId = null;
|
||||
$this->manualHostingerVirtualMachineId = null;
|
||||
$this->hostingerNoMatchFound = false;
|
||||
$this->hostingerSearchError = null;
|
||||
|
||||
$this->dispatch('success', 'Server successfully linked to Hostinger!');
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('refreshServerShow');
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function searchVultrInstance(): void
|
||||
{
|
||||
$this->vultrSearchError = null;
|
||||
|
||||
@@ -145,6 +145,18 @@ class ValidateAndInstall extends Component
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->server->hostinger_virtual_machine_id) {
|
||||
$status = $this->server->refreshHostingerState();
|
||||
$this->server->refresh();
|
||||
|
||||
if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) {
|
||||
$this->error = 'Hostinger VPS is '.($status ?? 'not running').'. Power it on before validating.';
|
||||
$this->server->update(['validation_logs' => $this->error]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if (! $this->uptime) {
|
||||
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -26,7 +27,7 @@ class Resources extends Component
|
||||
{
|
||||
$this->authorize('view', $this->storage);
|
||||
|
||||
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
|
||||
$backups = $this->backupsUsingStorage(ScheduledDatabaseBackup::query())
|
||||
->where('save_s3', true)
|
||||
->get();
|
||||
|
||||
@@ -34,8 +35,7 @@ class Resources extends Component
|
||||
$this->selectedStorages[$backup->id] = $this->storage->id;
|
||||
}
|
||||
|
||||
ScheduledVolumeBackup::query()
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
$this->backupsUsingStorage(ScheduledVolumeBackup::query())
|
||||
->where('save_s3', true)
|
||||
->each(function (ScheduledVolumeBackup $backup): void {
|
||||
$this->selectedVolumeStorages[$backup->id] = $this->storage->id;
|
||||
@@ -46,29 +46,26 @@ class Resources extends Component
|
||||
{
|
||||
$this->authorize('update', $this->storage);
|
||||
|
||||
$backup = ScheduledDatabaseBackup::where('id', $backupId)
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
$backup = $this->backupsUsingStorage(ScheduledDatabaseBackup::query())
|
||||
->whereKey($backupId)
|
||||
->firstOrFail();
|
||||
|
||||
$backup->fill([
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
$changedFields = auditChangedFields($backup);
|
||||
$backup->save();
|
||||
$changedFields = $this->replaceStorage($backup, null);
|
||||
|
||||
$this->auditDatabaseBackupUpdated($backup, $changedFields);
|
||||
unset($this->selectedStorages[$backupId]);
|
||||
|
||||
$this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.');
|
||||
$this->dispatch('success', 'Storage removed.', $backup->save_s3
|
||||
? 'This storage was removed from the schedule destinations.'
|
||||
: 'S3 backup has been disabled for this schedule.');
|
||||
}
|
||||
|
||||
public function moveBackup(int $backupId): void
|
||||
{
|
||||
$this->authorize('update', $this->storage);
|
||||
|
||||
$backup = ScheduledDatabaseBackup::where('id', $backupId)
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
$backup = $this->backupsUsingStorage(ScheduledDatabaseBackup::query())
|
||||
->whereKey($backupId)
|
||||
->firstOrFail();
|
||||
$newStorageId = $this->selectedStorages[$backupId] ?? null;
|
||||
|
||||
@@ -90,9 +87,7 @@ class Resources extends Component
|
||||
|
||||
$this->authorize('update', $newStorage);
|
||||
|
||||
$backup->s3_storage_id = $newStorage->id;
|
||||
$changedFields = auditChangedFields($backup);
|
||||
$backup->save();
|
||||
$changedFields = $this->replaceStorage($backup, $newStorage->id);
|
||||
|
||||
$this->auditDatabaseBackupUpdated($backup, $changedFields);
|
||||
unset($this->selectedStorages[$backupId]);
|
||||
@@ -104,31 +99,26 @@ class Resources extends Component
|
||||
{
|
||||
$this->authorize('update', $this->storage);
|
||||
|
||||
$backup = ScheduledVolumeBackup::query()
|
||||
->where('id', $backupId)
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
$backup = $this->backupsUsingStorage(ScheduledVolumeBackup::query())
|
||||
->whereKey($backupId)
|
||||
->firstOrFail();
|
||||
|
||||
$backup->fill([
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
$changedFields = auditChangedFields($backup);
|
||||
$backup->save();
|
||||
$changedFields = $this->replaceStorage($backup, null);
|
||||
|
||||
$this->auditVolumeBackupUpdated($backup, $changedFields);
|
||||
unset($this->selectedVolumeStorages[$backupId]);
|
||||
|
||||
$this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.');
|
||||
$this->dispatch('success', 'Storage removed.', $backup->save_s3
|
||||
? 'This storage was removed from the schedule destinations.'
|
||||
: 'S3 backup has been disabled for this schedule.');
|
||||
}
|
||||
|
||||
public function moveVolumeBackup(int $backupId): void
|
||||
{
|
||||
$this->authorize('update', $this->storage);
|
||||
|
||||
$backup = ScheduledVolumeBackup::query()
|
||||
->where('id', $backupId)
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
$backup = $this->backupsUsingStorage(ScheduledVolumeBackup::query())
|
||||
->whereKey($backupId)
|
||||
->firstOrFail();
|
||||
$newStorageId = $this->selectedVolumeStorages[$backupId] ?? null;
|
||||
|
||||
@@ -151,9 +141,7 @@ class Resources extends Component
|
||||
|
||||
$this->authorize('update', $newStorage);
|
||||
|
||||
$backup->s3_storage_id = $newStorage->id;
|
||||
$changedFields = auditChangedFields($backup);
|
||||
$backup->save();
|
||||
$changedFields = $this->replaceStorage($backup, $newStorage->id);
|
||||
|
||||
$this->auditVolumeBackupUpdated($backup, $changedFields);
|
||||
unset($this->selectedVolumeStorages[$backupId]);
|
||||
@@ -161,6 +149,49 @@ class Resources extends Component
|
||||
$this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules that have this storage among their destinations.
|
||||
*
|
||||
* @template TModel of ScheduledDatabaseBackup|ScheduledVolumeBackup
|
||||
*
|
||||
* @param Builder<TModel> $query
|
||||
* @return Builder<TModel>
|
||||
*/
|
||||
private function backupsUsingStorage(Builder $query): Builder
|
||||
{
|
||||
return $query->usingS3Storage($this->storage->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces this storage in the destinations of a schedule, or removes it when no replacement is given. S3 backups
|
||||
* turn off when no destination remains.
|
||||
*
|
||||
* @return array<int, string> Names of the changed backup fields.
|
||||
*/
|
||||
private function replaceStorage(ScheduledDatabaseBackup|ScheduledVolumeBackup $backup, ?int $newStorageId): array
|
||||
{
|
||||
$storageIds = $backup->selectedS3Storages()
|
||||
->pluck('id')
|
||||
->map(fn (int $id): ?int => $id === $this->storage->id ? $newStorageId : $id)
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
$previousPrimaryId = $backup->s3_storage_id;
|
||||
|
||||
$backup->syncS3Storages($storageIds->all());
|
||||
|
||||
$changedFields = ['s3_storages'];
|
||||
if ($backup->s3_storage_id !== $previousPrimaryId) {
|
||||
$changedFields[] = 's3_storage_id';
|
||||
}
|
||||
if ($storageIds->isEmpty() && $backup->save_s3) {
|
||||
$backup->update(['save_s3' => false]);
|
||||
$changedFields[] = 'save_s3';
|
||||
}
|
||||
|
||||
return $changedFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $changedFields
|
||||
*/
|
||||
@@ -211,7 +242,7 @@ class Resources extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
|
||||
$backups = $this->backupsUsingStorage(ScheduledDatabaseBackup::query())
|
||||
->where('save_s3', true)
|
||||
->with('database')
|
||||
->get()
|
||||
@@ -221,8 +252,7 @@ class Resources extends Component
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'is_usable']);
|
||||
|
||||
$volumeBackups = ScheduledVolumeBackup::query()
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
$volumeBackups = $this->backupsUsingStorage(ScheduledVolumeBackup::query())
|
||||
->where('save_s3', true)
|
||||
->with('backupable.resource')
|
||||
->get();
|
||||
|
||||
@@ -32,8 +32,10 @@ class Show extends Component
|
||||
return $this->redirectRoute('storage.index', navigate: true);
|
||||
}
|
||||
$this->currentRoute = request()->route()->getName();
|
||||
$this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count()
|
||||
+ ScheduledVolumeBackup::where('s3_storage_id', $this->storage->id)->count();
|
||||
$this->backupCount = collect([ScheduledDatabaseBackup::class, ScheduledVolumeBackup::class])
|
||||
->sum(fn (string $backupModel): int => $backupModel::query()
|
||||
->usingS3Storage($this->storage->id)
|
||||
->count());
|
||||
}
|
||||
|
||||
public function delete()
|
||||
|
||||
@@ -24,7 +24,7 @@ trait BuildsResponse
|
||||
// raw IDs / morph types (uuid is the public identifier)
|
||||
'id', 'team_id', 'tokenable_id', 'tokenable_type',
|
||||
'server_id', 'private_key_id', 'cloud_provider_token_id',
|
||||
'hetzner_server_id', 'digitalocean_droplet_id', 'environment_id', 'destination_id',
|
||||
'hetzner_server_id', 'digitalocean_droplet_id', 'hostinger_virtual_machine_id', 'environment_id', 'destination_id',
|
||||
'source_id', 'repository_project_id', 'application_id',
|
||||
'service_id', 'project_id', 'parent_id',
|
||||
'resourceable', 'resourceable_id', 'resourceable_type',
|
||||
|
||||
@@ -43,6 +43,7 @@ class ListDatabaseBackups extends Tool
|
||||
$backups = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)
|
||||
->where('database_id', $database->id)
|
||||
->where('database_type', $database->getMorphClass())
|
||||
->with(['s3', 's3Storages'])
|
||||
->get()
|
||||
->map(fn ($backup) => $this->scrubSensitive([
|
||||
'uuid' => $backup->uuid,
|
||||
@@ -51,6 +52,7 @@ class ListDatabaseBackups extends Tool
|
||||
'database_backup_retention_amount_locally' => $backup->database_backup_retention_amount_locally ?? null,
|
||||
'save_s3' => $backup->save_s3 ?? null,
|
||||
's3_storage_uuid' => $backup->s3?->uuid ?? null,
|
||||
's3_storage_uuids' => $backup->selectedS3Storages()->pluck('uuid')->all(),
|
||||
'created_at' => $backup->created_at,
|
||||
'updated_at' => $backup->updated_at,
|
||||
]))
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Enums\BuildPackTypes;
|
||||
use App\Enums\HttpBasicAuthHashAlgorithm;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Services\ConfigurationGenerator;
|
||||
@@ -16,6 +17,7 @@ use App\Traits\Auditable;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasComposeVolumeWarnings;
|
||||
use App\Traits\HasConfiguration;
|
||||
use App\Traits\HasMaintenancePage;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasNoindexDomains;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
@@ -125,6 +127,10 @@ use Symfony\Component\Yaml\Yaml;
|
||||
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
|
||||
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
|
||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
|
||||
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
|
||||
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
|
||||
new OA\Property(property: 'settings', ref: '#/components/schemas/ApplicationSetting'),
|
||||
]
|
||||
)]
|
||||
@@ -132,7 +138,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
class Application extends BaseModel
|
||||
{
|
||||
/** @use HasFactory<ApplicationFactory> */
|
||||
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes;
|
||||
|
||||
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
@@ -220,6 +226,10 @@ class Application extends BaseModel
|
||||
'is_http_basic_auth_enabled',
|
||||
'http_basic_auth_username',
|
||||
'http_basic_auth_password',
|
||||
'http_basic_auth_hash_algorithm',
|
||||
'http_basic_auth_bcrypt_cost',
|
||||
'http_basic_auth_argon2id_memory_cost',
|
||||
'http_basic_auth_argon2id_time_cost',
|
||||
'connect_to_docker_network',
|
||||
'force_domain_override',
|
||||
'is_container_label_escape_enabled',
|
||||
@@ -268,6 +278,7 @@ class Application extends BaseModel
|
||||
{
|
||||
return [
|
||||
'http_basic_auth_password' => 'encrypted',
|
||||
'http_basic_auth_hash_algorithm' => HttpBasicAuthHashAlgorithm::class,
|
||||
'manual_webhook_secret_github' => 'encrypted',
|
||||
'manual_webhook_secret_gitlab' => 'encrypted',
|
||||
'manual_webhook_secret_bitbucket' => 'encrypted',
|
||||
@@ -280,6 +291,7 @@ class Application extends BaseModel
|
||||
'restart_limit_reached' => 'boolean',
|
||||
'container_present' => 'boolean',
|
||||
'last_restart_at' => 'datetime',
|
||||
'is_maintenance_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -575,6 +587,39 @@ class Application extends BaseModel
|
||||
instant_remote_process(["docker network rm {$uuid}"], $server, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domains of the application, or of every service of a Docker Compose application.
|
||||
*
|
||||
* @return Collection<int, array{url: string, force_https: bool}>
|
||||
*/
|
||||
public function maintenanceDomains(): Collection
|
||||
{
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$urls = collect(json_decode($this->docker_compose_domains ?: '[]', true) ?: [])
|
||||
->flatMap(fn ($service) => explode(',', (string) data_get($service, 'domain', '')));
|
||||
} else {
|
||||
$urls = collect($this->fqdns);
|
||||
}
|
||||
$forceHttps = (bool) $this->isForceHttpsEnabled();
|
||||
|
||||
return $urls->map(fn ($url) => trim((string) $url))
|
||||
->filter()
|
||||
->map(fn (string $url) => ['url' => $url, 'force_https' => $forceHttps])
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Server>
|
||||
*/
|
||||
public function maintenanceServers(): Collection
|
||||
{
|
||||
return collect([$this->destination?->server])
|
||||
->concat($this->additional_servers)
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values();
|
||||
}
|
||||
|
||||
public function additional_servers()
|
||||
{
|
||||
return $this->belongsToMany(Server::class, 'additional_destinations')
|
||||
@@ -657,6 +702,12 @@ class Application extends BaseModel
|
||||
return $this->source instanceof GithubApp;
|
||||
}
|
||||
|
||||
public function usesArgon2idBasicAuth(): bool
|
||||
{
|
||||
return $this->http_basic_auth_hash_algorithm === HttpBasicAuthHashAlgorithm::ARGON2ID
|
||||
&& $this->destination->server->caddySupportsArgon2idBasicAuth();
|
||||
}
|
||||
|
||||
public function isForceHttpsEnabled()
|
||||
{
|
||||
return data_get($this, 'settings.is_force_https_enabled', false);
|
||||
@@ -2193,6 +2244,17 @@ class Application extends BaseModel
|
||||
return $reason ? '<br><br>Reason: '.e(str($reason)->limit(300)->value()) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* One shell command that clones the repository without a checkout into $checkoutDir on the server,
|
||||
* with the same credentials (GitHub App token, GitLab token, or deploy key) as a deployment.
|
||||
*/
|
||||
public function serverCheckoutCommand(string $uuid, string $checkoutDir): string
|
||||
{
|
||||
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir);
|
||||
|
||||
return str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand));
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands that check out only the Compose file on the server and print it. They run on the
|
||||
* server itself, not in a helper container, so the checkout uses an absolute folder in /tmp.
|
||||
@@ -2202,8 +2264,7 @@ class Application extends BaseModel
|
||||
private function composeFileReadCommands(string $uuid, string $gitVersion): Collection
|
||||
{
|
||||
$checkoutDir = "/tmp/{$uuid}/checkout";
|
||||
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir);
|
||||
$cloneCommand = str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand));
|
||||
$cloneCommand = $this->serverCheckoutCommand($uuid, $checkoutDir);
|
||||
$workdir = rtrim($this->base_directory, '/');
|
||||
$fileList = collect([".{$workdir}{$this->docker_compose_location}"]);
|
||||
$composeFilePath = escapeshellarg(".{$workdir}{$this->docker_compose_location}");
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* The copy of one database backup execution in one S3 destination.
|
||||
*/
|
||||
class DatabaseBackupS3Replica extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'execution_id',
|
||||
's3_storage_id',
|
||||
's3_uploaded',
|
||||
's3_storage_deleted',
|
||||
'message',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
's3_uploaded' => 'boolean',
|
||||
's3_storage_deleted' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function execution(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ScheduledDatabaseBackupExecution::class, 'execution_id');
|
||||
}
|
||||
|
||||
public function s3(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(S3Storage::class, 's3_storage_id');
|
||||
}
|
||||
}
|
||||
+44
-13
@@ -6,10 +6,12 @@ use App\Rules\SafeWebhookUrl;
|
||||
use App\Rules\ValidS3BucketName;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
@@ -67,19 +69,48 @@ class S3Storage extends BaseModel
|
||||
});
|
||||
|
||||
static::deleting(function (S3Storage $storage) {
|
||||
ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
ScheduledVolumeBackupExecution::where('s3_storage_id', $storage->id)
|
||||
->update([
|
||||
's3_storage_deleted' => true,
|
||||
's3_cleanup_pending' => false,
|
||||
]);
|
||||
ScheduledVolumeBackup::where('s3_storage_id', $storage->id)->update([
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
$storage->detachFromBackups();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes this storage from every backup schedule and marks the copies in it as deleted. A schedule moves its
|
||||
* primary destination to a remaining storage, or stops S3 backups when none remains.
|
||||
*/
|
||||
public function detachFromBackups(): void
|
||||
{
|
||||
DB::transaction(function (): void {
|
||||
foreach ([ScheduledDatabaseBackup::class, ScheduledVolumeBackup::class] as $backupModel) {
|
||||
$backupModel::query()
|
||||
->usingS3Storage($this->id)
|
||||
->lazyById()
|
||||
->each(function (ScheduledDatabaseBackup|ScheduledVolumeBackup $backup): void {
|
||||
$backup->syncS3Storages($backup->s3Storages()->whereKeyNot($this->id)->pluck('s3_storages.id')->all());
|
||||
if ($backup->s3_storage_id === null && $backup->save_s3) {
|
||||
$backup->update(['save_s3' => false]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
foreach ([DatabaseBackupS3Replica::class, VolumeBackupS3Replica::class] as $replicaModel) {
|
||||
$replicaModel::query()
|
||||
->with('execution')
|
||||
->where('s3_storage_id', $this->id)
|
||||
->where('s3_storage_deleted', false)
|
||||
->lazyById()
|
||||
->each(function (DatabaseBackupS3Replica|VolumeBackupS3Replica $replica): void {
|
||||
$replica->update(['s3_storage_deleted' => true]);
|
||||
$replica->execution?->refreshS3Summary();
|
||||
});
|
||||
}
|
||||
|
||||
ScheduledVolumeBackupExecution::query()
|
||||
->where('s3_cleanup_pending', true)
|
||||
->whereHas('s3Replicas', fn (Builder $query) => $query->where('s3_storage_id', $this->id))
|
||||
->whereDoesntHave('s3Replicas', fn (Builder $query) => $query
|
||||
->where('s3_storage_deleted', false)
|
||||
->where(fn (Builder $query) => $query->whereNull('s3_uploaded')->orWhere('s3_uploaded', false)))
|
||||
->update(['s3_cleanup_pending' => false]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasS3Destinations;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class ScheduledDatabaseBackup extends BaseModel
|
||||
{
|
||||
use HasS3Destinations;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -78,6 +82,11 @@ class ScheduledDatabaseBackup extends BaseModel
|
||||
return $this->belongsTo(S3Storage::class, 's3_storage_id');
|
||||
}
|
||||
|
||||
public function s3Storages(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(S3Storage::class, 'scheduled_database_backup_s3_storage')->withTimestamps();
|
||||
}
|
||||
|
||||
public function get_last_days_backup_status($days = 7)
|
||||
{
|
||||
return $this->hasMany(ScheduledDatabaseBackupExecution::class)->where('created_at', '>=', now()->subDays($days))->get();
|
||||
@@ -85,7 +94,7 @@ class ScheduledDatabaseBackup extends BaseModel
|
||||
|
||||
public function executionsPaginated(int $skip = 0, int $take = 10)
|
||||
{
|
||||
$executions = $this->hasMany(ScheduledDatabaseBackupExecution::class)->orderBy('created_at', 'desc');
|
||||
$executions = $this->hasMany(ScheduledDatabaseBackupExecution::class)->with('s3Replicas.s3')->orderBy('created_at', 'desc');
|
||||
$count = $executions->count();
|
||||
$executions = $executions->skip($skip)->take($take)->get();
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasS3Replicas;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ScheduledDatabaseBackupExecution extends BaseModel
|
||||
{
|
||||
use HasS3Replicas;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (ScheduledDatabaseBackupExecution $execution): void {
|
||||
@@ -42,4 +46,9 @@ class ScheduledDatabaseBackupExecution extends BaseModel
|
||||
{
|
||||
return $this->belongsTo(ScheduledDatabaseBackup::class);
|
||||
}
|
||||
|
||||
public function s3Replicas(): HasMany
|
||||
{
|
||||
return $this->hasMany(DatabaseBackupS3Replica::class, 'execution_id');
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasS3Destinations;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class ScheduledVolumeBackup extends BaseModel
|
||||
{
|
||||
use HasS3Destinations;
|
||||
|
||||
public const int DEFAULT_TIMEOUT = 36000;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -123,6 +127,11 @@ class ScheduledVolumeBackup extends BaseModel
|
||||
return $this->belongsTo(S3Storage::class, 's3_storage_id');
|
||||
}
|
||||
|
||||
public function s3Storages(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(S3Storage::class, 'scheduled_volume_backup_s3_storage')->withTimestamps();
|
||||
}
|
||||
|
||||
public function executions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ScheduledVolumeBackupExecution::class)->latest();
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasS3Replicas;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ScheduledVolumeBackupExecution extends BaseModel
|
||||
{
|
||||
use HasS3Replicas;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'scheduled_volume_backup_id',
|
||||
@@ -56,4 +60,9 @@ class ScheduledVolumeBackupExecution extends BaseModel
|
||||
{
|
||||
return $this->belongsTo(S3Storage::class, 's3_storage_id');
|
||||
}
|
||||
|
||||
public function s3Replicas(): HasMany
|
||||
{
|
||||
return $this->hasMany(VolumeBackupS3Replica::class, 'execution_id');
|
||||
}
|
||||
}
|
||||
+393
-58
@@ -21,6 +21,7 @@ use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use App\Services\DigitalOceanService;
|
||||
use App\Services\HetznerService;
|
||||
use App\Services\HostingerService;
|
||||
use App\Services\VultrService;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\Auditable;
|
||||
@@ -129,6 +130,19 @@ class Server extends BaseModel
|
||||
|
||||
public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::'];
|
||||
|
||||
/**
|
||||
* Serves the proxy error pages (503.html) for the Traefik and Caddy catch-all.
|
||||
*/
|
||||
public const PROXY_ERROR_PAGE_CONTAINER = 'coolify-proxy-error-pages';
|
||||
|
||||
public const PROXY_ERROR_PAGE_IMAGE = 'busybox:musl';
|
||||
|
||||
/**
|
||||
* Maximum size of a custom error or maintenance page. The page goes to the server with scp
|
||||
* (see uploadProxyFile()), so this limit only keeps the Livewire payload reasonable.
|
||||
*/
|
||||
public const PROXY_ERROR_PAGE_MAX_BYTES = 1048576;
|
||||
|
||||
/**
|
||||
* Default Caddy proxy image. caddy-docker-proxy 2.13 ships Caddy 2.11.
|
||||
*/
|
||||
@@ -324,6 +338,7 @@ class Server extends BaseModel
|
||||
protected $hidden = [
|
||||
'logdrain_axiom_api_key',
|
||||
'logdrain_newrelic_license_key',
|
||||
'proxy_error_page',
|
||||
];
|
||||
|
||||
protected $schemalessAttributes = [
|
||||
@@ -345,6 +360,8 @@ class Server extends BaseModel
|
||||
'vultr_instance_status',
|
||||
'digitalocean_droplet_id',
|
||||
'digitalocean_droplet_status',
|
||||
'hostinger_virtual_machine_id',
|
||||
'hostinger_virtual_machine_status',
|
||||
'is_validating',
|
||||
'validation_logs',
|
||||
'detected_traefik_version',
|
||||
@@ -523,6 +540,27 @@ class Server extends BaseModel
|
||||
return $status;
|
||||
}
|
||||
|
||||
public function refreshHostingerState(): ?string
|
||||
{
|
||||
if (! $this->hostinger_virtual_machine_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hostinger') {
|
||||
return $this->hostinger_virtual_machine_status;
|
||||
}
|
||||
|
||||
$hostingerService = new HostingerService($this->cloudProviderToken->token);
|
||||
$virtualMachine = $hostingerService->getVirtualMachine((int) $this->hostinger_virtual_machine_id);
|
||||
|
||||
if (empty($virtualMachine)) {
|
||||
return $this->hostinger_virtual_machine_status;
|
||||
}
|
||||
|
||||
$status = $virtualMachine['state'] ?? null;
|
||||
$ip = $hostingerService->getPublicIpAddress($virtualMachine);
|
||||
$this->persistProviderState(['hostinger_virtual_machine_status' => $status]);
|
||||
$this->backfillPlaceholderIp($ip);
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
protected function isCoolifyHost(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -621,6 +659,10 @@ class Server extends BaseModel
|
||||
return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && $this->canHostResources();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the catch-all proxy configuration for requests that no resource handles.
|
||||
* Priority: redirect URL > custom error page > Coolify default error page.
|
||||
*/
|
||||
public function setupDefaultRedirect()
|
||||
{
|
||||
$banner =
|
||||
@@ -629,7 +671,6 @@ class Server extends BaseModel
|
||||
$dynamic_conf_path = $this->proxyPath().'/dynamic';
|
||||
$proxy_type = $this->proxyType();
|
||||
$redirect_enabled = $this->proxy->redirect_enabled ?? true;
|
||||
$redirect_url = $this->proxy->redirect_url;
|
||||
if ($proxy_type === ProxyTypes::TRAEFIK->value) {
|
||||
$default_redirect_file = "$dynamic_conf_path/default_redirect_503.yaml";
|
||||
} elseif ($proxy_type === ProxyTypes::CADDY->value) {
|
||||
@@ -641,67 +682,13 @@ class Server extends BaseModel
|
||||
"rm -f $dynamic_conf_path/default_redirect_404.yaml",
|
||||
"rm -f $dynamic_conf_path/default_redirect_404.caddy",
|
||||
], $this);
|
||||
// The error page container must run before the catch-all points to it.
|
||||
$this->setupProxyErrorPageContainer();
|
||||
|
||||
if ($redirect_enabled === false) {
|
||||
instant_remote_process(["rm -f $default_redirect_file"], $this);
|
||||
} else {
|
||||
if ($proxy_type === ProxyTypes::CADDY->value) {
|
||||
if (filled($redirect_url)) {
|
||||
$conf = ":80, :443 {
|
||||
redir $redirect_url
|
||||
}";
|
||||
} else {
|
||||
$conf = ':80, :443 {
|
||||
respond 503
|
||||
}';
|
||||
}
|
||||
} elseif ($proxy_type === ProxyTypes::TRAEFIK->value) {
|
||||
$dynamic_conf = [
|
||||
'http' => [
|
||||
'routers' => [
|
||||
'catchall' => [
|
||||
'entryPoints' => [
|
||||
0 => 'http',
|
||||
1 => 'https',
|
||||
],
|
||||
'service' => 'noop',
|
||||
'rule' => 'PathPrefix(`/`)',
|
||||
'tls' => [
|
||||
'certResolver' => 'letsencrypt',
|
||||
],
|
||||
'priority' => -1000,
|
||||
],
|
||||
],
|
||||
'services' => [
|
||||
'noop' => [
|
||||
'loadBalancer' => [
|
||||
'servers' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
if (filled($redirect_url)) {
|
||||
$dynamic_conf['http']['routers']['catchall']['middlewares'] = [
|
||||
0 => 'redirect-regexp',
|
||||
];
|
||||
|
||||
$dynamic_conf['http']['services']['noop']['loadBalancer']['servers'][0] = [
|
||||
'url' => '',
|
||||
];
|
||||
$dynamic_conf['http']['middlewares'] = [
|
||||
'redirect-regexp' => [
|
||||
'redirectRegex' => [
|
||||
'regex' => '(.*)',
|
||||
'replacement' => $redirect_url,
|
||||
'permanent' => false,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
$conf = Yaml::dump($dynamic_conf, 12, 2);
|
||||
}
|
||||
$conf = $banner.$conf;
|
||||
$conf = $banner.$this->defaultRedirectConfiguration();
|
||||
$base64 = base64_encode($conf);
|
||||
instant_remote_process([
|
||||
"echo '$base64' | base64 -d | tee $default_redirect_file > /dev/null",
|
||||
@@ -713,6 +700,348 @@ class Server extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all proxy configuration (without the banner) for an enabled default redirect.
|
||||
*/
|
||||
public function defaultRedirectConfiguration(): string
|
||||
{
|
||||
$redirect_url = $this->proxy->redirect_url;
|
||||
$usesErrorPage = $this->usesProxyErrorPage();
|
||||
|
||||
if ($this->proxyType() === ProxyTypes::CADDY->value) {
|
||||
if (filled($redirect_url)) {
|
||||
return ":80, :443 {
|
||||
redir $redirect_url
|
||||
}";
|
||||
}
|
||||
if ($usesErrorPage) {
|
||||
// busybox httpd answers 200, so copy_response sets the 503 status.
|
||||
return ':80, :443 {
|
||||
reverse_proxy '.self::PROXY_ERROR_PAGE_CONTAINER.':80 {
|
||||
rewrite /503.html
|
||||
handle_response {
|
||||
copy_response 503
|
||||
}
|
||||
}
|
||||
}';
|
||||
}
|
||||
|
||||
return ':80, :443 {
|
||||
respond 503
|
||||
}';
|
||||
}
|
||||
|
||||
$dynamic_conf = [
|
||||
'http' => [
|
||||
'routers' => [
|
||||
'catchall' => [
|
||||
'entryPoints' => [
|
||||
0 => 'http',
|
||||
1 => 'https',
|
||||
],
|
||||
'service' => 'noop',
|
||||
'rule' => 'PathPrefix(`/`)',
|
||||
'tls' => [
|
||||
'certResolver' => 'letsencrypt',
|
||||
],
|
||||
'priority' => -1000,
|
||||
],
|
||||
],
|
||||
'services' => [
|
||||
'noop' => [
|
||||
'loadBalancer' => [
|
||||
'servers' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
if (filled($redirect_url)) {
|
||||
$dynamic_conf['http']['routers']['catchall']['middlewares'] = [
|
||||
0 => 'redirect-regexp',
|
||||
];
|
||||
|
||||
$dynamic_conf['http']['services']['noop']['loadBalancer']['servers'][0] = [
|
||||
'url' => '',
|
||||
];
|
||||
$dynamic_conf['http']['middlewares'] = [
|
||||
'redirect-regexp' => [
|
||||
'redirectRegex' => [
|
||||
'regex' => '(.*)',
|
||||
'replacement' => $redirect_url,
|
||||
'permanent' => false,
|
||||
],
|
||||
],
|
||||
];
|
||||
} elseif ($usesErrorPage) {
|
||||
// The noop service (no servers) keeps the 503; the errors middleware only replaces the body.
|
||||
$dynamic_conf['http']['routers']['catchall']['middlewares'] = [
|
||||
0 => 'error-pages',
|
||||
];
|
||||
$dynamic_conf['http']['middlewares'] = [
|
||||
'error-pages' => [
|
||||
'errors' => [
|
||||
'status' => ['503'],
|
||||
'service' => 'error-pages',
|
||||
'query' => '/503.html',
|
||||
],
|
||||
],
|
||||
];
|
||||
$dynamic_conf['http']['services']['error-pages'] = [
|
||||
'loadBalancer' => [
|
||||
'servers' => [
|
||||
['url' => 'http://'.self::PROXY_ERROR_PAGE_CONTAINER.':80'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// A router with `tls` matches only HTTPS, so plain HTTP needs its own catch-all router.
|
||||
if (! $this->isSwarm()) {
|
||||
$catchall = $dynamic_conf['http']['routers']['catchall'];
|
||||
$dynamic_conf['http']['routers']['catchall']['entryPoints'] = ['https'];
|
||||
unset($catchall['tls']);
|
||||
$dynamic_conf['http']['routers']['catchall-http'] = array_merge($catchall, ['entryPoints' => ['http']]);
|
||||
}
|
||||
|
||||
return Yaml::dump($dynamic_conf, 12, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the catch-all shows the error page: a standalone Traefik or Caddy proxy with
|
||||
* the default redirect enabled and no redirect URL. Swarm servers keep the plain 503.
|
||||
*/
|
||||
public function usesProxyErrorPage(): bool
|
||||
{
|
||||
return ! $this->isSwarm()
|
||||
&& in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)
|
||||
&& ($this->proxy->redirect_enabled ?? true) !== false
|
||||
&& blank($this->proxy->redirect_url);
|
||||
}
|
||||
|
||||
public function proxyErrorPagesPath(): string
|
||||
{
|
||||
return base_configuration_dir().'/proxy/error-pages';
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom error page HTML, or the Coolify default page.
|
||||
*/
|
||||
public function proxyErrorPageHtml(): string
|
||||
{
|
||||
return filled($this->proxy_error_page) ? $this->proxy_error_page : view('proxy.error-page')->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applications and services on this server that are in maintenance mode.
|
||||
*
|
||||
* @return Collection<int, Application|Service>
|
||||
*/
|
||||
public function maintenanceResources(): Collection
|
||||
{
|
||||
if ($this->isSwarm() || ! in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $this->applications()->where('is_maintenance_enabled', true)->values()
|
||||
->concat($this->services()->where('is_maintenance_enabled', true)->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes 503.html and starts the error page container, or removes the container when
|
||||
* neither the catch-all nor a maintenance page uses it. Swarm servers are not supported.
|
||||
*/
|
||||
public function setupProxyErrorPageContainer(): void
|
||||
{
|
||||
if ($this->isSwarm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$container = self::PROXY_ERROR_PAGE_CONTAINER;
|
||||
if (! $this->usesProxyErrorPage() && $this->maintenanceResources()->isEmpty()) {
|
||||
instant_remote_process(["docker rm -f $container >/dev/null 2>&1 || true"], $this);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $this->proxyErrorPagesPath();
|
||||
$mount = devHostDockerPath($this, $path);
|
||||
$restart = RESTART_MODE;
|
||||
|
||||
instant_remote_process(["mkdir -p $path"], $this);
|
||||
$this->uploadProxyFile("$path/503.html", $this->proxyErrorPageHtml());
|
||||
instant_remote_process([
|
||||
dockerNetworkEnsureCommand('coolify'),
|
||||
"docker start $container >/dev/null 2>&1 || docker run -d --name $container --network coolify --restart $restart --label coolify.managed=true -v $mount:/www:ro ".self::PROXY_ERROR_PAGE_IMAGE.' httpd -f -p 80 -h /www',
|
||||
// A proxy on custom networks only must still reach the error page container.
|
||||
'docker network connect coolify coolify-proxy >/dev/null 2>&1 || true',
|
||||
], $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes $contents to $path on this server with scp. A shell command is limited to 128 KiB,
|
||||
* so large pages and proxy files cannot go inside one command.
|
||||
*/
|
||||
public function uploadProxyFile(string $path, string $contents): void
|
||||
{
|
||||
$uploadPath = '/tmp/coolify-upload-'.bin2hex(random_bytes(8));
|
||||
$localPath = tempnam(sys_get_temp_dir(), 'coolify-proxy-file-');
|
||||
try {
|
||||
file_put_contents($localPath, $contents);
|
||||
instant_scp($localPath, $uploadPath, $this);
|
||||
} finally {
|
||||
@unlink($localPath);
|
||||
}
|
||||
|
||||
// The proxy and the page container read the file as root, other users do not need it.
|
||||
instant_remote_process(["mv -f $uploadPath $path", "chmod 644 $path"], $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the maintenance pages and the proxy configuration that shows them instead of the
|
||||
* applications and services in maintenance mode. Swarm servers are not supported.
|
||||
*/
|
||||
public function setupMaintenancePages(): void
|
||||
{
|
||||
$proxyType = $this->proxyType();
|
||||
if ($this->isSwarm() || ! in_array($proxyType, [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$resources = $this->maintenanceResources();
|
||||
$path = $this->proxyErrorPagesPath();
|
||||
$keep = $resources->map(fn ($resource) => "! -name '{$resource->maintenancePageFile()}' ")->implode('');
|
||||
|
||||
instant_remote_process([
|
||||
"mkdir -p $path",
|
||||
"find $path -maxdepth 1 -name 'maintenance-*.html' {$keep}-delete",
|
||||
], $this);
|
||||
$this->setupProxyErrorPageContainer();
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$this->uploadProxyFile("$path/{$resource->maintenancePageFile()}", $resource->maintenancePageHtml());
|
||||
}
|
||||
|
||||
$dynamicPath = $this->proxyPath().'/dynamic';
|
||||
instant_remote_process(["mkdir -p $dynamicPath"], $this);
|
||||
if ($proxyType === ProxyTypes::TRAEFIK->value) {
|
||||
$file = "$dynamicPath/coolify-maintenance.yaml";
|
||||
$configuration = $this->maintenanceTraefikConfiguration($resources);
|
||||
if ($configuration === null) {
|
||||
instant_remote_process(["rm -f $file"], $this);
|
||||
} else {
|
||||
$this->uploadProxyFile($file, $configuration);
|
||||
}
|
||||
} else {
|
||||
// caddy-docker-proxy reads the base Caddyfile again on its next poll.
|
||||
$this->uploadProxyFile("$dynamicPath/Caddyfile", $this->caddyBaseCaddyfile($resources));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traefik routers that use the same rule as the resource routers, with a priority one
|
||||
* higher. Traefik uses the rule length as the default priority, so a resource on a longer
|
||||
* path of the same host keeps its own router.
|
||||
*
|
||||
* @param Collection<int, Application|Service> $resources
|
||||
*/
|
||||
public function maintenanceTraefikConfiguration(Collection $resources): ?string
|
||||
{
|
||||
$routers = [];
|
||||
$middlewares = [];
|
||||
foreach ($resources as $resource) {
|
||||
$routes = $resource->maintenanceRoutes();
|
||||
if ($routes->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
$middleware = "coolify-maintenance-{$resource->uuid}";
|
||||
$middlewares[$middleware] = [
|
||||
'errors' => [
|
||||
'status' => ['503'],
|
||||
'service' => 'coolify-maintenance-pages',
|
||||
'query' => '/'.$resource->maintenancePageFile(),
|
||||
],
|
||||
];
|
||||
foreach ($routes as $index => $route) {
|
||||
$rule = "Host(`{$route['host']}`) && PathPrefix(`{$route['path']}`)";
|
||||
$router = [
|
||||
'entryPoints' => ['http'],
|
||||
'rule' => $rule,
|
||||
'service' => 'coolify-maintenance-noop',
|
||||
'middlewares' => ['coolify-maintenance-headers', $middleware],
|
||||
'priority' => strlen($rule) + 1,
|
||||
];
|
||||
$routers["coolify-maintenance-{$resource->uuid}-{$index}-http"] = $router;
|
||||
if ($route['scheme'] === 'https') {
|
||||
$routers["coolify-maintenance-{$resource->uuid}-{$index}-https"] = array_merge($router, [
|
||||
'entryPoints' => ['https'],
|
||||
'tls' => ['certResolver' => 'letsencrypt'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($routers === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$middlewares['coolify-maintenance-headers'] = [
|
||||
'headers' => ['customResponseHeaders' => ['Retry-After' => '300']],
|
||||
];
|
||||
|
||||
return "# This file is generated by Coolify, do not edit it manually.\n\n".Yaml::dump([
|
||||
'http' => [
|
||||
'routers' => $routers,
|
||||
'middlewares' => $middlewares,
|
||||
'services' => [
|
||||
// No servers: Traefik answers 503 and the errors middleware adds the page.
|
||||
'coolify-maintenance-noop' => ['loadBalancer' => ['servers' => []]],
|
||||
'coolify-maintenance-pages' => [
|
||||
'loadBalancer' => ['servers' => [['url' => 'http://'.self::PROXY_ERROR_PAGE_CONTAINER.':80']]],
|
||||
],
|
||||
],
|
||||
],
|
||||
], 12, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* The base Caddyfile of caddy-docker-proxy. A maintenance block uses the same site address
|
||||
* as the resource labels, so caddy-docker-proxy merges both into one site block. Its named
|
||||
* matcher has the same path as the resource handle, so Caddy runs it first.
|
||||
*
|
||||
* @param Collection<int, Application|Service>|null $resources
|
||||
*/
|
||||
public function caddyBaseCaddyfile(?Collection $resources = null): string
|
||||
{
|
||||
$resources ??= $this->maintenanceResources();
|
||||
$sites = [];
|
||||
foreach ($resources as $resource) {
|
||||
foreach ($resource->maintenanceRoutes() as $index => $route) {
|
||||
$address = caddySiteAddress($route['scheme'], $route['host'], $route['force_https']);
|
||||
$matcher = "@coolify-maintenance-{$resource->uuid}-{$index}";
|
||||
$sites[$address][] = implode("\n", [
|
||||
"\t{$matcher} path {$route['path']}*",
|
||||
"\thandle {$matcher} {",
|
||||
"\t\treverse_proxy ".self::PROXY_ERROR_PAGE_CONTAINER.':80 {',
|
||||
"\t\t\trewrite /{$resource->maintenancePageFile()}",
|
||||
"\t\t\theader_down Retry-After 300",
|
||||
"\t\t\thandle_response {",
|
||||
"\t\t\t\tcopy_response 503",
|
||||
"\t\t\t}",
|
||||
"\t\t}",
|
||||
"\t}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$caddyfile = 'import /dynamic/*.caddy';
|
||||
foreach ($sites as $address => $blocks) {
|
||||
$caddyfile .= "\n\n{$address} {\n".implode("\n", $blocks)."\n}";
|
||||
}
|
||||
|
||||
return $caddyfile."\n";
|
||||
}
|
||||
|
||||
public function setupDynamicProxyConfiguration()
|
||||
{
|
||||
$settings = instanceSettings();
|
||||
@@ -1272,6 +1601,12 @@ $siteAddress {
|
||||
return $this->caddyRunsCurrentVersion();
|
||||
}
|
||||
|
||||
public function caddySupportsArgon2idBasicAuth(): bool
|
||||
{
|
||||
return $this->caddyRunsCurrentVersion()
|
||||
&& self::caddyDockerProxyImageVersion($this->configuredCaddyProxyImage()) >= [2, 11];
|
||||
}
|
||||
|
||||
public function isServerApiEnabled(): bool
|
||||
{
|
||||
return $this->settings->is_sentinel_enabled;
|
||||
|
||||
+32
-1
@@ -9,6 +9,7 @@ use App\Support\ResourceStartActivity;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasComposeVolumeWarnings;
|
||||
use App\Traits\HasMaintenancePage;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -44,7 +45,7 @@ use OpenApi\Attributes as OA;
|
||||
)]
|
||||
class Service extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
private static $parserVersion = '5';
|
||||
|
||||
@@ -78,6 +79,13 @@ class Service extends BaseModel
|
||||
'docker_compose_raw',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_maintenance_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($service) {
|
||||
@@ -1515,6 +1523,29 @@ class Service extends BaseModel
|
||||
return $this->hasMany(ServiceApplication::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domains of every application in the service stack.
|
||||
*
|
||||
* @return Collection<int, array{url: string, force_https: bool}>
|
||||
*/
|
||||
public function maintenanceDomains(): Collection
|
||||
{
|
||||
return $this->applications()->get()
|
||||
->flatMap(fn (ServiceApplication $application) => collect($application->fqdns)
|
||||
->map(fn ($url) => trim((string) $url))
|
||||
->filter()
|
||||
->map(fn (string $url) => ['url' => $url, 'force_https' => $application->isForceHttpsEnabled()]))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Server>
|
||||
*/
|
||||
public function maintenanceServers(): Collection
|
||||
{
|
||||
return collect([$this->server])->filter()->values();
|
||||
}
|
||||
|
||||
public function databases()
|
||||
{
|
||||
return $this->hasMany(ServiceDatabase::class);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* The copy of one volume or directory backup execution in one S3 destination.
|
||||
*/
|
||||
class VolumeBackupS3Replica extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'execution_id',
|
||||
's3_storage_id',
|
||||
's3_uploaded',
|
||||
's3_storage_deleted',
|
||||
'message',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
's3_uploaded' => 'boolean',
|
||||
's3_storage_deleted' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function execution(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ScheduledVolumeBackupExecution::class, 'execution_id');
|
||||
}
|
||||
|
||||
public function s3(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(S3Storage::class, 's3_storage_id');
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ class CoolifyUpgradeStatus
|
||||
}
|
||||
|
||||
[$step, , $timestamp] = $parts;
|
||||
if (! in_array($step, ['1', '2', '3', '4', '5'], true)) {
|
||||
if (! in_array($step, ['1', '2', '3', '4', '5'], true) || trim($timestamp) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,10 @@ class ApplicationConfigurationSnapshot
|
||||
$this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'),
|
||||
$this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'),
|
||||
$this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true),
|
||||
$this->item('http_basic_auth_hash_algorithm', 'HTTP basic auth hash algorithm', $this->application->http_basic_auth_hash_algorithm?->value, 'redeploy'),
|
||||
$this->item('http_basic_auth_bcrypt_cost', 'HTTP basic auth bcrypt cost factor', $this->application->http_basic_auth_bcrypt_cost, 'redeploy'),
|
||||
$this->item('http_basic_auth_argon2id_memory_cost', 'HTTP basic auth Argon2id memory (KiB)', $this->application->http_basic_auth_argon2id_memory_cost, 'redeploy'),
|
||||
$this->item('http_basic_auth_argon2id_time_cost', 'HTTP basic auth Argon2id iterations', $this->application->http_basic_auth_argon2id_time_cost, 'redeploy'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RateLimitException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class HostingerService
|
||||
{
|
||||
private string $baseUrl = 'https://developers.hostinger.com';
|
||||
|
||||
public function __construct(private string $token) {}
|
||||
|
||||
private function request(string $method, string $endpoint, array $data = []): array
|
||||
{
|
||||
$response = Http::withToken($this->token)
|
||||
->acceptJson()
|
||||
->asJson()
|
||||
->timeout(30)
|
||||
->connectTimeout(10)
|
||||
->retry(
|
||||
$method === 'get' ? 3 : 1,
|
||||
fn (int $attempt) => $attempt * 100,
|
||||
fn (\Exception $exception) => ! $exception instanceof RequestException || $exception->response->serverError(),
|
||||
throw: false
|
||||
)
|
||||
->{$method}($this->baseUrl.$endpoint, $data);
|
||||
|
||||
if (! $response->successful()) {
|
||||
if ($response->status() === 429) {
|
||||
throw new RateLimitException(
|
||||
'Rate limit exceeded. Please try again later.',
|
||||
$response->header('Retry-After') !== null ? (int) $response->header('Retry-After') : null
|
||||
);
|
||||
}
|
||||
|
||||
$message = $response->json('message')
|
||||
?? $response->json('error')
|
||||
?? 'Unknown error';
|
||||
|
||||
throw new \Exception('Hostinger API error: '.$message, $response->status());
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
public function getDataCenters(): array
|
||||
{
|
||||
return $this->request('get', '/api/vps/v1/data-centers');
|
||||
}
|
||||
|
||||
public function getTemplates(): array
|
||||
{
|
||||
return $this->request('get', '/api/vps/v1/templates');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the KVM VPS plans. Game Panel plans share the VPS category but are not offered for Coolify servers.
|
||||
*/
|
||||
public function getCatalogItems(): array
|
||||
{
|
||||
return collect($this->request('get', '/api/billing/v1/catalog', ['category' => 'VPS']))
|
||||
->filter(fn ($item) => is_array($item) && preg_match('/-vps-kvm\d+$/', (string) ($item['id'] ?? '')) === 1)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getPublicKeys(): array
|
||||
{
|
||||
return $this->request('get', '/api/vps/v1/public-keys')['data'] ?? [];
|
||||
}
|
||||
|
||||
public function getPostInstallScripts(): array
|
||||
{
|
||||
return $this->request('get', '/api/vps/v1/post-install-scripts')['data'] ?? [];
|
||||
}
|
||||
|
||||
public function attachPublicKeys(int $virtualMachineId, array $publicKeyIds): array
|
||||
{
|
||||
return $this->request('post', "/api/vps/v1/public-keys/attach/{$virtualMachineId}", [
|
||||
'ids' => array_map('intval', array_values($publicKeyIds)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function purchaseVirtualMachine(array $params): array
|
||||
{
|
||||
// Hostinger accepts only a fully qualified hostname and uses its own default otherwise.
|
||||
if (! str_contains($params['setup']['hostname'] ?? '', '.')) {
|
||||
unset($params['setup']['hostname']);
|
||||
}
|
||||
|
||||
$existingVirtualMachineIds = collect($this->getVirtualMachines())->pluck('id')->all();
|
||||
|
||||
try {
|
||||
$response = $this->request('post', '/api/vps/v1/virtual-machines', $params);
|
||||
} catch (\Throwable $e) {
|
||||
// Hostinger can charge for the VPS before its setup fails, so keep the VPS if one was created.
|
||||
$virtualMachine = $this->findPurchasedVirtualMachine($existingVirtualMachineIds);
|
||||
if (! $virtualMachine) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
logger()->warning('Hostinger charged for a VPS but its setup failed', [
|
||||
'hostinger_virtual_machine_id' => $virtualMachine['id'],
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $this->setupPurchasedVirtualMachine($virtualMachine, $params['setup']);
|
||||
}
|
||||
|
||||
if (empty($response['virtual_machine']['id'])) {
|
||||
logger()->warning('Hostinger VPS order did not return a virtual machine', Arr::only($response, ['id', 'subscription_id', 'status', 'message']));
|
||||
|
||||
throw new \Exception('Hostinger order '.($response['id'] ?? 'unknown').' is waiting for payment. Finish the VPS setup in hPanel.', 202);
|
||||
}
|
||||
|
||||
return $response['virtual_machine'];
|
||||
}
|
||||
|
||||
private function findPurchasedVirtualMachine(array $existingVirtualMachineIds): ?array
|
||||
{
|
||||
try {
|
||||
return collect($this->getVirtualMachines())
|
||||
->first(fn (array $virtualMachine) => ! in_array($virtualMachine['id'] ?? null, $existingVirtualMachineIds, true));
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function setupPurchasedVirtualMachine(array $virtualMachine, array $setup): array
|
||||
{
|
||||
if (($virtualMachine['state'] ?? null) !== 'initial') {
|
||||
return $virtualMachine;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->request('post', $this->virtualMachineEndpoint((int) $virtualMachine['id']).'/setup', $setup);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->warning('Hostinger VPS setup retry failed', [
|
||||
'hostinger_virtual_machine_id' => $virtualMachine['id'],
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $virtualMachine;
|
||||
}
|
||||
}
|
||||
|
||||
public function getVirtualMachine(int $virtualMachineId): array
|
||||
{
|
||||
return $this->request('get', $this->virtualMachineEndpoint($virtualMachineId));
|
||||
}
|
||||
|
||||
public function getVirtualMachines(): array
|
||||
{
|
||||
return $this->request('get', '/api/vps/v1/virtual-machines');
|
||||
}
|
||||
|
||||
/**
|
||||
* Briefly wait for a public IP. VPS setup usually takes longer, so the server status check fills in the IP later.
|
||||
*/
|
||||
public function waitForPublicIp(array $virtualMachine, int $attempts = 10, int $sleepMilliseconds = 1000): array
|
||||
{
|
||||
if ($this->getPublicIpAddress($virtualMachine) || empty($virtualMachine['id'])) {
|
||||
return $virtualMachine;
|
||||
}
|
||||
|
||||
for ($attempt = 0; $attempt < $attempts; $attempt++) {
|
||||
usleep($sleepMilliseconds * 1000);
|
||||
|
||||
$virtualMachine = $this->getVirtualMachine((int) $virtualMachine['id']);
|
||||
|
||||
if ($this->getPublicIpAddress($virtualMachine)) {
|
||||
return $virtualMachine;
|
||||
}
|
||||
}
|
||||
|
||||
return $virtualMachine;
|
||||
}
|
||||
|
||||
public function getPublicIpAddress(array $virtualMachine): ?string
|
||||
{
|
||||
foreach (['ipv4', 'ipv6'] as $version) {
|
||||
foreach ($virtualMachine[$version] ?? [] as $ipAddress) {
|
||||
if (filled($ipAddress['address'] ?? null)) {
|
||||
return $ipAddress['address'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function disableAutoRenewal(string $subscriptionId): array
|
||||
{
|
||||
return $this->request('delete', "/api/billing/v1/subscriptions/{$subscriptionId}/auto-renewal/disable");
|
||||
}
|
||||
|
||||
public function startVirtualMachine(int $virtualMachineId): array
|
||||
{
|
||||
return $this->request('post', $this->virtualMachineEndpoint($virtualMachineId).'/start');
|
||||
}
|
||||
|
||||
public function findVirtualMachineByIp(string $ip): ?array
|
||||
{
|
||||
foreach ($this->getVirtualMachines() as $virtualMachine) {
|
||||
foreach (['ipv4', 'ipv6'] as $version) {
|
||||
foreach ($virtualMachine[$version] ?? [] as $ipAddress) {
|
||||
if (($ipAddress['address'] ?? null) === $ip) {
|
||||
return $virtualMachine;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function virtualMachineEndpoint(int $virtualMachineId): string
|
||||
{
|
||||
return '/api/vps/v1/virtual-machines/'.$virtualMachineId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Data\RepositoryDetectionResult;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Collection;
|
||||
use RuntimeException;
|
||||
|
||||
class RepositoryDetector
|
||||
{
|
||||
/**
|
||||
* Largest env template that is read from the repository, in bytes.
|
||||
*/
|
||||
private const MAX_ENV_FILE_BYTES = 65536;
|
||||
|
||||
/**
|
||||
* @param Application $application Unsaved application with the repository, branch, and Git source or deploy key.
|
||||
*/
|
||||
public function __construct(
|
||||
private Application $application,
|
||||
private string $baseDirectory,
|
||||
private Server $server,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException When the repository cannot be read.
|
||||
*/
|
||||
public function detect(): RepositoryDetectionResult
|
||||
{
|
||||
$uuid = new_public_id();
|
||||
|
||||
try {
|
||||
$output = instant_remote_process($this->scanCommands($uuid), $this->server, timeout: 60);
|
||||
} finally {
|
||||
instant_remote_process(["rm -rf /tmp/{$uuid}"], $this->server, false);
|
||||
}
|
||||
|
||||
return $this->parseOutput((string) str($output)->trim()->afterLast("\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones the repository without a checkout and lists the files with `git ls-tree`, so no file is
|
||||
* written to the disk. Each command is one line because non-root servers send every line through
|
||||
* parseCommandsByLineForSudo().
|
||||
*
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
protected function scanCommands(string $uuid): Collection
|
||||
{
|
||||
$checkoutDir = "/tmp/{$uuid}/checkout";
|
||||
$baseDir = trim($this->baseDirectory, '/');
|
||||
if (preg_match('~(^|/)\.\.?(/|$)~', $baseDir) || ! preg_match('~^[a-zA-Z0-9_./-]*$~', $baseDir)) {
|
||||
throw new RuntimeException('Invalid repository base directory.');
|
||||
}
|
||||
$tree = escapeshellarg($baseDir === '' ? 'HEAD' : "HEAD:{$baseDir}");
|
||||
$maxEnvBytes = self::MAX_ENV_FILE_BYTES;
|
||||
|
||||
$classifyFiles = <<<'AWK'
|
||||
{ split($1, meta, " "); if (meta[1] != "100644" && meta[1] != "100755") next; path = $2; lower = tolower(path); name = lower; sub(/.*\//, "", name); if (name ~ /^dockerfile(\.[a-z0-9_-]+)?$/) kind = "dockerfile"; else if (name ~ /^(docker-)?compose\.ya?ml$/) kind = "compose"; else if (lower ~ /^\.env\.(example|sample|template|dist|local\.example)$/) kind = "env"; else next; print kind "\t" meta[3] "\t" path }
|
||||
AWK;
|
||||
$exposedPort = <<<'AWK'
|
||||
{ sub(/\r$/, "") } toupper($1) == "EXPOSE" { split($2, p, "/"); if (p[1] ~ /^[0-9]+$/ && p[1] + 0 > 0 && p[1] + 0 <= 65535) print p[1] + 0; exit }
|
||||
AWK;
|
||||
$summary = '{dockerfiles: map(select(.kind == "dockerfile") | .file), dockerComposeFiles: map(select(.kind == "compose") | .file), envFiles: (map(select(.kind == "env") | {(.file): .content}) | add // {}), dockerfilePorts: (map(select(.kind == "dockerfile") | {(.file): .port}) | add // {})}';
|
||||
|
||||
$scan = 'tab=$(printf "\t"); '
|
||||
."git -c core.quotePath=false ls-tree -r {$tree} | awk -F '\\t' ".escapeshellarg($classifyFiles)
|
||||
.' | while IFS="$tab" read -r kind object file; do case "$kind" in '
|
||||
.'dockerfile) port=$(git cat-file blob "$object" | awk '.escapeshellarg($exposedPort).'); '
|
||||
.'jq -nc --arg file "$file" --argjson port "${port:-null}" \'{kind: "dockerfile", file: $file, port: $port}\' ;; '
|
||||
.'compose) jq -nc --arg file "$file" \'{kind: "compose", file: $file}\' ;; '
|
||||
."env) git cat-file blob \"\$object\" | head -c {$maxEnvBytes} | jq -Rsc --arg file \"\$file\" '{kind: \"env\", file: \$file, content: .}' ;; "
|
||||
.'esac; done | jq -sc '.escapeshellarg($summary);
|
||||
|
||||
return collect([
|
||||
"rm -rf /tmp/{$uuid}",
|
||||
"mkdir -p /tmp/{$uuid}",
|
||||
"cd /tmp/{$uuid}",
|
||||
'sh -c '.escapeshellarg($this->application->serverCheckoutCommand($uuid, $checkoutDir)),
|
||||
"cd {$checkoutDir}",
|
||||
'sh -c '.escapeshellarg($scan),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function parseOutput(string $output): RepositoryDetectionResult
|
||||
{
|
||||
$data = json_decode(trim($output), true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
return RepositoryDetectionResult::none();
|
||||
}
|
||||
|
||||
foreach (['dockerfiles', 'dockerComposeFiles', 'envFiles', 'dockerfilePorts'] as $key) {
|
||||
if (isset($data[$key]) && ! is_array($data[$key])) {
|
||||
return RepositoryDetectionResult::none();
|
||||
}
|
||||
}
|
||||
|
||||
$dockerfilePorts = [];
|
||||
foreach ($data['dockerfilePorts'] ?? [] as $file => $port) {
|
||||
$dockerfilePorts[$file] = (is_int($port) || (is_string($port) && ctype_digit($port))) && (int) $port > 0 && (int) $port <= 65535 ? (int) $port : null;
|
||||
}
|
||||
|
||||
return new RepositoryDetectionResult(
|
||||
dockerfiles: $this->sortByPreference(array_filter($data['dockerfiles'] ?? [], 'is_string'), '/^dockerfile$/i'),
|
||||
dockerComposeFiles: $this->sortByPreference(array_filter($data['dockerComposeFiles'] ?? [], 'is_string'), '/^docker-compose\.ya?ml$/i'),
|
||||
envFiles: array_filter($data['envFiles'] ?? [], fn ($content) => is_string($content) || $content === null),
|
||||
dockerfilePorts: $dockerfilePorts,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the file nearest to the base directory first. At the same depth, a file with the default
|
||||
* name (for example `Dockerfile`) comes before a variant. Other files keep the scan order.
|
||||
*
|
||||
* @param array<int|string, string> $files
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function sortByPreference(array $files, string $defaultNamePattern): array
|
||||
{
|
||||
$files = array_values($files);
|
||||
usort($files, fn (string $a, string $b): int => [substr_count($a, '/'), ! preg_match($defaultNamePattern, basename($a))]
|
||||
<=> [substr_count($b, '/'), ! preg_match($defaultNamePattern, basename($b))]);
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
@@ -202,22 +203,22 @@ class ServerTransferExporter
|
||||
}
|
||||
}
|
||||
|
||||
$collectVolumeS3 = function ($resource) use (&$s3Ids): void {
|
||||
$collectBackupS3 = function (ScheduledDatabaseBackup|ScheduledVolumeBackup $backup) use (&$s3Ids): void {
|
||||
$s3Ids->push($backup->s3_storage_id, ...$backup->s3Storages()->pluck('s3_storages.id')->all());
|
||||
};
|
||||
|
||||
$collectVolumeS3 = function ($resource) use ($collectBackupS3): void {
|
||||
if (method_exists($resource, 'persistentStorages')) {
|
||||
foreach ($resource->persistentStorages as $volume) {
|
||||
foreach ($volume->scheduledBackups as $vb) {
|
||||
if ($vb->s3_storage_id) {
|
||||
$s3Ids->push($vb->s3_storage_id);
|
||||
}
|
||||
$collectBackupS3($vb);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method_exists($resource, 'fileStorages')) {
|
||||
foreach ($resource->fileStorages as $file) {
|
||||
foreach ($file->scheduledBackups as $vb) {
|
||||
if ($vb->s3_storage_id) {
|
||||
$s3Ids->push($vb->s3_storage_id);
|
||||
}
|
||||
$collectBackupS3($vb);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,9 +227,7 @@ class ServerTransferExporter
|
||||
foreach ($databases as $database) {
|
||||
if (method_exists($database, 'scheduledBackups')) {
|
||||
foreach ($database->scheduledBackups as $backup) {
|
||||
if ($backup->s3_storage_id) {
|
||||
$s3Ids->push($backup->s3_storage_id);
|
||||
}
|
||||
$collectBackupS3($backup);
|
||||
}
|
||||
}
|
||||
$database->loadMissing(['persistentStorages.scheduledBackups', 'fileStorages.scheduledBackups']);
|
||||
@@ -257,9 +256,7 @@ class ServerTransferExporter
|
||||
}
|
||||
foreach ($service->databases as $serviceDb) {
|
||||
foreach ($serviceDb->scheduledBackups as $backup) {
|
||||
if ($backup->s3_storage_id) {
|
||||
$s3Ids->push($backup->s3_storage_id);
|
||||
}
|
||||
$collectBackupS3($backup);
|
||||
}
|
||||
$collectVolumeS3($serviceDb);
|
||||
}
|
||||
@@ -981,6 +978,7 @@ class ServerTransferExporter
|
||||
'timeout' => $backup->timeout,
|
||||
'disable_local_backup' => (bool) ($backup->disable_local_backup ?? false),
|
||||
's3_storage_uuid' => $backup->s3?->uuid,
|
||||
's3_storage_uuids' => $backup->selectedS3Storages()->pluck('uuid')->values()->all(),
|
||||
'had_s3_storage' => filled($backup->s3_storage_id),
|
||||
];
|
||||
}
|
||||
@@ -1193,6 +1191,7 @@ class ServerTransferExporter
|
||||
'retention_max_storage_s3' => $backup->retention_max_storage_s3,
|
||||
'timeout' => $backup->timeout,
|
||||
's3_storage_uuid' => $backup->s3?->uuid,
|
||||
's3_storage_uuids' => $backup->selectedS3Storages()->pluck('uuid')->values()->all(),
|
||||
'had_s3_storage' => filled($backup->s3_storage_id),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1237,13 +1237,10 @@ class ServerTransferImporter
|
||||
$uuid = new_public_id();
|
||||
}
|
||||
|
||||
$s3Id = null;
|
||||
$s3Uuid = data_get($backup, 's3_storage_uuid');
|
||||
if ($s3Uuid && isset($this->s3StorageMap[$s3Uuid])) {
|
||||
$s3Id = $this->s3StorageMap[$s3Uuid]->id;
|
||||
}
|
||||
$s3Ids = $this->importedBackupS3StorageIds($backup);
|
||||
$s3Id = $s3Ids[0] ?? null;
|
||||
|
||||
ScheduledDatabaseBackup::create([
|
||||
$importedBackup = ScheduledDatabaseBackup::create([
|
||||
'uuid' => $uuid,
|
||||
'team_id' => $teamId,
|
||||
'description' => data_get($backup, 'description'),
|
||||
@@ -1264,9 +1261,26 @@ class ServerTransferImporter
|
||||
'database_type' => $database->getMorphClass(),
|
||||
'database_id' => $database->id,
|
||||
]);
|
||||
$importedBackup->syncS3Storages($s3Ids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the exported S3 destinations of a backup to imported storages, primary first. Older bundles only have
|
||||
* `s3_storage_uuid`.
|
||||
*
|
||||
* @param array<string, mixed> $backup
|
||||
* @return list<int>
|
||||
*/
|
||||
private function importedBackupS3StorageIds(array $backup): array
|
||||
{
|
||||
$uuids = collect(data_get($backup, 's3_storage_uuids', []))
|
||||
->prepend(data_get($backup, 's3_storage_uuid'))
|
||||
->filter(fn ($uuid): bool => is_string($uuid) && isset($this->s3StorageMap[$uuid]));
|
||||
|
||||
return $uuids->map(fn (string $uuid): int => $this->s3StorageMap[$uuid]->id)->unique()->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
@@ -1612,13 +1626,10 @@ class ServerTransferImporter
|
||||
$uuid = new_public_id();
|
||||
}
|
||||
|
||||
$s3Id = null;
|
||||
$s3Uuid = data_get($backupPayload, 's3_storage_uuid');
|
||||
if ($s3Uuid && isset($this->s3StorageMap[$s3Uuid])) {
|
||||
$s3Id = $this->s3StorageMap[$s3Uuid]->id;
|
||||
}
|
||||
$s3Ids = $this->importedBackupS3StorageIds($backupPayload);
|
||||
$s3Id = $s3Ids[0] ?? null;
|
||||
|
||||
ScheduledVolumeBackup::create([
|
||||
$importedBackup = ScheduledVolumeBackup::create([
|
||||
'uuid' => $uuid,
|
||||
'backupable_type' => $volume->getMorphClass(),
|
||||
'backupable_id' => $volume->id,
|
||||
@@ -1637,6 +1648,7 @@ class ServerTransferImporter
|
||||
'retention_max_storage_s3' => data_get($backupPayload, 'retention_max_storage_s3'),
|
||||
'timeout' => data_get($backupPayload, 'timeout'),
|
||||
]);
|
||||
$importedBackup->syncS3Storages($s3Ids);
|
||||
$count++;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Collection;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
/**
|
||||
* Maintenance mode for a resource with domains: the proxy shows a maintenance page with
|
||||
* status 503 instead of the resource. The resource containers keep running.
|
||||
*
|
||||
* @property bool $is_maintenance_enabled
|
||||
* @property string|null $maintenance_page
|
||||
*/
|
||||
trait HasMaintenancePage
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, array{url: string, force_https: bool}>
|
||||
*/
|
||||
abstract public function maintenanceDomains(): Collection;
|
||||
|
||||
/**
|
||||
* @return Collection<int, Server>
|
||||
*/
|
||||
abstract public function maintenanceServers(): Collection;
|
||||
|
||||
/**
|
||||
* Proxy routes for the maintenance page. Domains go into the Traefik and Caddy configuration
|
||||
* as plain text, so a domain with an unexpected host or path is skipped.
|
||||
*
|
||||
* @return Collection<int, array{scheme: string, host: string, path: string, force_https: bool}>
|
||||
*/
|
||||
public function maintenanceRoutes(): Collection
|
||||
{
|
||||
return $this->maintenanceDomains()
|
||||
->map(function (array $domain): ?array {
|
||||
try {
|
||||
$url = Url::fromString(trim($domain['url']));
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
// Host and scheme stay as the proxy labels use them, so the Caddy site address is the same.
|
||||
$scheme = $url->getScheme();
|
||||
$host = $url->getHost();
|
||||
$path = $url->getPath() ?: '/';
|
||||
|
||||
if (! in_array($scheme, ['http', 'https'], true)
|
||||
|| ! preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i', $host)
|
||||
|| ! preg_match('#^/[A-Za-z0-9/._~%-]*$#', $path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['scheme' => $scheme, 'host' => $host, 'path' => $path, 'force_https' => $domain['force_https']];
|
||||
})
|
||||
->filter()
|
||||
->unique(fn (array $route) => "{$route['scheme']}://{$route['host']}{$route['path']}")
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom maintenance page HTML, or the Coolify default page.
|
||||
*/
|
||||
public function maintenancePageHtml(): string
|
||||
{
|
||||
return filled($this->maintenance_page) ? $this->maintenance_page : view('proxy.maintenance-page')->render();
|
||||
}
|
||||
|
||||
public function maintenancePageFile(): string
|
||||
{
|
||||
return "maintenance-{$this->uuid}.html";
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the maintenance state of this resource on every server that runs it.
|
||||
*/
|
||||
public function syncMaintenancePage(?Collection $servers = null): void
|
||||
{
|
||||
foreach ($servers ?? $this->maintenanceServers() as $server) {
|
||||
if ($server->isFunctional()) {
|
||||
$server->setupMaintenancePages();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Data\RepositoryDetectionResult;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Services\RepositoryDetector;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
||||
/**
|
||||
* @property string $build_pack
|
||||
* @property int $port
|
||||
* @property string|null $docker_compose_location
|
||||
*
|
||||
* @method void updatedBuildPack()
|
||||
*/
|
||||
trait HasRepositoryDetection
|
||||
{
|
||||
public bool $detectionRan = false;
|
||||
|
||||
public bool $detectionFailed = false;
|
||||
|
||||
/**
|
||||
* Build pack that the scan selected from a file in the base directory.
|
||||
*/
|
||||
#[Locked]
|
||||
public ?string $suggestedBuildPack = null;
|
||||
|
||||
#[Locked]
|
||||
public array $detectedDockerfiles = [];
|
||||
|
||||
#[Locked]
|
||||
public array $detectedDockerComposeFiles = [];
|
||||
|
||||
public ?string $selectedDockerfile = null;
|
||||
|
||||
public ?string $selectedDockerComposeFile = null;
|
||||
|
||||
public ?int $detectedPort = null;
|
||||
|
||||
#[Locked]
|
||||
public array $dockerfilePorts = [];
|
||||
|
||||
#[Locked]
|
||||
public array $detectedEnvFiles = [];
|
||||
|
||||
public ?string $selectedEnvFile = null;
|
||||
|
||||
#[Locked]
|
||||
public array $parsedEnvFiles = [];
|
||||
|
||||
public array $envExampleVars = [];
|
||||
|
||||
public bool $envImported = false;
|
||||
|
||||
/**
|
||||
* Unsaved application with the repository, branch, and Git source or deploy key to scan.
|
||||
*/
|
||||
abstract protected function applicationForDetection(): Application;
|
||||
|
||||
public function detectRepository(): void
|
||||
{
|
||||
$this->authorize('create', Application::class);
|
||||
try {
|
||||
$application = $this->applicationForDetection();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
|
||||
return;
|
||||
}
|
||||
Validator::make([
|
||||
'branch' => $application->git_branch,
|
||||
'base_directory' => $this->base_directory ?? '/',
|
||||
], [
|
||||
'branch' => ['required', new ValidGitBranch],
|
||||
'base_directory' => ['required', 'string', 'regex:~^/?(?:[a-zA-Z0-9_.-]+/)*[a-zA-Z0-9_.-]*$~', 'not_regex:~(^|/)\.\.?(/|$)~'],
|
||||
])->validate();
|
||||
|
||||
$destination = find_resource_destination_for_current_team(data_get($this->query, 'destination'));
|
||||
if (! $destination) {
|
||||
$this->dispatch('error', 'Destination not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->authorize('update', $destination->server);
|
||||
|
||||
$this->detectionRan = false;
|
||||
$this->detectionFailed = false;
|
||||
$this->applyDetectionResult(RepositoryDetectionResult::none());
|
||||
try {
|
||||
$detector = new RepositoryDetector($application, $this->base_directory ?? '/', $destination->server);
|
||||
$this->applyDetectionResult($detector->detect());
|
||||
} catch (\Throwable $e) {
|
||||
// The exception can contain the clone command with a Git token, so do not log the message.
|
||||
Log::debug('Repository detection failed', ['exception' => $e::class]);
|
||||
$this->detectionFailed = true;
|
||||
$this->dispatch('error', 'Smart Scan could not read the repository. Check the repository access and the branch.');
|
||||
}
|
||||
$this->detectionRan = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an application that is not saved, only to generate the clone command for the scan.
|
||||
*/
|
||||
protected function unsavedApplicationForDetection(string $repository, string $branch, GithubApp|GitlabApp|null $source = null, ?PrivateKey $privateKey = null): Application
|
||||
{
|
||||
$application = (new Application)->forceFill([
|
||||
'git_repository' => $repository,
|
||||
'git_branch' => $branch,
|
||||
'git_commit_sha' => 'HEAD',
|
||||
'private_key_id' => $privateKey?->id,
|
||||
]);
|
||||
$application->setRelation('settings', (new ApplicationSetting)->forceFill([
|
||||
'is_git_shallow_clone_enabled' => true,
|
||||
'is_git_submodules_enabled' => false,
|
||||
]));
|
||||
$application->setRelation('source', $source);
|
||||
$application->setRelation('private_key', $privateKey);
|
||||
|
||||
return $application;
|
||||
}
|
||||
|
||||
protected function importDetectedEnvironmentVariables(Application $application): void
|
||||
{
|
||||
if (! $this->envImported || empty($this->envExampleVars)) {
|
||||
return;
|
||||
}
|
||||
Validator::make(['variables' => $this->envExampleVars], [
|
||||
'variables' => ['array'],
|
||||
'variables.*' => ['nullable', 'string'],
|
||||
])->validate();
|
||||
$application->getConnection()->transaction(function () use ($application): void {
|
||||
foreach ($this->envExampleVars as $key => $value) {
|
||||
if (! preg_match('/^[A-Z_][A-Z0-9_]*$/i', (string) $key)) {
|
||||
continue;
|
||||
}
|
||||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'resourceable_type' => $application->getMorphClass(),
|
||||
'resourceable_id' => $application->id,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function applyDetectionResult(RepositoryDetectionResult $result): void
|
||||
{
|
||||
$this->detectedDockerfiles = $result->dockerfiles;
|
||||
$this->detectedDockerComposeFiles = $result->dockerComposeFiles;
|
||||
$this->dockerfilePorts = $result->dockerfilePorts;
|
||||
|
||||
$this->selectedDockerfile = null;
|
||||
$this->selectedDockerComposeFile = null;
|
||||
$this->detectedPort = null;
|
||||
$this->detectedEnvFiles = [];
|
||||
$this->parsedEnvFiles = [];
|
||||
$this->selectedEnvFile = null;
|
||||
$this->envExampleVars = [];
|
||||
$this->envImported = false;
|
||||
|
||||
$this->suggestedBuildPack = $result->getSuggestedBuildPack()?->value;
|
||||
if ($this->suggestedBuildPack && $this->suggestedBuildPack !== $this->build_pack) {
|
||||
$this->build_pack = $this->suggestedBuildPack;
|
||||
$this->updatedBuildPack();
|
||||
}
|
||||
|
||||
if ($result->hasDockerfile()) {
|
||||
$this->selectedDockerfile = $result->dockerfiles[0];
|
||||
$port = $result->dockerfilePorts[$this->selectedDockerfile] ?? null;
|
||||
$this->port = $port ?? 3000;
|
||||
$this->detectedPort = $port;
|
||||
}
|
||||
|
||||
if ($result->hasDockerCompose()) {
|
||||
$this->selectedDockerComposeFile = $result->dockerComposeFiles[0];
|
||||
$this->docker_compose_location = '/'.$this->selectedDockerComposeFile;
|
||||
}
|
||||
|
||||
if ($result->hasEnvFiles()) {
|
||||
$this->detectedEnvFiles = array_keys($result->envFiles);
|
||||
$this->parsedEnvFiles = [];
|
||||
foreach ($result->envFiles as $filename => $content) {
|
||||
$parsed = $content !== null ? parseEnvFormatToArray($content) : [];
|
||||
// parseEnvFormatToArray returns ['KEY' => ['value' => ..., 'comment' => ...]].
|
||||
// Flatten to a plain ['KEY' => 'value'] map for the import form.
|
||||
$this->parsedEnvFiles[$filename] = collect($parsed)
|
||||
->map(fn ($item) => is_array($item) ? (string) ($item['value'] ?? '') : (string) $item)
|
||||
->all();
|
||||
}
|
||||
$this->selectedEnvFile = $this->detectedEnvFiles[0];
|
||||
$this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository path without a leading slash, so a typed path matches the detected paths.
|
||||
*/
|
||||
protected function normalizeRepositoryFilePath(?string $path): ?string
|
||||
{
|
||||
$path = ltrim(trim((string) $path), '/');
|
||||
|
||||
return $path === '' ? null : $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dockerfile location for the new application: a detected file or a path the user typed.
|
||||
*/
|
||||
protected function selectedDockerfileLocation(): ?string
|
||||
{
|
||||
$path = $this->normalizeRepositoryFilePath($this->selectedDockerfile);
|
||||
if ($path === null) {
|
||||
return null;
|
||||
}
|
||||
$location = '/'.$path;
|
||||
Validator::make(
|
||||
['selectedDockerfile' => $location],
|
||||
['selectedDockerfile' => ValidationPatterns::filePathRules()],
|
||||
ValidationPatterns::filePathMessages('selectedDockerfile', 'Dockerfile'),
|
||||
)->validate();
|
||||
|
||||
return $location;
|
||||
}
|
||||
|
||||
public function updatedSelectedDockerfile(): void
|
||||
{
|
||||
$this->selectedDockerfile = $this->normalizeRepositoryFilePath($this->selectedDockerfile);
|
||||
$this->resetErrorBag('selectedDockerfile');
|
||||
$this->selectedDockerfileLocation();
|
||||
|
||||
$port = $this->dockerfilePorts[$this->selectedDockerfile ?? ''] ?? null;
|
||||
$this->port = $port ?? 3000;
|
||||
$this->detectedPort = $port;
|
||||
}
|
||||
|
||||
public function updatedSelectedDockerComposeFile(): void
|
||||
{
|
||||
$this->selectedDockerComposeFile = $this->normalizeRepositoryFilePath($this->selectedDockerComposeFile);
|
||||
|
||||
if ($this->selectedDockerComposeFile) {
|
||||
$this->docker_compose_location = '/'.$this->selectedDockerComposeFile;
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSelectedEnvFile(): void
|
||||
{
|
||||
if ($this->selectedEnvFile && isset($this->parsedEnvFiles[$this->selectedEnvFile])) {
|
||||
$this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile];
|
||||
$this->envImported = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmEnvImport(): void
|
||||
{
|
||||
$this->envImported = true;
|
||||
}
|
||||
|
||||
public function clearEnvVars(): void
|
||||
{
|
||||
$this->envImported = false;
|
||||
$this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* S3 destinations of a backup schedule. The `s3_storage_id` column keeps the primary destination for older clients.
|
||||
*/
|
||||
trait HasS3Destinations
|
||||
{
|
||||
abstract public function s3Storages(): BelongsToMany;
|
||||
|
||||
/**
|
||||
* @return Collection<int, S3Storage> The primary destination comes first.
|
||||
*/
|
||||
public function selectedS3Storages(): Collection
|
||||
{
|
||||
$storages = $this->relationLoaded('s3Storages')
|
||||
? $this->s3Storages->sortBy('name')
|
||||
: $this->s3Storages()->orderBy('s3_storages.name')->get();
|
||||
|
||||
if ($storages->isEmpty()) {
|
||||
return $this->s3 ? collect([$this->s3]) : collect();
|
||||
}
|
||||
|
||||
return $storages
|
||||
->sortBy(fn (S3Storage $storage): int => $storage->id === $this->s3_storage_id ? 0 : 1)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules that send backups to the given storage.
|
||||
*/
|
||||
public function scopeUsingS3Storage(Builder $query, int $storageId): Builder
|
||||
{
|
||||
return $query->where(fn (Builder $query) => $query
|
||||
->where('s3_storage_id', $storageId)
|
||||
->orWhereHas('s3Storages', fn (Builder $query) => $query->whereKey($storageId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives a cloned schedule the same destinations, limited to storages of the clone's team.
|
||||
*/
|
||||
public function copyS3StoragesTo(self $copy): void
|
||||
{
|
||||
$copy->s3Storages()->sync(
|
||||
$this->s3Storages()->where('s3_storages.team_id', $copy->team_id)->pluck('s3_storages.id')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the selected destinations. The current primary destination stays primary when it is still selected.
|
||||
*
|
||||
* @param array<int, int|string> $storageIds
|
||||
*/
|
||||
public function syncS3Storages(array $storageIds): void
|
||||
{
|
||||
$storageIds = collect($storageIds)->map(fn ($id): int => (int) $id)->filter()->unique()->values();
|
||||
|
||||
$this->s3Storages()->sync($storageIds->all());
|
||||
$this->s3_storage_id = $storageIds->contains($this->s3_storage_id) ? $this->s3_storage_id : $storageIds->first();
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* S3 copies of a backup execution, one per destination. The `s3_uploaded` and `s3_storage_deleted` columns of the
|
||||
* execution summarize them.
|
||||
*/
|
||||
trait HasS3Replicas
|
||||
{
|
||||
abstract public function s3Replicas(): HasMany;
|
||||
|
||||
/**
|
||||
* Deletes the backup file from every destination that still has a copy.
|
||||
*/
|
||||
public function deleteS3Copies(): void
|
||||
{
|
||||
$replicas = $this->s3Replicas()
|
||||
->with('s3')
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false)
|
||||
->get();
|
||||
|
||||
try {
|
||||
foreach ($replicas as $replica) {
|
||||
if (! $replica->s3) {
|
||||
throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.');
|
||||
}
|
||||
if (filled($this->filename)) {
|
||||
deleteBackupsS3($this->filename, $replica->s3);
|
||||
}
|
||||
$replica->update(['s3_storage_deleted' => true]);
|
||||
}
|
||||
} finally {
|
||||
$this->refreshS3Summary();
|
||||
}
|
||||
}
|
||||
|
||||
public function hasLiveS3Copies(): bool
|
||||
{
|
||||
return $this->s3Replicas()->where('s3_uploaded', true)->where('s3_storage_deleted', false)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the replica states to the summary columns: uploaded when every copy uploaded, deleted when every uploaded
|
||||
* copy is deleted.
|
||||
*/
|
||||
public function refreshS3Summary(): void
|
||||
{
|
||||
$replicas = $this->s3Replicas()->get();
|
||||
if ($replicas->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uploaded = $replicas->where('s3_uploaded', true);
|
||||
|
||||
$this->update([
|
||||
's3_uploaded' => $replicas->every(fn ($replica): bool => $replica->s3_uploaded === true),
|
||||
's3_storage_deleted' => $uploaded->isNotEmpty() && $uploaded->every(fn ($replica): bool => $replica->s3_storage_deleted),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executions that have no S3 copy left to keep.
|
||||
*/
|
||||
public function scopeWithoutLiveS3Copies(Builder $query): Builder
|
||||
{
|
||||
return $query->whereDoesntHave('s3Replicas', fn (Builder $query) => $query
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Actions\Shared\MigrateResourceToDestination;
|
||||
use App\Enums\BuildPackTypes;
|
||||
use App\Enums\HttpBasicAuthHashAlgorithm;
|
||||
use App\Enums\RedirectTypes;
|
||||
use App\Enums\StaticImageTypes;
|
||||
use App\Models\Application;
|
||||
@@ -276,6 +277,10 @@ function sharedDataApplications(?Application $application = null): array
|
||||
'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'is_container_label_escape_enabled' => 'boolean',
|
||||
'is_preserve_repository_enabled' => 'boolean',
|
||||
'http_basic_auth_hash_algorithm' => Rule::enum(HttpBasicAuthHashAlgorithm::class),
|
||||
'http_basic_auth_bcrypt_cost' => 'integer|min:4|max:14',
|
||||
'http_basic_auth_argon2id_memory_cost' => 'integer|min:8192|max:262144',
|
||||
'http_basic_auth_argon2id_time_cost' => 'integer|min:1|max:12',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+114
-134
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Models\DatabaseBackupS3Replica;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
@@ -314,110 +316,96 @@ function deleteEmptyBackupFolder($folderPath, Server $server): void
|
||||
|
||||
function removeOldBackups($backup): void
|
||||
{
|
||||
try {
|
||||
if ($backup->executions) {
|
||||
// Delete old local backups (only if local backup is NOT disabled)
|
||||
// Note: When disable_local_backup is enabled, each execution already marks its own
|
||||
// local_storage_deleted status at the time of backup, so we don't need to retroactively
|
||||
// update old executions
|
||||
if (! $backup->disable_local_backup) {
|
||||
$localBackupsToDelete = deleteOldBackupsLocally($backup);
|
||||
if ($localBackupsToDelete->isNotEmpty()) {
|
||||
$backup->executions()
|
||||
->whereIn('id', $localBackupsToDelete->pluck('id'))
|
||||
->update(['local_storage_deleted' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($backup->save_s3 && $backup->executions) {
|
||||
$s3BackupsToDelete = deleteOldBackupsFromS3($backup);
|
||||
if ($s3BackupsToDelete->isNotEmpty()) {
|
||||
$backup->executions()
|
||||
->whereIn('id', $s3BackupsToDelete->pluck('id'))
|
||||
->update(['s3_storage_deleted' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete execution records where all backup copies are gone
|
||||
// Case 1: Both local and S3 backups are deleted
|
||||
// When local backups are disabled, retain the local file until an S3 copy is available.
|
||||
$localBackupsToDelete = deleteOldBackupsLocally($backup);
|
||||
if ($localBackupsToDelete->isNotEmpty()) {
|
||||
$backup->executions()
|
||||
->where('local_storage_deleted', true)
|
||||
->where('s3_storage_deleted', true)
|
||||
->delete();
|
||||
|
||||
// Case 2: Local backup is deleted and S3 was never used (s3_uploaded is null)
|
||||
$backup->executions()
|
||||
->where('local_storage_deleted', true)
|
||||
->whereNull('s3_uploaded')
|
||||
->delete();
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw $e;
|
||||
->whereIn('id', $localBackupsToDelete->pluck('id'))
|
||||
->update(['local_storage_deleted' => true]);
|
||||
}
|
||||
|
||||
if ($backup->save_s3) {
|
||||
deleteOldBackupsFromS3($backup);
|
||||
}
|
||||
|
||||
// Delete execution records when no local file and no S3 copy is left
|
||||
$backup->executions()
|
||||
->where('local_storage_deleted', true)
|
||||
->withoutLiveS3Copies()
|
||||
->delete();
|
||||
}
|
||||
|
||||
function deleteOldBackupsLocally($backup): Collection
|
||||
/**
|
||||
* Successful executions that fall outside the retention settings, newest execution first.
|
||||
*
|
||||
* @param Collection<int, ScheduledDatabaseBackupExecution> $executions Sorted newest first.
|
||||
* @return Collection<int, ScheduledDatabaseBackupExecution>
|
||||
*/
|
||||
function backupExecutionsOutsideRetention(Collection $executions, ?int $retentionAmount, ?int $retentionDays, int|float|null $maxStorageGB): Collection
|
||||
{
|
||||
if (! $backup || ! $backup->executions) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$successfulBackups = $backup->executions()
|
||||
->where('status', 'success')
|
||||
->where('local_storage_deleted', false)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if ($successfulBackups->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$retentionAmount = $backup->database_backup_retention_amount_locally;
|
||||
$retentionDays = $backup->database_backup_retention_days_locally;
|
||||
$maxStorageGB = $backup->database_backup_retention_max_storage_locally;
|
||||
|
||||
if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) {
|
||||
if ($executions->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$backupsToDelete = collect();
|
||||
|
||||
if ($retentionAmount > 0) {
|
||||
$byAmount = $successfulBackups->skip($retentionAmount);
|
||||
$backupsToDelete = $backupsToDelete->merge($byAmount);
|
||||
$backupsToDelete = $backupsToDelete->merge($executions->skip($retentionAmount));
|
||||
}
|
||||
|
||||
if ($retentionDays > 0) {
|
||||
$oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays);
|
||||
$oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate);
|
||||
$backupsToDelete = $backupsToDelete->merge($oldBackups);
|
||||
$oldestAllowedDate = $executions->first()->created_at->clone()->utc()->subDays($retentionDays);
|
||||
$backupsToDelete = $backupsToDelete->merge(
|
||||
$executions->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate)
|
||||
);
|
||||
}
|
||||
|
||||
if ($maxStorageGB > 0) {
|
||||
$maxStorageBytes = $maxStorageGB * pow(1024, 3);
|
||||
$totalSize = 0;
|
||||
$backupsOverLimit = collect();
|
||||
|
||||
$backupsToCheck = $successfulBackups->skip(1);
|
||||
|
||||
foreach ($backupsToCheck as $backupExecution) {
|
||||
foreach ($executions->skip(1) as $backupExecution) {
|
||||
$totalSize += (int) $backupExecution->size;
|
||||
if ($totalSize > $maxStorageBytes) {
|
||||
$backupsOverLimit = $successfulBackups->filter(
|
||||
$backupsToDelete = $backupsToDelete->merge($executions->filter(
|
||||
fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc()
|
||||
)->skip(1);
|
||||
)->skip(1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->merge($backupsOverLimit);
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->unique('id');
|
||||
$processedBackups = collect();
|
||||
return $backupsToDelete->unique('id')->values();
|
||||
}
|
||||
|
||||
function deleteOldBackupsLocally($backup): Collection
|
||||
{
|
||||
if (! $backup) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$successfulBackups = $backup->executions()
|
||||
->where('status', 'success')
|
||||
->where('local_storage_deleted', false)
|
||||
->when($backup->disable_local_backup, fn ($query) => $query
|
||||
->whereHas('s3Replicas', fn ($query) => $query
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false)))
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
$backupsToDelete = backupExecutionsOutsideRetention(
|
||||
$successfulBackups,
|
||||
$backup->database_backup_retention_amount_locally,
|
||||
$backup->database_backup_retention_days_locally,
|
||||
$backup->database_backup_retention_max_storage_locally,
|
||||
);
|
||||
|
||||
if ($backupsToDelete->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$server = null;
|
||||
if ($backup->database_type === ServiceDatabase::class) {
|
||||
$server = $backup->database->service->server;
|
||||
} else {
|
||||
@@ -433,89 +421,81 @@ function deleteOldBackupsLocally($backup): Collection
|
||||
->pluck('filename')
|
||||
->all();
|
||||
|
||||
if (! empty($filesToDelete)) {
|
||||
deleteBackupsLocally($filesToDelete, $server);
|
||||
$processedBackups = $backupsToDelete;
|
||||
if (empty($filesToDelete)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $processedBackups;
|
||||
deleteBackupsLocally($filesToDelete, $server);
|
||||
|
||||
return $backupsToDelete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the S3 retention settings to each destination separately. Copies in a storage that no longer exists are
|
||||
* marked as deleted.
|
||||
*
|
||||
* @return Collection<int, ScheduledDatabaseBackupExecution> The executions that lost at least one S3 copy.
|
||||
*/
|
||||
function deleteOldBackupsFromS3($backup): Collection
|
||||
{
|
||||
if (! $backup || ! $backup->executions || ! $backup->s3) {
|
||||
if (! $backup) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$successfulBackups = $backup->executions()
|
||||
->where('status', 'success')
|
||||
$liveReplicas = DatabaseBackupS3Replica::query()
|
||||
->with('s3')
|
||||
->whereHas('execution', fn ($query) => $query
|
||||
->where('scheduled_database_backup_id', $backup->id)
|
||||
->where('status', 'success'))
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if ($successfulBackups->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
$deletedReplicas = $liveReplicas->filter(fn (DatabaseBackupS3Replica $replica) => ! $replica->s3);
|
||||
|
||||
$retentionAmount = $backup->database_backup_retention_amount_s3;
|
||||
$retentionDays = $backup->database_backup_retention_days_s3;
|
||||
$maxStorageGB = $backup->database_backup_retention_max_storage_s3;
|
||||
foreach ($liveReplicas->filter(fn (DatabaseBackupS3Replica $replica) => $replica->s3)->groupBy('s3_storage_id') as $replicas) {
|
||||
$successfulBackups = $backup->executions()
|
||||
->whereIn('id', $replicas->pluck('execution_id'))
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) {
|
||||
return collect();
|
||||
}
|
||||
$backupsToDelete = backupExecutionsOutsideRetention(
|
||||
$successfulBackups,
|
||||
$backup->database_backup_retention_amount_s3,
|
||||
$backup->database_backup_retention_days_s3,
|
||||
$backup->database_backup_retention_max_storage_s3,
|
||||
);
|
||||
|
||||
$backupsToDelete = collect();
|
||||
$filesToDelete = $backupsToDelete
|
||||
->filter(fn ($execution) => ! empty($execution->filename))
|
||||
->pluck('filename')
|
||||
->all();
|
||||
|
||||
if ($retentionAmount > 0) {
|
||||
$byAmount = $successfulBackups->skip($retentionAmount);
|
||||
$backupsToDelete = $backupsToDelete->merge($byAmount);
|
||||
}
|
||||
|
||||
if ($retentionDays > 0) {
|
||||
$oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays);
|
||||
$oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate);
|
||||
$backupsToDelete = $backupsToDelete->merge($oldBackups);
|
||||
}
|
||||
|
||||
if ($maxStorageGB > 0) {
|
||||
$maxStorageBytes = $maxStorageGB * pow(1024, 3);
|
||||
$totalSize = 0;
|
||||
$backupsOverLimit = collect();
|
||||
|
||||
$backupsToCheck = $successfulBackups->skip(1);
|
||||
|
||||
foreach ($backupsToCheck as $backupExecution) {
|
||||
$totalSize += (int) $backupExecution->size;
|
||||
if ($totalSize > $maxStorageBytes) {
|
||||
$backupsOverLimit = $successfulBackups->filter(
|
||||
fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc()
|
||||
)->skip(1);
|
||||
break;
|
||||
}
|
||||
if (empty($filesToDelete)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->merge($backupsOverLimit);
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->unique('id');
|
||||
$processedBackups = collect();
|
||||
|
||||
$filesToDelete = $backupsToDelete
|
||||
->filter(fn ($execution) => ! empty($execution->filename))
|
||||
->pluck('filename')
|
||||
->all();
|
||||
|
||||
if (! empty($filesToDelete)) {
|
||||
try {
|
||||
deleteBackupsS3($filesToDelete, $backup->s3);
|
||||
$processedBackups = $backupsToDelete;
|
||||
deleteBackupsS3($filesToDelete, $replicas->first()->s3);
|
||||
$deletedReplicas = $deletedReplicas->merge($replicas->whereIn('execution_id', $backupsToDelete->pluck('id')));
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
return $processedBackups;
|
||||
if ($deletedReplicas->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
DatabaseBackupS3Replica::query()
|
||||
->whereKey($deletedReplicas->pluck('id'))
|
||||
->update(['s3_storage_deleted' => true]);
|
||||
|
||||
$executions = $backup->executions()->whereIn('id', $deletedReplicas->pluck('execution_id')->unique())->get();
|
||||
$executions->each(fn (ScheduledDatabaseBackupExecution $execution) => $execution->refreshS3Summary());
|
||||
|
||||
return $executions;
|
||||
}
|
||||
|
||||
function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool
|
||||
|
||||
@@ -766,7 +766,20 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool
|
||||
->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain));
|
||||
}
|
||||
|
||||
function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false)
|
||||
/**
|
||||
* Caddy site address of a domain. Maintenance blocks in the base Caddyfile must use the same
|
||||
* address as the labels, so caddy-docker-proxy merges them into one site block.
|
||||
*/
|
||||
function caddySiteAddress(string $scheme, string $host, bool $isForceHttpsEnabled): string
|
||||
{
|
||||
if ($scheme === 'https' && ! $isForceHttpsEnabled) {
|
||||
return "http://{$host}, https://{$host}";
|
||||
}
|
||||
|
||||
return "{$scheme}://{$host}";
|
||||
}
|
||||
|
||||
function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, ?int $http_basic_auth_bcrypt_cost = null, array $http_basic_auth_argon2id_options = [])
|
||||
{
|
||||
$labels = collect([]);
|
||||
if ($serviceLabels) {
|
||||
@@ -777,7 +790,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
|
||||
|
||||
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
|
||||
if ($is_http_basic_auth_enabled) {
|
||||
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]);
|
||||
$hashedPassword = $http_basic_auth_argon2id_options
|
||||
? password_hash($http_basic_auth_password, PASSWORD_ARGON2ID, $http_basic_auth_argon2id_options)
|
||||
: password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]);
|
||||
}
|
||||
|
||||
$trafficAppKey = caddyTrafficAppKey($uuid, $service_name);
|
||||
@@ -788,10 +803,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
|
||||
$path = $url->getPath();
|
||||
$host_without_www = str($host)->replace('www.', '');
|
||||
$schema = $url->getScheme();
|
||||
$siteAddress = "{$schema}://{$host}";
|
||||
if ($schema === 'https' && ! $is_force_https_enabled) {
|
||||
$siteAddress = "http://{$host}, https://{$host}";
|
||||
}
|
||||
$siteAddress = caddySiteAddress($schema, $host, $is_force_https_enabled);
|
||||
$portlessDomain = ServiceApplication::withoutPort($domain);
|
||||
$port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null);
|
||||
$handle = 'handle_path';
|
||||
@@ -834,6 +846,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
|
||||
if ($is_http_basic_auth_enabled) {
|
||||
// Caddy 2.8 renamed basicauth to basic_auth; see Server::caddySupportsBasicAuthDirective().
|
||||
$basicAuthDirective = $supports_basic_auth_directive ? 'basic_auth' : 'basicauth';
|
||||
if ($http_basic_auth_argon2id_options) {
|
||||
$labels->push("caddy_{$loop}.{$basicAuthDirective}=argon2id");
|
||||
}
|
||||
$labels->push("caddy_{$loop}.{$basicAuthDirective}.{$http_basic_auth_username}=\"{$hashedPassword}\"");
|
||||
}
|
||||
if ($is_traffic_analytics_enabled) {
|
||||
@@ -907,7 +922,7 @@ function dockerComposeServicePorts(?string $compose, ?string $serviceName): arra
|
||||
->unique()->values()->all();
|
||||
}
|
||||
|
||||
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [])
|
||||
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?int $http_basic_auth_bcrypt_cost = null)
|
||||
{
|
||||
$labels = collect([]);
|
||||
$labels->push('traefik.enable=true');
|
||||
@@ -919,7 +934,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
|
||||
$http_basic_auth_label = "http-basic-auth-{$uuid}";
|
||||
if ($is_http_basic_auth_enabled) {
|
||||
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]);
|
||||
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]);
|
||||
}
|
||||
|
||||
if ($is_http_basic_auth_enabled) {
|
||||
@@ -1186,6 +1201,10 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
$appUuid = $appUuid.'-pr-'.$pull_request_id;
|
||||
}
|
||||
$labels = collect([]);
|
||||
$httpBasicAuthArgon2idOptions = $application->usesArgon2idBasicAuth() ? [
|
||||
'memory_cost' => $application->http_basic_auth_argon2id_memory_cost,
|
||||
'time_cost' => $application->http_basic_auth_argon2id_time_cost,
|
||||
] : [];
|
||||
if ($pull_request_id === 0) {
|
||||
if ($application->fqdn) {
|
||||
$domains = str(data_get($application, 'fqdn'))->explode(',');
|
||||
@@ -1205,6 +1224,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
noindex_domains: $noindexDomains,
|
||||
domainPortOverrides: $application->domain_port_overrides ?? [],
|
||||
));
|
||||
@@ -1222,6 +1242,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
||||
noindex_domains: $noindexDomains,
|
||||
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
||||
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
||||
@@ -1242,6 +1264,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
noindex_domains: $noindexDomains,
|
||||
escape_redirect_replacement_for_compose: false,
|
||||
domainPortOverrides: $application->domain_port_overrides ?? [],
|
||||
@@ -1258,6 +1281,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
||||
noindex_domains: $noindexDomains,
|
||||
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
||||
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
||||
@@ -1288,6 +1313,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
noindex_domains: $noindexDomains,
|
||||
escape_redirect_replacement_for_compose: false,
|
||||
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
||||
@@ -1305,6 +1331,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
||||
noindex_domains: $noindexDomains,
|
||||
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
||||
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
||||
@@ -1324,6 +1352,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
noindex_domains: $noindexDomains,
|
||||
escape_redirect_replacement_for_compose: false,
|
||||
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
||||
@@ -1339,6 +1368,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
||||
noindex_domains: $noindexDomains,
|
||||
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
||||
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
||||
@@ -2078,3 +2109,37 @@ function injectDockerComposeBuildArgs(string $command, string $buildArgsString):
|
||||
|
||||
return $modifiedCommand ?? $command;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command that uses the Railpack BuildKit builder, then stop the builder when no other
|
||||
* process uses it. An idle buildkitd keeps the memory of earlier builds until it restarts.
|
||||
*
|
||||
* The lock file is in the buildx metadata directory, which all helper containers of a server share.
|
||||
* The script has no `&&`, `||`, pipe, or `$(`, because the sudo parser would put sudo into it.
|
||||
*/
|
||||
function railpackBuilderLockedScript(string $command): string
|
||||
{
|
||||
return 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; '
|
||||
."{$command}; status=\$?; "
|
||||
.'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi; '
|
||||
.'exit $status';
|
||||
}
|
||||
|
||||
/**
|
||||
* The buildx metadata of a server, as the `docker run -v` source that the deployment helper uses.
|
||||
*/
|
||||
function railpackBuildxMetadataVolume(Server $server): string
|
||||
{
|
||||
return isDev() && $server->isLocalhost() ? 'coolify-buildx' : '$HOME/.docker/buildx';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command for the Railpack builder from a temporary helper container, then stop the builder
|
||||
* when no build uses it.
|
||||
*/
|
||||
function railpackBuilderHelperCommand(string $buildxMetadataVolume, string $helperImage, string $command): string
|
||||
{
|
||||
$script = railpackBuilderLockedScript($command);
|
||||
|
||||
return "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImage} bash -c '{$script}' 2>/dev/null || true";
|
||||
}
|
||||
@@ -950,3 +950,16 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation rule for an HTML page that Coolify sends to the proxy in one shell command
|
||||
* (see Server::PROXY_ERROR_PAGE_MAX_BYTES).
|
||||
*/
|
||||
function proxyPageSizeRule(string $name): Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, Closure $fail) use ($name): void {
|
||||
if (strlen((string) $value) > Server::PROXY_ERROR_PAGE_MAX_BYTES) {
|
||||
$fail("The {$name} must not be larger than ".(Server::PROXY_ERROR_PAGE_MAX_BYTES / 1024).' KB.');
|
||||
}
|
||||
};
|
||||
}
|
||||
+62
-60
@@ -12,67 +12,68 @@
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"danharrin/livewire-rate-limiting": "2.2.1",
|
||||
"doctrine/dbal": "4.4.4",
|
||||
"firebase/php-jwt": "7.1.0",
|
||||
"guzzlehttp/guzzle": "7.15.3",
|
||||
"laravel/fortify": "1.37.3",
|
||||
"laravel/framework": "12.69.3",
|
||||
"laravel/horizon": "5.48.2",
|
||||
"laravel/mcp": "1.0.1",
|
||||
"laravel/pail": "1.2.7",
|
||||
"laravel/prompts": "0.3.22",
|
||||
"laravel/reverb": "1.11.1",
|
||||
"laravel/sanctum": "4.3.3",
|
||||
"laravel/socialite": "5.29.0",
|
||||
"laravel/tinker": "2.11.1",
|
||||
"laravel/ui": "4.6.3",
|
||||
"lcobucci/jwt": "5.6.0",
|
||||
"league/flysystem-aws-s3-v3": "3.35.2",
|
||||
"league/flysystem-sftp-v3": "3.33.0",
|
||||
"livewire/livewire": "3.8.7",
|
||||
"log1x/laravel-webfonts": "2.0.1",
|
||||
"lorisleiva/laravel-actions": "2.10.2",
|
||||
"nubs/random-name-generator": "2.2.0",
|
||||
"phpseclib/phpseclib": "3.0.57",
|
||||
"pion/laravel-chunk-upload": "1.6.3",
|
||||
"poliander/cron": "3.3.1",
|
||||
"purplepixie/phpdns": "2.3.6",
|
||||
"pusher/pusher-php-server": "7.3.0",
|
||||
"resend/resend-laravel": "1.5.0",
|
||||
"sentry/sentry-laravel": "4.27.0",
|
||||
"socialiteproviders/authentik": "5.3.0",
|
||||
"socialiteproviders/clerk": "5.1.0",
|
||||
"socialiteproviders/discord": "4.2.0",
|
||||
"socialiteproviders/google": "4.1.0",
|
||||
"socialiteproviders/infomaniak": "4.0.0",
|
||||
"socialiteproviders/microsoft-azure": "5.2.0",
|
||||
"socialiteproviders/zitadel": "4.2.0",
|
||||
"spatie/laravel-activitylog": "4.12.3",
|
||||
"spatie/laravel-data": "4.23.0",
|
||||
"spatie/laravel-markdown": "2.8.0",
|
||||
"spatie/laravel-schemaless-attributes": "2.6.0",
|
||||
"spatie/url": "2.4.0",
|
||||
"stevebauman/purify": "6.3.2",
|
||||
"stripe/stripe-php": "16.6.0",
|
||||
"symfony/yaml": "7.4.15",
|
||||
"visus/cuid2": "6.0.0",
|
||||
"yosymfony/toml": "1.0.4",
|
||||
"zircote/swagger-php": "5.8.3"
|
||||
"danharrin/livewire-rate-limiting": "^2.3.0",
|
||||
"doctrine/dbal": "^4.5.0",
|
||||
"firebase/php-jwt": "^7.2.1",
|
||||
"guzzlehttp/guzzle": "^7.15.5",
|
||||
"laravel/fortify": "^1.40.0",
|
||||
"laravel/framework": "^12.69.3",
|
||||
"laravel/horizon": "^5.50.0",
|
||||
"laravel/mcp": "^1.0.1",
|
||||
"laravel/pail": "^1.2.7",
|
||||
"laravel/prompts": "^0.3.24",
|
||||
"laravel/reverb": "^1.12.0",
|
||||
"laravel/sanctum": "^4.3.3",
|
||||
"laravel/socialite": "^5.30.0",
|
||||
"laravel/tinker": "^3.0.2",
|
||||
"laravel/ui": "^4.6.3",
|
||||
"lcobucci/jwt": "^5.6.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.35.3",
|
||||
"league/flysystem-sftp-v3": "^3.36.0",
|
||||
"livewire/livewire": "^3.8.10",
|
||||
"log1x/laravel-webfonts": "^2.0.1",
|
||||
"lorisleiva/laravel-actions": "^2.12.0",
|
||||
"nubs/random-name-generator": "^2.2.0",
|
||||
"phpseclib/phpseclib": "^3.0.57",
|
||||
"pion/laravel-chunk-upload": "^1.6.3",
|
||||
"poliander/cron": "^3.3.1",
|
||||
"purplepixie/phpdns": "^2.4.0",
|
||||
"pusher/pusher-php-server": "^7.3.0",
|
||||
"resend/resend-laravel": "^1.6.0",
|
||||
"sentry/sentry-laravel": "^4.28.0",
|
||||
"socialiteproviders/authentik": "^5.3.0",
|
||||
"socialiteproviders/clerk": "^5.1.0",
|
||||
"socialiteproviders/discord": "^4.2.0",
|
||||
"socialiteproviders/google": "^4.1.0",
|
||||
"socialiteproviders/infomaniak": "^4.0.0",
|
||||
"socialiteproviders/microsoft-azure": "^5.2.1",
|
||||
"socialiteproviders/zitadel": "^4.2.0",
|
||||
"spatie/laravel-activitylog": "^4.12.3",
|
||||
"spatie/laravel-data": "^4.23.0",
|
||||
"spatie/laravel-markdown": "^2.8.0",
|
||||
"spatie/laravel-schemaless-attributes": "^2.6.0",
|
||||
"spatie/url": "^2.4.0",
|
||||
"stevebauman/purify": "^6.3.2",
|
||||
"stripe/stripe-php": "^16.6.0",
|
||||
"symfony/yaml": "^8.1.8",
|
||||
"visus/cuid2": "^7.0.0",
|
||||
"yosymfony/toml": "^1.0.4",
|
||||
"zircote/swagger-php": "^6.11.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"driftingly/rector-laravel": "2.5.0",
|
||||
"fakerphp/faker": "1.24.1",
|
||||
"laravel/boost": "2.10.0",
|
||||
"laravel/pint": "1.30.4",
|
||||
"mockery/mockery": "1.6.12",
|
||||
"nunomaduro/collision": "8.9.5",
|
||||
"pestphp/pest": "4.7.8",
|
||||
"pestphp/pest-plugin-browser": "4.3.1",
|
||||
"phpstan/phpstan": "2.2.8",
|
||||
"rector/rector": "2.6.1",
|
||||
"serversideup/spin": "3.3.0",
|
||||
"symfony/http-client": "7.4.16"
|
||||
"driftingly/rector-laravel": "^2.6.2",
|
||||
"fakerphp/faker": "^1.24.1",
|
||||
"laravel/boost": "^2.10.0",
|
||||
"laravel/pint": "^1.32.1",
|
||||
"laravel/vet": "^0.2.1",
|
||||
"mockery/mockery": "^1.6.15",
|
||||
"nunomaduro/collision": "^8.9.5",
|
||||
"pestphp/pest": "^4.7.8",
|
||||
"pestphp/pest-plugin-browser": "^4.3.1",
|
||||
"phpstan/phpstan": "^2.2.16",
|
||||
"rector/rector": "^2.6.7",
|
||||
"serversideup/spin": "^3.5.2",
|
||||
"symfony/http-client": "^8.1.8"
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
@@ -94,7 +95,8 @@
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
"php-http/discovery": true,
|
||||
"laravel/vet": true
|
||||
},
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
|
||||
Generated
+1002
-649
File diff suppressed because it is too large.
Load diff
@@ -6,8 +6,8 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600);
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => env('COOLIFY_VERSION') ?: '4.4.4',
|
||||
'helper_version' => '1.0.17',
|
||||
'railpack_version' => '0.23.0',
|
||||
'helper_version' => '1.0.18',
|
||||
'railpack_version' => '0.40.1',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
'autoupdate' => env('AUTOUPDATE'),
|
||||
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
|
||||
|
||||
@@ -21,6 +21,9 @@ class ApplicationFactory extends Factory
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => 1,
|
||||
'destination_id' => 1,
|
||||
'http_basic_auth_bcrypt_cost' => 10,
|
||||
'http_basic_auth_argon2id_memory_cost' => 65536,
|
||||
'http_basic_auth_argon2id_time_cost' => 4,
|
||||
];
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->bigInteger('hostinger_virtual_machine_id')->nullable()->after('digitalocean_droplet_status');
|
||||
$table->string('hostinger_virtual_machine_status')->nullable()->after('hostinger_virtual_machine_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'hostinger_virtual_machine_status',
|
||||
'hostinger_virtual_machine_id',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Custom HTML that the proxy shows (with status 503) for requests that no resource handles.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->text('proxy_error_page')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn('proxy_error_page');
|
||||
});
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Maintenance mode: the proxy shows a maintenance page (status 503) instead of the resource.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['applications', 'services'] as $table) {
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
$table->boolean('is_maintenance_enabled')->default(false);
|
||||
$table->text('maintenance_page')->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
foreach (['applications', 'services'] as $table) {
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
$table->dropColumn(['is_maintenance_enabled', 'maintenance_page']);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->string('http_basic_auth_hash_algorithm')->default('bcrypt');
|
||||
$table->unsignedTinyInteger('http_basic_auth_bcrypt_cost')->default(10);
|
||||
$table->unsignedInteger('http_basic_auth_argon2id_memory_cost')->default(65536);
|
||||
$table->unsignedTinyInteger('http_basic_auth_argon2id_time_cost')->default(4);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'http_basic_auth_hash_algorithm',
|
||||
'http_basic_auth_bcrypt_cost',
|
||||
'http_basic_auth_argon2id_memory_cost',
|
||||
'http_basic_auth_argon2id_time_cost',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('scheduled_database_backup_s3_storage', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('scheduled_database_backup_id')
|
||||
->constrained(indexName: 'db_backup_s3_storage_backup_fk')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('s3_storage_id')
|
||||
->constrained(indexName: 'db_backup_s3_storage_storage_fk')
|
||||
->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
$table->unique(['scheduled_database_backup_id', 's3_storage_id'], 'db_backup_s3_storage_unique');
|
||||
});
|
||||
|
||||
Schema::create('scheduled_volume_backup_s3_storage', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('scheduled_volume_backup_id')
|
||||
->constrained(indexName: 'volume_backup_s3_storage_backup_fk')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('s3_storage_id')
|
||||
->constrained(indexName: 'volume_backup_s3_storage_storage_fk')
|
||||
->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
$table->unique(['scheduled_volume_backup_id', 's3_storage_id'], 'volume_backup_s3_storage_unique');
|
||||
});
|
||||
|
||||
Schema::create('database_backup_s3_replicas', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('execution_id')
|
||||
->index()
|
||||
->constrained('scheduled_database_backup_executions', indexName: 'db_backup_s3_replicas_execution_fk')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('s3_storage_id')
|
||||
->nullable()
|
||||
->constrained(indexName: 'db_backup_s3_replicas_storage_fk')
|
||||
->nullOnDelete();
|
||||
$table->boolean('s3_uploaded')->nullable();
|
||||
$table->boolean('s3_storage_deleted')->default(false);
|
||||
$table->text('message')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('volume_backup_s3_replicas', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('execution_id')
|
||||
->index()
|
||||
->constrained('scheduled_volume_backup_executions', indexName: 'volume_backup_s3_replicas_execution_fk')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('s3_storage_id')
|
||||
->nullable()
|
||||
->constrained(indexName: 'volume_backup_s3_replicas_storage_fk')
|
||||
->nullOnDelete();
|
||||
$table->boolean('s3_uploaded')->nullable();
|
||||
$table->boolean('s3_storage_deleted')->default(false);
|
||||
$table->text('message')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
$this->backfill();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('volume_backup_s3_replicas');
|
||||
Schema::dropIfExists('database_backup_s3_replicas');
|
||||
Schema::dropIfExists('scheduled_volume_backup_s3_storage');
|
||||
Schema::dropIfExists('scheduled_database_backup_s3_storage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the single S3 destination of existing schedules and executions into the new tables.
|
||||
*/
|
||||
private function backfill(): void
|
||||
{
|
||||
$now = now();
|
||||
|
||||
DB::table('scheduled_database_backup_s3_storage')->insertUsing(
|
||||
['scheduled_database_backup_id', 's3_storage_id', 'created_at', 'updated_at'],
|
||||
DB::table('scheduled_database_backups')
|
||||
->join('s3_storages', 's3_storages.id', '=', 'scheduled_database_backups.s3_storage_id')
|
||||
->select('scheduled_database_backups.id', 'scheduled_database_backups.s3_storage_id')
|
||||
->selectRaw('?, ?', [$now, $now]),
|
||||
);
|
||||
|
||||
DB::table('scheduled_volume_backup_s3_storage')->insertUsing(
|
||||
['scheduled_volume_backup_id', 's3_storage_id', 'created_at', 'updated_at'],
|
||||
DB::table('scheduled_volume_backups')
|
||||
->whereNotNull('s3_storage_id')
|
||||
->select('id', 's3_storage_id')
|
||||
->selectRaw('?, ?', [$now, $now]),
|
||||
);
|
||||
|
||||
// Database executions have no storage column: the copy went to the storage of the schedule.
|
||||
DB::table('database_backup_s3_replicas')->insertUsing(
|
||||
['execution_id', 's3_storage_id', 's3_uploaded', 's3_storage_deleted', 'created_at', 'updated_at'],
|
||||
DB::table('scheduled_database_backup_executions')
|
||||
->join('scheduled_database_backups', 'scheduled_database_backups.id', '=', 'scheduled_database_backup_executions.scheduled_database_backup_id')
|
||||
->leftJoin('s3_storages', 's3_storages.id', '=', 'scheduled_database_backups.s3_storage_id')
|
||||
->whereNotNull('scheduled_database_backup_executions.s3_uploaded')
|
||||
->select(
|
||||
'scheduled_database_backup_executions.id',
|
||||
's3_storages.id',
|
||||
'scheduled_database_backup_executions.s3_uploaded',
|
||||
'scheduled_database_backup_executions.s3_storage_deleted',
|
||||
)
|
||||
->selectRaw('?, ?', [$now, $now]),
|
||||
);
|
||||
|
||||
DB::table('volume_backup_s3_replicas')->insertUsing(
|
||||
['execution_id', 's3_storage_id', 's3_uploaded', 's3_storage_deleted', 'created_at', 'updated_at'],
|
||||
DB::table('scheduled_volume_backup_executions')
|
||||
->whereNotNull('s3_storage_id')
|
||||
->select('id', 's3_storage_id', 's3_uploaded', 's3_storage_deleted')
|
||||
->selectRaw('?, ?', [$now, $now]),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -12,9 +12,9 @@ ARG PACK_VERSION=0.38.2
|
||||
# https://github.com/railwayapp/nixpacks/releases
|
||||
ARG NIXPACKS_VERSION=1.41.0
|
||||
# https://github.com/railwayapp/railpack/releases
|
||||
ARG RAILPACK_VERSION=0.23.0
|
||||
# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt)
|
||||
ARG MISE_VERSION=2026.3.17
|
||||
ARG RAILPACK_VERSION=0.40.1
|
||||
# https://github.com/jdx/mise/releases — must match the version pinned at that railpack tag (https://raw.githubusercontent.com/railwayapp/railpack/v${RAILPACK_VERSION}/core/mise/version.txt)
|
||||
ARG MISE_VERSION=2026.9.15
|
||||
FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client
|
||||
|
||||
FROM ${BASE_IMAGE} AS base
|
||||
@@ -36,7 +36,7 @@ RUN apk upgrade --no-cache && \
|
||||
RUN mkdir -p ~/.docker/cli-plugins
|
||||
|
||||
# Install mise (musl build) at the path railpack expects (/tmp/railpack/mise/mise-VERSION).
|
||||
# Railpack hardcodes a glibc mise download that fails on Alpine, so we pre-place a musl binary.
|
||||
# Railpack skips its own mise download when this file exists.
|
||||
RUN mkdir -p /tmp/railpack/mise && \
|
||||
if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \
|
||||
curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64-musl.tar.gz" | tar xz && \
|
||||
|
||||
+432
-11
@@ -86,9 +86,9 @@
|
||||
"properties": {
|
||||
"pull_request_id": {
|
||||
"type": "integer",
|
||||
"example": 42,
|
||||
"maximum": 2147483647,
|
||||
"minimum": 1,
|
||||
"example": 42
|
||||
"minimum": 1
|
||||
},
|
||||
"pull_request_html_url": {
|
||||
"type": [
|
||||
@@ -980,6 +980,36 @@
|
||||
"nullable": true,
|
||||
"description": "Password for HTTP Basic Authentication"
|
||||
},
|
||||
"http_basic_auth_hash_algorithm": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bcrypt",
|
||||
"argon2id"
|
||||
],
|
||||
"default": "bcrypt",
|
||||
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
|
||||
},
|
||||
"http_basic_auth_bcrypt_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 4,
|
||||
"maximum": 14,
|
||||
"default": 10,
|
||||
"description": "Bcrypt cost for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_memory_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 8192,
|
||||
"maximum": 262144,
|
||||
"default": 65536,
|
||||
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_time_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 12,
|
||||
"default": 4,
|
||||
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
|
||||
},
|
||||
"connect_to_docker_network": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to connect the service to the predefined Docker network."
|
||||
@@ -1571,6 +1601,36 @@
|
||||
"nullable": true,
|
||||
"description": "Password for HTTP Basic Authentication"
|
||||
},
|
||||
"http_basic_auth_hash_algorithm": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bcrypt",
|
||||
"argon2id"
|
||||
],
|
||||
"default": "bcrypt",
|
||||
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
|
||||
},
|
||||
"http_basic_auth_bcrypt_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 4,
|
||||
"maximum": 14,
|
||||
"default": 10,
|
||||
"description": "Bcrypt cost for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_memory_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 8192,
|
||||
"maximum": 262144,
|
||||
"default": 65536,
|
||||
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_time_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 12,
|
||||
"default": 4,
|
||||
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
|
||||
},
|
||||
"connect_to_docker_network": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to connect the service to the predefined Docker network."
|
||||
@@ -2162,6 +2222,36 @@
|
||||
"nullable": true,
|
||||
"description": "Password for HTTP Basic Authentication"
|
||||
},
|
||||
"http_basic_auth_hash_algorithm": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bcrypt",
|
||||
"argon2id"
|
||||
],
|
||||
"default": "bcrypt",
|
||||
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
|
||||
},
|
||||
"http_basic_auth_bcrypt_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 4,
|
||||
"maximum": 14,
|
||||
"default": 10,
|
||||
"description": "Bcrypt cost for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_memory_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 8192,
|
||||
"maximum": 262144,
|
||||
"default": 65536,
|
||||
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_time_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 12,
|
||||
"default": 4,
|
||||
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
|
||||
},
|
||||
"connect_to_docker_network": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to connect the service to the predefined Docker network."
|
||||
@@ -2648,6 +2738,36 @@
|
||||
"nullable": true,
|
||||
"description": "Password for HTTP Basic Authentication"
|
||||
},
|
||||
"http_basic_auth_hash_algorithm": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bcrypt",
|
||||
"argon2id"
|
||||
],
|
||||
"default": "bcrypt",
|
||||
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
|
||||
},
|
||||
"http_basic_auth_bcrypt_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 4,
|
||||
"maximum": 14,
|
||||
"default": 10,
|
||||
"description": "Bcrypt cost for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_memory_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 8192,
|
||||
"maximum": 262144,
|
||||
"default": 65536,
|
||||
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_time_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 12,
|
||||
"default": 4,
|
||||
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
|
||||
},
|
||||
"connect_to_docker_network": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to connect the service to the predefined Docker network."
|
||||
@@ -3114,6 +3234,36 @@
|
||||
"nullable": true,
|
||||
"description": "Password for HTTP Basic Authentication"
|
||||
},
|
||||
"http_basic_auth_hash_algorithm": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bcrypt",
|
||||
"argon2id"
|
||||
],
|
||||
"default": "bcrypt",
|
||||
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
|
||||
},
|
||||
"http_basic_auth_bcrypt_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 4,
|
||||
"maximum": 14,
|
||||
"default": 10,
|
||||
"description": "Bcrypt cost for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_memory_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 8192,
|
||||
"maximum": 262144,
|
||||
"default": 65536,
|
||||
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_time_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 12,
|
||||
"default": 4,
|
||||
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
|
||||
},
|
||||
"connect_to_docker_network": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to connect the service to the predefined Docker network."
|
||||
@@ -3703,6 +3853,32 @@
|
||||
"type": "boolean",
|
||||
"description": "Use Docker Build Secrets for build-time environment variables."
|
||||
},
|
||||
"http_basic_auth_hash_algorithm": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bcrypt",
|
||||
"argon2id"
|
||||
],
|
||||
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
|
||||
},
|
||||
"http_basic_auth_bcrypt_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 4,
|
||||
"maximum": 14,
|
||||
"description": "Bcrypt cost for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_memory_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 8192,
|
||||
"maximum": 262144,
|
||||
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_time_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 12,
|
||||
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
|
||||
},
|
||||
"is_git_submodules_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Clone Git submodules."
|
||||
@@ -6142,7 +6318,8 @@
|
||||
"enum": [
|
||||
"hetzner",
|
||||
"digitalocean",
|
||||
"vultr"
|
||||
"vultr",
|
||||
"hostinger"
|
||||
]
|
||||
},
|
||||
"team_id": {
|
||||
@@ -6201,7 +6378,8 @@
|
||||
"enum": [
|
||||
"hetzner",
|
||||
"digitalocean",
|
||||
"vultr"
|
||||
"vultr",
|
||||
"hostinger"
|
||||
],
|
||||
"example": "hetzner",
|
||||
"description": "The cloud provider."
|
||||
@@ -6679,7 +6857,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Get all backups for a database",
|
||||
"description": "Get all backups for a database. Each backup includes s3_storage_uuid (primary S3 destination) and s3_storage_uuids (every S3 destination, primary first).",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
@@ -6749,7 +6927,14 @@
|
||||
},
|
||||
"s3_storage_uuid": {
|
||||
"type": "string",
|
||||
"description": "S3 storage UUID (required if save_s3 is true)"
|
||||
"description": "Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true."
|
||||
},
|
||||
"s3_storage_uuids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination."
|
||||
},
|
||||
"databases_to_backup": {
|
||||
"type": "string",
|
||||
@@ -7352,7 +7537,14 @@
|
||||
},
|
||||
"s3_storage_uuid": {
|
||||
"type": "string",
|
||||
"description": "S3 storage UUID"
|
||||
"description": "Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay."
|
||||
},
|
||||
"s3_storage_uuids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination."
|
||||
},
|
||||
"backup_now": {
|
||||
"type": "boolean",
|
||||
@@ -10700,8 +10892,8 @@
|
||||
},
|
||||
"network": {
|
||||
"type": "string",
|
||||
"maxLength": 255,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$",
|
||||
"maxLength": 255
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
@@ -12697,6 +12889,189 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/hostinger\/data-centers": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Hostinger"
|
||||
],
|
||||
"summary": "Get Hostinger VPS data centers",
|
||||
"operationId": "get-hostinger-data-centers",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of Hostinger VPS data centers."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Hostinger denied access to this resource for the cloud provider token."
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation failed."
|
||||
},
|
||||
"429": {
|
||||
"description": "Hostinger rate limit exceeded."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/hostinger\/catalog": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Hostinger"
|
||||
],
|
||||
"summary": "Get Hostinger VPS plans and prices",
|
||||
"operationId": "get-hostinger-catalog",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of Hostinger VPS plans and prices."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Hostinger denied access to this resource for the cloud provider token."
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation failed."
|
||||
},
|
||||
"429": {
|
||||
"description": "Hostinger rate limit exceeded."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/hostinger\/templates": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Hostinger"
|
||||
],
|
||||
"summary": "Get Hostinger VPS operating system templates",
|
||||
"operationId": "get-hostinger-templates",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of Hostinger VPS templates."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Hostinger denied access to this resource for the cloud provider token."
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation failed."
|
||||
},
|
||||
"429": {
|
||||
"description": "Hostinger rate limit exceeded."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/hostinger\/ssh-keys": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Hostinger"
|
||||
],
|
||||
"summary": "Get Hostinger account SSH keys",
|
||||
"operationId": "get-hostinger-ssh-keys",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of Hostinger account SSH keys."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Hostinger denied access to this resource for the cloud provider token."
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation failed."
|
||||
},
|
||||
"429": {
|
||||
"description": "Hostinger rate limit exceeded."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/hostinger\/post-install-scripts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Hostinger"
|
||||
],
|
||||
"summary": "Get Hostinger post-install scripts",
|
||||
"operationId": "get-hostinger-post-install-scripts",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of Hostinger post-install scripts."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Hostinger denied access to this resource for the cloud provider token."
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation failed."
|
||||
},
|
||||
"429": {
|
||||
"description": "Hostinger rate limit exceeded."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/servers\/hostinger": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Hostinger"
|
||||
],
|
||||
"summary": "Purchase a Hostinger VPS and create a linked server",
|
||||
"operationId": "create-hostinger-server",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Hostinger VPS purchased and linked to a Coolify server."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation failed."
|
||||
},
|
||||
"429": {
|
||||
"description": "Hostinger rate limit exceeded."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/settings\/email": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -23180,8 +23555,8 @@
|
||||
"properties": {
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"maxLength": 255,
|
||||
"example": "0 2 * * *"
|
||||
"example": "0 2 * * *",
|
||||
"maxLength": 255
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
@@ -23200,11 +23575,19 @@
|
||||
"default": false
|
||||
},
|
||||
"s3_storage_uuid": {
|
||||
"description": "Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"s3_storage_uuids": {
|
||||
"description": "UUIDs of every S3 storage the backup is uploaded to. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination. save_s3 requires at least one destination.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"retention_amount_locally": {
|
||||
"type": "integer",
|
||||
"default": 7,
|
||||
@@ -23313,11 +23696,19 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"s3_storage_uuid": {
|
||||
"description": "Primary S3 destination.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"s3_storage_uuids": {
|
||||
"description": "Every S3 destination, primary first.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"retention_amount_locally": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -23753,6 +24144,32 @@
|
||||
"nullable": true,
|
||||
"description": "Password for HTTP Basic Authentication"
|
||||
},
|
||||
"http_basic_auth_hash_algorithm": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bcrypt",
|
||||
"argon2id"
|
||||
],
|
||||
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
|
||||
},
|
||||
"http_basic_auth_bcrypt_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 4,
|
||||
"maximum": 14,
|
||||
"description": "Bcrypt cost for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_memory_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 8192,
|
||||
"maximum": 262144,
|
||||
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
|
||||
},
|
||||
"http_basic_auth_argon2id_time_cost": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 12,
|
||||
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#\/components\/schemas\/ApplicationSetting"
|
||||
}
|
||||
@@ -24875,6 +25292,10 @@
|
||||
"name": "Hetzner",
|
||||
"description": "Hetzner"
|
||||
},
|
||||
{
|
||||
"name": "Hostinger",
|
||||
"description": "Hostinger"
|
||||
},
|
||||
{
|
||||
"name": "Settings",
|
||||
"description": "Settings"
|
||||
|
||||
+304
-8
@@ -62,9 +62,9 @@ paths:
|
||||
properties:
|
||||
pull_request_id:
|
||||
type: integer
|
||||
example: 42
|
||||
maximum: 2147483647
|
||||
minimum: 1
|
||||
example: 42
|
||||
pull_request_html_url:
|
||||
type: [string, 'null']
|
||||
example: 'https://github.com/org/repo/pull/42'
|
||||
@@ -646,6 +646,29 @@ paths:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Password for HTTP Basic Authentication'
|
||||
http_basic_auth_hash_algorithm:
|
||||
type: string
|
||||
enum: [bcrypt, argon2id]
|
||||
default: bcrypt
|
||||
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
|
||||
http_basic_auth_bcrypt_cost:
|
||||
type: integer
|
||||
minimum: 4
|
||||
maximum: 14
|
||||
default: 10
|
||||
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_memory_cost:
|
||||
type: integer
|
||||
minimum: 8192
|
||||
maximum: 262144
|
||||
default: 65536
|
||||
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_time_cost:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 12
|
||||
default: 4
|
||||
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
|
||||
connect_to_docker_network:
|
||||
type: boolean
|
||||
description: 'The flag to connect the service to the predefined Docker network.'
|
||||
@@ -1032,6 +1055,29 @@ paths:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Password for HTTP Basic Authentication'
|
||||
http_basic_auth_hash_algorithm:
|
||||
type: string
|
||||
enum: [bcrypt, argon2id]
|
||||
default: bcrypt
|
||||
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
|
||||
http_basic_auth_bcrypt_cost:
|
||||
type: integer
|
||||
minimum: 4
|
||||
maximum: 14
|
||||
default: 10
|
||||
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_memory_cost:
|
||||
type: integer
|
||||
minimum: 8192
|
||||
maximum: 262144
|
||||
default: 65536
|
||||
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_time_cost:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 12
|
||||
default: 4
|
||||
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
|
||||
connect_to_docker_network:
|
||||
type: boolean
|
||||
description: 'The flag to connect the service to the predefined Docker network.'
|
||||
@@ -1418,6 +1464,29 @@ paths:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Password for HTTP Basic Authentication'
|
||||
http_basic_auth_hash_algorithm:
|
||||
type: string
|
||||
enum: [bcrypt, argon2id]
|
||||
default: bcrypt
|
||||
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
|
||||
http_basic_auth_bcrypt_cost:
|
||||
type: integer
|
||||
minimum: 4
|
||||
maximum: 14
|
||||
default: 10
|
||||
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_memory_cost:
|
||||
type: integer
|
||||
minimum: 8192
|
||||
maximum: 262144
|
||||
default: 65536
|
||||
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_time_cost:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 12
|
||||
default: 4
|
||||
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
|
||||
connect_to_docker_network:
|
||||
type: boolean
|
||||
description: 'The flag to connect the service to the predefined Docker network.'
|
||||
@@ -1745,6 +1814,29 @@ paths:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Password for HTTP Basic Authentication'
|
||||
http_basic_auth_hash_algorithm:
|
||||
type: string
|
||||
enum: [bcrypt, argon2id]
|
||||
default: bcrypt
|
||||
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
|
||||
http_basic_auth_bcrypt_cost:
|
||||
type: integer
|
||||
minimum: 4
|
||||
maximum: 14
|
||||
default: 10
|
||||
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_memory_cost:
|
||||
type: integer
|
||||
minimum: 8192
|
||||
maximum: 262144
|
||||
default: 65536
|
||||
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_time_cost:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 12
|
||||
default: 4
|
||||
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
|
||||
connect_to_docker_network:
|
||||
type: boolean
|
||||
description: 'The flag to connect the service to the predefined Docker network.'
|
||||
@@ -2058,6 +2150,29 @@ paths:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Password for HTTP Basic Authentication'
|
||||
http_basic_auth_hash_algorithm:
|
||||
type: string
|
||||
enum: [bcrypt, argon2id]
|
||||
default: bcrypt
|
||||
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
|
||||
http_basic_auth_bcrypt_cost:
|
||||
type: integer
|
||||
minimum: 4
|
||||
maximum: 14
|
||||
default: 10
|
||||
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_memory_cost:
|
||||
type: integer
|
||||
minimum: 8192
|
||||
maximum: 262144
|
||||
default: 65536
|
||||
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_time_cost:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 12
|
||||
default: 4
|
||||
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
|
||||
connect_to_docker_network:
|
||||
type: boolean
|
||||
description: 'The flag to connect the service to the predefined Docker network.'
|
||||
@@ -2430,6 +2545,25 @@ paths:
|
||||
use_build_secrets:
|
||||
type: boolean
|
||||
description: 'Use Docker Build Secrets for build-time environment variables.'
|
||||
http_basic_auth_hash_algorithm:
|
||||
type: string
|
||||
enum: [bcrypt, argon2id]
|
||||
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
|
||||
http_basic_auth_bcrypt_cost:
|
||||
type: integer
|
||||
minimum: 4
|
||||
maximum: 14
|
||||
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_memory_cost:
|
||||
type: integer
|
||||
minimum: 8192
|
||||
maximum: 262144
|
||||
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_time_cost:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 12
|
||||
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
|
||||
is_git_submodules_enabled:
|
||||
type: boolean
|
||||
description: 'Clone Git submodules.'
|
||||
@@ -3941,7 +4075,7 @@ paths:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } }
|
||||
properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr, hostinger] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } }
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
@@ -3969,7 +4103,7 @@ paths:
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
enum: [hetzner, digitalocean, vultr]
|
||||
enum: [hetzner, digitalocean, vultr, hostinger]
|
||||
example: hetzner
|
||||
description: 'The cloud provider.'
|
||||
token:
|
||||
@@ -4267,7 +4401,7 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Get all backups for a database'
|
||||
description: 'Get all backups for a database. Each backup includes s3_storage_uuid (primary S3 destination) and s3_storage_uuids (every S3 destination, primary first).'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -4318,7 +4452,11 @@ paths:
|
||||
default: false
|
||||
s3_storage_uuid:
|
||||
type: string
|
||||
description: 'S3 storage UUID (required if save_s3 is true)'
|
||||
description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true.'
|
||||
s3_storage_uuids:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.'
|
||||
databases_to_backup:
|
||||
type: string
|
||||
description: 'Comma separated list of databases to backup'
|
||||
@@ -4741,7 +4879,11 @@ paths:
|
||||
description: 'Whether data is saved in s3 or not'
|
||||
s3_storage_uuid:
|
||||
type: string
|
||||
description: 'S3 storage UUID'
|
||||
description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay.'
|
||||
s3_storage_uuids:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.'
|
||||
backup_now:
|
||||
type: boolean
|
||||
description: 'Whether to take a backup now or not'
|
||||
@@ -6965,8 +7107,8 @@ paths:
|
||||
maxLength: 255
|
||||
network:
|
||||
type: string
|
||||
maxLength: 255
|
||||
pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'
|
||||
maxLength: 255
|
||||
type:
|
||||
type: string
|
||||
enum: [standalone, swarm]
|
||||
@@ -8113,6 +8255,124 @@ paths:
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/hostinger/data-centers:
|
||||
get:
|
||||
tags:
|
||||
- Hostinger
|
||||
summary: 'Get Hostinger VPS data centers'
|
||||
operationId: get-hostinger-data-centers
|
||||
responses:
|
||||
'200':
|
||||
description: 'List of Hostinger VPS data centers.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'Hostinger denied access to this resource for the cloud provider token.'
|
||||
'422':
|
||||
description: 'Validation failed.'
|
||||
'429':
|
||||
description: 'Hostinger rate limit exceeded.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/hostinger/catalog:
|
||||
get:
|
||||
tags:
|
||||
- Hostinger
|
||||
summary: 'Get Hostinger VPS plans and prices'
|
||||
operationId: get-hostinger-catalog
|
||||
responses:
|
||||
'200':
|
||||
description: 'List of Hostinger VPS plans and prices.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'Hostinger denied access to this resource for the cloud provider token.'
|
||||
'422':
|
||||
description: 'Validation failed.'
|
||||
'429':
|
||||
description: 'Hostinger rate limit exceeded.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/hostinger/templates:
|
||||
get:
|
||||
tags:
|
||||
- Hostinger
|
||||
summary: 'Get Hostinger VPS operating system templates'
|
||||
operationId: get-hostinger-templates
|
||||
responses:
|
||||
'200':
|
||||
description: 'List of Hostinger VPS templates.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'Hostinger denied access to this resource for the cloud provider token.'
|
||||
'422':
|
||||
description: 'Validation failed.'
|
||||
'429':
|
||||
description: 'Hostinger rate limit exceeded.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/hostinger/ssh-keys:
|
||||
get:
|
||||
tags:
|
||||
- Hostinger
|
||||
summary: 'Get Hostinger account SSH keys'
|
||||
operationId: get-hostinger-ssh-keys
|
||||
responses:
|
||||
'200':
|
||||
description: 'List of Hostinger account SSH keys.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'Hostinger denied access to this resource for the cloud provider token.'
|
||||
'422':
|
||||
description: 'Validation failed.'
|
||||
'429':
|
||||
description: 'Hostinger rate limit exceeded.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/hostinger/post-install-scripts:
|
||||
get:
|
||||
tags:
|
||||
- Hostinger
|
||||
summary: 'Get Hostinger post-install scripts'
|
||||
operationId: get-hostinger-post-install-scripts
|
||||
responses:
|
||||
'200':
|
||||
description: 'List of Hostinger post-install scripts.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'Hostinger denied access to this resource for the cloud provider token.'
|
||||
'422':
|
||||
description: 'Validation failed.'
|
||||
'429':
|
||||
description: 'Hostinger rate limit exceeded.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/servers/hostinger:
|
||||
post:
|
||||
tags:
|
||||
- Hostinger
|
||||
summary: 'Purchase a Hostinger VPS and create a linked server'
|
||||
operationId: create-hostinger-server
|
||||
responses:
|
||||
'201':
|
||||
description: 'Hostinger VPS purchased and linked to a Coolify server.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'422':
|
||||
description: 'Validation failed.'
|
||||
'429':
|
||||
description: 'Hostinger rate limit exceeded.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/settings/email:
|
||||
get:
|
||||
tags:
|
||||
@@ -14695,8 +14955,8 @@ components:
|
||||
properties:
|
||||
frequency:
|
||||
type: string
|
||||
maxLength: 255
|
||||
example: '0 2 * * *'
|
||||
maxLength: 255
|
||||
enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
@@ -14710,9 +14970,15 @@ components:
|
||||
type: boolean
|
||||
default: false
|
||||
s3_storage_uuid:
|
||||
description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination.'
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
s3_storage_uuids:
|
||||
description: 'UUIDs of every S3 storage the backup is uploaded to. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination. save_s3 requires at least one destination.'
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
retention_amount_locally:
|
||||
type: integer
|
||||
default: 7
|
||||
@@ -14800,9 +15066,15 @@ components:
|
||||
stop_during_backup:
|
||||
type: boolean
|
||||
s3_storage_uuid:
|
||||
description: 'Primary S3 destination.'
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
s3_storage_uuids:
|
||||
description: 'Every S3 destination, primary first.'
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
retention_amount_locally:
|
||||
type: integer
|
||||
retention_days_locally:
|
||||
@@ -15139,6 +15411,27 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Password for HTTP Basic Authentication'
|
||||
http_basic_auth_hash_algorithm:
|
||||
type: string
|
||||
enum:
|
||||
- bcrypt
|
||||
- argon2id
|
||||
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
|
||||
http_basic_auth_bcrypt_cost:
|
||||
type: integer
|
||||
minimum: 4
|
||||
maximum: 14
|
||||
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_memory_cost:
|
||||
type: integer
|
||||
minimum: 8192
|
||||
maximum: 262144
|
||||
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
|
||||
http_basic_auth_argon2id_time_cost:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 12
|
||||
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
|
||||
settings:
|
||||
$ref: '#/components/schemas/ApplicationSetting'
|
||||
type: object
|
||||
@@ -15923,6 +16216,9 @@ tags:
|
||||
-
|
||||
name: Hetzner
|
||||
description: Hetzner
|
||||
-
|
||||
name: Hostinger
|
||||
description: Hostinger
|
||||
-
|
||||
name: Settings
|
||||
description: Settings
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"version": "4.5-rc.1"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.17"
|
||||
"version": "1.0.18"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "1.0.2"
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
'searchable' => false, // true = a search field filters the options
|
||||
'searchPlaceholder' => 'Search…',
|
||||
'searchEmptyText' => 'No matching options',
|
||||
'allowCustom' => false, // true (with searchable) = the search text can be chosen as a custom value
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -92,9 +93,15 @@
|
||||
return this.options.filter((option) => !option.header && [option.label, option.value, option.description]
|
||||
.some((text) => String(text ?? '').toLowerCase().includes(query)));
|
||||
},
|
||||
get customOption() {
|
||||
const query = this.query.trim();
|
||||
if (!@js($allowCustom) || query === '' || this.options.some((option) => !option.header && String(option.value) === query)) return null;
|
||||
return { value: query, label: query };
|
||||
},
|
||||
chooseOnlyMatch() {
|
||||
const matches = this.visibleOptions.filter((option) => !option.disabled);
|
||||
if (matches.length === 1) this.choose(matches[0]);
|
||||
else if (this.customOption) this.choose(this.customOption);
|
||||
},
|
||||
@else
|
||||
get visibleOptions() {
|
||||
@@ -126,7 +133,8 @@
|
||||
},
|
||||
get current() {
|
||||
const found = this.options.find((option) => String(option.value) === String(this.value));
|
||||
return found ? found.label : @js($placeholder);
|
||||
if (found) return found.label;
|
||||
return @js($allowCustom) && this.value ? String(this.value) : @js($placeholder);
|
||||
},
|
||||
get title() {
|
||||
return this.current;
|
||||
@@ -237,7 +245,7 @@
|
||||
</div>
|
||||
<div class="searchable-listbox-options">
|
||||
@endif
|
||||
<div x-show="visibleOptions.length === 0"
|
||||
<div x-show="visibleOptions.length === 0{{ $searchable && $allowCustom ? ' && !customOption' : '' }}"
|
||||
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
@if ($searchable)
|
||||
<span x-text="options.length === 0 ? @js($emptyText) : @js($searchEmptyText)"></span>
|
||||
@@ -272,6 +280,14 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@if ($searchable && $allowCustom)
|
||||
<template x-if="customOption">
|
||||
<button type="button" class="listbox-option" role="option" aria-selected="false"
|
||||
@click="choose(customOption)">
|
||||
<span class="truncate">Use <span class="font-mono" x-text="customOption.label"></span></span>
|
||||
</button>
|
||||
</template>
|
||||
@endif
|
||||
@if ($searchable)
|
||||
</div>
|
||||
@endif
|
||||
@@ -296,7 +312,7 @@
|
||||
</div>
|
||||
<div class="searchable-listbox-options">
|
||||
@endif
|
||||
<div x-show="visibleOptions.length === 0"
|
||||
<div x-show="visibleOptions.length === 0{{ $searchable && $allowCustom ? ' && !customOption' : '' }}"
|
||||
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
@if ($searchable)
|
||||
<span x-text="options.length === 0 ? @js($emptyText) : @js($searchEmptyText)"></span>
|
||||
@@ -329,6 +345,14 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@if ($searchable && $allowCustom)
|
||||
<template x-if="customOption">
|
||||
<button type="button" class="listbox-option" role="option" aria-selected="false"
|
||||
@click="choose(customOption)">
|
||||
<span class="truncate">Use <span class="font-mono" x-text="customOption.label"></span></span>
|
||||
</button>
|
||||
</template>
|
||||
@endif
|
||||
@if ($searchable)
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
@props([
|
||||
'base',
|
||||
'file' => null,
|
||||
'fileLabel' => null,
|
||||
'defaultFile' => null,
|
||||
'disabled' => 'false',
|
||||
])
|
||||
|
||||
<div wire:key="repository-paths-{{ $file }}" {{ $attributes->class('grid gap-4') }} x-data="{
|
||||
base: $wire.entangle('{{ $base }}'),
|
||||
file: {{ $file ? "\$wire.entangle('{$file}')" : 'null' }},
|
||||
normalize(path) {
|
||||
path = (path ?? '').trim().replace(/\/+$/, '');
|
||||
return path === '' || path.startsWith('/') ? path : '/' + path;
|
||||
},
|
||||
get directory() {
|
||||
return this.normalize(this.base);
|
||||
},
|
||||
get location() {
|
||||
return this.normalize(this.file) || '{{ $defaultFile }}';
|
||||
},
|
||||
get repeatsBase() {
|
||||
return this.directory !== '' && this.location.startsWith(this.directory + '/');
|
||||
},
|
||||
get suggestion() {
|
||||
return this.location.slice(this.directory.length);
|
||||
},
|
||||
}">
|
||||
<x-forms.input label="Base directory" placeholder="/"
|
||||
helper="Repository directory used as the build root. Useful for monorepos." x-model="base"
|
||||
x-bind:disabled="{{ $disabled }}" @blur="base = directory || '/'" />
|
||||
@if ($file)
|
||||
<x-forms.input :label="$fileLabel" :placeholder="$defaultFile" helper="Path relative to the base directory."
|
||||
x-model="file" x-bind:disabled="{{ $disabled }}" @blur="file = location" />
|
||||
<p class="col-span-full text-xs text-neutral-500 dark:text-fg-dim">
|
||||
Resolved file:
|
||||
<code class="font-mono text-coollabs dark:text-warning" x-text="directory + location"></code>
|
||||
</p>
|
||||
<x-callout class="col-span-full" x-cloak x-show="repeatsBase" title="Base directory is repeated">
|
||||
{{ $fileLabel }} is relative to the base directory, so it should not start with
|
||||
<code class="rounded bg-black/10 px-1 dark:bg-white/10" x-text="directory"></code> again.
|
||||
<button type="button" class="font-medium underline" x-show="!({{ $disabled }})"
|
||||
@click="file = suggestion">Use <span x-text="suggestion"></span></button>
|
||||
</x-callout>
|
||||
@endif
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg {{ $attributes }} viewBox="0 0 26 30" fill="none" xmlns="http://www.w3.org/2000/svg" role="img"
|
||||
aria-label="Hostinger">
|
||||
<title>Hostinger</title>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M0.000249566 14.046V0.000497794L6.99464 3.78046V10.1086L16.2538 10.1132L23.3617 14.046H0.000249566ZM18.1472 8.95059V0L25.3302 3.68591V13.1797L18.1472 8.95059ZM18.1472 26.1177V19.8441L8.81663 19.8376C8.82534 19.8793 1.59081 15.8418 1.59081 15.8418L25.3302 15.9547V30L18.1472 26.1177ZM0 26.1177L0.000248849 16.9393L6.99464 21.0683V29.8033L0 26.1177Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 606 B |
@@ -8,6 +8,7 @@
|
||||
$icons = [
|
||||
'fire' => '<path d="M12.83 2.18a.75.75 0 0 0-1.15.64c0 2.04-.88 3.72-2.31 5.19-.45-1.13-.59-2.31-.42-3.55a.75.75 0 0 0-1.17-.72C4.55 5.96 2.75 9.25 2.75 13a9.25 9.25 0 0 0 18.5 0c0-4.72-3.28-8.6-8.42-10.82ZM12 20.75A7.75 7.75 0 0 1 4.25 13c0-2.67 1.07-5.1 3.05-6.94.08 1.37.46 2.68 1.12 3.93a.75.75 0 0 0 1.18.19c1.85-1.62 3.03-3.48 3.43-5.6 4.13 2.09 6.72 5.13 6.72 8.42A7.75 7.75 0 0 1 12 20.75Z" fill="currentColor"/><path d="M12.38 10.13a.75.75 0 0 0-1.26.32c-.33 1.2-1.06 2.06-1.7 2.82-.69.81-1.17 1.52-1.17 2.73a3.75 3.75 0 0 0 7.5 0c0-2.13-1.2-4.1-3.37-5.87ZM12 18.25A2.25 2.25 0 0 1 9.75 16c0-.65.24-1.03.82-1.72.48-.57 1.03-1.21 1.45-2.06 1.47 1.37 2.23 2.63 2.23 3.78A2.25 2.25 0 0 1 12 18.25Z" fill="currentColor"/>',
|
||||
'cloud' => '<path d="M7.5 19.25h9.75a4.5 4.5 0 0 0 .62-8.96A6.25 6.25 0 0 0 5.8 8.65 5.25 5.25 0 0 0 7.5 19.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'brain' => '<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18V5"/><path d="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4"/><path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5"/><path d="M17.997 5.125a4 4 0 0 1 2.526 5.77"/><path d="M18 18a4 4 0 0 0 2-7.464"/><path d="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517"/><path d="M6 18a4 4 0 0 1-2-7.464"/><path d="M6.003 5.125a4 4 0 0 0-2.526 5.77"/></g>',
|
||||
'code' => '<path d="m8.5 7-5 5 5 5M15.5 7l5 5-5 5M13.5 4l-3 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'analytics' => '<path d="M5 3v14a2 2 0 0 0 2 2h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 17v-3.5M13 17v-7M17 17v-10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
|
||||
'mail' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M5.5 3.25h13A4.25 4.25 0 0 1 22.75 7.5v9a4.25 4.25 0 0 1-4.25 4.25h-13A4.25 4.25 0 0 1 1.25 16.5v-9A4.25 4.25 0 0 1 5.5 3.25Zm-2.75 4.5v8.75a2.75 2.75 0 0 0 2.75 2.75h13a2.75 2.75 0 0 0 2.75-2.75V7.75l-6.6 5.13a4.25 4.25 0 0 1-5.3 0l-6.6-5.13Zm18.4-1.3A2.75 2.75 0 0 0 18.5 4.75h-13a2.75 2.75 0 0 0-2.65 1.7l7.42 5.77a2.75 2.75 0 0 0 3.46 0l7.42-5.77Z" fill="currentColor"/>',
|
||||
|
||||
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user