*/ 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 $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 $bundle * @return array{valid: bool, errors: list, warnings: list} */ 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 $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 $bundle * @return list */ 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 $errors * @param array $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 $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> */ private static function items(mixed $value): array { return is_array($value) ? array_filter($value, 'is_array') : []; } /** * @param list $errors * @param array $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 $errors */ private static function checkDomains(array &$errors, string $at, mixed $value): void { foreach (ValidationPatterns::validateApplicationDomains($value) as $message) { $errors[] = "{$at}: {$message}"; } } /** * @param list $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 $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 $errors * @param array $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 $errors * @param array $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 $errors * @param array $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 $errors * @param array $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; } } }