mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-06 14:07:37 -04:00
Use the shared Docker container-name rules for custom internal names in the API and Advanced settings form. Quote container names in deployment inspect, log, and stop commands.
417 lines
16 KiB
PHP
417 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Actions\Shared\MigrateResourceToDestination;
|
|
use App\Enums\BuildPackTypes;
|
|
use App\Enums\RedirectTypes;
|
|
use App\Enums\StaticImageTypes;
|
|
use App\Models\ApplicationSetting;
|
|
use App\Models\Environment;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\SwarmDocker;
|
|
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\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($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 $d;
|
|
});
|
|
} else {
|
|
$d = collect($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 $d;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
function sharedDataApplications()
|
|
{
|
|
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',
|
|
'is_pr_deployments_public_enabled' => 'boolean',
|
|
'is_gzip_enabled' => 'boolean',
|
|
'is_stripprefix_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',
|
|
'gpu_device_ids' => 'string|nullable',
|
|
'gpu_options' => 'string|nullable',
|
|
'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,
|
|
'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', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
|
'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' => 'string|nullable',
|
|
'manual_webhook_secret_gitlab' => 'string|nullable',
|
|
'manual_webhook_secret_bitbucket' => 'string|nullable',
|
|
'manual_webhook_secret_gitea' => 'string|nullable',
|
|
'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',
|
|
];
|
|
}
|
|
|
|
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('is_pr_deployments_public_enabled');
|
|
$request->offsetUnset('stop_grace_period');
|
|
$request->offsetUnset('docker_images_to_keep');
|
|
$request->offsetUnset('is_gzip_enabled');
|
|
$request->offsetUnset('is_stripprefix_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');
|
|
}
|