$traceId, 'requested_channel' => $requestedChannel, 'frontend_version' => $frontendVersion, 'backend_version' => self::backendVersion(), 'request_started_at' => date('c'), 'original_request_uri' => (string)($_SERVER['REQUEST_URI'] ?? ''), 'normalized_request_uri' => '', 'ingress_prefix_stripped' => false, ]; $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; return $context; } public static function normalizeReleaseApiIngressPath(array $enabledChannelSlugs): array { $requestUri = (string)($_SERVER['REQUEST_URI'] ?? ''); $parts = parse_url($requestUri); $path = is_array($parts) ? (string)($parts['path'] ?? '') : ''; if ($path === '') { return []; } if (preg_match('#^/([A-Za-z0-9_-]{1,64})/api(?:/|$)(.*)$#', $path, $matches) !== 1) { return []; } $channelSlug = self::safeSlug((string)$matches[1]); $enabled = array_flip(array_values(array_filter(array_map( static fn(mixed $value): string => self::safeSlug((string)$value), $enabledChannelSlugs )))); if ($channelSlug === '' || !isset($enabled[$channelSlug])) { return []; } $suffix = (string)($matches[2] ?? ''); $normalizedPath = '/' . ltrim($suffix, '/'); if ($normalizedPath === '/') { $normalizedPath = '/'; } $query = is_array($parts) && isset($parts['query']) && $parts['query'] !== '' ? '?' . (string)$parts['query'] : ''; $normalizedUri = $normalizedPath . $query; $_SERVER['REQUEST_URI'] = $normalizedUri; $_SERVER['PATH_INFO'] = $normalizedPath; $_GET['release_channel'] = $channelSlug; $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] : self::initializeRequestContext(); $context['original_request_uri'] = $context['original_request_uri'] ?: $requestUri; $context['normalized_request_uri'] = $normalizedUri; $context['requested_channel'] = $channelSlug; $context['ingress_prefix_stripped'] = true; $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; return [ 'channel_slug' => $channelSlug, 'original_request_uri' => $requestUri, 'normalized_request_uri' => $normalizedUri, 'normalized_path' => $normalizedPath, ]; } public static function moduleKeys(): array { return self::MODULE_KEYS; } public static function backendVersion(): string { foreach (['RELEASE_VERSION', 'GITHUB_SHA', 'COMMIT_SHA', 'VITE_COMMIT_HASH'] as $key) { $value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? ''))); if ($value !== '') { return self::safeIdentifier($value, 128); } } return 'unknown'; } public static function verifyGithubSignature(string $secret, string $payload, string $signatureHeader): bool { $secret = trim($secret); $signatureHeader = trim($signatureHeader); if ($secret === '' || $signatureHeader === '' || !str_starts_with($signatureHeader, 'sha256=')) { return false; } $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret); return hash_equals($expected, $signatureHeader); } public static function normalizeGithubRepositoryName(string $value): string { $repository = trim($value); if ($repository === '') { return ''; } if (preg_match('#^git@github\.com:(.+)$#i', $repository, $matches) === 1) { $repository = $matches[1]; } elseif (preg_match('#^https?://#i', $repository) === 1) { $path = parse_url($repository, PHP_URL_PATH); $repository = is_string($path) ? ltrim($path, '/') : $repository; } else { $repository = preg_replace('#^github\.com/#i', '', $repository) ?? $repository; } $repository = preg_replace('#\.git$#i', '', $repository) ?? $repository; $repository = trim($repository, "/ \t\n\r\0\x0B"); return preg_match('/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/', $repository) === 1 ? $repository : ''; } public static function redactPayload(mixed $value, int $depth = 0): mixed { if ($depth > 8) { return '[depth-limit]'; } if (is_array($value)) { $redacted = []; $index = 0; foreach ($value as $key => $item) { $index++; if ($index > 80) { $redacted['[truncated]'] = 'More than 80 keys omitted.'; break; } $keyString = (string)$key; if (self::isSensitiveKey($keyString)) { $redacted[$key] = '[redacted]'; continue; } $redacted[$key] = self::redactPayload($item, $depth + 1); } return $redacted; } if (is_object($value)) { return self::redactPayload((array)$value, $depth + 1); } if (is_string($value)) { if (strlen($value) > 4000) { return substr($value, 0, 4000) . "\n... [truncated]"; } return $value; } return $value; } public static function deploymentCanBePromoted(string $status): bool { return in_array(strtolower(trim($status)), ['deployed'], true); } public static function deploymentPromotionBlockedReason(array $deployment): string { $status = strtolower(trim((string)($deployment['status'] ?? 'unknown'))) ?: 'unknown'; $result = self::jsonDecode($deployment['result_json'] ?? null); if ($result === [] && is_array($deployment['result'] ?? null)) { $result = $deployment['result']; } $failure = is_array($result['failure_summary'] ?? null) ? $result['failure_summary'] : []; $rootCause = trim((string)($failure['root_cause'] ?? $deployment['error_message'] ?? '')); if ($status === 'active') { return 'Deployment is already active.'; } if ($status === 'failed') { return 'Deployment failed and cannot be promoted.' . ($rootCause !== '' ? ' Cause: ' . $rootCause : ''); } return 'Only successfully deployed release deployments can be promoted. Current status: ' . $status . '.'; } public static function deploymentFailureSummary(Throwable $throwable, array $context = []): array { $message = trim($throwable->getMessage()) ?: 'Deployment failed without an error message.'; $normalized = strtolower($message); $category = 'unknown'; $stage = trim((string)($context['stage'] ?? 'deployment')) ?: 'deployment'; $nextAction = 'Open the Coolify deployment logs for the service and compare the failing commit with the last successful deployment.'; if (str_contains($normalized, 'github repository access') || str_contains($normalized, 'github api')) { $category = 'github_access'; $stage = 'source_access'; $nextAction = 'Verify the Release Manager GitHub token, repository, branch, and selected commit before deploying again.'; } elseif ( str_contains($normalized, 'coolify instance') || str_contains($normalized, 'base url') || str_contains($normalized, 'api token') ) { $category = 'coolify_connection'; $stage = 'provider_connection'; $nextAction = 'Test the configured Coolify instance and API token from Release Manager settings.'; } elseif ( str_contains($normalized, 'service uuid') || str_contains($normalized, 'select an existing coolify service') || str_contains($normalized, 'http 404') ) { $category = 'coolify_target'; $stage = 'provider_target'; $nextAction = 'Check that the saved deployment target points at the correct Coolify service UUID and instance.'; } elseif ( str_contains($normalized, 'docker_compose') || str_contains($normalized, 'explicit image') || str_contains($normalized, 'image/repository') || str_contains($normalized, 'pull access denied') || str_contains($normalized, 'manifest') || str_contains($normalized, 'denied') || str_contains($normalized, 'validation') ) { $category = 'configuration'; $stage = 'provider_configuration'; $nextAction = 'Review the deployment target image, registry access, compose payload, and required environment variables.'; } elseif (str_contains($normalized, 'health') || str_contains($normalized, 'smoke')) { $category = 'smoke_test'; $stage = 'post_deploy_smoke_test'; $nextAction = 'Check container startup logs and the configured health URL before promoting the deployment.'; } elseif ( str_contains($normalized, 'timeout') || str_contains($normalized, 'timed out') || str_contains($normalized, 'could not connect') || str_contains($normalized, 'network') ) { $category = 'network'; $stage = 'provider_connection'; $nextAction = 'Check network access from the API container to GitHub and Coolify, then retry the deployment.'; } $evidence = array_filter([ 'message' => $message, 'app' => $context['app'] ?? null, 'repository' => $context['repository'] ?? null, 'branch' => $context['branch'] ?? null, 'commit_sha' => $context['commit_sha'] ?? null, 'target_id' => $context['target_id'] ?? null, 'coolify_instance_id' => $context['coolify_instance_id'] ?? null, 'coolify_service_uuid' => $context['coolify_service_uuid'] ?? null, ], static fn(mixed $value): bool => $value !== null && $value !== ''); return [ 'category' => $category, 'stage' => $stage, 'root_cause' => $message, 'next_action' => $nextAction, 'promotion_blocked' => true, 'captured_at' => date('c'), 'evidence' => self::redactPayload($evidence), ]; } public static function recordBackendFailure(bool $success, mixed $data, ?int $status): void { if ($success || ($status !== null && $status < 400)) { return; } $uri = (string)($_SERVER['REQUEST_URI'] ?? ''); if (str_starts_with($uri, '/release/timeline/events')) { return; } try { if (!isset($GLOBALS['db']) || !release_manager_schema_bootstrap::tablesExist()) { return; } $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] : self::initializeRequestContext(); $manager = new self(); $principalContext = $manager->currentPrincipalContext(); $channel = $manager->resolveChannel($principalContext); $manager->ingestTimelineEvents([ [ 'type' => 'backend_response_failed', 'severity' => ($status ?? 500) >= 500 ? 'error' : 'warning', 'module_key' => $manager->inferModuleKeyFromUri($uri), 'route' => explode('?', $uri)[0] ?: '/', 'occurred_at' => date('c'), 'payload' => [ 'status' => $status, 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', 'response' => $data, ], ], ], [ 'trace_id' => $context['trace_id'] ?? '', 'channel_slug' => $channel['slug'] ?? '', 'principal_type' => $principalContext['principal_type'] ?? null, 'principal_id' => $principalContext['principal_id'] ?? null, 'customer_number' => $principalContext['customer_number'] ?? null, ], false); } catch (Throwable) { // Release telemetry must never block API responses. } } public function bootstrap(): array { $this->ensureSchema(); $channel = $this->defaultChannel(); $versions = $this->currentVersionsForChannel((int)$channel['id']); $urls = $this->releaseRuntimeUrls($channel, $versions); return [ 'generated_at' => date('c'), 'trace_id' => $this->requestTraceId(), 'channel' => $this->publicRuntimeChannel($channel), 'versions' => $versions, 'frontend_base_url' => $urls['frontend_base_url'], 'api_base_url' => $urls['api_base_url'], 'urls' => $urls, 'availability' => $this->channelAvailability($channel), 'capture_policy' => [ 'enabled' => false, 'capture_level' => 'metadata', 'all_failure_metadata' => true, 'retention_days' => (int)($channel['retention_days'] ?? 14), ], 'available_channels' => $this->publicRuntimeChannelOptions([$channel]), 'selected_channel_slug' => (string)($channel['slug'] ?? ''), ]; } public function runtimeForPayload(array $payload, array $input = []): array { $this->ensureSchema(); $context = [ 'principal_type' => 'user', 'principal_id' => isset($payload['id']) ? (string)$payload['id'] : null, 'customer_number' => isset($payload['customer_number']) ? (int)$payload['customer_number'] : null, ]; $resolvedChannel = $this->resolveChannel($context); $availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel); $channel = $this->chooseRuntimeChannel( $resolvedChannel, $availableChannels, $this->requestedRuntimeChannelSlug($input) ); $versions = $this->currentVersionsForChannel((int)$channel['id']); $capturePolicy = $this->capturePolicyFor($context, $channel); $urls = $this->releaseRuntimeUrls($channel, $versions); return [ 'generated_at' => date('c'), 'trace_id' => $this->requestTraceId(), 'channel' => $this->publicRuntimeChannel($channel), 'versions' => $versions, 'frontend_base_url' => $urls['frontend_base_url'], 'api_base_url' => $urls['api_base_url'], 'urls' => $urls, 'availability' => $this->channelAvailability($channel), 'capture_policy' => $capturePolicy, 'available_channels' => $this->publicRuntimeChannelOptions($availableChannels), 'selected_channel_slug' => (string)($channel['slug'] ?? ''), 'module_keys' => self::MODULE_KEYS, ]; } public function runtimeForCurrentPrincipal(array $input = []): array { $this->ensureSchema(); $context = $this->currentPrincipalContext(); $resolvedChannel = $this->resolveChannel($context); $availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel); $channel = $this->chooseRuntimeChannel( $resolvedChannel, $availableChannels, $this->requestedRuntimeChannelSlug($input) ); $versions = $this->currentVersionsForChannel((int)$channel['id']); $urls = $this->releaseRuntimeUrls($channel, $versions); return [ 'generated_at' => date('c'), 'trace_id' => $this->requestTraceId(), 'channel' => $this->publicRuntimeChannel($channel), 'versions' => $versions, 'frontend_base_url' => $urls['frontend_base_url'], 'api_base_url' => $urls['api_base_url'], 'urls' => $urls, 'availability' => $this->channelAvailability($channel), 'capture_policy' => $this->capturePolicyFor($context, $channel), 'available_channels' => $this->publicRuntimeChannelOptions($availableChannels), 'selected_channel_slug' => (string)($channel['slug'] ?? ''), 'module_keys' => self::MODULE_KEYS, ]; } public function enabledReleaseChannelSlugs(): array { $this->ensureSchema(); return array_values(array_filter(array_map( static fn(array $channel): string => self::safeSlug((string)($channel['slug'] ?? '')), $this->selectRows("SELECT slug FROM release_channels WHERE deleted_at IS NULL AND enabled = 1") ))); } public function summary(): array { $this->ensureSchema(); $this->cleanupExpiredReplayData(); $summary = [ 'generated_at' => date('c'), 'channels' => $this->listChannels(), 'assignments' => $this->listAssignments(), 'deployment_targets' => $this->listDeploymentTargets(), 'service_sets' => $this->listServiceSets(), 'bundles' => $this->listBundles(25), 'deployments' => $this->listDeployments(25), 'timeline' => $this->timelineSummary(), 'module_health' => $this->latestModuleHealth(), 'module_keys' => self::MODULE_KEYS, 'suggestions' => $this->releaseSuggestions(), ]; $summary['status_overview'] = $this->releaseStatusOverview($summary); return $summary; } public function suggestions(): array { $this->ensureSchema(); return $this->releaseSuggestions(); } private function releaseStatusOverview(array $summary): array { $channels = array_values(array_filter( is_array($summary['channels'] ?? null) ? $summary['channels'] : [], static fn(mixed $channel): bool => is_array($channel) )); $targetsByChannelApp = $this->releaseStatusTargetsByChannelApp( is_array($summary['deployment_targets'] ?? null) ? $summary['deployment_targets'] : [] ); $deployments = array_values(array_filter( is_array($summary['deployments'] ?? null) ? $summary['deployments'] : [], static fn(mixed $deployment): bool => is_array($deployment) )); $deploymentsByChannelApp = $this->releaseStatusLatestDeploymentsByChannelApp($deployments); $serviceSetsByChannel = $this->releaseStatusServiceSetsByChannel( is_array($summary['service_sets'] ?? null) ? $summary['service_sets'] : [] ); $channelRows = []; $issues = []; foreach ($channels as $channel) { $row = $this->releaseStatusChannelRow( $channel, $targetsByChannelApp, $deployments, $deploymentsByChannelApp, $serviceSetsByChannel ); $channelRows[] = $row; foreach ($row['issues'] as $issue) { $issues[] = $issue; } } usort($issues, static function (array $a, array $b): int { $rank = self::releaseStatusSeverityRank($b['severity'] ?? 'ok') <=> self::releaseStatusSeverityRank($a['severity'] ?? 'ok'); if ($rank !== 0) { return $rank; } return strcmp((string)($a['channel_slug'] ?? ''), (string)($b['channel_slug'] ?? '')); }); $affectedChannels = []; $serviceCount = 0; $unhealthyServiceCount = 0; $missingValueCount = 0; $criticalCount = 0; $warningCount = 0; foreach ($channelRows as $row) { foreach ($row['services'] as $service) { $serviceCount++; if (self::releaseStatusSeverityRank($service['severity'] ?? 'ok') > 0) { $unhealthyServiceCount++; } } } foreach ($issues as $issue) { $severity = (string)($issue['severity'] ?? 'ok'); if ($severity === 'critical') { $criticalCount++; } elseif ($severity === 'warning') { $warningCount++; } if (($issue['type'] ?? '') === 'missing_value') { $missingValueCount++; } if (self::releaseStatusSeverityRank($severity) > 0 && !empty($issue['channel_slug'])) { $affectedChannels[(string)$issue['channel_slug']] = true; } } return [ 'generated_at' => $summary['generated_at'] ?? date('c'), 'state' => $criticalCount > 0 ? 'blocked' : ($warningCount > 0 ? 'attention' : 'ready'), 'totals' => [ 'channels' => count($channelRows), 'ready_channels' => count(array_filter( $channelRows, static fn(array $row): bool => ($row['readiness'] ?? '') === 'ready' )), 'affected_channels' => count($affectedChannels), 'issues' => count($issues), 'critical' => $criticalCount, 'warning' => $warningCount, 'services' => $serviceCount, 'unhealthy_services' => $unhealthyServiceCount, 'missing_values' => $missingValueCount, ], 'issues' => $issues, 'channels' => $channelRows, ]; } private function releaseStatusChannelRow( array $channel, array $targetsByChannelApp, array $deployments, array $deploymentsByChannelApp, array $serviceSetsByChannel ): array { $channelId = (int)($channel['id'] ?? 0); $channelSlug = (string)($channel['slug'] ?? ''); $channelName = (string)($channel['name'] ?? $channelSlug); $availability = $this->releaseStatusChannelAvailability($channel); $services = $this->releaseStatusServicesForChannel( $channel, $availability, $targetsByChannelApp, $deploymentsByChannelApp, $serviceSetsByChannel ); $issues = []; $missingValues = []; foreach ($availability['missing'] as $missingKey) { $missing = [ 'key' => $missingKey, 'label' => self::releaseStatusMissingValueLabel($missingKey), 'service_key' => self::releaseStatusMissingServiceKey($missingKey), 'target_tab' => self::releaseStatusMissingTargetTab($missingKey), ]; $missingValues[] = $missing; $issues[] = self::releaseStatusIssue([ 'severity' => 'critical', 'type' => 'missing_value', 'channel_id' => $channelId, 'channel_slug' => $channelSlug, 'service_key' => $missing['service_key'], 'label' => $missing['label'], 'message' => $channelName . ' is missing ' . $missing['label'] . '.', 'next_action' => self::releaseStatusMissingNextAction($missingKey), 'target_tab' => $missing['target_tab'], 'missing_key' => $missingKey, ]); } foreach ($services as $service) { if (self::releaseStatusSeverityRank($service['severity'] ?? 'ok') === 0 || empty($service['issue_type'])) { continue; } $issues[] = self::releaseStatusIssue([ 'severity' => (string)$service['severity'], 'type' => (string)$service['issue_type'], 'channel_id' => $channelId, 'channel_slug' => $channelSlug, 'service_key' => (string)$service['service_key'], 'label' => (string)$service['label'], 'message' => (string)$service['message'], 'next_action' => (string)$service['next_action'], 'target_tab' => (string)$service['target_tab'], 'target_id' => $service['target_id'] ?? null, 'deployment_id' => $service['deployment_id'] ?? null, 'coolify_target_id' => $service['coolify_target_id'] ?? null, 'missing_key' => $service['missing_key'] ?? null, ]); } $severity = 'ok'; foreach ($issues as $issue) { $severity = self::releaseStatusMaxSeverity($severity, (string)($issue['severity'] ?? 'ok')); } $readiness = $severity === 'critical' ? 'blocked' : ($severity === 'warning' ? 'attention' : 'ready'); $latestDeployments = array_slice(array_values(array_filter( $deployments, static fn(array $deployment): bool => (int)($deployment['channel_id'] ?? 0) === $channelId )), 0, 5); return [ 'channel_id' => $channelId, 'channel_slug' => $channelSlug, 'channel_name' => $channelName, 'default_channel' => (bool)($channel['default_channel'] ?? false), 'enabled' => (bool)($channel['enabled'] ?? true), 'severity' => $severity, 'readiness' => $readiness, 'message' => self::releaseStatusChannelMessage($readiness, count($issues)), 'availability' => $availability, 'missing_values' => $missingValues, 'services' => $services, 'versions' => is_array($channel['versions'] ?? null) ? $channel['versions'] : [], 'replay' => [ 'enabled' => (bool)($channel['replay_enabled'] ?? false), 'capture_level' => (string)($channel['capture_level'] ?? 'metadata'), ], 'latest_deployments' => $latestDeployments, 'issues' => $issues, ]; } private function releaseStatusServicesForChannel( array $channel, array $availability, array $targetsByChannelApp, array $deploymentsByChannelApp, array $serviceSetsByChannel ): array { $channelId = (int)($channel['id'] ?? 0); $versions = is_array($channel['versions'] ?? null) ? $channel['versions'] : []; $serviceSet = is_array($versions['service_set'] ?? null) ? $versions['service_set'] : ($serviceSetsByChannel[$channelId][0] ?? null); $missingLookup = array_fill_keys($availability['missing'] ?? [], true); $services = []; foreach (self::APPS as $app) { $key = $channelId . ':' . $app; $services[] = $this->releaseStatusAppServiceRow( $app, $channel, is_array($versions[$app] ?? null) ? $versions[$app] : null, is_array($targetsByChannelApp[$key] ?? null) ? $targetsByChannelApp[$key] : null, is_array($deploymentsByChannelApp[$key] ?? null) ? $deploymentsByChannelApp[$key] : null, $missingLookup ); } foreach (self::STACK_DATA_KINDS as $kind) { $services[] = $this->releaseStatusDataServiceRow($kind, $channel, is_array($serviceSet) ? $serviceSet : null); } return $services; } private function releaseStatusAppServiceRow( string $app, array $channel, ?array $version, ?array $target, ?array $deployment, array $missingLookup ): array { $serviceLabel = self::releaseStatusServiceLabel($app); $missingKeys = $app === 'frontend' ? ['frontend_version', 'frontend_base_url'] : ['api_version', 'api_base_url']; $missingKey = null; foreach ($missingKeys as $key) { if (isset($missingLookup[$key])) { $missingKey = $key; break; } } $row = [ 'service_key' => $app, 'label' => $serviceLabel, 'status' => (string)($deployment['status'] ?? $version['status'] ?? 'ready'), 'state' => 'ready', 'severity' => 'ok', 'message' => $serviceLabel . ' release service is ready.', 'next_action' => '', 'target_tab' => 'overview', 'target_id' => $target['id'] ?? null, 'deployment_id' => $deployment['id'] ?? null, 'version_label' => $version['version_label'] ?? null, 'commit_sha' => $version['commit_sha'] ?? $deployment['commit_sha'] ?? null, 'repository' => $target['repository'] ?? $deployment['repository'] ?? $version['repository'] ?? null, 'branch' => $target['branch'] ?? $deployment['branch'] ?? $version['branch'] ?? null, 'health_url' => $target['health_url'] ?? null, 'issue_type' => null, ]; $deploymentStatus = strtolower((string)($deployment['status'] ?? '')); if (in_array($deploymentStatus, ['failed', 'error'], true)) { return array_replace($row, [ 'state' => 'failed', 'severity' => 'critical', 'message' => trim((string)( $deployment['failure_summary']['root_cause'] ?? $deployment['error_message'] ?? ($serviceLabel . ' deployment failed.') )), 'next_action' => trim((string)( $deployment['failure_summary']['next_action'] ?? 'Open the deployment details and fix the failing release before promotion.' )), 'target_tab' => 'deployments', 'issue_type' => 'failed_deployment', ]); } if (in_array($deploymentStatus, ['queued', 'running', 'deploying', 'building', 'pending'], true)) { return array_replace($row, [ 'state' => 'deployment_in_progress', 'severity' => 'warning', 'message' => $serviceLabel . ' deployment is still in progress.', 'next_action' => 'Wait for the deployment to finish, then refresh Release Manager.', 'target_tab' => 'deployments', 'issue_type' => 'deployment_in_progress', ]); } if ($missingKey !== null) { return array_replace($row, [ 'state' => 'missing_value', 'status' => 'missing', 'severity' => 'critical', 'message' => $serviceLabel . ' is missing ' . self::releaseStatusMissingValueLabel($missingKey) . '.', 'next_action' => self::releaseStatusMissingNextAction($missingKey), 'target_tab' => self::releaseStatusMissingTargetTab($missingKey), 'missing_key' => $missingKey, ]); } $isDefaultChannel = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable'; if (!$isDefaultChannel && is_array($target) && trim((string)($target['coolify_service_uuid'] ?? '')) === '') { return array_replace($row, [ 'state' => 'stale_unknown', 'status' => 'missing_coolify_service', 'severity' => 'warning', 'message' => $serviceLabel . ' target is missing its Coolify service UUID.', 'next_action' => 'Open Integrations and connect or create the Coolify service.', 'target_tab' => 'integrations', 'issue_type' => 'stale_unknown', 'missing_key' => $app . '_coolify_service_uuid', ]); } return $row; } private function releaseStatusDataServiceRow(string $kind, array $channel, ?array $serviceSet): array { $serviceLabel = self::releaseStatusServiceLabel($kind); $isDefaultChannel = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable'; $mode = (string)($serviceSet['mode'] ?? ''); $stack = is_array($serviceSet['stack'] ?? null) ? $serviceSet['stack'] : []; $dataServices = is_array($serviceSet['data_services'] ?? null) ? $serviceSet['data_services'] : []; $service = is_array($stack[$kind] ?? null) ? $stack[$kind] : (is_array($dataServices[$kind] ?? null) ? $dataServices[$kind] : null); $row = [ 'service_key' => $kind, 'label' => $serviceLabel, 'status' => 'ready', 'state' => 'ready', 'severity' => 'ok', 'message' => $serviceLabel . ' release service is ready.', 'next_action' => '', 'target_tab' => 'bundles', 'coolify_target_id' => $service['id'] ?? null, 'resource_uuid' => $service['resource_uuid'] ?? null, 'resource_name' => $service['resource_name'] ?? $service['label'] ?? null, 'issue_type' => null, ]; if ($serviceSet === null) { return $isDefaultChannel ? $row : array_replace($row, [ 'status' => 'not_configured', 'state' => 'stale_unknown', 'severity' => 'warning', 'message' => 'No service set is selected for this channel.', 'next_action' => 'Create or assign a release bundle with a service set.', 'issue_type' => 'stale_unknown', ]); } if ($service === null) { $critical = $mode === 'isolated_stack'; return array_replace($row, [ 'status' => 'missing', 'state' => 'missing_value', 'severity' => $critical ? 'critical' : 'warning', 'message' => $serviceLabel . ' is not assigned to this service set.', 'next_action' => $critical ? 'Add the missing isolated data service before deploying or promoting this bundle.' : 'Review the service set and attach the data service if this channel needs isolated data.', 'issue_type' => 'missing_value', 'missing_key' => $kind . '_service', ]); } $deploymentStatus = strtolower((string)($service['deployment_status'] ?? '')); $availabilityState = strtolower((string)($service['availability_state'] ?? '')); $replication = is_array($service['replication'] ?? null) ? $service['replication'] : []; $replicationLastStatus = is_array($replication['last_status'] ?? null) ? $replication['last_status'] : []; $replicationStatus = strtolower((string)($replicationLastStatus['status'] ?? $replication['status'] ?? '')); $blockers = array_values(array_filter( is_array($replicationLastStatus['blockers'] ?? null) ? $replicationLastStatus['blockers'] : [] )); $row['status'] = $deploymentStatus ?: ($availabilityState ?: ($replicationStatus ?: 'ready')); if ( in_array($deploymentStatus, ['failed', 'reconcile_failed', 'restart_failed', 'provision_blocked'], true) || in_array($availabilityState, ['degraded', 'failover_blocked', 'destructive_action_required'], true) ) { return array_replace($row, [ 'state' => 'service_unhealthy', 'severity' => 'critical', 'message' => $serviceLabel . ' Coolify target is unhealthy.', 'next_action' => 'Open Bundles or Integrations and inspect the Coolify target before promotion.', 'issue_type' => 'service_unhealthy', ]); } if (in_array($deploymentStatus, ['created', 'deploying', 'restarting', 'waiting_for_coolify', 'provisioning'], true)) { return array_replace($row, [ 'state' => 'deployment_in_progress', 'severity' => 'warning', 'message' => $serviceLabel . ' service provisioning is still in progress.', 'next_action' => 'Wait for Coolify provisioning to finish, then refresh Release Manager.', 'issue_type' => 'deployment_in_progress', ]); } if ($replicationStatus !== '' && !in_array($replicationStatus, ['ok', 'ready', 'protected', 'healthy'], true)) { return array_replace($row, [ 'state' => 'service_unhealthy', 'severity' => $blockers === [] ? 'warning' : 'critical', 'message' => $blockers[0] ?? ($serviceLabel . ' replication is not healthy.'), 'next_action' => 'Check replication status before promoting this release bundle.', 'issue_type' => 'service_unhealthy', ]); } return $row; } private function releaseStatusTargetsByChannelApp(array $targets): array { $indexed = []; foreach ($targets as $target) { if (!is_array($target)) { continue; } $channelId = (int)($target['channel_id'] ?? 0); $app = (string)($target['app'] ?? ''); if ($channelId > 0 && in_array($app, self::APPS, true)) { $indexed[$channelId . ':' . $app] = $target; } } return $indexed; } private function releaseStatusLatestDeploymentsByChannelApp(array $deployments): array { $indexed = []; foreach ($deployments as $deployment) { $channelId = (int)($deployment['channel_id'] ?? 0); $app = (string)($deployment['app'] ?? ''); $key = $channelId . ':' . $app; if ($channelId > 0 && in_array($app, self::APPS, true) && !isset($indexed[$key])) { $indexed[$key] = $deployment; } } return $indexed; } private function releaseStatusServiceSetsByChannel(array $serviceSets): array { $indexed = []; foreach ($serviceSets as $set) { if (!is_array($set)) { continue; } $channelId = (int)($set['channel_id'] ?? 0); if ($channelId > 0) { $indexed[$channelId][] = $set; } } return $indexed; } private function releaseStatusChannelAvailability(array $channel): array { if (is_array($channel['availability'] ?? null)) { $availability = $channel['availability']; $missing = array_values(array_filter( is_array($availability['missing'] ?? null) ? $availability['missing'] : [], static fn(mixed $key): bool => trim((string)$key) !== '' )); return [ 'configured' => ($availability['configured'] ?? null) === null ? $missing === [] : (bool)$availability['configured'], 'missing' => array_values(array_map('strval', $missing)), 'status' => (string)($availability['status'] ?? ($missing === [] ? 'ready' : 'unconfigured')), 'bundle_id' => $availability['bundle_id'] ?? null, 'frontend_base_url' => $availability['frontend_base_url'] ?? $channel['frontend_base_url'] ?? null, 'api_base_url' => $availability['api_base_url'] ?? $channel['api_base_url'] ?? null, ]; } $isDefault = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable'; if ($isDefault) { return [ 'configured' => true, 'missing' => [], 'status' => 'ready', 'frontend_base_url' => $channel['frontend_base_url'] ?? null, 'api_base_url' => $channel['api_base_url'] ?? null, ]; } $versions = is_array($channel['versions'] ?? null) ? $channel['versions'] : []; $missing = []; if (empty($versions['bundle_id'])) { $missing[] = 'release_bundle'; } if (empty($versions['frontend'])) { $missing[] = 'frontend_version'; } elseif (empty($channel['frontend_base_url'])) { $missing[] = 'frontend_base_url'; } if (empty($versions['api'])) { $missing[] = 'api_version'; } elseif (empty($channel['api_base_url'])) { $missing[] = 'api_base_url'; } return [ 'configured' => $missing === [], 'missing' => $missing, 'status' => $missing === [] ? 'ready' : 'unconfigured', 'bundle_id' => $versions['bundle_id'] ?? null, 'frontend_base_url' => $channel['frontend_base_url'] ?? null, 'api_base_url' => $channel['api_base_url'] ?? null, ]; } private static function releaseStatusIssue(array $issue): array { return [ 'severity' => (string)($issue['severity'] ?? 'warning'), 'type' => (string)($issue['type'] ?? 'stale_unknown'), 'channel_id' => isset($issue['channel_id']) ? (int)$issue['channel_id'] : null, 'channel_slug' => (string)($issue['channel_slug'] ?? ''), 'service_key' => $issue['service_key'] ?? null, 'label' => (string)($issue['label'] ?? ''), 'message' => (string)($issue['message'] ?? ''), 'next_action' => (string)($issue['next_action'] ?? ''), 'target_tab' => (string)($issue['target_tab'] ?? 'overview'), 'target_id' => $issue['target_id'] ?? null, 'deployment_id' => $issue['deployment_id'] ?? null, 'coolify_target_id' => $issue['coolify_target_id'] ?? null, 'missing_key' => $issue['missing_key'] ?? null, ]; } private static function releaseStatusSeverityRank(string $severity): int { return match ($severity) { 'critical' => 2, 'warning' => 1, default => 0, }; } private static function releaseStatusMaxSeverity(string $a, string $b): string { return self::releaseStatusSeverityRank($b) > self::releaseStatusSeverityRank($a) ? $b : $a; } private static function releaseStatusChannelMessage(string $readiness, int $issueCount): string { if ($readiness === 'ready') { return 'All release services are ready.'; } if ($readiness === 'blocked') { return $issueCount . ' blocker' . ($issueCount === 1 ? '' : 's') . ' need attention before promotion.'; } return $issueCount . ' warning' . ($issueCount === 1 ? '' : 's') . ' should be reviewed.'; } private static function releaseStatusServiceLabel(string $service): string { return match ($service) { 'frontend' => 'Frontend', 'api' => 'API', 'database' => 'Database', 'redis' => 'Redis', 'minio' => 'MinIO', default => ucfirst(str_replace('_', ' ', $service)), }; } private static function releaseStatusMissingValueLabel(string $key): string { return match ($key) { 'release_bundle' => 'release bundle', 'frontend_version' => 'frontend version', 'frontend_base_url' => 'frontend URL', 'api_version' => 'API version', 'api_base_url' => 'API URL', 'database_service' => 'database service', 'redis_service' => 'Redis service', 'minio_service' => 'MinIO service', default => str_replace('_', ' ', $key), }; } private static function releaseStatusMissingServiceKey(string $key): ?string { return match ($key) { 'frontend_version', 'frontend_base_url', 'frontend_coolify_service_uuid' => 'frontend', 'api_version', 'api_base_url', 'api_coolify_service_uuid' => 'api', 'database_service' => 'database', 'redis_service' => 'redis', 'minio_service' => 'minio', default => null, }; } private static function releaseStatusMissingTargetTab(string $key): string { return match ($key) { 'release_bundle', 'database_service', 'redis_service', 'minio_service' => 'bundles', 'frontend_version', 'api_version' => 'deployments', 'frontend_base_url', 'api_base_url', 'frontend_coolify_service_uuid', 'api_coolify_service_uuid' => 'integrations', default => 'overview', }; } private static function releaseStatusMissingNextAction(string $key): string { return match ($key) { 'release_bundle' => 'Create or deploy a release bundle, then attach it to the channel.', 'frontend_version', 'api_version' => 'Deploy the missing application version for this channel.', 'frontend_base_url', 'api_base_url' => 'Set the public release URL from the deployment target or channel configuration.', 'database_service', 'redis_service', 'minio_service' => 'Add the missing data service to the isolated service set.', default => 'Open Release Manager details and complete the missing value.', }; } public function releaseConfig(): array { $this->ensureSchema(); $storedToken = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_token', '')); $storedWebhookSecret = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_webhook_secret', '')); return [ 'github_token_configured' => $this->hasGithubApiToken(), 'github_token_env_configured' => $this->githubEnvToken() !== '', 'github_token_module_configured' => $storedToken !== '', 'github_token_variable' => 'ReleaseManager.github_token', 'github_token_env_variable' => 'RELEASE_MANAGER_GITHUB_TOKEN', 'github_api_url' => $this->githubApiBaseUrl(), 'github_api_url_variable' => 'ReleaseManager.github_api_url', 'github_webhook_secret_configured' => $storedWebhookSecret !== '', 'github_webhook_secret_variable' => 'ReleaseManager.github_webhook_secret', ]; } public function updateReleaseConfig(array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $updated = []; if (array_key_exists('github_api_url', $input)) { $apiUrl = rtrim(trim((string)$input['github_api_url']), '/'); if ($apiUrl === '') { $apiUrl = 'https://api.github.com'; } if (preg_match('#^https?://#i', $apiUrl) !== 1) { throw new RuntimeException('GitHub API URL must start with http:// or https://.'); } $this->upsertModuleConfigValue('ReleaseManager', 'github_api_url', $apiUrl, 'string'); $updated[] = 'github_api_url'; } if (array_key_exists('github_token', $input)) { $token = trim((string)$input['github_token']); if ($token !== '' && $token !== '[redacted]' && !str_starts_with($token, 'twsec:v1:') && class_exists(replication_secret_box::class)) { $token = replication_secret_box::encrypt($token); } if ($token !== '' && $token !== '[redacted]') { $this->upsertModuleConfigValue('ReleaseManager', 'github_token', $token, 'string'); $updated[] = 'github_token'; } } if ($this->toBool($input['clear_github_token'] ?? false)) { $this->upsertModuleConfigValue('ReleaseManager', 'github_token', '', 'string'); $updated[] = 'github_token'; } if (array_key_exists('github_webhook_secret', $input)) { $secret = trim((string)$input['github_webhook_secret']); if ($secret !== '' && $secret !== '[redacted]') { $this->upsertModuleConfigValue('ReleaseManager', 'github_webhook_secret', $secret, 'string'); $updated[] = 'github_webhook_secret'; } } $this->audit(null, null, 'release_config_updated', $actorUserId, 'info', [ 'updated' => array_values(array_unique($updated)), ]); return $this->releaseConfig(); } public function listGithubRepositories(array $filters = []): array { $this->ensureSchema(); if (!$this->hasGithubApiToken()) { return $this->githubTokenMissingResponse(); } $query = strtolower(trim((string)($filters['query'] ?? $filters['search'] ?? ''))); $repositories = []; for ($page = 1; $page <= 5; $page++) { $rows = $this->githubRequest('GET', '/user/repos', [ 'visibility' => 'all', 'affiliation' => 'owner,collaborator,organization_member', 'sort' => 'updated', 'direction' => 'desc', 'per_page' => 100, 'page' => $page, ]); if (!is_array($rows)) { break; } foreach ($rows as $row) { if (!is_array($row)) { continue; } $repository = $this->publicGithubRepository($row); if ($query !== '') { $haystack = strtolower(($repository['full_name'] ?? '') . ' ' . ($repository['description'] ?? '')); if (!str_contains($haystack, $query)) { continue; } } $repositories[$repository['full_name']] = $repository; } if (count($rows) < 100) { break; } } return [ 'token_configured' => true, 'github_api_url' => $this->githubApiBaseUrl(), 'repositories' => array_values($repositories), ]; } public function listGithubBranches(array $input): array { $this->ensureSchema(); $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? '')); if ($repository === '') { throw new RuntimeException('GitHub repository must use owner/repo format.'); } if (!$this->hasGithubApiToken()) { return $this->githubTokenMissingResponse($repository); } $branches = []; for ($page = 1; $page <= 5; $page++) { $rows = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/branches', [ 'per_page' => 100, 'page' => $page, ]); if (!is_array($rows)) { break; } foreach ($rows as $row) { if (is_array($row)) { $branch = $this->publicGithubBranch($row); $branches[$branch['name']] = $branch; } } if (count($rows) < 100) { break; } } return [ 'token_configured' => true, 'repository' => $repository, 'branches' => array_values($branches), ]; } public function listGithubCommits(array $input): array { $this->ensureSchema(); $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? '')); if ($repository === '') { throw new RuntimeException('GitHub repository must use owner/repo format.'); } if (!$this->hasGithubApiToken()) { return $this->githubTokenMissingResponse($repository, (string)($input['branch'] ?? '')); } $branch = trim((string)($input['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; $commit = trim((string)($input['commit_sha'] ?? $input['commit'] ?? '')); $query = strtolower(trim((string)($input['query'] ?? $input['search'] ?? ''))); if ($commit !== '' && !in_array(strtolower($commit), ['latest', 'head'], true)) { $row = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($commit)); $publicCommit = is_array($row) ? $this->publicGithubCommit($row) : []; return [ 'token_configured' => true, 'repository' => $repository, 'branch' => $branch, 'commits' => $publicCommit !== [] ? [$publicCommit] : [], 'latest' => $publicCommit !== [] ? $publicCommit : null, ]; } $rows = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits', [ 'sha' => $branch, 'per_page' => 25, ]); $commits = []; foreach (is_array($rows) ? $rows : [] as $row) { if (!is_array($row)) { continue; } $publicCommit = $this->publicGithubCommit($row); if ($query !== '') { $haystack = strtolower(($publicCommit['sha'] ?? '') . ' ' . ($publicCommit['message'] ?? '') . ' ' . ($publicCommit['author_name'] ?? '')); if (!str_contains($haystack, $query)) { continue; } } $commits[] = $publicCommit; } return [ 'token_configured' => true, 'repository' => $repository, 'branch' => $branch, 'commits' => $commits, 'latest' => $commits[0] ?? null, ]; } public function testGithubRepositoryAccess(array $input): array { $this->ensureSchema(); return $this->githubRepositoryAccess($input); } public function listChannels(): array { if (!release_manager_schema_bootstrap::tablesExist()) { return []; } $channels = $this->selectRows( "SELECT * FROM release_channels WHERE deleted_at IS NULL ORDER BY default_channel DESC, slug" ); return array_map(function (array $channel): array { $public = $this->publicChannel($channel); $public['versions'] = $this->currentVersionsForChannel((int)$channel['id']); return $public; }, $channels); } public function createChannel(array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $normalized = $this->normalizeChannelInput($input, true); $this->execute( "INSERT INTO release_channels ( slug, name, description, enabled, default_channel, rollout_percent, frontend_base_url, api_base_url, replay_enabled, capture_level, retention_days, metadata_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 'sssiidssisis', [ $normalized['slug'], $normalized['name'], $normalized['description'], $normalized['enabled'], $normalized['default_channel'], $normalized['rollout_percent'], $normalized['frontend_base_url'], $normalized['api_base_url'], $normalized['replay_enabled'], $normalized['capture_level'], $normalized['retention_days'], self::jsonEncode($normalized['metadata']), ] ); $id = $this->insertId(); if ($normalized['default_channel'] === 1) { $this->clearOtherDefaultChannels($id); } $this->audit($id, null, 'channel_created', $actorUserId, 'info', $normalized); return $this->publicChannel($this->getChannel($id)); } public function updateChannel(int $id, array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $channel = $this->getChannel($id); $normalized = $this->normalizeChannelInput(array_replace($channel, $input), false); $this->execute( "UPDATE release_channels SET slug = ?, name = ?, description = ?, enabled = ?, default_channel = ?, rollout_percent = ?, frontend_base_url = ?, api_base_url = ?, replay_enabled = ?, capture_level = ?, retention_days = ?, metadata_json = ? WHERE id = ?", 'sssiidssisisi', [ $normalized['slug'], $normalized['name'], $normalized['description'], $normalized['enabled'], $normalized['default_channel'], $normalized['rollout_percent'], $normalized['frontend_base_url'], $normalized['api_base_url'], $normalized['replay_enabled'], $normalized['capture_level'], $normalized['retention_days'], self::jsonEncode($normalized['metadata']), $id, ] ); if ($normalized['default_channel'] === 1) { $this->clearOtherDefaultChannels($id); } $this->audit($id, null, 'channel_updated', $actorUserId, 'info', $normalized); return $this->publicChannel($this->getChannel($id)); } public function listAssignments(): array { if (!release_manager_schema_bootstrap::tablesExist()) { return []; } return array_map( fn(array $row): array => $this->publicAssignment($row), $this->selectRows( "SELECT a.*, c.slug AS channel_slug, c.name AS channel_name FROM release_assignments a INNER JOIN release_channels c ON c.id = a.channel_id WHERE a.deleted_at IS NULL AND (a.expires_at IS NULL OR a.expires_at > NOW()) ORDER BY a.created_at DESC LIMIT 250" ) ); } public function searchAssignmentSubjects(array $input): array { $query = self::normalizeAssignmentSubjectSearch($input['search'] ?? $input['query'] ?? ''); if ($query === '') { return []; } $limit = self::normalizeAssignmentSubjectLimit($input['limit'] ?? 5); $subjects = array_merge( $this->searchAssignmentUsers($query, $limit), $this->searchAssignmentSubusers($query, $limit), $this->searchAssignmentCustomers($query, $limit) ); $seen = []; $normalized = []; foreach ($subjects as $subject) { $item = self::publicAssignmentSubjectSuggestion($subject); if ($item === null) { continue; } $key = $item['subject_type'] . ':' . $item['subject_id']; if (isset($seen[$key])) { continue; } $seen[$key] = true; $normalized[] = $item; } return $normalized; } public function createAssignment(array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $subjectType = strtolower(trim((string)($input['subject_type'] ?? ''))); if (!in_array($subjectType, self::SUBJECT_TYPES, true)) { throw new RuntimeException('Invalid release assignment subject type.'); } $subjectId = trim((string)($input['subject_id'] ?? '')); if ($subjectId === '') { throw new RuntimeException('Release assignment subject_id is required.'); } $channel = $this->channelFromInput($input); $reason = trim((string)($input['reason'] ?? '')); $expiresAt = $this->normalizeDateTime($input['expires_at'] ?? null); $this->execute( "INSERT INTO release_assignments (subject_type, subject_id, channel_id, reason, expires_at, actor_user_id) VALUES (?, ?, ?, ?, ?, ?)", 'ssissi', [$subjectType, $subjectId, (int)$channel['id'], $reason !== '' ? $reason : null, $expiresAt, $actorUserId] ); $id = $this->insertId(); $this->clearAssignmentCache($subjectType, $subjectId); $this->audit((int)$channel['id'], null, 'assignment_created', $actorUserId, 'info', [ 'subject_type' => $subjectType, 'subject_id' => $subjectId, 'channel_slug' => $channel['slug'], ]); $row = $this->selectOne( "SELECT a.*, c.slug AS channel_slug, c.name AS channel_name FROM release_assignments a INNER JOIN release_channels c ON c.id = a.channel_id WHERE a.id = ?", 'i', [$id] ); return $this->publicAssignment($row ?? []); } public function deleteAssignment(int $id, ?int $actorUserId = null): array { $this->ensureSchema(); $assignment = $this->selectOne('SELECT * FROM release_assignments WHERE id = ? AND deleted_at IS NULL', 'i', [$id]); if ($assignment === null) { throw new RuntimeException('Release assignment not found.'); } $this->execute('UPDATE release_assignments SET deleted_at = NOW() WHERE id = ?', 'i', [$id]); $this->clearAssignmentCache((string)$assignment['subject_type'], (string)$assignment['subject_id']); $this->audit((int)$assignment['channel_id'], null, 'assignment_deleted', $actorUserId, 'info', [ 'assignment_id' => $id, ]); return ['deleted' => true, 'id' => $id]; } public function listDeploymentTargets(): array { if (!release_manager_schema_bootstrap::tablesExist()) { return []; } return array_map( fn(array $row): array => $this->publicDeploymentTarget($row), $this->selectRows( "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label FROM release_deployment_targets t INNER JOIN release_channels c ON c.id = t.channel_id LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id WHERE t.deleted_at IS NULL ORDER BY c.slug, FIELD(t.app, 'frontend', 'api'), t.repository" ) ); } public function upsertDeploymentTarget(array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $id = (int)($input['id'] ?? 0); $channel = $this->channelFromInput($input); $app = $this->normalizeApp((string)($input['app'] ?? '')); $repository = trim((string)($input['repository'] ?? '')); $branch = trim((string)($input['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; if ($repository === '') { throw new RuntimeException('Repository is required for release deployment targets.'); } $normalizedRepository = self::normalizeGithubRepositoryName($repository); if ($normalizedRepository !== '') { $repository = $normalizedRepository; } $githubAccess = $this->githubRepositoryAccess([ 'repository' => $repository, 'branch' => $branch, 'commit_mode' => 'latest', ]); if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) { throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.')); } $deployContext = is_array($input['deploy_context'] ?? null) ? $input['deploy_context'] : []; foreach (['coolify_auto_create', 'coolify_enable_ssl', 'coolify_deploy_now'] as $key) { if (array_key_exists($key, $input)) { $deployContext[$key] = $this->toBool($input[$key]); } } foreach (['coolify_domain', 'coolify_public_url', 'coolify_url_name'] as $key) { if (array_key_exists($key, $input)) { $deployContext[$key] = trim((string)$input[$key]); } } foreach ([ 'coolify_project_uuid', 'project_uuid', 'coolify_environment_uuid', 'environment_uuid', 'coolify_environment_name', 'environment_name', 'coolify_github_app_uuid', 'github_app_uuid', 'coolify_git_app_uuid', 'git_app_uuid', 'coolify_build_pack', 'build_pack', ] as $key) { if (array_key_exists($key, $input)) { $deployContext[$key] = trim((string)$input[$key]); } } $payload = [ 'channel_id' => (int)$channel['id'], 'app' => $app, 'coolify_instance_id' => $this->nullablePositiveInt($input['coolify_instance_id'] ?? null), 'coolify_service_uuid' => trim((string)($input['coolify_service_uuid'] ?? '')) ?: null, 'repository' => $repository, 'branch' => $branch, 'auto_deploy' => $this->toBool($input['auto_deploy'] ?? true) ? 1 : 0, 'health_url' => trim((string)($input['health_url'] ?? '')) ?: null, 'deploy_context' => $deployContext, ]; if ($id > 0) { $this->execute( "UPDATE release_deployment_targets SET channel_id = ?, app = ?, coolify_instance_id = ?, coolify_service_uuid = ?, repository = ?, branch = ?, auto_deploy = ?, health_url = ?, deploy_context_json = ? WHERE id = ? AND deleted_at IS NULL", 'isisssissi', [ $payload['channel_id'], $payload['app'], $payload['coolify_instance_id'], $payload['coolify_service_uuid'], $payload['repository'], $payload['branch'], $payload['auto_deploy'], $payload['health_url'], self::jsonEncode($payload['deploy_context']), $id, ] ); $targetId = $id; $action = 'deployment_target_updated'; } else { $this->execute( "INSERT INTO release_deployment_targets ( channel_id, app, coolify_instance_id, coolify_service_uuid, repository, branch, auto_deploy, health_url, deploy_context_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", 'isisssiss', [ $payload['channel_id'], $payload['app'], $payload['coolify_instance_id'], $payload['coolify_service_uuid'], $payload['repository'], $payload['branch'], $payload['auto_deploy'], $payload['health_url'], self::jsonEncode($payload['deploy_context']), ] ); $targetId = $this->insertId(); $action = 'deployment_target_created'; } $this->audit((int)$channel['id'], null, $action, $actorUserId, 'info', $payload + [ 'github_access' => $githubAccess, ]); return $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)); } public function deleteDeploymentTarget(int $id, ?int $actorUserId = null): array { $this->ensureSchema(); $target = $this->getDeploymentTarget($id); $this->execute('UPDATE release_deployment_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$id]); $this->audit((int)$target['channel_id'], null, 'deployment_target_deleted', $actorUserId, 'warning', [ 'target_id' => $id, ]); return ['deleted' => true, 'id' => $id]; } public function listServiceSets(): array { if (!release_manager_schema_bootstrap::tablesExist()) { return []; } return array_map( fn(array $row): array => $this->publicServiceSet($row), $this->selectRows( "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name FROM release_service_sets s LEFT JOIN release_channels c ON c.id = s.channel_id WHERE s.deleted_at IS NULL ORDER BY s.updated_at DESC, s.created_at DESC, s.id DESC" ) ); } public function createServiceSet(array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $mode = $this->normalizeServiceSetMode((string)($input['mode'] ?? $input['dataset_mode'] ?? 'attach_existing')); $sourceId = $this->nullablePositiveInt($input['source_service_set_id'] ?? $input['source_id'] ?? null); if ($mode === 'isolated_stack') { $sourceId = null; } $source = $sourceId !== null ? $this->getServiceSet($sourceId) : null; $channel = $this->channelFromInputOrDefault($input, $source); $frontendTargetId = $this->serviceSetTargetIdFromInput($input, 'frontend', $source); $apiTargetId = $this->serviceSetTargetIdFromInput($input, 'api', $source); $dataTargets = []; foreach (self::STACK_DATA_KINDS as $kind) { $dataTargets[$kind] = $this->serviceSetDataTargetIdFromInput($input, $kind, $source); } $name = trim((string)($input['name'] ?? '')); if ($name === '') { $name = $source !== null ? sprintf('%s %s', (string)($source['name'] ?? 'Release service set'), str_replace('_', ' ', $mode)) : sprintf('%s service set', ucfirst(str_replace('_', ' ', $mode))); } if ($mode === 'isolated_stack') { $this->assertIsolatedStackTarget($frontendTargetId, 'frontend'); $this->assertIsolatedStackTarget($apiTargetId, 'api'); $createDataTargets = $this->toBool( $input['create_data_targets'] ?? $input['create_isolated_data_targets'] ?? $input['create_empty_data_targets'] ?? true ); foreach (self::STACK_DATA_KINDS as $kind) { if ($dataTargets[$kind] !== null) { $this->assertIsolatedStackDataTarget($dataTargets[$kind], $kind); continue; } if ($createDataTargets) { $dataTargets[$kind] = $this->createIsolatedStackDataTarget( $kind, $input, $channel, $name, $frontendTargetId, $apiTargetId, $actorUserId ); } } } if (!in_array($mode, ['fresh_empty', 'isolated_stack'], true) && $source === null && $frontendTargetId === null && $apiTargetId === null) { throw new RuntimeException('Select an existing release deployment or target before creating a reusable service set.'); } $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null); $metadata['dataset_mode'] = $mode; $metadata['source_service_set_id'] = $sourceId; $metadata['replica_integration'] = $this->replicaProvisioningPlan($mode, $source, $dataTargets); if ($mode === 'fresh_empty') { $metadata['isolated_empty_services'] = true; $metadata['production_replication_attached'] = false; } if ($mode === 'isolated_stack') { $metadata['isolated_stack'] = true; $metadata['isolated_empty_services'] = true; $metadata['production_replication_attached'] = false; $metadata['production_code_targets_attached'] = false; } $slug = $this->uniqueServiceSetSlug(self::safeSlug((string)($input['slug'] ?? $name))); $status = $this->serviceSetStatus($mode, $frontendTargetId, $apiTargetId, $dataTargets); $stackComplete = $frontendTargetId !== null && $apiTargetId !== null && !in_array(null, $dataTargets, true); $health = [ 'status' => $status, 'stack_complete' => $stackComplete, 'checked_at' => date('c'), ]; $this->execute( "INSERT INTO release_service_sets ( channel_id, name, slug, mode, source_service_set_id, frontend_target_id, api_target_id, database_coolify_target_id, redis_coolify_target_id, minio_coolify_target_id, status, health_json, metadata_json, actor_user_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 'isssiiiiiisssi', [ (int)$channel['id'], substr($name, 0, 128), $slug, $mode, $sourceId, $frontendTargetId, $apiTargetId, $dataTargets['database'], $dataTargets['redis'], $dataTargets['minio'], $status, self::jsonEncode($health), self::jsonEncode($metadata), $actorUserId, ] ); $id = $this->insertId(); $this->audit((int)$channel['id'], null, 'service_set_created', $actorUserId, 'info', [ 'service_set_id' => $id, 'mode' => $mode, 'source_service_set_id' => $sourceId, 'data_targets' => $dataTargets, ]); return $this->publicServiceSet($this->getServiceSet($id)); } public function deleteServiceSet(int $id, array $input = [], ?int $actorUserId = null): array { $this->ensureSchema(); $serviceSet = $this->getServiceSet($id); if ((string)($serviceSet['mode'] ?? '') !== 'isolated_stack') { throw new RuntimeException('Only isolated stack service sets can be removed from Release Manager.'); } if ($this->serviceSetIsActive($id)) { throw new RuntimeException('The active release service set cannot be removed.'); } $frontendTargetId = $this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null); $apiTargetId = $this->nullablePositiveInt($serviceSet['api_target_id'] ?? null); $dataTargetIds = []; foreach (self::STACK_DATA_KINDS as $kind) { $dataTargetIds[$kind] = $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null); } $this->execute( "UPDATE release_bundles SET status = 'removed', deleted_at = NOW() WHERE service_set_id = ? AND deleted_at IS NULL", 'i', [$id] ); $this->execute( "UPDATE release_deployments SET status = 'removed' WHERE service_set_id = ?", 'i', [$id] ); $this->execute( "UPDATE release_service_sets SET status = 'removed', deleted_at = NOW(), actor_user_id = ? WHERE id = ?", 'ii', [$actorUserId, $id] ); foreach ([$frontendTargetId, $apiTargetId] as $targetId) { if ($this->isolatedDeploymentTargetCanBeForgotten($targetId, $id)) { $this->execute('UPDATE release_deployment_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]); } } foreach ($dataTargetIds as $targetId) { if ($this->isolatedCoolifyTargetCanBeForgotten($targetId, $id)) { $this->execute('UPDATE coolify_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]); } } $this->audit((int)$serviceSet['channel_id'], null, 'service_set_removed', $actorUserId, 'warning', [ 'service_set_id' => $id, 'mode' => 'isolated_stack', 'provider_resources_deleted' => false, 'frontend_target_id' => $frontendTargetId, 'api_target_id' => $apiTargetId, 'data_target_ids' => $dataTargetIds, ]); return [ 'id' => $id, 'removed' => true, 'provider_resources_deleted' => false, ]; } public function completeIsolatedStackDataServices(int $serviceSetId, array $input = [], ?int $actorUserId = null): array { $this->ensureSchema(); $serviceSet = $this->getServiceSet($serviceSetId); if ((string)($serviceSet['mode'] ?? '') !== 'isolated_stack') { throw new RuntimeException('Only isolated stack service sets can create isolated data services.'); } $frontendTargetId = $this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null); $apiTargetId = $this->nullablePositiveInt($serviceSet['api_target_id'] ?? null); $this->assertIsolatedStackTarget($frontendTargetId, 'frontend', true); $this->assertIsolatedStackTarget($apiTargetId, 'api', true); $channel = $this->getChannel((int)$serviceSet['channel_id']); $name = trim((string)($input['name'] ?? $serviceSet['name'] ?? 'Isolated stack')); $dataTargets = []; foreach (self::STACK_DATA_KINDS as $kind) { $dataTargets[$kind] = $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null); if ($dataTargets[$kind] !== null) { $this->assertIsolatedStackDataTarget($dataTargets[$kind], $kind); continue; } $dataTargets[$kind] = $this->createIsolatedStackDataTarget( $kind, $input, $channel, $name, $frontendTargetId, $apiTargetId, $actorUserId ); } $metadata = self::jsonDecode($serviceSet['metadata_json'] ?? null); $metadata['dataset_mode'] = 'isolated_stack'; $metadata['isolated_stack'] = true; $metadata['isolated_empty_services'] = true; $metadata['production_replication_attached'] = false; $metadata['production_code_targets_attached'] = false; $metadata['replica_integration'] = $this->replicaProvisioningPlan('isolated_stack', null, $dataTargets); $status = $this->serviceSetStatus('isolated_stack', $frontendTargetId, $apiTargetId, $dataTargets); $health = [ 'status' => $status, 'stack_complete' => $frontendTargetId !== null && $apiTargetId !== null && !in_array(null, $dataTargets, true), 'checked_at' => date('c'), ]; $this->execute( "UPDATE release_service_sets SET database_coolify_target_id = ?, redis_coolify_target_id = ?, minio_coolify_target_id = ?, status = ?, health_json = ?, metadata_json = ?, actor_user_id = ? WHERE id = ?", 'iiisssii', [ $dataTargets['database'], $dataTargets['redis'], $dataTargets['minio'], $status, self::jsonEncode($health), self::jsonEncode($metadata), $actorUserId, $serviceSetId, ] ); $this->audit((int)$channel['id'], null, 'isolated_stack_data_services_created', $actorUserId, 'info', [ 'service_set_id' => $serviceSetId, 'data_targets' => $dataTargets, ]); return $this->publicServiceSet($this->getServiceSet($serviceSetId)); } public function listBundles(int $limit = 50): array { if (!release_manager_schema_bootstrap::tablesExist()) { return []; } $limit = max(1, min(250, $limit)); return array_map( fn(array $row): array => $this->publicBundle($row), $this->selectRows( "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug FROM release_bundles b INNER JOIN release_channels c ON c.id = b.channel_id INNER JOIN release_service_sets s ON s.id = b.service_set_id WHERE b.deleted_at IS NULL ORDER BY b.created_at DESC, b.id DESC LIMIT $limit" ) ); } public function createBundle(array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $serviceSetId = $this->nullablePositiveInt($input['service_set_id'] ?? null); if ($serviceSetId === null) { throw new RuntimeException('A release service set is required before creating a bundle.'); } $serviceSet = $this->getServiceSet($serviceSetId); $channel = $this->channelFromInputOrDefault($input, $serviceSet); $versionLabel = trim((string)($input['version_label'] ?? '')); if ($versionLabel === '') { $versionLabel = sprintf('%s-bundle-%s', (string)($channel['slug'] ?? 'release'), date('Ymd-His')); } $frontendTarget = $this->nullableDeploymentTarget((int)($serviceSet['frontend_target_id'] ?? 0) ?: null); $apiTarget = $this->nullableDeploymentTarget((int)($serviceSet['api_target_id'] ?? 0) ?: null); $frontend = $this->bundleAppInput($input, 'frontend', $frontendTarget, $versionLabel); $api = $this->bundleAppInput($input, 'api', $apiTarget, $versionLabel); $frontendVersionId = $this->createBundleVersion($frontend, 'frontend'); $apiVersionId = $this->createBundleVersion($api, 'api'); $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null); $metadata['service_set_id'] = $serviceSetId; $metadata['stack_services'] = array_merge(['frontend', 'api'], self::STACK_DATA_KINDS); $metadata['promotion_policy'] = 'attach_code_and_service_set_only'; $this->execute( "INSERT INTO release_bundles ( channel_id, service_set_id, version_label, frontend_version_id, api_version_id, frontend_repository, frontend_branch, frontend_commit_sha, api_repository, api_branch, api_commit_sha, metadata_json, actor_user_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 'iisiisssssssi', [ (int)$channel['id'], $serviceSetId, $versionLabel, $frontendVersionId, $apiVersionId, $frontend['repository'], $frontend['branch'], $frontend['commit_sha'], $api['repository'], $api['branch'], $api['commit_sha'], self::jsonEncode($metadata), $actorUserId, ] ); $id = $this->insertId(); $this->audit((int)$channel['id'], null, 'bundle_created', $actorUserId, 'info', [ 'bundle_id' => $id, 'service_set_id' => $serviceSetId, 'frontend_repository' => $frontend['repository'], 'api_repository' => $api['repository'], ]); return $this->publicBundle($this->getBundle($id)); } public function deployBundle(int $bundleId, ?int $actorUserId = null): array { $this->ensureSchema(); $bundle = $this->getBundle($bundleId); $serviceSet = $this->getServiceSet((int)$bundle['service_set_id']); $results = []; $deploymentIds = ['frontend' => null, 'api' => null]; foreach (['frontend', 'api'] as $app) { $versionId = $this->nullablePositiveInt($bundle[$app . '_version_id'] ?? null); if ($versionId === null) { $results[$app] = ['status' => 'skipped', 'message' => 'No release version is attached to this app.']; continue; } $deployment = $this->startDeployment([ 'channel_id' => (int)$bundle['channel_id'], 'target_id' => $this->nullablePositiveInt($serviceSet[$app . '_target_id'] ?? null), 'version_id' => $versionId, 'service_set_id' => (int)$bundle['service_set_id'], 'bundle_id' => $bundleId, 'deployment_kind' => 'bundle_member', 'app' => $app, 'repository' => $bundle[$app . '_repository'] ?? '', 'branch' => $bundle[$app . '_branch'] ?? self::DEFAULT_BRANCH, 'commit_mode' => trim((string)($bundle[$app . '_commit_sha'] ?? '')) !== '' ? 'specific' : 'latest', 'commit_sha' => $bundle[$app . '_commit_sha'] ?? '', 'version_label' => $bundle['version_label'] ?? null, ], $actorUserId); $deploymentIds[$app] = (int)($deployment['id'] ?? 0) ?: null; $results[$app] = $deployment; } $statuses = array_map(static fn(array $result): string => strtolower((string)($result['status'] ?? 'unknown')), $results); $status = in_array('failed', $statuses, true) ? 'failed' : (count(array_intersect($statuses, ['queued', 'deploying', 'unknown', 'skipped'])) > 0 ? 'deploying' : 'deployed'); $this->execute( "UPDATE release_bundles SET status = ?, frontend_deployment_id = ?, api_deployment_id = ?, deployment_result_json = ?, deployed_at = CASE WHEN ? IN ('deployed', 'deploying') THEN NOW() ELSE deployed_at END WHERE id = ?", 'siissi', [ $status, $deploymentIds['frontend'], $deploymentIds['api'], self::jsonEncode(self::redactPayload($results)), $status, $bundleId, ] ); $this->audit((int)$bundle['channel_id'], null, 'bundle_deployed', $actorUserId, $status === 'failed' ? 'error' : 'info', [ 'bundle_id' => $bundleId, 'service_set_id' => (int)$bundle['service_set_id'], 'status' => $status, ]); return $this->publicBundle($this->getBundle($bundleId)); } public function promoteBundle(int $bundleId, ?int $actorUserId = null): array { $this->ensureSchema(); $bundle = $this->getBundle($bundleId); $status = strtolower((string)($bundle['status'] ?? '')); if (!in_array($status, ['deployed', 'active', 'promoted'], true)) { throw new RuntimeException('Only deployed release bundles can be promoted.'); } $channelId = (int)$bundle['channel_id']; $frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null); $apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null); $deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null) ?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null); $serviceSetId = (int)$bundle['service_set_id']; $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]); $this->execute( "UPDATE release_bundles SET status = 'superseded' WHERE channel_id = ? AND id <> ? AND status = 'promoted' AND deleted_at IS NULL", 'ii', [$channelId, $bundleId] ); $this->execute( "UPDATE release_deployments SET status = 'superseded' WHERE channel_id = ? AND bundle_id IS NOT NULL AND bundle_id <> ? AND status = 'active'", 'ii', [$channelId, $bundleId] ); $this->execute( "INSERT INTO release_channel_versions ( channel_id, frontend_version_id, api_version_id, deployment_id, service_set_id, bundle_id, actor_user_id, active ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)", 'iiiiiii', [$channelId, $frontendVersionId, $apiVersionId, $deploymentId, $serviceSetId, $bundleId, $actorUserId] ); $this->execute( "UPDATE release_bundles SET status = 'promoted', promoted_at = NOW() WHERE id = ?", 'i', [$bundleId] ); $this->execute( "UPDATE release_deployments SET status = 'active', completed_at = COALESCE(completed_at, NOW()) WHERE bundle_id = ? AND status IN ('deployed', 'queued', 'deploying')", 'i', [$bundleId] ); foreach ([$frontendVersionId, $apiVersionId] as $versionId) { if ($versionId !== null) { $this->execute( "UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?", 'i', [$versionId] ); } } $this->audit($channelId, $deploymentId, 'bundle_promoted', $actorUserId, 'info', [ 'bundle_id' => $bundleId, 'service_set_id' => $serviceSetId, 'data_promotion' => false, 'replica_failover' => false, ]); return [ 'channel' => $this->publicChannel($this->getChannel($channelId)), 'versions' => $this->currentVersionsForChannel($channelId), 'service_set' => $this->publicServiceSet($this->getServiceSet($serviceSetId)), 'bundle' => $this->publicBundle($this->getBundle($bundleId)), ]; } public function setChannelBundle(int $channelId, array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $channel = $this->getChannel($channelId); $bundleId = $this->nullablePositiveInt($input['bundle_id'] ?? null); if ($bundleId === null) { throw new RuntimeException('A release bundle is required.'); } $bundle = $this->getBundle($bundleId); if ((int)$bundle['channel_id'] !== (int)$channel['id']) { throw new RuntimeException('Release bundle does not belong to this channel.'); } $status = strtolower((string)($bundle['status'] ?? '')); if (!in_array($status, ['deployed', 'promoted', 'active'], true)) { throw new RuntimeException('Only deployed release bundles can be set on a channel.'); } $previous = $this->currentChannelVersionRow($channelId); $result = $this->promoteBundle($bundleId, $actorUserId); $this->audit($channelId, null, 'channel_bundle_set', $actorUserId, 'info', [ 'bundle_id' => $bundleId, 'previous_bundle_id' => $this->nullablePositiveInt($previous['bundle_id'] ?? null), ]); return $result; } public function listDeployments(int $limit = 50): array { if (!release_manager_schema_bootstrap::tablesExist()) { return []; } $limit = max(1, min(250, $limit)); return array_map( fn(array $row): array => $this->publicDeployment($row), $this->selectRows( "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url FROM release_deployments d INNER JOIN release_channels c ON c.id = d.channel_id LEFT JOIN release_versions v ON v.id = d.version_id ORDER BY d.created_at DESC LIMIT $limit" ) ); } public function startDeployment(array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $channel = $this->channelFromInput($input); $app = $this->normalizeApp((string)($input['app'] ?? '')); $target = $this->deploymentTargetFromInput($input, (int)$channel['id'], $app); $repository = trim((string)($input['repository'] ?? $target['repository'] ?? '')); $branch = trim((string)($input['branch'] ?? $target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; $normalizedRepository = self::normalizeGithubRepositoryName($repository); if ($normalizedRepository !== '') { $repository = $normalizedRepository; } $rawCommitSha = trim((string)($input['commit_sha'] ?? $input['commit'] ?? '')); $commitMode = $this->normalizeCommitMode((string)($input['commit_mode'] ?? ''), $rawCommitSha); $commitSha = $commitMode === 'specific' && $rawCommitSha !== '' ? $rawCommitSha : null; $githubAccess = null; if ($repository !== '') { $githubAccess = $this->githubRepositoryAccess([ 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'commit_mode' => $commitMode, ]); if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) { throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.')); } if (($githubAccess['ok'] ?? false) && !empty($githubAccess['commit_sha'])) { $commitSha = (string)$githubAccess['commit_sha']; $branch = (string)($githubAccess['branch'] ?? $branch); } } $versionLabel = trim((string)($input['version_label'] ?? $input['tag'] ?? $commitSha ?? date('Ymd-His'))) ?: date('Ymd-His'); $targetPublicUrl = is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null; $deployedUrl = $this->normalizeReleasePublicBaseUrl( $input['deployed_url'] ?? $targetPublicUrl ?? $target['health_url'] ?? null, $app ); $versionId = $this->nullablePositiveInt($input['version_id'] ?? null); if ($versionId !== null) { $version = $this->getVersion($versionId); if ((string)($version['app'] ?? '') !== $app) { throw new RuntimeException('Release bundle version does not match the deployment app.'); } $this->execute( "UPDATE release_versions SET repository = COALESCE(NULLIF(?, ''), repository), branch = COALESCE(NULLIF(?, ''), branch), commit_sha = COALESCE(?, commit_sha), version_label = COALESCE(NULLIF(?, ''), version_label), deployed_url = COALESCE(?, deployed_url), status = 'deploying' WHERE id = ?", 'sssssi', [$repository, $branch, $commitSha, $versionLabel, $deployedUrl, $versionId] ); } else { $versionId = $this->createVersion([ 'app' => $app, 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'tag' => trim((string)($input['tag'] ?? '')) ?: null, 'version_label' => $versionLabel, 'build_url' => trim((string)($input['build_url'] ?? '')) ?: null, 'artifact_url' => trim((string)($input['artifact_url'] ?? '')) ?: null, 'deployed_url' => $deployedUrl, 'status' => 'deploying', 'metadata' => is_array($input['metadata'] ?? null) ? $input['metadata'] : [], ]); } $requestedPayload = self::redactPayload($input); if (is_array($requestedPayload)) { $requestedPayload['commit_mode'] = $commitMode; $requestedPayload['github_access'] = $githubAccess; } $targetId = isset($target['id']) ? (int)$target['id'] : null; $serviceSetId = $this->nullablePositiveInt($input['service_set_id'] ?? null); $bundleId = $this->nullablePositiveInt($input['bundle_id'] ?? null); $deploymentKind = self::safeIdentifier((string)($input['deployment_kind'] ?? 'single_app'), 32) ?: 'single_app'; $this->execute( "INSERT INTO release_deployments ( channel_id, target_id, version_id, service_set_id, bundle_id, deployment_kind, app, provider, repository, branch, commit_sha, status, actor_user_id, requested_payload_json, started_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 'coolify', ?, ?, ?, 'deploying', ?, ?, NOW())", 'iiiiisssssis', [ (int)$channel['id'], $targetId, $versionId, $serviceSetId, $bundleId, $deploymentKind, $app, $repository, $branch, $commitSha, $actorUserId, self::jsonEncode($requestedPayload), ] ); $deploymentId = $this->insertId(); try { $result = ['message' => 'Deployment recorded; no Coolify service target is configured.']; $status = 'queued'; if ($target !== null && !empty($target['coolify_instance_id'])) { $coolifyTarget = array_replace($target, [ 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha ?? '', ]); $result = $this->deployCoolifyReleaseTarget($coolifyTarget); $status = 'deployed'; } $effectiveDeployedUrl = $this->normalizeReleasePublicBaseUrl($result['public_url'] ?? $deployedUrl, $app); $this->execute( "UPDATE release_deployments SET status = ?, result_json = ?, deployment_url = ?, completed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE NULL END WHERE id = ?", 'ssssi', [$status, self::jsonEncode(self::redactPayload($result)), $effectiveDeployedUrl, $status, $deploymentId] ); $this->execute( "UPDATE release_versions SET status = ?, deployed_url = COALESCE(?, deployed_url), deployed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE deployed_at END WHERE id = ?", 'sssi', [$status === 'deployed' ? 'deployed' : 'deploying', $effectiveDeployedUrl, $status, $versionId] ); $this->audit((int)$channel['id'], $deploymentId, 'deployment_started', $actorUserId, 'info', [ 'app' => $app, 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'commit_mode' => $commitMode, 'github_access_status' => $githubAccess['status'] ?? null, 'status' => $status, ]); } catch (Throwable $throwable) { $failureSummary = self::deploymentFailureSummary($throwable, [ 'app' => $app, 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'target_id' => $targetId, 'coolify_instance_id' => is_array($target) ? ($target['coolify_instance_id'] ?? null) : null, 'coolify_service_uuid' => is_array($target) ? ($target['coolify_service_uuid'] ?? null) : null, ]); $failureResult = [ 'message' => 'Deployment failed before promotion. A successful deployment is required before promotion.', 'failure_summary' => $failureSummary, ]; $this->execute( "UPDATE release_deployments SET status = 'failed', result_json = ?, error_message = ?, completed_at = NOW() WHERE id = ?", 'ssi', [self::jsonEncode($failureResult), $throwable->getMessage(), $deploymentId] ); $this->execute("UPDATE release_versions SET status = 'failed' WHERE id = ?", 'i', [$versionId]); $this->audit((int)$channel['id'], $deploymentId, 'deployment_failed', $actorUserId, 'error', [ 'error' => $throwable->getMessage(), 'failure_summary' => $failureSummary, 'app' => $app, ]); } return $this->publicDeployment($this->getDeployment($deploymentId)); } public function promoteDeployment(int $deploymentId, ?int $actorUserId = null): array { $this->ensureSchema(); $deployment = $this->getDeployment($deploymentId); if (!self::deploymentCanBePromoted((string)($deployment['status'] ?? ''))) { throw new RuntimeException(self::deploymentPromotionBlockedReason($deployment)); } $versionId = (int)($deployment['version_id'] ?? 0); if ($versionId <= 0) { throw new RuntimeException('Deployment has no release version to promote.'); } $channelId = (int)$deployment['channel_id']; $current = $this->currentChannelVersionRow($channelId); $frontendVersionId = (int)($current['frontend_version_id'] ?? 0) ?: null; $apiVersionId = (int)($current['api_version_id'] ?? 0) ?: null; if ((string)$deployment['app'] === 'frontend') { $frontendVersionId = $versionId; } else { $apiVersionId = $versionId; } $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]); $this->execute( "INSERT INTO release_channel_versions (channel_id, frontend_version_id, api_version_id, deployment_id, actor_user_id, active) VALUES (?, ?, ?, ?, ?, 1)", 'iiiii', [$channelId, $frontendVersionId, $apiVersionId, $deploymentId, $actorUserId] ); $this->execute("UPDATE release_deployments SET status = 'active', completed_at = COALESCE(completed_at, NOW()) WHERE id = ?", 'i', [$deploymentId]); $this->execute("UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?", 'i', [$versionId]); $this->audit($channelId, $deploymentId, 'deployment_promoted', $actorUserId, 'info', [ 'app' => $deployment['app'], 'version_id' => $versionId, ]); return [ 'channel' => $this->publicChannel($this->getChannel($channelId)), 'versions' => $this->currentVersionsForChannel($channelId), 'deployment' => $this->publicDeployment($this->getDeployment($deploymentId)), ]; } public function rollbackChannel(int $channelId, ?int $actorUserId = null): array { $this->ensureSchema(); $channel = $this->getChannel($channelId); $previous = $this->selectOne( "SELECT * FROM release_channel_versions WHERE channel_id = ? AND active = 0 ORDER BY activated_at DESC, id DESC LIMIT 1", 'i', [$channelId] ); if ($previous === null) { throw new RuntimeException('No previous release version exists for this channel.'); } $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]); $this->execute( "INSERT INTO release_channel_versions (channel_id, frontend_version_id, api_version_id, deployment_id, actor_user_id, active) VALUES (?, ?, ?, ?, ?, 1)", 'iiiii', [ $channelId, (int)($previous['frontend_version_id'] ?? 0) ?: null, (int)($previous['api_version_id'] ?? 0) ?: null, (int)($previous['deployment_id'] ?? 0) ?: null, $actorUserId, ] ); $this->audit($channelId, (int)($previous['deployment_id'] ?? 0) ?: null, 'channel_rolled_back', $actorUserId, 'warning', [ 'previous_channel_version_id' => $previous['id'] ?? null, ]); return [ 'channel' => $this->publicChannel($channel), 'versions' => $this->currentVersionsForChannel($channelId), ]; } public function setReplayTarget(array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $targetType = strtolower(trim((string)($input['target_type'] ?? ''))); if (!in_array($targetType, ['user', 'subuser', 'customer', 'channel'], true)) { throw new RuntimeException('Invalid replay target type.'); } $targetId = trim((string)($input['target_id'] ?? '')) ?: null; $channel = null; if ($targetType === 'channel' || isset($input['channel_id']) || isset($input['channel_slug'])) { $channel = $this->channelFromInput($input); $targetId = $targetId ?: (string)$channel['slug']; } $captureLevel = $this->normalizeCaptureLevel((string)($input['capture_level'] ?? 'full_redacted')); $enabled = $this->toBool($input['enabled'] ?? true) ? 1 : 0; $expiresAt = $this->normalizeDateTime($input['expires_at'] ?? null); $this->execute( "INSERT INTO release_replay_targets (target_type, target_id, channel_id, capture_level, enabled, expires_at, actor_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)", 'ssisisi', [$targetType, $targetId, $channel['id'] ?? null, $captureLevel, $enabled, $expiresAt, $actorUserId] ); $id = $this->insertId(); $this->audit($channel !== null ? (int)$channel['id'] : null, null, 'replay_target_created', $actorUserId, 'warning', [ 'target_type' => $targetType, 'target_id' => $targetId, 'capture_level' => $captureLevel, 'enabled' => (bool)$enabled, ]); return $this->selectOne('SELECT * FROM release_replay_targets WHERE id = ?', 'i', [$id]) ?? []; } public function ingestTimelineEvents(array $events, array $context = [], bool $ensureSchema = true): array { if ($ensureSchema) { $this->ensureSchema(); } if ($events === [] || !isset($events[0])) { $events = [$events]; } $traceId = self::safeIdentifier((string)($context['trace_id'] ?? $this->requestTraceId()), 64); if ($traceId === '') { $traceId = $this->requestTraceId(); } $principalContext = $this->currentPrincipalContext(); $context = array_replace($principalContext, array_filter($context, static fn(mixed $value): bool => $value !== null && $value !== '')); $channelSlug = self::safeSlug((string)($context['channel_slug'] ?? $context['release_channel'] ?? '')); $channel = $channelSlug !== '' ? $this->findChannelBySlug($channelSlug) : null; if ($channel === null) { $channel = $this->resolveChannel($context); } if (empty($context['route_path']) && empty($context['route'])) { foreach (array_reverse($events) as $eventForRoute) { if (!is_array($eventForRoute)) { continue; } $route = trim((string)($eventForRoute['route_path'] ?? $eventForRoute['route'] ?? '')); if ($route !== '') { $context['route_path'] = $route; break; } } } $sessionId = $this->timelineSessionId($traceId, $context, $channel); $accepted = 0; foreach ($events as $event) { if (!is_array($event)) { continue; } $eventType = self::safeIdentifier((string)($event['type'] ?? $event['event_type'] ?? 'event'), 64) ?: 'event'; $severity = self::safeIdentifier((string)($event['severity'] ?? 'info'), 16) ?: 'info'; $payload = self::redactPayload($event['payload'] ?? $event); $occurredAt = $this->normalizeDateTime($event['occurred_at'] ?? null) ?? date('Y-m-d H:i:s'); $this->execute( "INSERT INTO release_timeline_events ( timeline_session_id, trace_id, event_type, severity, module_key, route_path, component, request_id, occurred_at, payload_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 'isssssssss', [ $sessionId, $traceId, $eventType, $severity, self::safeIdentifier((string)($event['module_key'] ?? ''), 64) ?: null, trim((string)($event['route'] ?? $event['route_path'] ?? '')) ?: null, trim((string)($event['component'] ?? '')) ?: null, trim((string)($event['request_id'] ?? '')) ?: null, $occurredAt, self::jsonEncode($payload), ] ); $accepted++; } return ['accepted' => $accepted, 'trace_id' => $traceId, 'timeline_session_id' => $sessionId]; } public function searchTimeline(array $filters = []): array { $this->ensureSchema(); $this->cleanupExpiredReplayData(); $types = ''; $params = []; $where = ['1 = 1']; foreach ([ 'trace_id' => 'e.trace_id', 'event_type' => 'e.event_type', 'severity' => 'e.severity', 'module_key' => 'e.module_key', 'channel_slug' => 's.channel_slug', 'principal_type' => 's.principal_type', 'principal_id' => 's.principal_id', ] as $filterKey => $column) { $value = trim((string)($filters[$filterKey] ?? '')); if ($value === '') { continue; } $where[] = "$column = ?"; $types .= 's'; $params[] = $value; } if (isset($filters['customer_number']) && is_numeric($filters['customer_number'])) { $where[] = 's.customer_number = ?'; $types .= 'i'; $params[] = (int)$filters['customer_number']; } $limit = max(1, min(500, (int)($filters['limit'] ?? 100))); $rows = $this->selectRows( "SELECT e.*, s.principal_type, s.principal_id, s.customer_number, s.channel_slug FROM release_timeline_events e LEFT JOIN release_timeline_sessions s ON s.id = e.timeline_session_id WHERE " . implode(' AND ', $where) . " ORDER BY e.occurred_at DESC, e.id DESC LIMIT $limit", $types, $params ); return array_map(fn(array $row): array => $this->publicTimelineEvent($row), $rows); } public function listTimelineSessions(array $filters = []): array { $this->ensureSchema(); $this->cleanupExpiredReplayData(); $types = ''; $params = []; $where = ['1 = 1']; foreach ([ 'trace_id' => 's.trace_id', 'principal_type' => 's.principal_type', 'principal_id' => 's.principal_id', 'device_type' => 's.device_type', 'channel_slug' => 's.channel_slug', 'frontend_version' => 's.frontend_version_label', 'api_version' => 's.api_version_label', 'event_type' => 'e.event_type', 'severity' => 'e.severity', 'module_key' => 'e.module_key', ] as $filterKey => $column) { $value = trim((string)($filters[$filterKey] ?? '')); if ($value === '') { continue; } $where[] = "$column = ?"; $types .= 's'; $params[] = $value; } if (isset($filters['customer_number']) && is_numeric($filters['customer_number'])) { $where[] = 's.customer_number = ?'; $types .= 'i'; $params[] = (int)$filters['customer_number']; } foreach (['date_from' => '>=', 'date_to' => '<='] as $filterKey => $operator) { $date = $this->normalizeDateTime($filters[$filterKey] ?? null); if ($date === null) { continue; } $where[] = "s.last_seen_at $operator ?"; $types .= 's'; $params[] = $date; } $hasErrorReport = $this->toBool($filters['has_error_report'] ?? false); $hasErrorReportTable = $this->tableExists('error_reports'); if ($hasErrorReport && $hasErrorReportTable) { $where[] = 'EXISTS (SELECT 1 FROM error_reports er_filter WHERE er_filter.release_trace_id = s.trace_id)'; } elseif ($hasErrorReport) { $where[] = '1 = 0'; } $errorReportCountSelect = $hasErrorReportTable ? "(SELECT COUNT(*) FROM error_reports er_count WHERE er_count.release_trace_id = s.trace_id) AS error_report_count" : '0 AS error_report_count'; $limit = max(1, min(500, (int)($filters['limit'] ?? 100))); $rows = $this->selectRows( "SELECT s.*, COUNT(e.id) AS event_count, SUM(CASE WHEN e.severity = 'error' THEN 1 ELSE 0 END) AS error_count, MIN(e.occurred_at) AS first_event_at, MAX(e.occurred_at) AS last_event_at, GROUP_CONCAT(DISTINCT e.module_key ORDER BY e.module_key SEPARATOR ',') AS module_keys, $errorReportCountSelect FROM release_timeline_sessions s LEFT JOIN release_timeline_events e ON e.timeline_session_id = s.id WHERE " . implode(' AND ', $where) . " GROUP BY s.id ORDER BY COALESCE(MAX(e.occurred_at), s.last_seen_at) DESC, s.id DESC LIMIT $limit", $types, $params ); return array_map(fn(array $row): array => $this->publicTimelineSession($row), $rows); } public function timelineSessionDetail(string $traceId): array { $this->ensureSchema(); $this->cleanupExpiredReplayData(); $traceId = self::safeIdentifier($traceId, 64); if ($traceId === '') { throw new RuntimeException('Invalid timeline trace id.'); } $session = $this->selectOne( 'SELECT * FROM release_timeline_sessions WHERE trace_id = ? LIMIT 1', 's', [$traceId] ); if ($session === null) { throw new RuntimeException('Timeline session not found.'); } $events = $this->selectRows( "SELECT e.*, s.principal_type, s.principal_id, s.customer_number, s.channel_slug FROM release_timeline_events e LEFT JOIN release_timeline_sessions s ON s.id = e.timeline_session_id WHERE e.trace_id = ? ORDER BY e.occurred_at ASC, e.id ASC", 's', [$traceId] ); $channel = isset($session['channel_id']) ? $this->selectOne('SELECT * FROM release_channels WHERE id = ? LIMIT 1', 'i', [(int)$session['channel_id']]) : null; return [ 'session' => $this->publicTimelineSession($session), 'events' => array_map(fn(array $row): array => $this->publicTimelineEvent($row), $events), 'error_reports' => $this->timelineErrorReports($traceId), 'release' => $this->timelineReleaseContext($session, $channel), ]; } public function handleGithubWebhook(array $headers, string $rawBody): array { $this->ensureSchema(); $secret = (string)$this->moduleConfigValue('ReleaseManager', 'github_webhook_secret', ''); $signature = self::headerValue($headers, 'X-Hub-Signature-256'); if (!self::verifyGithubSignature($secret, $rawBody, $signature)) { throw new RuntimeException('Invalid GitHub webhook signature.'); } $event = self::headerValue($headers, 'X-GitHub-Event') ?: 'unknown'; $payload = json_decode($rawBody, true); if (!is_array($payload)) { throw new RuntimeException('Invalid GitHub webhook JSON payload.'); } if ($event !== 'push') { $this->audit(null, null, 'github_webhook_ignored', null, 'info', ['event' => $event]); return ['event' => $event, 'deployments' => [], 'ignored' => true]; } $repository = (string)($payload['repository']['full_name'] ?? $payload['repository']['name'] ?? ''); $branch = preg_replace('#^refs/heads/#', '', (string)($payload['ref'] ?? '')); $commitSha = (string)($payload['after'] ?? ''); if ($repository === '' || $branch === '' || $commitSha === '') { throw new RuntimeException('GitHub push payload is missing repository, branch, or commit.'); } $targets = $this->selectRows( "SELECT * FROM release_deployment_targets WHERE deleted_at IS NULL AND auto_deploy = 1 AND repository = ? AND branch = ?", 'ss', [$repository, $branch] ); $deployments = []; foreach ($targets as $target) { $deployments[] = $this->startDeployment([ 'target_id' => (int)$target['id'], 'channel_id' => (int)$target['channel_id'], 'app' => (string)$target['app'], 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'version_label' => substr($commitSha, 0, 12), 'build_url' => (string)($payload['compare'] ?? ''), 'metadata' => [ 'github_event' => $event, 'head_commit' => self::redactPayload($payload['head_commit'] ?? []), ], ]); } $this->audit(null, null, 'github_webhook_processed', null, 'info', [ 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'deployment_count' => count($deployments), ]); return [ 'event' => $event, 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'deployments' => $deployments, ]; } public function healthProbe(): array { $startedAt = microtime(true); try { $summary = $this->summary(); $channels = $summary['channels'] ?? []; $deployments = $summary['deployments'] ?? []; $failedDeployments = array_values(array_filter($deployments, static fn(array $deployment): bool => ($deployment['status'] ?? '') === 'failed')); $channelsWithoutVersions = array_values(array_filter($channels, static function (array $channel): bool { if (($channel['enabled'] ?? false) !== true) { return false; } $versions = $channel['versions'] ?? []; return empty($versions['frontend']) && empty($versions['api']); })); $status = 'ok'; $reason = 'Release channels and deployment telemetry are available.'; $reasonKey = 'release_manager_available'; if ($failedDeployments !== []) { $status = 'degraded'; $reason = 'One or more recent release deployments failed.'; $reasonKey = 'release_deployments_failed'; } elseif ($channelsWithoutVersions !== []) { $status = 'degraded'; $reason = 'One or more enabled release channels have no active versions yet.'; $reasonKey = 'release_channels_without_versions'; } return [ 'status' => $status, 'status_reason' => $reason, 'status_reason_key' => $reasonKey, 'status_reason_params' => [ 'channels' => count($channels), 'deployment_targets' => count($summary['deployment_targets'] ?? []), 'recent_failed_deployments' => count($failedDeployments), ], 'checked_at' => date('c'), 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), ]; } catch (Throwable $throwable) { return [ 'status' => 'down', 'status_reason' => 'Release manager probe failed: ' . $throwable->getMessage(), 'status_reason_key' => 'release_manager_probe_failed', 'status_reason_params' => ['error' => $throwable->getMessage()], 'checked_at' => date('c'), 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), ]; } } private function ensureSchema(): void { if ($this->schemaEnsured) { return; } release_manager_schema_bootstrap::ensureTables(); $this->schemaEnsured = true; } private function resolveChannel(array $context): array { $cacheKey = $this->assignmentCacheKey($context); if ($cacheKey !== '' && defined('redis')) { try { $cached = redis->get($cacheKey); if (is_string($cached) && $cached !== '') { $decoded = json_decode($cached, true); if (is_array($decoded) && !empty($decoded['id'])) { return $decoded; } } } catch (Throwable) { } } $candidates = []; if (!empty($context['principal_type']) && !empty($context['principal_id'])) { $candidates[] = [(string)$context['principal_type'], (string)$context['principal_id']]; } if (!empty($context['customer_number'])) { $candidates[] = ['customer', (string)(int)$context['customer_number']]; } foreach ($candidates as [$subjectType, $subjectId]) { $row = $this->selectOne( "SELECT c.* FROM release_assignments a INNER JOIN release_channels c ON c.id = a.channel_id WHERE a.deleted_at IS NULL AND c.deleted_at IS NULL AND c.enabled = 1 AND a.subject_type = ? AND a.subject_id = ? AND (a.expires_at IS NULL OR a.expires_at > NOW()) ORDER BY a.created_at DESC, a.id DESC LIMIT 1", 'ss', [$subjectType, $subjectId] ); if ($row !== null) { $this->cacheResolvedChannel($cacheKey, $row); return $row; } } $rolloutChannel = $this->rolloutChannelForContext($context); if ($rolloutChannel !== null) { $this->cacheResolvedChannel($cacheKey, $rolloutChannel); return $rolloutChannel; } $default = $this->defaultChannel(); $this->cacheResolvedChannel($cacheKey, $default); return $default; } private function runtimeChannelsForContext(array $context, ?array $resolvedChannel = null): array { $channelsByKey = []; $this->addRuntimeChannel($channelsByKey, $this->defaultChannel()); foreach ($this->assignmentCandidates($context) as [$subjectType, $subjectId]) { $rows = $this->selectRows( "SELECT c.* FROM release_assignments a INNER JOIN release_channels c ON c.id = a.channel_id WHERE a.deleted_at IS NULL AND c.deleted_at IS NULL AND c.enabled = 1 AND a.subject_type = ? AND a.subject_id = ? AND (a.expires_at IS NULL OR a.expires_at > NOW()) ORDER BY a.created_at DESC, a.id DESC", 'ss', [$subjectType, $subjectId] ); foreach ($rows as $row) { $this->addRuntimeChannel($channelsByKey, $row); } } if ($resolvedChannel !== null) { $this->addRuntimeChannel($channelsByKey, $resolvedChannel); } return array_values($channelsByKey); } private function assignmentCandidates(array $context): array { $candidates = []; if (!empty($context['principal_type']) && !empty($context['principal_id'])) { $candidates[] = [(string)$context['principal_type'], (string)$context['principal_id']]; } if (!empty($context['customer_number'])) { $candidates[] = ['customer', (string)(int)$context['customer_number']]; } return $candidates; } private function addRuntimeChannel(array &$channelsByKey, array $channel): void { $id = (int)($channel['id'] ?? 0); $slug = self::safeSlug((string)($channel['slug'] ?? '')); $key = $id > 0 ? 'id:' . $id : ($slug !== '' ? 'slug:' . $slug : ''); if ($key === '' || isset($channelsByKey[$key])) { return; } $channelsByKey[$key] = $channel; } private function chooseRuntimeChannel(array $resolvedChannel, array $availableChannels, string $requestedSlug): array { if ($requestedSlug === '') { return $resolvedChannel; } foreach ($availableChannels as $channel) { if (self::safeSlug((string)($channel['slug'] ?? '')) === $requestedSlug) { return $channel; } } $requestedChannel = $this->findChannelBySlug($requestedSlug); if ($requestedChannel !== null && (int)($requestedChannel['enabled'] ?? 0) === 1) { return $requestedChannel; } return $resolvedChannel; } private function requestedRuntimeChannelSlug(array $input = []): string { $value = $input['release_channel'] ?? $input['channel_slug'] ?? ($_GET['release_channel'] ?? $_GET['channel_slug'] ?? ''); return self::safeSlug((string)$value); } private function rolloutChannelForContext(array $context): ?array { $seed = (string)($context['principal_id'] ?? $context['customer_number'] ?? ''); if ($seed === '') { return null; } $bucket = (hexdec(substr(hash('sha256', $seed), 0, 8)) % 10000) / 100; $channels = $this->selectRows( "SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 AND default_channel = 0 AND rollout_percent > 0 ORDER BY rollout_percent DESC, slug" ); foreach ($channels as $channel) { if ($bucket < (float)$channel['rollout_percent']) { return $channel; } } return null; } private function capturePolicyFor(array $context, array $channel): array { $enabled = (bool)((int)($channel['replay_enabled'] ?? 0)); $captureLevel = $this->normalizeCaptureLevel((string)($channel['capture_level'] ?? 'metadata')); $retentionDays = max(1, (int)($channel['retention_days'] ?? 14)); $targets = []; if (!empty($context['principal_type']) && !empty($context['principal_id'])) { $targets[] = [(string)$context['principal_type'], (string)$context['principal_id']]; } if (!empty($context['customer_number'])) { $targets[] = ['customer', (string)(int)$context['customer_number']]; } $targets[] = ['channel', (string)$channel['slug']]; foreach ($targets as [$targetType, $targetId]) { $row = $this->selectOne( "SELECT capture_level FROM release_replay_targets WHERE deleted_at IS NULL AND enabled = 1 AND target_type = ? AND (target_id = ? OR (target_type = 'channel' AND channel_id = ?)) AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at DESC, id DESC LIMIT 1", 'ssi', [$targetType, $targetId, (int)$channel['id']] ); if ($row !== null) { $enabled = true; $captureLevel = $this->normalizeCaptureLevel((string)$row['capture_level']); break; } } return [ 'enabled' => $enabled, 'capture_level' => $captureLevel, 'all_failure_metadata' => true, 'retention_days' => $retentionDays, ]; } private function currentPrincipalContext(): array { try { $auth = new authentication(); $subuser = $auth->get_subuser(); if ($subuser !== false) { return [ 'principal_type' => 'subuser', 'principal_id' => (string)$subuser->id, 'customer_number' => $auth->get_subuser_customer_number_target() ?: null, ]; } $user = $auth->get_user(); if ($user !== false) { return [ 'principal_type' => 'user', 'principal_id' => (string)$user->id, 'customer_number' => isset($user->customer_number) ? (int)$user->customer_number->value() : null, ]; } } catch (Throwable) { } return [ 'principal_type' => null, 'principal_id' => null, 'customer_number' => null, ]; } private function defaultChannel(): array { $channel = $this->selectOne( "SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 AND default_channel = 1 ORDER BY id LIMIT 1" ); if ($channel !== null) { return $channel; } $channel = $this->selectOne( "SELECT * FROM release_channels WHERE deleted_at IS NULL AND slug = 'stable' ORDER BY id LIMIT 1" ); if ($channel !== null) { return $channel; } throw new RuntimeException('No release channel is configured.'); } private function currentVersionsForChannel(int $channelId): array { $current = $this->currentChannelVersionRow($channelId); $frontend = null; $api = null; if (!empty($current['frontend_version_id'])) { $frontend = $this->publicVersion($this->getVersion((int)$current['frontend_version_id'])); } if (!empty($current['api_version_id'])) { $api = $this->publicVersion($this->getVersion((int)$current['api_version_id'])); } $serviceSet = null; if (!empty($current['service_set_id'])) { try { $serviceSet = $this->publicServiceSet($this->getServiceSet((int)$current['service_set_id']), false); } catch (Throwable) { $serviceSet = null; } } $bundle = null; if (!empty($current['bundle_id'])) { try { $bundle = $this->publicBundle($this->getBundle((int)$current['bundle_id']), false); } catch (Throwable) { $bundle = null; } } return [ 'frontend' => $frontend, 'api' => $api, 'service_set' => $serviceSet, 'bundle_id' => isset($current['bundle_id']) ? (int)$current['bundle_id'] : null, 'bundle' => $bundle, ]; } private function channelAvailability(array $channel): array { $channelId = (int)($channel['id'] ?? 0); $isDefault = ((int)($channel['default_channel'] ?? 0) === 1) || (string)($channel['slug'] ?? '') === 'stable'; if ($isDefault || $channelId <= 0) { return [ 'configured' => true, 'missing' => [], 'status' => 'ready', ]; } $versions = $this->currentVersionsForChannel($channelId); $urls = $this->releaseRuntimeUrls($channel, $versions); $missing = []; if (empty($versions['bundle_id'])) { $missing[] = 'release_bundle'; } if (empty($versions['frontend'])) { $missing[] = 'frontend_version'; } elseif (empty($urls['frontend_base_url'])) { $missing[] = 'frontend_base_url'; } if (empty($versions['api'])) { $missing[] = 'api_version'; } elseif (empty($urls['api_base_url'])) { $missing[] = 'api_base_url'; } return [ 'configured' => count($missing) === 0, 'missing' => $missing, 'bundle_id' => $versions['bundle_id'] ?? null, 'frontend_base_url' => $urls['frontend_base_url'], 'api_base_url' => $urls['api_base_url'], 'status' => count($missing) === 0 ? 'ready' : 'unconfigured', ]; } private function releaseRuntimeUrls(array $channel, array $versions): array { $frontend = is_array($versions['frontend'] ?? null) ? $versions['frontend'] : []; $api = is_array($versions['api'] ?? null) ? $versions['api'] : []; $serviceSet = is_array($versions['service_set'] ?? null) ? $versions['service_set'] : []; $targets = is_array($serviceSet['targets'] ?? null) ? $serviceSet['targets'] : []; $frontendTarget = is_array($targets['frontend'] ?? null) ? $targets['frontend'] : null; $apiTarget = is_array($targets['api'] ?? null) ? $targets['api'] : null; return [ 'frontend_base_url' => $this->normalizeReleasePublicBaseUrl($frontend['deployed_url'] ?? null, 'frontend') ?? $this->normalizeReleasePublicBaseUrl($channel['frontend_base_url'] ?? null, 'frontend') ?? (is_array($frontendTarget) ? $this->releaseTargetPublicBaseUrl($frontendTarget) : null), 'api_base_url' => $this->normalizeReleasePublicBaseUrl($api['deployed_url'] ?? null, 'api') ?? $this->normalizeReleasePublicBaseUrl($channel['api_base_url'] ?? null, 'api') ?? (is_array($apiTarget) ? $this->releaseTargetPublicBaseUrl($apiTarget) : null), ]; } private function normalizeReleasePublicBaseUrl(mixed $value, string $app = ''): ?string { $raw = trim((string)$value); if ($raw === '') { return null; } $raw = preg_replace('#/(health|ping)$#i', '', rtrim($raw, '/')) ?: $raw; if (preg_match('#^https?://#i', $raw) !== 1) { $raw = 'https://' . ltrim($raw, '/'); } $parts = parse_url($raw); if (!is_array($parts) || empty($parts['host'])) { return null; } $scheme = strtolower((string)($parts['scheme'] ?? 'https')); if (!in_array($scheme, ['http', 'https'], true)) { return null; } $path = isset($parts['path']) ? '/' . trim((string)$parts['path'], '/') : ''; $port = isset($parts['port']) ? ':' . (int)$parts['port'] : ''; return rtrim($scheme . '://' . strtolower((string)$parts['host']) . $port . $path, '/'); } private function currentChannelVersionRow(int $channelId): ?array { return $this->selectOne( "SELECT * FROM release_channel_versions WHERE channel_id = ? AND active = 1 ORDER BY activated_at DESC, id DESC LIMIT 1", 'i', [$channelId] ); } private function createVersion(array $input): int { $this->execute( "INSERT INTO release_versions ( app, repository, branch, commit_sha, tag, version_label, build_url, artifact_url, deployed_url, status, metadata_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 'sssssssssss', [ $input['app'], $input['repository'] ?? null, $input['branch'] ?? null, $input['commit_sha'] ?? null, $input['tag'] ?? null, $input['version_label'] ?? null, $input['build_url'] ?? null, $input['artifact_url'] ?? null, $input['deployed_url'] ?? null, $input['status'] ?? 'discovered', self::jsonEncode($input['metadata'] ?? []), ] ); return $this->insertId(); } private function restartCoolifyService(int $instanceId, string $serviceUuid): array { $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]); if ($instance === null) { throw new RuntimeException('Coolify instance for release deployment was not found.'); } $token = replication_secret_box::decrypt((string)$instance['api_token_secret']); $client = new coolify_api_client((string)$instance['base_url'], $token, 20); return $client->restartService($serviceUuid); } private function deployCoolifyReleaseTarget(array $target): array { $instanceId = (int)($target['coolify_instance_id'] ?? 0); $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]); if ($instance === null) { throw new RuntimeException('Coolify instance for release deployment was not found.'); } $token = replication_secret_box::decrypt((string)$instance['api_token_secret']); $client = new coolify_api_client((string)$instance['base_url'], $token, 20); $context = self::jsonDecode($target['deploy_context_json'] ?? null); $serviceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); $resourceType = $this->releaseCoolifyResourceType($context, $serviceUuid); $created = null; $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); if ($serviceUuid === '' && $this->toBool($context['coolify_auto_create'] ?? false)) { $githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true); if ($githubAppUuid !== '') { $context['coolify_github_app_uuid'] = $githubAppUuid; $created = $client->createPrivateGithubAppApplication($this->releaseCoolifyApplicationPayload($target, $context, $instance)); $resourceType = 'application'; } else { if (self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)) { throw new RuntimeException('Path-routed release targets require a Coolify application resource with StripPrefix labels. Set coolify_resource_type=application or migrate this target before deploying.'); } if ($this->releaseCoolifyServiceSourceIsMissing($context)) { throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App for this private source repository. Configure one GitHub App in Coolify or set coolify_github_app_uuid on the target; no source-code credentials are required in Release Manager.'); } $created = $client->createService($this->releaseCoolifyServicePayload($target, $context, $instance)); $resourceType = 'service'; } $serviceUuid = trim((string)($created['uuid'] ?? '')); if ($serviceUuid === '') { throw new RuntimeException('Coolify did not return a resource UUID for the release target.'); } $context['coolify_resource_type'] = $resourceType; $this->execute( 'UPDATE release_deployment_targets SET coolify_service_uuid = ?, deploy_context_json = ? WHERE id = ?', 'ssi', [$serviceUuid, self::jsonEncode($context), (int)$target['id']] ); } if ($serviceUuid === '') { throw new RuntimeException('Select an existing Coolify service or enable Coolify service creation before deployment.'); } $update = null; if ($resourceType === 'application') { $applicationUpdate = $this->releaseCoolifyApplicationUpdatePayload($target, $context); if ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) { $resource = []; try { $resource = $client->getApplication($serviceUuid); } catch (Throwable) { $resource = is_array($created) ? $created : []; } $applicationUpdate = array_replace( $applicationUpdate, $this->releaseCoolifyApplicationRoutePayload( $target, $context, $publicUrl, $serviceUuid, $resource['custom_labels'] ?? null ) ); } if ($applicationUpdate !== []) { $update = $client->updateApplication($serviceUuid, $applicationUpdate); } } elseif ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) { if (self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)) { throw new RuntimeException('Path-routed release targets require a Coolify application resource with StripPrefix labels. Set coolify_resource_type=application or migrate this target before deploying.'); } $update = $client->updateService($serviceUuid, [ 'urls' => [ [ 'name' => (string)($target['app'] ?? 'release'), 'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)), ], ], 'force_domain_override' => true, ]); } $deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context)); return [ 'service_uuid' => $serviceUuid, 'resource_type' => $resourceType, 'ssl_enabled' => $this->toBool($context['coolify_enable_ssl'] ?? false), 'public_url' => $publicUrl, 'created' => self::redactPayload($created ?? []), 'updated' => self::redactPayload($update ?? []), 'deployment' => self::redactPayload($deployment), ]; } private function releaseCoolifyApplicationPayload(array $target, array $context, array $instance): array { $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); $projectUuid = trim((string)($context['coolify_project_uuid'] ?? $context['project_uuid'] ?? $instance['default_project_uuid'] ?? '')); if ($projectUuid === '') { throw new RuntimeException('Select a Coolify project for this release target before creating an application.'); } $githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true); if ($githubAppUuid === '') { throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App UUID so Coolify can pull with the app token.'); } $serverUuid = $this->releaseCoolifyServerUuid($context, $instance); if ($serverUuid === '') { throw new RuntimeException('Release Manager could not resolve a Coolify server UUID automatically for this instance.'); } $repository = trim((string)($target['repository'] ?? '')); if ($repository === '') { throw new RuntimeException('Repository is required before creating a Coolify GitHub App application.'); } $environment = $this->releaseCoolifyEnvironment($target, $context, $instance); $payload = [ 'name' => $this->releaseCoolifyResourceName($target, $context), 'description' => 'Truckwash release manager target for ' . $repository, 'project_uuid' => $projectUuid, 'environment_name' => $environment['name'], 'environment_uuid' => $environment['uuid'], 'server_uuid' => $serverUuid, 'destination_uuid' => trim((string)($context['coolify_destination_uuid'] ?? $context['destination_uuid'] ?? $instance['default_destination_uuid'] ?? '')), 'github_app_uuid' => $githubAppUuid, 'git_repository' => $repository, 'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH, 'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context), 'build_pack' => $this->releaseCoolifyBuildPack($target, $context), 'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context), 'instant_deploy' => $this->toBool($context['coolify_deploy_now'] ?? true), 'is_auto_deploy_enabled' => $this->toBool($target['auto_deploy'] ?? true), 'force_domain_override' => true, ]; foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) { $payload[$key] = $value; } if ($publicUrl !== null) { $payload['domains'] = self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)); $payload['is_force_https_enabled'] = $this->toBool($context['coolify_enable_ssl'] ?? false); } foreach ($this->releaseCoolifyApplicationOptionalFields($context) as $key => $value) { $payload[$key] = $value; } return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== ''); } private function releaseCoolifyApplicationRoutePayload( array $target, array $context, string $publicUrl, string $resourceUuid, mixed $existingLabels = null ): array { $decodedLabels = self::decodeCoolifyLabels($existingLabels); $routePort = $this->releaseCoolifyProxyPort($target, $context) ?? self::coolifyLabelFirstServicePort($decodedLabels, $resourceUuid); $payload = [ 'domains' => self::coolifyProxyUrl($publicUrl, $routePort), 'is_force_https_enabled' => true, 'force_domain_override' => true, ]; $labels = self::releaseCoolifyApplicationLabels( $publicUrl, $resourceUuid, $routePort, self::gatewayRouteDefaultCertResolver($publicUrl) ); if ($labels !== []) { $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( $decodedLabels, $labels ))); } return $payload; } private function releaseCoolifyApplicationUpdatePayload(array $target, array $context): array { $app = strtolower(trim((string)($target['app'] ?? ''))); $buildPack = $this->releaseCoolifyBuildPack($target, $context); $payload = [ 'git_repository' => trim((string)($target['repository'] ?? '')), 'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH, 'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context), 'build_pack' => $buildPack, 'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context), ]; foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) { $payload[$key] = $value; } foreach ($this->releaseCoolifyApplicationOptionalFields($context) as $key => $value) { $payload[$key] = $value; } if ($app === 'frontend' && $buildPack === 'dockerfile') { $payload['install_command'] = ''; $payload['build_command'] = ''; $payload['start_command'] = ''; $payload['publish_directory'] = ''; $payload['is_static'] = false; $payload['is_spa'] = false; } return array_filter( $payload, static fn(mixed $value, string $key): bool => $value !== null && ($value !== '' || in_array($key, ['install_command', 'build_command', 'start_command', 'publish_directory'], true)), ARRAY_FILTER_USE_BOTH ); } private static function releaseCoolifyApplicationLabels( string $publicUrl, string $resourceUuid, ?int $port = null, ?string $certResolver = null ): array { $resourceUuid = self::coolifyRouteLabelId($resourceUuid); if ($resourceUuid === '') { return []; } $parts = parse_url($publicUrl); $host = trim((string)($parts['host'] ?? '')); if ($host === '') { return []; } $scheme = strtolower((string)($parts['scheme'] ?? 'https')); $path = trim((string)($parts['path'] ?? '/')); $path = $path !== '' ? $path : '/'; if ($path[0] !== '/') { $path = '/' . $path; } $routePort = $port ?? self::firstInteger($parts['port'] ?? null); $certResolver = trim((string)($certResolver ?? '')); $httpLabel = 'http-0-' . $resourceUuid; $httpsLabel = 'https-0-' . $resourceUuid; $labels = [ 'traefik.enable=true', 'traefik.http.middlewares.gzip.compress=true', 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https', ]; if ($scheme === 'https') { $labels[] = "traefik.http.routers.{$httpsLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; $labels[] = "traefik.http.routers.{$httpsLabel}.entryPoints=https"; if ($routePort !== null) { $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}"; $labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}"; } if ($path !== '/') { $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}"; $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip"; } else { $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip"; } $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; if ($certResolver !== '') { $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; } $labels[] = "traefik.http.routers.{$httpsLabel}.tls.domains[0].main={$host}"; $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http"; if ($routePort !== null) { $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}"; $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}"; } $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=redirect-to-https"; } else { $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http"; if ($routePort !== null) { $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}"; $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}"; } if ($path !== '/') { $labels[] = "traefik.http.middlewares.{$httpLabel}-stripprefix.stripprefix.prefixes={$path}"; $labels[] = "traefik.http.routers.{$httpLabel}.middlewares={$httpLabel}-stripprefix,gzip"; } else { $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=gzip"; } } sort($labels); return $labels; } private static function mergeCoolifyLabels(array $existingLabels, array $generatedLabels): array { $merged = []; foreach (array_merge($existingLabels, $generatedLabels) as $label) { $label = trim((string)$label); if ($label === '') { continue; } $merged[self::coolifyLabelKey($label)] = $label; } return array_values($merged); } private static function coolifyLabelFirstServicePort(array $labels, string $resourceUuid): ?int { $resourceUuid = self::coolifyRouteLabelId($resourceUuid); $fallback = null; foreach ($labels as $label) { if (preg_match('/^traefik\.http\.services\.([^=]+)\.loadbalancer\.server\.port=(\d+)$/', trim((string)$label), $matches) !== 1) { continue; } $port = (int)$matches[2]; if ($port <= 0) { continue; } if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { return $port; } $fallback ??= $port; } return $fallback; } private static function coolifyLabelCertResolver(array $labels, string $resourceUuid): ?string { $resourceUuid = self::coolifyRouteLabelId($resourceUuid); $fallback = null; foreach ($labels as $label) { if (preg_match('/^traefik\.http\.routers\.([^=]+)\.tls\.certresolver=([A-Za-z0-9_.-]+)$/', trim((string)$label), $matches) !== 1) { continue; } $resolver = trim((string)$matches[2]); if ($resolver === '') { continue; } if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { return $resolver; } $fallback ??= $resolver; } return $fallback; } private static function gatewayRouteDefaultCertResolver(string $publicUrl): string { return 'letsencrypt'; } private static function decodeCoolifyLabels(mixed $labels): array { if (!is_scalar($labels)) { return []; } $raw = trim((string)$labels); if ($raw === '') { return []; } $decoded = base64_decode($raw, true); $content = $decoded !== false ? $decoded : $raw; return array_values(array_filter( preg_split('/\r\n|\r|\n/', (string)$content) ?: [], static fn(string $label): bool => trim($label) !== '' )); } private static function coolifyLabelKey(string $label): string { $position = strpos($label, '='); return $position === false ? trim($label) : trim(substr($label, 0, $position)); } private static function coolifyRouteLabelId(string $value): string { $value = strtolower(trim($value)); $value = preg_replace('/[^a-z0-9-]+/', '-', $value) ?: ''; return trim($value, '-'); } private static function firstInteger(mixed $value): ?int { if (is_int($value)) { return $value > 0 ? $value : null; } if (is_float($value)) { return $value > 0 ? (int)$value : null; } if (is_array($value)) { foreach ($value as $item) { $integer = self::firstInteger($item); if ($integer !== null) { return $integer; } } return null; } if (!is_scalar($value)) { return null; } if (preg_match('/\d+/', (string)$value, $matches) !== 1) { return null; } $integer = (int)$matches[0]; return $integer > 0 ? $integer : null; } private static function coolifyProxyUrl(string $publicUrl, ?int $port): string { if ($port === null || $port <= 0) { return $publicUrl; } $parts = parse_url($publicUrl); if (!is_array($parts) || trim((string)($parts['host'] ?? '')) === '' || isset($parts['port'])) { return $publicUrl; } $scheme = trim((string)($parts['scheme'] ?? 'https')) ?: 'https'; $host = trim((string)$parts['host']); $path = (string)($parts['path'] ?? ''); $query = isset($parts['query']) ? '?' . $parts['query'] : ''; $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; return "{$scheme}://{$host}:{$port}{$path}{$query}{$fragment}"; } private static function releaseCoolifyPublicUrlNeedsStripPrefixLabels(?string $publicUrl): bool { if ($publicUrl === null || trim($publicUrl) === '') { return false; } $parts = parse_url($publicUrl); if (!is_array($parts)) { return false; } return trim((string)($parts['path'] ?? ''), '/') !== ''; } private function releaseCoolifyServicePayload(array $target, array $context, array $instance): array { $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); $projectUuid = trim((string)($context['coolify_project_uuid'] ?? $context['project_uuid'] ?? $instance['default_project_uuid'] ?? '')); if ($projectUuid === '') { throw new RuntimeException('Select a Coolify project for this release target before creating a service.'); } $environment = $this->releaseCoolifyEnvironment($target, $context, $instance); $serverUuid = $this->releaseCoolifyServerUuid($context, $instance); if ($serverUuid === '') { throw new RuntimeException('Release Manager could not resolve a Coolify server UUID automatically for this instance.'); } $name = $this->releaseCoolifyResourceName($target, $context); $compose = (string)($context['docker_compose_raw'] ?? ''); if (trim($compose) === '') { $image = $this->releaseCoolifyExplicitImage($context); if ($image === '' && $this->toBool($context['coolify_assume_ghcr_image'] ?? $context['assume_ghcr_image'] ?? false)) { $repository = strtolower(trim((string)($target['repository'] ?? ''))); $branch = self::safeIdentifier((string)($target['branch'] ?? self::DEFAULT_BRANCH), 64); if ($repository !== '') { $image = 'ghcr.io/' . $repository . ':' . ($branch !== '' ? $branch : self::DEFAULT_BRANCH); } } if ($image === '') { throw new RuntimeException('Coolify service creation needs docker_compose_raw or an explicit image in deploy_context. Release Manager will not assume a GHCR image from repository and branch.'); } $compose = "services:\n app:\n image: " . $image . "\n restart: unless-stopped\n"; } $payload = [ 'name' => $name, 'description' => 'Truckwash release manager target for ' . (string)($target['repository'] ?? ''), 'project_uuid' => $projectUuid, 'environment_name' => $environment['name'], 'environment_uuid' => $environment['uuid'], 'server_uuid' => $serverUuid, 'instant_deploy' => $this->toBool($context['coolify_deploy_now'] ?? true), 'docker_compose_raw' => base64_encode($compose), 'force_domain_override' => true, ]; if ($publicUrl !== null) { $payload['urls'] = [ [ 'name' => (string)($target['app'] ?? 'release'), 'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)), ], ]; } return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== ''); } private function releaseCoolifyResourceName(array $target, array $context): string { $requestedName = trim((string)($context['coolify_service_name'] ?? $context['service_name'] ?? $context['coolify_application_name'] ?? $context['application_name'] ?? '')); return self::safeIdentifier( $requestedName !== '' ? $requestedName : 'release-' . (string)($target['channel_slug'] ?? $target['channel_id'] ?? 'channel') . '-' . (string)($target['app'] ?? 'app'), 64 ); } private function releaseCoolifyServiceSourceIsMissing(array $context): bool { return trim((string)($context['docker_compose_raw'] ?? '')) === '' && $this->releaseCoolifyExplicitImage($context) === '' && !$this->toBool($context['coolify_assume_ghcr_image'] ?? $context['assume_ghcr_image'] ?? false); } private function releaseCoolifyGithubAppUuid(array $context, array $target = [], array $instance = [], bool $discover = false): string { foreach ([ 'coolify_github_app_uuid', 'github_app_uuid', 'coolify_git_app_uuid', 'git_app_uuid', 'default_github_app_uuid', 'default_coolify_github_app_uuid', ] as $key) { $value = trim((string)($context[$key] ?? '')); if ($value !== '') { return $value; } } if (!$discover) { return ''; } return $this->releaseCoolifyDefaultGithubAppUuid($target, $context, $instance); } private function releaseCoolifyDefaultGithubAppUuid(array $target, array $context, array $instance): string { foreach ([ 'default_github_app_uuid', 'default_coolify_github_app_uuid', 'coolify_github_app_uuid', 'github_app_uuid', ] as $key) { $value = trim((string)($instance[$key] ?? '')); if ($value !== '') { return $value; } } foreach ([ getenv('RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID'] ?? null), getenv('COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['COOLIFY_GITHUB_APP_UUID'] ?? null), $this->moduleConfigValue('ReleaseManager', 'coolify_github_app_uuid', ''), $this->moduleConfigValue('Coolify', 'github_app_uuid', ''), ] as $value) { $value = trim((string)$value); if ($value !== '') { return $value; } } $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); if ($tokenSecret === '') { return ''; } try { $token = replication_secret_box::decrypt($tokenSecret); $apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps(); } catch (Throwable) { return ''; } $rows = array_values(array_filter($this->payloadRows($apps), static function (mixed $row): bool { return is_array($row) && trim((string)($row['uuid'] ?? '')) !== ''; })); if (count($rows) === 1) { return trim((string)$rows[0]['uuid']); } $repository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? '')); $owner = strtolower(trim(strtok($repository, '/') ?: '')); if ($owner === '') { return ''; } $matches = array_values(array_filter($rows, static function (array $row) use ($owner): bool { foreach (['organization', 'name', 'custom_user', 'html_url'] as $key) { $value = strtolower(trim((string)($row[$key] ?? ''))); if ($value === '') { continue; } if ($value === $owner || str_contains($value, '/' . $owner) || str_contains($value, $owner . '-')) { return true; } } return false; })); return count($matches) === 1 ? trim((string)$matches[0]['uuid']) : ''; } private function releaseCoolifyResourceType(array $context, string $serviceUuid = ''): string { $type = strtolower(trim((string)($context['coolify_resource_type'] ?? $context['resource_type'] ?? ''))); if (in_array($type, ['application', 'app'], true)) { return 'application'; } if ($type === '' && trim($serviceUuid) === '' && $this->releaseCoolifyGithubAppUuid($context) !== '') { return 'application'; } return 'service'; } private function releaseCoolifyBuildPack(array $target, array $context): string { $app = strtolower(trim((string)($target['app'] ?? ''))); $buildPack = strtolower(trim((string)($context['coolify_build_pack'] ?? $context['build_pack'] ?? ''))); if ($buildPack !== '') { if ($app === 'frontend' && $buildPack === 'nixpacks') { return 'dockerfile'; } return $buildPack; } return in_array($app, ['api', 'frontend'], true) ? 'dockerfile' : 'static'; } private function releaseCoolifyPortsExposes(array $target, array $context): string { foreach ([ 'coolify_ports_exposes', 'ports_exposes', 'coolify_exposed_port', 'exposed_port', 'coolify_port', 'port', ] as $key) { $value = trim((string)($context[$key] ?? '')); if ($value !== '') { return $value; } } $app = strtolower(trim((string)($target['app'] ?? ''))); $envKeys = $app === 'api' ? ['RELEASE_MANAGER_API_PORTS_EXPOSES', 'RELEASE_API_PORTS_EXPOSES', 'API_PORTS_EXPOSES'] : ['RELEASE_MANAGER_FRONTEND_PORTS_EXPOSES', 'RELEASE_FRONTEND_PORTS_EXPOSES', 'FRONTEND_PORTS_EXPOSES']; foreach ($envKeys as $key) { $value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? ''))); if ($value !== '') { return $value; } } return self::DEFAULT_COOLIFY_APPLICATION_PORT; } private function releaseCoolifyProxyPort(array $target, array $context): ?int { foreach ([ 'coolify_ports_exposes', 'ports_exposes', 'coolify_exposed_port', 'exposed_port', 'coolify_port', 'port', ] as $key) { $port = self::firstInteger($context[$key] ?? null); if ($port !== null) { return $port; } } $app = strtolower(trim((string)($target['app'] ?? ''))); if ($app !== 'api') { return null; } return self::firstInteger($this->releaseCoolifyPortsExposes($target, $context)); } private function releaseCoolifyGitCommitSha(array $target, array $context): string { foreach ([ 'coolify_git_commit_sha', 'git_commit_sha', 'commit_sha', 'commit', ] as $key) { $value = trim((string)($context[$key] ?? $target[$key] ?? '')); if ($value !== '') { return $value; } } return ''; } private function releaseCoolifyForceRebuild(array $context): bool { if (array_key_exists('coolify_force_rebuild', $context) || array_key_exists('force_rebuild', $context)) { return $this->toBool($context['coolify_force_rebuild'] ?? $context['force_rebuild'] ?? false); } return true; } private function releaseCoolifyApplicationDefaultFields(array $target, array $context): array { $app = strtolower(trim((string)($target['app'] ?? ''))); $buildPack = $this->releaseCoolifyBuildPack($target, $context); if ($app === 'api' && $buildPack === 'dockerfile') { return [ 'dockerfile_location' => self::DEFAULT_COOLIFY_API_DOCKERFILE, ]; } if ($app === 'frontend' && $buildPack === 'dockerfile') { return [ 'dockerfile_location' => '/Dockerfile.coolify-frontend', ]; } if ($app !== 'frontend' || $buildPack !== 'static') { return []; } return [ 'install_command' => 'npm ci', 'build_command' => 'npm run build', 'publish_directory' => 'dist', 'is_static' => true, 'is_spa' => true, ]; } private function releaseCoolifyApplicationOptionalFields(array $context): array { $fields = []; foreach ([ 'base_directory', 'publish_directory', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'ports_exposes', 'ports_mappings', 'install_command', 'build_command', 'start_command', ] as $key) { $value = trim((string)($context['coolify_' . $key] ?? $context[$key] ?? '')); if ($value !== '') { $fields[$key] = $value; } } foreach ([ 'is_static', 'is_spa', 'is_force_https_enabled', 'is_auto_deploy_enabled', ] as $key) { if (array_key_exists('coolify_' . $key, $context) || array_key_exists($key, $context)) { $fields[$key] = $this->toBool($context['coolify_' . $key] ?? $context[$key] ?? false); } } return $fields; } private function releaseCoolifyEnvironment(array $target, array $context, array $instance): array { $explicitUuid = trim((string)($context['coolify_environment_uuid'] ?? $context['environment_uuid'] ?? '')); $explicitName = trim((string)($context['coolify_environment_name'] ?? $context['environment_name'] ?? '')); $releaseEnvironmentName = $this->releaseBranchCoolifyEnvironmentName($target); if ($explicitUuid !== '' || ($explicitName !== '' && !($releaseEnvironmentName !== null && strtolower($explicitName) === 'production'))) { return [ 'uuid' => $explicitUuid !== '' ? $explicitUuid : null, 'name' => $explicitName !== '' ? $explicitName : (trim((string)($instance['default_environment_name'] ?? 'production')) ?: 'production'), ]; } if ($releaseEnvironmentName !== null) { return [ 'uuid' => null, 'name' => $releaseEnvironmentName, ]; } return [ 'uuid' => trim((string)($instance['default_environment_uuid'] ?? '')) ?: null, 'name' => trim((string)($instance['default_environment_name'] ?? 'production')) ?: 'production', ]; } private function releaseBranchCoolifyEnvironmentName(array $target): ?string { $channelSlug = self::safeSlug((string)($target['channel_slug'] ?? $target['channel'] ?? '')); if ($channelSlug !== '' && in_array($channelSlug, self::DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS, true)) { return null; } $branchSlug = self::safeSlug(preg_replace('#^refs/heads/#', '', (string)($target['branch'] ?? '')) ?? ''); if ($channelSlug === '' && $branchSlug === '') { return null; } if ($branchSlug !== '' && !in_array($branchSlug, ['main', 'master'], true)) { return $branchSlug; } return $channelSlug !== '' ? $channelSlug : null; } private function releaseCoolifyExplicitImage(array $context): string { foreach (['image', 'docker_image', 'coolify_image', 'coolify_docker_image', 'registry_image'] as $key) { $value = $context[$key] ?? null; if (is_scalar($value)) { $image = trim((string)$value); if ($image !== '') { return $image; } } } return ''; } private function releaseCoolifyServerUuid(array $context, array $instance): string { $explicit = trim((string)($context['coolify_server_uuid'] ?? $context['server_uuid'] ?? '')); if ($explicit !== '') { return $explicit; } $default = trim((string)($instance['default_server_uuid'] ?? '')); if ($default !== '') { return $default; } $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); if ($tokenSecret === '') { return ''; } try { $token = replication_secret_box::decrypt($tokenSecret); $servers = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listServers(); } catch (Throwable) { return ''; } $firstServerUuid = ''; foreach ($this->payloadRows($servers) as $server) { if (!is_array($server)) { continue; } $uuid = trim((string)($server['uuid'] ?? '')); if ($uuid === '') { continue; } if ($firstServerUuid === '') { $firstServerUuid = $uuid; } $settings = is_array($server['settings'] ?? null) ? $server['settings'] : []; if (($settings['is_reachable'] ?? true) !== false && ($settings['is_usable'] ?? true) !== false) { return $uuid; } } return $firstServerUuid; } private function releaseCoolifyPublicUrl(array $target, array $context): ?string { $explicitPublicUrl = $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context); if ($explicitPublicUrl !== null) { return $explicitPublicUrl; } $raw = trim((string)($context['coolify_domain'] ?? '')); $hasCoolifyDomain = $raw !== ''; if ($raw === '') { $raw = trim((string)($target['health_url'] ?? '')); } if ($raw === '') { return null; } if ($this->toBool($context['coolify_enable_ssl'] ?? false)) { $domain = self::domainSuggestionHost($raw); if ($domain === null) { throw new RuntimeException('Coolify SSL requires a DNS domain routed to the load balancer.'); } return $hasCoolifyDomain ? $this->releaseRoutedPublicBaseUrl('https://' . $domain, $target, $context) : $this->normalizeReleasePublicBaseUrl('https://' . $domain, (string)($target['app'] ?? '')); } if (preg_match('#^https?://#i', $raw) !== 1) { $raw = 'http://' . $raw; } return $hasCoolifyDomain ? $this->releaseRoutedPublicBaseUrl($raw, $target, $context) : $this->normalizeReleasePublicBaseUrl($raw, (string)($target['app'] ?? '')); } private function releaseRoutedPublicBaseUrl(mixed $value, array $target, array $context = []): ?string { $app = (string)($target['app'] ?? ''); $baseUrl = $this->normalizeReleasePublicBaseUrl($value, $app); if ($baseUrl === null) { return null; } $parts = parse_url($baseUrl); if (!is_array($parts) || empty($parts['host'])) { return $baseUrl; } if ($this->toBool($context['gateway_route_autoprovision'] ?? false)) { return $baseUrl; } $path = trim((string)($parts['path'] ?? ''), '/'); if ($path !== '') { return $baseUrl; } $channelSlug = self::safeSlug((string)( $target['channel_slug'] ?? $context['channel_slug'] ?? $target['release_channel'] ?? $context['release_channel'] ?? $target['channel'] ?? $context['channel'] ?? '' )); $appSlug = self::safeSlug($app); if ($channelSlug === '' || $appSlug === '') { return $baseUrl; } $scheme = strtolower((string)($parts['scheme'] ?? 'https')); $host = strtolower((string)$parts['host']); $port = isset($parts['port']) ? ':' . (int)$parts['port'] : ''; return sprintf('%s://%s%s/%s/%s', $scheme, $host, $port, $channelSlug, $appSlug); } private function releaseTargetPublicBaseUrl(array $target): ?string { $context = is_array($target['deploy_context'] ?? null) ? $target['deploy_context'] : self::jsonDecode($target['deploy_context_json'] ?? null); $app = (string)($target['app'] ?? ''); return $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context) ?? $this->releaseRoutedPublicBaseUrl($context['coolify_domain'] ?? null, $target, $context) ?? $this->normalizeReleasePublicBaseUrl($target['health_url'] ?? null, $app); } private function timelineSessionContext(array $context): array { $device = is_array($context['device'] ?? null) ? $context['device'] : []; $browser = is_array($context['browser'] ?? null) ? $context['browser'] : []; $os = is_array($context['os'] ?? null) ? $context['os'] : []; $viewport = is_array($context['viewport'] ?? null) ? $context['viewport'] : []; $frontend = is_array($context['frontend'] ?? null) ? $context['frontend'] : []; $api = is_array($context['api'] ?? null) ? $context['api'] : []; return [ 'device_type' => $this->nullableIdentifier($context['device_type'] ?? $device['type'] ?? null, 16), 'browser_name' => $this->nullableString($context['browser_name'] ?? $browser['name'] ?? null, 64), 'browser_version' => $this->nullableString($context['browser_version'] ?? $browser['version'] ?? null, 64), 'os_name' => $this->nullableString($context['os_name'] ?? $os['name'] ?? null, 64), 'os_version' => $this->nullableString($context['os_version'] ?? $os['version'] ?? null, 64), 'viewport_width' => $this->nullableInt($context['viewport_width'] ?? $viewport['width'] ?? null), 'viewport_height' => $this->nullableInt($context['viewport_height'] ?? $viewport['height'] ?? null), 'device_pixel_ratio' => $this->nullableFloat($context['device_pixel_ratio'] ?? $viewport['device_pixel_ratio'] ?? null), 'frontend_version_label' => $this->nullableString( $context['frontend_version_label'] ?? $frontend['version_label'] ?? $context['frontend_version'] ?? null, 128 ), 'frontend_commit_sha' => $this->nullableString( $context['frontend_commit_sha'] ?? $frontend['commit_sha'] ?? $context['frontend_commit'] ?? null, 128 ), 'api_version_label' => $this->nullableString( $context['api_version_label'] ?? $api['version_label'] ?? $context['api_version'] ?? null, 128 ), 'api_commit_sha' => $this->nullableString( $context['api_commit_sha'] ?? $api['commit_sha'] ?? $context['backend_version'] ?? null, 128 ), 'last_route_path' => $this->nullableString($context['route_path'] ?? $context['route'] ?? null, 255), ]; } private function latestRouteFromContext(array $context): ?string { foreach (['route_path', 'route'] as $key) { $value = $this->nullableString($context[$key] ?? null, 255); if ($value !== null) { return $value; } } return null; } private function timelineSessionId(string $traceId, array $context, ?array $channel): int { $existing = $this->selectOne('SELECT id FROM release_timeline_sessions WHERE trace_id = ? LIMIT 1', 's', [$traceId]); $userAgent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 512); $principalType = trim((string)($context['principal_type'] ?? '')) ?: null; $principalId = trim((string)($context['principal_id'] ?? '')) ?: null; $customerNumber = isset($context['customer_number']) && is_numeric($context['customer_number']) ? (int)$context['customer_number'] : null; $sessionContext = $this->timelineSessionContext($context); $lastRoutePath = $sessionContext['last_route_path'] ?? null; if ($lastRoutePath === null) { $lastRoutePath = $this->latestRouteFromContext($context); } if ($existing !== null) { $this->execute( "UPDATE release_timeline_sessions SET last_seen_at = NOW(), channel_id = COALESCE(?, channel_id), channel_slug = COALESCE(?, channel_slug), principal_type = COALESCE(?, principal_type), principal_id = COALESCE(?, principal_id), customer_number = COALESCE(?, customer_number), device_type = COALESCE(?, device_type), browser_name = COALESCE(?, browser_name), browser_version = COALESCE(?, browser_version), os_name = COALESCE(?, os_name), os_version = COALESCE(?, os_version), viewport_width = COALESCE(?, viewport_width), viewport_height = COALESCE(?, viewport_height), device_pixel_ratio = COALESCE(?, device_pixel_ratio), frontend_version_label = COALESCE(?, frontend_version_label), frontend_commit_sha = COALESCE(?, frontend_commit_sha), api_version_label = COALESCE(?, api_version_label), api_commit_sha = COALESCE(?, api_commit_sha), last_route_path = COALESCE(?, last_route_path), user_agent = COALESCE(?, user_agent) WHERE id = ?", 'isssisssssiidssssssi', [ $channel['id'] ?? null, $channel['slug'] ?? null, $principalType, $principalId, $customerNumber, $sessionContext['device_type'], $sessionContext['browser_name'], $sessionContext['browser_version'], $sessionContext['os_name'], $sessionContext['os_version'], $sessionContext['viewport_width'], $sessionContext['viewport_height'], $sessionContext['device_pixel_ratio'], $sessionContext['frontend_version_label'], $sessionContext['frontend_commit_sha'], $sessionContext['api_version_label'], $sessionContext['api_commit_sha'], $lastRoutePath, $userAgent !== '' ? $userAgent : null, (int)$existing['id'], ] ); return (int)$existing['id']; } $this->execute( "INSERT INTO release_timeline_sessions ( trace_id, session_hash, principal_type, principal_id, customer_number, channel_id, channel_slug, device_type, browser_name, browser_version, os_name, os_version, viewport_width, viewport_height, device_pixel_ratio, frontend_version_label, frontend_commit_sha, api_version_label, api_commit_sha, last_route_path, user_agent ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 'ssssiisssssiidsssssss', [ $traceId, $this->sessionHash(), $principalType, $principalId, $customerNumber, $channel['id'] ?? null, $channel['slug'] ?? null, $sessionContext['device_type'], $sessionContext['browser_name'], $sessionContext['browser_version'], $sessionContext['os_name'], $sessionContext['os_version'], $sessionContext['viewport_width'], $sessionContext['viewport_height'], $sessionContext['device_pixel_ratio'], $sessionContext['frontend_version_label'], $sessionContext['frontend_commit_sha'], $sessionContext['api_version_label'], $sessionContext['api_commit_sha'], $lastRoutePath, $userAgent !== '' ? $userAgent : null, ] ); return $this->insertId(); } private function sessionHash(): ?string { $authorization = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''); if ($authorization === '') { return null; } return hash('sha256', str_replace('Bearer ', '', $authorization)); } private function inferModuleKeyFromUri(string $uri): ?string { $path = strtolower(explode('?', $uri)[0] ?? ''); $map = [ '/auth' => 'auth', '/superuser/releases' => 'releasemanager', '/release' => 'releasemanager', '/superuser/coolify' => 'coolify', '/coolify' => 'coolify', '/failover' => 'failover', '/edge-gateway' => 'edgegateway', '/edgegateway' => 'edgegateway', '/modules/action-logs' => 'moduleactionlogs', '/worker' => 'worker', '/economic' => 'economic', '/stripe' => 'stripe', '/selfserve' => 'selfserve', '/bird' => 'bird', '/xlvask' => 'xlvask', ]; foreach ($map as $prefix => $moduleKey) { if (str_starts_with($path, $prefix)) { return $moduleKey; } } return null; } private function timelineSummary(): array { $events = $this->selectOne( "SELECT COUNT(*) AS total, SUM(CASE WHEN severity = 'error' THEN 1 ELSE 0 END) AS errors, MAX(created_at) AS last_event_at FROM release_timeline_events" ) ?? []; $sessions = $this->selectOne('SELECT COUNT(*) AS total FROM release_timeline_sessions') ?? []; return [ 'sessions' => (int)($sessions['total'] ?? 0), 'events' => (int)($events['total'] ?? 0), 'errors' => (int)($events['errors'] ?? 0), 'last_event_at' => $events['last_event_at'] ?? null, ]; } private function latestModuleHealth(): array { return $this->selectRows( "SELECT h.* FROM release_module_health_snapshots h INNER JOIN ( SELECT module_key, MAX(checked_at) AS checked_at FROM release_module_health_snapshots GROUP BY module_key ) latest ON latest.module_key = h.module_key AND latest.checked_at = h.checked_at ORDER BY h.module_key" ); } private function githubRepositoryAccess(array $input): array { $tokenConfigured = $this->hasGithubApiToken(); $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? '')); $branch = trim((string)($input['branch'] ?? '')); $rawCommitSha = trim((string)($input['commit_sha'] ?? $input['commit'] ?? '')); $commitMode = $this->normalizeCommitMode((string)($input['commit_mode'] ?? ''), $rawCommitSha); if ($repository === '') { return [ 'ok' => false, 'status' => 'invalid_repository', 'token_configured' => $tokenConfigured, 'message' => 'GitHub repository must use owner/repo format.', 'repository' => trim((string)($input['repository'] ?? '')), 'branch' => $branch, 'commit_mode' => $commitMode, ]; } if ($commitMode === 'specific' && $rawCommitSha === '') { return [ 'ok' => false, 'status' => 'commit_required', 'token_configured' => $tokenConfigured, 'message' => 'Specific commit deployment requires a commit SHA.', 'repository' => $repository, 'branch' => $branch, 'commit_mode' => $commitMode, ]; } if (!$tokenConfigured) { $response = $this->githubTokenMissingResponse($repository, $branch); $response['commit_mode'] = $commitMode; return $response; } try { $repo = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository)); $defaultBranch = trim((string)($repo['default_branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; $branch = $branch !== '' ? $branch : $defaultBranch; $branchRow = $this->githubRequest( 'GET', '/repos/' . $this->githubRepositoryPath($repository) . '/branches/' . rawurlencode($branch) ); $latestCommitSha = trim((string)($branchRow['commit']['sha'] ?? '')); $commitSha = $latestCommitSha; $commitUrl = (string)($branchRow['commit']['url'] ?? ''); $latestCommit = []; if ($latestCommitSha !== '') { $latestCommitRow = $this->githubRequest( 'GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($latestCommitSha) ); $latestCommit = is_array($latestCommitRow) ? $this->publicGithubCommit($latestCommitRow) : []; $commitUrl = (string)($latestCommit['html_url'] ?? $commitUrl); } $commit = $latestCommit; if ($commitMode === 'specific') { $commitRow = $this->githubRequest( 'GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($rawCommitSha) ); $commit = is_array($commitRow) ? $this->publicGithubCommit($commitRow) : []; $commitSha = trim((string)($commit['sha'] ?? (is_array($commitRow) ? ($commitRow['sha'] ?? null) : null) ?? $rawCommitSha)); $commitUrl = (string)($commit['html_url'] ?? (is_array($commitRow) ? ($commitRow['html_url'] ?? null) : null) ?? $commitUrl); } return [ 'ok' => true, 'status' => 'accessible', 'token_configured' => true, 'message' => $commitMode === 'specific' ? 'Repository, branch, and commit are accessible with the configured GitHub token.' : 'Repository and branch are accessible with the configured GitHub token.', 'repository' => $repository, 'branch' => $branch, 'default_branch' => $defaultBranch, 'private' => (bool)($repo['private'] ?? false), 'html_url' => $repo['html_url'] ?? null, 'commit_mode' => $commitMode, 'commit_sha' => $commitSha !== '' ? $commitSha : null, 'latest_commit_sha' => $latestCommitSha !== '' ? $latestCommitSha : null, 'commit' => $commit !== [] ? $commit : null, 'latest_commit' => $latestCommit !== [] ? $latestCommit : null, 'commit_authored_at' => $commit['authored_at'] ?? null, 'commit_url' => $commitUrl !== '' ? $commitUrl : null, ]; } catch (Throwable $throwable) { return [ 'ok' => false, 'status' => 'inaccessible', 'token_configured' => true, 'message' => $throwable->getMessage(), 'repository' => $repository, 'branch' => $branch, 'commit_mode' => $commitMode, ]; } } private function normalizeCommitMode(string $commitMode, string $commitSha): string { $mode = strtolower(trim($commitMode)); $commit = strtolower(trim($commitSha)); if ($mode === 'specific') { return 'specific'; } if ($mode === 'latest' || $mode === 'head' || $commit === '' || in_array($commit, ['latest', 'head'], true)) { return 'latest'; } return 'specific'; } private function githubTokenMissingResponse(?string $repository = null, ?string $branch = null): array { return [ 'ok' => false, 'status' => 'not_configured', 'token_configured' => false, 'message' => 'Configure ReleaseManager github_token or RELEASE_MANAGER_GITHUB_TOKEN before using private GitHub repositories.', 'repository' => $repository, 'branch' => $branch, 'repositories' => [], 'branches' => [], 'commits' => [], ]; } private function hasGithubApiToken(): bool { return $this->githubApiToken() !== ''; } private function githubEnvToken(): string { foreach (['RELEASE_MANAGER_GITHUB_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN'] as $key) { $value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? ''))); if ($value !== '') { return $value; } } return ''; } private function githubApiToken(): string { $envToken = $this->githubEnvToken(); if ($envToken !== '') { return $envToken; } $token = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_token', '')); if ($token !== '' && str_starts_with($token, 'twsec:v1:') && class_exists(replication_secret_box::class)) { try { $token = replication_secret_box::decrypt($token); } catch (Throwable) { $token = ''; } } return trim($token); } private function githubApiBaseUrl(): string { $value = trim((string)(getenv('RELEASE_MANAGER_GITHUB_API_URL') ?: ($_SERVER['RELEASE_MANAGER_GITHUB_API_URL'] ?? ''))); if ($value === '') { $value = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_api_url', 'https://api.github.com')); } $value = rtrim($value, '/'); return preg_match('#^https?://#i', $value) === 1 ? $value : 'https://api.github.com'; } private function githubRepositoryPath(string $repository): string { [$owner, $name] = explode('/', $repository, 2); return rawurlencode($owner) . '/' . rawurlencode($name); } private function githubRequest(string $method, string $path, array $query = []): array { $token = $this->githubApiToken(); if ($token === '') { throw new RuntimeException('GitHub token is not configured.'); } $url = $this->githubApiBaseUrl() . '/' . ltrim($path, '/'); if ($query !== []) { $url .= '?' . http_build_query($query); } $curl = curl_init($url); if ($curl === false) { throw new RuntimeException('Could not initialize GitHub API request.'); } curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method)); curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3); curl_setopt($curl, CURLOPT_TIMEOUT, 10); curl_setopt($curl, CURLOPT_NOSIGNAL, true); curl_setopt($curl, CURLOPT_HTTPHEADER, [ 'Accept: application/vnd.github+json', 'Authorization: Bearer ' . $token, 'User-Agent: Truckwash-Release-Manager', 'X-GitHub-Api-Version: 2022-11-28', ]); $raw = curl_exec($curl); $error = curl_error($curl); $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); if ($raw === false) { throw new RuntimeException('GitHub API request failed: ' . $error); } $decoded = []; if (trim((string)$raw) !== '') { $decodedJson = json_decode((string)$raw, true); $decoded = is_array($decodedJson) ? $decodedJson : ['raw' => (string)$raw]; } if ($status < 200 || $status >= 300) { $message = is_array($decoded) ? (string)($decoded['message'] ?? $decoded['error'] ?? ('HTTP ' . $status)) : ('HTTP ' . $status); throw new RuntimeException('GitHub API request failed: ' . $message); } return $decoded; } private function publicGithubRepository(array $row): array { $fullName = self::normalizeGithubRepositoryName((string)($row['full_name'] ?? '')); return [ 'id' => isset($row['id']) ? (int)$row['id'] : null, 'name' => (string)($row['name'] ?? ''), 'full_name' => $fullName, 'private' => (bool)($row['private'] ?? false), 'default_branch' => (string)($row['default_branch'] ?? self::DEFAULT_BRANCH), 'description' => $row['description'] ?? null, 'html_url' => $row['html_url'] ?? null, 'clone_url' => $row['clone_url'] ?? null, 'ssh_url' => $row['ssh_url'] ?? null, 'pushed_at' => $row['pushed_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } private function publicGithubBranch(array $row): array { return [ 'name' => (string)($row['name'] ?? ''), 'commit_sha' => $row['commit']['sha'] ?? null, 'protected' => (bool)($row['protected'] ?? false), ]; } private function publicGithubCommit(array $row): array { $sha = (string)($row['sha'] ?? ''); $message = (string)($row['commit']['message'] ?? ''); $title = trim(strtok($message, "\n") ?: $message); return [ 'sha' => $sha, 'short_sha' => substr($sha, 0, 12), 'message' => $title, 'author_name' => $row['commit']['author']['name'] ?? $row['author']['login'] ?? null, 'authored_at' => $row['commit']['author']['date'] ?? null, 'html_url' => $row['html_url'] ?? null, ]; } private function releaseSuggestions(): array { $channels = $this->listChannels(); $targets = $this->listDeploymentTargets(); $deployments = $this->listDeployments(50); $versions = release_manager_schema_bootstrap::tablesExist() ? $this->selectRows( "SELECT app, repository, branch, deployed_url, build_url FROM release_versions WHERE repository IS NOT NULL OR branch IS NOT NULL OR deployed_url IS NOT NULL ORDER BY created_at DESC LIMIT 100" ) : []; $repositories = []; $branches = [self::DEFAULT_BRANCH, 'main', 'develop', 'staging']; $frontendUrls = []; $apiUrls = []; $healthUrls = []; $loadBalancerDomains = []; $serviceUuids = []; foreach ([ $this->moduleConfigValue('Coolify', 'public_gateway_host', 'api-v2.truckwash.io'), getenv('COOLIFY_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['COOLIFY_PUBLIC_GATEWAY_HOST'] ?? null), getenv('PUBLIC_GATEWAY_HOST') ?: ($_SERVER['PUBLIC_GATEWAY_HOST'] ?? null), getenv('RELEASE_LOAD_BALANCER_DOMAIN') ?: ($_SERVER['RELEASE_LOAD_BALANCER_DOMAIN'] ?? null), getenv('RELEASE_LOAD_BALANCER_DOMAINS') ?: ($_SERVER['RELEASE_LOAD_BALANCER_DOMAINS'] ?? null), ] as $domain) { $this->appendDomainSuggestion($loadBalancerDomains, $domain); } foreach (array_merge($targets, $deployments, $versions) as $row) { $this->appendSuggestion($repositories, $row['repository'] ?? null); $this->appendSuggestion($branches, $row['branch'] ?? null); $this->appendSuggestion($healthUrls, $row['health_url'] ?? null); $this->appendSuggestion($healthUrls, $row['deployment_url'] ?? null); $this->appendSuggestion($healthUrls, $row['deployed_url'] ?? null); $this->appendSuggestion($serviceUuids, $row['coolify_service_uuid'] ?? null); } foreach ($channels as $channel) { $slug = (string)($channel['slug'] ?? ''); $this->appendSuggestion($branches, $slug !== '' ? 'release/' . $slug : null); $this->appendSuggestion($frontendUrls, $channel['frontend_base_url'] ?? null); $this->appendSuggestion($apiUrls, $channel['api_base_url'] ?? null); if (!empty($channel['frontend_base_url'])) { $this->appendSuggestion($healthUrls, rtrim((string)$channel['frontend_base_url'], '/') . '/health'); } if (!empty($channel['api_base_url'])) { $this->appendSuggestion($healthUrls, rtrim((string)$channel['api_base_url'], '/') . '/ping'); } } foreach ([ 'GITHUB_REPOSITORY', 'RELEASE_FRONTEND_REPOSITORY', 'RELEASE_API_REPOSITORY', 'FRONTEND_GITHUB_REPOSITORY', 'API_GITHUB_REPOSITORY', ] as $key) { $this->appendSuggestion($repositories, getenv($key) ?: ($_SERVER[$key] ?? null)); } foreach (['GITHUB_REF_NAME', 'RELEASE_BRANCH', 'FRONTEND_BRANCH', 'API_BRANCH'] as $key) { $this->appendSuggestion($branches, getenv($key) ?: ($_SERVER[$key] ?? null)); } foreach (['FRONTEND_URL', 'APP_URL', 'VITE_APP_URL'] as $key) { $this->appendSuggestion($frontendUrls, getenv($key) ?: ($_SERVER[$key] ?? null)); } foreach (['API_URL', 'BACKEND_URL', 'PUBLIC_API_URL'] as $key) { $this->appendSuggestion($apiUrls, getenv($key) ?: ($_SERVER[$key] ?? null)); } $origin = trim((string)($_SERVER['HTTP_ORIGIN'] ?? '')); if ($origin !== '') { $this->appendSuggestion($frontendUrls, $origin); } $host = trim((string)($_SERVER['HTTP_HOST'] ?? '')); if ($host !== '') { $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $this->appendSuggestion($apiUrls, $scheme . '://' . $host); } $coolifyInstances = []; $coolifyProjects = []; $coolifyServices = []; $coolifyGithubApps = []; if ($this->tableExists('coolify_instances')) { $instanceRows = $this->selectRows( 'SELECT id, label, base_url, api_token_secret, status, default_project_uuid, default_environment_uuid, default_environment_name, default_server_uuid FROM coolify_instances WHERE deleted_at IS NULL ORDER BY status = \'ok\' DESC, label' ); $coolifyInstances = array_map(static function (array $row): array { return [ 'id' => (int)($row['id'] ?? 0), 'label' => (string)($row['label'] ?? ''), 'base_url' => (string)($row['base_url'] ?? ''), 'status' => (string)($row['status'] ?? 'unknown'), 'default_project_uuid' => $row['default_project_uuid'] ?? null, 'default_environment_uuid' => $row['default_environment_uuid'] ?? null, 'default_environment_name' => $row['default_environment_name'] ?? null, 'default_server_uuid' => $row['default_server_uuid'] ?? null, ]; }, $instanceRows); foreach ($instanceRows as $instanceRow) { foreach ($this->coolifyProjectSuggestions($instanceRow) as $project) { $coolifyProjects[] = $project; } foreach ($this->coolifyGithubAppSuggestions($instanceRow) as $githubApp) { $coolifyGithubApps[] = $githubApp; } foreach ($this->coolifyServiceSuggestions($instanceRow) as $service) { $coolifyServices[] = $service; $this->appendSuggestion($serviceUuids, $service['uuid'] ?? null); foreach (($service['urls'] ?? []) as $url) { $this->appendSuggestion($healthUrls, $url); } } } } $channelPresets = [ [ 'slug' => 'stable', 'name' => 'Stable', 'description' => 'Default production release channel.', 'rollout_percent' => 100, 'default_channel' => true, 'replay_enabled' => false, 'capture_level' => 'metadata', 'retention_days' => 14, ], [ 'slug' => 'canary', 'name' => 'Canary', 'description' => 'Small early-access channel for validating a release before broad rollout.', 'rollout_percent' => 5, 'default_channel' => false, 'replay_enabled' => true, 'capture_level' => 'full_redacted', 'retention_days' => 7, ], [ 'slug' => 'beta', 'name' => 'Beta', 'description' => 'Customer or staff opt-in channel for release candidate validation.', 'rollout_percent' => 0, 'default_channel' => false, 'replay_enabled' => true, 'capture_level' => 'metadata', 'retention_days' => 14, ], [ 'slug' => 'internal', 'name' => 'Internal', 'description' => 'Staff-only channel for internal verification and support replay.', 'rollout_percent' => 0, 'default_channel' => false, 'replay_enabled' => true, 'capture_level' => 'full_redacted', 'retention_days' => 14, ], ]; $frontendRepository = $this->firstSuggestion($repositories, ['front-end', 'frontend', 'vue']) ?? ($repositories[0] ?? ''); $apiRepository = $this->firstSuggestion($repositories, ['backend', 'api', 'php']) ?? ($repositories[1] ?? $repositories[0] ?? ''); return [ 'github_token_configured' => $this->hasGithubApiToken(), 'github_api_url' => $this->githubApiBaseUrl(), 'repositories' => array_values($repositories), 'branches' => array_values($branches), 'frontend_base_urls' => array_values($frontendUrls), 'api_base_urls' => array_values($apiUrls), 'health_urls' => array_values($healthUrls), 'load_balancer_domains' => array_values($loadBalancerDomains), 'coolify_instances' => $coolifyInstances, 'coolify_projects' => $coolifyProjects, 'coolify_github_apps' => $coolifyGithubApps, 'coolify_services' => $coolifyServices, 'coolify_service_uuids' => array_values($serviceUuids), 'channel_presets' => $channelPresets, 'target_presets' => [ [ 'label' => 'Frontend target', 'app' => 'frontend', 'repository' => $frontendRepository, 'branch' => $branches[0] ?? self::DEFAULT_BRANCH, 'health_url' => $healthUrls[0] ?? '', 'auto_deploy' => true, ], [ 'label' => 'API target', 'app' => 'api', 'repository' => $apiRepository, 'branch' => $branches[0] ?? self::DEFAULT_BRANCH, 'health_url' => $this->firstSuggestion($healthUrls, ['/ping']) ?? $this->firstSuggestion($healthUrls, ['/health']) ?? '', 'auto_deploy' => true, ], ], 'setup_steps' => [ ['key' => 'channels', 'done' => count($channels) > 0], ['key' => 'targets', 'done' => count($targets) > 0], ['key' => 'deployments', 'done' => count($deployments) > 0], ['key' => 'timeline', 'done' => (int)($this->timelineSummary()['events'] ?? 0) > 0], ], ]; } private function appendSuggestion(array &$values, mixed $value): void { $value = trim((string)($value ?? '')); if ($value === '') { return; } foreach (preg_split('/\s*,\s*/', $value) ?: [] as $part) { $part = trim($part); if ($part !== '' && !in_array($part, $values, true)) { $values[] = $part; } } } private function appendDomainSuggestion(array &$values, mixed $value): void { $value = trim((string)($value ?? '')); if ($value === '') { return; } foreach (preg_split('/\s*,\s*/', $value) ?: [] as $part) { $domain = self::domainSuggestionHost($part); if ($domain !== null && !in_array($domain, $values, true)) { $values[] = $domain; } } } private static function domainSuggestionHost(mixed $value): ?string { $raw = trim((string)($value ?? '')); if ($raw === '') { return null; } $candidate = preg_match('#^https?://#i', $raw) === 1 ? $raw : 'https://' . $raw; $host = parse_url($candidate, PHP_URL_HOST); $port = parse_url($candidate, PHP_URL_PORT); $host = strtolower(trim((string)$host, "[] \t\n\r\0\x0B.")); if ( $host === '' || $port !== null || $host === 'localhost' || str_ends_with($host, '.localhost') || str_contains($host, '/') || filter_var($host, FILTER_VALIDATE_IP) !== false ) { return null; } return $host; } private function firstSuggestion(array $values, array $needles): ?string { foreach ($values as $value) { foreach ($needles as $needle) { if (stripos((string)$value, $needle) !== false) { return (string)$value; } } } return null; } private function coolifyProjectSuggestions(array $instance): array { $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); if ($tokenSecret === '') { return []; } try { $token = replication_secret_box::decrypt($tokenSecret); $projects = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listProjects(); } catch (Throwable) { return []; } $suggestions = []; foreach ($this->payloadRows($projects) as $row) { if (!is_array($row)) { continue; } $uuid = trim((string)($row['uuid'] ?? '')); if ($uuid === '') { continue; } $suggestions[] = [ 'instance_id' => (int)($instance['id'] ?? 0), 'instance_label' => (string)($instance['label'] ?? ''), 'uuid' => $uuid, 'name' => (string)($row['name'] ?? $uuid), 'description' => (string)($row['description'] ?? ''), 'default' => $uuid === trim((string)($instance['default_project_uuid'] ?? '')), ]; } return array_slice($suggestions, 0, 50); } private function coolifyGithubAppSuggestions(array $instance): array { $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); if ($tokenSecret === '') { return []; } try { $token = replication_secret_box::decrypt($tokenSecret); $apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps(); } catch (Throwable) { return []; } $suggestions = []; foreach ($this->payloadRows($apps) as $row) { if (!is_array($row)) { continue; } $uuid = trim((string)($row['uuid'] ?? '')); if ($uuid === '') { continue; } $suggestions[] = [ 'instance_id' => (int)($instance['id'] ?? 0), 'instance_label' => (string)($instance['label'] ?? ''), 'uuid' => $uuid, 'name' => (string)($row['name'] ?? $uuid), 'organization' => (string)($row['organization'] ?? ''), 'type' => (string)($row['type'] ?? ''), 'is_system_wide' => (bool)($row['is_system_wide'] ?? false), 'html_url' => (string)($row['html_url'] ?? ''), ]; } return array_slice($suggestions, 0, 50); } private function coolifyServiceSuggestions(array $instance): array { $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); if ($tokenSecret === '') { return []; } try { $token = replication_secret_box::decrypt($tokenSecret); $services = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listServices(); } catch (Throwable) { return []; } $rows = $this->payloadRows($services); $suggestions = []; foreach ($rows as $row) { if (!is_array($row)) { continue; } $uuid = trim((string)($row['uuid'] ?? $row['id'] ?? '')); if ($uuid === '') { continue; } $urls = []; foreach (['fqdn', 'domain', 'url'] as $key) { $this->appendSuggestion($urls, $row[$key] ?? null); } foreach (['urls', 'domains'] as $key) { if (!is_array($row[$key] ?? null)) { continue; } foreach ($row[$key] as $url) { if (is_array($url)) { $this->appendSuggestion($urls, $url['url'] ?? $url['domain'] ?? $url['fqdn'] ?? null); } else { $this->appendSuggestion($urls, $url); } } } $suggestions[] = [ 'instance_id' => (int)($instance['id'] ?? 0), 'instance_label' => (string)($instance['label'] ?? ''), 'uuid' => $uuid, 'name' => (string)($row['name'] ?? $row['service_name'] ?? $uuid), 'status' => (string)($row['status'] ?? $row['deployment_status'] ?? 'unknown'), 'urls' => array_values($urls), ]; } return array_slice($suggestions, 0, 50); } private function payloadRows(array $payload): array { if ($payload === []) { return []; } if (array_keys($payload) === range(0, count($payload) - 1)) { return $payload; } foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'results'] as $key) { if (is_array($payload[$key] ?? null)) { return $this->payloadRows($payload[$key]); } } return []; } private function normalizeChannelInput(array $input, bool $creating): array { $slug = self::safeSlug((string)($input['slug'] ?? '')); if ($slug === '') { throw new RuntimeException('Release channel slug is required.'); } $name = trim((string)($input['name'] ?? ($creating ? '' : $slug))); if ($name === '') { throw new RuntimeException('Release channel name is required.'); } $retention = (int)($input['retention_days'] ?? 14); return [ 'slug' => $slug, 'name' => substr($name, 0, 128), 'description' => trim((string)($input['description'] ?? '')) ?: null, 'enabled' => $this->toBool($input['enabled'] ?? true) ? 1 : 0, 'default_channel' => $this->toBool($input['default_channel'] ?? false) ? 1 : 0, 'rollout_percent' => max(0, min(100, (float)($input['rollout_percent'] ?? 0))), 'frontend_base_url' => trim((string)($input['frontend_base_url'] ?? '')) ?: null, 'api_base_url' => trim((string)($input['api_base_url'] ?? '')) ?: null, 'replay_enabled' => $this->toBool($input['replay_enabled'] ?? false) ? 1 : 0, 'capture_level' => $this->normalizeCaptureLevel((string)($input['capture_level'] ?? 'metadata')), 'retention_days' => max(1, min(365, $retention > 0 ? $retention : 14)), 'metadata' => is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null), ]; } private function channelFromInput(array $input): array { $id = $this->nullablePositiveInt($input['channel_id'] ?? null); if ($id !== null) { return $this->getChannel($id); } $slug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? '')); if ($slug !== '') { $channel = $this->findChannelBySlug($slug); if ($channel !== null) { return $channel; } } throw new RuntimeException('Release channel is required.'); } private function deploymentTargetFromInput(array $input, int $channelId, string $app): ?array { $targetId = $this->nullablePositiveInt($input['target_id'] ?? null); if ($targetId !== null) { return $this->getDeploymentTarget($targetId); } return $this->selectOne( "SELECT * FROM release_deployment_targets WHERE deleted_at IS NULL AND channel_id = ? AND app = ? ORDER BY auto_deploy DESC, id DESC LIMIT 1", 'is', [$channelId, $app] ); } private function normalizeServiceSetMode(string $value): string { $mode = strtolower(trim($value)); if (!in_array($mode, self::SERVICE_SET_MODES, true)) { throw new RuntimeException('Release service set mode must be attach_existing, clone_existing, fresh_empty, or isolated_stack.'); } return $mode; } private function channelFromInputOrDefault(array $input, ?array $source = null): array { foreach (['channel_id', 'channel_slug', 'channel'] as $key) { if (array_key_exists($key, $input) && trim((string)$input[$key]) !== '') { return $this->channelFromInput($input); } } if ($source !== null && !empty($source['channel_id'])) { return $this->getChannel((int)$source['channel_id']); } return $this->defaultChannel(); } private function serviceSetTargetIdFromInput(array $input, string $app, ?array $source): ?int { $aliases = $app === 'api' ? ['api_target_id', 'php_target_id', 'backend_target_id'] : ['frontend_target_id']; $targets = is_array($input['targets'] ?? null) ? $input['targets'] : []; if (is_array($targets[$app] ?? null)) { foreach (['target_id', 'id'] as $key) { $aliases[] = $app . '.' . $key; } } foreach ($aliases as $key) { $value = str_contains($key, '.') ? ($targets[$app][substr($key, strpos($key, '.') + 1)] ?? null) : ($input[$key] ?? null); $id = $this->nullablePositiveInt($value); if ($id === null) { continue; } $target = $this->getDeploymentTarget($id); if ((string)($target['app'] ?? '') !== $app) { throw new RuntimeException(sprintf('Selected %s target does not match the requested app.', $app)); } return $id; } $sourceKey = $app . '_target_id'; return $source !== null ? $this->nullablePositiveInt($source[$sourceKey] ?? null) : null; } private function serviceSetDataTargetIdFromInput(array $input, string $kind, ?array $source): ?int { $dataTargets = is_array($input['data_targets'] ?? null) ? $input['data_targets'] : []; $value = $input[$kind . '_coolify_target_id'] ?? $input[$kind . '_target_id'] ?? $dataTargets[$kind . '_coolify_target_id'] ?? $dataTargets[$kind . '_target_id'] ?? $dataTargets[$kind] ?? null; $id = $this->nullablePositiveInt($value); if ($id !== null) { $target = $this->nullableCoolifyTarget($id); if ($target !== null && (string)($target['kind'] ?? '') !== $kind) { throw new RuntimeException(sprintf('Selected %s data service target has the wrong replica kind.', $kind)); } return $id; } $sourceKey = $kind . '_coolify_target_id'; return $source !== null ? $this->nullablePositiveInt($source[$sourceKey] ?? null) : null; } private function assertIsolatedStackTarget(?int $targetId, string $app, bool $allowCreatedService = false): void { if ($targetId === null) { throw new RuntimeException(sprintf('Isolated stack deployments require a new %s Coolify target.', $app)); } $target = $this->getDeploymentTarget($targetId); $context = self::jsonDecode($target['deploy_context_json'] ?? null); if ((string)($target['app'] ?? '') !== $app) { throw new RuntimeException(sprintf('Isolated stack %s target does not match the requested app.', $app)); } if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) === null) { throw new RuntimeException(sprintf('Isolated stack %s target must select a Coolify instance.', $app)); } if (trim((string)($target['coolify_service_uuid'] ?? '')) !== '' && !$allowCreatedService) { throw new RuntimeException(sprintf('Isolated stack %s target must not point at an existing Coolify service.', $app)); } if (!$this->toBool($context['coolify_auto_create'] ?? false)) { throw new RuntimeException(sprintf('Isolated stack %s target must create a new Coolify service.', $app)); } if (!$this->toBool($context['isolated_stack'] ?? false)) { throw new RuntimeException(sprintf('Isolated stack %s target must be marked as isolated.', $app)); } } private function assertIsolatedStackDataTarget(?int $targetId, string $kind): void { if ($targetId === null) { throw new RuntimeException(sprintf('Isolated stack deployments require a new %s data target.', $kind)); } $target = $this->nullableCoolifyTarget($targetId); if ($target === null) { throw new RuntimeException(sprintf('Selected %s isolated data target was not found.', $kind)); } if ((string)($target['kind'] ?? '') !== $kind) { throw new RuntimeException(sprintf('Selected %s isolated data target has the wrong kind.', $kind)); } $options = is_array($target['options'] ?? null) ? $target['options'] : []; if (!$this->toBool($options['isolated_stack'] ?? false)) { throw new RuntimeException(sprintf('Selected %s data target is not marked as an isolated stack target.', $kind)); } if ($this->toBool($options['production_data_attached'] ?? true)) { throw new RuntimeException(sprintf('Selected %s data target must not attach production data.', $kind)); } if (!$this->toBool($options['skip_replication_provisioning'] ?? false)) { throw new RuntimeException(sprintf('Selected %s data target must skip production replication provisioning.', $kind)); } } private function createIsolatedStackDataTarget( string $kind, array $input, array $channel, string $serviceSetName, ?int $frontendTargetId, ?int $apiTargetId, ?int $actorUserId ): int { if (!class_exists(coolify_manager::class) && function_exists('app_require')) { app_require('classes/coolify_manager.php'); } if (!class_exists(coolify_manager::class)) { throw new RuntimeException('Coolify integration is required to create isolated stack data services.'); } $placementTarget = $this->isolatedStackPlacementTarget($apiTargetId, $frontendTargetId); $context = self::jsonDecode($placementTarget['deploy_context_json'] ?? null); $dataServicesInput = is_array($input['data_services'] ?? null) ? $input['data_services'] : []; $dataInput = is_array($dataServicesInput[$kind] ?? null) ? $dataServicesInput[$kind] : []; $placementContext = array_replace($context, $dataInput); $instanceId = $this->nullablePositiveInt($placementTarget['coolify_instance_id'] ?? null); if ($instanceId === null) { throw new RuntimeException('Isolated stack data services require a Coolify instance.'); } $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]); if ($instance === null) { throw new RuntimeException('Coolify instance for isolated stack data services was not found.'); } $serverUuid = $this->releaseCoolifyServerUuid($placementContext, $instance); if ($serverUuid === '') { throw new RuntimeException('Release Manager could not resolve a Coolify server UUID for isolated data services.'); } $placementTarget['channel_slug'] = $placementTarget['channel_slug'] ?? $channel['slug'] ?? ''; $environment = $this->releaseCoolifyEnvironment($placementTarget, $placementContext, $instance); $stackSlug = self::safeSlug($serviceSetName !== '' ? $serviceSetName : ((string)($channel['slug'] ?? 'release') . '-isolated-stack')); $serviceName = substr('release-' . ($stackSlug !== '' ? $stackSlug : 'isolated-stack') . '-' . $kind, 0, 64); $payload = array_replace($dataInput, [ 'kind' => $kind, 'role' => 'replica', 'instance_id' => $instanceId, 'server_uuid' => $serverUuid, 'project_uuid' => trim((string)($placementContext['coolify_project_uuid'] ?? $placementContext['project_uuid'] ?? $instance['default_project_uuid'] ?? '')), 'environment_uuid' => $environment['uuid'] ?? '', 'environment_name' => $environment['name'], 'destination_uuid' => trim((string)($placementContext['coolify_destination_uuid'] ?? $placementContext['destination_uuid'] ?? $instance['default_destination_uuid'] ?? '')), 'label' => $serviceName, 'service_name' => $serviceName, 'resource_name' => $serviceName, 'isolated_stack' => true, 'skip_replication_provisioning' => true, 'deploy' => $this->toBool($input['deploy_data_targets'] ?? $input['deploy_isolated_data_targets'] ?? true), 'options' => [ 'isolated_stack' => true, 'skip_replication_provisioning' => true, 'production_data_attached' => false, 'release_service_set_name' => $serviceSetName, 'release_channel_slug' => (string)($channel['slug'] ?? ''), ], ]); $created = (new coolify_manager())->createTarget($payload, $actorUserId); $targetId = $this->nullablePositiveInt($created['target']['id'] ?? null); if ($targetId === null) { throw new RuntimeException(sprintf('Coolify did not return a %s isolated data target id.', $kind)); } $this->assertIsolatedStackDataTarget($targetId, $kind); return $targetId; } private function isolatedStackPlacementTarget(?int $apiTargetId, ?int $frontendTargetId): array { foreach ([$apiTargetId, $frontendTargetId] as $targetId) { if ($targetId === null) { continue; } $target = $this->getDeploymentTarget($targetId); if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) !== null) { return $target; } } throw new RuntimeException('Isolated stack data services require a frontend or API Coolify target.'); } private function uniqueServiceSetSlug(string $slug): string { $base = $slug !== '' ? $slug : 'service-set-' . date('Ymd-His'); $candidate = substr($base, 0, 64); $suffix = 2; while ($this->selectOne('SELECT id FROM release_service_sets WHERE slug = ? LIMIT 1', 's', [$candidate]) !== null) { $tail = '-' . $suffix; $candidate = substr($base, 0, 64 - strlen($tail)) . $tail; $suffix++; } return $candidate; } private function serviceSetStatus(string $mode, ?int $frontendTargetId, ?int $apiTargetId, array $dataTargets): string { $hasCode = $frontendTargetId !== null && $apiTargetId !== null; $hasData = !in_array(null, $dataTargets, true); if ($mode === 'isolated_stack') { return $hasCode && $hasData ? 'isolated_stack' : 'needs_isolated_targets'; } if ($hasCode && $hasData) { return $mode === 'clone_existing' ? 'provisioning' : 'ready'; } if ($mode === 'fresh_empty') { return 'isolated_empty'; } return $mode === 'clone_existing' ? 'needs_clone_targets' : 'needs_configuration'; } private function replicaProvisioningPlan(string $mode, ?array $source, array $dataTargets): array { $plan = []; foreach (self::STACK_DATA_KINDS as $kind) { $sourceTargetId = $source !== null ? $this->nullablePositiveInt($source[$kind . '_coolify_target_id'] ?? null) : null; $sourceTarget = $this->nullableCoolifyTarget($sourceTargetId); $target = $this->nullableCoolifyTarget($dataTargets[$kind] ?? null); $plan[$kind] = [ 'action' => match ($mode) { 'clone_existing' => 'clone_replica_from_source', 'isolated_stack' => 'create_isolated_empty_stack_service', 'fresh_empty' => 'register_isolated_empty_service', default => 'attach_existing_service', }, 'source_coolify_target_id' => $sourceTargetId, 'source_replication_host_id' => $sourceTarget['replication']['id'] ?? null, 'target_coolify_target_id' => $dataTargets[$kind] ?? null, 'target_replication_host_id' => $target['replication']['id'] ?? null, 'production_replication_attached' => $mode === 'attach_existing', ]; } return $plan; } private function bundleAppInput(array $input, string $app, ?array $target, string $versionLabel): array { $appPayload = is_array($input[$app] ?? null) ? $input[$app] : []; if ($app === 'api') { $appPayload = array_replace( is_array($input['php'] ?? null) ? $input['php'] : [], is_array($input['backend'] ?? null) ? $input['backend'] : [], $appPayload ); } $repository = trim((string)($appPayload['repository'] ?? $input[$app . '_repository'] ?? $target['repository'] ?? '')); $normalizedRepository = self::normalizeGithubRepositoryName($repository); if ($normalizedRepository !== '') { $repository = $normalizedRepository; } if ($repository === '') { throw new RuntimeException(sprintf('%s repository is required for bundle releases.', $app === 'api' ? 'PHP backend' : 'Frontend')); } $branch = trim((string)($appPayload['branch'] ?? $input[$app . '_branch'] ?? $target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; $rawCommitSha = trim((string)($appPayload['commit_sha'] ?? $appPayload['commit'] ?? $input[$app . '_commit_sha'] ?? '')); $commitMode = $this->normalizeCommitMode((string)($appPayload['commit_mode'] ?? $input[$app . '_commit_mode'] ?? ''), $rawCommitSha); $commitSha = $commitMode === 'specific' && $rawCommitSha !== '' ? $rawCommitSha : null; $githubAccess = $this->githubRepositoryAccess([ 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'commit_mode' => $commitMode, ]); if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) { throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.')); } if (($githubAccess['ok'] ?? false) && !empty($githubAccess['commit_sha'])) { $commitSha = (string)$githubAccess['commit_sha']; $branch = (string)($githubAccess['branch'] ?? $branch); } return [ 'app' => $app, 'repository' => $repository, 'branch' => $branch, 'commit_mode' => $commitMode, 'commit_sha' => $commitSha, 'version_label' => sprintf('%s-%s', $versionLabel, $app === 'api' ? 'php' : 'frontend'), 'deployed_url' => is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null, 'github_access' => $githubAccess, ]; } private function createBundleVersion(array $input, string $app): int { return $this->createVersion([ 'app' => $app, 'repository' => $input['repository'], 'branch' => $input['branch'], 'commit_sha' => $input['commit_sha'], 'version_label' => $input['version_label'], 'deployed_url' => $input['deployed_url'] ?? null, 'status' => 'draft', 'metadata' => [ 'commit_mode' => $input['commit_mode'], 'github_access' => $input['github_access'], 'bundle_member' => true, ], ]); } private function getChannel(int $id): array { $row = $this->selectOne('SELECT * FROM release_channels WHERE id = ? AND deleted_at IS NULL', 'i', [$id]); if ($row === null) { throw new RuntimeException('Release channel not found.'); } return $row; } private function findChannelBySlug(string $slug): ?array { return $this->selectOne( 'SELECT * FROM release_channels WHERE slug = ? AND deleted_at IS NULL LIMIT 1', 's', [$slug] ); } private function getVersion(int $id): array { return $this->selectOne('SELECT * FROM release_versions WHERE id = ?', 'i', [$id]) ?? []; } private function getDeployment(int $id): array { $row = $this->selectOne( "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url FROM release_deployments d INNER JOIN release_channels c ON c.id = d.channel_id LEFT JOIN release_versions v ON v.id = d.version_id WHERE d.id = ?", 'i', [$id] ); if ($row === null) { throw new RuntimeException('Release deployment not found.'); } return $row; } private function getServiceSet(int $id): array { $row = $this->selectOne( "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name FROM release_service_sets s LEFT JOIN release_channels c ON c.id = s.channel_id WHERE s.id = ? AND s.deleted_at IS NULL", 'i', [$id] ); if ($row === null) { throw new RuntimeException('Release service set not found.'); } return $row; } private function getBundle(int $id): array { $row = $this->selectOne( "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug FROM release_bundles b INNER JOIN release_channels c ON c.id = b.channel_id INNER JOIN release_service_sets s ON s.id = b.service_set_id WHERE b.id = ? AND b.deleted_at IS NULL", 'i', [$id] ); if ($row === null) { throw new RuntimeException('Release bundle not found.'); } return $row; } private function getDeploymentTarget(int $id): array { $row = $this->selectOne( "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label FROM release_deployment_targets t INNER JOIN release_channels c ON c.id = t.channel_id LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id WHERE t.id = ? AND t.deleted_at IS NULL", 'i', [$id] ); if ($row === null) { throw new RuntimeException('Release deployment target not found.'); } return $row; } private function publicChannel(array $channel): array { return [ 'id' => (int)($channel['id'] ?? 0), 'slug' => (string)($channel['slug'] ?? ''), 'name' => (string)($channel['name'] ?? ''), 'description' => $channel['description'] ?? null, 'enabled' => (bool)((int)($channel['enabled'] ?? 0)), 'default_channel' => (bool)((int)($channel['default_channel'] ?? 0)), 'rollout_percent' => (float)($channel['rollout_percent'] ?? 0), 'frontend_base_url' => $channel['frontend_base_url'] ?? null, 'api_base_url' => $channel['api_base_url'] ?? null, 'replay_enabled' => (bool)((int)($channel['replay_enabled'] ?? 0)), 'capture_level' => (string)($channel['capture_level'] ?? 'metadata'), 'retention_days' => (int)($channel['retention_days'] ?? 14), 'metadata' => self::jsonDecode($channel['metadata_json'] ?? null), 'created_at' => $channel['created_at'] ?? null, 'updated_at' => $channel['updated_at'] ?? null, ]; } private function publicRuntimeChannel(array $channel): array { $public = $this->publicChannel($channel); unset($public['frontend_base_url'], $public['api_base_url']); return $public; } private function publicRuntimeChannelOptions(array $channels): array { return array_map(function (array $channel): array { return [ 'channel' => $this->publicRuntimeChannel($channel), 'versions' => $this->currentVersionsForChannel((int)($channel['id'] ?? 0)), 'availability' => $this->channelAvailability($channel), ]; }, $channels); } public static function publicAssignmentSubjectSuggestion(array $candidate): ?array { $subjectType = strtolower(trim((string)($candidate['subject_type'] ?? ''))); if (!in_array($subjectType, self::SUBJECT_TYPES, true)) { return null; } $subjectId = self::safeIdentifier((string)($candidate['subject_id'] ?? ''), 64); if ($subjectId === '') { return null; } $title = self::safeDisplayText($candidate['title'] ?? '', 120); if ($title === '') { $title = $subjectType . ':' . $subjectId; } $description = self::safeDisplayText($candidate['description'] ?? '', 180); $icon = self::safeIconClass($candidate['icon'] ?? self::assignmentSubjectIcon($subjectType)); $source = self::safeIdentifier((string)($candidate['source'] ?? $subjectType), 32) ?: $subjectType; return [ 'subject_type' => $subjectType, 'subject_id' => $subjectId, 'label' => $description !== '' ? $title . ' - ' . $description : $title, 'title' => $title, 'description' => $description, 'icon' => $icon, 'source' => $source, ]; } private function searchAssignmentUsers(string $query, int $limit): array { $like = '%' . $query . '%'; $rows = $this->selectRows( "SELECT id, customer_number, display_name, email, phone_country_code, phone FROM users WHERE CAST(id AS CHAR) LIKE ? OR CAST(customer_number AS CHAR) LIKE ? OR display_name LIKE ? OR email LIKE ? ORDER BY id DESC LIMIT ?", 'ssssi', [$like, $like, $like, $like, $limit] ); return array_map(function (array $row): array { $id = (string)($row['id'] ?? ''); $customerNumber = trim((string)($row['customer_number'] ?? '')); $displayName = self::safeDisplayText($row['display_name'] ?? '', 80); $email = self::safeDisplayText($row['email'] ?? '', 80); $parts = array_filter([ $customerNumber !== '' ? 'Customer #' . $customerNumber : '', $email, $this->phoneLabel($row), ]); return [ 'subject_type' => 'user', 'subject_id' => $id, 'title' => $displayName !== '' ? $displayName : 'User #' . $id, 'description' => implode(' / ', $parts), 'icon' => self::assignmentSubjectIcon('user'), 'source' => 'users', ]; }, $rows); } private function searchAssignmentSubusers(string $query, int $limit): array { $like = '%' . $query . '%'; $rows = $this->selectRows( "SELECT id, username, name, email, phone_country_code, phone FROM subusers WHERE CAST(id AS CHAR) LIKE ? OR username LIKE ? OR name LIKE ? OR email LIKE ? OR CAST(phone AS CHAR) LIKE ? ORDER BY id DESC LIMIT ?", 'sssssi', [$like, $like, $like, $like, $like, $limit] ); return array_map(function (array $row): array { $id = (string)($row['id'] ?? ''); $name = self::safeDisplayText($row['name'] ?? '', 80); $username = self::safeDisplayText($row['username'] ?? '', 80); $email = self::safeDisplayText($row['email'] ?? '', 80); $parts = array_filter([ $username !== '' ? '@' . ltrim($username, '@') : '', $email, $this->phoneLabel($row), ]); return [ 'subject_type' => 'subuser', 'subject_id' => $id, 'title' => $name !== '' ? $name : 'Subuser #' . $id, 'description' => implode(' / ', $parts), 'icon' => self::assignmentSubjectIcon('subuser'), 'source' => 'subusers', ]; }, $rows); } private function searchAssignmentCustomers(string $query, int $limit): array { try { $result = (new economicCustomers())->listCustomers(1, $limit, $query, null); } catch (Throwable) { return []; } $customers = is_array($result->collection ?? null) ? $result->collection : []; return array_map(static function (object $customer): array { $customerNumber = (string)($customer->customerNumber ?? $customer->customer_number ?? ''); $name = self::safeDisplayText($customer->name ?? $customer->customer_name ?? '', 100); $email = self::safeDisplayText($customer->email ?? '', 80); $city = self::safeDisplayText($customer->city ?? '', 80); $parts = array_filter([ $customerNumber !== '' ? 'Customer #' . $customerNumber : '', $email, $city, ]); return [ 'subject_type' => 'customer', 'subject_id' => $customerNumber, 'title' => $name !== '' ? $name : 'Customer #' . $customerNumber, 'description' => implode(' / ', $parts), 'icon' => self::assignmentSubjectIcon('customer'), 'source' => 'customers', ]; }, $customers); } private static function normalizeAssignmentSubjectSearch(mixed $value): string { $query = self::safeDisplayText($value, 80); return trim($query); } private static function normalizeAssignmentSubjectLimit(mixed $value): int { $limit = (int)$value; if ($limit <= 0) { return 5; } return min(10, max(1, $limit)); } private static function safeDisplayText(mixed $value, int $maxLength): string { $text = trim(strip_tags((string)$value)); $text = preg_replace('/\s+/', ' ', $text) ?? ''; return substr($text, 0, max(1, $maxLength)); } private static function safeIconClass(mixed $value): string { $icon = trim((string)$value); if (!preg_match('/^[a-z0-9 _-]+$/i', $icon)) { return 'fas fa-tag'; } return $icon; } private static function assignmentSubjectIcon(string $subjectType): string { return match ($subjectType) { 'customer' => 'fas fa-building', 'subuser' => 'fas fa-id-badge', default => 'fas fa-user', }; } private function phoneLabel(array $row): string { $countryCode = trim((string)($row['phone_country_code'] ?? '')); $phone = trim((string)($row['phone'] ?? '')); if ($phone === '') { return ''; } return $countryCode !== '' ? '+' . $countryCode . ' ' . $phone : $phone; } private function publicVersion(?array $version): ?array { if (!$version || empty($version['id'])) { return null; } $metadata = self::jsonDecode($version['metadata_json'] ?? null); $commit = $this->versionGithubCommit(['metadata' => $metadata]); return [ 'id' => (int)$version['id'], 'app' => (string)$version['app'], 'repository' => $version['repository'] ?? null, 'branch' => $version['branch'] ?? null, 'commit_sha' => $version['commit_sha'] ?? null, 'commit' => $commit, 'commit_authored_at' => $commit['authored_at'] ?? null, 'tag' => $version['tag'] ?? null, 'version_label' => $version['version_label'] ?? null, 'build_url' => $version['build_url'] ?? null, 'artifact_url' => $version['artifact_url'] ?? null, 'deployed_url' => $version['deployed_url'] ?? null, 'status' => (string)($version['status'] ?? 'unknown'), 'metadata' => $metadata, 'created_at' => $version['created_at'] ?? null, 'deployed_at' => $version['deployed_at'] ?? null, ]; } private function publicAssignment(array $assignment): array { return [ 'id' => (int)($assignment['id'] ?? 0), 'subject_type' => (string)($assignment['subject_type'] ?? ''), 'subject_id' => (string)($assignment['subject_id'] ?? ''), 'channel_id' => (int)($assignment['channel_id'] ?? 0), 'channel_slug' => (string)($assignment['channel_slug'] ?? ''), 'channel_name' => (string)($assignment['channel_name'] ?? ''), 'reason' => $assignment['reason'] ?? null, 'expires_at' => $assignment['expires_at'] ?? null, 'actor_user_id' => isset($assignment['actor_user_id']) ? (int)$assignment['actor_user_id'] : null, 'created_at' => $assignment['created_at'] ?? null, ]; } private function publicDeploymentTarget(array $target): array { return [ 'id' => (int)($target['id'] ?? 0), 'channel_id' => (int)($target['channel_id'] ?? 0), 'channel_slug' => (string)($target['channel_slug'] ?? ''), 'channel_name' => (string)($target['channel_name'] ?? ''), 'app' => (string)($target['app'] ?? ''), 'coolify_instance_id' => isset($target['coolify_instance_id']) ? (int)$target['coolify_instance_id'] : null, 'coolify_instance_label' => $target['coolify_instance_label'] ?? null, 'coolify_service_uuid' => $target['coolify_service_uuid'] ?? null, 'repository' => (string)($target['repository'] ?? ''), 'branch' => (string)($target['branch'] ?? ''), 'auto_deploy' => (bool)((int)($target['auto_deploy'] ?? 0)), 'health_url' => $target['health_url'] ?? null, 'deploy_context' => self::jsonDecode($target['deploy_context_json'] ?? null), 'created_at' => $target['created_at'] ?? null, 'updated_at' => $target['updated_at'] ?? null, ]; } private function publicServiceSet(array $serviceSet, bool $includeBundles = true): array { $dataServices = []; foreach (self::STACK_DATA_KINDS as $kind) { $dataServices[$kind] = $this->nullableCoolifyTarget( $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null) ); } $attachedBundles = $includeBundles ? $this->serviceSetBundles((int)($serviceSet['id'] ?? 0)) : []; $serviceSetId = (int)($serviceSet['id'] ?? 0); return [ 'id' => $serviceSetId, 'channel_id' => isset($serviceSet['channel_id']) ? (int)$serviceSet['channel_id'] : null, 'channel_slug' => $serviceSet['channel_slug'] ?? null, 'channel_name' => $serviceSet['channel_name'] ?? null, 'name' => (string)($serviceSet['name'] ?? ''), 'slug' => (string)($serviceSet['slug'] ?? ''), 'mode' => (string)($serviceSet['mode'] ?? 'attach_existing'), 'source_service_set_id' => isset($serviceSet['source_service_set_id']) ? (int)$serviceSet['source_service_set_id'] : null, 'status' => (string)($serviceSet['status'] ?? 'unknown'), 'active' => $serviceSetId > 0 && $this->serviceSetIsActive($serviceSetId), 'targets' => [ 'frontend' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null)), 'api' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['api_target_id'] ?? null)), ], 'data_services' => $dataServices, 'stack' => [ 'frontend' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null)), 'api' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['api_target_id'] ?? null)), 'database' => $dataServices['database'], 'redis' => $dataServices['redis'], 'minio' => $dataServices['minio'], ], 'health' => self::jsonDecode($serviceSet['health_json'] ?? null), 'metadata' => self::jsonDecode($serviceSet['metadata_json'] ?? null), 'attached_bundle_count' => count($attachedBundles), 'attached_bundles' => $attachedBundles, 'created_at' => $serviceSet['created_at'] ?? null, 'updated_at' => $serviceSet['updated_at'] ?? null, ]; } private function publicBundle(array $bundle, bool $includeServiceSet = true): array { $bundleId = (int)($bundle['id'] ?? 0); $frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null); $apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null); $frontendDeploymentId = $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null); $apiDeploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null); $frontendVersion = $frontendVersionId !== null ? $this->publicVersion($this->getVersion($frontendVersionId)) : null; $apiVersion = $apiVersionId !== null ? $this->publicVersion($this->getVersion($apiVersionId)) : null; $frontendCommit = $this->versionGithubCommit($frontendVersion); $apiCommit = $this->versionGithubCommit($apiVersion); $active = $bundleId > 0 && $this->bundleIsActive($bundleId); $status = (string)($bundle['status'] ?? 'draft'); if ($status === 'promoted' && !$active) { $status = 'superseded'; } return [ 'id' => $bundleId, 'channel_id' => (int)($bundle['channel_id'] ?? 0), 'channel_slug' => (string)($bundle['channel_slug'] ?? ''), 'channel_name' => (string)($bundle['channel_name'] ?? ''), 'service_set_id' => (int)($bundle['service_set_id'] ?? 0), 'service_set_name' => $bundle['service_set_name'] ?? null, 'service_set_slug' => $bundle['service_set_slug'] ?? null, 'service_set' => $includeServiceSet ? $this->publicServiceSet($this->getServiceSet((int)$bundle['service_set_id']), false) : null, 'version_label' => $bundle['version_label'] ?? null, 'status' => $status, 'active' => $active, 'apps' => [ 'frontend' => [ 'version_id' => $frontendVersionId, 'deployment_id' => $frontendDeploymentId, 'repository' => $bundle['frontend_repository'] ?? null, 'branch' => $bundle['frontend_branch'] ?? null, 'commit_sha' => $bundle['frontend_commit_sha'] ?? null, 'commit' => $frontendCommit, 'commit_authored_at' => $frontendCommit['authored_at'] ?? null, 'version' => $frontendVersion, 'deployment' => $this->nullableDeployment($frontendDeploymentId), ], 'api' => [ 'version_id' => $apiVersionId, 'deployment_id' => $apiDeploymentId, 'repository' => $bundle['api_repository'] ?? null, 'branch' => $bundle['api_branch'] ?? null, 'commit_sha' => $bundle['api_commit_sha'] ?? null, 'commit' => $apiCommit, 'commit_authored_at' => $apiCommit['authored_at'] ?? null, 'version' => $apiVersion, 'deployment' => $this->nullableDeployment($apiDeploymentId), ], ], 'deployment_result' => self::jsonDecode($bundle['deployment_result_json'] ?? null), 'metadata' => self::jsonDecode($bundle['metadata_json'] ?? null), 'actor_user_id' => isset($bundle['actor_user_id']) ? (int)$bundle['actor_user_id'] : null, 'deployed_at' => $bundle['deployed_at'] ?? null, 'promoted_at' => $bundle['promoted_at'] ?? null, 'created_at' => $bundle['created_at'] ?? null, 'updated_at' => $bundle['updated_at'] ?? null, ]; } private function versionGithubCommit(?array $version): ?array { $metadata = is_array($version['metadata'] ?? null) ? $version['metadata'] : []; $access = is_array($metadata['github_access'] ?? null) ? $metadata['github_access'] : []; foreach (['commit', 'latest_commit'] as $key) { $commit = is_array($access[$key] ?? null) ? $access[$key] : []; if (trim((string)($commit['sha'] ?? '')) !== '') { return $commit; } } return null; } private function serviceSetBundles(int $serviceSetId): array { if ($serviceSetId <= 0 || !release_manager_schema_bootstrap::tablesExist()) { return []; } return array_map( fn(array $row): array => $this->publicBundle($row, false), $this->selectRows( "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug FROM release_bundles b INNER JOIN release_channels c ON c.id = b.channel_id INNER JOIN release_service_sets s ON s.id = b.service_set_id WHERE b.deleted_at IS NULL AND b.service_set_id = ? ORDER BY b.created_at DESC, b.id DESC LIMIT 10", 'i', [$serviceSetId] ) ); } private function serviceSetIsActive(int $serviceSetId): bool { return $this->selectOne( 'SELECT id FROM release_channel_versions WHERE service_set_id = ? AND active = 1 LIMIT 1', 'i', [$serviceSetId] ) !== null; } private function bundleIsActive(int $bundleId): bool { return $this->selectOne( 'SELECT id FROM release_channel_versions WHERE bundle_id = ? AND active = 1 LIMIT 1', 'i', [$bundleId] ) !== null; } private function isolatedDeploymentTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool { if ($targetId === null || $targetId <= 0) { return false; } $target = $this->nullableDeploymentTarget($targetId); if ($target === null) { return false; } $context = is_array($target['deploy_context'] ?? null) ? $target['deploy_context'] : []; if (!$this->toBool($context['isolated_stack'] ?? false)) { return false; } if ($this->toBool($context['production_data_attached'] ?? false)) { return false; } return $this->selectOne( "SELECT id FROM release_service_sets WHERE id <> ? AND deleted_at IS NULL AND (frontend_target_id = ? OR api_target_id = ?) LIMIT 1", 'iii', [$serviceSetId, $targetId, $targetId] ) === null; } private function isolatedCoolifyTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool { if ($targetId === null || $targetId <= 0 || !$this->tableExists('coolify_targets')) { return false; } $target = $this->nullableCoolifyTarget($targetId); if ($target === null) { return false; } $options = is_array($target['options'] ?? null) ? $target['options'] : []; if (!$this->toBool($options['isolated_stack'] ?? false)) { return false; } if ($this->toBool($options['production_data_attached'] ?? false)) { return false; } return $this->selectOne( "SELECT id FROM release_service_sets WHERE id <> ? AND deleted_at IS NULL AND ( database_coolify_target_id = ? OR redis_coolify_target_id = ? OR minio_coolify_target_id = ? ) LIMIT 1", 'iiii', [$serviceSetId, $targetId, $targetId, $targetId] ) === null; } private function nullableDeploymentTarget(?int $id): ?array { if ($id === null || $id <= 0) { return null; } try { return $this->publicDeploymentTarget($this->getDeploymentTarget($id)); } catch (Throwable) { return null; } } private function nullableDeployment(?int $id): ?array { if ($id === null || $id <= 0) { return null; } try { return $this->publicDeployment($this->getDeployment($id)); } catch (Throwable) { return null; } } private function nullableCoolifyTarget(?int $id): ?array { if ($id === null || $id <= 0 || !$this->tableExists('coolify_targets')) { return null; } $hasReplicationHosts = $this->tableExists('replication_hosts'); $replicationColumns = $hasReplicationHosts ? "h.id AS host_id, h.kind AS host_kind, h.label AS host_label, h.host AS host_host, h.port AS host_port, h.role AS host_role, h.status AS host_status, h.replication_source_id AS host_replication_source_id, h.last_status_json AS host_last_status_json, h.last_checked_at AS host_last_checked_at" : "NULL AS host_id, NULL AS host_kind, NULL AS host_label, NULL AS host_host, NULL AS host_port, NULL AS host_role, NULL AS host_status, NULL AS host_replication_source_id, NULL AS host_last_status_json, NULL AS host_last_checked_at"; $replicationJoin = $hasReplicationHosts ? 'LEFT JOIN replication_hosts h ON h.id = t.replication_host_id' : ''; $row = $this->selectOne( "SELECT t.*, i.label AS instance_label, i.base_url AS instance_base_url, $replicationColumns FROM coolify_targets t LEFT JOIN coolify_instances i ON i.id = t.instance_id $replicationJoin WHERE t.id = ? AND t.deleted_at IS NULL", 'i', [$id] ); if ($row === null) { return null; } return [ 'id' => (int)($row['id'] ?? 0), 'kind' => (string)($row['kind'] ?? ''), 'label' => (string)($row['label'] ?? ''), 'role' => (string)($row['role'] ?? ''), 'instance_id' => isset($row['instance_id']) ? (int)$row['instance_id'] : null, 'instance_label' => $row['instance_label'] ?? null, 'resource_uuid' => $row['resource_uuid'] ?? null, 'resource_name' => $row['resource_name'] ?? null, 'deployment_status' => (string)($row['deployment_status'] ?? 'unknown'), 'availability_state' => (string)($row['availability_state'] ?? 'unknown'), 'last_reconcile_status' => $row['last_reconcile_status'] ?? null, 'last_reconciled_at' => $row['last_reconciled_at'] ?? null, 'replication' => !empty($row['host_id']) ? [ 'id' => (int)$row['host_id'], 'kind' => (string)($row['host_kind'] ?? $row['kind'] ?? ''), 'label' => (string)($row['host_label'] ?? ''), 'host' => $row['host_host'] ?? null, 'port' => isset($row['host_port']) ? (int)$row['host_port'] : null, 'role' => (string)($row['host_role'] ?? 'unknown'), 'status' => (string)($row['host_status'] ?? 'unknown'), 'source_host_id' => isset($row['host_replication_source_id']) ? (int)$row['host_replication_source_id'] : null, 'last_status' => self::jsonDecode($row['host_last_status_json'] ?? null), 'last_checked_at' => $row['host_last_checked_at'] ?? null, ] : null, 'options' => self::jsonDecode($row['options_json'] ?? null), ]; } private function publicDeployment(array $deployment): array { $status = (string)($deployment['status'] ?? 'unknown'); $result = self::jsonDecode($deployment['result_json'] ?? null); $failureSummary = is_array($result['failure_summary'] ?? null) ? $result['failure_summary'] : null; $promotable = self::deploymentCanBePromoted($status); return [ 'id' => (int)($deployment['id'] ?? 0), 'channel_id' => (int)($deployment['channel_id'] ?? 0), 'channel_slug' => (string)($deployment['channel_slug'] ?? ''), 'channel_name' => (string)($deployment['channel_name'] ?? ''), 'target_id' => isset($deployment['target_id']) ? (int)$deployment['target_id'] : null, 'version_id' => isset($deployment['version_id']) ? (int)$deployment['version_id'] : null, 'service_set_id' => isset($deployment['service_set_id']) ? (int)$deployment['service_set_id'] : null, 'bundle_id' => isset($deployment['bundle_id']) ? (int)$deployment['bundle_id'] : null, 'deployment_kind' => (string)($deployment['deployment_kind'] ?? 'single_app'), 'version_label' => $deployment['version_label'] ?? null, 'app' => (string)($deployment['app'] ?? ''), 'provider' => (string)($deployment['provider'] ?? 'coolify'), 'repository' => $deployment['repository'] ?? null, 'branch' => $deployment['branch'] ?? null, 'commit_sha' => $deployment['commit_sha'] ?? null, 'status' => $status, 'deployment_url' => $deployment['deployment_url'] ?? $deployment['deployed_url'] ?? null, 'actor_user_id' => isset($deployment['actor_user_id']) ? (int)$deployment['actor_user_id'] : null, 'result' => $result, 'failure_summary' => $failureSummary, 'error_message' => $deployment['error_message'] ?? null, 'promotable' => $promotable, 'promotion_blocked_reason' => $promotable ? null : self::deploymentPromotionBlockedReason($deployment), 'started_at' => $deployment['started_at'] ?? null, 'completed_at' => $deployment['completed_at'] ?? null, 'created_at' => $deployment['created_at'] ?? null, 'updated_at' => $deployment['updated_at'] ?? null, ]; } private function publicTimelineEvent(array $event): array { return [ 'id' => (int)($event['id'] ?? 0), 'timeline_session_id' => isset($event['timeline_session_id']) ? (int)$event['timeline_session_id'] : null, 'trace_id' => (string)($event['trace_id'] ?? ''), 'event_type' => (string)($event['event_type'] ?? ''), 'severity' => (string)($event['severity'] ?? 'info'), 'module_key' => $event['module_key'] ?? null, 'route_path' => $event['route_path'] ?? null, 'component' => $event['component'] ?? null, 'request_id' => $event['request_id'] ?? null, 'occurred_at' => $event['occurred_at'] ?? null, 'payload' => self::jsonDecode($event['payload_json'] ?? null), 'principal_type' => $event['principal_type'] ?? null, 'principal_id' => $event['principal_id'] ?? null, 'customer_number' => isset($event['customer_number']) ? (int)$event['customer_number'] : null, 'channel_slug' => $event['channel_slug'] ?? null, ]; } private function publicTimelineSession(array $session): array { $moduleKeys = array_filter(array_map('trim', explode(',', (string)($session['module_keys'] ?? '')))); $principal = $this->timelinePrincipal($session); return [ 'id' => (int)($session['id'] ?? 0), 'trace_id' => (string)($session['trace_id'] ?? ''), 'principal_type' => $session['principal_type'] ?? null, 'principal_id' => $session['principal_id'] ?? null, 'customer_number' => isset($session['customer_number']) ? (int)$session['customer_number'] : null, 'user' => $principal, 'channel_id' => isset($session['channel_id']) ? (int)$session['channel_id'] : null, 'channel_slug' => $session['channel_slug'] ?? null, 'release' => [ 'frontend' => [ 'version_label' => $session['frontend_version_label'] ?? null, 'commit_sha' => $session['frontend_commit_sha'] ?? null, ], 'api' => [ 'version_label' => $session['api_version_label'] ?? null, 'commit_sha' => $session['api_commit_sha'] ?? null, ], ], 'device' => [ 'type' => $session['device_type'] ?? null, 'browser_name' => $session['browser_name'] ?? null, 'browser_version' => $session['browser_version'] ?? null, 'os_name' => $session['os_name'] ?? null, 'os_version' => $session['os_version'] ?? null, 'viewport_width' => isset($session['viewport_width']) ? (int)$session['viewport_width'] : null, 'viewport_height' => isset($session['viewport_height']) ? (int)$session['viewport_height'] : null, 'device_pixel_ratio' => isset($session['device_pixel_ratio']) ? (float)$session['device_pixel_ratio'] : null, 'user_agent' => $session['user_agent'] ?? null, ], 'last_route_path' => $session['last_route_path'] ?? null, 'event_count' => isset($session['event_count']) ? (int)$session['event_count'] : 0, 'error_count' => isset($session['error_count']) ? (int)$session['error_count'] : 0, 'error_report_count' => isset($session['error_report_count']) ? (int)$session['error_report_count'] : 0, 'module_keys' => array_values($moduleKeys), 'first_event_at' => $session['first_event_at'] ?? null, 'last_event_at' => $session['last_event_at'] ?? $session['last_seen_at'] ?? null, 'created_at' => $session['created_at'] ?? null, 'last_seen_at' => $session['last_seen_at'] ?? null, ]; } private function timelinePrincipal(array $session): array { $type = $session['principal_type'] ?? null; $id = $session['principal_id'] ?? null; $customerNumber = isset($session['customer_number']) ? (int)$session['customer_number'] : null; $label = trim(implode(':', array_filter([(string)$type, (string)$id]))); $name = null; $email = null; if ($type === 'user' && is_numeric($id) && $this->tableExists('users')) { $row = $this->selectOne( 'SELECT id, customer_number, display_name, email FROM users WHERE id = ? LIMIT 1', 'i', [(int)$id] ); if ($row !== null) { $name = $row['display_name'] ?? null; $email = $row['email'] ?? null; $customerNumber = isset($row['customer_number']) ? (int)$row['customer_number'] : $customerNumber; } } if ($type === 'subuser' && is_numeric($id) && $this->tableExists('subusers')) { $row = $this->selectOne( 'SELECT id, username, name, email FROM subusers WHERE id = ? LIMIT 1', 'i', [(int)$id] ); if ($row !== null) { $name = $row['name'] ?? $row['username'] ?? null; $email = $row['email'] ?? null; } } $displayLabel = trim((string)($name ?: $email ?: $label)); if ($displayLabel === '') { $displayLabel = $customerNumber !== null ? 'customer:' . $customerNumber : 'unknown'; } return [ 'type' => $type, 'id' => $id, 'customer_number' => $customerNumber, 'name' => $name, 'email' => $email, 'label' => $displayLabel, ]; } private function timelineErrorReports(string $traceId): array { if (!$this->tableExists('error_reports')) { return []; } $rows = $this->selectRows( "SELECT id, status, reporter_type, reporter_user_id, reporter_subuser_id, reporter_customer_number, reporter_customer_number_context, reporter_name, reporter_email, route_path, page_url, release_trace_id, frontend_version, api_version, request_error_count, vue_error_count, created_at, updated_at FROM error_reports WHERE release_trace_id = ? ORDER BY created_at DESC, id DESC LIMIT 25", 's', [$traceId] ); return array_map(static fn(array $row): array => [ 'id' => (int)($row['id'] ?? 0), 'status' => (string)($row['status'] ?? ''), 'reporter' => [ 'type' => $row['reporter_type'] ?? null, 'user_id' => isset($row['reporter_user_id']) ? (int)$row['reporter_user_id'] : null, 'subuser_id' => isset($row['reporter_subuser_id']) ? (int)$row['reporter_subuser_id'] : null, 'customer_number' => isset($row['reporter_customer_number']) ? (int)$row['reporter_customer_number'] : null, 'customer_number_context' => isset($row['reporter_customer_number_context']) ? (int)$row['reporter_customer_number_context'] : null, 'name' => $row['reporter_name'] ?? null, 'email' => $row['reporter_email'] ?? null, ], 'route_path' => $row['route_path'] ?? null, 'page_url' => $row['page_url'] ?? null, 'release_trace_id' => $row['release_trace_id'] ?? null, 'frontend_version' => $row['frontend_version'] ?? null, 'api_version' => $row['api_version'] ?? null, 'request_error_count' => isset($row['request_error_count']) ? (int)$row['request_error_count'] : 0, 'vue_error_count' => isset($row['vue_error_count']) ? (int)$row['vue_error_count'] : 0, 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ], $rows); } private function timelineReleaseContext(array $session, ?array $channel): array { $frontend = $this->timelineAppReleaseContext( 'frontend', $session['frontend_version_label'] ?? null, $session['frontend_commit_sha'] ?? null ); $api = $this->timelineAppReleaseContext( 'api', $session['api_version_label'] ?? null, $session['api_commit_sha'] ?? null ); $bundle = $this->timelineBundleReference( $this->nullablePositiveInt($frontend['version']['id'] ?? null), $this->nullablePositiveInt($api['version']['id'] ?? null), $this->nullablePositiveInt($frontend['deployment']['bundle_id'] ?? $api['deployment']['bundle_id'] ?? null) ); return [ 'channel' => $channel !== null ? $this->publicChannel($channel) : null, 'frontend' => $frontend, 'api' => $api, 'bundle' => $bundle, ]; } private function timelineAppReleaseContext(string $app, mixed $versionLabel, mixed $commitSha): array { $versionLabel = $this->nullableString($versionLabel, 128); $commitSha = $this->nullableString($commitSha, 128); $context = [ 'version_label' => $versionLabel, 'commit_sha' => $commitSha, 'version' => null, 'deployment' => null, ]; $where = ['app = ?']; $types = 's'; $params = [$app]; if ($versionLabel !== null && $commitSha !== null) { $where[] = '(version_label = ? OR commit_sha = ?)'; $types .= 'ss'; $params[] = $versionLabel; $params[] = $commitSha; } elseif ($versionLabel !== null) { $where[] = 'version_label = ?'; $types .= 's'; $params[] = $versionLabel; } elseif ($commitSha !== null) { $where[] = 'commit_sha = ?'; $types .= 's'; $params[] = $commitSha; } else { return $context; } $version = $this->selectOne( "SELECT id, app, repository, branch, commit_sha, tag, version_label, build_url, artifact_url, deployed_url, status, created_at, deployed_at FROM release_versions WHERE " . implode(' AND ', $where) . " ORDER BY deployed_at DESC, id DESC LIMIT 1", $types, $params ); if ($version === null) { return $context; } $context['version_label'] = $version['version_label'] ?? $versionLabel; $context['commit_sha'] = $version['commit_sha'] ?? $commitSha; $context['version'] = $this->publicTimelineVersionReference($version); $deployment = $this->selectOne( "SELECT id, channel_id, target_id, version_id, service_set_id, bundle_id, deployment_kind, app, provider, repository, branch, commit_sha, status, deployment_url, started_at, completed_at, created_at FROM release_deployments WHERE version_id = ? ORDER BY id DESC LIMIT 1", 'i', [(int)$version['id']] ); if ($deployment !== null) { $context['deployment'] = $this->publicTimelineDeploymentReference($deployment); } return $context; } private function timelineBundleReference(?int $frontendVersionId, ?int $apiVersionId, ?int $bundleId): ?array { if ($bundleId !== null) { $bundle = $this->selectOne( "SELECT id, channel_id, service_set_id, version_label, frontend_version_id, api_version_id, frontend_deployment_id, api_deployment_id, status, deployed_at, promoted_at, created_at FROM release_bundles WHERE id = ? AND deleted_at IS NULL LIMIT 1", 'i', [$bundleId] ); return $bundle !== null ? $this->publicTimelineBundleReference($bundle) : null; } $where = ['deleted_at IS NULL']; $types = ''; $params = []; if ($frontendVersionId !== null && $apiVersionId !== null) { $where[] = 'frontend_version_id = ?'; $where[] = 'api_version_id = ?'; $types .= 'ii'; $params[] = $frontendVersionId; $params[] = $apiVersionId; } elseif ($frontendVersionId !== null) { $where[] = 'frontend_version_id = ?'; $types .= 'i'; $params[] = $frontendVersionId; } elseif ($apiVersionId !== null) { $where[] = 'api_version_id = ?'; $types .= 'i'; $params[] = $apiVersionId; } else { return null; } $bundle = $this->selectOne( "SELECT id, channel_id, service_set_id, version_label, frontend_version_id, api_version_id, frontend_deployment_id, api_deployment_id, status, deployed_at, promoted_at, created_at FROM release_bundles WHERE " . implode(' AND ', $where) . " ORDER BY id DESC LIMIT 1", $types, $params ); return $bundle !== null ? $this->publicTimelineBundleReference($bundle) : null; } private function publicTimelineVersionReference(array $version): array { return [ 'id' => (int)($version['id'] ?? 0), 'app' => (string)($version['app'] ?? ''), 'repository' => $version['repository'] ?? null, 'branch' => $version['branch'] ?? null, 'commit_sha' => $version['commit_sha'] ?? null, 'tag' => $version['tag'] ?? null, 'version_label' => $version['version_label'] ?? null, 'build_url' => $version['build_url'] ?? null, 'artifact_url' => $version['artifact_url'] ?? null, 'deployed_url' => $version['deployed_url'] ?? null, 'status' => (string)($version['status'] ?? ''), 'created_at' => $version['created_at'] ?? null, 'deployed_at' => $version['deployed_at'] ?? null, ]; } private function publicTimelineDeploymentReference(array $deployment): array { return [ 'id' => (int)($deployment['id'] ?? 0), 'channel_id' => isset($deployment['channel_id']) ? (int)$deployment['channel_id'] : null, 'target_id' => isset($deployment['target_id']) ? (int)$deployment['target_id'] : null, 'version_id' => isset($deployment['version_id']) ? (int)$deployment['version_id'] : null, 'service_set_id' => isset($deployment['service_set_id']) ? (int)$deployment['service_set_id'] : null, 'bundle_id' => isset($deployment['bundle_id']) ? (int)$deployment['bundle_id'] : null, 'deployment_kind' => (string)($deployment['deployment_kind'] ?? ''), 'app' => (string)($deployment['app'] ?? ''), 'provider' => (string)($deployment['provider'] ?? ''), 'repository' => $deployment['repository'] ?? null, 'branch' => $deployment['branch'] ?? null, 'commit_sha' => $deployment['commit_sha'] ?? null, 'status' => (string)($deployment['status'] ?? ''), 'deployment_url' => $deployment['deployment_url'] ?? null, 'started_at' => $deployment['started_at'] ?? null, 'completed_at' => $deployment['completed_at'] ?? null, 'created_at' => $deployment['created_at'] ?? null, ]; } private function publicTimelineBundleReference(array $bundle): array { return [ 'id' => (int)($bundle['id'] ?? 0), 'channel_id' => isset($bundle['channel_id']) ? (int)$bundle['channel_id'] : null, 'service_set_id' => isset($bundle['service_set_id']) ? (int)$bundle['service_set_id'] : null, 'version_label' => $bundle['version_label'] ?? null, 'frontend_version_id' => isset($bundle['frontend_version_id']) ? (int)$bundle['frontend_version_id'] : null, 'api_version_id' => isset($bundle['api_version_id']) ? (int)$bundle['api_version_id'] : null, 'frontend_deployment_id' => isset($bundle['frontend_deployment_id']) ? (int)$bundle['frontend_deployment_id'] : null, 'api_deployment_id' => isset($bundle['api_deployment_id']) ? (int)$bundle['api_deployment_id'] : null, 'status' => (string)($bundle['status'] ?? ''), 'deployed_at' => $bundle['deployed_at'] ?? null, 'promoted_at' => $bundle['promoted_at'] ?? null, 'created_at' => $bundle['created_at'] ?? null, ]; } private function clearOtherDefaultChannels(int $channelId): void { $this->execute('UPDATE release_channels SET default_channel = 0 WHERE id <> ?', 'i', [$channelId]); } private function normalizeApp(string $value): string { $app = strtolower(trim($value)); if (!in_array($app, self::APPS, true)) { throw new RuntimeException('Release app must be frontend or api.'); } return $app; } private function normalizeCaptureLevel(string $value): string { $level = strtolower(trim($value)); return in_array($level, self::CAPTURE_LEVELS, true) ? $level : 'metadata'; } private function nullableString(mixed $value, int $maxLength): ?string { if ($value === null) { return null; } $value = trim((string)$value); if ($value === '') { return null; } return substr($value, 0, max(1, $maxLength)); } private function nullableIdentifier(mixed $value, int $maxLength): ?string { $identifier = self::safeIdentifier((string)($value ?? ''), $maxLength); return $identifier === '' ? null : $identifier; } private function nullableInt(mixed $value): ?int { if ($value === null || $value === '' || !is_numeric($value)) { return null; } return (int)$value; } private function nullableFloat(mixed $value): ?float { if ($value === null || $value === '' || !is_numeric($value)) { return null; } return (float)$value; } private function normalizeDateTime(mixed $value): ?string { if (!is_string($value) || trim($value) === '') { return null; } $timestamp = strtotime($value); return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp); } private function nullablePositiveInt(mixed $value): ?int { if ($value === null || $value === '') { return null; } $int = (int)$value; return $int > 0 ? $int : null; } private function toBool(mixed $value): bool { if (is_bool($value)) { return $value; } return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); } private function requestTraceId(): string { $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] : self::initializeRequestContext(); return (string)($context['trace_id'] ?? ''); } private function assignmentCacheKey(array $context): string { if (!empty($context['principal_type']) && !empty($context['principal_id'])) { return 'release_manager:assignment:' . $context['principal_type'] . ':' . $context['principal_id']; } if (!empty($context['customer_number'])) { return 'release_manager:assignment:customer:' . (int)$context['customer_number']; } return ''; } private function cacheResolvedChannel(string $cacheKey, array $channel): void { if ($cacheKey === '' || !defined('redis')) { return; } try { redis->setEx($cacheKey, self::jsonEncode($channel), 60); } catch (Throwable) { } } private function clearAssignmentCache(string $subjectType, string $subjectId): void { if (!defined('redis')) { return; } try { redis->delete('release_manager:assignment:' . $subjectType . ':' . $subjectId); } catch (Throwable) { } } private function moduleConfigValue(string $module, string $variable, mixed $default = null): mixed { $row = $this->selectOne( 'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1', 'ss', [$module, $variable] ); return $row['value'] ?? $default; } private function upsertModuleConfigValue(string $module, string $variable, string $value, string $type): void { $existing = $this->selectOne( 'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1', 'ss', [$module, $variable] ); if ($existing === null) { $this->execute( 'INSERT INTO module_config (module, variable, value, type) VALUES (?, ?, ?, ?)', 'ssss', [$module, $variable, $value, $type] ); return; } $this->execute( 'UPDATE module_config SET value = ?, type = ? WHERE module = ? AND variable = ?', 'ssss', [$value, $type, $module, $variable] ); } private function audit(?int $channelId, ?int $deploymentId, string $action, ?int $actorUserId, string $severity, array $context): void { $this->execute( "INSERT INTO release_audit_logs (channel_id, deployment_id, action, actor_user_id, severity, context_json) VALUES (?, ?, ?, ?, ?, ?)", 'iisiss', [$channelId, $deploymentId, $action, $actorUserId, $severity, self::jsonEncode(self::redactPayload($context))] ); } private function selectOne(string $sql, string $types = '', array $params = []): ?array { $rows = $this->selectRows($sql, $types, $params); return $rows[0] ?? null; } private function selectRows(string $sql, string $types = '', array $params = []): array { global $db; if ($types === '') { $result = $db->query($sql); return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; } $stmt = $db->prepare($sql); if ($stmt === false) { throw new RuntimeException('Could not prepare release manager query.'); } $stmt->bind_param($types, ...$params); $stmt->execute(); $result = $stmt->get_result(); return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; } private function tableExists(string $table): bool { $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? ''; if ($table === '') { return false; } try { return $this->selectOne( 'SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1', 's', [$table] ) !== null; } catch (Throwable) { return false; } } private function cleanupExpiredReplayData(): void { try { $this->execute( "UPDATE release_replay_targets SET deleted_at = NOW(), enabled = 0 WHERE deleted_at IS NULL AND expires_at IS NOT NULL AND expires_at <= NOW()" ); $this->execute( "DELETE e FROM release_timeline_events e INNER JOIN release_timeline_sessions s ON s.id = e.timeline_session_id LEFT JOIN release_channels c ON c.id = s.channel_id WHERE s.last_seen_at < DATE_SUB(NOW(), INTERVAL COALESCE(c.retention_days, 14) DAY)" ); $this->execute( "DELETE s FROM release_timeline_sessions s LEFT JOIN release_channels c ON c.id = s.channel_id WHERE s.last_seen_at < DATE_SUB(NOW(), INTERVAL COALESCE(c.retention_days, 14) DAY)" ); } catch (Throwable) { // Retention cleanup should never block release debugging reads. } } private function execute(string $sql, string $types = '', array $params = []): void { global $db; if ($types === '') { $db->query($sql); return; } $stmt = $db->prepare($sql); if ($stmt === false) { throw new RuntimeException('Could not prepare release manager statement.'); } $stmt->bind_param($types, ...$params); $stmt->execute(); } private function insertId(): int { global $db; return (int)$db->insert_id(); } private static function headerValue(array $headers, string $name): string { foreach ($headers as $key => $value) { if (strcasecmp((string)$key, $name) === 0) { return trim((string)$value); } } $serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); return trim((string)($_SERVER[$serverKey] ?? '')); } private static function requestHeaderValue(string $name): string { $headers = function_exists('getallheaders') ? getallheaders() : []; return self::headerValue(is_array($headers) ? $headers : [], $name); } private static function safeSlug(string $value): string { $slug = strtolower(trim($value)); $slug = preg_replace('/[^a-z0-9_-]/', '-', $slug) ?? ''; $slug = trim(preg_replace('/-+/', '-', $slug) ?? '', '-'); return substr($slug, 0, 64); } private static function safeIdentifier(string $value, int $maxLength): string { $value = trim($value); $value = preg_replace('/[^a-zA-Z0-9_.:-]/', '', $value) ?? ''; return substr($value, 0, max(1, $maxLength)); } private static function isSensitiveKey(string $key): bool { return preg_match('/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i', $key) === 1; } private static function jsonEncode(mixed $value): string { $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false) { throw new RuntimeException('Could not encode release manager JSON payload.'); } return $json; } private static function jsonDecode(mixed $value): array { if (!is_string($value) || trim($value) === '') { return []; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : []; } }