Add extensive testing for ReleaseManager status overview logic and normalize API ingress paths. Enhance CORS headers and update gateway route URL handling for Coolify.

This commit is contained in:
Jeppe Bundgaard
2026-05-20 17:49:16 +02:00
parent c5a1271798
commit 86fb092c22
10 changed files with 1255 additions and 23 deletions
+31 -9
View File
@@ -1020,6 +1020,7 @@ class coolify_manager
$resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
$instanceId = (int)($target['coolify_instance_id'] ?? 0);
$resourceType = $this->gatewayRouteResourceType($target);
$targetPublicUrl = self::gatewayRouteTargetPublicUrl($publicHost, $target);
$action = [
'type' => 'deploy_gateway_route',
'target_id' => (int)($target['id'] ?? 0),
@@ -1027,7 +1028,7 @@ class coolify_manager
'app' => (string)($target['app'] ?? 'api'),
'resource_uuid' => $resourceUuid,
'resource_type' => $resourceType,
'public_url' => $publicUrl,
'public_url' => $targetPublicUrl,
'deploy' => true,
];
@@ -1056,12 +1057,12 @@ class coolify_manager
$updatePayload = $resourceType === 'service'
? self::gatewayRouteServicePayload(
$publicUrl,
$targetPublicUrl,
(string)($target['app'] ?? 'api'),
self::resourceFirstExposedPort($resource, $target)
)
: self::gatewayRouteApplicationPayload(
$publicUrl,
$targetPublicUrl,
$resourceUuid,
self::resourceFirstExposedPort($resource, $target),
$resource['custom_labels'] ?? null
@@ -1078,7 +1079,7 @@ class coolify_manager
'resource_type' => $resourceType,
])
);
$this->persistGatewayRouteTargetContext((int)$target['id'], $target, $publicHost, $publicUrl);
$this->persistGatewayRouteTargetContext((int)$target['id'], $target, $publicHost, $targetPublicUrl);
$applied[] = array_replace($action, [
'updated' => self::redactCoolifyResponse($update),
'deployment' => self::redactCoolifyResponse($deployment),
@@ -1210,6 +1211,7 @@ class coolify_manager
$branch = trim((string)($target['branch'] ?? 'master')) ?: 'master';
$resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
$instanceId = (int)($target['coolify_instance_id'] ?? 0);
$targetPublicUrl = self::gatewayRouteTargetPublicUrl($publicHost, $target);
$action = [
'type' => 'deploy_gateway_api_code',
'target_id' => (int)($target['id'] ?? 0),
@@ -1220,7 +1222,7 @@ class coolify_manager
'branch' => $branch,
'resource_uuid' => $resourceUuid,
'resource_type' => $this->gatewayRouteResourceType($target),
'public_url' => $publicUrl,
'public_url' => $targetPublicUrl,
'commit_mode' => 'latest',
];
@@ -1251,7 +1253,7 @@ class coolify_manager
'branch' => $branch,
'commit_mode' => 'latest',
'version_label' => $this->gatewayApiCodeVersionLabel($target),
'deployed_url' => $publicUrl,
'deployed_url' => $targetPublicUrl,
'metadata' => [
'gateway_api_code_deploy' => true,
'public_host' => $publicHost,
@@ -1778,6 +1780,7 @@ class coolify_manager
return $this->selectRows(
"SELECT t.*, c.slug AS channel_slug, c.name AS channel_name,
c.default_channel AS channel_default_channel,
d.id AS latest_deployment_id, d.status AS latest_deployment_status,
d.completed_at AS latest_deployment_completed_at,
d.commit_sha AS latest_deployment_commit_sha,
@@ -1824,12 +1827,15 @@ class coolify_manager
$coveredTargetIps = [];
$deploymentWaitItems = [];
$sourceTarget = $this->gatewayRouteProvisionSourceTarget($targets);
$sourcePublicUrl = $sourceTarget === null
? $publicUrl
: self::gatewayRouteTargetPublicUrl($publicHost, $sourceTarget);
foreach ($uncoveredGatewayIps as $targetIp) {
$action = [
'type' => 'provision_gateway_api_target',
'target_ip' => $targetIp,
'public_url' => $publicUrl,
'public_url' => $sourcePublicUrl,
'dry_run' => $dryRun,
];
@@ -1884,8 +1890,8 @@ class coolify_manager
'repository' => (string)($sourceTarget['repository'] ?? ''),
'branch' => (string)($sourceTarget['branch'] ?? 'master'),
'auto_deploy' => !isset($sourceTarget['auto_deploy']) || (int)$sourceTarget['auto_deploy'] === 1,
'health_url' => $publicUrl . '/ping',
'deploy_context' => $this->gatewayRouteProvisionDeployContext($sourceTarget, $server, $targetIp, $publicHost, $publicUrl),
'health_url' => $sourcePublicUrl . '/ping',
'deploy_context' => $this->gatewayRouteProvisionDeployContext($sourceTarget, $server, $targetIp, $publicHost, $sourcePublicUrl),
], $actorUserId);
}
@@ -2143,6 +2149,22 @@ class coolify_manager
return substr($slug !== '' ? $slug : $fallback, 0, 40);
}
private static function gatewayRouteTargetPublicUrl(string $publicHost, array $target): string
{
$baseUrl = 'https://' . strtolower(trim($publicHost));
$channelSlug = self::gatewayRouteSlug((string)($target['channel_slug'] ?? ''), '');
$appSlug = self::gatewayRouteSlug((string)($target['app'] ?? 'api'), 'api');
$defaultChannel = (int)($target['channel_default_channel'] ?? $target['default_channel'] ?? 0) === 1
|| $channelSlug === 'stable'
|| $channelSlug === '';
if ($defaultChannel || !in_array($appSlug, ['api', 'frontend'], true)) {
return $baseUrl;
}
return $baseUrl . '/' . $channelSlug . '/' . $appSlug;
}
private function gatewayRouteResourceType(array $target): string
{
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
+739 -5
View File
@@ -12,6 +12,7 @@ class release_manager
private const DEFAULT_BRANCH = 'master';
private const SERVICE_SET_MODES = ['attach_existing', 'clone_existing', 'fresh_empty', 'isolated_stack'];
private const STACK_DATA_KINDS = ['database', 'redis', 'minio'];
private const RELEASE_STATUS_SERVICES = ['frontend', 'api', 'database', 'redis', 'minio'];
private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod', 'beta'];
private const DEFAULT_COOLIFY_APPLICATION_PORT = '80';
private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
@@ -52,18 +53,91 @@ class release_manager
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' => bin2hex(random_bytes(16)),
'requested_channel' => '',
'frontend_version' => '',
'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 [];
}
$channelSlug = self::safeSlug((string)$matches[1]);
$enabled = array_flip(array_values(array_filter(array_map(
static fn(mixed $value): string => self::safeSlug((string)$value),
$enabledChannelSlugs
))));
if ($channelSlug === '' || !isset($enabled[$channelSlug])) {
return [];
}
$suffix = (string)($matches[2] ?? '');
$normalizedPath = '/' . ltrim($suffix, '/');
if ($normalizedPath === '/') {
$normalizedPath = '/';
}
$query = is_array($parts) && isset($parts['query']) && $parts['query'] !== ''
? '?' . (string)$parts['query']
: '';
$normalizedUri = $normalizedPath . $query;
$_SERVER['REQUEST_URI'] = $normalizedUri;
$_SERVER['PATH_INFO'] = $normalizedPath;
$_GET['release_channel'] = $channelSlug;
$context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null)
? $GLOBALS['RELEASE_REQUEST_CONTEXT']
: self::initializeRequestContext();
$context['original_request_uri'] = $context['original_request_uri'] ?: $requestUri;
$context['normalized_request_uri'] = $normalizedUri;
$context['requested_channel'] = $channelSlug;
$context['ingress_prefix_stripped'] = true;
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
return [
'channel_slug' => $channelSlug,
'original_request_uri' => $requestUri,
'normalized_request_uri' => $normalizedUri,
'normalized_path' => $normalizedPath,
];
}
public static function moduleKeys(): array
{
return self::MODULE_KEYS;
@@ -399,12 +473,21 @@ class release_manager
];
}
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();
return [
$summary = [
'generated_at' => date('c'),
'channels' => $this->listChannels(),
'assignments' => $this->listAssignments(),
@@ -417,6 +500,9 @@ class release_manager
'module_keys' => self::MODULE_KEYS,
'suggestions' => $this->releaseSuggestions(),
];
$summary['status_overview'] = $this->releaseStatusOverview($summary);
return $summary;
}
public function suggestions(): array
@@ -425,6 +511,628 @@ class release_manager
return $this->releaseSuggestions();
}
private function releaseStatusOverview(array $summary): array
{
$channels = array_values(array_filter(
is_array($summary['channels'] ?? null) ? $summary['channels'] : [],
static fn(mixed $channel): bool => is_array($channel)
));
$targetsByChannelApp = $this->releaseStatusTargetsByChannelApp(
is_array($summary['deployment_targets'] ?? null) ? $summary['deployment_targets'] : []
);
$deployments = array_values(array_filter(
is_array($summary['deployments'] ?? null) ? $summary['deployments'] : [],
static fn(mixed $deployment): bool => is_array($deployment)
));
$deploymentsByChannelApp = $this->releaseStatusLatestDeploymentsByChannelApp($deployments);
$serviceSetsByChannel = $this->releaseStatusServiceSetsByChannel(
is_array($summary['service_sets'] ?? null) ? $summary['service_sets'] : []
);
$channelRows = [];
$issues = [];
foreach ($channels as $channel) {
$row = $this->releaseStatusChannelRow(
$channel,
$targetsByChannelApp,
$deployments,
$deploymentsByChannelApp,
$serviceSetsByChannel
);
$channelRows[] = $row;
foreach ($row['issues'] as $issue) {
$issues[] = $issue;
}
}
usort($issues, static function (array $a, array $b): int {
$rank = self::releaseStatusSeverityRank($b['severity'] ?? 'ok')
<=> self::releaseStatusSeverityRank($a['severity'] ?? 'ok');
if ($rank !== 0) {
return $rank;
}
return strcmp((string)($a['channel_slug'] ?? ''), (string)($b['channel_slug'] ?? ''));
});
$affectedChannels = [];
$serviceCount = 0;
$unhealthyServiceCount = 0;
$missingValueCount = 0;
$criticalCount = 0;
$warningCount = 0;
foreach ($channelRows as $row) {
foreach ($row['services'] as $service) {
$serviceCount++;
if (self::releaseStatusSeverityRank($service['severity'] ?? 'ok') > 0) {
$unhealthyServiceCount++;
}
}
}
foreach ($issues as $issue) {
$severity = (string)($issue['severity'] ?? 'ok');
if ($severity === 'critical') {
$criticalCount++;
} elseif ($severity === 'warning') {
$warningCount++;
}
if (($issue['type'] ?? '') === 'missing_value') {
$missingValueCount++;
}
if (self::releaseStatusSeverityRank($severity) > 0 && !empty($issue['channel_slug'])) {
$affectedChannels[(string)$issue['channel_slug']] = true;
}
}
return [
'generated_at' => $summary['generated_at'] ?? date('c'),
'state' => $criticalCount > 0 ? 'blocked' : ($warningCount > 0 ? 'attention' : 'ready'),
'totals' => [
'channels' => count($channelRows),
'ready_channels' => count(array_filter(
$channelRows,
static fn(array $row): bool => ($row['readiness'] ?? '') === 'ready'
)),
'affected_channels' => count($affectedChannels),
'issues' => count($issues),
'critical' => $criticalCount,
'warning' => $warningCount,
'services' => $serviceCount,
'unhealthy_services' => $unhealthyServiceCount,
'missing_values' => $missingValueCount,
],
'issues' => $issues,
'channels' => $channelRows,
];
}
private function releaseStatusChannelRow(
array $channel,
array $targetsByChannelApp,
array $deployments,
array $deploymentsByChannelApp,
array $serviceSetsByChannel
): array {
$channelId = (int)($channel['id'] ?? 0);
$channelSlug = (string)($channel['slug'] ?? '');
$channelName = (string)($channel['name'] ?? $channelSlug);
$availability = $this->releaseStatusChannelAvailability($channel);
$services = $this->releaseStatusServicesForChannel(
$channel,
$availability,
$targetsByChannelApp,
$deploymentsByChannelApp,
$serviceSetsByChannel
);
$issues = [];
$missingValues = [];
foreach ($availability['missing'] as $missingKey) {
$missing = [
'key' => $missingKey,
'label' => self::releaseStatusMissingValueLabel($missingKey),
'service_key' => self::releaseStatusMissingServiceKey($missingKey),
'target_tab' => self::releaseStatusMissingTargetTab($missingKey),
];
$missingValues[] = $missing;
$issues[] = self::releaseStatusIssue([
'severity' => 'critical',
'type' => 'missing_value',
'channel_id' => $channelId,
'channel_slug' => $channelSlug,
'service_key' => $missing['service_key'],
'label' => $missing['label'],
'message' => $channelName . ' is missing ' . $missing['label'] . '.',
'next_action' => self::releaseStatusMissingNextAction($missingKey),
'target_tab' => $missing['target_tab'],
'missing_key' => $missingKey,
]);
}
foreach ($services as $service) {
if (self::releaseStatusSeverityRank($service['severity'] ?? 'ok') === 0 || empty($service['issue_type'])) {
continue;
}
$issues[] = self::releaseStatusIssue([
'severity' => (string)$service['severity'],
'type' => (string)$service['issue_type'],
'channel_id' => $channelId,
'channel_slug' => $channelSlug,
'service_key' => (string)$service['service_key'],
'label' => (string)$service['label'],
'message' => (string)$service['message'],
'next_action' => (string)$service['next_action'],
'target_tab' => (string)$service['target_tab'],
'target_id' => $service['target_id'] ?? null,
'deployment_id' => $service['deployment_id'] ?? null,
'coolify_target_id' => $service['coolify_target_id'] ?? null,
'missing_key' => $service['missing_key'] ?? null,
]);
}
$severity = 'ok';
foreach ($issues as $issue) {
$severity = self::releaseStatusMaxSeverity($severity, (string)($issue['severity'] ?? 'ok'));
}
$readiness = $severity === 'critical' ? 'blocked' : ($severity === 'warning' ? 'attention' : 'ready');
$latestDeployments = array_slice(array_values(array_filter(
$deployments,
static fn(array $deployment): bool => (int)($deployment['channel_id'] ?? 0) === $channelId
)), 0, 5);
return [
'channel_id' => $channelId,
'channel_slug' => $channelSlug,
'channel_name' => $channelName,
'default_channel' => (bool)($channel['default_channel'] ?? false),
'enabled' => (bool)($channel['enabled'] ?? true),
'severity' => $severity,
'readiness' => $readiness,
'message' => self::releaseStatusChannelMessage($readiness, count($issues)),
'availability' => $availability,
'missing_values' => $missingValues,
'services' => $services,
'versions' => is_array($channel['versions'] ?? null) ? $channel['versions'] : [],
'replay' => [
'enabled' => (bool)($channel['replay_enabled'] ?? false),
'capture_level' => (string)($channel['capture_level'] ?? 'metadata'),
],
'latest_deployments' => $latestDeployments,
'issues' => $issues,
];
}
private function releaseStatusServicesForChannel(
array $channel,
array $availability,
array $targetsByChannelApp,
array $deploymentsByChannelApp,
array $serviceSetsByChannel
): array {
$channelId = (int)($channel['id'] ?? 0);
$versions = is_array($channel['versions'] ?? null) ? $channel['versions'] : [];
$serviceSet = is_array($versions['service_set'] ?? null)
? $versions['service_set']
: ($serviceSetsByChannel[$channelId][0] ?? null);
$missingLookup = array_fill_keys($availability['missing'] ?? [], true);
$services = [];
foreach (self::APPS as $app) {
$key = $channelId . ':' . $app;
$services[] = $this->releaseStatusAppServiceRow(
$app,
$channel,
is_array($versions[$app] ?? null) ? $versions[$app] : null,
is_array($targetsByChannelApp[$key] ?? null) ? $targetsByChannelApp[$key] : null,
is_array($deploymentsByChannelApp[$key] ?? null) ? $deploymentsByChannelApp[$key] : null,
$missingLookup
);
}
foreach (self::STACK_DATA_KINDS as $kind) {
$services[] = $this->releaseStatusDataServiceRow($kind, $channel, is_array($serviceSet) ? $serviceSet : null);
}
return $services;
}
private function releaseStatusAppServiceRow(
string $app,
array $channel,
?array $version,
?array $target,
?array $deployment,
array $missingLookup
): array {
$serviceLabel = self::releaseStatusServiceLabel($app);
$missingKeys = $app === 'frontend'
? ['frontend_version', 'frontend_base_url']
: ['api_version', 'api_base_url'];
$missingKey = null;
foreach ($missingKeys as $key) {
if (isset($missingLookup[$key])) {
$missingKey = $key;
break;
}
}
$row = [
'service_key' => $app,
'label' => $serviceLabel,
'status' => (string)($deployment['status'] ?? $version['status'] ?? 'ready'),
'state' => 'ready',
'severity' => 'ok',
'message' => $serviceLabel . ' release service is ready.',
'next_action' => '',
'target_tab' => 'overview',
'target_id' => $target['id'] ?? null,
'deployment_id' => $deployment['id'] ?? null,
'version_label' => $version['version_label'] ?? null,
'commit_sha' => $version['commit_sha'] ?? $deployment['commit_sha'] ?? null,
'repository' => $target['repository'] ?? $deployment['repository'] ?? $version['repository'] ?? null,
'branch' => $target['branch'] ?? $deployment['branch'] ?? $version['branch'] ?? null,
'health_url' => $target['health_url'] ?? null,
'issue_type' => null,
];
$deploymentStatus = strtolower((string)($deployment['status'] ?? ''));
if (in_array($deploymentStatus, ['failed', 'error'], true)) {
return array_replace($row, [
'state' => 'failed',
'severity' => 'critical',
'message' => trim((string)(
$deployment['failure_summary']['root_cause']
?? $deployment['error_message']
?? ($serviceLabel . ' deployment failed.')
)),
'next_action' => trim((string)(
$deployment['failure_summary']['next_action']
?? 'Open the deployment details and fix the failing release before promotion.'
)),
'target_tab' => 'deployments',
'issue_type' => 'failed_deployment',
]);
}
if (in_array($deploymentStatus, ['queued', 'running', 'deploying', 'building', 'pending'], true)) {
return array_replace($row, [
'state' => 'deployment_in_progress',
'severity' => 'warning',
'message' => $serviceLabel . ' deployment is still in progress.',
'next_action' => 'Wait for the deployment to finish, then refresh Release Manager.',
'target_tab' => 'deployments',
'issue_type' => 'deployment_in_progress',
]);
}
if ($missingKey !== null) {
return array_replace($row, [
'state' => 'missing_value',
'status' => 'missing',
'severity' => 'critical',
'message' => $serviceLabel . ' is missing ' . self::releaseStatusMissingValueLabel($missingKey) . '.',
'next_action' => self::releaseStatusMissingNextAction($missingKey),
'target_tab' => self::releaseStatusMissingTargetTab($missingKey),
'missing_key' => $missingKey,
]);
}
$isDefaultChannel = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable';
if (!$isDefaultChannel && is_array($target) && trim((string)($target['coolify_service_uuid'] ?? '')) === '') {
return array_replace($row, [
'state' => 'stale_unknown',
'status' => 'missing_coolify_service',
'severity' => 'warning',
'message' => $serviceLabel . ' target is missing its Coolify service UUID.',
'next_action' => 'Open Integrations and connect or create the Coolify service.',
'target_tab' => 'integrations',
'issue_type' => 'stale_unknown',
'missing_key' => $app . '_coolify_service_uuid',
]);
}
return $row;
}
private function releaseStatusDataServiceRow(string $kind, array $channel, ?array $serviceSet): array
{
$serviceLabel = self::releaseStatusServiceLabel($kind);
$isDefaultChannel = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable';
$mode = (string)($serviceSet['mode'] ?? '');
$stack = is_array($serviceSet['stack'] ?? null) ? $serviceSet['stack'] : [];
$dataServices = is_array($serviceSet['data_services'] ?? null) ? $serviceSet['data_services'] : [];
$service = is_array($stack[$kind] ?? null) ? $stack[$kind] : (is_array($dataServices[$kind] ?? null) ? $dataServices[$kind] : null);
$row = [
'service_key' => $kind,
'label' => $serviceLabel,
'status' => 'ready',
'state' => 'ready',
'severity' => 'ok',
'message' => $serviceLabel . ' release service is ready.',
'next_action' => '',
'target_tab' => 'bundles',
'coolify_target_id' => $service['id'] ?? null,
'resource_uuid' => $service['resource_uuid'] ?? null,
'resource_name' => $service['resource_name'] ?? $service['label'] ?? null,
'issue_type' => null,
];
if ($serviceSet === null) {
return $isDefaultChannel ? $row : array_replace($row, [
'status' => 'not_configured',
'state' => 'stale_unknown',
'severity' => 'warning',
'message' => 'No service set is selected for this channel.',
'next_action' => 'Create or assign a release bundle with a service set.',
'issue_type' => 'stale_unknown',
]);
}
if ($service === null) {
$critical = $mode === 'isolated_stack';
return array_replace($row, [
'status' => 'missing',
'state' => 'missing_value',
'severity' => $critical ? 'critical' : 'warning',
'message' => $serviceLabel . ' is not assigned to this service set.',
'next_action' => $critical
? 'Add the missing isolated data service before deploying or promoting this bundle.'
: 'Review the service set and attach the data service if this channel needs isolated data.',
'issue_type' => 'missing_value',
'missing_key' => $kind . '_service',
]);
}
$deploymentStatus = strtolower((string)($service['deployment_status'] ?? ''));
$availabilityState = strtolower((string)($service['availability_state'] ?? ''));
$replication = is_array($service['replication'] ?? null) ? $service['replication'] : [];
$replicationLastStatus = is_array($replication['last_status'] ?? null) ? $replication['last_status'] : [];
$replicationStatus = strtolower((string)($replicationLastStatus['status'] ?? $replication['status'] ?? ''));
$blockers = array_values(array_filter(
is_array($replicationLastStatus['blockers'] ?? null) ? $replicationLastStatus['blockers'] : []
));
$row['status'] = $deploymentStatus ?: ($availabilityState ?: ($replicationStatus ?: 'ready'));
if (
in_array($deploymentStatus, ['failed', 'reconcile_failed', 'restart_failed', 'provision_blocked'], true)
|| in_array($availabilityState, ['degraded', 'failover_blocked', 'destructive_action_required'], true)
) {
return array_replace($row, [
'state' => 'service_unhealthy',
'severity' => 'critical',
'message' => $serviceLabel . ' Coolify target is unhealthy.',
'next_action' => 'Open Bundles or Integrations and inspect the Coolify target before promotion.',
'issue_type' => 'service_unhealthy',
]);
}
if (in_array($deploymentStatus, ['created', 'deploying', 'restarting', 'waiting_for_coolify', 'provisioning'], true)) {
return array_replace($row, [
'state' => 'deployment_in_progress',
'severity' => 'warning',
'message' => $serviceLabel . ' service provisioning is still in progress.',
'next_action' => 'Wait for Coolify provisioning to finish, then refresh Release Manager.',
'issue_type' => 'deployment_in_progress',
]);
}
if ($replicationStatus !== '' && !in_array($replicationStatus, ['ok', 'ready', 'protected', 'healthy'], true)) {
return array_replace($row, [
'state' => 'service_unhealthy',
'severity' => $blockers === [] ? 'warning' : 'critical',
'message' => $blockers[0] ?? ($serviceLabel . ' replication is not healthy.'),
'next_action' => 'Check replication status before promoting this release bundle.',
'issue_type' => 'service_unhealthy',
]);
}
return $row;
}
private function releaseStatusTargetsByChannelApp(array $targets): array
{
$indexed = [];
foreach ($targets as $target) {
if (!is_array($target)) {
continue;
}
$channelId = (int)($target['channel_id'] ?? 0);
$app = (string)($target['app'] ?? '');
if ($channelId > 0 && in_array($app, self::APPS, true)) {
$indexed[$channelId . ':' . $app] = $target;
}
}
return $indexed;
}
private function releaseStatusLatestDeploymentsByChannelApp(array $deployments): array
{
$indexed = [];
foreach ($deployments as $deployment) {
$channelId = (int)($deployment['channel_id'] ?? 0);
$app = (string)($deployment['app'] ?? '');
$key = $channelId . ':' . $app;
if ($channelId > 0 && in_array($app, self::APPS, true) && !isset($indexed[$key])) {
$indexed[$key] = $deployment;
}
}
return $indexed;
}
private function releaseStatusServiceSetsByChannel(array $serviceSets): array
{
$indexed = [];
foreach ($serviceSets as $set) {
if (!is_array($set)) {
continue;
}
$channelId = (int)($set['channel_id'] ?? 0);
if ($channelId > 0) {
$indexed[$channelId][] = $set;
}
}
return $indexed;
}
private function releaseStatusChannelAvailability(array $channel): array
{
if (is_array($channel['availability'] ?? null)) {
$availability = $channel['availability'];
$missing = array_values(array_filter(
is_array($availability['missing'] ?? null) ? $availability['missing'] : [],
static fn(mixed $key): bool => trim((string)$key) !== ''
));
return [
'configured' => ($availability['configured'] ?? null) === null ? $missing === [] : (bool)$availability['configured'],
'missing' => array_values(array_map('strval', $missing)),
'status' => (string)($availability['status'] ?? ($missing === [] ? 'ready' : 'unconfigured')),
'bundle_id' => $availability['bundle_id'] ?? null,
'frontend_base_url' => $availability['frontend_base_url'] ?? $channel['frontend_base_url'] ?? null,
'api_base_url' => $availability['api_base_url'] ?? $channel['api_base_url'] ?? null,
];
}
$isDefault = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable';
if ($isDefault) {
return [
'configured' => true,
'missing' => [],
'status' => 'ready',
'frontend_base_url' => $channel['frontend_base_url'] ?? null,
'api_base_url' => $channel['api_base_url'] ?? null,
];
}
$versions = is_array($channel['versions'] ?? null) ? $channel['versions'] : [];
$missing = [];
if (empty($versions['bundle_id'])) {
$missing[] = 'release_bundle';
}
if (empty($versions['frontend'])) {
$missing[] = 'frontend_version';
} elseif (empty($channel['frontend_base_url'])) {
$missing[] = 'frontend_base_url';
}
if (empty($versions['api'])) {
$missing[] = 'api_version';
} elseif (empty($channel['api_base_url'])) {
$missing[] = 'api_base_url';
}
return [
'configured' => $missing === [],
'missing' => $missing,
'status' => $missing === [] ? 'ready' : 'unconfigured',
'bundle_id' => $versions['bundle_id'] ?? null,
'frontend_base_url' => $channel['frontend_base_url'] ?? null,
'api_base_url' => $channel['api_base_url'] ?? null,
];
}
private static function releaseStatusIssue(array $issue): array
{
return [
'severity' => (string)($issue['severity'] ?? 'warning'),
'type' => (string)($issue['type'] ?? 'stale_unknown'),
'channel_id' => isset($issue['channel_id']) ? (int)$issue['channel_id'] : null,
'channel_slug' => (string)($issue['channel_slug'] ?? ''),
'service_key' => $issue['service_key'] ?? null,
'label' => (string)($issue['label'] ?? ''),
'message' => (string)($issue['message'] ?? ''),
'next_action' => (string)($issue['next_action'] ?? ''),
'target_tab' => (string)($issue['target_tab'] ?? 'overview'),
'target_id' => $issue['target_id'] ?? null,
'deployment_id' => $issue['deployment_id'] ?? null,
'coolify_target_id' => $issue['coolify_target_id'] ?? null,
'missing_key' => $issue['missing_key'] ?? null,
];
}
private static function releaseStatusSeverityRank(string $severity): int
{
return match ($severity) {
'critical' => 2,
'warning' => 1,
default => 0,
};
}
private static function releaseStatusMaxSeverity(string $a, string $b): string
{
return self::releaseStatusSeverityRank($b) > self::releaseStatusSeverityRank($a) ? $b : $a;
}
private static function releaseStatusChannelMessage(string $readiness, int $issueCount): string
{
if ($readiness === 'ready') {
return 'All release services are ready.';
}
if ($readiness === 'blocked') {
return $issueCount . ' blocker' . ($issueCount === 1 ? '' : 's') . ' need attention before promotion.';
}
return $issueCount . ' warning' . ($issueCount === 1 ? '' : 's') . ' should be reviewed.';
}
private static function releaseStatusServiceLabel(string $service): string
{
return match ($service) {
'frontend' => 'Frontend',
'api' => 'API',
'database' => 'Database',
'redis' => 'Redis',
'minio' => 'MinIO',
default => ucfirst(str_replace('_', ' ', $service)),
};
}
private static function releaseStatusMissingValueLabel(string $key): string
{
return match ($key) {
'release_bundle' => 'release bundle',
'frontend_version' => 'frontend version',
'frontend_base_url' => 'frontend URL',
'api_version' => 'API version',
'api_base_url' => 'API URL',
'database_service' => 'database service',
'redis_service' => 'Redis service',
'minio_service' => 'MinIO service',
default => str_replace('_', ' ', $key),
};
}
private static function releaseStatusMissingServiceKey(string $key): ?string
{
return match ($key) {
'frontend_version', 'frontend_base_url', 'frontend_coolify_service_uuid' => 'frontend',
'api_version', 'api_base_url', 'api_coolify_service_uuid' => 'api',
'database_service' => 'database',
'redis_service' => 'redis',
'minio_service' => 'minio',
default => null,
};
}
private static function releaseStatusMissingTargetTab(string $key): string
{
return match ($key) {
'release_bundle', 'database_service', 'redis_service', 'minio_service' => 'bundles',
'frontend_version', 'api_version' => 'deployments',
'frontend_base_url', 'api_base_url', 'frontend_coolify_service_uuid', 'api_coolify_service_uuid' => 'integrations',
default => 'overview',
};
}
private static function releaseStatusMissingNextAction(string $key): string
{
return match ($key) {
'release_bundle' => 'Create or deploy a release bundle, then attach it to the channel.',
'frontend_version', 'api_version' => 'Deploy the missing application version for this channel.',
'frontend_base_url', 'api_base_url' => 'Set the public release URL from the deployment target or channel configuration.',
'database_service', 'redis_service', 'minio_service' => 'Add the missing data service to the isolated service set.',
default => 'Open Release Manager details and complete the missing value.',
};
}
public function releaseConfig(): array
{
$this->ensureSchema();
@@ -2644,6 +3352,7 @@ class release_manager
$serviceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
$resourceType = $this->releaseCoolifyResourceType($context, $serviceUuid);
$created = null;
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
if ($serviceUuid === '' && $this->toBool($context['coolify_auto_create'] ?? false)) {
$githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true);
@@ -2652,6 +3361,9 @@ class release_manager
$created = $client->createPrivateGithubAppApplication($this->releaseCoolifyApplicationPayload($target, $context, $instance));
$resourceType = 'application';
} else {
if (self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)) {
throw new RuntimeException('Path-routed release targets require a Coolify application resource with StripPrefix labels. Set coolify_resource_type=application or migrate this target before deploying.');
}
if ($this->releaseCoolifyServiceSourceIsMissing($context)) {
throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App for this private source repository. Configure one GitHub App in Coolify or set coolify_github_app_uuid on the target; no source-code credentials are required in Release Manager.');
}
@@ -2675,7 +3387,6 @@ class release_manager
}
$update = null;
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
if ($resourceType === 'application') {
$applicationUpdate = $this->releaseCoolifyApplicationUpdatePayload($target, $context);
if ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
@@ -2700,6 +3411,9 @@ class release_manager
$update = $client->updateApplication($serviceUuid, $applicationUpdate);
}
} elseif ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
if (self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)) {
throw new RuntimeException('Path-routed release targets require a Coolify application resource with StripPrefix labels. Set coolify_resource_type=application or migrate this target before deploying.');
}
$update = $client->updateService($serviceUuid, [
'urls' => [
[
@@ -3055,6 +3769,20 @@ class release_manager
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);
@@ -6264,6 +6992,12 @@ class release_manager
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));
+12 -4
View File
@@ -12,12 +12,13 @@ require_once __DIR__ . '/vendor/autoload.php';
require_once 'config.php';
/** CORS */
$corsAllowedHeaders = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *");
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
}
@@ -27,7 +28,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *');
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
header('Content-Type: application/json');
http_response_code(200);
exit;
@@ -178,7 +179,6 @@ use classes\response;
use classes\router;
// Start the session
$router = new router();
$response = new response();
$request = new request();
$db = new db($CONFIG_DB);
@@ -201,10 +201,18 @@ try {
try {
release_manager::initializeRequestContext();
$releaseIngressPath = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '');
release_manager::normalizeReleaseApiIngressPath(
preg_match('#^/[A-Za-z0-9_-]{1,64}/api(?:/|$)#', $releaseIngressPath) === 1
? (new release_manager())->enabledReleaseChannelSlugs()
: []
);
} catch (Throwable $e) {
error_log('[release-manager] Could not initialize request context: ' . $e->getMessage());
error_log('[release-manager] Could not initialize request context or normalize ingress path: ' . $e->getMessage());
}
$router = new router();
try {
$replicationBootstrapSnapshotForRequest = replication_bootstrap_config::loadSnapshot();
$pendingStartupFailovers = is_array($replicationBootstrapSnapshotForRequest['pending_failovers'] ?? null)
@@ -27,7 +27,7 @@ require_once 'twc_spreadsheet_class.php';
// Set CORS headers
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma");
// Set the timezone
date_default_timezone_set('Europe/Copenhagen');
+1 -1
View File
@@ -831,7 +831,7 @@ class authRoute
'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(),
'default_distribution_department_id' => (new economic())->getDefaultDistributionDepartmentId(),
],
'release' => (new release_manager())->runtimeForPayload($payload),
'release' => (new release_manager())->runtimeForPayload($payload, $this->getParametersAsArray()),
]
);
+2 -2
View File
@@ -19,7 +19,7 @@ class optionsRoute
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: *');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *');
header('Content-Type: application/json');
http_response_code(200);
} else {
@@ -28,4 +28,4 @@ class optionsRoute
}
});
}
}
}
@@ -376,6 +376,8 @@ it('builds gateway API auto-provision context for connected Coolify servers', fu
it('adds explicit Coolify application route labels for gateway API domains', function (): void {
$payloadMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteApplicationPayload');
$payloadMethod->setAccessible(true);
$publicUrlMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteTargetPublicUrl');
$publicUrlMethod->setAccessible(true);
$payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [
'custom.keep=true',
@@ -394,6 +396,25 @@ it('adds explicit Coolify application route labels for gateway API domains', fun
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
expect($publicUrlMethod->invoke(null, 'api-v2.truckwash.io', [
'channel_slug' => 'stable',
'channel_default_channel' => 1,
'app' => 'api',
]))->toBe('https://api-v2.truckwash.io');
expect($publicUrlMethod->invoke(null, 'api-v2.truckwash.io', [
'channel_slug' => 'internal',
'channel_default_channel' => 0,
'app' => 'api',
]))->toBe('https://api-v2.truckwash.io/internal/api');
$pathPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/api', 'api-app-uuid', 8080, '');
$pathLabels = explode("\n", base64_decode($pathPayload['custom_labels'], true));
expect($pathPayload['domains'])->toBe('https://api-v2.truckwash.io:8080/internal/api')
->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)')
->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-stripprefix.stripprefix.prefixes=/internal/api')
->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-stripprefix,gzip');
});
it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void {
@@ -5,11 +5,14 @@ it('allows release telemetry headers at PHP-served CORS entry points', function
'X-Release-Trace',
'X-Release-Channel',
'X-Frontend-Version',
'Cache-Control',
'Pragma',
];
$files = [
app_path('index.php'),
app_path('modules/washcertificates/index.php'),
app_path('routes/optionsRoute.php'),
];
foreach ($files as $file) {
@@ -0,0 +1,350 @@
<?php
app_require('classes/release_manager.php');
use classes\release_manager;
function releaseStatusOverviewForTest(array $summary): array
{
$manager = new release_manager();
$method = new ReflectionMethod(release_manager::class, 'releaseStatusOverview');
$method->setAccessible(true);
return $method->invoke($manager, array_replace([
'generated_at' => '2026-05-20T10:00:00+00:00',
'channels' => [],
'deployment_targets' => [],
'service_sets' => [],
'deployments' => [],
], $summary));
}
function releaseStatusChannelBySlug(array $overview, string $slug): array
{
foreach ($overview['channels'] as $channel) {
if (($channel['channel_slug'] ?? '') === $slug) {
return $channel;
}
}
throw new RuntimeException('Release status channel not found: ' . $slug);
}
function releaseStatusServiceByKey(array $channel, string $key): array
{
foreach ($channel['services'] as $service) {
if (($service['service_key'] ?? '') === $key) {
return $service;
}
}
throw new RuntimeException('Release status service not found: ' . $key);
}
function releaseStatusReadyService(string $kind, int $id): array
{
return [
'id' => $id,
'resource_uuid' => $kind . '-resource',
'resource_name' => ucfirst($kind),
'deployment_status' => 'deployed',
'availability_state' => 'failover_ready',
'replication' => [
'status' => 'ok',
'last_status' => [
'status' => 'ok',
'blockers' => [],
],
],
];
}
function releaseStatusReadyVersions(int $bundleId = 7): array
{
return [
'bundle_id' => $bundleId,
'bundle_label' => '#' . $bundleId,
'frontend' => [
'id' => 101,
'version_label' => 'frontend-2026-05-20',
'status' => 'active',
],
'api' => [
'id' => 102,
'version_label' => 'api-2026-05-20',
'status' => 'active',
],
];
}
it('marks a channel ready when all release services are healthy', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 1,
'slug' => 'stable',
'name' => 'Stable',
'default_channel' => true,
'enabled' => true,
'frontend_base_url' => 'https://app.example.test',
'api_base_url' => 'https://api.example.test',
'versions' => releaseStatusReadyVersions(),
],
],
'deployment_targets' => [
['id' => 11, 'channel_id' => 1, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue'],
['id' => 12, 'channel_id' => 1, 'app' => 'api', 'repository' => 'truckwash/backend-php'],
],
'service_sets' => [
[
'id' => 21,
'channel_id' => 1,
'mode' => 'isolated_stack',
'stack' => [
'database' => releaseStatusReadyService('database', 31),
'redis' => releaseStatusReadyService('redis', 32),
'minio' => releaseStatusReadyService('minio', 33),
],
],
],
'deployments' => [
['id' => 41, 'channel_id' => 1, 'app' => 'frontend', 'status' => 'deployed'],
['id' => 42, 'channel_id' => 1, 'app' => 'api', 'status' => 'deployed'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'stable');
expect($overview['state'])->toBe('ready')
->and($overview['totals']['critical'])->toBe(0)
->and($overview['totals']['warning'])->toBe(0)
->and($overview['totals']['services'])->toBe(5)
->and($channel['readiness'])->toBe('ready')
->and($channel['services'])->toHaveCount(5);
});
it('reports non-default channels missing bundles, versions, and URLs as blocking missing values', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 2,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'versions' => [],
'availability' => [
'configured' => false,
'status' => 'unconfigured',
'missing' => [
'release_bundle',
'frontend_version',
'frontend_base_url',
'api_version',
'api_base_url',
],
],
],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
$missingLabels = array_column($channel['missing_values'], 'label');
expect($overview['state'])->toBe('blocked')
->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(5)
->and($channel['readiness'])->toBe('blocked')
->and($missingLabels)->toContain('release bundle')
->and($missingLabels)->toContain('frontend version')
->and($missingLabels)->toContain('frontend URL')
->and($missingLabels)->toContain('API version')
->and($missingLabels)->toContain('API URL')
->and($overview['issues'][0])->toMatchArray([
'severity' => 'critical',
'type' => 'missing_value',
'channel_slug' => 'beta',
]);
});
it('surfaces failed latest deployments as critical promotion blockers', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 3,
'slug' => 'canary',
'name' => 'Canary',
'default_channel' => false,
'enabled' => true,
'frontend_base_url' => 'https://canary.example.test',
'api_base_url' => 'https://api-canary.example.test',
'versions' => releaseStatusReadyVersions(9),
'availability' => [
'configured' => true,
'status' => 'ready',
'missing' => [],
],
],
],
'deployment_targets' => [
['id' => 51, 'channel_id' => 3, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue', 'coolify_service_uuid' => 'frontend-canary'],
['id' => 52, 'channel_id' => 3, 'app' => 'api', 'repository' => 'truckwash/backend-php', 'coolify_service_uuid' => 'api-canary'],
],
'service_sets' => [
[
'id' => 53,
'channel_id' => 3,
'mode' => 'isolated_stack',
'stack' => [
'database' => releaseStatusReadyService('database', 54),
'redis' => releaseStatusReadyService('redis', 55),
'minio' => releaseStatusReadyService('minio', 56),
],
],
],
'deployments' => [
[
'id' => 57,
'channel_id' => 3,
'app' => 'api',
'status' => 'failed',
'failure_summary' => [
'root_cause' => 'Composer install failed.',
'next_action' => 'Open the deployment logs and fix composer dependencies.',
],
],
['id' => 58, 'channel_id' => 3, 'app' => 'frontend', 'status' => 'deployed'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'canary');
$api = releaseStatusServiceByKey($channel, 'api');
expect($overview['state'])->toBe('blocked')
->and($channel['readiness'])->toBe('blocked')
->and($api)->toMatchArray([
'severity' => 'critical',
'state' => 'failed',
'issue_type' => 'failed_deployment',
'deployment_id' => 57,
])
->and($channel['issues'][0])->toMatchArray([
'severity' => 'critical',
'type' => 'failed_deployment',
'deployment_id' => 57,
'next_action' => 'Open the deployment logs and fix composer dependencies.',
]);
});
it('flags isolated stacks that are missing database Redis and MinIO services', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 4,
'slug' => 'internal',
'name' => 'Internal',
'default_channel' => false,
'enabled' => true,
'frontend_base_url' => 'https://internal.example.test',
'api_base_url' => 'https://api-internal.example.test',
'versions' => releaseStatusReadyVersions(11),
'availability' => [
'configured' => true,
'status' => 'ready',
'missing' => [],
],
],
],
'deployment_targets' => [
['id' => 61, 'channel_id' => 4, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-internal'],
['id' => 62, 'channel_id' => 4, 'app' => 'api', 'coolify_service_uuid' => 'api-internal'],
],
'service_sets' => [
[
'id' => 63,
'channel_id' => 4,
'mode' => 'isolated_stack',
'stack' => [
'frontend' => ['id' => 61],
'api' => ['id' => 62],
],
'data_services' => [],
],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'internal');
expect($channel['readiness'])->toBe('blocked')
->and(releaseStatusServiceByKey($channel, 'database'))->toMatchArray([
'severity' => 'critical',
'issue_type' => 'missing_value',
'missing_key' => 'database_service',
])
->and(releaseStatusServiceByKey($channel, 'redis'))->toMatchArray([
'severity' => 'critical',
'issue_type' => 'missing_value',
'missing_key' => 'redis_service',
])
->and(releaseStatusServiceByKey($channel, 'minio'))->toMatchArray([
'severity' => 'critical',
'issue_type' => 'missing_value',
'missing_key' => 'minio_service',
]);
});
it('maps degraded Coolify targets and reconcile failures to release service issues', function (): void {
$database = releaseStatusReadyService('database', 71);
$database['deployment_status'] = 'reconcile_failed';
$database['availability_state'] = 'degraded';
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 5,
'slug' => 'preview',
'name' => 'Preview',
'default_channel' => false,
'enabled' => true,
'frontend_base_url' => 'https://preview.example.test',
'api_base_url' => 'https://api-preview.example.test',
'versions' => releaseStatusReadyVersions(12),
'availability' => [
'configured' => true,
'status' => 'ready',
'missing' => [],
],
],
],
'deployment_targets' => [
['id' => 72, 'channel_id' => 5, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-preview'],
['id' => 73, 'channel_id' => 5, 'app' => 'api', 'coolify_service_uuid' => 'api-preview'],
],
'service_sets' => [
[
'id' => 74,
'channel_id' => 5,
'mode' => 'isolated_stack',
'stack' => [
'database' => $database,
'redis' => releaseStatusReadyService('redis', 75),
'minio' => releaseStatusReadyService('minio', 76),
],
],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'preview');
$databaseRow = releaseStatusServiceByKey($channel, 'database');
expect($databaseRow)->toMatchArray([
'severity' => 'critical',
'state' => 'service_unhealthy',
'issue_type' => 'service_unhealthy',
'coolify_target_id' => 71,
])
->and($channel['issues'][0])->toMatchArray([
'severity' => 'critical',
'type' => 'service_unhealthy',
'service_key' => 'database',
]);
});
@@ -574,7 +574,88 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($status)->toContain('probeReleaseManagerModule');
expect($index)->toContain('release_manager::initializeRequestContext');
expect($index)->toContain('X-Release-Trace');
expect($manager)->not->toContain('X-Release-Channel');
expect($manager)->toContain('X-Release-Channel');
expect($manager)->toContain('normalizeReleaseApiIngressPath');
expect($manager)->toContain('Path-routed release targets require a Coolify application resource with StripPrefix labels');
});
it('captures release request context from headers and runtime query parameters', function (): void {
$server = $_SERVER;
$get = $_GET;
$context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null;
try {
$_SERVER['HTTP_X_RELEASE_TRACE'] = 'trace-abc123';
$_SERVER['HTTP_X_RELEASE_CHANNEL'] = 'Internal';
$_SERVER['HTTP_X_FRONTEND_VERSION'] = 'frontend-130cc2fc106a';
$_SERVER['REQUEST_URI'] = '/release/runtime?release_channel=canary';
$_GET = ['release_channel' => 'canary'];
$initialized = release_manager::initializeRequestContext();
expect($initialized)->toMatchArray([
'trace_id' => 'trace-abc123',
'requested_channel' => 'internal',
'frontend_version' => 'frontend-130cc2fc106a',
'original_request_uri' => '/release/runtime?release_channel=canary',
'ingress_prefix_stripped' => false,
]);
expect($GLOBALS['RELEASE_REQUEST_CONTEXT']['requested_channel'])->toBe('internal');
} finally {
$_SERVER = $server;
$_GET = $get;
if ($context === null) {
unset($GLOBALS['RELEASE_REQUEST_CONTEXT']);
} else {
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
}
}
});
it('normalizes channel-prefixed API ingress paths before route dispatch', function (): void {
$server = $_SERVER;
$get = $_GET;
$context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null;
try {
$_SERVER['REQUEST_URI'] = '/internal/api/auth/session?foo=bar';
$_GET = ['foo' => 'bar'];
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = [
'trace_id' => 'trace-normalize',
'requested_channel' => '',
'frontend_version' => '',
'backend_version' => 'test',
'request_started_at' => date('c'),
'original_request_uri' => '/internal/api/auth/session?foo=bar',
'normalized_request_uri' => '',
'ingress_prefix_stripped' => false,
];
$normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'internal']);
expect($normalized)->toMatchArray([
'channel_slug' => 'internal',
'original_request_uri' => '/internal/api/auth/session?foo=bar',
'normalized_request_uri' => '/auth/session?foo=bar',
'normalized_path' => '/auth/session',
]);
expect($_SERVER['REQUEST_URI'])->toBe('/auth/session?foo=bar');
expect($_SERVER['PATH_INFO'])->toBe('/auth/session');
expect($_GET['release_channel'])->toBe('internal');
expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([
'requested_channel' => 'internal',
'normalized_request_uri' => '/auth/session?foo=bar',
'ingress_prefix_stripped' => true,
]);
} finally {
$_SERVER = $server;
$_GET = $get;
if ($context === null) {
unset($GLOBALS['RELEASE_REQUEST_CONTEXT']);
} else {
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
}
}
});
it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void {
@@ -587,6 +668,8 @@ it('requires non-default release channel runtime URLs and preserves load balance
$publicUrl->setAccessible(true);
$targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl');
$targetPublicBaseUrl->setAccessible(true);
$applicationLabels = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationLabels');
$applicationLabels->setAccessible(true);
expect($availability->invoke($manager, [
'id' => 1,
@@ -669,6 +752,17 @@ it('requires non-default release channel runtime URLs and preserves load balance
]),
]))->toBe('https://api-v2.truckwash.io/internal/frontend');
$labels = $applicationLabels->invoke(
null,
'https://api-v2.truckwash.io/internal/api',
'release-api-internal',
80,
'letsencrypt'
);
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)');
expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api');
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-stripprefix,gzip');
$source = file(app_path('classes/release_manager.php'));
$methodSource = implode('', array_slice(
$source,