mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
434 lines
17 KiB
PHP
434 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services\ServerTransfer;
|
|
|
|
use App\Support\ValidationPatterns;
|
|
use Closure;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Throwable;
|
|
|
|
class ServerTransferBundle
|
|
{
|
|
public const SCHEMA_VERSION = 1;
|
|
|
|
/**
|
|
* Every `uuid`, `*_uuid`, and `*_uuids` value in a bundle. Coolify ids are random lowercase
|
|
* letters and digits (new_public_id) or RFC 4122 UUIDs. The ids go into file paths and local
|
|
* shell commands (SSH key file, SSH mux socket, project icon directory).
|
|
*/
|
|
public const ID_PATTERN = '/\A[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}\z/';
|
|
|
|
/**
|
|
* The most items a bundle can have for each list key, summed over the whole bundle.
|
|
*
|
|
* @var array<string, int>
|
|
*/
|
|
public const MAX_ITEMS = [
|
|
'projects' => 1000,
|
|
'environments' => 5000,
|
|
'applications' => 5000,
|
|
'databases' => 5000,
|
|
'services' => 2000,
|
|
'previews' => 10000,
|
|
'environment_variables' => 50000,
|
|
'environment_variables_preview' => 50000,
|
|
'shared_environment_variables' => 20000,
|
|
'persistent_storages' => 20000,
|
|
'file_storages' => 20000,
|
|
'scheduled_tasks' => 10000,
|
|
'scheduled_backups' => 10000,
|
|
'volume_backups' => 10000,
|
|
'tags' => 20000,
|
|
'resource_links' => 10000,
|
|
'destinations' => 1000,
|
|
'private_keys' => 1000,
|
|
'github_apps' => 1000,
|
|
'gitlab_apps' => 1000,
|
|
'gitea_apps' => 1000,
|
|
's3_storages' => 1000,
|
|
'cloud_provider_tokens' => 1000,
|
|
'integration_tokens' => 1000,
|
|
'dns_zones' => 1000,
|
|
'managed_dns_records' => 10000,
|
|
'ssl_certificates' => 10000,
|
|
'warnings' => 1000,
|
|
'post_transfer_checklist' => 1000,
|
|
];
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
public static function wrap(array $payload): array
|
|
{
|
|
return array_merge([
|
|
'schema_version' => self::SCHEMA_VERSION,
|
|
'coolify_version' => config('constants.coolify.version'),
|
|
'exported_at' => now()->toIso8601String(),
|
|
'export_id' => new_public_id(),
|
|
], $payload);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $bundle
|
|
* @return array{valid: bool, errors: list<string>, warnings: list<string>}
|
|
*/
|
|
public static function validate(array $bundle): array
|
|
{
|
|
$errors = [];
|
|
$warnings = [];
|
|
|
|
if (! isset($bundle['schema_version'])) {
|
|
$errors[] = 'Missing schema_version.';
|
|
} elseif ((int) $bundle['schema_version'] !== self::SCHEMA_VERSION) {
|
|
$errors[] = 'Unsupported schema_version '.$bundle['schema_version'].'. Expected '.self::SCHEMA_VERSION.'.';
|
|
}
|
|
|
|
$bundleVersion = data_get($bundle, 'coolify_version');
|
|
$instanceVersion = (string) config('constants.coolify.version');
|
|
if (! is_string($bundleVersion) || $bundleVersion === '') {
|
|
$errors[] = 'Missing coolify_version. Export the server again with Coolify '.$instanceVersion.' or newer.';
|
|
} elseif (version_compare($bundleVersion, $instanceVersion, '>')) {
|
|
$errors[] = "This bundle was exported from Coolify {$bundleVersion}. Upgrade this instance (Coolify {$instanceVersion}) to {$bundleVersion} or newer before you import it.";
|
|
}
|
|
|
|
if (! isset($bundle['export_id']) || ! is_string($bundle['export_id']) || $bundle['export_id'] === '') {
|
|
$errors[] = 'Missing export_id.';
|
|
}
|
|
|
|
if (! isset($bundle['server']) || ! is_array($bundle['server'])) {
|
|
$errors[] = 'Missing server payload.';
|
|
} else {
|
|
foreach (['uuid', 'name', 'ip', 'port', 'user'] as $field) {
|
|
if (! array_key_exists($field, $bundle['server'])) {
|
|
$errors[] = "Missing server.{$field}.";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (! isset($bundle['private_key']) || ! is_array($bundle['private_key'])) {
|
|
$errors[] = 'Missing private_key payload.';
|
|
} else {
|
|
if (blank(data_get($bundle, 'private_key.private_key'))) {
|
|
$errors[] = 'Missing private_key.private_key material.';
|
|
}
|
|
}
|
|
|
|
if (! isset($bundle['destinations']) || ! is_array($bundle['destinations'])) {
|
|
$errors[] = 'Missing destinations array.';
|
|
} elseif (count($bundle['destinations']) === 0) {
|
|
$warnings[] = 'Bundle has no destinations; a default destination will be used.';
|
|
}
|
|
|
|
if (! isset($bundle['projects']) || ! is_array($bundle['projects'])) {
|
|
$errors[] = 'Missing projects array.';
|
|
}
|
|
|
|
$errors = array_merge($errors, self::contentErrors($bundle));
|
|
|
|
return [
|
|
'valid' => $errors === [],
|
|
'errors' => $errors,
|
|
'warnings' => $warnings,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $bundle
|
|
*/
|
|
public static function assertValid(array $bundle): void
|
|
{
|
|
$result = self::validate($bundle);
|
|
if (! $result['valid']) {
|
|
throw ValidationException::withMessages([
|
|
'bundle' => $result['errors'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks the values of a bundle before the import writes anything. A bundle comes from the
|
|
* API, so each value gets the validation that the normal API applies to it.
|
|
*
|
|
* @param array<string, mixed> $bundle
|
|
* @return list<string>
|
|
*/
|
|
public static function contentErrors(array $bundle): array
|
|
{
|
|
$errors = [];
|
|
$counts = [];
|
|
self::walk($bundle, '', $errors, $counts);
|
|
|
|
foreach ($counts as $key => $count) {
|
|
if ($count > self::MAX_ITEMS[$key]) {
|
|
$errors[] = "The bundle has {$count} {$key}. A bundle can have at most ".self::MAX_ITEMS[$key]." {$key}.";
|
|
}
|
|
}
|
|
|
|
// Stop before the field checks: a bundle with a bad id or too many items is not imported.
|
|
if ($errors !== []) {
|
|
return $errors;
|
|
}
|
|
|
|
foreach (self::items(data_get($bundle, 'destinations')) as $i => $destination) {
|
|
self::check($errors, "destinations.{$i}.network", $destination['network'] ?? null, ['nullable', 'string', 'max:255', 'regex:'.ValidationPatterns::DOCKER_NETWORK_PATTERN]);
|
|
}
|
|
|
|
// Only http and https URLs, as for a git source made with the API. The import does not resolve the host:
|
|
// the target may not reach the git host yet, and SafeExternalUrl checks the host on every request.
|
|
$gitSourceUrl = function (string $attribute, mixed $value, Closure $fail): void {
|
|
if (is_string($value) && ! isValidDomainUrl($value)) {
|
|
$fail('The :attribute must use the http or https scheme.');
|
|
}
|
|
};
|
|
foreach (['github_apps', 'gitlab_apps', 'gitea_apps'] as $key) {
|
|
foreach (self::items(data_get($bundle, $key)) as $i => $source) {
|
|
foreach (['html_url', 'api_url'] as $field) {
|
|
self::check($errors, "{$key}.{$i}.{$field}", $source[$field] ?? null, ['nullable', 'string', 'url', $gitSourceUrl]);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (self::items(data_get($bundle, 'ssl_certificates')) as $i => $certificate) {
|
|
self::checkPath($errors, "ssl_certificates.{$i}.configuration_dir", $certificate['configuration_dir'] ?? null);
|
|
self::checkPath($errors, "ssl_certificates.{$i}.mount_path", $certificate['mount_path'] ?? null);
|
|
}
|
|
|
|
foreach (self::items(data_get($bundle, 'volume_backups')) as $i => $backup) {
|
|
self::checkCron($errors, "volume_backups.{$i}.frequency", $backup['frequency'] ?? null);
|
|
}
|
|
|
|
foreach (self::items(data_get($bundle, 'projects')) as $p => $project) {
|
|
foreach (self::items($project['environments'] ?? null) as $e => $environment) {
|
|
$at = "projects.{$p}.environments.{$e}";
|
|
|
|
foreach (self::items($environment['applications'] ?? null) as $a => $application) {
|
|
self::checkDomains($errors, "{$at}.applications.{$a}.fqdn", data_get($application, 'attributes.fqdn'));
|
|
self::checkStorages($errors, "{$at}.applications.{$a}", $application);
|
|
self::checkScheduledTasks($errors, "{$at}.applications.{$a}", $application);
|
|
foreach (self::items($application['previews'] ?? null) as $r => $preview) {
|
|
self::checkDomains($errors, "{$at}.applications.{$a}.previews.{$r}.fqdn", $preview['fqdn'] ?? null);
|
|
self::checkStorages($errors, "{$at}.applications.{$a}.previews.{$r}", $preview);
|
|
}
|
|
}
|
|
|
|
foreach (self::items($environment['databases'] ?? null) as $d => $database) {
|
|
self::checkDatabasePorts($errors, "{$at}.databases.{$d}", (array) ($database['attributes'] ?? []));
|
|
self::checkStorages($errors, "{$at}.databases.{$d}", $database);
|
|
self::checkScheduledBackups($errors, "{$at}.databases.{$d}", $database);
|
|
}
|
|
|
|
foreach (self::items($environment['services'] ?? null) as $s => $service) {
|
|
self::checkScheduledTasks($errors, "{$at}.services.{$s}", $service);
|
|
foreach (self::items($service['applications'] ?? null) as $a => $serviceApplication) {
|
|
self::checkDomains($errors, "{$at}.services.{$s}.applications.{$a}.fqdn", data_get($serviceApplication, 'attributes.fqdn', $serviceApplication['fqdn'] ?? null));
|
|
self::checkStorages($errors, "{$at}.services.{$s}.applications.{$a}", $serviceApplication);
|
|
}
|
|
foreach (self::items($service['databases'] ?? null) as $d => $serviceDatabase) {
|
|
self::checkStorages($errors, "{$at}.services.{$s}.databases.{$d}", $serviceDatabase);
|
|
self::checkScheduledBackups($errors, "{$at}.services.{$s}.databases.{$d}", $serviceDatabase);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $errors;
|
|
}
|
|
|
|
/**
|
|
* Checks every id in the bundle and counts the items of every list key in MAX_ITEMS.
|
|
*
|
|
* @param list<string> $errors
|
|
* @param array<string, int> $counts
|
|
*/
|
|
private static function walk(array $node, string $path, array &$errors, array &$counts): void
|
|
{
|
|
foreach ($node as $key => $value) {
|
|
$at = $path === '' ? (string) $key : "{$path}.{$key}";
|
|
|
|
if (is_string($key)) {
|
|
if ($key === 'uuid' || str_ends_with($key, '_uuid')) {
|
|
self::checkId($errors, $at, $value);
|
|
} elseif (str_ends_with($key, '_uuids')) {
|
|
foreach (is_array($value) ? $value : [$value] as $i => $id) {
|
|
self::checkId($errors, "{$at}.{$i}", $id);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (isset(self::MAX_ITEMS[$key]) && is_array($value)) {
|
|
$counts[$key] = ($counts[$key] ?? 0) + (array_is_list($value)
|
|
? count($value)
|
|
: array_sum(array_map(fn ($list) => is_array($list) ? count($list) : 0, $value)));
|
|
}
|
|
}
|
|
|
|
if (is_array($value)) {
|
|
self::walk($value, $at, $errors, $counts);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $errors
|
|
*/
|
|
private static function checkId(array &$errors, string $at, mixed $value): void
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return;
|
|
}
|
|
|
|
if (! is_string($value) || preg_match(self::ID_PATTERN, $value) !== 1) {
|
|
$errors[] = "{$at} is not a valid id. An id may only contain letters, digits, hyphens, and underscores (at most 64 characters).";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int|string, array<string, mixed>>
|
|
*/
|
|
private static function items(mixed $value): array
|
|
{
|
|
return is_array($value) ? array_filter($value, 'is_array') : [];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $errors
|
|
* @param array<int, mixed> $rules
|
|
*/
|
|
private static function check(array &$errors, string $at, mixed $value, array $rules): void
|
|
{
|
|
$validator = Validator::make(['value' => $value], ['value' => $rules], [], ['value' => $at]);
|
|
foreach ($validator->errors()->all() as $message) {
|
|
$errors[] = $message;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $errors
|
|
*/
|
|
private static function checkDomains(array &$errors, string $at, mixed $value): void
|
|
{
|
|
foreach (ValidationPatterns::validateApplicationDomains($value) as $message) {
|
|
$errors[] = "{$at}: {$message}";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $errors
|
|
*/
|
|
private static function checkPath(array &$errors, string $at, mixed $value): void
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return;
|
|
}
|
|
if (! is_string($value)) {
|
|
$errors[] = "{$at} must be a string.";
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
validateShellSafePath($value, $at);
|
|
} catch (Throwable $e) {
|
|
$errors[] = $e->getMessage();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $errors
|
|
*/
|
|
private static function checkCron(array &$errors, string $at, mixed $value): void
|
|
{
|
|
if ($value === null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$valid = is_string($value) && validate_cron_expression($value);
|
|
} catch (Throwable) {
|
|
$valid = false;
|
|
}
|
|
if (! $valid) {
|
|
$errors[] = "{$at} is not a valid cron expression or frequency.";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $errors
|
|
* @param array<string, mixed> $resource
|
|
*/
|
|
private static function checkStorages(array &$errors, string $at, array $resource): void
|
|
{
|
|
foreach (self::items($resource['persistent_storages'] ?? null) as $i => $volume) {
|
|
self::checkPath($errors, "{$at}.persistent_storages.{$i}.mount_path", $volume['mount_path'] ?? null);
|
|
self::checkPath($errors, "{$at}.persistent_storages.{$i}.host_path", $volume['host_path'] ?? null);
|
|
}
|
|
foreach (self::items($resource['file_storages'] ?? null) as $i => $storage) {
|
|
self::checkPath($errors, "{$at}.file_storages.{$i}.fs_path", $storage['fs_path'] ?? null);
|
|
self::checkPath($errors, "{$at}.file_storages.{$i}.mount_path", $storage['mount_path'] ?? null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The same checks as the scheduled task API.
|
|
*
|
|
* @param list<string> $errors
|
|
* @param array<string, mixed> $resource
|
|
*/
|
|
private static function checkScheduledTasks(array &$errors, string $at, array $resource): void
|
|
{
|
|
foreach (self::items($resource['scheduled_tasks'] ?? null) as $i => $task) {
|
|
self::check($errors, "{$at}.scheduled_tasks.{$i}.command", $task['command'] ?? null, ['required', 'string']);
|
|
self::check($errors, "{$at}.scheduled_tasks.{$i}.container", $task['container'] ?? null, ['nullable', ...ValidationPatterns::containerNameRules()]);
|
|
self::checkCron($errors, "{$at}.scheduled_tasks.{$i}.frequency", $task['frequency'] ?? '');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $errors
|
|
* @param array<string, mixed> $database
|
|
*/
|
|
private static function checkScheduledBackups(array &$errors, string $at, array $database): void
|
|
{
|
|
foreach (self::items($database['scheduled_backups'] ?? null) as $i => $backup) {
|
|
self::checkCron($errors, "{$at}.scheduled_backups.{$i}.frequency", $backup['frequency'] ?? null);
|
|
self::checkPath($errors, "{$at}.scheduled_backups.{$i}.databases_to_backup", $backup['databases_to_backup'] ?? null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks the port values of a database. Older databases were saved before the UI checked them
|
|
* (March 2026), so this accepts any public port in 1-65535, spaces around mappings, empty entries,
|
|
* and container-only ports. It refuses values that are not ports, because they reach the compose file.
|
|
*
|
|
* @param list<string> $errors
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
private static function checkDatabasePorts(array &$errors, string $at, array $attributes): void
|
|
{
|
|
if (array_key_exists('public_port', $attributes)) {
|
|
self::check($errors, "{$at}.public_port", $attributes['public_port'], ['nullable', 'integer', 'between:1,65535']);
|
|
}
|
|
|
|
if (! array_key_exists('ports_mappings', $attributes) || $attributes['ports_mappings'] === null) {
|
|
return;
|
|
}
|
|
$mappings = $attributes['ports_mappings'];
|
|
if (! is_string($mappings)) {
|
|
$errors[] = "{$at}.ports_mappings must be a string.";
|
|
|
|
return;
|
|
}
|
|
foreach (explode(',', $mappings) as $mapping) {
|
|
$mapping = trim($mapping, " \t");
|
|
if ($mapping === '' || preg_match('/^\d+(?:-\d+)?(?:\/(?:tcp|udp|sctp))?$/', $mapping) === 1 || preg_match(ValidationPatterns::PORT_MAPPINGS_PATTERN, $mapping) === 1) {
|
|
continue;
|
|
}
|
|
$errors[] = "{$at}.ports_mappings must be a comma-separated list of port mappings (e.g. 5432:5432,127.0.0.1:8080:80/udp).";
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|