Files
coolify/bootstrap/helpers/api.php
T
Andras Bacsai a3712f8902 fix(deploy): stop 502 errors during rolling updates
Traefik and Caddy routes of applications now retry a failed connection
on another container, and the rolling update waits a configurable drain
time (default 5 seconds) after the new container is healthy before it
stops the old containers. The wait is skipped when no old container runs
or when the proxy labels changed, because the proxy drops a route that
two containers define differently.

Fixes #8627
2026-10-24 11:16:46 +02:00

514 lines
21 KiB
PHP

<?php
use App\Actions\Shared\MigrateResourceToDestination;
use App\Enums\BuildPackTypes;
use App\Enums\HttpBasicAuthHashAlgorithm;
use App\Enums\RedirectTypes;
use App\Enums\StaticImageTypes;
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\ServerSetting;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ManualWebhookSecret;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
function getTeamIdFromToken()
{
$user = auth()->user();
$token = $user?->currentAccessToken();
$teamId = data_get($token, 'team_id');
if (! $user || is_null($teamId) || ! $user->teams()->where('teams.id', $teamId)->exists()) {
return null;
}
return $teamId;
}
function invalidTokenResponse()
{
return response()->json(['message' => 'Invalid token.', 'docs' => 'https://coolify.io/docs/api-reference/authorization'], 400);
}
function serializeApiResponse($data)
{
if ($data instanceof Collection) {
return $data->map(function ($d) {
$d = collect(apiEnvironmentVariableDisplayValue($d))->sortKeys();
$created_at = data_get($d, 'created_at');
$updated_at = data_get($d, 'updated_at');
if ($created_at) {
unset($d['created_at']);
$d['created_at'] = $created_at;
}
if ($updated_at) {
unset($d['updated_at']);
$d['updated_at'] = $updated_at;
}
if (data_get($d, 'name')) {
$d = $d->prepend($d['name'], 'name');
}
if (data_get($d, 'description')) {
$d = $d->prepend($d['description'], 'description');
}
if (data_get($d, 'uuid')) {
$d = $d->prepend($d['uuid'], 'uuid');
}
if (! is_null(data_get($d, 'id'))) {
$d = $d->prepend($d['id'], 'id');
}
return removeServerProxySecretsFromApiPayload($d);
});
} else {
$d = collect(apiEnvironmentVariableDisplayValue($data))->sortKeys();
$created_at = data_get($d, 'created_at');
$updated_at = data_get($d, 'updated_at');
if ($created_at) {
unset($d['created_at']);
$d['created_at'] = $created_at;
}
if ($updated_at) {
unset($d['updated_at']);
$d['updated_at'] = $updated_at;
}
if (data_get($d, 'name')) {
$d = $d->prepend($d['name'], 'name');
}
if (data_get($d, 'description')) {
$d = $d->prepend($d['description'], 'description');
}
if (data_get($d, 'uuid')) {
$d = $d->prepend($d['uuid'], 'uuid');
}
if (! is_null(data_get($d, 'id'))) {
$d = $d->prepend($d['id'], 'id');
}
return removeServerProxySecretsFromApiPayload($d);
}
}
/**
* Serialize an environment variable with its display value, so a reference to a
* locked shared variable keeps its reference text instead of the locked value.
*/
function apiEnvironmentVariableDisplayValue(mixed $data): mixed
{
if (! $data instanceof EnvironmentVariable || in_array('real_value', $data->getHidden(), true)) {
return $data;
}
return [...$data->toArray(), 'real_value' => $data->displayRealValue()];
}
/**
* Remove the saved proxy configuration and validation logs from every
* serialized server in an API payload, including nested ones such as
* `destination.server`, unless the token can read sensitive data.
* The saved proxy configuration often contains DNS provider API tokens.
*
* @template T
*
* @param T $payload
* @return T
*/
function removeServerProxySecretsFromApiPayload(mixed $payload): mixed
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
return $payload;
}
return removeServerProxySecrets($payload);
}
/**
* @template T
*
* @param T $value
* @return T
*/
function removeServerProxySecrets(mixed $value): mixed
{
if (! is_array($value) && ! $value instanceof SupportCollection) {
return $value;
}
$proxy = $value['proxy'] ?? null;
if (is_array($proxy)) {
unset($proxy['last_saved_proxy_configuration']);
$value['proxy'] = $proxy;
unset($value['validation_logs']);
}
foreach ($value as $key => $item) {
if (is_array($item) || $item instanceof SupportCollection) {
$value[$key] = removeServerProxySecrets($item);
}
}
return $value;
}
/**
* Re-expose a model's `$hidden` sensitive fields when the current API request
* carries the `read:sensitive` or `root` token ability (set by the
* ApiSensitiveData middleware).
*/
function exposeSensitiveFields(Model $model): Model
{
if (request()->attributes->get('can_read_sensitive', false) === true && filled($model->getHidden())) {
$model->makeVisible($model->getHidden());
}
return $model;
}
/**
* Shared validation rules for application create and update API endpoints.
*
* Pass the existing application on update so unchanged manual webhook secrets
* are accepted even when they predate the minimum length.
*/
function sharedDataApplications(?Application $application = null): array
{
return [
'git_repository' => 'string',
'git_branch' => ['string', new ValidGitBranch],
'build_pack' => Rule::enum(BuildPackTypes::class),
'is_static' => 'boolean',
'is_spa' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
'is_preview_deployments_enabled' => 'boolean',
'use_build_secrets' => 'boolean',
'is_git_submodules_enabled' => 'boolean',
'is_git_lfs_enabled' => 'boolean',
'is_git_shallow_clone_enabled' => 'boolean',
'disable_build_cache' => 'boolean',
'inject_build_args_to_dockerfile' => 'boolean',
'include_source_commit_in_build' => 'boolean',
'is_env_sorting_enabled' => 'boolean',
'redact_all_env_values_in_logs' => 'boolean',
'include_coolify_default_variables' => 'boolean',
'is_pr_deployments_public_enabled' => 'boolean',
'is_gzip_enabled' => 'boolean',
'is_stripprefix_enabled' => 'boolean',
'is_permanent_redirect_enabled' => 'boolean',
'traefik_certificate_resolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN],
'is_container_label_readonly_enabled' => 'boolean',
'is_raw_compose_deployment_enabled' => 'boolean',
'is_log_drain_enabled' => 'boolean',
'is_gpu_enabled' => 'boolean',
'gpu_driver' => 'string|nullable',
'gpu_count' => ['string', 'nullable', 'regex:'.ApplicationSetting::GPU_COUNT_PATTERN],
'gpu_device_ids' => 'string|nullable',
'gpu_options' => ['string', 'nullable', function (string $attribute, mixed $value, Closure $fail): void {
if (ApplicationSetting::parseGpuOptions($value) === null) {
$fail('The gpu_options must be key=value pairs or a YAML mapping.');
}
}],
'is_consistent_container_name_enabled' => 'boolean',
'custom_internal_name' => ['nullable', ...ValidationPatterns::containerNameRules()],
'custom_container_name_prefix' => 'string|nullable|max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH,
'preview_url_template' => 'string',
'max_restart_count' => 'integer|min:0',
'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS,
'rolling_update_drain_seconds' => 'integer|min:0|max:'.MAX_ROLLING_UPDATE_DRAIN_SECONDS,
'docker_images_to_keep' => 'integer|min:0|max:100',
'static_image' => Rule::enum(StaticImageTypes::class),
'domains' => ValidationPatterns::applicationDomainRules(),
'noindex_domains' => 'array|nullable',
'noindex_domains.*' => 'string',
'redirect' => Rule::enum(RedirectTypes::class),
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(),
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'install_command' => ValidationPatterns::shellSafeCommandRules(),
'build_command' => ValidationPatterns::shellSafeCommandRules(),
'start_command' => ValidationPatterns::shellSafeCommandRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable',
'base_directory' => ValidationPatterns::directoryPathRules(),
'publish_directory' => ValidationPatterns::directoryPathRules(),
'health_check_enabled' => 'boolean',
'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
'health_check_port' => 'integer|nullable|min:1|max:65535',
'health_check_host' => ['string', function (string $attribute, mixed $value, Closure $fail): void {
if (! ValidationPatterns::isValidHealthCheckHost($value)) {
$fail('The health check host must be a hostname or an IP address.');
}
}],
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',
'health_check_return_code' => 'numeric',
'health_check_scheme' => 'string|in:http,https',
'health_check_response_text' => 'string|nullable',
'health_check_interval' => 'numeric',
'health_check_timeout' => 'numeric',
'health_check_retries' => 'numeric',
'health_check_start_period' => 'numeric',
'limits_memory' => 'string',
'limits_memory_swap' => 'string',
'limits_memory_swappiness' => 'numeric',
'limits_memory_reservation' => 'string',
'limits_cpus' => 'string',
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'custom_labels' => 'string|nullable',
'custom_docker_run_options' => ValidationPatterns::shellSafeCommandRules(2000),
// Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate").
// Access is gated by API token authentication. Commands run inside the app container, not the host.
'post_deployment_command' => 'string|nullable',
'post_deployment_command_container' => ValidationPatterns::containerNameRules(),
'pre_deployment_command' => 'string|nullable',
'pre_deployment_command_container' => ValidationPatterns::containerNameRules(),
'manual_webhook_secret_github' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_github)],
'manual_webhook_secret_gitlab' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_gitlab)],
'manual_webhook_secret_bitbucket' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_bitbucket)],
'manual_webhook_secret_gitea' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_gitea)],
'dockerfile_location' => ValidationPatterns::filePathRules(),
'dockerfile_target_build' => ValidationPatterns::dockerTargetRules(),
'docker_compose_location' => ValidationPatterns::filePathRules(),
'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => ValidationPatterns::shellSafeCommandRules(),
'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
'is_preserve_repository_enabled' => 'boolean',
'http_basic_auth_hash_algorithm' => Rule::enum(HttpBasicAuthHashAlgorithm::class),
'http_basic_auth_bcrypt_cost' => 'integer|min:4|max:14',
'http_basic_auth_argon2id_memory_cost' => 'integer|min:8192|max:262144',
'http_basic_auth_argon2id_time_cost' => 'integer|min:1|max:12',
];
}
function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
{
$validator = Validator::make($request->all(), [
'environment_uuid' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$extraFields = array_diff(array_keys($request->all()), ['environment_uuid']);
if (! empty($extraFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(),
], 422);
}
$newEnvironment = Environment::ownedByCurrentTeamAPI($teamId)
->whereUuid($request->environment_uuid)
->first();
if (! $newEnvironment) {
return response()->json(['message' => 'Target environment not found or not owned by your team.'], 404);
}
Gate::authorize('update', $newEnvironment);
if ($resource->environment_id === $newEnvironment->id) {
return response()->json(['message' => "$resourceType is already in this environment."], 400);
}
$oldEnvironment = $resource->environment()->with('project')->first();
$resource->update(['environment_id' => $newEnvironment->id]);
auditLog('api.'.str($resourceType)->lower()->value().'.moved', [
'team_id' => $teamId,
'resource_uuid' => $resource->uuid,
'resource_type' => str($resourceType)->lower()->value(),
'from_project_uuid' => $oldEnvironment?->project?->uuid,
'from_environment_uuid' => $oldEnvironment?->uuid,
'to_project_uuid' => $newEnvironment->project->uuid,
'to_environment_uuid' => $newEnvironment->uuid,
]);
return response()->json([
'message' => "$resourceType moved successfully.",
'uuid' => $resource->uuid,
'project_uuid' => $newEnvironment->project->uuid,
'environment_uuid' => $newEnvironment->uuid,
]);
}
function migrateResourceToDestination(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
{
if (! isDev()) {
abort(404);
}
$validator = Validator::make($request->all(), [
'destination_uuid' => 'required|string',
'migrate_volumes' => 'boolean',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$allowedFields = ['destination_uuid', 'migrate_volumes'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if (! empty($extraFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(),
], 422);
}
Gate::authorize('update', $resource);
$destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first()
?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first();
if (! $destination || ! $destination->server?->canHostResources()) {
return response()->json(['message' => 'Destination not found.'], 404);
}
$sourceDestination = $resource->destination;
$migrateVolumes = $request->boolean('migrate_volumes', true);
try {
$result = MigrateResourceToDestination::run(
$resource,
$destination,
$migrateVolumes,
);
} catch (ValidationException $e) {
return response()->json([
'message' => collect($e->errors())->flatten()->first() ?? $e->getMessage(),
'errors' => $e->errors(),
], 422);
}
auditLog('api.'.str($resourceType)->lower()->value().'.migrated', [
'team_id' => $teamId,
'resource_uuid' => $resource->uuid,
'resource_type' => str($resourceType)->lower()->value(),
'from_destination_uuid' => $sourceDestination?->uuid,
'to_destination_uuid' => $destination->uuid,
'from_server_id' => $sourceDestination?->server_id,
'to_server_id' => $destination->server_id,
'migrate_volumes' => $migrateVolumes,
'async' => $result['async'],
'volume_jobs' => $result['volume_jobs'],
]);
return response()->json([
'message' => $result['message'],
'uuid' => $resource->uuid,
'destination_uuid' => $destination->uuid,
'async' => $result['async'],
'volume_jobs' => $result['volume_jobs'],
]);
}
function validateIncomingRequest(Request $request)
{
// check if request is json
if (! $request->isJson()) {
return response()->json([
'message' => 'Invalid request.',
'error' => 'Content-Type must be application/json.',
], 400);
}
// check if request is valid json
if (! json_decode($request->getContent())) {
return response()->json([
'message' => 'Invalid request.',
'error' => 'Invalid JSON.',
], 400);
}
// check if valid json is empty
if (empty($request->json()->all())) {
return response()->json([
'message' => 'Invalid request.',
'error' => 'Empty JSON.',
], 400);
}
}
function removeUnnecessaryFieldsFromRequest(Request $request)
{
$request->offsetUnset('project_uuid');
$request->offsetUnset('environment_name');
$request->offsetUnset('environment_uuid');
$request->offsetUnset('destination_uuid');
$request->offsetUnset('server_uuid');
$request->offsetUnset('type');
$request->offsetUnset('domains');
$request->offsetUnset('instant_deploy');
$request->offsetUnset('github_app_uuid');
$request->offsetUnset('private_key_uuid');
$request->offsetUnset('use_build_server');
$request->offsetUnset('use_build_secrets');
$request->offsetUnset('is_static');
$request->offsetUnset('is_spa');
$request->offsetUnset('is_auto_deploy_enabled');
$request->offsetUnset('is_force_https_enabled');
$request->offsetUnset('is_preview_deployments_enabled');
$request->offsetUnset('connect_to_docker_network');
$request->offsetUnset('force_domain_override');
$request->offsetUnset('autogenerate_domain');
$request->offsetUnset('is_container_label_escape_enabled');
$request->offsetUnset('is_preserve_repository_enabled');
$request->offsetUnset('include_source_commit_in_build');
$request->offsetUnset('is_git_submodules_enabled');
$request->offsetUnset('is_git_lfs_enabled');
$request->offsetUnset('is_git_shallow_clone_enabled');
$request->offsetUnset('disable_build_cache');
$request->offsetUnset('inject_build_args_to_dockerfile');
$request->offsetUnset('is_env_sorting_enabled');
$request->offsetUnset('redact_all_env_values_in_logs');
$request->offsetUnset('include_coolify_default_variables');
$request->offsetUnset('is_pr_deployments_public_enabled');
$request->offsetUnset('stop_grace_period');
$request->offsetUnset('rolling_update_drain_seconds');
$request->offsetUnset('docker_images_to_keep');
$request->offsetUnset('is_gzip_enabled');
$request->offsetUnset('is_stripprefix_enabled');
$request->offsetUnset('is_permanent_redirect_enabled');
$request->offsetUnset('traefik_certificate_resolver');
$request->offsetUnset('is_container_label_readonly_enabled');
$request->offsetUnset('is_raw_compose_deployment_enabled');
$request->offsetUnset('is_log_drain_enabled');
$request->offsetUnset('is_gpu_enabled');
$request->offsetUnset('gpu_driver');
$request->offsetUnset('gpu_count');
$request->offsetUnset('gpu_device_ids');
$request->offsetUnset('gpu_options');
$request->offsetUnset('is_consistent_container_name_enabled');
$request->offsetUnset('custom_internal_name');
$request->offsetUnset('custom_container_name_prefix');
$request->offsetUnset('docker_compose_raw');
$request->offsetUnset('tags');
}