Merge remote-tracking branch 'origin/release/v4.5' into pr-12202-next

# Conflicts:
#	.ai/lessons.md
#	app/Actions/Server/CleanupDocker.php
#	app/Http/Controllers/Api/DatabasesController.php
#	app/Http/Controllers/Api/ServerCanvasController.php
#	app/Http/Controllers/Api/ServicesController.php
#	app/Http/Middleware/ApiIpAllowed.php
#	app/Jobs/VolumeBackupJob.php
#	app/Livewire/Concerns/RemembersCanvasView.php
#	app/Livewire/Fleet/Metrics.php
#	app/Livewire/Project/Database/BackupEdit.php
#	app/Livewire/Project/Service/BackupExecutions.php
#	app/Livewire/Project/Shared/EnvironmentVariable/All.php
#	app/Livewire/Project/Shared/Storages/VolumeBackups.php
#	app/Livewire/Security/ApiTokens.php
#	app/Livewire/Tags/Show.php
#	app/Models/ApplicationSetting.php
#	app/Models/Service.php
#	app/Rules/ValidGitRepositoryUrl.php
#	app/Services/EnvironmentCanvas.php
#	app/Services/ProjectCanvas.php
#	app/Services/ServerCanvas.php
#	app/Services/ServerTransfer/ServerTransferExporter.php
#	app/Services/ServerTransfer/ServerTransferImporter.php
#	app/Services/ServerTransfer/ServerTransferMigrator.php
#	app/Services/SharedVariableCanvas.php
#	app/Traits/ExecutesDatabaseStartCommands.php
#	app/Traits/HasS3Replicas.php
#	bootstrap/helpers/shared.php
#	config/constants.php
#	other/nightly/versions.json
#	resources/css/app.css
#	resources/js/canvas-core.js
#	resources/js/canvas/deploy/DeployCanvas.svelte
#	resources/js/canvas/deploy/state.svelte.js
#	resources/js/canvas/deploy/styles.js
#	resources/js/canvas/environment/Card.svelte
#	resources/js/canvas/environment/EnvironmentCanvas.svelte
#	resources/js/canvas/environment/Panel.svelte
#	resources/js/canvas/environment/StatusDot.svelte
#	resources/js/canvas/environment/Toolbar.svelte
#	resources/js/canvas/environment/Zone.svelte
#	resources/js/deploy-canvas.test.js
#	resources/js/environment-canvas.js
#	resources/js/environment-canvas.test.js
#	resources/views/components/canvas-view-toggle.blade.php
#	resources/views/components/navbar.blade.php
#	resources/views/components/server/sidebar.blade.php
#	resources/views/components/shared-variables/layout.blade.php
#	resources/views/components/shared-variables/sidebar.blade.php
#	resources/views/components/top-user-menu.blade.php
#	resources/views/layouts/app.blade.php
#	resources/views/livewire/fleet/metrics.blade.php
#	resources/views/livewire/project/database/backup-edit/s3.blade.php
#	resources/views/livewire/project/database/backup-executions.blade.php
#	resources/views/livewire/project/environment-canvas.blade.php
#	resources/views/livewire/project/new/select.blade.php
#	resources/views/livewire/project/project-canvas.blade.php
#	resources/views/livewire/project/projects-canvas.blade.php
#	resources/views/livewire/project/shared/environment-variable/all.blade.php
#	resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php
#	resources/views/livewire/security/api-tokens.blade.php
#	resources/views/livewire/server/canvas.blade.php
#	resources/views/livewire/server/index.blade.php
#	resources/views/livewire/server/navbar.blade.php
#	resources/views/livewire/server/partials/localhost-general.blade.php
#	resources/views/livewire/server/show.blade.php
#	resources/views/livewire/settings/advanced.blade.php
#	resources/views/livewire/shared-variables/canvas.blade.php
#	resources/views/livewire/tags/canvas.blade.php
#	resources/views/livewire/tags/show.blade.php
#	routes/web.php
#	tests/Feature/ApiDisabledCalloutTest.php
#	tests/Feature/BackupS3DestinationsFormTest.php
#	tests/Feature/DatabaseStartContentFilesTest.php
#	tests/Feature/DockerCleanupJobTest.php
#	tests/Feature/EnvironmentCanvasTest.php
#	tests/Feature/InstanceBackupMultipleS3DestinationsTest.php
#	tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php
#	tests/Feature/Livewire/Fleet/MetricsDataTest.php
#	tests/Feature/Mcp/McpEndpointTest.php
#	tests/Feature/Mcp/McpToggleApiTest.php
#	tests/Feature/ProjectCanvasTest.php
#	tests/Feature/ServerCanvasTest.php
#	tests/Feature/SharedVariableCanvasTest.php
#	tests/Feature/VolumeBackupTest.php
#	tests/Unit/ScpStyleGitUrlTest.php
#	tests/Unit/ServerTransfer/ServerTransferS3DestinationsTest.php
#	tests/v4/Browser/DeployCanvasTest.php
#	versions.json
This commit is contained in:
Andras Bacsai
2026-10-19 10:00:21 +02:00
525 changed files with 51706 additions and 5132 deletions
@@ -421,7 +421,7 @@ class ApplicationsController extends Controller
],
),
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'watch_paths' => ['type' => 'string', 'description' => 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
@@ -622,7 +622,7 @@ class ApplicationsController extends Controller
],
),
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'watch_paths' => ['type' => 'string', 'description' => 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
@@ -823,7 +823,7 @@ class ApplicationsController extends Controller
],
),
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'watch_paths' => ['type' => 'string', 'description' => 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
@@ -1276,6 +1276,9 @@ class ApplicationsController extends Controller
$this->authorize('create', Application::class);
// removeUnnecessaryFieldsFromRequest() strips force_domain_override before the docker_compose_domains checks run.
$forceDomainOverride = $request->boolean('force_domain_override');
if ($request->instant_deploy) {
abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy');
}
@@ -1531,7 +1534,7 @@ class ApplicationsController extends Controller
});
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) {
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
$errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()).' Use force_domain_override=true to proceed.';
}
@@ -1552,7 +1555,7 @@ class ApplicationsController extends Controller
], 422);
}
if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) {
if ($result['hasConflicts'] && ! $forceDomainOverride) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
@@ -1577,7 +1580,7 @@ class ApplicationsController extends Controller
$application->domain_port_overrides = $domainPortOverrides;
}
$gitRepository = $application->git_repository;
$httpsRepository = scpStyleGitUrlToHttps($gitRepository);
$httpsRepository = sshGitUrlToHttps($gitRepository);
if (is_string($httpsRepository)) {
$gitRepository = $httpsRepository;
$application->git_repository = $httpsRepository;
@@ -1799,7 +1802,7 @@ class ApplicationsController extends Controller
});
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) {
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
$errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()).' Use force_domain_override=true to proceed. ';
}
@@ -1820,7 +1823,7 @@ class ApplicationsController extends Controller
], 422);
}
if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) {
if ($result['hasConflicts'] && ! $forceDomainOverride) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
@@ -2032,7 +2035,7 @@ class ApplicationsController extends Controller
});
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) {
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
$errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()).' Use force_domain_override=true to proceed.';
}
@@ -2053,7 +2056,7 @@ class ApplicationsController extends Controller
], 422);
}
if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) {
if ($result['hasConflicts'] && ! $forceDomainOverride) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
@@ -3093,14 +3096,10 @@ class ApplicationsController extends Controller
schema: new OA\Schema(
type: 'object',
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
'environment_name' => ['type' => 'string', 'description' => 'The environment name.'],
'github_app_uuid' => ['type' => 'string', 'description' => 'The Github App UUID.'],
'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'],
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
@@ -3168,7 +3167,7 @@ class ApplicationsController extends Controller
],
),
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'watch_paths' => ['type' => 'string', 'description' => 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
@@ -3301,10 +3300,12 @@ class ApplicationsController extends Controller
$dnsCleanup = app(ManagedDnsRecordCleanup::class);
$previousDnsHostnames = $dnsCleanup->hostnamesOf($application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', 'dockerfile', ...self::APPLICATION_SETTING_FIELDS];
$validationRules = [
'name' => 'string|max:255',
'github_app_uuid' => 'string',
'dockerfile' => 'string',
'description' => 'string|nullable',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
@@ -3364,6 +3365,28 @@ class ApplicationsController extends Controller
'custom_nginx_configuration' => $customNginxConfiguration,
]);
}
if ($request->has('dockerfile')) {
if (blank($application->dockerfile)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'dockerfile' => 'The dockerfile can only be updated for applications created from a Dockerfile.',
],
], 422);
}
$dockerfile = is_string($request->dockerfile) && isBase64Encoded($request->dockerfile) ? base64_decode($request->dockerfile) : false;
if ($dockerfile === false || blank($dockerfile) || mb_detect_encoding($dockerfile, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'dockerfile' => 'The dockerfile should be base64 encoded.',
],
], 422);
}
$request->merge([
'dockerfile' => $dockerfile,
]);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) {
return $return;
@@ -3383,6 +3406,24 @@ class ApplicationsController extends Controller
], 422);
}
$githubApp = null;
if ($request->has('github_app_uuid')) {
if ($application->build_pack === 'dockerimage' || filled($application->dockerfile) || $application->deploymentType() === 'deploy_key') {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'github_app_uuid' => 'The github_app_uuid can only be changed for applications deployed from a GitHub App or public repository.',
],
], 422);
}
$githubApp = GithubApp::where('uuid', $request->github_app_uuid)
->where(fn ($query) => $query->where('team_id', $teamId)->orWhere('is_system_wide', true))
->first();
if (! $githubApp?->isConnected()) {
return response()->json(['message' => 'Github App not found.'], 404);
}
}
$applicationSettings = $this->applicationSettingsFromRequest($request);
if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) {
return $prefixValidation;
@@ -3642,6 +3683,12 @@ class ApplicationsController extends Controller
$requestHasNoindexDomains = $request->has('noindex_domains');
data_forget($data, 'noindex_domains');
$application->fill($data);
if ($githubApp && ($application->source_id !== $githubApp->id || $application->source_type !== $githubApp->getMorphClass())) {
$application->source_id = $githubApp->id;
$application->source_type = $githubApp->getMorphClass();
// The next deployment fetches the repository ID from the new source.
$application->repository_project_id = null;
}
if ($requestHasNoindexDomains) {
// Must run after fqdn is filled: flags are kept only for domains the app still has.
$application->setNoindexDomains($request->input('noindex_domains') ?? []);
@@ -3650,6 +3697,9 @@ class ApplicationsController extends Controller
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
}
$application->withoutAuditLogging(fn () => $application->save());
if ($application->wasChanged('dockerfile')) {
$application->parseHealthcheckFromDockerfile($application->dockerfile);
}
$dnsCleanup->queueReleaseOfRemovedHostnames($application, $previousDnsHostnames, (int) $teamId);
auditLog('api.application.updated', [
@@ -556,6 +556,7 @@ class DatabasesController extends Controller
'limits_cpus' => ['type' => 'string', 'description' => 'CPU limit of the database'],
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Restart the database after the update. Requires the deploy ability.'],
'postgres_user' => ['type' => 'string', 'description' => 'PostgreSQL user'],
'postgres_password' => ['type' => 'string', 'description' => 'PostgreSQL password'],
'postgres_db' => ['type' => 'string', 'description' => 'PostgreSQL database'],
@@ -644,6 +645,7 @@ class DatabasesController extends Controller
'limits_cpus' => 'string',
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'instant_deploy' => 'boolean',
'redact_all_env_values_in_logs' => 'boolean',
]);
@@ -654,6 +656,7 @@ class DatabasesController extends Controller
], 422);
}
$uuid = $request->uuid;
$instantDeploy = $request->boolean('instant_deploy');
$redactAllEnvValuesInLogs = $request->has('redact_all_env_values_in_logs') ? $request->boolean('redact_all_env_values_in_logs') : null;
removeUnnecessaryFieldsFromRequest($request);
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
@@ -663,6 +666,11 @@ class DatabasesController extends Controller
$this->authorize('update', $database);
if ($instantDeploy) {
abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy');
$this->authorize('manage', $database);
}
if ($redactAllEnvValuesInLogs !== null) {
$this->authorize('manageEnvironment', $database);
}
@@ -890,6 +898,17 @@ class DatabasesController extends Controller
'errors' => $errors,
], 422);
}
$restartReservation = null;
if ($instantDeploy) {
$prerequisiteError = StartDatabase::prerequisiteError($database);
if ($prerequisiteError !== null) {
return response()->json(['message' => $prerequisiteError], 422);
}
$restartReservation = StartDatabase::reserveOperation($database);
if ($restartReservation === null) {
return response()->json(['message' => ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE], 409);
}
}
$whatToDoWithDatabaseProxy = null;
if ($request->is_public === false && $database->is_public === true) {
$whatToDoWithDatabaseProxy = 'stop';
@@ -903,12 +922,22 @@ class DatabasesController extends Controller
$database->redact_all_env_values_in_logs = $redactAllEnvValuesInLogs;
}
$database->update($request->only($allowedFields));
// The Redis password is stored in the REDIS_PASSWORD variable, not in a column.
if ($database instanceof StandaloneRedis && filled($request->redis_password)) {
$database->runtime_environment_variables()->updateOrCreate(
['key' => 'REDIS_PASSWORD'],
['value' => $request->redis_password, 'resourceable_id' => $database->id]
);
}
if ($whatToDoWithDatabaseProxy === 'start') {
StartDatabaseProxy::dispatch($database);
} elseif ($whatToDoWithDatabaseProxy === 'stop') {
StopDatabaseProxy::dispatch($database);
}
if ($restartReservation !== null) {
StartDatabase::dispatchReserved(RestartDatabase::class, $database, $restartReservation);
}
auditLog('api.database.updated', [
'team_id' => $teamId,
@@ -122,6 +122,7 @@ class HostingerController extends Controller
responses: [
new OA\Response(response: 201, description: 'Hostinger VPS purchased and linked to a Coolify server.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'The token needs the deploy ability to set post_install_script_id or public_key_ids.'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
]
@@ -182,6 +183,10 @@ class HostingerController extends Controller
], 422);
}
if ($request->filled('post_install_script_id') || $request->array('public_key_ids')) {
abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy');
}
$team = Team::find($teamId);
if (Team::serverLimitReached($team)) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
@@ -29,6 +29,22 @@ class ServerCanvasController extends Controller
return response()->json($this->canvas->payload($teamId, $servers, null, $this->canReadSensitive($request), $this->canEdit($request, $teamId)));
}
/**
* Dashboard canvas: every server of the team with its resources and their running or queued deployments.
*/
public function dashboard(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('viewAny', Server::class);
$servers = Server::query()->whereTeamId($teamId)->with('settings')->orderBy('name')->get();
return response()->json($this->canvas->dashboardPayload($teamId, $servers, $this->canReadSensitive($request), $this->canEdit($request, $teamId)));
}
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
@@ -73,6 +89,27 @@ class ServerCanvasController extends Controller
return response()->json(['nodes' => $this->canvas->unmanagedContainerNodes($server)->all()]);
}
/**
* Latest CPU, memory and disk usage of one server for its zone header on the canvas.
*/
public function usage(string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$server = Server::query()->whereTeamId($teamId)->whereUuid($uuid)->with('settings')->first();
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('view', $server);
if (! $server->settings->is_metrics_enabled) {
return response()->json(['cpu' => null, 'memory' => null, 'disk' => null]);
}
return response()->json($this->canvas->usage($server));
}
private function canReadSensitive(Request $request): bool
{
return (bool) $request->attributes->get('can_read_sensitive', false);
@@ -3,10 +3,12 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\Server;
use App\Rules\ValidServerIp;
use App\Services\ServerTransfer\ServerTransferBundle;
use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\ServerTransfer\ServerTransferExporter;
use App\Services\ServerTransfer\ServerTransferImporter;
use App\Services\ServerTransfer\ServerTransferMigrator;
use Illuminate\Http\JsonResponse;
@@ -18,17 +20,14 @@ use Throwable;
class ServerTransferController extends Controller
{
public function __construct(
private ServerTransferExporter $exporter,
private ServerTransferImporter $importer,
private ServerTransferClaimer $claimer,
private ServerTransferMigrator $migrator,
) {
abort_unless(isDev(), 404);
}
) {}
#[OA\Post(
summary: 'Migrate server to another Coolify instance',
description: 'One-shot handoff: export this server, import+claim on the target instance (using the provided token), then disable automations here. Requires read:sensitive and write.',
description: 'Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. Follow the progress with GET /servers/{uuid}/transfer. The target URL must use https. Requires read:sensitive and write.',
path: '/servers/{uuid}/migrate',
operationId: 'migrate-server-between-instances',
security: [['bearerAuth' => []]],
@@ -43,13 +42,11 @@ class ServerTransferController extends Controller
properties: [
new OA\Property(property: 'target_url', type: 'string', example: 'https://coolify-b.example.com'),
new OA\Property(property: 'target_token', type: 'string', description: 'API token on the target instance (root or write)'),
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Migrated'),
new OA\Response(response: 202, description: 'Transfer started'),
new OA\Response(response: 403, description: 'Missing sensitive permission'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, description: 'Validation or remote import failed'),
@@ -83,10 +80,8 @@ class ServerTransferController extends Controller
$validator = customApiValidator($request->all(), [
'target_url' => 'required|string|url',
'target_token' => 'required|string',
'preserve_uuids' => 'boolean|nullable',
'adopt_mode' => 'boolean|nullable',
]);
$allowedFields = ['target_url', 'target_token', 'preserve_uuids', 'adopt_mode'];
$allowedFields = ['target_url', 'target_token'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
@@ -101,59 +96,52 @@ class ServerTransferController extends Controller
}
try {
$result = $this->migrator->migrate(
$this->migrator->start(
server: $server,
targetUrl: $request->string('target_url')->toString(),
targetToken: $request->string('target_token')->toString(),
preserveUuids: $request->boolean('preserve_uuids', true),
adoptMode: $request->boolean('adopt_mode', true),
);
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
$server->refresh();
auditLog('api.server.migrate', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'export_id' => $result['export_id'],
'target_url' => $result['target_url'],
'target_url' => data_get($server->server_metadata, 'migration.target_url'),
]);
return response()->json($result);
return response()->json([
'server_uuid' => $server->uuid,
'migration' => data_get($server->server_metadata, 'migration'),
'message' => 'Transfer started. Follow it with GET /api/v1/servers/'.$server->uuid.'/transfer.',
], 202);
}
#[OA\Get(
summary: 'Export server transfer bundle',
description: 'Export a server and all resources hosted on it as a versioned transfer bundle for moving between Coolify instances. Requires read:sensitive.',
path: '/servers/{uuid}/export',
operationId: 'export-server-transfer-bundle',
summary: 'Get server transfer status',
description: 'Show the transfer state of a server on this instance: the import/claim state (transfer) and the state of a running or finished transfer to another instance (migration).',
path: '/servers/{uuid}/transfer',
operationId: 'get-server-transfer-status',
security: [['bearerAuth' => []]],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'encrypt', in: 'query', required: false, description: 'If true and passphrase is provided, return an encrypted envelope.', schema: new OA\Schema(type: 'boolean')),
new OA\Parameter(name: 'passphrase', in: 'query', required: false, description: 'Passphrase used when encrypt=true.', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Transfer bundle'),
new OA\Response(response: 200, description: 'Transfer status'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Missing sensitive permission'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function export(Request $request, string $uuid): JsonResponse
public function status(string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $this->canReadSensitive($request)) {
return response()->json([
'message' => 'Exporting a server requires a token with read:sensitive (or root) ability and an admin/owner team role.',
], 403);
}
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
@@ -161,25 +149,106 @@ class ServerTransferController extends Controller
$this->authorize('view', $server);
try {
$bundle = $this->exporter->export($server, includeSensitive: true);
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
auditLog('api.server.export', [
'team_id' => $teamId,
return response()->json([
'server_uuid' => $server->uuid,
'export_id' => data_get($bundle, 'export_id'),
'transfer' => collect((array) data_get($server->server_metadata, 'transfer', []))
->only(['status', 'export_id', 'imported_at', 'claimed_at', 'transferred_at', 'target_instance_url'])
->all(),
'migration' => data_get($server->server_metadata, 'migration'),
]);
}
if ($request->boolean('encrypt') && $request->filled('passphrase')) {
return response()->json(
ServerTransferBundle::encryptWithPassphrase($bundle, $request->string('passphrase')->toString())
);
#[OA\Get(
summary: 'Check server import',
description: 'Check that this instance can import a server, before the source instance starts the transfer. It checks the token, the bundle version, the server limit, servers with the same UUID or IP, and that this instance can reach the SSH port of the server.',
path: '/servers/import/check',
operationId: 'check-server-import',
security: [['bearerAuth' => []]],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'server_uuid', in: 'query', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'ip', in: 'query', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'port', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 22)),
new OA\Parameter(name: 'schema_version', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'system_github_apps[]', in: 'query', required: false, description: 'UUIDs of system-wide GitHub Apps that the server uses. The response lists the ones this instance does not have.', schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string'))),
new OA\Parameter(name: 'system_gitlab_apps[]', in: 'query', required: false, description: 'UUIDs of system-wide GitLab Apps that the server uses. The response lists the ones this instance does not have.', schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string'))),
],
responses: [
new OA\Response(response: 200, description: 'The server can be imported. Lists every check with its result.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, description: 'One or more checks failed. Lists every check with its result.'),
]
)]
public function check(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
return response()->json($bundle);
$this->authorize('create', Server::class);
$validator = customApiValidator($request->query(), [
'server_uuid' => 'required|string',
'ip' => ['required', 'string', new ValidServerIp],
'port' => 'nullable|integer|between:1,65535',
'schema_version' => 'nullable|integer',
'system_github_apps' => 'nullable|array|max:100',
'system_github_apps.*' => 'string|max:64',
'system_gitlab_apps' => 'nullable|array|max:100',
'system_gitlab_apps.*' => 'string|max:64',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
// System-wide git sources are not transferred. Applications re-link only to one with the same uuid here.
$missingSystemSources = [
'github' => array_values(array_diff((array) $request->query('system_github_apps', []), GithubApp::query()->where('is_system_wide', true)->whereIn('uuid', (array) $request->query('system_github_apps', []))->pluck('uuid')->all())),
'gitlab' => array_values(array_diff((array) $request->query('system_gitlab_apps', []), GitlabApp::query()->where('is_system_wide', true)->whereIn('uuid', (array) $request->query('system_gitlab_apps', []))->pluck('uuid')->all())),
];
$schemaVersion = (int) $request->query('schema_version', ServerTransferBundle::SCHEMA_VERSION);
$ip = trim((string) $request->query('ip'));
$port = (int) $request->query('port', 22);
// The target connects to the server over SSH when it takes over management, so an unreachable
// server would leave an imported server here that this instance cannot manage.
$connection = @fsockopen($ip, $port, timeout: 5);
if ($connection !== false) {
fclose($connection);
}
$checks = [
[
'label' => 'the transfer bundle version is supported',
'ok' => $schemaVersion === ServerTransferBundle::SCHEMA_VERSION,
'message' => $schemaVersion === ServerTransferBundle::SCHEMA_VERSION ? null
: 'This instance reads transfer bundle version '.ServerTransferBundle::SCHEMA_VERSION.", but the source sends version {$schemaVersion}. Upgrade both instances to the same Coolify version.",
],
...collect($this->importer->importChecks((string) $request->query('server_uuid'), $ip, $teamId))
->map(fn (array $check) => collect($check)->only(['label', 'ok', 'message'])->all())
->all(),
[
'label' => "the target can reach the SSH port of the server ({$ip}:{$port})",
'ok' => $connection !== false,
'message' => $connection !== false ? null
: "This instance cannot reach the server at {$ip}:{$port}. Allow SSH from this instance to the server, then transfer again.",
],
];
$failed = collect($checks)->where('ok', false);
return response()->json([
'message' => $failed->isEmpty()
? 'This instance can import the server.'
: $failed->pluck('message')->implode(' '),
'checks' => $checks,
'missing_system_sources' => $missingSystemSources,
], $failed->isEmpty() ? 200 : 422);
}
#[OA\Post(
@@ -193,8 +262,7 @@ class ServerTransferController extends Controller
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'bundle', type: 'object', description: 'Plain or encrypted transfer bundle'),
new OA\Property(property: 'passphrase', type: 'string', nullable: true),
new OA\Property(property: 'bundle', type: 'object', description: 'Transfer bundle'),
new OA\Property(property: 'dry_run', type: 'boolean', default: false),
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true, description: 'Import without forcing redeploy; keep statuses for adoption'),
@@ -224,12 +292,11 @@ class ServerTransferController extends Controller
$validator = customApiValidator($request->all(), [
'bundle' => 'required|array',
'passphrase' => 'string|nullable',
'dry_run' => 'boolean|nullable',
'preserve_uuids' => 'boolean|nullable',
'adopt_mode' => 'boolean|nullable',
]);
$allowedFields = ['bundle', 'passphrase', 'dry_run', 'preserve_uuids', 'adopt_mode'];
$allowedFields = ['bundle', 'dry_run', 'preserve_uuids', 'adopt_mode'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
@@ -243,34 +310,21 @@ class ServerTransferController extends Controller
], 422);
}
$bundle = $request->input('bundle', []);
if (data_get($bundle, 'encrypted')) {
if (! $request->filled('passphrase')) {
return response()->json(['message' => 'Passphrase is required for encrypted bundles.'], 422);
}
try {
$bundle = ServerTransferBundle::decryptWithPassphrase($bundle, $request->string('passphrase')->toString());
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
try {
$result = $this->importer->import(
bundle: $bundle,
bundle: $request->input('bundle', []),
teamId: $teamId,
dryRun: $request->boolean('dry_run', false),
preserveUuids: $request->boolean('preserve_uuids', true),
adoptMode: $request->boolean('adopt_mode', true),
);
} catch (Throwable $e) {
$status = $e instanceof ValidationException ? 422 : 422;
$payload = ['message' => $e->getMessage()];
if ($e instanceof ValidationException) {
$payload['errors'] = $e->errors();
}
return response()->json($payload, $status);
return response()->json($payload, 422);
}
auditLog('api.server.import', [
@@ -285,7 +339,7 @@ class ServerTransferController extends Controller
#[OA\Post(
summary: 'Mark server transferred',
description: 'Source-instance step: disable automations after a successful export/import handoff.',
description: 'Source-instance recovery step: disable automations here when the target already manages the server but the transfer could not finish on this instance.',
path: '/servers/{uuid}/transfer/complete',
operationId: 'complete-server-transfer',
security: [['bearerAuth' => []]],
@@ -350,77 +404,6 @@ class ServerTransferController extends Controller
return response()->json($result);
}
#[OA\Post(
summary: 'Write transfer bundle to server mailbox',
description: 'Write an export bundle to /data/coolify/exports on the managed host for air-gapped import.',
path: '/servers/{uuid}/export/mailbox',
operationId: 'export-server-transfer-mailbox',
security: [['bearerAuth' => []]],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'passphrase', type: 'string', nullable: true),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Mailbox write result'),
new OA\Response(response: 403, description: 'Missing sensitive permission'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function writeMailbox(Request $request, string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $this->canReadSensitive($request)) {
return response()->json([
'message' => 'Writing a transfer mailbox requires read:sensitive (or root) ability and an admin/owner team role.',
], 403);
}
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('view', $server);
try {
$bundle = $this->exporter->export($server, includeSensitive: true);
$result = $this->claimer->writeMailbox(
$server,
$bundle,
$request->filled('passphrase') ? $request->string('passphrase')->toString() : null,
);
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
auditLog('api.server.export_mailbox', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'export_id' => data_get($bundle, 'export_id'),
'path' => $result['path'],
]);
return response()->json([
'export_id' => data_get($bundle, 'export_id'),
'path' => $result['path'],
'written' => $result['written'],
'message' => $result['written']
? 'Transfer bundle written to server mailbox.'
: 'Failed to write mailbox on remote host.',
], $result['written'] ? 200 : 422);
}
private function canReadSensitive(Request $request): bool
{
return (bool) $request->attributes->get('can_read_sensitive', false);
@@ -210,6 +210,7 @@ class ServicesController extends Controller
$application->fqdn = $containerUrls;
$application->save();
updateCompose($application);
});
if (! empty($errors)) {
@@ -308,6 +309,7 @@ class ServicesController extends Controller
'destination_uuid' => ['type' => 'string', 'description' => 'Destination UUID. Required if server has multiple destinations.'],
'instant_deploy' => ['type' => 'boolean', 'default' => false, 'description' => 'Start the service immediately after creation.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The base64 encoded Docker Compose content.'],
'connect_to_docker_network' => ['type' => 'boolean', 'default' => false, 'description' => 'Connect the service to the predefined docker network.'],
'urls' => [
'type' => 'array',
'description' => 'Array of URLs to be applied to containers of a service.',
@@ -389,7 +391,7 @@ class ServicesController extends Controller
)]
public function create_service(Request $request)
{
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'redact_all_env_values_in_logs', 'tags'];
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'redact_all_env_values_in_logs', 'tags'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -417,6 +419,7 @@ class ServicesController extends Controller
'name' => 'string|max:255',
'description' => 'string|nullable',
'instant_deploy' => 'boolean',
'connect_to_docker_network' => 'boolean',
'urls' => 'array|nullable',
'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required',
@@ -546,7 +549,7 @@ class ServicesController extends Controller
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK) || $request->boolean('connect_to_docker_network')) {
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = new Service($servicePayload);
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Models\Server;
use App\Services\ContainerFilesystemService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Streams a file browser download straight from the container to the browser.
* Livewire download responses are buffered in memory and base64-encoded, so
* FileBrowser::download() issues a one-time token and redirects here instead.
*/
class FileBrowserDownloadController extends Controller
{
public const TOKEN_TTL_SECONDS = 60;
public static function cacheKey(string $token): string
{
return 'file-browser-download:'.$token;
}
public function __invoke(string $token): StreamedResponse
{
/** @var array{user_id:int,resource_type:class-string,resource_id:int,server_id:int,container:string,path:string,name:string,is_dir:bool}|null $download */
$download = Cache::pull(self::cacheKey($token));
abort_unless(is_array($download) && $download['user_id'] === auth()->id(), 404);
Gate::authorize('canAccessTerminal');
$resource = $download['resource_type']::findOrFail($download['resource_id']);
Gate::authorize('view', $resource);
abort_unless((int) $resource->environment->project->team_id === (int) auth()->user()->currentTeam()->id, 403);
$filesystem = new ContainerFilesystemService(Server::findOrFail($download['server_id']), $download['container']);
return response()->streamDownload(function () use ($filesystem, $download) {
set_time_limit(0);
$filesystem->streamDownload($download['path'], $download['is_dir'], function (string $chunk) {
echo $chunk;
if (ob_get_level() > 0) {
ob_flush();
}
flush();
});
}, $download['name'], [
'Content-Type' => 'application/octet-stream',
// Stop nginx from buffering the response to a temp file on the Coolify host.
'X-Accel-Buffering' => 'no',
]);
}
}
+7 -1
View File
@@ -11,6 +11,7 @@ use App\Http\Controllers\Webhook\Concerns\ReadsWebhookPushPayload;
use App\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Services\ServerTransfer\TransferFinishChecklist;
use Exception;
use Illuminate\Http\Request;
@@ -84,7 +85,11 @@ class Bitbucket extends Controller
$failure_key = $this->manualWebhookFailureRateLimitKey($request, 'bitbucket', $full_name, $branch);
// A redelivery of the same signed payload is one guess.
$failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_bitbucket_token);
$applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
$applications = Application::query();
if ($x_bitbucket_event !== 'pullrequest:rejected' && $x_bitbucket_event !== 'pullrequest:fulfilled') {
$applications->where('git_branch', $branch);
}
$applications = $this->manualWebhookApplications($applications, $full_name);
if ($applications->isEmpty()) {
return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt);
}
@@ -128,6 +133,7 @@ class Bitbucket extends Controller
continue;
}
TransferFinishChecklist::recordWebhook('application', $application->uuid);
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
+6 -1
View File
@@ -11,6 +11,7 @@ use App\Http\Controllers\Webhook\Concerns\ReadsWebhookPushPayload;
use App\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Services\ServerTransfer\TransferFinishChecklist;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -84,7 +85,10 @@ class Gitea extends Controller
}
}
if ($x_gitea_event === 'pull_request') {
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($action !== 'closed') {
$applications->where('git_branch', $base_branch);
}
$applications = $this->manualWebhookApplications($applications, $full_name);
if ($applications->isEmpty()) {
return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt);
}
@@ -114,6 +118,7 @@ class Gitea extends Controller
continue;
}
TransferFinishChecklist::recordWebhook('application', $application->uuid);
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
+49 -12
View File
@@ -19,6 +19,7 @@ use App\Models\GithubRunnerExecution;
use App\Models\PrivateKey;
use App\Services\GithubRunner\GithubRunnerApi;
use App\Services\GithubRunner\GithubRunnerContainer;
use App\Services\ServerTransfer\TransferFinishChecklist;
use Exception;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
@@ -71,7 +72,6 @@ class Github extends Controller
'before_sha' => $before_sha,
'after_sha' => $after_sha,
'commit_sha' => $commit_sha,
'author_association' => $author_association,
'is_fork_pull_request' => $is_fork_pull_request,
] = $this->readPullRequestPayload($payload);
$full_name = data_get($payload, 'repository.full_name');
@@ -136,6 +136,7 @@ class Github extends Controller
continue;
}
TransferFinishChecklist::recordWebhook('application', $application->uuid);
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
@@ -239,7 +240,6 @@ class Github extends Controller
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: $commit_sha,
authorAssociation: $author_association,
fullName: $full_name,
isForkPullRequest: $is_fork_pull_request,
);
@@ -400,7 +400,12 @@ class Github extends Controller
// Just pong
return response('pong');
}
$github_app = GithubApp::where('app_id', $x_github_hook_installation_target_id)->first();
// One GitHub App can be installed on several accounts, each added as its own source.
$installation_id = data_get($payload, 'installation.id');
$github_app = is_numeric($installation_id)
? GithubApp::where('app_id', $x_github_hook_installation_target_id)->where('installation_id', (int) $installation_id)->first()
: null;
$github_app ??= GithubApp::where('app_id', $x_github_hook_installation_target_id)->first();
if (is_null($github_app)) {
return response('Nothing to do. No GitHub App found.');
}
@@ -428,6 +433,7 @@ class Github extends Controller
return response('Invalid signature.');
}
}
TransferFinishChecklist::recordWebhook('github_app', $github_app->uuid);
if ($x_github_event === 'installation' || $x_github_event === 'installation_repositories') {
// Installation handled by setup redirect url. Repositories queried on-demand.
$action = data_get($payload, 'action');
@@ -459,7 +465,6 @@ class Github extends Controller
'before_sha' => $before_sha,
'after_sha' => $after_sha,
'commit_sha' => $commit_sha,
'author_association' => $author_association,
'is_fork_pull_request' => $is_fork_pull_request,
] = $this->readPullRequestPayload($payload);
$full_name = $this->manualWebhookRepositoryFullName(data_get($payload, 'repository.full_name'));
@@ -484,6 +489,9 @@ class Github extends Controller
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$branch'.");
}
if ($applications->contains(fn (Application $application): bool => filled($application->watch_paths))) {
$changed_files = $this->githubPushComparedFiles($github_app, $payload) ?? $changed_files;
}
}
if ($x_github_event === 'pull_request') {
if ($action !== 'closed') {
@@ -598,7 +606,6 @@ class Github extends Controller
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: $commit_sha,
authorAssociation: $author_association,
fullName: $full_name,
isForkPullRequest: $is_fork_pull_request,
);
@@ -620,10 +627,39 @@ class Github extends Controller
}
}
/**
* Files that the push changes between its before and after commits, from
* the GitHub compare API.
*
* The file lists of the pushed commits are not the net change: a merge
* commit names the files that it changes relative to its first parent,
* also when the push does not change them. Returns null for a new branch,
* a missing repository name or an API failure; the caller then uses the
* file lists of the pushed commits.
*
* @return Collection<int, string>|null
*
* @throws InvalidWebhookPayloadException When the before or after value is not a commit SHA.
*/
private function githubPushComparedFiles(GithubApp $githubApp, mixed $payload): ?Collection
{
$before = $this->webhookCommitSha($payload, 'before');
$after = $this->webhookCommitSha($payload, 'after');
$fullName = $this->manualWebhookRepositoryFullName(data_get($payload, 'repository.full_name'));
if ($before === null || $after === null || $fullName === null || preg_match('/\A0+\z/', $before) === 1) {
return null;
}
[$owner, $repo] = explode('/', $fullName, 2);
$files = getGithubCommitRangeFiles($githubApp, $owner, $repo, $before, $after);
return $files === null ? null : collect($files);
}
/**
* Read and validate the pull_request payload fields that the handlers use.
*
* @return array{action: string, pull_request_id: int, pull_request_html_url: string, pull_request_title: ?string, branch: ?string, base_branch: ?string, before_sha: ?string, after_sha: ?string, commit_sha: string, author_association: ?string, is_fork_pull_request: bool}
* @return array{action: string, pull_request_id: int, pull_request_html_url: string, pull_request_title: ?string, branch: ?string, base_branch: ?string, before_sha: ?string, after_sha: ?string, commit_sha: string, is_fork_pull_request: bool}
*
* @throws InvalidWebhookPayloadException When a field has a wrong type or format.
*/
@@ -641,7 +677,6 @@ class Github extends Controller
'before_sha' => $this->webhookCommitSha($payload, 'before'),
'after_sha' => $this->webhookCommitSha($payload, 'after') ?? $head_sha,
'commit_sha' => $head_sha ?? 'HEAD',
'author_association' => $this->webhookPayloadString($payload, 'pull_request.author_association'),
'is_fork_pull_request' => $this->isForkPullRequest($payload),
];
}
@@ -649,13 +684,15 @@ class Github extends Controller
/**
* Determine whether a pull_request webhook payload originates from a fork.
*
* GitHub's `author_association` is not a reliable trust signal (it grants
* CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
* detection is gated on whether the PR crosses repository boundaries.
* A same-repository PR branch can only be pushed by users with write access,
* so this is the trust signal for preview deployments. GitHub's
* `author_association` is not reliable for this: users with access through a
* team or a private organization membership arrive as CONTRIBUTOR or NONE.
*
* The repository id comparison is the canonical signal; the `head.repo.fork`
* flag and a case-insensitive full_name comparison are fallbacks for payloads
* where the ids are unavailable (e.g. a deleted head repository).
* where the ids are unavailable. A PR whose head repository is unknown (e.g.
* a deleted fork) is treated as a fork.
*
* @throws InvalidWebhookPayloadException When a repository id is not a positive integer.
*/
@@ -679,7 +716,7 @@ class Github extends Controller
return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
}
return false;
return true;
}
public function redirect(Request $request)
+39 -2
View File
@@ -13,8 +13,10 @@ use App\Livewire\Source\Gitlab\Change as GitlabSource;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GitlabApp;
use App\Services\ServerTransfer\TransferFinishChecklist;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
@@ -84,6 +86,9 @@ class Gitlab extends Controller
try {
$return_payloads = collect([]);
$payload = $request->collect();
if ($request->header('X-Gitlab-Event') === 'System Hook') {
$payload = $this->normalizeSystemHookPayload($payload);
}
$x_gitlab_token = $request->header('X-Gitlab-Token');
$object_kind = $this->webhookString(data_get($payload, 'object_kind'));
@@ -118,6 +123,7 @@ class Gitlab extends Controller
], 401);
}
TransferFinishChecklist::recordWebhook('gitlab_app', $gitlab_app->uuid);
$project_id = $this->webhookPayloadDatabaseId($payload, 'project.id', required: true);
$applications = Application::where('source_id', $gitlab_app->id)
->where('source_type', GitlabApp::class)
@@ -231,7 +237,10 @@ class Gitlab extends Controller
'skip_deploy' => $skip_deploy_pr,
] = $this->readMergeRequestPayload($payload);
$applications = $applications->where('git_branch', $base_branch)->get();
if (! in_array($action, ['closed', 'close', 'merge'])) {
$applications->where('git_branch', $base_branch);
}
$applications = $applications->get();
foreach ($applications as $application) {
if (! $application->destination->server->isFunctional()) {
@@ -440,7 +449,10 @@ class Gitlab extends Controller
}
}
if ($x_gitlab_event === 'merge_request') {
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if (! in_array($action, ['closed', 'close', 'merge'])) {
$applications->where('git_branch', $base_branch);
}
$applications = $this->manualWebhookApplications($applications, $full_name);
if ($applications->isEmpty()) {
return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt);
}
@@ -469,6 +481,7 @@ class Gitlab extends Controller
continue;
}
TransferFinishChecklist::recordWebhook('application', $application->uuid);
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
@@ -661,6 +674,30 @@ class Gitlab extends Controller
}
}
/**
* Give a system hook payload the project webhook shape. Current GitLab sends
* the project webhook data to system hooks, but the documented push payload
* has only `event_name` and a top-level `project_id`.
*
* @see https://docs.gitlab.com/administration/system_hooks/#push-events
*
* @param Collection<string, mixed> $payload
* @return Collection<string, mixed>
*/
private function normalizeSystemHookPayload(Collection $payload): Collection
{
if (! $payload->has('object_kind') && $payload->has('event_name')) {
$payload->put('object_kind', $payload->get('event_name'));
}
$project = $payload->get('project');
if (is_array($project) && ! array_key_exists('id', $project) && $payload->has('project_id')) {
$payload->put('project', ['id' => $payload->get('project_id')] + $project);
}
return $payload;
}
/**
* Read and validate the merge_request payload fields that the handlers use.
*