Files
api/services/nginx/app/tests/Unit/Release/ReleaseManagerStatusOverviewTest.php
T
Jeppe Bundgaard d52ceb8513 Add robust release update and API health checks
This commit introduces a release update mechanism, including candidate detection, asset pre-downloading, and installation workflows with proper state management. Additionally, it implements API health checks both for successful and failure scenarios and adds related unit and e2e tests for enhanced reliability.
2026-05-27 13:24:15 +02:00

681 lines
26 KiB
PHP

<?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('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('marks beta ready from the production frontend and API services', 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(),
],
[
'id' => 2,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'versions' => [],
'availability' => [
'configured' => false,
'status' => 'unconfigured',
'missing' => ['frontend_version', 'frontend_base_url', 'api_version', 'api_base_url'],
],
],
],
'deployment_targets' => [
['id' => 11, 'channel_id' => 1, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-prod'],
['id' => 12, 'channel_id' => 1, 'app' => 'api', 'coolify_service_uuid' => 'api-prod'],
],
'deployments' => [
['id' => 41, 'channel_id' => 1, 'app' => 'frontend', 'status' => 'deployed'],
['id' => 42, 'channel_id' => 1, 'app' => 'api', 'status' => 'deployed'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
expect($channel['readiness'])->toBe('ready')
->and($channel['service_policy'])->toBe('production_shared')
->and($channel['service_channel_slug'])->toBe('stable')
->and($channel['missing_values'])->toBe([])
->and($channel['availability'])->toMatchArray([
'configured' => true,
'missing' => [],
'frontend_base_url' => 'https://app.example.test',
'api_base_url' => 'https://api.example.test',
])
->and(releaseStatusServiceByKey($channel, 'frontend'))->toMatchArray([
'status' => 'production_shared',
'service_policy' => 'production_shared',
'service_channel_slug' => 'stable',
])
->and(releaseStatusServiceByKey($channel, 'api'))->toMatchArray([
'status' => 'production_shared',
'service_policy' => 'production_shared',
'service_channel_slug' => 'stable',
]);
});
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');
});