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:
Andras Bacsai committed 2026-10-09 19:30:26 +02:00
commit 5cece62545
194 files changed
+13145 -1603

No files matched your search

+1
View File
@@ -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;
+31
View File
@@ -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);
}
}
+3 -2
View File
@@ -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",
+5
View File
@@ -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",
+2 -4
View File
@@ -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",
];
+56 -1
View File
@@ -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;
}
}
}
+9
View File
@@ -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');
+9
View File
@@ -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();
+7 -6
View File
@@ -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;
}
}
+69
View File
@@ -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;
}
}
+9
View File
@@ -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',
]);
+119 -70
View File
@@ -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,
+18 -1
View File
@@ -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
+49 -18
View File
@@ -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 {
+13
View File
@@ -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) {
+2 -1
View File
@@ -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);
+74 -44
View File
@@ -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();
}
+29 -8
View File
@@ -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()
+29 -3
View File
@@ -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();
}
+2
View File
@@ -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);
}
}
+57 -25
View File
@@ -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()
+26
View File
@@ -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');
+1 -1
View File
@@ -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;
+3
View File
@@ -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',
};
+14 -1
View File
@@ -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,
]);
+514
View File
@@ -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');
}
}
+19
View File
@@ -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();
+205
View File
@@ -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');
+66 -36
View File
@@ -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();
+4 -2
View File
@@ -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()
+1 -1
View File
@@ -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',
+2
View File
@@ -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,
]))
+64 -3
View File
@@ -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}");
+38
View File
@@ -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
View File
@@ -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]);
});
}
+10 -1
View File
@@ -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');
}
}
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+38
View File
@@ -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');
}
}
+1 -1
View File
@@ -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'),
];
}
+224
View File
@@ -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;
}
}
+129
View File
@@ -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++;
}
+85
View File
@@ -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();
}
}
}
}
+273
View File
@@ -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] ?? [];
}
}
+68
View File
@@ -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();
}
}
+75
View File
@@ -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));
}
}
+5
View File
@@ -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
View File
@@ -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
+73 -8
View File
@@ -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";
}
+13
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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,
];
}
}
@@ -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');
});
}
};
@@ -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']);
});
}
}
};
@@ -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]),
);
}
};
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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