From 24ac681365516dad179a7631eda39147d30681c2 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 20 May 2026 11:27:29 +0200 Subject: [PATCH] Add tests for GitHub commit timestamp handling and runtime channel selection in ReleaseManager. Extend release URL normalization and runtime channel methods, and introduce assignment subject searches. --- .../nginx/app/classes/release_manager.php | 577 +++++++++++++++++- .../nginx/app/routes/releaseManagerRoute.php | 22 +- .../ReleaseManager/ReleaseManagerTest.php | 177 ++++++ 3 files changed, 743 insertions(+), 33 deletions(-) diff --git a/services/nginx/app/classes/release_manager.php b/services/nginx/app/classes/release_manager.php index 0311e0cd..a49f56c3 100644 --- a/services/nginx/app/classes/release_manager.php +++ b/services/nginx/app/classes/release_manager.php @@ -2,6 +2,7 @@ namespace classes; +use customers\economicCustomers; use RuntimeException; use Throwable; @@ -307,14 +308,16 @@ class release_manager $this->ensureSchema(); $channel = $this->defaultChannel(); $versions = $this->currentVersionsForChannel((int)$channel['id']); + $urls = $this->releaseRuntimeUrls($channel, $versions); return [ 'generated_at' => date('c'), 'trace_id' => $this->requestTraceId(), - 'channel' => $this->publicChannel($channel), + 'channel' => $this->publicRuntimeChannel($channel), 'versions' => $versions, - 'api_base_url' => $channel['api_base_url'] ?? null, - 'frontend_base_url' => $channel['frontend_base_url'] ?? null, + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'urls' => $urls, 'availability' => $this->channelAvailability($channel), 'capture_policy' => [ 'enabled' => false, @@ -322,10 +325,12 @@ class release_manager '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 + public function runtimeForPayload(array $payload, array $input = []): array { $this->ensureSchema(); @@ -335,38 +340,60 @@ class release_manager 'customer_number' => isset($payload['customer_number']) ? (int)$payload['customer_number'] : null, ]; - $channel = $this->resolveChannel($context); + $resolvedChannel = $this->resolveChannel($context); + $availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel); + $channel = $this->chooseRuntimeChannel( + $resolvedChannel, + $availableChannels, + $this->requestedRuntimeChannelSlug($input) + ); $versions = $this->currentVersionsForChannel((int)$channel['id']); $capturePolicy = $this->capturePolicyFor($context, $channel); + $urls = $this->releaseRuntimeUrls($channel, $versions); return [ 'generated_at' => date('c'), 'trace_id' => $this->requestTraceId(), - 'channel' => $this->publicChannel($channel), + 'channel' => $this->publicRuntimeChannel($channel), 'versions' => $versions, - 'api_base_url' => $channel['api_base_url'] ?? null, - 'frontend_base_url' => $channel['frontend_base_url'] ?? null, + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'urls' => $urls, 'availability' => $this->channelAvailability($channel), 'capture_policy' => $capturePolicy, + 'available_channels' => $this->publicRuntimeChannelOptions($availableChannels), + 'selected_channel_slug' => (string)($channel['slug'] ?? ''), 'module_keys' => self::MODULE_KEYS, ]; } - public function runtimeForCurrentPrincipal(): array + public function runtimeForCurrentPrincipal(array $input = []): array { $this->ensureSchema(); $context = $this->currentPrincipalContext(); - $channel = $this->resolveChannel($context); + $resolvedChannel = $this->resolveChannel($context); + $availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel); + $channel = $this->chooseRuntimeChannel( + $resolvedChannel, + $availableChannels, + $this->requestedRuntimeChannelSlug($input) + ); + + $versions = $this->currentVersionsForChannel((int)$channel['id']); + $urls = $this->releaseRuntimeUrls($channel, $versions); return [ 'generated_at' => date('c'), 'trace_id' => $this->requestTraceId(), - 'channel' => $this->publicChannel($channel), - 'versions' => $this->currentVersionsForChannel((int)$channel['id']), - 'api_base_url' => $channel['api_base_url'] ?? null, - 'frontend_base_url' => $channel['frontend_base_url'] ?? null, + 'channel' => $this->publicRuntimeChannel($channel), + 'versions' => $versions, + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'urls' => $urls, 'availability' => $this->channelAvailability($channel), 'capture_policy' => $this->capturePolicyFor($context, $channel), + 'available_channels' => $this->publicRuntimeChannelOptions($availableChannels), + 'selected_channel_slug' => (string)($channel['slug'] ?? ''), 'module_keys' => self::MODULE_KEYS, ]; } @@ -723,6 +750,38 @@ class release_manager ); } + 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(); @@ -1435,6 +1494,35 @@ class release_manager ]; } + 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()) { @@ -1488,7 +1576,11 @@ class release_manager } } $versionLabel = trim((string)($input['version_label'] ?? $input['tag'] ?? $commitSha ?? date('Ymd-His'))) ?: date('Ymd-His'); - $deployedUrl = trim((string)($input['deployed_url'] ?? $target['health_url'] ?? '')) ?: null; + $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) { @@ -1568,18 +1660,23 @@ class release_manager $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)), $deployedUrl, $status, $deploymentId] + [$status, self::jsonEncode(self::redactPayload($result)), $effectiveDeployedUrl, $status, $deploymentId] ); $this->execute( - "UPDATE release_versions SET status = ?, deployed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE deployed_at END WHERE id = ?", - 'ssi', - [$status === 'deployed' ? 'deployed' : 'deploying', $status, $versionId] + "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, @@ -2166,6 +2263,86 @@ class release_manager 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 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'] ?? ''); @@ -2301,32 +2478,107 @@ class release_manager $serviceSet = null; } } + $bundle = null; + if (!empty($current['bundle_id'])) { + try { + $bundle = $this->publicBundle($this->getBundle((int)$current['bundle_id']), false); + } catch (Throwable) { + $bundle = null; + } + } return [ 'frontend' => $frontend, 'api' => $api, 'service_set' => $serviceSet, 'bundle_id' => isset($current['bundle_id']) ? (int)$current['bundle_id'] : null, + 'bundle' => $bundle, ]; } private function channelAvailability(array $channel): array { + $channelId = (int)($channel['id'] ?? 0); + $isDefault = ((int)($channel['default_channel'] ?? 0) === 1) || (string)($channel['slug'] ?? '') === 'stable'; + if ($isDefault || $channelId <= 0) { + return [ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + ]; + } + + $versions = $this->currentVersionsForChannel($channelId); + $urls = $this->releaseRuntimeUrls($channel, $versions); $missing = []; - if (trim((string)($channel['frontend_base_url'] ?? '')) === '') { + if (empty($versions['bundle_id'])) { + $missing[] = 'release_bundle'; + } + if (empty($versions['frontend'])) { + $missing[] = 'frontend_version'; + } elseif (empty($urls['frontend_base_url'])) { $missing[] = 'frontend_base_url'; } - if (trim((string)($channel['api_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'] : []; + + return [ + 'frontend_base_url' => $this->normalizeReleasePublicBaseUrl( + $frontend['deployed_url'] ?? $channel['frontend_base_url'] ?? null, + 'frontend' + ), + 'api_base_url' => $this->normalizeReleasePublicBaseUrl( + $api['deployed_url'] ?? $channel['api_base_url'] ?? null, + 'api' + ), + ]; + } + + 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( @@ -2939,8 +3191,13 @@ class release_manager private function releaseCoolifyPublicUrl(array $target, array $context): ?string { - $raw = trim((string)($context['coolify_public_url'] ?? $context['coolify_domain'] ?? '')); - if ($raw === '' && !$this->toBool($context['coolify_enable_ssl'] ?? false)) { + $explicitPublicUrl = $this->normalizeReleasePublicBaseUrl($context['coolify_public_url'] ?? null, (string)($target['app'] ?? '')); + if ($explicitPublicUrl !== null) { + return $explicitPublicUrl; + } + + $raw = trim((string)($context['coolify_domain'] ?? '')); + if ($raw === '') { $raw = trim((string)($target['health_url'] ?? '')); } if ($raw === '') { @@ -2953,11 +3210,19 @@ class release_manager } return 'https://' . $domain; } - $raw = preg_replace('#/(health|ping)$#i', '', rtrim($raw, '/')) ?: $raw; if (preg_match('#^https?://#i', $raw) !== 1) { - $raw = ($this->toBool($context['coolify_enable_ssl'] ?? false) ? 'https://' : 'http://') . $raw; + $raw = 'http://' . $raw; } - return rtrim($raw, '/'); + return $this->normalizeReleasePublicBaseUrl($raw, (string)($target['app'] ?? '')); + } + + private function releaseTargetPublicBaseUrl(array $target): ?string + { + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $app = (string)($target['app'] ?? ''); + return $this->normalizeReleasePublicBaseUrl($context['coolify_public_url'] ?? null, $app) + ?? $this->normalizeReleasePublicBaseUrl($context['coolify_domain'] ?? null, $app) + ?? $this->normalizeReleasePublicBaseUrl($target['health_url'] ?? null, $app); } private function timelineSessionContext(array $context): array @@ -3224,13 +3489,24 @@ class release_manager $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) ); - $commitSha = trim((string)($commitRow['sha'] ?? $rawCommitSha)); - $commitUrl = (string)($commitRow['html_url'] ?? $commitUrl); + $commit = is_array($commitRow) ? $this->publicGithubCommit($commitRow) : []; + $commitSha = trim((string)($commit['sha'] ?? (is_array($commitRow) ? ($commitRow['sha'] ?? null) : null) ?? $rawCommitSha)); + $commitUrl = (string)($commit['html_url'] ?? (is_array($commitRow) ? ($commitRow['html_url'] ?? null) : null) ?? $commitUrl); } return [ @@ -3248,6 +3524,9 @@ class release_manager '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) { @@ -4247,7 +4526,7 @@ class release_manager 'commit_mode' => $commitMode, 'commit_sha' => $commitSha, 'version_label' => sprintf('%s-%s', $versionLabel, $app === 'api' ? 'php' : 'frontend'), - 'deployed_url' => $target['health_url'] ?? null, + 'deployed_url' => is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null, 'github_access' => $githubAccess, ]; } @@ -4381,25 +4660,237 @@ class release_manager ]; } + private function publicRuntimeChannel(array $channel): array + { + $public = $this->publicChannel($channel); + unset($public['frontend_base_url'], $public['api_base_url']); + return $public; + } + + private function publicRuntimeChannelOptions(array $channels): array + { + return array_map(function (array $channel): array { + return [ + 'channel' => $this->publicRuntimeChannel($channel), + 'versions' => $this->currentVersionsForChannel((int)($channel['id'] ?? 0)), + 'availability' => $this->channelAvailability($channel), + ]; + }, $channels); + } + + public static function publicAssignmentSubjectSuggestion(array $candidate): ?array + { + $subjectType = strtolower(trim((string)($candidate['subject_type'] ?? ''))); + if (!in_array($subjectType, self::SUBJECT_TYPES, true)) { + return null; + } + + $subjectId = self::safeIdentifier((string)($candidate['subject_id'] ?? ''), 64); + if ($subjectId === '') { + return null; + } + + $title = self::safeDisplayText($candidate['title'] ?? '', 120); + if ($title === '') { + $title = $subjectType . ':' . $subjectId; + } + $description = self::safeDisplayText($candidate['description'] ?? '', 180); + $icon = self::safeIconClass($candidate['icon'] ?? self::assignmentSubjectIcon($subjectType)); + $source = self::safeIdentifier((string)($candidate['source'] ?? $subjectType), 32) ?: $subjectType; + + return [ + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'label' => $description !== '' ? $title . ' - ' . $description : $title, + 'title' => $title, + 'description' => $description, + 'icon' => $icon, + 'source' => $source, + ]; + } + + private function searchAssignmentUsers(string $query, int $limit): array + { + $like = '%' . $query . '%'; + $rows = $this->selectRows( + "SELECT id, customer_number, display_name, email, phone_country_code, phone + FROM users + WHERE CAST(id AS CHAR) LIKE ? + OR CAST(customer_number AS CHAR) LIKE ? + OR display_name LIKE ? + OR email LIKE ? + ORDER BY id DESC + LIMIT ?", + 'ssssi', + [$like, $like, $like, $like, $limit] + ); + + return array_map(function (array $row): array { + $id = (string)($row['id'] ?? ''); + $customerNumber = trim((string)($row['customer_number'] ?? '')); + $displayName = self::safeDisplayText($row['display_name'] ?? '', 80); + $email = self::safeDisplayText($row['email'] ?? '', 80); + $parts = array_filter([ + $customerNumber !== '' ? 'Customer #' . $customerNumber : '', + $email, + $this->phoneLabel($row), + ]); + + return [ + 'subject_type' => 'user', + 'subject_id' => $id, + 'title' => $displayName !== '' ? $displayName : 'User #' . $id, + 'description' => implode(' / ', $parts), + 'icon' => self::assignmentSubjectIcon('user'), + 'source' => 'users', + ]; + }, $rows); + } + + private function searchAssignmentSubusers(string $query, int $limit): array + { + $like = '%' . $query . '%'; + $rows = $this->selectRows( + "SELECT id, username, name, email, phone_country_code, phone + FROM subusers + WHERE CAST(id AS CHAR) LIKE ? + OR username LIKE ? + OR name LIKE ? + OR email LIKE ? + OR CAST(phone AS CHAR) LIKE ? + ORDER BY id DESC + LIMIT ?", + 'sssssi', + [$like, $like, $like, $like, $like, $limit] + ); + + return array_map(function (array $row): array { + $id = (string)($row['id'] ?? ''); + $name = self::safeDisplayText($row['name'] ?? '', 80); + $username = self::safeDisplayText($row['username'] ?? '', 80); + $email = self::safeDisplayText($row['email'] ?? '', 80); + $parts = array_filter([ + $username !== '' ? '@' . ltrim($username, '@') : '', + $email, + $this->phoneLabel($row), + ]); + + return [ + 'subject_type' => 'subuser', + 'subject_id' => $id, + 'title' => $name !== '' ? $name : 'Subuser #' . $id, + 'description' => implode(' / ', $parts), + 'icon' => self::assignmentSubjectIcon('subuser'), + 'source' => 'subusers', + ]; + }, $rows); + } + + private function searchAssignmentCustomers(string $query, int $limit): array + { + try { + $result = (new economicCustomers())->listCustomers(1, $limit, $query, null); + } catch (Throwable) { + return []; + } + + $customers = is_array($result->collection ?? null) ? $result->collection : []; + return array_map(static function (object $customer): array { + $customerNumber = (string)($customer->customerNumber ?? $customer->customer_number ?? ''); + $name = self::safeDisplayText($customer->name ?? $customer->customer_name ?? '', 100); + $email = self::safeDisplayText($customer->email ?? '', 80); + $city = self::safeDisplayText($customer->city ?? '', 80); + $parts = array_filter([ + $customerNumber !== '' ? 'Customer #' . $customerNumber : '', + $email, + $city, + ]); + + return [ + 'subject_type' => 'customer', + 'subject_id' => $customerNumber, + 'title' => $name !== '' ? $name : 'Customer #' . $customerNumber, + 'description' => implode(' / ', $parts), + 'icon' => self::assignmentSubjectIcon('customer'), + 'source' => 'customers', + ]; + }, $customers); + } + + private static function normalizeAssignmentSubjectSearch(mixed $value): string + { + $query = self::safeDisplayText($value, 80); + return trim($query); + } + + private static function normalizeAssignmentSubjectLimit(mixed $value): int + { + $limit = (int)$value; + if ($limit <= 0) { + return 5; + } + return min(10, max(1, $limit)); + } + + private static function safeDisplayText(mixed $value, int $maxLength): string + { + $text = trim(strip_tags((string)$value)); + $text = preg_replace('/\s+/', ' ', $text) ?? ''; + return substr($text, 0, max(1, $maxLength)); + } + + private static function safeIconClass(mixed $value): string + { + $icon = trim((string)$value); + if (!preg_match('/^[a-z0-9 _-]+$/i', $icon)) { + return 'fas fa-tag'; + } + return $icon; + } + + private static function assignmentSubjectIcon(string $subjectType): string + { + return match ($subjectType) { + 'customer' => 'fas fa-building', + 'subuser' => 'fas fa-id-badge', + default => 'fas fa-user', + }; + } + + private function phoneLabel(array $row): string + { + $countryCode = trim((string)($row['phone_country_code'] ?? '')); + $phone = trim((string)($row['phone'] ?? '')); + if ($phone === '') { + return ''; + } + return $countryCode !== '' ? '+' . $countryCode . ' ' . $phone : $phone; + } + private function publicVersion(?array $version): ?array { if (!$version || empty($version['id'])) { return null; } + $metadata = self::jsonDecode($version['metadata_json'] ?? null); + $commit = $this->versionGithubCommit(['metadata' => $metadata]); + return [ 'id' => (int)$version['id'], 'app' => (string)$version['app'], 'repository' => $version['repository'] ?? null, 'branch' => $version['branch'] ?? null, 'commit_sha' => $version['commit_sha'] ?? null, + 'commit' => $commit, + 'commit_authored_at' => $commit['authored_at'] ?? null, 'tag' => $version['tag'] ?? null, 'version_label' => $version['version_label'] ?? null, 'build_url' => $version['build_url'] ?? null, 'artifact_url' => $version['artifact_url'] ?? null, 'deployed_url' => $version['deployed_url'] ?? null, 'status' => (string)($version['status'] ?? 'unknown'), - 'metadata' => self::jsonDecode($version['metadata_json'] ?? null), + 'metadata' => $metadata, 'created_at' => $version['created_at'] ?? null, 'deployed_at' => $version['deployed_at'] ?? null, ]; @@ -4493,6 +4984,10 @@ class release_manager $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) { @@ -4518,7 +5013,9 @@ class release_manager 'repository' => $bundle['frontend_repository'] ?? null, 'branch' => $bundle['frontend_branch'] ?? null, 'commit_sha' => $bundle['frontend_commit_sha'] ?? null, - 'version' => $frontendVersionId !== null ? $this->publicVersion($this->getVersion($frontendVersionId)) : null, + 'commit' => $frontendCommit, + 'commit_authored_at' => $frontendCommit['authored_at'] ?? null, + 'version' => $frontendVersion, 'deployment' => $this->nullableDeployment($frontendDeploymentId), ], 'api' => [ @@ -4527,7 +5024,9 @@ class release_manager 'repository' => $bundle['api_repository'] ?? null, 'branch' => $bundle['api_branch'] ?? null, 'commit_sha' => $bundle['api_commit_sha'] ?? null, - 'version' => $apiVersionId !== null ? $this->publicVersion($this->getVersion($apiVersionId)) : null, + 'commit' => $apiCommit, + 'commit_authored_at' => $apiCommit['authored_at'] ?? null, + 'version' => $apiVersion, 'deployment' => $this->nullableDeployment($apiDeploymentId), ], ], @@ -4541,6 +5040,20 @@ class release_manager ]; } + 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()) { diff --git a/services/nginx/app/routes/releaseManagerRoute.php b/services/nginx/app/routes/releaseManagerRoute.php index 7fc6c552..60b8fe5d 100644 --- a/services/nginx/app/routes/releaseManagerRoute.php +++ b/services/nginx/app/routes/releaseManagerRoute.php @@ -20,7 +20,7 @@ class releaseManagerRoute $this->get('/release/runtime', function () { global $response; - $response->success((new release_manager())->runtimeForCurrentPrincipal()); + $response->success((new release_manager())->runtimeForCurrentPrincipal($this->getParametersAsArray())); }); $this->post('/release/timeline/events', function () { @@ -161,6 +161,18 @@ class releaseManagerRoute 'superuser_release_manager_rollback' => 'Rollback an active release channel', ]); + $this->post('/superuser/releases/channels/{id}/bundle', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->setChannelBundle($this->routeId(), $this->requestPayload(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Set the active release bundle for a channel', + ]); + $this->get('/superuser/releases/assignments', function () { global $response; $this->requirePermission('superuser_release_manager_view'); @@ -169,6 +181,14 @@ class releaseManagerRoute 'superuser_release_manager_view' => 'View release channel assignments', ]); + $this->get('/superuser/releases/assignment-subjects', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + $response->success((new release_manager())->searchAssignmentSubjects($this->getParametersAsArray())); + }, [ + 'superuser_release_manager_manage' => 'Search users, subusers, and customers for Release Manager assignments', + ]); + $this->post('/superuser/releases/assignments', function () { global $response; $this->requirePermission('superuser_release_manager_manage'); diff --git a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php index 3ded6bdd..f454642b 100644 --- a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php +++ b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php @@ -46,6 +46,32 @@ it('normalizes GitHub repository identifiers for private repository access check expect(release_manager::normalizeGithubRepositoryName('not a repository'))->toBe(''); }); +it('keeps GitHub commit timestamps in public release manager commit payloads', function (): void { + $manager = new release_manager(); + $method = new ReflectionMethod(release_manager::class, 'publicGithubCommit'); + $method->setAccessible(true); + + $commit = $method->invoke($manager, [ + 'sha' => 'feedface00000000000000000000000000000000', + 'html_url' => 'https://github.com/truckwash/backend-php/commit/feedface', + 'commit' => [ + 'message' => "Deploy release bundle\n\nBody is intentionally omitted from option labels.", + 'author' => [ + 'name' => 'Release Bot', + 'date' => '2026-05-19T08:10:00Z', + ], + ], + ]); + + expect($commit)->toMatchArray([ + 'sha' => 'feedface00000000000000000000000000000000', + 'short_sha' => 'feedface0000', + 'message' => 'Deploy release bundle', + 'author_name' => 'Release Bot', + 'authored_at' => '2026-05-19T08:10:00Z', + ]); +}); + it('summarizes failed deployments and blocks promotion until a deployment succeeds', function (): void { $summary = release_manager::deploymentFailureSummary( new RuntimeException('Coolify API request failed: HTTP 404'), @@ -381,6 +407,8 @@ it('defines release manager schema, routes, permissions, and system-status integ expect($route)->toContain('/superuser/releases/github/commits'); expect($route)->toContain('/superuser/releases/github/test'); expect($route)->toContain('/superuser/releases/channels'); + expect($route)->toContain('/superuser/releases/channels/{id}/bundle'); + expect($route)->toContain('/superuser/releases/assignment-subjects'); expect($route)->toContain('/superuser/releases/assignments'); expect($route)->toContain('/superuser/releases/service-sets'); expect($route)->toContain("\$this->delete('/superuser/releases/service-sets/{id}'"); @@ -424,6 +452,12 @@ it('defines release manager schema, routes, permissions, and system-status integ expect($manager)->toContain('createBundle'); expect($manager)->toContain('deployBundle'); expect($manager)->toContain('promoteBundle'); + expect($manager)->toContain('setChannelBundle'); + expect($manager)->toContain('searchAssignmentSubjects'); + expect($manager)->toContain('publicAssignmentSubjectSuggestion'); + expect($manager)->toContain('available_channels'); + expect($manager)->toContain('chooseRuntimeChannel'); + expect($manager)->toContain('requestedRuntimeChannelSlug'); expect($manager)->toContain("status = 'superseded'"); expect($manager)->toContain('serviceSetIsActive'); expect($manager)->toContain('bundleIsActive'); @@ -469,6 +503,7 @@ it('defines release manager schema, routes, permissions, and system-status integ expect($manager)->toContain('load_balancer_domains'); expect($manager)->toContain('appendDomainSuggestion'); expect($manager)->toContain('Coolify SSL requires a DNS domain routed to the load balancer.'); + expect($manager)->toContain('releaseRuntimeUrls'); expect($manager)->toContain('coolify_services'); expect($manager)->toContain('coolify_enable_ssl'); expect($manager)->toContain('createService'); @@ -484,3 +519,145 @@ it('defines release manager schema, routes, permissions, and system-status integ expect($index)->toContain('X-Release-Trace'); expect($manager)->not->toContain('X-Release-Channel'); }); + +it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void { + $manager = new release_manager(); + $availability = new ReflectionMethod(release_manager::class, 'channelAvailability'); + $availability->setAccessible(true); + $runtimeUrls = new ReflectionMethod(release_manager::class, 'releaseRuntimeUrls'); + $runtimeUrls->setAccessible(true); + $publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl'); + $publicUrl->setAccessible(true); + + expect($availability->invoke($manager, [ + 'id' => 1, + 'slug' => 'stable', + 'default_channel' => 1, + 'frontend_base_url' => null, + 'api_base_url' => null, + ]))->toMatchArray([ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + ]); + + expect($runtimeUrls->invoke($manager, [ + 'id' => 2, + 'slug' => 'canary', + 'default_channel' => 0, + 'frontend_base_url' => null, + 'api_base_url' => null, + ], [ + 'frontend' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/frontend/health'], + 'api' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/api/ping'], + ]))->toBe([ + 'frontend_base_url' => 'https://api-v2.truckwash.io/canary/frontend', + 'api_base_url' => 'https://api-v2.truckwash.io/canary/api', + ]); + + expect($publicUrl->invoke($manager, ['app' => 'frontend'], [ + 'coolify_public_url' => 'https://api-v2.truckwash.io/canary/frontend', + 'coolify_enable_ssl' => true, + ]))->toBe('https://api-v2.truckwash.io/canary/frontend'); + + $source = file(app_path('classes/release_manager.php')); + $methodSource = implode('', array_slice( + $source, + $availability->getStartLine() - 1, + $availability->getEndLine() - $availability->getStartLine() + 1 + )); + + expect($methodSource)->toContain('frontend_base_url'); + expect($methodSource)->toContain('api_base_url'); + expect($methodSource)->toContain('release_bundle'); + expect($methodSource)->toContain('frontend_version'); + expect($methodSource)->toContain('api_version'); +}); + +it('exposes release version git commit metadata for runtime channel cards', function (): void { + $manager = new release_manager(); + $publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion'); + $publicVersion->setAccessible(true); + + $version = $publicVersion->invoke($manager, [ + 'id' => 12, + 'app' => 'frontend', + 'repository' => 'truckwash/front-end-vue', + 'branch' => 'release/canary', + 'commit_sha' => 'c0ffee0000001111222233334444555566667777', + 'tag' => null, + 'version_label' => 'frontend-canary', + 'build_url' => null, + 'artifact_url' => null, + 'deployed_url' => null, + 'status' => 'active', + 'metadata_json' => json_encode([ + 'github_access' => [ + 'commit' => [ + 'sha' => 'c0ffee0000001111222233334444555566667777', + 'authored_at' => '2026-05-19T10:15:00Z', + ], + ], + ]), + 'created_at' => '2026-05-19 10:10:00', + 'deployed_at' => '2026-05-19 10:20:00', + ]); + + expect($version['commit_sha'])->toBe('c0ffee0000001111222233334444555566667777'); + expect($version['commit']['sha'])->toBe('c0ffee0000001111222233334444555566667777'); + expect($version['commit_authored_at'])->toBe('2026-05-19T10:15:00Z'); + expect($version['deployed_at'])->toBe('2026-05-19 10:20:00'); +}); + +it('chooses a requested runtime channel only when it is available to the principal', function (): void { + $manager = new release_manager(); + $choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel'); + $choose->setAccessible(true); + + $stable = [ + 'id' => 1, + 'slug' => 'stable', + 'name' => 'Stable', + 'default_channel' => 1, + ]; + $canary = [ + 'id' => 2, + 'slug' => 'canary', + 'name' => 'Canary', + 'default_channel' => 0, + ]; + + expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary); + expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable); + expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary); +}); + +it('normalizes release assignment subject suggestions without leaking private fields', function (): void { + $suggestion = release_manager::publicAssignmentSubjectSuggestion([ + 'subject_type' => 'USER', + 'subject_id' => 42, + 'title' => ' Dispatcher ', + 'description' => 'Customer #424242 / dispatcher@example.test', + 'icon' => 'fas fa-user', + 'source' => 'users', + 'password' => 'secret', + 'two_factor_secret' => 'private', + ]); + + expect($suggestion)->toBe([ + 'subject_type' => 'user', + 'subject_id' => '42', + 'label' => 'Dispatcher - Customer #424242 / dispatcher@example.test', + 'title' => 'Dispatcher', + 'description' => 'Customer #424242 / dispatcher@example.test', + 'icon' => 'fas fa-user', + 'source' => 'users', + ]); + expect(array_keys($suggestion))->not->toContain('password'); + expect(array_keys($suggestion))->not->toContain('two_factor_secret'); + expect(release_manager::publicAssignmentSubjectSuggestion([ + 'subject_type' => 'invalid', + 'subject_id' => 42, + 'title' => 'Invalid', + ]))->toBeNull(); +});