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('does not block channels on unhealthy data targets when data services are production shared', function (): void { $database = releaseStatusReadyService('database', 71); $database['deployment_status'] = 'reconcile_failed'; $database['availability_state'] = 'degraded'; $overview = releaseStatusOverviewForTest([ 'channels' => [ [ 'id' => 2, 'slug' => 'beta', 'name' => 'Beta', 'default_channel' => false, 'enabled' => true, 'frontend_base_url' => 'https://beta.example.test', 'api_base_url' => 'https://api-beta.example.test', 'versions' => releaseStatusReadyVersions(8), ], ], 'deployment_targets' => [ ['id' => 51, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'], ['id' => 52, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'], ], 'service_sets' => [ [ 'id' => 53, 'channel_id' => 2, 'mode' => 'attach_existing', 'stack' => [ 'database' => $database, 'redis' => releaseStatusReadyService('redis', 75), 'minio' => releaseStatusReadyService('minio', 76), ], ], ], 'deployments' => [ ['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'], ['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'], ], ]); $channel = releaseStatusChannelBySlug($overview, 'beta'); expect($channel['readiness'])->toBe('ready') ->and($channel['issues'])->toBe([]) ->and(releaseStatusServiceByKey($channel, 'database'))->toMatchArray([ 'status' => 'production_shared', 'state' => 'ready', 'severity' => 'ok', 'issue_type' => null, ]); }); it('ignores missing legacy bundles but still blocks on missing versions and URLs', 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'); $missingKeys = array_column($channel['missing_values'], 'key'); expect($overview['state'])->toBe('blocked') ->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(4) ->and($channel['readiness'])->toBe('blocked') ->and($missingKeys)->not->toContain('release_bundle') ->and($missingLabels)->not->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('marks a branch-based channel ready without a release bundle when app versions and URLs exist', function (): void { $versions = releaseStatusReadyVersions(0); unset($versions['bundle_id'], $versions['bundle_label']); $overview = releaseStatusOverviewForTest([ 'channels' => [ [ 'id' => 2, 'slug' => 'beta', 'name' => 'Beta', 'default_channel' => false, 'enabled' => true, 'frontend_base_url' => 'https://api-v2.truckwash.io/beta/frontend', 'api_base_url' => 'https://api-v2.truckwash.io/beta/api', 'versions' => $versions, ], ], 'deployment_targets' => [ ['id' => 21, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'], ['id' => 22, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'], ], 'deployments' => [ ['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'], ['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'], ], ]); $channel = releaseStatusChannelBySlug($overview, 'beta'); expect($overview['state'])->toBe('ready') ->and($channel['readiness'])->toBe('ready') ->and($channel['availability'])->toMatchArray([ 'configured' => true, 'missing' => [], 'status' => 'ready', ]) ->and($channel['issues'])->toBe([]) ->and($channel['missing_values'])->toBe([]); }); it('does not report frontend or API URLs missing when target auto endpoints are resolvable', function (): void { $overview = releaseStatusOverviewForTest([ 'channels' => [ [ 'id' => 2, 'slug' => 'beta', 'name' => 'Beta', 'default_channel' => false, 'enabled' => true, 'versions' => releaseStatusReadyVersions(), ], ], 'deployment_targets' => [ [ 'id' => 21, 'channel_id' => 2, 'channel_slug' => 'beta', 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue', 'branch' => 'master', 'coolify_service_uuid' => 'frontend-beta-service', 'deploy_context' => [ 'endpoint_mode' => 'auto', 'public_gateway_host' => 'gateway.example.test', ], ], [ 'id' => 22, 'channel_id' => 2, 'channel_slug' => 'beta', 'app' => 'api', 'repository' => 'truckwash/backend-php', 'branch' => 'master', 'coolify_service_uuid' => 'api-beta-service', 'deploy_context' => [ 'endpoint_mode' => 'auto', 'public_gateway_host' => 'gateway.example.test', ], ], ], 'service_sets' => [ [ 'id' => 31, 'channel_id' => 2, 'mode' => 'isolated_stack', 'stack' => [ 'database' => releaseStatusReadyService('database', 41), 'redis' => releaseStatusReadyService('redis', 42), 'minio' => releaseStatusReadyService('minio', 43), ], ], ], ]); $channel = releaseStatusChannelBySlug($overview, 'beta'); $missingKeys = array_column($channel['missing_values'], 'key'); expect($missingKeys)->not->toContain('frontend_base_url') ->and($missingKeys)->not->toContain('api_base_url') ->and($channel['availability'])->toMatchArray([ 'configured' => true, 'frontend_base_url' => 'https://gateway.example.test/beta/frontend', 'api_base_url' => 'https://gateway.example.test/beta/api', ]) ->and($channel['readiness'])->toBe('ready'); }); it('does not surface legacy release bundle actions as readiness blockers', function (): void { $overview = releaseStatusOverviewForTest([ 'channels' => [ [ 'id' => 20, 'slug' => 'beta', 'name' => 'Beta', 'default_channel' => false, 'enabled' => true, 'frontend_base_url' => 'https://beta.example.test', 'api_base_url' => 'https://api-beta.example.test', 'versions' => [], 'availability' => [ 'configured' => false, 'status' => 'unconfigured', 'missing' => ['release_bundle'], ], ], ], 'bundles' => [ ['id' => 91, 'channel_id' => 20, 'status' => 'deployed', 'version_label' => 'beta-bundle'], ['id' => 92, 'channel_id' => 20, 'status' => 'failed', 'version_label' => 'failed-bundle'], ], ]); $channel = releaseStatusChannelBySlug($overview, 'beta'); expect($channel['readiness'])->toBe('ready') ->and($channel['availability'])->toMatchArray([ 'configured' => true, 'missing' => [], 'status' => 'ready', ]) ->and($channel['issues'])->toBe([]) ->and($channel['missing_values'])->toBe([]); }); 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.', ]) ->and($channel['issues'][0]['key'])->toBe('failed_deployment:3:api::57:') ->and($channel['issues'][0]['impact'])->toContain('cannot be promoted') ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('retry_deployment'); }); it('offers application target preparation for path routed Coolify failures', function (): void { $overview = releaseStatusOverviewForTest([ 'channels' => [ [ 'id' => 30, '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(19), 'availability' => [ 'configured' => true, 'status' => 'ready', 'missing' => [], ], ], ], 'deployment_targets' => [ ['id' => 81, 'channel_id' => 30, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue', 'coolify_service_uuid' => 'frontend-internal'], ['id' => 82, 'channel_id' => 30, 'app' => 'api', 'repository' => 'truckwash/backend-php', 'coolify_service_uuid' => 'legacy-api-service'], ], 'service_sets' => [ [ 'id' => 83, 'channel_id' => 30, 'mode' => 'isolated_stack', 'stack' => [ 'database' => releaseStatusReadyService('database', 84), 'redis' => releaseStatusReadyService('redis', 85), 'minio' => releaseStatusReadyService('minio', 86), ], ], ], 'deployments' => [ [ 'id' => 87, 'channel_id' => 30, 'app' => 'api', 'status' => 'failed', 'failure_summary' => [ 'root_cause' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.', 'next_action' => 'Set coolify_resource_type=application or migrate this target before deploying.', ], ], ['id' => 88, 'channel_id' => 30, 'app' => 'frontend', 'status' => 'deployed'], ], ]); $channel = releaseStatusChannelBySlug($overview, 'internal'); $actions = $channel['issues'][0]['actions']; $actionIds = array_column($actions, 'id'); $prepareAction = $actions[array_search('prepare_application_target', $actionIds, true)]; expect($actionIds)->toContain('retry_deployment') ->and($actionIds)->toContain('prepare_application_target') ->and($prepareAction)->toMatchArray([ 'requires_confirmation' => true, 'requires_input' => false, 'disabled_reason' => '', ]); }); 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', ]) ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('complete_data_services'); }); 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', ]) ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('reconcile_coolify_target') ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('redeploy_coolify_target') ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('restart_coolify_target'); });