'master', 'master' => 'master', 'beta' => 'beta', 'canary' => 'canary', 'internal' => 'internal', ]; private const RELEASE_ROUTE_CHANNELS = [ 'master' => 'stable', 'beta' => 'beta', 'canary' => 'canary', 'internal' => 'internal', ]; private const SERVICE_SET_MODES = ['attach_existing', 'clone_existing', 'fresh_empty', 'isolated_stack']; private const STACK_DATA_KINDS = ['database', 'redis', 'minio']; private const PRODUCTION_DATA_POLICY = 'production_shared'; private const BETA_PRODUCTION_DATA_SOURCE_CHANNELS = ['stable', 'master', 'production', 'prod']; private const PRODUCTION_SERVICE_POLICY = 'production_shared'; private const PRODUCTION_SERVICE_CHANNELS = ['beta']; private const RELEASE_STATUS_SERVICES = ['frontend', 'api', 'database', 'redis', 'minio']; private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod']; private const DEFAULT_COOLIFY_APPLICATION_PORT = '80'; private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api'; private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io']; private const RELEASE_GATE_MAX_PATHS = 10; private const RELEASE_GATE_MAX_ASSETS = 50; private const RELEASE_API_RUNTIME_ENV_KEYS = [ 'USE_ENV', 'DEBUG', 'ENCRYPTION_KEY', 'CORS', 'CONFIG_TIMEZONE', 'CONFIG_DB_TARGET', 'CONFIG_DB_HOST', 'CONFIG_DB_USER', 'CONFIG_DB_PASSWORD', 'CONFIG_DB_DATABASE', 'CONFIG_DB_PORT', 'CONFIG_DB_SSL_MODE', 'CONFIG_DB_DEBUG_HOST', 'CONFIG_DB_DEBUG_USER', 'CONFIG_DB_DEBUG_PASSWORD', 'CONFIG_DB_DEBUG_DATABASE', 'CONFIG_DB_DEBUG_PORT', 'CONFIG_DB_DEBUG_SSL_MODE', 'REDIS_CONFIG_HOST', 'REDIS_CONFIG_USER', 'REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_HOST', 'REDIS_CONFIG_DEBUG_USER', 'REDIS_CONFIG_DEBUG_PASSWORD', 'REDIS_CONFIG_DEBUG_DATABASE', 'REDIS_CONFIG_DEBUG_PORT', 'ECONOMIC_API_APP_ACCESS_GRANT', 'ECONOMIC_API_APP_ACCESS_GRANT2', 'ECONOMIC_API_APP_SECRET_TOKEN', 'WORDPRESS_STATIC_TOKEN', 'EMAIL_WASH_CERTIFICATE_TOKEN', 'WORDPRESS_API_URL', 'MINIO_ENDPOINT', 'MINIO_ACCESS_KEY', 'MINIO_SECRET_KEY', 'SLACK_DEFAULT_WEBHOOK', 'API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA', ]; private const RELEASE_API_RUNTIME_ENV_PREFIXES = [ 'EDGE_', 'RELEASE_MANAGER_', 'COOLIFY_', 'HETZNER_', 'OPENAI_', 'STRIPE_', 'FXRATES_', 'WEATHER_', 'MOTOR_', 'BIRD_', 'OCR_', 'LICENSE_', 'VIRK_', 'LIMBLE_', 'ENTRA_', 'REQUEST_QUEUE_', 'WORKFEED_', ]; private const SUBJECT_TYPES = ['user', 'subuser', 'customer']; private const CAPTURE_LEVELS = ['metadata', 'full_redacted', 'full']; private const MODULE_KEYS = [ 'economic', 'reCAPTCHA', 'email', 'backups', 'motorapi', 'stripe', 'fxratesapi', 'weatherapi', 'workfeed', 'gatewayapi', 'xlvask', 'entra', 'limble', 'ocrspace', 'openai', 'licenseplaterecognizer', 'virkdata', 'shelly', 'coolify', 'failover', 'edgegateway', 'selfserve', 'bird', 'auth', 'worker', 'requestqueue', 'moduleactionlogs', 'releasemanager', ]; private bool $schemaEnsured = false; private array $inProcessPassedReleaseGates = []; public static function initializeRequestContext(): array { $traceId = self::safeIdentifier( self::requestHeaderValue('X-Release-Trace') ?: (string)($_GET['release_trace'] ?? ''), 64 ); if ($traceId === '') { $traceId = bin2hex(random_bytes(16)); } $requestedChannel = self::safeSlug((string)( self::requestHeaderValue('X-Release-Channel') ?: ($_GET['release_channel'] ?? $_GET['channel_slug'] ?? '') )); $frontendVersion = self::safeIdentifier( (string)(self::requestHeaderValue('X-Frontend-Version') ?: ($_GET['frontend_version'] ?? '')), 128 ); $context = [ 'trace_id' => $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 []; } $routeSlug = self::safeSlug((string)$matches[1]); $channelSlug = self::channelSlugForRoute($routeSlug); $enabled = array_flip(array_values(array_filter(array_map( static fn(mixed $value): string => self::safeSlug((string)$value), $enabledChannelSlugs )))); if ($routeSlug === '' || $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['release_route_slug'] = $routeSlug; $context['ingress_prefix_stripped'] = true; $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; return [ 'channel_slug' => $channelSlug, 'route_slug' => $routeSlug, 'original_request_uri' => $requestUri, 'normalized_request_uri' => $normalizedUri, 'normalized_path' => $normalizedPath, ]; } public static function routeSlugForChannel(string $channelSlug): string { $slug = self::safeSlug($channelSlug); if ($slug === '') { return ''; } return self::RELEASE_ROUTE_SLUGS[$slug] ?? $slug; } public static function channelSlugForRoute(string $routeSlug): string { $slug = self::safeSlug($routeSlug); if ($slug === '') { return ''; } return self::RELEASE_ROUTE_CHANNELS[$slug] ?? $slug; } 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 = self::runtimeEnvValue($key); if ($value !== '') { return self::safeIdentifier($value, 128); } } return 'unknown'; } public static function backendCommitSha(): string { foreach (['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'] as $key) { $sha = self::normalizeCommitSha(self::runtimeEnvValue($key)); if ($sha !== '') { return $sha; } } $sha = self::localGitCommitSha(); return $sha !== '' ? $sha : 'unknown'; } private static function runtimeEnvValue(string $key): string { $value = getenv($key); if (($value === false || trim((string)$value) === '') && array_key_exists($key, $_ENV ?? [])) { $value = $_ENV[$key]; } if (($value === false || trim((string)$value) === '') && array_key_exists($key, $_SERVER ?? [])) { $value = $_SERVER[$key]; } return is_scalar($value) ? trim((string)$value) : ''; } private static function normalizeCommitSha(string $value): string { $value = strtolower(trim($value)); return preg_match('/^[a-f0-9]{7,40}$/', $value) === 1 ? $value : ''; } private static function localGitCommitSha(): string { $base = defined('WD') ? (string)WD : dirname(__DIR__); $candidates = []; $current = $base; for ($i = 0; $i < 6; $i++) { if ($current === '' || isset($candidates[$current])) { break; } $candidates[$current] = true; $parent = dirname($current); if ($parent === $current) { break; } $current = $parent; } foreach (array_keys($candidates) as $directory) { $gitPath = $directory . DIRECTORY_SEPARATOR . '.git'; if (!is_dir($directory) || (!is_dir($gitPath) && !is_file($gitPath))) { continue; } $output = []; $exitCode = 1; @exec('git -C ' . escapeshellarg($directory) . ' rev-parse HEAD 2>&1', $output, $exitCode); if ($exitCode !== 0 || !isset($output[0])) { continue; } $sha = self::normalizeCommitSha((string)$output[0]); if ($sha !== '') { return $sha; } } return ''; } 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 function verifyReleaseGateToken(string $token): bool { $token = trim($token); if ($token === '') { return false; } $expected = trim((string)(getenv('RELEASE_MANAGER_GATE_TOKEN') ?: ($_SERVER['RELEASE_MANAGER_GATE_TOKEN'] ?? ''))); if ($expected === '') { $expected = trim((string)$this->moduleConfigValue('ReleaseManager', 'release_gate_token', '')); } if ($expected !== '' && str_starts_with($expected, 'twsec:v1:') && class_exists(replication_secret_box::class)) { try { $expected = replication_secret_box::decrypt($expected); } catch (Throwable) { $expected = ''; } } return $expected !== '' && hash_equals($expected, $token); } 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 [ 'source' => 'deployment', '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) ); $serviceChannel = $this->runtimeServiceChannelFor($channel); $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']); $capturePolicy = $this->capturePolicyFor($context, $channel); $urls = $this->releaseRuntimeUrls($serviceChannel, $versions); return [ 'source' => 'deployment', 'generated_at' => date('c'), 'trace_id' => $this->requestTraceId(), 'channel' => $this->publicRuntimeChannel($channel), 'service_channel' => $this->publicRuntimeChannel($serviceChannel), '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'] ?? ''), 'selected_service_channel_slug' => (string)($serviceChannel['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) ); $serviceChannel = $this->runtimeServiceChannelFor($channel); $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']); $urls = $this->releaseRuntimeUrls($serviceChannel, $versions); return [ 'source' => 'deployment', 'generated_at' => date('c'), 'trace_id' => $this->requestTraceId(), 'channel' => $this->publicRuntimeChannel($channel), 'service_channel' => $this->publicRuntimeChannel($serviceChannel), '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'] ?? ''), 'selected_service_channel_slug' => (string)($serviceChannel['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), 'operations' => $this->listOperations(['limit' => 20]), 'data_services' => $this->releaseDataServicesSummary(), 'replication_policy' => $this->releaseReplicationPolicySummary(), 'coolify' => $this->releaseCoolifySummary(), 'failover' => $this->releaseFailoverSummary(), '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(); } public function listOperations(array $filters = []): array { if (!release_manager_schema_bootstrap::tablesExist()) { return []; } $limit = max(1, min(100, (int)($filters['limit'] ?? 50))); $where = []; $types = ''; $params = []; $channelId = $this->nullablePositiveInt($filters['channel_id'] ?? null); if ($channelId !== null) { $where[] = 'r.channel_id = ?'; $types .= 'i'; $params[] = $channelId; } $operationType = self::safeIdentifier((string)($filters['operation_type'] ?? $filters['type'] ?? ''), 64); if ($operationType !== '') { $where[] = 'r.operation_type = ?'; $types .= 's'; $params[] = $operationType; } $status = self::safeIdentifier((string)($filters['status'] ?? ''), 32); if ($status !== '') { $where[] = 'r.status = ?'; $types .= 's'; $params[] = $status; } $whereSql = $where !== [] ? 'WHERE ' . implode(' AND ', $where) : ''; $rows = $this->selectRows( "SELECT r.*, c.slug AS channel_slug, c.name AS channel_name, (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id) AS step_count, (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('passed', 'deployed')) AS passed_step_count, (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status = 'failed') AS failed_step_count, (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('warning', 'skipped')) AS warning_step_count FROM release_operation_runs r LEFT JOIN release_channels c ON c.id = r.channel_id $whereSql ORDER BY r.created_at DESC, r.id DESC LIMIT $limit", $types, $params ); return array_map(fn(array $row): array => $this->publicOperationRun($row, false), $rows); } public function operationDetail(int $id): array { $this->ensureSchema(); $operation = $this->getOperationRun($id); return $this->publicOperationRun($operation, true); } public function runReleaseTest(array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $channel = null; try { if ($this->nullablePositiveInt($input['channel_id'] ?? null) !== null || trim((string)($input['channel_slug'] ?? $input['channel'] ?? '')) !== '') { $channel = $this->channelFromInput($input); } } catch (Throwable $throwable) { $channel = null; } $gateInput = $this->normalizeReleaseGateInput($input, $channel); $operationId = $this->createOperationRun('release_test', [ 'subject_type' => $channel !== null ? 'channel' : 'release_manager', 'subject_id' => $channel !== null ? (string)$channel['id'] : null, 'channel_id' => $channel !== null ? (int)$channel['id'] : null, 'title' => $channel !== null ? sprintf('Release checks for %s', (string)($channel['name'] ?? $channel['slug'] ?? 'channel')) : 'Release Manager checks', 'actor_user_id' => $actorUserId, 'context' => array_replace(self::redactPayload($input), [ 'release_gate' => self::redactPayload($gateInput), ]), ]); $statuses = []; $this->recordOperationStep($operationId, 'dashboard_contract', 'Dashboard data contract', 'passed', 'Release Manager exposes channels, operations, Coolify, failover, and data-service state.', null, null, [ 'summary_keys' => ['channels', 'operations', 'coolify', 'failover', 'data_services'], ]); $statuses[] = 'passed'; if ($this->releaseGateAutoSyncRequested($gateInput)) { foreach ($this->releaseGateAutoSyncValidationSteps($gateInput, $channel) as $autoSyncStep) { $this->recordOperationStep( $operationId, (string)$autoSyncStep['step_key'], (string)$autoSyncStep['label'], (string)$autoSyncStep['status'], $autoSyncStep['message'] ?? null, $autoSyncStep['diagnostic'] ?? null, $autoSyncStep['solution_hint'] ?? null, is_array($autoSyncStep['context'] ?? null) ? $autoSyncStep['context'] : [] ); $statuses[] = (string)$autoSyncStep['status']; } } if (($gateInput['required_checks'] ?? []) !== []) { $this->recordOperationStep( $operationId, 'release_gate_inputs', 'Release gate payload', 'passed', 'Release gate payload includes CI deploy metadata and required checks.', null, null, $gateInput ); $statuses[] = 'passed'; foreach ($this->runReleaseGateChecks($gateInput) as $gateStep) { $this->recordOperationStep( $operationId, (string)$gateStep['step_key'], (string)$gateStep['label'], (string)$gateStep['status'], $gateStep['message'] ?? null, $gateStep['diagnostic'] ?? null, $gateStep['solution_hint'] ?? null, is_array($gateStep['context'] ?? null) ? $gateStep['context'] : [] ); $statuses[] = (string)$gateStep['status']; } } $appsToCheck = $this->releaseTestAppsFromInput($input); $channels = $channel !== null ? [$channel] : $this->selectRows("SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 ORDER BY default_channel DESC, slug"); foreach ($channels as $testChannel) { foreach ($appsToCheck as $app) { $target = $this->deploymentTargetForChannelApp((int)$testChannel['id'], $app); $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); $branch = self::releaseBranchForChannel($testChannel); if ($target === null) { $this->recordOperationStep( $operationId, sprintf('%s_%s_target', (string)$testChannel['slug'], $app), sprintf('%s %s target', (string)$testChannel['name'], strtoupper($app)), 'warning', 'No Coolify deployment target is configured for this app/channel pair.', 'Release Manager cannot deploy this app until a target exists.', 'Create or repair the channel deployment target, then run the test again.', ['channel_slug' => $testChannel['slug'], 'app' => $app, 'retry_action' => 'configure_target'] ); $statuses[] = 'warning'; continue; } $access = $this->githubRepositoryAccess([ 'repository' => $repository, 'branch' => $branch, 'commit_mode' => 'latest', ]); $ok = (bool)($access['ok'] ?? false); $status = $ok ? 'passed' : 'warning'; $statuses[] = $status; $this->recordOperationStep( $operationId, sprintf('%s_%s_branch', (string)$testChannel['slug'], $app), sprintf('%s %s branch', (string)$testChannel['name'], strtoupper($app)), $status, $ok ? sprintf('Branch %s is reachable and resolves to the latest commit.', $branch) : sprintf('Branch %s could not be verified and will be skipped by sync.', $branch), $ok ? null : (string)($access['message'] ?? 'GitHub branch access failed.'), $ok ? null : 'Create the missing branch or repair the Release Manager GitHub token, then use Retry.', [ 'channel_slug' => $testChannel['slug'], 'route_slug' => self::routeSlugForChannel((string)$testChannel['slug']), 'app' => $app, 'repository' => $repository, 'branch' => $branch, 'github_access' => $access, 'retry_action' => 'retry_branch_check', ] ); } } $dataSummary = $channel !== null ? $this->channelDataServicesSummary($channel) : $this->releaseDataServicesSummary(); $this->recordOperationStep($operationId, 'data_services', 'Production-shared data services', 'passed', 'Normal channel sync keeps MariaDB, Redis, and MinIO on production_shared unless an explicit data-service action changes that mode.', null, null, [ 'data_services' => $dataSummary, ]); $statuses[] = 'passed'; $finalStatus = in_array('failed', $statuses, true) ? 'failed' : (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed'); if ($finalStatus === 'passed' && $this->releaseGateAutoSyncRequested($gateInput)) { try { $this->inProcessPassedReleaseGates[$operationId] = [ 'channel_id' => $channel !== null ? (int)$channel['id'] : null, 'release_gate' => $gateInput, ]; $autoSyncResult = $this->processReleaseGateAutoSync($gateInput, $channel, $operationId, $actorUserId); $autoSyncStepStatus = (string)($autoSyncResult['step_status'] ?? 'passed'); $this->recordOperationStep( $operationId, 'auto_sync', 'Automatic container update', $autoSyncStepStatus, (string)($autoSyncResult['message'] ?? 'Automatic container update completed.'), $autoSyncResult['diagnostic'] ?? null, $autoSyncResult['solution_hint'] ?? null, $autoSyncResult ); $statuses[] = $autoSyncStepStatus; } catch (Throwable $throwable) { $this->recordOperationStep( $operationId, 'auto_sync', 'Automatic container update', 'failed', 'Automatic container update failed after the release gate passed.', $throwable->getMessage(), 'Open the channel sync operation or Release Manager target diagnostics, fix the failure, then rerun the gate.', $gateInput ); $statuses[] = 'failed'; } $finalStatus = in_array('failed', $statuses, true) ? 'failed' : (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed'); } $this->completeOperationRun( $operationId, $finalStatus, match ($finalStatus) { 'passed' => 'Release Manager tests completed without detected issues.', 'failed' => 'Release Manager tests failed. Promotion is blocked until the failed gate checks pass.', default => 'Release Manager tests completed with warnings. Open the failed or warning steps for fixes.', }, $finalStatus === 'passed' ? null : 'Resolve the failed or warning steps, then run the test again.' ); return $this->operationDetail($operationId); } private function releaseTestAppsFromInput(array $input): array { $raw = $input['apps'] ?? $input['app'] ?? null; $values = $this->releaseGateStringArray($raw); $apps = []; foreach ($values as $value) { try { $app = $this->normalizeApp($value); } catch (Throwable) { continue; } if (!in_array($app, $apps, true)) { $apps[] = $app; } } return $apps !== [] ? $apps : self::APPS; } private function normalizeReleaseGateInput(array $input, ?array $channel): array { $channelSlug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? ($channel['slug'] ?? ''))); $environmentUrl = $this->normalizeReleaseGateUrl((string)($input['environment_url'] ?? $input['frontend_url'] ?? '')); $apiBaseUrl = $this->normalizeReleaseGateUrl((string)($input['api_base_url'] ?? 'https://api-v2.truckwash.io')); $requiredChecks = $this->normalizeReleaseGateChecks($input, $environmentUrl); $routeSlug = self::routeSlugForChannel($channelSlug ?: 'stable') ?: 'master'; $app = ''; if (trim((string)($input['app'] ?? '')) !== '') { try { $app = $this->normalizeApp((string)$input['app']); } catch (Throwable) { $app = ''; } } $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? $input['repo'] ?? '')); $branch = trim((string)($input['branch'] ?? '')); $workflowUrl = $this->normalizeReleaseGateUrl((string)($input['workflow_url'] ?? $input['build_url'] ?? '')); return [ 'environment_url' => $environmentUrl, 'channel_slug' => $channelSlug, 'route_slug' => $routeSlug, 'app' => $app, 'apps' => $this->releaseTestAppsFromInput($input), 'repository' => $repository, 'branch' => $branch, 'auto_sync' => $this->toBool($input['auto_sync'] ?? false), 'workflow_url' => $workflowUrl, 'expected_commit' => self::safeIdentifier((string)($input['expected_commit'] ?? $input['commit_sha'] ?? ''), 128), 'build_id' => substr(trim((string)($input['build_id'] ?? '')), 0, 128), 'wait_timeout_seconds' => max(0, min(300, (int)($input['wait_timeout_seconds'] ?? 300))), 'poll_interval_seconds' => max(1, min(60, (int)($input['poll_interval_seconds'] ?? 10))), 'required_checks' => $requiredChecks, 'api_base_url' => $apiBaseUrl, 'api_ping_paths' => $this->releaseGateStringArray( $input['api_ping_paths'] ?? $input['api_paths'] ?? ['/master/api/ping'], self::RELEASE_GATE_MAX_PATHS ), 'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash'], self::RELEASE_GATE_MAX_PATHS), ]; } private function normalizeReleaseGateChecks(array $input, string $environmentUrl): array { $checks = $this->releaseGateStringArray($input['required_checks'] ?? []); if ($checks === [] && $environmentUrl !== '') { $checks = ['static_artifact']; } $allowed = ['static_artifact', 'api_gateway']; $normalized = []; foreach ($checks as $check) { $check = self::safeIdentifier(strtolower($check), 64); if (in_array($check, $allowed, true) && !in_array($check, $normalized, true)) { $normalized[] = $check; } } return $normalized; } private function releaseGateAutoSyncRequested(array $gateInput): bool { return (bool)($gateInput['auto_sync'] ?? false); } private function releaseGateAutoSyncValidationSteps(array $gateInput, ?array $channel): array { $steps = []; $requiredChecks = is_array($gateInput['required_checks'] ?? null) ? $gateInput['required_checks'] : []; $context = [ 'channel_slug' => $gateInput['channel_slug'] ?? null, 'app' => $gateInput['app'] ?? null, 'repository' => $gateInput['repository'] ?? null, 'branch' => $gateInput['branch'] ?? null, 'expected_commit' => $gateInput['expected_commit'] ?? null, 'workflow_url' => $gateInput['workflow_url'] ?? null, 'required_checks' => $requiredChecks, ]; if ($channel === null) { $steps[] = [ 'step_key' => 'auto_sync_channel', 'label' => 'Automatic update channel', 'status' => 'failed', 'message' => 'Automatic container updates require a release channel.', 'diagnostic' => 'The gate payload did not resolve to a configured release channel.', 'solution_hint' => 'Pass channel_slug from CI, for example stable for master.', 'context' => $context, ]; } if (trim((string)($gateInput['app'] ?? '')) === '') { $steps[] = [ 'step_key' => 'auto_sync_app', 'label' => 'Automatic update app', 'status' => 'failed', 'message' => 'Automatic container updates require an app.', 'diagnostic' => 'The gate payload must identify frontend or api so Release Manager updates exactly one container.', 'solution_hint' => 'Pass app=frontend from the frontend workflow or app=api from the backend workflow.', 'context' => $context, ]; } if (trim((string)($gateInput['expected_commit'] ?? '')) === '') { $steps[] = [ 'step_key' => 'auto_sync_commit', 'label' => 'Automatic update commit', 'status' => 'failed', 'message' => 'Automatic container updates require the CI-verified commit SHA.', 'diagnostic' => 'expected_commit was empty.', 'solution_hint' => 'Pass github.sha as expected_commit in the release gate payload.', 'context' => $context, ]; } if ($requiredChecks === []) { $steps[] = [ 'step_key' => 'auto_sync_required_checks', 'label' => 'Automatic update required checks', 'status' => 'failed', 'message' => 'Automatic container updates require at least one release gate check.', 'diagnostic' => 'required_checks was empty.', 'solution_hint' => 'Include required_checks (for example static_artifact and/or api_gateway) in the release gate payload.', 'context' => $context, ]; } if ($steps === []) { $steps[] = [ 'step_key' => 'auto_sync_inputs', 'label' => 'Automatic update inputs', 'status' => 'passed', 'message' => 'Release gate payload includes app, channel, and exact commit metadata for automatic container updates.', 'context' => $context, ]; } return $steps; } private function releaseGateStringArray(mixed $value, int $limit = 50): array { if (is_string($value)) { $value = preg_split('/\s*,\s*/', trim($value)) ?: []; } if (!is_array($value)) { return []; } $values = []; foreach ($value as $item) { $item = trim((string)$item); if ($item !== '' && !in_array($item, $values, true)) { $values[] = $item; } } return array_slice($values, 0, max(0, $limit)); } private function normalizeReleaseGateUrl(string $value): string { $value = trim($value); if ($value === '') { return ''; } if (preg_match('#^https?://#i', $value) !== 1) { $value = 'https://' . ltrim($value, '/'); } $parts = parse_url($value); if (!is_array($parts) || empty($parts['host'])) { return ''; } return rtrim($value, '/'); } private function runReleaseGateChecks(array $gateInput): array { $steps = []; foreach ($gateInput['required_checks'] as $check) { $steps[] = match ($check) { 'static_artifact' => $this->verifyReleaseStaticArtifact($gateInput), 'api_gateway' => $this->verifyReleaseApiGateway($gateInput), default => [ 'step_key' => $check, 'label' => 'Unknown release gate check', 'status' => 'skipped', 'message' => 'Unknown release gate check was skipped.', 'context' => ['check' => $check], ], }; } return $steps; } private function assertReleaseGatePassedForPromotion(int $channelId, ?string $expectedCommit = null, ?string $buildId = null, ?string $app = null): void { if (!$this->releaseGateRequiredForPromotion()) { return; } $expectedCommit = trim((string)$expectedCommit); $buildId = trim((string)$buildId); $app = trim((string)$app) !== '' ? $this->normalizeApp((string)$app) : ''; foreach ($this->inProcessPassedReleaseGates as $inProcessGate) { if ((int)($inProcessGate['channel_id'] ?? 0) !== $channelId) { continue; } $gate = is_array($inProcessGate['release_gate'] ?? null) ? $inProcessGate['release_gate'] : []; if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) { continue; } if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) { continue; } if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) { continue; } return; } $rows = $this->selectRows( "SELECT id, context_json, completed_at FROM release_operation_runs WHERE operation_type = 'release_test' AND channel_id = ? AND status = 'passed' AND completed_at >= DATE_SUB(NOW(), INTERVAL 12 HOUR) ORDER BY completed_at DESC, id DESC LIMIT 20", 'i', [$channelId] ); foreach ($rows as $row) { $context = json_decode((string)($row['context_json'] ?? ''), true); $gate = is_array($context['release_gate'] ?? null) ? $context['release_gate'] : []; if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) { continue; } if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) { continue; } if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) { continue; } return; } throw new RuntimeException('A passing Release Manager gate is required before promotion. Run the dev upload, public live smoke, credentialed smoke, and api-v2 health checks, then retry promotion.'); } private function releaseGateAppMatches(array $gate, string $app): bool { $app = $this->normalizeApp($app); $gateApp = trim((string)($gate['app'] ?? '')); if ($gateApp !== '') { try { return $this->normalizeApp($gateApp) === $app; } catch (Throwable) { return false; } } $gateApps = $this->releaseGateStringArray($gate['apps'] ?? []); if ($gateApps !== []) { foreach ($gateApps as $value) { try { if ($this->normalizeApp($value) === $app) { return true; } } catch (Throwable) { } } return false; } // Gates recorded before app-specific payloads existed were frontend release gates. return $app === 'frontend'; } private function releaseGateRequiredForPromotion(): bool { $envValue = trim((string)(getenv('RELEASE_GATE_REQUIRED_FOR_PROMOTION') ?: ($_SERVER['RELEASE_GATE_REQUIRED_FOR_PROMOTION'] ?? ''))); if ($envValue !== '') { return $this->toBool($envValue); } return $this->toBool($this->moduleConfigValue('ReleaseManager', 'release_gate_required_for_promotion', 'true')); } private function verifyReleaseStaticArtifact(array $gateInput): array { if (($gateInput['environment_url'] ?? '') === '') { return [ 'step_key' => 'static_artifact', 'label' => 'Static artifact deployment', 'status' => 'failed', 'message' => 'environment_url is required for static artifact verification.', 'solution_hint' => 'Pass the dev or channel frontend URL from CI.', 'context' => $gateInput, ]; } $deadline = time() + (int)$gateInput['wait_timeout_seconds']; $pollInterval = (int)$gateInput['poll_interval_seconds']; $attempts = 0; $lastMessage = 'Static artifact verification did not run.'; do { $attempts++; try { $context = $this->releaseStaticArtifactAttempt($gateInput); $context['attempts'] = $attempts; return [ 'step_key' => 'static_artifact', 'label' => 'Static artifact deployment', 'status' => 'passed', 'message' => 'The exact release manifest, app shell, JS, CSS, PWA assets, and release entry are reachable.', 'context' => $context, ]; } catch (Throwable $throwable) { $lastMessage = $throwable->getMessage(); if (time() >= $deadline) { break; } sleep($pollInterval); } } while (true); return [ 'step_key' => 'static_artifact', 'label' => 'Static artifact deployment', 'status' => 'failed', 'message' => 'The uploaded frontend artifact is not ready or does not match the expected build.', 'diagnostic' => $lastMessage, 'solution_hint' => 'Upload hashed assets first, keep old hashed assets, upload release-entry.json and index.html last, then rerun the gate.', 'context' => [ 'environment_url' => $gateInput['environment_url'], 'attempts' => $attempts, 'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'], 'poll_interval_seconds' => $gateInput['poll_interval_seconds'], ], ]; } private function releaseStaticArtifactAttempt(array $gateInput): array { $baseUrl = (string)$gateInput['environment_url']; $manifest = $this->releaseGateFetchJson($baseUrl, 'release-manifest.json'); $releaseEntry = $this->releaseGateFetchJson($baseUrl, 'release-entry.json'); $manifestData = $manifest['json']; $releaseEntryData = $releaseEntry['json']; if (trim((string)($manifestData['build_id'] ?? '')) === '') { throw new RuntimeException('release-manifest.json is missing build_id.'); } if (!$this->releaseGateCommitMatches((string)($manifestData['commit_sha'] ?? ''), (string)$gateInput['expected_commit'])) { throw new RuntimeException(sprintf( 'release-manifest.json commit_sha %s did not match expected commit %s.', (string)($manifestData['commit_sha'] ?? '(missing)'), (string)$gateInput['expected_commit'] )); } if ((string)$gateInput['build_id'] !== '' && (string)($manifestData['build_id'] ?? '') !== (string)$gateInput['build_id']) { throw new RuntimeException(sprintf( 'release-manifest.json build_id %s did not match expected build_id %s.', (string)($manifestData['build_id'] ?? '(missing)'), (string)$gateInput['build_id'] )); } if ((string)($releaseEntryData['entry'] ?? '') !== (string)($manifestData['entry'] ?? '')) { throw new RuntimeException('release-entry.json entry does not match release-manifest.json.'); } if (json_encode($releaseEntryData['css'] ?? []) !== json_encode($manifestData['css'] ?? [])) { throw new RuntimeException('release-entry.json css does not match release-manifest.json.'); } foreach ($gateInput['shell_paths'] as $shellPath) { $shell = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $shellPath)); if (($shell['status'] ?? 0) !== 200) { throw new RuntimeException(sprintf('%s returned HTTP %d.', $shellPath, (int)($shell['status'] ?? 0))); } if (!str_contains(strtolower((string)($shell['content_type'] ?? '')), 'text/html')) { throw new RuntimeException(sprintf('%s did not return HTML.', $shellPath)); } $body = (string)($shell['body'] ?? ''); if (strlen(trim(preg_replace('/\s+/', '', $body) ?? '')) < 40) { throw new RuntimeException(sprintf('%s returned an empty app shell.', $shellPath)); } if (!str_contains($body, '
')) { throw new RuntimeException(sprintf('%s did not include the Vue app root.', $shellPath)); } } $assetUrls = array_slice($this->releaseGateUniqueStrings(array_merge( ['release-manifest.json', 'release-entry.json'], [(string)($manifestData['entry'] ?? '')], is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [], is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [], is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [], is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : [] )), 0, self::RELEASE_GATE_MAX_ASSETS); $verifiedAssets = 0; foreach ($assetUrls as $assetUrl) { if ($assetUrl === '/index.html') { continue; } $this->releaseGateVerifyStaticAsset($baseUrl, $assetUrl, is_array($manifestData['asset_hashes'] ?? null) ? $manifestData['asset_hashes'] : []); $verifiedAssets++; } return [ 'environment_url' => $baseUrl, 'build_id' => (string)$manifestData['build_id'], 'commit_sha' => (string)($manifestData['commit_sha'] ?? ''), 'entry' => (string)$manifestData['entry'], 'verified_assets' => $verifiedAssets, ]; } private function verifyReleaseApiGateway(array $gateInput): array { $apiBaseUrl = (string)($gateInput['api_base_url'] ?? ''); if ($apiBaseUrl === '') { return [ 'step_key' => 'api_gateway', 'label' => 'api-v2 channel health', 'status' => 'failed', 'message' => 'api_base_url is required for API gateway verification.', 'solution_hint' => 'Pass the api-v2 base URL from CI.', 'context' => $gateInput, ]; } $expectedCommit = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? '')); $enforceExpectedCommit = $expectedCommit !== '' && !$this->releaseGateAutoSyncRequested($gateInput); $checked = []; try { foreach ($gateInput['api_ping_paths'] as $path) { $json = $this->releaseGateFetchJson($apiBaseUrl, $path); $payload = $json['json']; if (array_key_exists('success', $payload) && $payload['success'] !== true) { throw new RuntimeException(sprintf('%s returned success=false.', $path)); } $actualCommit = $this->releaseGateApiPayloadCommitSha($payload); if ($enforceExpectedCommit && !$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) { throw new RuntimeException(sprintf( '%s returned commit %s, expected %s.', $path, $actualCommit !== '' ? $actualCommit : 'unknown', $expectedCommit )); } $checked[] = [ 'path' => $path, 'status' => $json['status'], 'commit_sha' => $actualCommit !== '' ? $actualCommit : null, ]; } } catch (Throwable $throwable) { return [ 'step_key' => 'api_gateway', 'label' => 'api-v2 channel health', 'status' => 'failed', 'message' => 'api-v2 gateway or channel API health failed.', 'diagnostic' => $throwable->getMessage(), 'solution_hint' => 'Repair api-v2 routing so the configured channel API ping endpoints return 200 JSON before frontend promotion.', 'context' => [ 'api_base_url' => $apiBaseUrl, 'checked' => $checked, 'api_ping_paths' => $gateInput['api_ping_paths'], ], ]; } return [ 'step_key' => 'api_gateway', 'label' => 'api-v2 channel health', 'status' => 'passed', 'message' => 'api-v2 gateway and channel API prefixes returned 200 JSON.', 'context' => [ 'api_base_url' => $apiBaseUrl, 'checked' => $checked, 'expected_commit' => $expectedCommit !== '' ? $expectedCommit : null, ], ]; } private function releaseGateApiPayloadCommitSha(array $payload): string { $data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload; foreach (['api_commit_sha', 'backend_version', 'commit_sha', 'version'] as $key) { $commit = self::normalizeCommitSha((string)($data[$key] ?? '')); if ($commit !== '') { return $commit; } } return ''; } private function releaseGateFetchJson(string $baseUrl, string $path): array { $result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path)); if (($result['status'] ?? 0) !== 200) { throw new RuntimeException(sprintf('%s returned HTTP %d.', $path, (int)($result['status'] ?? 0))); } if (str_contains(strtolower((string)($result['content_type'] ?? '')), 'text/html')) { throw new RuntimeException(sprintf('%s was served as HTML.', $path)); } $decoded = json_decode((string)($result['body'] ?? ''), true); if (!is_array($decoded)) { throw new RuntimeException(sprintf('%s did not return valid JSON.', $path)); } $result['json'] = $decoded; return $result; } private function releaseGateVerifyStaticAsset(string $baseUrl, string $assetUrl, array $assetHashes): void { $result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $assetUrl)); if (($result['status'] ?? 0) !== 200) { throw new RuntimeException(sprintf('%s returned HTTP %d.', $assetUrl, (int)($result['status'] ?? 0))); } $body = (string)($result['body'] ?? ''); if ($body === '') { throw new RuntimeException(sprintf('%s returned an empty body.', $assetUrl)); } if ($this->releaseGateRejectsHtml($assetUrl) && str_contains(strtolower((string)($result['content_type'] ?? '')), 'text/html')) { throw new RuntimeException(sprintf('%s was served as HTML.', $assetUrl)); } $hashKey = str_starts_with($assetUrl, '/') ? $assetUrl : '/' . ltrim($assetUrl, '/'); if (is_array($assetHashes[$hashKey] ?? null) && !empty($assetHashes[$hashKey]['sha256'])) { $actualHash = hash('sha256', $body); if (!hash_equals((string)$assetHashes[$hashKey]['sha256'], $actualHash)) { throw new RuntimeException(sprintf('%s sha256 hash mismatch.', $assetUrl)); } } } private function releaseGateFetch(string $url): array { $this->assertReleaseGateFetchUrlAllowed($url); $curl = curl_init($url); if ($curl === false) { throw new RuntimeException('Could not initialize release gate request.'); } curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false); curl_setopt($curl, CURLOPT_MAXREDIRS, 0); curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3); curl_setopt($curl, CURLOPT_TIMEOUT, 8); curl_setopt($curl, CURLOPT_NOSIGNAL, true); curl_setopt($curl, CURLOPT_HTTPHEADER, [ 'Accept: application/json, text/html, */*', 'Cache-Control: no-cache', 'Pragma: no-cache', 'User-Agent: Truckwash-Release-Gate', ]); $body = curl_exec($curl); $error = curl_error($curl); $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); $contentType = (string)curl_getinfo($curl, CURLINFO_CONTENT_TYPE); curl_close($curl); if ($body === false) { throw new RuntimeException('Release gate request failed: ' . $error); } return [ 'url' => $url, 'status' => $status, 'content_type' => $contentType, 'body' => (string)$body, ]; } private function releaseGateJoinUrl(string $baseUrl, string $path): string { $path = trim($path); $parts = parse_url($path); if (is_array($parts) && (!empty($parts['scheme']) || !empty($parts['host']))) { throw new RuntimeException('Release gate paths must be relative to the configured Truckwash release host.'); } if (str_starts_with($path, '//')) { throw new RuntimeException('Release gate paths must not be protocol-relative URLs.'); } return rtrim($baseUrl, '/') . '/' . ltrim($path, '/'); } private function assertReleaseGateFetchUrlAllowed(string $url): void { $parts = parse_url($url); $scheme = strtolower((string)($parts['scheme'] ?? '')); $host = strtolower(rtrim((string)($parts['host'] ?? ''), '.')); if (!in_array($scheme, ['http', 'https'], true) || $host === '') { throw new RuntimeException('Release gate checks may only fetch HTTP(S) URLs from Truckwash release hosts.'); } if (!$this->releaseGateFetchHostAllowed($host)) { throw new RuntimeException('Release gate checks may only fetch configured Truckwash release hosts.'); } $addresses = $this->releaseGateResolveHost($host); if ($addresses === []) { throw new RuntimeException('Release gate host could not be resolved.'); } foreach ($addresses as $address) { if (!$this->releaseGatePublicIpAllowed($address)) { throw new RuntimeException('Release gate host resolved to a private, loopback, or reserved address.'); } } } private function releaseGateFetchHostAllowed(string $host): bool { $host = strtolower(rtrim($host, '.')); foreach (self::RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES as $allowedSuffix) { $allowedSuffix = strtolower($allowedSuffix); if ($host === $allowedSuffix || str_ends_with($host, '.' . $allowedSuffix)) { return true; } } return false; } private function releaseGateResolveHost(string $host): array { if (filter_var($host, FILTER_VALIDATE_IP) !== false) { return [$host]; } $addresses = gethostbynamel($host) ?: []; if (function_exists('dns_get_record')) { foreach (dns_get_record($host, DNS_AAAA) ?: [] as $record) { if (is_array($record) && !empty($record['ipv6'])) { $addresses[] = (string)$record['ipv6']; } } } return array_values(array_unique(array_filter($addresses, fn(string $address): bool => filter_var($address, FILTER_VALIDATE_IP) !== false))); } private function releaseGatePublicIpAllowed(string $address): bool { return filter_var( $address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) !== false; } private function releaseGateCommitMatches(string $actual, string $expected): bool { $expected = strtolower(trim($expected)); if ($expected === '') { return true; } $actual = strtolower(trim($actual)); return $actual !== '' && ($actual === $expected || str_starts_with($actual, $expected)); } private function verifyReleaseDeploymentReadiness(array $deployment, array $target, string $app, string $expectedCommit, array $options = []): array { $app = $this->normalizeApp($app); $baseUrl = $this->normalizeReleasePublicBaseUrl($deployment['deployment_url'] ?? null, $app) ?? $this->releaseTargetPublicBaseUrl($target); if ($baseUrl === null || $baseUrl === '') { throw new RuntimeException(sprintf('%s deployment has no public URL for readiness verification.', strtoupper($app))); } $timeout = max(0, min(300, (int)($options['wait_timeout_seconds'] ?? 300))); $pollInterval = max(1, min(60, (int)($options['poll_interval_seconds'] ?? 10))); $deadline = time() + $timeout; $attempts = 0; $lastMessage = 'Readiness verification did not run.'; do { $attempts++; try { if ($app === 'frontend') { $context = $this->releaseStaticArtifactAttempt([ 'environment_url' => $baseUrl, 'expected_commit' => $expectedCommit, 'build_id' => (string)($options['build_id'] ?? ''), 'shell_paths' => $this->releaseGateStringArray($options['shell_paths'] ?? ['/', '/guest/book/wash']), ]); $context['app'] = $app; $context['base_url'] = $baseUrl; $context['attempts'] = $attempts; return $context; } $json = $this->releaseGateFetchJson($baseUrl, 'ping'); $payload = $json['json']; if (array_key_exists('success', $payload) && $payload['success'] !== true) { throw new RuntimeException('API ping returned success=false.'); } $data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload; $actualCommit = (string)( $data['api_commit_sha'] ?? $data['backend_commit_sha'] ?? $data['commit_sha'] ?? $data['backend_version'] ?? '' ); if (!$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) { throw new RuntimeException(sprintf( 'API ping commit %s did not match expected commit %s.', $actualCommit !== '' ? $actualCommit : '(missing)', $expectedCommit )); } return [ 'app' => $app, 'base_url' => $baseUrl, 'path' => 'ping', 'status' => $json['status'] ?? null, 'commit_sha' => $actualCommit, 'attempts' => $attempts, ]; } catch (Throwable $throwable) { $lastMessage = $throwable->getMessage(); if (time() >= $deadline) { break; } sleep($pollInterval); } } while (time() <= $deadline); throw new RuntimeException(sprintf( '%s container readiness did not match commit %s after %d attempts: %s', strtoupper($app), $expectedCommit, $attempts, $lastMessage )); } private function releaseGateRejectsHtml(string $assetUrl): bool { return preg_match('/\.(js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i', parse_url($assetUrl, PHP_URL_PATH) ?: '') === 1; } private function releaseGateUniqueStrings(array $values): array { $unique = []; foreach ($values as $value) { $value = trim((string)$value); if ($value !== '' && !in_array($value, $unique, true)) { $unique[] = $value; } } return $unique; } public function syncChannel(int $channelId, ?int $actorUserId = null, array $options = []): array { $this->ensureSchema(); $channel = $this->getChannel($channelId); if ((int)($channel['enabled'] ?? 0) !== 1) { throw new RuntimeException('Release channel is disabled.'); } $requestedApp = ''; if (trim((string)($options['app'] ?? '')) !== '') { $requestedApp = $this->normalizeApp((string)$options['app']); } $apps = $requestedApp !== '' ? [$requestedApp] : self::APPS; $branch = trim((string)($options['branch'] ?? '')) ?: self::releaseBranchForChannel($channel); $routeSlug = self::routeSlugForChannel((string)$channel['slug']); $requestedCommitSha = self::normalizeCommitSha((string)($options['commit_sha'] ?? $options['commit'] ?? '')); $commitMode = $requestedCommitSha !== '' ? 'specific' : 'latest'; $requireReadiness = $this->toBool($options['require_readiness'] ?? false); $operationId = $this->createOperationRun('channel_sync', [ 'subject_type' => 'channel', 'subject_id' => (string)$channelId, 'channel_id' => $channelId, 'app' => $requestedApp !== '' ? $requestedApp : null, 'title' => sprintf('Sync %s release channel', (string)($channel['name'] ?? $channel['slug'])), 'actor_user_id' => $actorUserId, 'context' => [ 'channel_slug' => $channel['slug'], 'route_slug' => $routeSlug, 'branch' => $branch, 'apps' => $apps, 'source' => $options['source'] ?? 'manual', 'repository' => $options['repository'] ?? null, 'commit_mode' => $commitMode, 'commit_sha' => $requestedCommitSha !== '' ? $requestedCommitSha : null, 'workflow_url' => $options['workflow_url'] ?? $options['build_url'] ?? null, 'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null, 'auto_sync_event' => $options['auto_sync_event'] ?? null, 'gate_operation_id' => $options['gate_operation_id'] ?? null, ], ]); $statuses = []; $deployments = []; $this->recordOperationStep( $operationId, 'channel_mapping', 'Channel route and branch mapping', 'passed', sprintf('Channel %s syncs from branch %s and publishes under /%s/{api|frontend}.', (string)$channel['slug'], $branch, $routeSlug), null, null, ['channel_slug' => $channel['slug'], 'route_slug' => $routeSlug, 'branch' => $branch] ); $statuses[] = 'passed'; $this->recordOperationStep( $operationId, 'data_services_guard', 'Data services guard', 'passed', 'Code sync will not deploy or replace MariaDB, Redis, or MinIO.', null, null, ['data_services' => $this->channelDataServicesSummary($channel)] ); $statuses[] = 'passed'; if ($this->channelUsesProductionServices($channel)) { $serviceChannel = $this->productionServiceChannel(); $this->recordOperationStep( $operationId, 'production_services', 'Production frontend and API services', 'passed', sprintf( '%s uses the %s production frontend and API services; channel sync does not deploy separate release services.', (string)($channel['name'] ?? $channel['slug']), (string)($serviceChannel['name'] ?? $serviceChannel['slug']) ), null, null, [ 'channel_slug' => (string)($channel['slug'] ?? ''), 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), 'service_policy' => self::PRODUCTION_SERVICE_POLICY, ] ); $statuses[] = 'passed'; $this->completeOperationRun( $operationId, 'passed', 'Channel sync completed; production services remain active.', null ); $operation = $this->operationDetail($operationId); $operation['deployments'] = []; $operation['channel'] = $this->publicChannel($this->getChannel($channelId)); return $operation; } foreach ($apps as $app) { $target = $this->deploymentTargetForChannelApp($channelId, $app); if ($target === null) { $this->recordOperationStep( $operationId, $app . '_target', strtoupper($app) . ' Coolify target', 'failed', 'No Coolify deployment target exists for this channel/app.', 'The channel cannot receive a new ' . $app . ' deployment.', 'Create the missing deployment target and use Retry.', ['channel_id' => $channelId, 'channel_slug' => $channel['slug'], 'app' => $app, 'retry_action' => 'configure_target'] ); $statuses[] = 'failed'; continue; } $target = $this->prepareChannelSyncApplicationTarget($target, $actorUserId); if (($target['_release_auto_prepared_application'] ?? false) === true) { $this->recordOperationStep( $operationId, $app . '_target_prepared', strtoupper($app) . ' Coolify application target', 'passed', sprintf('%s target was prepared to create a path-routed Coolify application.', strtoupper($app)), null, null, ['target_id' => (int)$target['id'], 'app' => $app] ); $statuses[] = 'passed'; } $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); $requestedRepository = self::normalizeGithubRepositoryName((string)($options['repository'] ?? '')); if ($requestedRepository !== '') { $repository = $requestedRepository; } if ($repository === '') { $repository = self::defaultRepositoryForApp($app); } $access = $this->githubRepositoryAccess([ 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $requestedCommitSha, 'commit_mode' => $commitMode, ]); if (!($access['ok'] ?? false)) { $this->recordOperationStep( $operationId, $app . '_branch', strtoupper($app) . ' branch', 'warning', sprintf('%s branch %s was not deployed because it could not be verified.', strtoupper($app), $branch), (string)($access['message'] ?? 'GitHub branch access failed.'), 'Create the branch from master or repair GitHub access, then use Retry.', [ 'channel_slug' => $channel['slug'], 'route_slug' => $routeSlug, 'app' => $app, 'repository' => $repository, 'branch' => $branch, 'github_access' => $access, 'retry_action' => 'retry_sync', ] ); $statuses[] = 'warning'; continue; } $commitSha = trim((string)($access['commit_sha'] ?? $access['latest_commit_sha'] ?? '')); $current = $this->currentDeploymentForChannelApp($channelId, $app); if ($commitSha !== '' && $current !== null && trim((string)($current['commit_sha'] ?? '')) === $commitSha) { $this->recordOperationStep( $operationId, $app . '_already_current', strtoupper($app) . ' deployment', 'skipped', sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)), null, null, ['deployment' => $this->publicDeployment($current), 'github_access' => $access] ); $statuses[] = 'skipped'; continue; } try { $deployment = $this->startDeployment([ 'target_id' => (int)$target['id'], 'channel_id' => $channelId, 'app' => $app, 'repository' => $repository, 'branch' => $branch, 'commit_mode' => $commitMode, 'commit_sha' => $commitSha, 'version_label' => $commitSha !== '' ? substr($commitSha, 0, 12) : date('Ymd-His'), 'build_url' => $options['build_url'] ?? null, 'metadata' => [ 'release_operation_id' => $operationId, 'sync_source' => $options['source'] ?? 'manual', 'webhook_commit_sha' => $options['commit_sha'] ?? null, 'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null, 'gate_operation_id' => $options['gate_operation_id'] ?? null, 'workflow_url' => $options['workflow_url'] ?? null, ], ], $actorUserId); if (($deployment['status'] ?? '') === 'deployed' && !empty($deployment['id'])) { if ($requireReadiness) { $readiness = $this->verifyReleaseDeploymentReadiness($deployment, $target, $app, $commitSha, $options); $this->recordOperationStep( $operationId, $app . '_readiness', strtoupper($app) . ' container readiness', 'passed', sprintf('%s container readiness matched commit %s.', strtoupper($app), substr($commitSha, 0, 12)), null, null, $readiness ); } $promoted = $this->promoteDeployment((int)$deployment['id'], $actorUserId); $deployment = $promoted['deployment'] ?? $deployment; } $deployments[] = $deployment; $this->recordOperationStep( $operationId, $app . '_deploy', strtoupper($app) . ' deploy and activate', (($deployment['status'] ?? '') === 'failed') ? 'failed' : 'passed', (($deployment['status'] ?? '') === 'failed') ? sprintf('%s deployment failed before activation.', strtoupper($app)) : sprintf('%s deployment was recorded and the latest deployed revision is active for this channel.', strtoupper($app)), (($deployment['status'] ?? '') === 'failed') ? (string)($deployment['error_message'] ?? 'Deployment failed.') : null, (($deployment['status'] ?? '') === 'failed') ? 'Open the deployment result diagnostics, fix the provider error, then use Retry.' : null, ['deployment' => $deployment, 'github_access' => $access] ); $statuses[] = (($deployment['status'] ?? '') === 'failed') ? 'failed' : 'passed'; } catch (Throwable $throwable) { $this->recordOperationStep( $operationId, $app . '_deploy', strtoupper($app) . ' deploy and activate', 'failed', sprintf('%s deployment failed before activation.', strtoupper($app)), $throwable->getMessage(), 'Review the deployment target, Coolify service, and GitHub branch, then use Retry.', ['app' => $app, 'repository' => $repository, 'branch' => $branch] ); $statuses[] = 'failed'; } } $finalStatus = in_array('failed', $statuses, true) ? 'failed' : (count(array_intersect($statuses, ['warning'])) > 0 ? 'warning' : 'passed'); $this->completeOperationRun( $operationId, $finalStatus, $finalStatus === 'passed' ? 'Channel sync completed.' : 'Channel sync finished with failures or warnings. Open the operation steps for exact diagnostics.', $finalStatus === 'passed' ? null : 'Use the step retry action after fixing the reported target, branch, or provider issue.' ); $operation = $this->operationDetail($operationId); $operation['deployments'] = $deployments; $operation['channel'] = $this->publicChannel($this->getChannel($channelId)); return $operation; } private function processReleaseGateAutoSync(array $gateInput, ?array $channel, int $gateOperationId, ?int $actorUserId): array { if ($channel === null) { throw new RuntimeException('Automatic container update requires a release channel.'); } $channelId = (int)$channel['id']; $app = $this->normalizeApp((string)($gateInput['app'] ?? '')); $commitSha = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? '')); if ($commitSha === '') { throw new RuntimeException('Automatic container update requires a 7-40 character Git commit SHA.'); } $branch = trim((string)($gateInput['branch'] ?? '')) ?: self::releaseBranchForChannel($channel); $repository = self::normalizeGithubRepositoryName((string)($gateInput['repository'] ?? '')); if ($repository === '') { $repository = self::defaultRepositoryForApp($app); } $target = $this->deploymentTargetForChannelApp($channelId, $app); if ($target === null) { throw new RuntimeException(sprintf('No %s deployment target is configured for %s.', strtoupper($app), (string)$channel['slug'])); } if (!$this->toBool($target['auto_deploy'] ?? false)) { throw new RuntimeException(sprintf('%s automatic deployments are disabled for %s.', strtoupper($app), (string)$channel['slug'])); } $targetRepository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? '')); $targetBranch = trim((string)($target['branch'] ?? '')); if ($targetRepository !== '' && $repository !== $targetRepository) { throw new RuntimeException(sprintf('Gate repository %s does not match target repository %s.', $repository, $targetRepository)); } if ($targetBranch !== '' && $branch !== $targetBranch) { throw new RuntimeException(sprintf('Gate branch %s does not match target branch %s.', $branch, $targetBranch)); } $event = $this->upsertReleaseAutoSyncEvent([ 'channel_id' => $channelId, 'app' => $app, 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'status' => 'gate_passed', 'source' => 'release_gate', 'workflow_url' => $gateInput['workflow_url'] ?? null, 'gate_operation_id' => $gateOperationId, 'metadata' => [ 'release_gate' => $gateInput, ], ]); $eventId = (int)$event['id']; if (!$this->acquireReleaseAutoSyncLock($eventId)) { return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput); } try { $event = $this->releaseAutoSyncEventById($eventId) ?? $event; if (in_array((string)($event['status'] ?? ''), ['promoted', 'deployed'], true)) { return [ 'step_status' => 'passed', 'message' => 'Automatic container update was already completed for this commit.', 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), ]; } $current = $this->currentDeploymentForChannelApp($channelId, $app); if ($current !== null && $this->releaseGateCommitMatches((string)($current['commit_sha'] ?? ''), $commitSha)) { $event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [ 'deployment_id' => (int)$current['id'], 'metadata' => ['already_current' => true], ]); return [ 'step_status' => 'passed', 'message' => sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)), 'deployment' => $this->publicDeployment($current), 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), ]; } $event = $this->updateReleaseAutoSyncEvent($eventId, 'syncing'); $operation = $this->syncChannel($channelId, $actorUserId, [ 'app' => $app, 'source' => 'release_gate', 'repository' => $repository, 'branch' => $branch, 'commit_mode' => 'specific', 'commit_sha' => $commitSha, 'build_url' => $gateInput['workflow_url'] ?? null, 'workflow_url' => $gateInput['workflow_url'] ?? null, 'gate_operation_id' => $gateOperationId, 'auto_sync_event_id' => $eventId, 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), 'require_readiness' => true, 'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'] ?? 300, 'poll_interval_seconds' => $gateInput['poll_interval_seconds'] ?? 10, 'build_id' => $gateInput['build_id'] ?? '', 'shell_paths' => $gateInput['shell_paths'] ?? [], ]); if ((string)($operation['status'] ?? '') !== 'passed') { throw new RuntimeException((string)($operation['summary'] ?? 'Automatic channel sync did not pass.')); } $deployment = $this->currentDeploymentForChannelApp($channelId, $app); if ($deployment === null || !$this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) { throw new RuntimeException(sprintf('%s was deployed but was not promoted as the active %s release.', strtoupper($app), (string)$channel['slug'])); } $event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [ 'sync_operation_id' => (int)($operation['id'] ?? 0) ?: null, 'deployment_id' => (int)$deployment['id'], 'metadata' => [ 'sync_operation_id' => $operation['id'] ?? null, 'deployment_id' => $deployment['id'] ?? null, ], ]); return [ 'step_status' => 'passed', 'message' => sprintf('%s container was deployed and promoted at %s.', strtoupper($app), substr($commitSha, 0, 12)), 'sync_operation' => $operation, 'deployment' => $this->publicDeployment($deployment), 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), ]; } catch (Throwable $throwable) { $this->updateReleaseAutoSyncEvent($eventId, 'failed', [ 'error_message' => $throwable->getMessage(), ]); throw $throwable; } finally { $this->releaseReleaseAutoSyncLock($eventId); } } private function waitForReleaseAutoSyncEventResult(int $eventId, int $channelId, string $app, string $commitSha, array $gateInput): array { $timeout = max(0, min(300, (int)($gateInput['wait_timeout_seconds'] ?? 300))); $pollInterval = max(1, min(60, (int)($gateInput['poll_interval_seconds'] ?? 10))); $deadline = time() + $timeout; $attempts = 0; $lastStatus = 'unknown'; do { $attempts++; $event = $this->releaseAutoSyncEventById($eventId); if ($event === null) { throw new RuntimeException('Automatic container update disappeared while another request was processing it.'); } $lastStatus = (string)($event['status'] ?? 'unknown'); if (in_array($lastStatus, ['promoted', 'deployed'], true)) { $deployment = null; $deploymentId = $this->nullablePositiveInt($event['deployment_id'] ?? null); if ($deploymentId !== null) { $deployment = $this->getDeployment($deploymentId); } $deployment ??= $this->currentDeploymentForChannelApp($channelId, $app); if ($deployment !== null && $this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) { return [ 'step_status' => 'passed', 'message' => sprintf('%s container update completed by an in-flight request at %s.', strtoupper($app), substr($commitSha, 0, 12)), 'deployment' => $this->publicDeployment($deployment), 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), 'attempts' => $attempts, ]; } throw new RuntimeException(sprintf( 'Automatic container update completed for event %d but the active %s deployment does not match %s.', $eventId, strtoupper($app), $commitSha )); } if ($lastStatus === 'failed') { $message = trim((string)($event['error_message'] ?? 'Automatic container update failed in another request.')); throw new RuntimeException($message !== '' ? $message : 'Automatic container update failed in another request.'); } if (time() >= $deadline) { break; } sleep($pollInterval); } while (true); throw new RuntimeException(sprintf( 'Automatic container update is already being processed for event %d but did not finish within %d seconds; last status was %s.', $eventId, $timeout, $lastStatus )); } private function upsertReleaseAutoSyncEvent(array $input): array { $channelId = (int)$input['channel_id']; $app = $this->normalizeApp((string)$input['app']); $repository = self::normalizeGithubRepositoryName((string)$input['repository']); $branch = trim((string)$input['branch']); $commitSha = self::normalizeCommitSha((string)$input['commit_sha']); $status = self::safeIdentifier((string)($input['status'] ?? 'pending'), 32) ?: 'pending'; $source = self::safeIdentifier((string)($input['source'] ?? ''), 64) ?: null; $workflowUrl = $this->nullableString($input['workflow_url'] ?? null, 512); $gateOperationId = $this->nullablePositiveInt($input['gate_operation_id'] ?? null); $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : []; if ($channelId <= 0 || $repository === '' || $branch === '' || $commitSha === '') { throw new RuntimeException('Automatic sync event requires channel, app, repository, branch, and commit.'); } $existing = $this->releaseAutoSyncEventFor($channelId, $app, $repository, $branch, $commitSha); if ($existing === null) { $this->execute( "INSERT INTO release_auto_sync_events ( channel_id, app, repository, branch, commit_sha, status, source, workflow_url, gate_operation_id, metadata_json, gate_passed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = 'gate_passed' THEN NOW() ELSE NULL END)", 'isssssssiss', [ $channelId, $app, $repository, $branch, $commitSha, $status, $source, $workflowUrl, $gateOperationId, self::jsonEncode(self::redactPayload($metadata)), $status, ] ); return $this->releaseAutoSyncEventById($this->insertId()) ?? []; } $existingStatus = (string)($existing['status'] ?? 'pending'); if ($status === 'pending' && !in_array($existingStatus, ['pending', 'failed'], true)) { $status = $existingStatus; } if ($status === 'gate_passed' && in_array($existingStatus, ['syncing', 'promoted', 'deployed'], true)) { $status = $existingStatus; } $this->execute( "UPDATE release_auto_sync_events SET status = ?, source = COALESCE(?, source), workflow_url = COALESCE(?, workflow_url), gate_operation_id = COALESCE(NULLIF(?, 0), gate_operation_id), error_message = NULL, metadata_json = ?, gate_passed_at = CASE WHEN ? = 'gate_passed' THEN COALESCE(gate_passed_at, NOW()) ELSE gate_passed_at END, updated_at = NOW() WHERE id = ?", 'sssissi', [ $status, $source, $workflowUrl, $gateOperationId ?? 0, self::jsonEncode(self::redactPayload($metadata)), $status, (int)$existing['id'], ] ); return $this->releaseAutoSyncEventById((int)$existing['id']) ?? []; } private function updateReleaseAutoSyncEvent(int $id, string $status, array $input = []): array { $status = self::safeIdentifier($status, 32) ?: 'pending'; $syncOperationId = $this->nullablePositiveInt($input['sync_operation_id'] ?? null); $deploymentId = $this->nullablePositiveInt($input['deployment_id'] ?? null); $errorMessage = isset($input['error_message']) ? substr((string)$input['error_message'], 0, 4096) : null; $metadata = is_array($input['metadata'] ?? null) ? self::jsonEncode(self::redactPayload($input['metadata'])) : null; $this->execute( "UPDATE release_auto_sync_events SET status = ?, sync_operation_id = COALESCE(NULLIF(?, 0), sync_operation_id), deployment_id = COALESCE(NULLIF(?, 0), deployment_id), error_message = ?, metadata_json = COALESCE(?, metadata_json), synced_at = CASE WHEN ? IN ('deployed', 'promoted') THEN COALESCE(synced_at, NOW()) ELSE synced_at END, promoted_at = CASE WHEN ? = 'promoted' THEN COALESCE(promoted_at, NOW()) ELSE promoted_at END, failed_at = CASE WHEN ? = 'failed' THEN NOW() ELSE failed_at END, updated_at = NOW() WHERE id = ?", 'siisssssi', [$status, $syncOperationId ?? 0, $deploymentId ?? 0, $errorMessage, $metadata, $status, $status, $status, $id] ); return $this->releaseAutoSyncEventById($id) ?? []; } private function releaseAutoSyncEventFor(int $channelId, string $app, string $repository, string $branch, string $commitSha): ?array { return $this->selectOne( "SELECT e.*, c.slug AS channel_slug, c.name AS channel_name FROM release_auto_sync_events e INNER JOIN release_channels c ON c.id = e.channel_id WHERE e.channel_id = ? AND e.app = ? AND e.repository = ? AND e.branch = ? AND e.commit_sha = ? LIMIT 1", 'issss', [$channelId, $app, $repository, $branch, $commitSha] ); } private function releaseAutoSyncEventById(int $id): ?array { return $this->selectOne( "SELECT e.*, c.slug AS channel_slug, c.name AS channel_name FROM release_auto_sync_events e INNER JOIN release_channels c ON c.id = e.channel_id WHERE e.id = ? LIMIT 1", 'i', [$id] ); } private function publicReleaseAutoSyncEvent(array $event): array { return [ 'id' => (int)($event['id'] ?? 0), 'channel_id' => (int)($event['channel_id'] ?? 0), 'channel_slug' => $event['channel_slug'] ?? null, 'channel_name' => $event['channel_name'] ?? null, 'app' => (string)($event['app'] ?? ''), 'repository' => (string)($event['repository'] ?? ''), 'branch' => (string)($event['branch'] ?? ''), 'commit_sha' => (string)($event['commit_sha'] ?? ''), 'status' => (string)($event['status'] ?? 'unknown'), 'source' => $event['source'] ?? null, 'workflow_url' => $event['workflow_url'] ?? null, 'gate_operation_id' => isset($event['gate_operation_id']) ? (int)$event['gate_operation_id'] : null, 'sync_operation_id' => isset($event['sync_operation_id']) ? (int)$event['sync_operation_id'] : null, 'deployment_id' => isset($event['deployment_id']) ? (int)$event['deployment_id'] : null, 'error_message' => $event['error_message'] ?? null, 'metadata' => self::jsonDecode($event['metadata_json'] ?? null), 'received_at' => $event['received_at'] ?? null, 'gate_passed_at' => $event['gate_passed_at'] ?? null, 'synced_at' => $event['synced_at'] ?? null, 'promoted_at' => $event['promoted_at'] ?? null, 'failed_at' => $event['failed_at'] ?? null, ]; } private function acquireReleaseAutoSyncLock(int $eventId): bool { $lockName = 'release_auto_sync:' . $eventId; $row = $this->selectOne('SELECT GET_LOCK(?, 0) AS acquired', 's', [$lockName]); return (int)($row['acquired'] ?? 0) === 1; } private function releaseReleaseAutoSyncLock(int $eventId): void { try { $this->selectOne('SELECT RELEASE_LOCK(?) AS released', 's', ['release_auto_sync:' . $eventId]); } catch (Throwable) { } } public function runIssueAction(array $input, ?int $actorUserId = null): array { $this->ensureSchema(); $issueKey = trim((string)($input['issue_key'] ?? $input['key'] ?? '')); $actionId = self::safeIdentifier((string)($input['action_id'] ?? $input['action'] ?? ''), 64); $actionInputs = is_array($input['inputs'] ?? null) ? $input['inputs'] : []; $confirmed = $this->toBool($input['confirm'] ?? false); $summary = $this->summary(); $issue = $this->releaseStatusIssueByKey($summary, $issueKey); if ($issue === null) { $this->audit(null, null, 'release_issue_action_attempted', $actorUserId, 'warning', [ 'issue_key' => $issueKey, 'action_id' => $actionId, 'inputs' => $actionInputs, 'status' => 'stale_issue', ]); return [ 'status' => 'failed', 'message' => 'This release issue is no longer active. Refresh Release Manager and review the current state.', 'result' => null, 'summary' => $summary, ]; } $action = $this->releaseStatusActionById($issue, $actionId); if ($action === null) { $this->audit( $this->nullablePositiveInt($issue['channel_id'] ?? null), $this->nullablePositiveInt($issue['deployment_id'] ?? null), 'release_issue_action_attempted', $actorUserId, 'warning', [ 'issue_key' => $issueKey, 'issue' => $issue, 'action_id' => $actionId, 'inputs' => $actionInputs, 'status' => 'unavailable_action', ] ); return [ 'status' => 'failed', 'message' => 'This release issue action is no longer available.', 'issue' => $issue, 'result' => null, 'summary' => $summary, ]; } if (trim((string)($action['disabled_reason'] ?? '')) !== '') { $this->audit( $this->nullablePositiveInt($issue['channel_id'] ?? null), $this->nullablePositiveInt($issue['deployment_id'] ?? null), 'release_issue_action_attempted', $actorUserId, 'warning', [ 'issue_key' => $issueKey, 'issue' => $issue, 'action_id' => $actionId, 'inputs' => $actionInputs, 'status' => 'disabled', 'disabled_reason' => (string)$action['disabled_reason'], ] ); return [ 'status' => 'needs_input', 'message' => (string)$action['disabled_reason'], 'issue' => $issue, 'action' => $action, 'result' => null, 'summary' => $summary, ]; } if (($action['requires_confirmation'] ?? false) && !$confirmed) { $this->audit( $this->nullablePositiveInt($issue['channel_id'] ?? null), $this->nullablePositiveInt($issue['deployment_id'] ?? null), 'release_issue_action_attempted', $actorUserId, 'warning', [ 'issue_key' => $issueKey, 'issue' => $issue, 'action_id' => $actionId, 'inputs' => $actionInputs, 'status' => 'confirmation_required', ] ); return [ 'status' => 'needs_input', 'message' => 'Confirm this release issue action before Release Manager changes deployment state.', 'issue' => $issue, 'action' => $action, 'result' => null, 'summary' => $summary, ]; } $this->audit( $this->nullablePositiveInt($issue['channel_id'] ?? null), $this->nullablePositiveInt($issue['deployment_id'] ?? null), 'release_issue_action_attempted', $actorUserId, 'info', [ 'issue_key' => $issueKey, 'issue' => $issue, 'action_id' => $actionId, 'inputs' => $actionInputs, ] ); try { $result = $this->executeReleaseIssueAction($issue, $actionId, $actionInputs, $actorUserId); $status = (string)($result['status'] ?? 'completed'); $message = (string)($result['message'] ?? 'Release issue action completed.'); $this->audit( $this->nullablePositiveInt($issue['channel_id'] ?? null), $this->nullablePositiveInt($issue['deployment_id'] ?? null), 'release_issue_action_completed', $actorUserId, $status === 'failed' ? 'error' : 'info', [ 'issue_key' => $issueKey, 'action_id' => $actionId, 'status' => $status, 'result' => $result['result'] ?? null, ] ); return [ 'status' => $status, 'message' => $message, 'issue' => $issue, 'action' => $action, 'result' => $result['result'] ?? null, 'summary' => $this->summary(), ]; } catch (Throwable $throwable) { $this->audit( $this->nullablePositiveInt($issue['channel_id'] ?? null), $this->nullablePositiveInt($issue['deployment_id'] ?? null), 'release_issue_action_failed', $actorUserId, 'error', [ 'issue_key' => $issueKey, 'action_id' => $actionId, 'error' => $throwable->getMessage(), ] ); return [ 'status' => 'failed', 'message' => $throwable->getMessage(), 'issue' => $issue, 'action' => $action, 'result' => null, 'summary' => $this->summary(), ]; } } private function executeReleaseIssueAction(array $issue, string $actionId, array $inputs, ?int $actorUserId): array { return match ($actionId) { 'retry_deployment' => $this->retryReleaseIssueDeployment($issue, $inputs, $actorUserId), 'deploy_missing_version' => $this->deployReleaseIssueMissingVersion($issue, $inputs, $actorUserId), 'set_bundle' => $this->setReleaseIssueBundle($issue, $inputs, $actorUserId), 'complete_data_services' => $this->completeReleaseIssueDataServices($issue, $inputs, $actorUserId), 'reconcile_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'reconcile', $actorUserId), 'redeploy_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'deploy', $actorUserId), 'restart_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'restart', $actorUserId), 'prepare_application_target' => $this->prepareReleaseIssueApplicationTarget($issue, $actorUserId), 'refresh_status' => [ 'status' => 'completed', 'message' => 'Release status refreshed.', 'result' => null, ], default => throw new RuntimeException('Unknown release issue action.'), }; } private function retryReleaseIssueDeployment(array $issue, array $inputs, ?int $actorUserId): array { $deploymentId = $this->nullablePositiveInt($issue['deployment_id'] ?? null); if ($deploymentId === null) { throw new RuntimeException('The failed deployment record is missing.'); } $deployment = $this->getDeployment($deploymentId); $payload = self::jsonDecode($deployment['requested_payload_json'] ?? null); $payload = is_array($payload) ? $payload : []; foreach (['version_id'] as $key) { if (array_key_exists($key, $inputs)) { $payload[$key] = $inputs[$key]; } } $payload = array_replace($payload, [ 'channel_id' => (int)$deployment['channel_id'], 'target_id' => $this->nullablePositiveInt($deployment['target_id'] ?? null), 'app' => (string)$deployment['app'], 'repository' => (string)($deployment['repository'] ?? ''), 'branch' => (string)($deployment['branch'] ?? self::DEFAULT_BRANCH), 'commit_mode' => trim((string)($deployment['commit_sha'] ?? '')) !== '' ? 'specific' : 'latest', 'commit_sha' => (string)($deployment['commit_sha'] ?? ''), 'service_set_id' => $this->nullablePositiveInt($deployment['service_set_id'] ?? null), 'bundle_id' => $this->nullablePositiveInt($deployment['bundle_id'] ?? null), 'deployment_kind' => (string)($deployment['deployment_kind'] ?? 'single_app'), ]); $newDeployment = $this->startDeployment($payload, $actorUserId); $status = strtolower((string)($newDeployment['status'] ?? '')); return [ 'status' => $status === 'failed' ? 'failed' : (in_array($status, ['queued', 'deploying'], true) ? 'queued' : 'completed'), 'message' => $status === 'failed' ? 'Deployment retry failed.' : 'Deployment retry started.', 'result' => ['deployment' => $newDeployment], ]; } private function deployReleaseIssueMissingVersion(array $issue, array $inputs, ?int $actorUserId): array { $targetId = $this->nullablePositiveInt($inputs['target_id'] ?? $issue['target_id'] ?? null); if ($targetId === null) { return [ 'status' => 'needs_input', 'message' => 'Select or create a deployment target before deploying the missing version.', 'result' => [ 'required_inputs' => ['target_id'], 'channel_id' => $issue['channel_id'] ?? null, 'app' => $issue['service_key'] ?? null, ], ]; } $target = $this->getDeploymentTarget($targetId); $deployment = $this->startDeployment([ 'channel_id' => (int)$target['channel_id'], 'target_id' => $targetId, 'app' => (string)$target['app'], 'repository' => (string)$target['repository'], 'branch' => (string)$target['branch'], 'commit_mode' => (string)($inputs['commit_mode'] ?? 'latest'), 'commit_sha' => (string)($inputs['commit_sha'] ?? ''), 'version_label' => (string)($inputs['version_label'] ?? ''), ], $actorUserId); $status = strtolower((string)($deployment['status'] ?? '')); return [ 'status' => $status === 'failed' ? 'failed' : (in_array($status, ['queued', 'deploying'], true) ? 'queued' : 'completed'), 'message' => $status === 'failed' ? 'Missing version deployment failed.' : 'Missing version deployment started.', 'result' => ['deployment' => $deployment], ]; } private function setReleaseIssueBundle(array $issue, array $inputs, ?int $actorUserId): array { $channelId = $this->nullablePositiveInt($issue['channel_id'] ?? null); if ($channelId === null) { throw new RuntimeException('Release channel is missing.'); } $bundleId = $this->nullablePositiveInt($inputs['bundle_id'] ?? null); if ($bundleId === null) { $eligible = self::releaseStatusEligibleBundles(array_filter( $this->listBundles(250), static fn(array $bundle): bool => (int)($bundle['channel_id'] ?? 0) === $channelId )); if (count($eligible) !== 1) { return [ 'status' => 'needs_input', 'message' => $eligible === [] ? 'No deployed bundle is available for this channel.' : 'Choose which deployed bundle to set.', 'result' => [ 'required_inputs' => ['bundle_id'], 'bundle_choices' => $eligible, ], ]; } $bundleId = (int)$eligible[0]['id']; } return [ 'status' => 'completed', 'message' => 'Release bundle set for channel.', 'result' => $this->setChannelBundle($channelId, ['bundle_id' => $bundleId], $actorUserId), ]; } private function completeReleaseIssueDataServices(array $issue, array $inputs, ?int $actorUserId): array { $serviceSetId = $this->nullablePositiveInt($inputs['service_set_id'] ?? $issue['service_set_id'] ?? null); if ($serviceSetId === null) { return [ 'status' => 'needs_input', 'message' => 'Select the isolated service set before creating missing data services.', 'result' => ['required_inputs' => ['service_set_id']], ]; } return [ 'status' => 'queued', 'message' => 'Missing isolated data services were requested.', 'result' => $this->completeIsolatedStackDataServices($serviceSetId, ['deploy_data_targets' => true], $actorUserId), ]; } private function runReleaseIssueCoolifyTargetAction(array $issue, string $operation, ?int $actorUserId): array { $targetId = $this->nullablePositiveInt($issue['coolify_target_id'] ?? null); if ($targetId === null) { throw new RuntimeException('Coolify target is missing.'); } 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 manager is not available.'); } $manager = new coolify_manager(); $result = match ($operation) { 'reconcile' => $manager->reconcileTarget($targetId, $actorUserId), 'deploy' => $manager->deployTarget($targetId, $actorUserId), 'restart' => $manager->restartTarget($targetId, $actorUserId), default => throw new RuntimeException('Unknown Coolify target action.'), }; return [ 'status' => 'queued', 'message' => 'Coolify target action requested.', 'result' => $result, ]; } private function prepareReleaseIssueApplicationTarget(array $issue, ?int $actorUserId): array { $targetId = $this->nullablePositiveInt($issue['target_id'] ?? null); if ($targetId === null) { throw new RuntimeException('Release deployment target is missing.'); } return $this->prepareDeploymentTargetAsApplication($targetId, $actorUserId, 'warning'); } private function prepareDeploymentTargetAsApplication(int $targetId, ?int $actorUserId, string $severity = 'warning'): array { $target = $this->getDeploymentTarget($targetId); $context = self::jsonDecode($target['deploy_context_json'] ?? null); $context = is_array($context) ? $context : []; $context['coolify_resource_type'] = 'application'; $context['coolify_auto_create'] = true; $context['coolify_enable_ssl'] = $this->toBool($context['coolify_enable_ssl'] ?? true); $replacedLegacyUuid = trim((string)($target['coolify_service_uuid'] ?? '')) !== ''; $this->execute( 'UPDATE release_deployment_targets SET coolify_service_uuid = NULL, deploy_context_json = ? WHERE id = ?', 'si', [self::jsonEncode($context), $targetId] ); $this->audit((int)$target['channel_id'], null, 'deployment_target_prepared_as_application', $actorUserId, $severity, [ 'target_id' => $targetId, 'replaced_legacy_service_uuid' => $replacedLegacyUuid, 'severity' => $severity, ]); return [ 'status' => 'completed', 'message' => 'Deployment target will create a Coolify application on the next deployment.', 'result' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)), ]; } private function prepareChannelSyncApplicationTarget(array $target, ?int $actorUserId): array { $context = self::jsonDecode($target['deploy_context_json'] ?? null); $context = is_array($context) ? $context : []; if (!$this->releaseTargetNeedsApplicationAutoCreate($target, $context)) { return $target; } $this->prepareDeploymentTargetAsApplication((int)$target['id'], $actorUserId, 'info'); $prepared = $this->getDeploymentTarget((int)$target['id']); $prepared['_release_auto_prepared_application'] = true; return $prepared; } private function releaseStatusIssueByKey(array $summary, string $issueKey): ?array { foreach (is_array($summary['status_overview']['issues'] ?? null) ? $summary['status_overview']['issues'] : [] as $issue) { if (is_array($issue) && (string)($issue['key'] ?? '') === $issueKey) { return $issue; } } return null; } private function releaseStatusActionById(array $issue, string $actionId): ?array { foreach (is_array($issue['actions'] ?? null) ? $issue['actions'] : [] as $action) { if (is_array($action) && (string)($action['id'] ?? '') === $actionId) { return $action; } } return null; } 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'] : [] ); $bundlesByChannel = $this->releaseStatusBundlesByChannel( is_array($summary['bundles'] ?? null) ? $summary['bundles'] : [] ); $productionServiceChannel = $this->releaseStatusProductionServiceChannel($channels); $channelRows = []; $issues = []; foreach ($channels as $channel) { $row = $this->releaseStatusChannelRow( $channel, $productionServiceChannel, $targetsByChannelApp, $deployments, $deploymentsByChannelApp, $serviceSetsByChannel, $bundlesByChannel ); $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 $productionServiceChannel, array $targetsByChannelApp, array $deployments, array $deploymentsByChannelApp, array $serviceSetsByChannel, array $bundlesByChannel ): array { $channelId = (int)($channel['id'] ?? 0); $channelSlug = (string)($channel['slug'] ?? ''); $channelName = (string)($channel['name'] ?? $channelSlug); $channel = $this->releaseStatusChannelWithProductionServices($channel, $productionServiceChannel, $targetsByChannelApp); $channelWithTargetEndpoints = $this->releaseStatusChannelWithTargetEndpoints($channel, $targetsByChannelApp); $availability = $this->releaseStatusChannelAvailability($channelWithTargetEndpoints); $services = $this->releaseStatusServicesForChannel( $channelWithTargetEndpoints, $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, 'service_set_id' => $service['service_set_id'] ?? null, ]); } $issues = array_map( fn(array $issue): array => $this->releaseStatusIssueWithActions( $issue, $channel, $services, $bundlesByChannel[$channelId] ?? [] ), $issues ); $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)($channelWithTargetEndpoints['default_channel'] ?? false), 'enabled' => (bool)($channelWithTargetEndpoints['enabled'] ?? true), 'service_policy' => (string)($channelWithTargetEndpoints['service_policy'] ?? 'channel'), 'service_channel_id' => $channelWithTargetEndpoints['_service_channel_id'] ?? $channelId, 'service_channel_slug' => $channelWithTargetEndpoints['_service_channel_slug'] ?? $channelSlug, 'severity' => $severity, 'readiness' => $readiness, 'message' => self::releaseStatusChannelMessage($readiness, count($issues)), 'availability' => $availability, 'missing_values' => $missingValues, 'services' => $services, 'versions' => is_array($channelWithTargetEndpoints['versions'] ?? null) ? $channelWithTargetEndpoints['versions'] : [], 'replay' => [ 'enabled' => (bool)($channelWithTargetEndpoints['replay_enabled'] ?? false), 'capture_level' => (string)($channelWithTargetEndpoints['capture_level'] ?? 'metadata'), ], 'latest_deployments' => $latestDeployments, 'issues' => $issues, ]; } private function releaseStatusProductionServiceChannel(array $channels): ?array { $normalized = array_values(array_filter($channels, static fn(mixed $channel): bool => is_array($channel))); foreach ($normalized as $channel) { if ( ((bool)($channel['default_channel'] ?? false) || (int)($channel['default_channel'] ?? 0) === 1) && !$this->channelUsesProductionServices($channel) ) { return $channel; } } foreach (self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS as $slug) { foreach ($normalized as $channel) { if ( self::safeSlug((string)($channel['slug'] ?? '')) === $slug && !$this->channelUsesProductionServices($channel) ) { return $channel; } } } foreach ($normalized as $channel) { if (!$this->channelUsesProductionServices($channel)) { return $channel; } } return null; } private function releaseStatusChannelWithProductionServices( array $channel, ?array $productionServiceChannel, array $targetsByChannelApp ): array { if (!$this->channelUsesProductionServices($channel) || $productionServiceChannel === null) { return $channel; } $source = $this->releaseStatusChannelWithTargetEndpoints($productionServiceChannel, $targetsByChannelApp); $channel['_uses_production_services'] = true; $channel['_service_channel_id'] = (int)($source['id'] ?? 0); $channel['_service_channel_slug'] = (string)($source['slug'] ?? ''); $channel['service_policy'] = self::PRODUCTION_SERVICE_POLICY; $channel['versions'] = is_array($source['versions'] ?? null) ? $source['versions'] : []; foreach (['frontend_base_url', 'api_base_url'] as $field) { if (!empty($source[$field])) { $channel[$field] = $source[$field]; } } return $channel; } private function releaseStatusChannelWithTargetEndpoints(array $channel, array $targetsByChannelApp): array { $channelId = (int)($channel['id'] ?? 0); if ($channelId <= 0) { return $channel; } foreach (self::APPS as $app) { $field = $app === 'frontend' ? 'frontend_base_url' : 'api_base_url'; if (!empty($channel[$field])) { continue; } $target = $targetsByChannelApp[$channelId . ':' . $app] ?? null; if (!is_array($target)) { continue; } $endpointUrl = is_array($target['endpoint'] ?? null) ? $this->normalizeReleasePublicBaseUrl($target['endpoint']['url'] ?? null, $app) : null; $endpointUrl ??= $this->releaseTargetPublicBaseUrl($target + [ 'channel_slug' => $channel['slug'] ?? $target['channel_slug'] ?? '', ]); if ($endpointUrl !== null) { $channel[$field] = $endpointUrl; } } return $channel; } private function releaseStatusServicesForChannel( array $channel, array $availability, array $targetsByChannelApp, array $deploymentsByChannelApp, array $serviceSetsByChannel ): array { $channelId = (int)($channel['id'] ?? 0); $appServiceChannelId = (int)($channel['_service_channel_id'] ?? $channelId); $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 = $appServiceChannelId . ':' . $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, ]); } if (($channel['_uses_production_services'] ?? false) === true) { return array_replace($row, [ 'status' => self::PRODUCTION_SERVICE_POLICY, 'service_policy' => self::PRODUCTION_SERVICE_POLICY, 'service_channel_slug' => (string)($channel['_service_channel_slug'] ?? ''), 'message' => $serviceLabel . ' uses the production service for this channel.', ]); } $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'] ?? ''); $dataPolicy = $this->serviceSetDataPolicy($serviceSet); $usesSharedProduction = $dataPolicy === self::PRODUCTION_DATA_POLICY; $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' => $usesSharedProduction ? 'production_shared' : 'ready', 'data_policy' => $dataPolicy, 'data_service_mode' => $dataPolicy, 'state' => 'ready', 'severity' => 'ok', 'message' => $usesSharedProduction ? $serviceLabel . ' uses the production-shared service and is not replaced by channel sync.' : $serviceLabel . ' release service is ready.', 'next_action' => '', 'target_tab' => 'data-services', 'service_set_id' => isset($serviceSet['id']) ? (int)$serviceSet['id'] : null, 'coolify_target_id' => $service['id'] ?? null, 'resource_uuid' => $service['resource_uuid'] ?? null, 'resource_name' => $service['resource_name'] ?? $service['label'] ?? null, 'issue_type' => null, ]; if ($usesSharedProduction) { return $row; } 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 releaseStatusBundlesByChannel(array $bundles): array { $indexed = []; foreach ($bundles as $bundle) { if (!is_array($bundle)) { continue; } $channelId = (int)($bundle['channel_id'] ?? 0); if ($channelId > 0) { $indexed[$channelId][] = $bundle; } } return $indexed; } private function releaseStatusChannelAvailability(array $channel): array { if (($channel['_uses_production_services'] ?? false) !== true && is_array($channel['availability'] ?? null)) { $availability = $channel['availability']; $missing = self::releaseStatusReadinessMissingValues( is_array($availability['missing'] ?? null) ? $availability['missing'] : [] ); $status = (string)($availability['status'] ?? ''); if ($status === '' || ($missing === [] && in_array($status, ['unconfigured', 'missing_target'], true))) { $status = $missing === [] ? 'ready' : 'unconfigured'; } return [ 'configured' => $missing === [] ? true : (($availability['configured'] ?? null) === null ? false : (bool)$availability['configured']), 'missing' => $missing, 'status' => $status, '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['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 releaseStatusReadinessMissingValues(array $missing): array { $normalized = []; foreach ($missing as $key) { $value = trim((string)$key); if ($value === '' || $value === 'release_bundle') { continue; } $normalized[] = $value; } return array_values(array_unique($normalized)); } private static function releaseStatusIssue(array $issue): array { $normalized = [ '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, 'service_set_id' => $issue['service_set_id'] ?? null, 'missing_key' => $issue['missing_key'] ?? null, 'impact' => (string)($issue['impact'] ?? ''), 'resolution_state' => (string)($issue['resolution_state'] ?? 'open'), 'actions' => is_array($issue['actions'] ?? null) ? $issue['actions'] : [], ]; $normalized['key'] = (string)($issue['key'] ?? self::releaseStatusIssueKey($normalized)); if ($normalized['impact'] === '') { $normalized['impact'] = self::releaseStatusIssueImpact($normalized); } return $normalized; } private function releaseStatusIssueWithActions(array $issue, array $channel, array $services, array $channelBundles): array { $issue = self::releaseStatusIssue($issue); $service = null; foreach ($services as $candidate) { if ((string)($candidate['service_key'] ?? '') === (string)($issue['service_key'] ?? '')) { $service = $candidate; break; } } if (($issue['target_id'] ?? null) === null && isset($service['target_id'])) { $issue['target_id'] = $service['target_id']; } if (($issue['deployment_id'] ?? null) === null && isset($service['deployment_id'])) { $issue['deployment_id'] = $service['deployment_id']; } if (($issue['coolify_target_id'] ?? null) === null && isset($service['coolify_target_id'])) { $issue['coolify_target_id'] = $service['coolify_target_id']; } if (($issue['service_set_id'] ?? null) === null && isset($service['service_set_id'])) { $issue['service_set_id'] = $service['service_set_id']; } $issue['key'] = self::releaseStatusIssueKey($issue); $issue['impact'] = $issue['impact'] !== '' ? $issue['impact'] : self::releaseStatusIssueImpact($issue); $issue['resolution_state'] = self::releaseStatusResolutionState($issue); $issue['actions'] = $this->releaseStatusIssueActions($issue, $channel, $service, $channelBundles); return $issue; } private function releaseStatusIssueActions(array $issue, array $channel, ?array $service, array $channelBundles): array { $type = (string)($issue['type'] ?? ''); $missingKey = (string)($issue['missing_key'] ?? ''); $serviceKey = (string)($issue['service_key'] ?? ''); $actions = []; if ($type === 'failed_deployment') { $actions[] = self::releaseStatusAction( 'retry_deployment', 'Retry deployment', 'mutation', true, empty($issue['deployment_id']), empty($issue['deployment_id']) ? 'The failed deployment record is missing.' : '' ); if (self::releaseStatusIssueNeedsApplicationTarget($issue)) { $actions[] = self::releaseStatusAction( 'prepare_application_target', 'Prepare application target', 'mutation', true, empty($issue['target_id']), empty($issue['target_id']) ? 'The deployment target is missing.' : '' ); } } if ($type === 'missing_value' && in_array($missingKey, ['frontend_version', 'api_version'], true)) { $actions[] = self::releaseStatusAction( 'deploy_missing_version', 'Deploy missing version', 'mutation', true, empty($issue['target_id']), empty($issue['target_id']) ? 'Select or create a deployment target first.' : '' ); } if ($type === 'missing_value' && $missingKey === 'release_bundle') { $eligibleBundles = self::releaseStatusEligibleBundles($channelBundles); $actions[] = self::releaseStatusAction( 'set_bundle', count($eligibleBundles) === 1 ? 'Set available bundle' : 'Choose release bundle', 'mutation', true, count($eligibleBundles) !== 1, $eligibleBundles === [] ? 'No deployed bundle is available for this channel.' : '', ['bundle_choices' => $eligibleBundles] ); } if ($type === 'missing_value' && in_array($missingKey, ['database_service', 'redis_service', 'minio_service'], true)) { $actions[] = self::releaseStatusAction( 'complete_data_services', 'Create missing data services', 'mutation', true, empty($issue['service_set_id']), empty($issue['service_set_id']) ? 'The isolated service set is missing.' : '' ); } if ($type === 'service_unhealthy' && in_array($serviceKey, self::STACK_DATA_KINDS, true)) { foreach ([ 'reconcile_coolify_target' => 'Reconcile target', 'redeploy_coolify_target' => 'Redeploy target', 'restart_coolify_target' => 'Restart target', ] as $id => $label) { $actions[] = self::releaseStatusAction( $id, $label, 'mutation', true, empty($issue['coolify_target_id']), empty($issue['coolify_target_id']) ? 'The Coolify target is missing.' : '' ); } } if ($type === 'deployment_in_progress') { $actions[] = self::releaseStatusAction('refresh_status', 'Refresh status', 'refresh', false, false); } return $actions; } private static function releaseStatusAction( string $id, string $label, string $kind, bool $requiresConfirmation, bool $requiresInput, string $disabledReason = '', array $extra = [] ): array { return array_replace([ 'id' => $id, 'label' => $label, 'kind' => $kind, 'requires_confirmation' => $requiresConfirmation, 'requires_input' => $requiresInput, 'disabled_reason' => $disabledReason, 'permission' => 'superuser_release_manager_deploy', ], $extra); } private static function releaseStatusIssueKey(array $issue): string { return implode(':', [ self::safeIdentifier((string)($issue['type'] ?? 'unknown'), 32) ?: 'unknown', self::safeIdentifier((string)($issue['channel_id'] ?? $issue['channel_slug'] ?? ''), 64), self::safeIdentifier((string)($issue['service_key'] ?? ''), 32), self::safeIdentifier((string)($issue['missing_key'] ?? ''), 64), self::safeIdentifier((string)($issue['deployment_id'] ?? ''), 64), self::safeIdentifier((string)($issue['coolify_target_id'] ?? ''), 64), ]); } private static function releaseStatusIssueImpact(array $issue): string { return match ((string)($issue['type'] ?? '')) { 'failed_deployment' => 'This channel cannot be promoted until the failed deployment is replaced by a successful one.', 'missing_value' => 'This channel is incomplete and cannot receive traffic safely.', 'service_unhealthy' => 'This channel has an unhealthy runtime service and should not be promoted.', 'deployment_in_progress' => 'Promotion should wait until the deployment or provisioning job finishes.', default => 'Review this release issue before publishing or promoting the channel.', }; } private static function releaseStatusResolutionState(array $issue): string { if ((string)($issue['severity'] ?? '') === 'critical') { return 'blocked'; } if ((string)($issue['severity'] ?? '') === 'warning') { return 'action_available'; } return 'open'; } private static function releaseStatusIssueNeedsApplicationTarget(array $issue): bool { $text = strtolower(trim((string)($issue['message'] ?? '') . ' ' . (string)($issue['next_action'] ?? ''))); return str_contains($text, 'stripprefix') || str_contains($text, 'path-routed') || str_contains($text, 'service creation') || str_contains($text, 'coolify service'); } private static function releaseStatusEligibleBundles(array $bundles): array { $eligible = []; foreach ($bundles as $bundle) { $status = strtolower((string)($bundle['status'] ?? '')); if (!in_array($status, ['deployed', 'promoted', 'active'], true)) { continue; } $eligible[] = [ 'id' => (int)($bundle['id'] ?? 0), 'label' => (string)($bundle['version_label'] ?? ('Bundle #' . (int)($bundle['id'] ?? 0))), 'status' => (string)($bundle['status'] ?? ''), ]; } return $eligible; } 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); $serviceChannel = $this->runtimeServiceChannelFor($channel); $public['service_channel'] = $this->publicChannel($serviceChannel); $public['service_policy'] = $this->channelUsesProductionServices($channel) ? self::PRODUCTION_SERVICE_POLICY : 'channel'; $public['versions'] = $this->currentVersionsForChannel((int)$serviceChannel['id']); $public['current_deployments'] = $this->channelCurrentDeployments((int)$serviceChannel['id']); $public['branch_status'] = $this->channelBranchStatus($channel); $public['data_services'] = $this->channelDataServicesSummary($channel); $public['replication_policy'] = $public['data_services']['policy'] ?? $this->replicationPolicyForMode('production_shared'); 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', 'manual_endpoint_host', 'coolify_ports_exposes'] as $key) { if (array_key_exists($key, $input)) { $deployContext[$key] = trim((string)$input[$key]); } } if (array_key_exists('endpoint_mode', $input)) { $mode = strtolower(trim((string)$input['endpoint_mode'])); $deployContext['endpoint_mode'] = $mode === 'manual' ? 'manual' : 'auto'; } elseif (!isset($deployContext['endpoint_mode'])) { $deployContext['endpoint_mode'] = 'auto'; } if (array_key_exists('manual_endpoint_port', $input)) { $port = trim((string)$input['manual_endpoint_port']); $deployContext['manual_endpoint_port'] = $port; } $endpointMode = strtolower(trim((string)($deployContext['endpoint_mode'] ?? 'auto'))) === 'manual' ? 'manual' : 'auto'; $deployContext['endpoint_mode'] = $endpointMode; if ($endpointMode === 'manual') { $manualHost = self::normalizeEndpointHost($deployContext['manual_endpoint_host'] ?? ''); if ($manualHost === '') { throw new RuntimeException('Manual endpoint mode requires a public host.'); } $deployContext['manual_endpoint_host'] = $manualHost; } if (array_key_exists('manual_endpoint_port', $deployContext)) { $manualPort = trim((string)$deployContext['manual_endpoint_port']); if ($manualPort !== '' && (filter_var($manualPort, FILTER_VALIDATE_INT) === false || (int)$manualPort < 1 || (int)$manualPort > 65535)) { throw new RuntimeException('Manual endpoint port must be between 1 and 65535.'); } $deployContext['manual_endpoint_port'] = $manualPort; } 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; $dataSourceId = $mode === 'isolated_stack' ? null : $this->nullablePositiveInt($input['data_source_service_set_id'] ?? $input['data_source_id'] ?? null); $dataSource = $dataSourceId !== null ? $this->getServiceSet($dataSourceId) : null; $channel = $this->channelFromInputOrDefault($input, $source); $isBetaChannel = $this->isBetaChannel($channel); if ($isBetaChannel && $mode !== 'attach_existing') { throw new RuntimeException('Beta release service sets must use production-shared data services.'); } if ($isBetaChannel && $this->serviceSetInputHasExplicitDataTargets($input)) { throw new RuntimeException('Beta data-only service sets must copy data targets from Stable/Master or leave them production_shared.'); } $frontendTargetId = $this->serviceSetTargetIdFromInput($input, 'frontend', $source); $apiTargetId = $this->serviceSetTargetIdFromInput($input, 'api', $source); $dataTargets = []; $dataTargetSource = $isBetaChannel ? $dataSource : ($dataSource ?? $source); foreach (self::STACK_DATA_KINDS as $kind) { $dataTargets[$kind] = $this->serviceSetDataTargetIdFromInput($input, $kind, $dataTargetSource); } if ($isBetaChannel) { $this->assertBetaDataSourceChannel($dataSource); } $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 (!$isBetaChannel && !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['data_source_service_set_id'] = $dataSourceId; if ($dataSource !== null) { $metadata['data_source_channel_slug'] = (string)($dataSource['channel_slug'] ?? ''); } if ($mode === 'attach_existing') { $metadata['data_policy'] = self::PRODUCTION_DATA_POLICY; $metadata['data_service_mode'] = self::PRODUCTION_DATA_POLICY; } $metadata['replica_integration'] = $this->replicaProvisioningPlan($mode, $dataTargetSource, $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 = $isBetaChannel ? 'ready' : $this->serviceSetStatus($mode, $frontendTargetId, $apiTargetId, $dataTargets); $stackComplete = $isBetaChannel || ($frontendTargetId !== null && $apiTargetId !== null && (!in_array(null, $dataTargets, true) || $mode === 'attach_existing')); $health = [ 'status' => $status, 'stack_complete' => $stackComplete, 'data_policy' => $this->serviceSetDataPolicy([ 'mode' => $mode, 'metadata_json' => self::jsonEncode($metadata), ]), '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_source_service_set_id' => $dataSourceId, 'data_policy' => $this->serviceSetDataPolicy($this->getServiceSet($id)), '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); if ($this->channelUsesProductionServices($channel)) { throw new RuntimeException('Beta release channel uses production services and does not create separate release bundles.'); } $this->assertBetaProductionDataPolicy($channel, $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']); $channel = $this->getChannel((int)$bundle['channel_id']); if ($this->channelUsesProductionServices($channel)) { throw new RuntimeException('Beta release channel uses production services and does not deploy separate release bundles.'); } $this->assertBetaProductionDataPolicy($channel, $serviceSet); $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']; $channel = $this->getChannel($channelId); $serviceSetId = (int)$bundle['service_set_id']; $serviceSet = $this->getServiceSet($serviceSetId); if ($this->channelUsesProductionServices($channel)) { throw new RuntimeException('Beta release channel uses production services and does not promote separate release bundles.'); } $this->assertBetaProductionDataPolicy($channel, $serviceSet); $frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null); $apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null); $this->assertReleaseGatePassedForPromotion( $channelId, (string)($bundle['frontend_commit_sha'] ?? ''), null, 'frontend' ); if ($apiVersionId !== null) { $this->assertReleaseGatePassedForPromotion( $channelId, (string)($bundle['api_commit_sha'] ?? ''), null, 'api' ); } $deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null) ?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null); $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', active_channel_app_key = NULL 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] ); foreach ($this->selectRows('SELECT id, app FROM release_deployments WHERE bundle_id = ?', 'i', [$bundleId]) as $bundleDeployment) { $bundleDeploymentId = (int)($bundleDeployment['id'] ?? 0); $app = (string)($bundleDeployment['app'] ?? ''); if ($bundleDeploymentId > 0 && in_array($app, self::APPS, true)) { $this->activateDeploymentForChannelApp($bundleDeploymentId, $channelId, $app); } } 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); if ($this->channelUsesProductionServices($channel)) { throw new RuntimeException('Beta release channel uses production services and cannot deploy separate frontend or API services.'); } $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']; if ($this->channelUsesProductionServices($this->getChannel($channelId))) { throw new RuntimeException('Beta release channel uses production services and does not promote separate deployments.'); } $this->assertReleaseGatePassedForPromotion( $channelId, (string)($deployment['commit_sha'] ?? ''), null, (string)($deployment['app'] ?? '') ); $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->activateDeploymentForChannelApp($deploymentId, $channelId, (string)$deployment['app']); $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, service_set_id, bundle_id, actor_user_id, active ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)", 'iiiiiii', [ $channelId, (int)($previous['frontend_version_id'] ?? 0) ?: null, (int)($previous['api_version_id'] ?? 0) ?: null, (int)($previous['deployment_id'] ?? 0) ?: null, (int)($previous['service_set_id'] ?? 0) ?: null, (int)($previous['bundle_id'] ?? 0) ?: null, $actorUserId, ] ); foreach (self::APPS as $app) { $versionId = $this->nullablePositiveInt($previous[$app . '_version_id'] ?? null); if ($versionId === null) { continue; } $deployment = $this->selectOne( "SELECT id FROM release_deployments WHERE channel_id = ? AND app = ? AND version_id = ? ORDER BY completed_at DESC, id DESC LIMIT 1", 'isi', [$channelId, $app, $versionId] ); if ($deployment !== null) { $this->activateDeploymentForChannelApp((int)$deployment['id'], $channelId, $app); } $this->execute( "UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?", 'i', [$versionId] ); } $this->audit($channelId, (int)($previous['deployment_id'] ?? 0) ?: null, 'channel_rolled_back', $actorUserId, 'warning', [ 'previous_channel_version_id' => $previous['id'] ?? null, 'frontend_version_id' => $this->nullablePositiveInt($previous['frontend_version_id'] ?? null), 'api_version_id' => $this->nullablePositiveInt($previous['api_version_id'] ?? null), 'service_set_id' => $this->nullablePositiveInt($previous['service_set_id'] ?? null), 'bundle_id' => $this->nullablePositiveInt($previous['bundle_id'] ?? null), 'public_smoke_required' => true, ]); 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.'); } $normalizedRepository = self::normalizeGithubRepositoryName($repository); if ($normalizedRepository !== '') { $repository = $normalizedRepository; } $mappedChannelSlug = self::channelSlugForRoute($branch); $mappedChannel = $this->findChannelBySlug($mappedChannelSlug); $mappedApp = ''; if ($repository === self::defaultRepositoryForApp('frontend')) { $mappedApp = 'frontend'; } elseif ($repository === self::defaultRepositoryForApp('api')) { $mappedApp = 'api'; } $targets = $this->selectRows( "SELECT * FROM release_deployment_targets WHERE deleted_at IS NULL AND auto_deploy = 1 AND repository = ? AND branch = ?", 'ss', [$repository, $branch] ); $autoSyncEvents = []; foreach ($targets as $target) { $autoSyncEvents[] = $this->publicReleaseAutoSyncEvent($this->upsertReleaseAutoSyncEvent([ 'channel_id' => (int)$target['channel_id'], 'app' => (string)$target['app'], 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'status' => 'pending', 'source' => 'github_webhook', 'workflow_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, 'auto_sync_event_count' => count($autoSyncEvents), ]); return [ 'event' => $event, 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'mapped_channel_slug' => $mappedChannelSlug, 'mapped_app' => $mappedApp !== '' ? $mappedApp : null, 'auto_sync_events' => $autoSyncEvents, 'operations' => [], '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; } } return $resolvedChannel; } private function runtimeServiceChannelFor(array $channel): array { if (!$this->channelUsesProductionServices($channel)) { return $channel; } return $this->productionServiceChannel(); } private function productionServiceChannel(): array { $channel = $this->selectOne( "SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 AND default_channel = 1 AND slug <> 'beta' ORDER BY id LIMIT 1" ); if ($channel !== null) { return $channel; } foreach (self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS as $slug) { $channel = $this->findChannelBySlug($slug); if ($channel !== null && (int)($channel['enabled'] ?? 0) === 1 && !$this->channelUsesProductionServices($channel)) { return $channel; } } $default = $this->defaultChannel(); if ($this->channelUsesProductionServices($default)) { throw new RuntimeException('No production release channel is configured for beta services.'); } return $default; } 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); if ($this->channelUsesProductionServices($channel)) { $serviceChannel = $this->productionServiceChannel(); $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']); $urls = $this->releaseRuntimeUrls($serviceChannel, $versions); $availability = $this->channelAvailability($serviceChannel); return array_replace($availability, [ 'configured' => (bool)($availability['configured'] ?? false), 'missing' => is_array($availability['missing'] ?? null) ? $availability['missing'] : [], 'frontend_base_url' => $urls['frontend_base_url'], 'api_base_url' => $urls['api_base_url'], 'service_policy' => self::PRODUCTION_SERVICE_POLICY, 'service_channel_id' => (int)($serviceChannel['id'] ?? 0), 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), ]); } $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['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); $runtimeEnvUpdate = null; if ($serviceUuid === '' && $this->toBool($context['coolify_auto_create'] ?? false)) { $githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true); if ($githubAppUuid !== '') { $context['coolify_github_app_uuid'] = $githubAppUuid; $applicationPayload = $this->releaseCoolifyApplicationPayload($target, $context, $instance); $applicationPayload['instant_deploy'] = false; $created = $client->createPrivateGithubAppApplication($applicationPayload); $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.'); } $servicePayload = $this->releaseCoolifyServicePayload($target, $context, $instance); $servicePayload['instant_deploy'] = false; $created = $client->createService($servicePayload); $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); } $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'application', $target, $context); } 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, ]); $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'service', $target, $context); } if ($runtimeEnvUpdate === null) { $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, $resourceType, $target, $context); } $deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context)); $previousApplications = $this->stopCoolifyPreviousApplications($client, $context, $serviceUuid); 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 ?? []), 'runtime_env' => $runtimeEnvUpdate, 'deployment' => self::redactPayload($deployment), 'previous_applications' => self::redactPayload($previousApplications), ]; } private function stopCoolifyPreviousApplications(coolify_api_client $client, array $context, string $activeUuid): array { $stopped = []; foreach ($this->releaseCoolifyPreviousApplicationUuids($context, $activeUuid) as $uuid) { try { $stopped[] = [ 'uuid' => $uuid, 'status' => 'stop_requested', 'result' => $client->stopApplication($uuid), ]; } catch (Throwable $throwable) { $stopped[] = [ 'uuid' => $uuid, 'status' => 'warning', 'error' => $throwable->getMessage(), ]; } } return $stopped; } private function releaseCoolifyPreviousApplicationUuids(array $context, string $activeUuid): array { $values = []; foreach ([ 'coolify_previous_application_uuid', 'coolify_previous_artifact_app_uuid', 'coolify_previous_artifact_application_uuid', 'previous_application_uuid', 'previous_app_uuid', ] as $key) { if (is_scalar($context[$key] ?? null)) { $values[] = (string)$context[$key]; } } foreach ([ 'coolify_previous_application_uuids', 'coolify_previous_artifact_app_uuids', 'previous_application_uuids', 'previous_app_uuids', ] as $key) { if (!is_array($context[$key] ?? null)) { continue; } foreach ($context[$key] as $value) { if (is_scalar($value)) { $values[] = (string)$value; } } } $activeUuid = trim($activeUuid); $uuids = []; foreach ($values as $value) { $uuid = trim((string)$value); if ($uuid === '' || $uuid === $activeUuid || in_array($uuid, $uuids, true)) { continue; } $uuids[] = $uuid; } return $uuids; } private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array { $env = $this->releaseCoolifyRuntimeEnv($target, $context); if ($env === []) { return null; } if ($resourceType === 'application') { $this->deleteCoolifyGeneratedCommitEnvs($client, $resourceUuid, $target, $context); $client->updateApplicationEnvsBulk($resourceUuid, $env); } else { $client->updateServiceEnvsBulk($resourceUuid, $env); } return [ 'resource_type' => $resourceType, 'count' => count($env), 'keys' => array_keys($env), ]; } private function deleteCoolifyGeneratedCommitEnvs(coolify_api_client $client, string $resourceUuid, array $target, array $context): void { $keys = $this->releaseCoolifyGeneratedCommitEnvKeys($target, $context); if ($keys === []) { return; } try { $rows = $this->payloadRows($client->listApplicationEnvs($resourceUuid)); } catch (Throwable) { return; } foreach ($rows as $row) { if (!is_array($row)) { continue; } $key = trim((string)($row['key'] ?? $row['name'] ?? '')); $uuid = trim((string)($row['uuid'] ?? $row['id'] ?? '')); if ($key === '' || $uuid === '' || !in_array($key, $keys, true)) { continue; } try { $client->deleteApplicationEnv($resourceUuid, $uuid); } catch (Throwable) { } } } private function releaseCoolifyRuntimeEnv(array $target, array $context): array { $contextEnv = $this->releaseCoolifyContextEnv($context); $env = $contextEnv; $app = strtolower(trim((string)($target['app'] ?? ''))); $deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context)); if ($app !== 'api') { $this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha); return $env; } $env['USE_ENV'] = $env['USE_ENV'] ?? 'true'; foreach (self::RELEASE_API_RUNTIME_ENV_KEYS as $key) { $this->appendRuntimeEnvValue($env, $key); } $runtime = array_replace( is_array($_ENV ?? null) ? $_ENV : [], is_array($_SERVER ?? null) ? $_SERVER : [], is_array(getenv()) ? getenv() : [] ); foreach ($runtime as $key => $value) { $key = (string)$key; if (!$this->releaseRuntimeEnvKeyAllowed($key)) { continue; } $this->appendRuntimeEnvValue($env, $key, $value); } $env = array_replace($env, $contextEnv); $env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true'; $env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? '')); $this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha); return $this->normalizeCoolifyRuntimeEnv($env); } private function applyReleaseCoolifyCommitRuntimeEnv(array &$env, string $app, string $deploymentCommitSha): void { if ($deploymentCommitSha === '') { return; } foreach ($this->releaseCoolifyGeneratedCommitEnvKeys(['app' => $app], []) as $key) { $env[$key] = $deploymentCommitSha; } } private function releaseCoolifyGeneratedCommitEnvKeys(array $target, array $context): array { $app = strtolower(trim((string)($target['app'] ?? $context['app'] ?? ''))); if ($app === 'frontend') { return ['SOURCE_COMMIT', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH']; } if ($app === 'api') { return ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA']; } return []; } private function releaseCoolifyContextEnv(array $context): array { $env = []; foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) { if (is_array($context[$key] ?? null)) { foreach ($context[$key] as $envKey => $value) { $this->appendRuntimeEnvValue($env, (string)$envKey, $value); } } } foreach (['coolify_env_file', 'env'] as $key) { $raw = $context[$key] ?? null; if (!is_string($raw) || trim($raw) === '') { continue; } foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) { $line = trim((string)$line); if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { continue; } [$envKey, $value] = explode('=', $line, 2); $this->appendRuntimeEnvValue($env, trim($envKey), $value); } } return $this->normalizeCoolifyRuntimeEnv($env); } private function appendRuntimeEnvValue(array &$env, string $key, mixed $value = null): void { $key = trim($key); if ($key === '' || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) { return; } if ($value === null) { $value = getenv($key); if ($value === false && array_key_exists($key, $_ENV ?? [])) { $value = $_ENV[$key]; } if ($value === false && array_key_exists($key, $_SERVER ?? [])) { $value = $_SERVER[$key]; } } if ($value === false || $value === null || is_array($value) || is_object($value)) { return; } $env[$key] = (string)$value; } private function releaseRuntimeEnvKeyAllowed(string $key): bool { if (in_array($key, self::RELEASE_API_RUNTIME_ENV_KEYS, true)) { return true; } foreach (self::RELEASE_API_RUNTIME_ENV_PREFIXES as $prefix) { if (str_starts_with($key, $prefix)) { return true; } } return false; } private function normalizeCoolifyRuntimeEnv(array $env): array { $normalized = []; foreach ($env as $key => $value) { $this->appendRuntimeEnvValue($normalized, (string)$key, $value); } ksort($normalized); return $normalized; } 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; $priority = (string)(1000 + strlen($path)); $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"; $labels[] = "traefik.http.routers.{$httpsLabel}.priority={$priority}"; 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"; $labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}"; 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"; $labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}"; 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 releaseTargetNeedsApplicationAutoCreate(array $target, array $context): bool { if (!in_array(strtolower(trim((string)($target['app'] ?? ''))), self::APPS, true)) { return false; } if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) === null) { return false; } if (trim((string)($target['coolify_service_uuid'] ?? '')) !== '') { return false; } if ($this->toBool($context['coolify_auto_create'] ?? false)) { return false; } $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); return self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl) || $this->releaseCoolifyResourceType($context, '') === 'application'; } 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 (['commit_sha', 'commit'] as $key) { $value = trim((string)($target[$key] ?? '')); if ($value !== '') { return $value; } } 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 releaseDeploymentEndpoint(array $target): array { $context = is_array($target['deploy_context'] ?? null) ? $target['deploy_context'] : self::jsonDecode($target['deploy_context_json'] ?? null); $mode = strtolower(trim((string)($context['endpoint_mode'] ?? 'auto'))) === 'manual' ? 'manual' : 'auto'; $app = (string)($target['app'] ?? ''); if ($mode === 'manual') { $host = self::normalizeEndpointHost($context['manual_endpoint_host'] ?? ''); $port = $this->releaseEndpointPort($context['manual_endpoint_port'] ?? null); if ($host !== '') { return $this->releaseEndpointFromParts( 'manual', 'resolved', $host, $port, 'manual', 'Manual endpoint override is configured.' ); } return self::releasePendingEndpoint( 'manual', 'manual', 'Manual endpoint mode needs a public host before deployment.' ); } foreach ([ 'coolify_public_url' => 'coolify_public_url', 'health_url' => 'health_url', 'coolify_domain' => 'coolify_domain', ] as $key => $source) { $value = $key === 'health_url' ? ($target['health_url'] ?? null) : ($context[$key] ?? null); $url = $key === 'coolify_domain' ? $this->releaseRoutedPublicBaseUrl($value, $target, $context) : ($key === 'coolify_public_url' ? $this->releaseRoutedPublicBaseUrl($value, $target, $context) : $this->normalizeReleasePublicBaseUrl($value, $app)); if ($url !== null) { return $this->releaseEndpointFromUrl( $url, 'auto', 'resolved', $source, 'Automatic endpoint resolved from ' . str_replace('_', ' ', $source) . '.' ); } } $gatewayUrl = $this->releaseAutoGatewayPublicBaseUrl($target, $context); if ($gatewayUrl !== null) { return $this->releaseEndpointFromUrl( $gatewayUrl, 'auto', 'pending', 'auto_gateway', 'Automatic gateway endpoint will be used when Coolify routing is ready.' ); } foreach ([ 'coolify_deployed_public_url', 'deployed_public_url', 'resource_public_url', 'public_url', 'coolify_deployed_url', 'deployed_url', ] as $key) { $url = $this->normalizeReleasePublicBaseUrl($context[$key] ?? null, $app); if ($url !== null) { return $this->releaseEndpointFromUrl( $url, 'auto', 'resolved', 'coolify_resource_metadata', 'Automatic endpoint resolved from deployed Coolify resource metadata.' ); } } return self::releasePendingEndpoint( 'auto', 'auto', 'Automatic endpoint resolution is pending deployment metadata.' ); } private static function releasePendingEndpoint(string $mode, string $source, string $message): array { return [ 'mode' => $mode, 'status' => 'pending', 'host' => null, 'port' => null, 'url' => null, 'source' => $source, 'message' => $message, ]; } private function releaseEndpointFromUrl(string $url, string $mode, string $status, string $source, string $message): array { $normalized = $this->normalizeReleasePublicBaseUrl($url); if ($normalized === null) { return self::releasePendingEndpoint($mode, $source, $message); } $parts = parse_url($normalized); if (!is_array($parts) || empty($parts['host'])) { return self::releasePendingEndpoint($mode, $source, $message); } $scheme = strtolower((string)($parts['scheme'] ?? 'https')); $port = isset($parts['port']) ? (int)$parts['port'] : ($scheme === 'http' ? 80 : 443); return [ 'mode' => $mode, 'status' => $status, 'host' => strtolower((string)$parts['host']), 'port' => $port, 'url' => $normalized, 'source' => $source, 'message' => $message, ]; } private function releaseEndpointFromParts( string $mode, string $status, string $host, ?int $port, string $source, string $message ): array { $host = self::normalizeEndpointHost($host); if ($host === '') { return self::releasePendingEndpoint($mode, $source, $message); } $url = 'https://' . $host . ($port !== null && $port !== 443 ? ':' . $port : ''); return [ 'mode' => $mode, 'status' => $status, 'host' => strtolower($host), 'port' => $port, 'url' => $url, 'source' => $source, 'message' => $message, ]; } private function releaseEndpointPort(mixed $value): ?int { $raw = trim((string)$value); if ($raw === '') { return null; } if (filter_var($raw, FILTER_VALIDATE_INT) === false) { return null; } $port = (int)$raw; return $port >= 1 && $port <= 65535 ? $port : null; } private static function normalizeEndpointHost(mixed $value): string { $raw = trim((string)$value); if ($raw === '') { return ''; } if (preg_match('#^https?://#i', $raw) === 1) { $parts = parse_url($raw); $raw = is_array($parts) ? (string)($parts['host'] ?? '') : ''; } $raw = trim($raw); $raw = preg_replace('#[/\s].*$#', '', $raw) ?? ''; if (str_contains($raw, ':') && preg_match('/^\[[^\]]+\]:(\d+)$/', $raw) !== 1) { $parts = parse_url('https://' . $raw); if (is_array($parts) && !empty($parts['host'])) { $raw = (string)$parts['host']; } } return strtolower(trim($raw, " \t\n\r\0\x0B[]")); } private function releaseAutoGatewayPublicBaseUrl(array $target, array $context): ?string { $host = $this->releasePublicGatewayHost($context); if ($host === '') { return null; } return $this->releaseRoutedPublicBaseUrl('https://' . $host, $target, array_replace($context, [ 'gateway_route_autoprovision' => false, ])); } private function releasePublicGatewayHost(array $context = []): string { foreach ([ $context['public_gateway_host'] ?? null, $context['coolify_public_gateway_host'] ?? null, ] as $value) { $host = self::normalizeEndpointHost($value); if ($host !== '') { return $host; } } foreach ([ getenv('RELEASE_MANAGER_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['RELEASE_MANAGER_PUBLIC_GATEWAY_HOST'] ?? null), getenv('COOLIFY_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['COOLIFY_PUBLIC_GATEWAY_HOST'] ?? null), ] as $value) { $host = self::normalizeEndpointHost($value); if ($host !== '') { return $host; } } try { if ($this->tableExists('module_config')) { $host = self::normalizeEndpointHost($this->moduleConfigValue('Coolify', 'public_gateway_host', '')); if ($host !== '') { return $host; } } } catch (Throwable) { } return 'api-v2.truckwash.io'; } private function releaseCoolifyPublicUrl(array $target, array $context): ?string { $endpoint = $this->releaseDeploymentEndpoint($target + ['deploy_context' => $context]); if (!empty($endpoint['url']) && in_array((string)($endpoint['source'] ?? ''), [ 'manual', 'coolify_public_url', 'health_url', 'coolify_domain', 'auto_gateway', 'coolify_resource_metadata', ], true)) { return (string)$endpoint['url']; } $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; } $routeSlug = self::routeSlugForChannel($channelSlug); $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, $routeSlug, $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'] ?? ''); $endpoint = is_array($target['endpoint'] ?? null) ? $target['endpoint'] : $this->releaseDeploymentEndpoint($target + ['deploy_context' => $context]); return $this->normalizeReleasePublicBaseUrl($endpoint['url'] ?? null, $app) ?? $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context) ?? $this->normalizeReleasePublicBaseUrl($target['health_url'] ?? null, $app) ?? $this->releaseRoutedPublicBaseUrl($context['coolify_domain'] ?? null, $target, $context) ?? (is_array($target['endpoint'] ?? null) ? ($target['endpoint']['url'] ?? null) : null) ?? ($this->releaseDeploymentEndpoint($target)['url'] ?? null); } 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 static function releaseBranchForChannel(array|string $channel): string { $slug = is_array($channel) ? (string)($channel['slug'] ?? '') : $channel; $routeSlug = self::routeSlugForChannel($slug); return $routeSlug !== '' ? $routeSlug : self::DEFAULT_BRANCH; } private static function defaultRepositoryForApp(string $app): string { return match (strtolower(trim($app))) { 'frontend' => 'copenhagentruckwash/pleno-vue', 'api' => 'copenhagentruckwash/api', default => '', }; } private function getOperationRun(int $id): array { $row = $this->selectOne( "SELECT r.*, c.slug AS channel_slug, c.name AS channel_name, (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id) AS step_count, (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('passed', 'deployed')) AS passed_step_count, (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status = 'failed') AS failed_step_count, (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('warning', 'skipped')) AS warning_step_count FROM release_operation_runs r LEFT JOIN release_channels c ON c.id = r.channel_id WHERE r.id = ? LIMIT 1", 'i', [$id] ); if ($row === null) { throw new RuntimeException('Release operation not found.'); } return $row; } private function createOperationRun(string $operationType, array $input): int { $operationType = self::safeIdentifier($operationType, 64) ?: 'operation'; $subjectType = self::safeIdentifier((string)($input['subject_type'] ?? ''), 64) ?: null; $subjectId = trim((string)($input['subject_id'] ?? '')) ?: null; $channelId = $this->nullablePositiveInt($input['channel_id'] ?? null); $app = trim((string)($input['app'] ?? '')) !== '' ? $this->normalizeApp((string)$input['app']) : null; $title = substr(trim((string)($input['title'] ?? $operationType)), 0, 255); $context = is_array($input['context'] ?? null) ? $input['context'] : []; $actorUserId = $this->nullablePositiveInt($input['actor_user_id'] ?? null); $this->execute( "INSERT INTO release_operation_runs ( operation_type, subject_type, subject_id, channel_id, app, status, title, actor_user_id, context_json, started_at ) VALUES (?, ?, ?, ?, ?, 'running', ?, ?, ?, NOW())", 'sssissis', [ $operationType, $subjectType, $subjectId, $channelId, $app, $title, $actorUserId, self::jsonEncode(self::redactPayload($context)), ] ); return $this->insertId(); } private function recordOperationStep( int $operationId, string $stepKey, string $label, string $status, ?string $message = null, ?string $diagnostic = null, ?string $solutionHint = null, array $context = [] ): void { $stepKey = self::safeIdentifier($stepKey, 64) ?: 'step'; $status = self::safeIdentifier($status, 32) ?: 'queued'; $completedSql = in_array($status, ['passed', 'deployed', 'failed', 'warning', 'skipped'], true) ? 'NOW()' : 'NULL'; $this->execute( "INSERT INTO release_operation_steps ( operation_run_id, step_key, label, status, message, diagnostic, solution_hint, context_json, started_at, completed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), $completedSql)", 'isssssss', [ $operationId, $stepKey, substr($label, 0, 255), $status, $message, $diagnostic, $solutionHint, self::jsonEncode(self::redactPayload($context)), ] ); } private function completeOperationRun(int $operationId, string $status, string $summary, ?string $solutionHint = null): void { $status = self::safeIdentifier($status, 32) ?: 'completed'; $this->execute( "UPDATE release_operation_runs SET status = ?, summary = ?, solution_hint = ?, completed_at = NOW() WHERE id = ?", 'sssi', [$status, $summary, $solutionHint, $operationId] ); } private function publicOperationRun(array $operation, bool $includeSteps = true): array { $operationId = (int)($operation['id'] ?? 0); $steps = []; if ($includeSteps && $operationId > 0) { $steps = array_map( fn(array $row): array => $this->publicOperationStep($row), $this->selectRows( 'SELECT * FROM release_operation_steps WHERE operation_run_id = ? ORDER BY id ASC', 'i', [$operationId] ) ); } return [ 'id' => $operationId, 'operation_type' => (string)($operation['operation_type'] ?? ''), 'subject_type' => $operation['subject_type'] ?? null, 'subject_id' => $operation['subject_id'] ?? null, 'channel_id' => isset($operation['channel_id']) ? (int)$operation['channel_id'] : null, 'channel_slug' => $operation['channel_slug'] ?? null, 'channel_name' => $operation['channel_name'] ?? null, 'app' => $operation['app'] ?? null, 'status' => (string)($operation['status'] ?? 'unknown'), 'title' => $operation['title'] ?? null, 'summary' => $operation['summary'] ?? null, 'solution_hint' => $operation['solution_hint'] ?? null, 'context' => self::jsonDecode($operation['context_json'] ?? null), 'step_count' => (int)($operation['step_count'] ?? count($steps)), 'passed_step_count' => (int)($operation['passed_step_count'] ?? 0), 'failed_step_count' => (int)($operation['failed_step_count'] ?? 0), 'warning_step_count' => (int)($operation['warning_step_count'] ?? 0), 'steps' => $includeSteps ? $steps : null, 'actor_user_id' => isset($operation['actor_user_id']) ? (int)$operation['actor_user_id'] : null, 'started_at' => $operation['started_at'] ?? null, 'completed_at' => $operation['completed_at'] ?? null, 'created_at' => $operation['created_at'] ?? null, 'updated_at' => $operation['updated_at'] ?? null, ]; } private function publicOperationStep(array $step): array { return [ 'id' => (int)($step['id'] ?? 0), 'operation_run_id' => (int)($step['operation_run_id'] ?? 0), 'step_key' => (string)($step['step_key'] ?? ''), 'label' => (string)($step['label'] ?? ''), 'status' => (string)($step['status'] ?? 'unknown'), 'message' => $step['message'] ?? null, 'diagnostic' => $step['diagnostic'] ?? null, 'solution_hint' => $step['solution_hint'] ?? null, 'context' => self::jsonDecode($step['context_json'] ?? null), 'started_at' => $step['started_at'] ?? null, 'completed_at' => $step['completed_at'] ?? null, 'created_at' => $step['created_at'] ?? null, 'updated_at' => $step['updated_at'] ?? null, ]; } private function activeDeploymentKey(int $channelId, string $app): string { return $channelId . ':' . $this->normalizeApp($app); } private function activateDeploymentForChannelApp(int $deploymentId, int $channelId, string $app): void { $app = $this->normalizeApp($app); $key = $this->activeDeploymentKey($channelId, $app); $this->execute( "UPDATE release_deployments SET status = 'superseded', active_channel_app_key = NULL WHERE active_channel_app_key = ? AND id <> ?", 'si', [$key, $deploymentId] ); $this->execute( "UPDATE release_deployments SET status = 'superseded', active_channel_app_key = NULL WHERE channel_id = ? AND app = ? AND id <> ? AND status = 'active'", 'isi', [$channelId, $app, $deploymentId] ); $this->execute( "UPDATE release_deployments SET status = 'active', active_channel_app_key = ?, completed_at = COALESCE(completed_at, NOW()) WHERE id = ?", 'si', [$key, $deploymentId] ); } private function currentDeploymentForChannelApp(int $channelId, string $app): ?array { $app = $this->normalizeApp($app); $key = $this->activeDeploymentKey($channelId, $app); $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.active_channel_app_key = ? LIMIT 1", 's', [$key] ); if ($row !== null) { return $row; } return $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.channel_id = ? AND d.app = ? AND d.status = 'active' ORDER BY d.completed_at DESC, d.id DESC LIMIT 1", 'is', [$channelId, $app] ); } private function deploymentTargetForChannelApp(int $channelId, string $app): ?array { return $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.deleted_at IS NULL AND t.channel_id = ? AND t.app = ? ORDER BY t.auto_deploy DESC, t.id DESC LIMIT 1", 'is', [$channelId, $this->normalizeApp($app)] ); } private function channelCurrentDeployments(int $channelId): array { $deployments = []; foreach (self::APPS as $app) { $row = $this->currentDeploymentForChannelApp($channelId, $app); $deployments[$app] = $row !== null ? $this->publicDeployment($row) : null; } return $deployments; } private function channelBranchStatus(array $channel): array { $channelId = (int)($channel['id'] ?? 0); if ($this->channelUsesProductionServices($channel)) { $serviceChannel = $this->productionServiceChannel(); $serviceChannelId = (int)($serviceChannel['id'] ?? 0); $branch = self::releaseBranchForChannel($serviceChannel); $status = []; foreach (self::APPS as $app) { $target = $this->deploymentTargetForChannelApp($serviceChannelId, $app); $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); $status[$app] = [ 'repository' => $repository, 'branch' => $branch, 'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')), 'target_configured' => true, 'target_branch' => $target['branch'] ?? $branch, 'state' => self::PRODUCTION_SERVICE_POLICY, 'retry_action' => null, 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), ]; } return $status; } $branch = self::releaseBranchForChannel($channel); $status = []; foreach (self::APPS as $app) { $target = $this->deploymentTargetForChannelApp($channelId, $app); $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); $status[$app] = [ 'repository' => $repository, 'branch' => $branch, 'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')), 'target_configured' => $target !== null, 'target_branch' => $target['branch'] ?? null, 'state' => $target !== null ? 'ready_to_check' : 'missing_target', 'retry_action' => $target !== null ? 'sync_channel' : 'configure_target', ]; } return $status; } private function releaseDataServicesSummary(): array { $summary = []; foreach ($this->selectRows("SELECT * FROM release_channels WHERE deleted_at IS NULL ORDER BY default_channel DESC, slug") as $channel) { $summary[(string)$channel['slug']] = $this->channelDataServicesSummary($channel); } return $summary; } private function channelDataServicesSummary(array $channel): array { $serviceChannel = $this->channelUsesProductionServices($channel) ? $this->productionServiceChannel() : $channel; $serviceSet = $this->activeServiceSetForChannel((int)($serviceChannel['id'] ?? 0)); $serviceSetMode = $serviceSet !== null ? (string)($serviceSet['mode'] ?? 'attach_existing') : self::PRODUCTION_DATA_POLICY; $mode = $this->serviceSetDataPolicy($serviceSet); $policy = $this->replicationPolicyForMode($mode); $services = []; foreach (self::STACK_DATA_KINDS as $kind) { $target = $serviceSet !== null ? $this->nullableCoolifyTarget($this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null)) : null; $replication = is_array($target['replication'] ?? null) ? $target['replication'] : []; $lastStatus = is_array($replication['last_status'] ?? null) ? $replication['last_status'] : []; $services[$kind] = [ 'kind' => $kind, 'mode' => $mode, 'service_set_mode' => $serviceSetMode, 'data_policy' => $mode, 'state' => $target !== null ? (string)($target['availability_state'] ?? 'configured') : 'production_shared', 'target' => $target, 'replication_policy' => $policy, 'default_shared_production' => $mode === self::PRODUCTION_DATA_POLICY, 'change_requires_explicit_action' => true, 'entity_facts' => [ 'service' => $kind, 'mode' => $mode, 'service_set_mode' => $serviceSetMode, 'data_policy' => $mode, 'node' => $target['instance_label'] ?? null, 'online_state' => $target['deployment_status'] ?? $target['availability_state'] ?? 'production_shared', 'hostname' => $replication['host'] ?? null, 'port' => $replication['port'] ?? null, 'uptime' => $lastStatus['uptime'] ?? $lastStatus['uptime_text'] ?? null, 'version' => $lastStatus['version'] ?? null, 'replication_role' => $replication['role'] ?? null, 'replication_lag' => $lastStatus['lag'] ?? $lastStatus['lag_seconds'] ?? null, 'last_check' => $replication['last_checked_at'] ?? $target['last_reconciled_at'] ?? null, ], ]; } return [ 'channel_id' => (int)($channel['id'] ?? 0), 'channel_slug' => (string)($channel['slug'] ?? ''), 'service_channel_id' => (int)($serviceChannel['id'] ?? 0), 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), 'mode' => $serviceSet === null ? 'production_shared' : $mode, 'service_set_mode' => $serviceSetMode, 'data_policy' => $mode, 'data_service_mode' => $mode, 'policy' => $policy, 'services' => $services, ]; } private function releaseReplicationPolicySummary(): array { return [ 'default_mode' => 'production_shared', 'normal_sync_changes_data_services' => false, 'allowed_modes' => [ 'production_shared', 'attach_existing', 'clone_existing', 'fresh_empty', 'isolated_stack', ], 'service_kinds' => self::STACK_DATA_KINDS, 'change_control' => 'Data service mode changes are only allowed through explicit replication/failover actions.', ]; } private function replicationPolicyForMode(string $mode): array { return [ 'mode' => $mode, 'production_shared' => in_array($mode, ['production_shared', 'attach_existing'], true), 'replication_configurable' => true, 'failover_configurable' => true, 'normal_sync_changes_service' => false, ]; } private function activeServiceSetForChannel(int $channelId): ?array { return $this->selectOne( "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name FROM release_channel_versions v INNER JOIN release_service_sets s ON s.id = v.service_set_id LEFT JOIN release_channels c ON c.id = s.channel_id WHERE v.channel_id = ? AND v.active = 1 AND s.deleted_at IS NULL ORDER BY v.activated_at DESC, v.id DESC LIMIT 1", 'i', [$channelId] ); } private function releaseCoolifySummary(): array { $targetCount = (int)($this->selectOne('SELECT COUNT(*) AS total FROM release_deployment_targets WHERE deleted_at IS NULL')['total'] ?? 0); $instanceCount = $this->tableExists('coolify_instances') ? (int)($this->selectOne('SELECT COUNT(*) AS total FROM coolify_instances WHERE deleted_at IS NULL')['total'] ?? 0) : 0; return [ 'integrated' => $this->tableExists('coolify_instances'), 'panel' => 'release_manager', 'instances' => $instanceCount, 'deployment_targets' => $targetCount, 'legacy_route' => '/superuser/configuration/coolify', 'redirect_panel' => '/superuser/configuration/releases/integrations?panel=coolify', 'entity_facts' => [ 'instances' => $instanceCount, 'deployment_targets' => $targetCount, 'status' => $this->tableExists('coolify_instances') ? 'integrated' : 'not_configured', 'last_check' => date('c'), ], ]; } private function releaseFailoverSummary(): array { $replicationHosts = $this->tableExists('replication_hosts') ? (int)($this->selectOne('SELECT COUNT(*) AS total FROM replication_hosts')['total'] ?? 0) : 0; return [ 'integrated' => $this->tableExists('replication_hosts'), 'panel' => 'release_manager', 'replication_hosts' => $replicationHosts, 'default_data_mode' => 'production_shared', 'normal_sync_triggers_failover' => false, 'legacy_route' => '/superuser/configuration/failover', 'redirect_panel' => '/superuser/configuration/releases/data-services?panel=failover', 'entity_facts' => [ 'replication_hosts' => $replicationHosts, 'default_data_mode' => 'production_shared', 'readiness' => $this->tableExists('replication_hosts') ? 'ready' : 'not_configured', 'normal_sync_triggers_failover' => false, 'last_check' => date('c'), ], ]; } 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); if ($latestCommitSha !== '' && $commitSha !== '' && $commitSha !== $latestCommitSha) { $comparison = $this->githubRequest( 'GET', '/repos/' . $this->githubRepositoryPath($repository) . '/compare/' . rawurlencode($commitSha) . '...' . rawurlencode($latestCommitSha) ); $comparisonStatus = (string)($comparison['status'] ?? ''); if (!in_array($comparisonStatus, ['behind', 'identical'], true)) { throw new RuntimeException(sprintf('Commit %s is not reachable from branch %s.', $commitSha, $branch)); } } } 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', 'envs', 'environment_variables', '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(self::channelSlugForRoute($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 serviceSetInputHasExplicitDataTargets(array $input): bool { $dataTargets = is_array($input['data_targets'] ?? null) ? $input['data_targets'] : []; foreach (self::STACK_DATA_KINDS as $kind) { foreach ([ $input[$kind . '_coolify_target_id'] ?? null, $input[$kind . '_target_id'] ?? null, $dataTargets[$kind . '_coolify_target_id'] ?? null, $dataTargets[$kind . '_target_id'] ?? null, $dataTargets[$kind] ?? null, ] as $value) { if ($this->nullablePositiveInt($value) !== null) { return true; } } } return false; } private function isBetaChannel(array $channel): bool { return self::safeSlug((string)($channel['slug'] ?? '')) === 'beta'; } private function channelUsesProductionServices(array $channel): bool { return in_array( self::safeSlug((string)($channel['slug'] ?? '')), self::PRODUCTION_SERVICE_CHANNELS, true ); } private function serviceSetDataPolicy(?array $serviceSet): string { if ($serviceSet === null) { return self::PRODUCTION_DATA_POLICY; } $mode = strtolower(trim((string)($serviceSet['mode'] ?? ''))); if (in_array($mode, ['clone_existing', 'fresh_empty', 'isolated_stack'], true)) { return $mode; } if (in_array($mode, ['', 'attach_existing', self::PRODUCTION_DATA_POLICY], true)) { return self::PRODUCTION_DATA_POLICY; } $metadata = is_array($serviceSet['metadata'] ?? null) ? $serviceSet['metadata'] : self::jsonDecode($serviceSet['metadata_json'] ?? null); $policy = strtolower(trim((string)($serviceSet['data_policy'] ?? $serviceSet['data_service_mode'] ?? $metadata['data_policy'] ?? $metadata['data_service_mode'] ?? ''))); if ($policy === self::PRODUCTION_DATA_POLICY) { return self::PRODUCTION_DATA_POLICY; } return $policy !== '' ? $policy : self::PRODUCTION_DATA_POLICY; } private function assertBetaProductionDataPolicy(array $channel, array $serviceSet): void { if (!$this->isBetaChannel($channel)) { return; } if ($this->serviceSetDataPolicy($serviceSet) !== self::PRODUCTION_DATA_POLICY) { throw new RuntimeException('Beta release bundles must use production-shared data services.'); } } private function assertBetaDataSourceChannel(?array $source): void { if ($source === null) { return; } $slug = self::safeSlug((string)($source['channel_slug'] ?? '')); if (!in_array($slug, self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS, true)) { throw new RuntimeException('Beta data-only service sets can copy data targets only from Stable/Master production service sets.'); } } private function assertServiceSetTargetBelongsToChannel(?int $targetId, string $app, array $channel): void { if ($targetId === null) { return; } $target = $this->getDeploymentTarget($targetId); if ((string)($target['app'] ?? '') !== $app) { throw new RuntimeException(sprintf('Selected %s target does not match the requested app.', $app)); } if ((int)($target['channel_id'] ?? 0) !== (int)($channel['id'] ?? 0)) { throw new RuntimeException(sprintf('Beta production-data service sets require %s targets from the beta channel.', $app)); } } 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 ($mode === 'attach_existing') { return $hasCode ? 'ready' : 'needs_configuration'; } 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' => self::releaseVersionGithubAccessMetadata($input['github_access'] ?? null), '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'] ?? ''), 'route_slug' => self::routeSlugForChannel((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 { $serviceChannel = $this->runtimeServiceChannelFor($channel); return [ 'channel' => $this->publicRuntimeChannel($channel), 'service_channel' => $this->publicRuntimeChannel($serviceChannel), 'versions' => $this->currentVersionsForChannel((int)($serviceChannel['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::publicReleaseVersionMetadata(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 static function publicReleaseVersionMetadata(mixed $metadata): array { if (!is_array($metadata)) { return []; } unset($metadata['github_access']); return $metadata; } private static function releaseVersionGithubAccessMetadata(mixed $githubAccess): ?array { if (!is_array($githubAccess)) { return null; } unset($githubAccess['commit'], $githubAccess['latest_commit'], $githubAccess['commit_authored_at']); return $githubAccess; } 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 { $deployContext = self::jsonDecode($target['deploy_context_json'] ?? null); $targetWithContext = $target + ['deploy_context' => $deployContext]; 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' => $deployContext, 'endpoint' => $this->releaseDeploymentEndpoint($targetWithContext), '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); $metadata = self::jsonDecode($serviceSet['metadata_json'] ?? null); $dataPolicy = $this->serviceSetDataPolicy(array_replace($serviceSet, ['metadata' => $metadata])); 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'), 'data_policy' => $dataPolicy, 'data_service_mode' => $dataPolicy, 'source_service_set_id' => isset($serviceSet['source_service_set_id']) ? (int)$serviceSet['source_service_set_id'] : null, 'data_source_service_set_id' => $this->nullablePositiveInt($metadata['data_source_service_set_id'] ?? null), 'data_source_channel_slug' => $metadata['data_source_channel_slug'] ?? 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' => $metadata, '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; } $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; $endpoint = $replication !== null && !empty($replication['host']) ? $this->releaseEndpointFromParts( 'auto', 'resolved', (string)$replication['host'], isset($replication['port']) ? (int)$replication['port'] : null, 'replication_host', 'Endpoint resolved from the attached replication host.' ) : self::releasePendingEndpoint('auto', 'coolify_target', 'Automatic endpoint resolution is pending Coolify target metadata.'); 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, 'endpoint' => $endpoint, 'replication' => $replication, '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'] ?? ''), 'active_channel_app_key' => $deployment['active_channel_app_key'] ?? null, 'active_current' => trim((string)($deployment['active_channel_app_key'] ?? '')) !== '', '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 : []; } }