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:
@@ -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);
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user