purge($team); foreach ($team->members()->get() as $member) { $member->clearStoredTeamIfMatches($team->id); DB::table('sessions')->where('user_id', $member->id)->delete(); } $team->members()->detach(); foreach (self::NOTIFICATION_SETTINGS_MODELS as $model) { $model::query()->where('team_id', $team->id)->delete(); } $team->delete(); return $counts; }); } /** * Deletes the records of the team but keeps the team, its memberships, and notification * settings reset to the defaults of a new team. * * @return array{servers: int, resources: int, projects: int} */ public function purge(Team $team): array { if ($team->id === 0) { throw new LogicException('The root team (id 0) is never deleted or purged.'); } return DB::transaction(function () use ($team): array { $projects = Project::query()->where('team_id', $team->id)->get(); $environmentIds = Environment::query()->whereIn('project_id', $projects->modelKeys())->pluck('id'); $servers = Server::withTrashed()->where('team_id', $team->id)->get(); $resources = $this->resources($environmentIds); $applicationIds = $resources->whereInstanceOf(Application::class)->modelKeys(); $serviceIds = $resources->whereInstanceOf(Service::class)->modelKeys(); $owners = $resources ->concat(ServiceApplication::withTrashed()->whereIn('service_id', $serviceIds)->get()) ->concat(ServiceDatabase::withTrashed()->whereIn('service_id', $serviceIds)->get()) ->concat(ApplicationPreview::withTrashed()->whereIn('application_id', $applicationIds)->get()); $this->forgetGithubRunners($team, $servers->modelKeys()); $backupIds = ScheduledDatabaseBackup::query() ->where('team_id', $team->id) ->orWhere(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'database')) ->pluck('id'); ScheduledDatabaseBackupExecution::query()->whereIn('scheduled_database_backup_id', $backupIds)->delete(); ScheduledDatabaseBackup::query()->whereKey($backupIds)->delete(); $taskIds = ScheduledTask::query() ->where('team_id', $team->id) ->orWhereIn('application_id', $applicationIds) ->orWhereIn('service_id', $serviceIds) ->pluck('id'); ScheduledTaskExecution::query()->whereIn('scheduled_task_id', $taskIds)->delete(); ScheduledTask::query()->whereKey($taskIds)->delete(); // Previews are removed with a query: their model hook removes volumes on the server. ApplicationPreview::withTrashed()->whereIn('application_id', $applicationIds)->forceDelete(); ApplicationDeploymentQueue::query() ->whereIn('application_id', array_map('strval', $applicationIds)) ->orWhereIn('server_id', $servers->modelKeys()) ->delete(); // Without references, deleting a resource does not queue the removal of its DNS records at the team's provider. ManagedDnsRecordReference::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resource'))->delete(); // Volume rows go first, so their backup schedule guards do not block the resource deletion. LocalPersistentVolume::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resource'))->delete(); LocalFileVolume::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resource'))->delete(); foreach ($resources as $resource) { $resource instanceof Service ? app(DeleteService::class)->deleteLocal($resource) : $resource->forceDelete(); } ServiceApplication::withTrashed()->whereIn('service_id', $serviceIds)->forceDelete(); ServiceDatabase::withTrashed()->whereIn('service_id', $serviceIds)->forceDelete(); EnvironmentVariable::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resourceable'))->delete(); SecretManagerLink::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resourceable'))->delete(); SslCertificate::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resource'))->delete(); DB::table('taggables')->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'taggable'))->delete(); DockerCleanupExecution::query()->whereIn('server_id', $servers->modelKeys())->delete(); foreach ($servers as $server) { $server->forceDelete(); } $iconStorage = app(ProjectIconStorageService::class); foreach ($projects as $project) { $iconStorage->deleteProject($project); $project->delete(); } // Production has cascading foreign keys for most of these; the test schema does not. $integrationTokenIds = IntegrationToken::query()->where('team_id', $team->id)->pluck('id'); SecretManagerLink::query()->whereIn('integration_token_id', $integrationTokenIds)->delete(); ManagedDnsRecord::query()->where('team_id', $team->id)->delete(); DnsProviderZone::query()->whereIn('integration_token_id', $integrationTokenIds)->delete(); IntegrationToken::query()->whereKey($integrationTokenIds)->delete(); $volumeBackupIds = ScheduledVolumeBackup::query()->where('team_id', $team->id)->pluck('id'); ScheduledVolumeBackupExecution::query()->whereIn('scheduled_volume_backup_id', $volumeBackupIds)->delete(); ScheduledVolumeBackup::query()->whereKey($volumeBackupIds)->delete(); foreach ([CloudProviderToken::class, CloudInitScript::class, SharedEnvironmentVariable::class, TeamInvitation::class, Subscription::class, ...self::NOTIFICATION_SETTINGS_MODELS] as $model) { $model::query()->where('team_id', $team->id)->delete(); } $team->deleteOwnedRecords(); $team->createDefaultNotificationSettings(); // The cached team of a member still holds the removed subscription. foreach ($team->members()->pluck('users.id') as $memberId) { Cache::forget("user:{$memberId}:team:{$team->id}"); } return [ 'servers' => $servers->count(), 'resources' => $resources->count(), 'projects' => $projects->count(), ]; }); } /** * @param Collection $environmentIds * @return EloquentCollection */ private function resources(Collection $environmentIds): Collection { return new EloquentCollection(collect([Application::class, Service::class, ...STANDALONE_DATABASE_MODELS]) ->flatMap(fn (string $model) => $model::withTrashed()->whereIn('environment_id', $environmentIds)->get()) ->all()); } /** * Matches rows of a polymorphic relation that belong to one of the owners. * * @param Collection $owners */ private function whereOwnedBy(QueryBuilder $query, Collection $owners, string $morph): void { $query->whereRaw('1 = 0'); foreach ($owners->groupBy(fn (Model $owner) => $owner->getMorphClass()) as $type => $group) { $query->orWhere(fn (QueryBuilder $query) => $query ->where("{$morph}_type", $type) ->whereIn("{$morph}_id", $group->map->getKey()->all())); } } /** * Removes the runner records of the team's servers and GitHub Apps, so that busy runners do not * block deleting them. The runners keep running on the servers; Coolify only stops tracking them. * * @param array $serverIds */ private function forgetGithubRunners(Team $team, array $serverIds): void { $githubAppIds = GithubApp::query()->where('team_id', $team->id)->where('is_system_wide', false)->pluck('id'); $belongsToTeam = fn ($query) => $query->whereIn('github_app_id', $githubAppIds)->orWhereIn('server_id', $serverIds); GithubRunnerExecution::query()->where($belongsToTeam)->delete(); GithubRunnerConfig::query()->where($belongsToTeam)->delete(); } }