Files
api/services/nginx/app/classes/release_manager.php
T
Jeppe Bandopenhands a71194d46e refactor(api): centralise truthy-string -> bool coercion in a shared trait (#368)
## What

Centralises the truthy-string → bool coercion that six different classes
were reimplementing (and which two implementations disagreed about).

The shared helper lives in
`services/nginx/app/traits/boolean_normalization_t.php`:

```php
namespace traits;
trait boolean_normalization_t {
    public static function normalizeBoolean(mixed $value): bool {
        if (is_bool($value)) return $value;
        return in_array(strtolower(trim((string)$value)), ['1','true','yes','on'], true);
    }
}
```

`traits/module_config_variable_t::inputToBool` now delegates to it. The
seven call sites that previously inlined the same expression (or wrapped
it in a private `toBool`/`boolValue`/`isEnabled`) are reduced to a
single `self::normalizeBoolean(...)` call:

| Class | Old helper | New |
| --- | --- | --- |
| `classes/cron_worker.php` | inline in `boolOption` |
`self::normalizeBoolean(...)` (after empty-value short-circuit) |
| `classes/replica_failover_manager.php` | `boolValue` |
`self::normalizeBoolean(...)` |
| `classes/release_manager.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/releasemanager.php` | `isEnabled` |
`self::normalizeBoolean(...)` |
| `classes/superuser_system_status_service.php` | inline in
`parseModuleConfigValue` | `self::normalizeBoolean(...)` |
| `classes/module_usage_service.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/account_deletion_service.php` | inline in `apiEnabled` |
`self::normalizeBoolean(...)` |
| `traits/module_config_variable_t.php` | `inputToBool` (narrow set) |
`self::normalizeBoolean(...)` (full set) |

## Why

The pre-PR repo had two silent bugs:

1. **`inputToBool` accepted only `'true'`/`'1'`** while the six inline
copies accepted the wider `['1','true','yes','on']` set. Config values
such as `"yes"` or `" ON "` would round-trip to `false` through
`inputToBool` but `true` through any of the inline copies. This PR picks
the wider set as the single source of truth; the change is a strict
superset, so no caller flips from truthy to falsy.
2. **Six copies of the same expression** to drift in any of the seven
places (whitespace handling, case sensitivity, empty-string semantics).
One trait replaces them.

## Tests

* `tests/Unit/Traits/BooleanNormalizationTest.php` — Pest, runs the
helper directly through anonymous-class composition (no DB/HTTP).
* `tests/Smoke/boolean_normalization_smoke.php` — standalone PHP smoke
runner for environments without composer installed. Verified locally:
7/7 consumer wiring checks pass, 19/19 normalization cases pass (`true`,
`false`, `1`, `0`, `'true'`, `'TRUE'`, `'1'`, `'yes'`, `'YES'`, `'on'`,
`' ON '`, `'false'`, `'no'`, `'off'`, `''`, `null`, `'0'`, `[]`,
stdClass).
* All eight touched files pass `php -l` syntax check.

## Risk

* Behavioural change is a strict superset for the shared expression
path, so no caller can flip from truthy → falsy. The only consumer that
saw a behaviour change for *negative* inputs is `inputToBool` itself,
which previously rejected `'yes'`/`'on'`. Worth a CI pass on the
unit/integration suites before merge.

## Co-author

Co-authored-by: openhands <openhands@all-hands.dev>

---

_This PR was generated by an AI agent (OpenHands) on behalf of
copenhagentruckwash._

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 20:20:03 +02:00

12907 lines
538 KiB
PHP

<?php
namespace classes;
use customers\economicCustomers;
use RuntimeException;
use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/cors_policy.php';
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class release_manager
{
use boolean_normalization_t;
private const APPS = ['frontend', 'api'];
private const DEFAULT_BRANCH = 'master';
private const RELEASE_ROUTE_SLUGS = [
'stable' => 'master',
'master' => 'master',
'beta' => 'beta',
'canary' => 'canary',
'internal' => 'internal',
];
private const RELEASE_ROUTE_CHANNELS = [
'master' => 'stable',
'beta' => 'beta',
'canary' => 'canary',
'internal' => 'internal',
];
private const SERVICE_SET_MODES = ['attach_existing', 'clone_existing', 'fresh_empty', 'isolated_stack'];
private const STACK_DATA_KINDS = ['database', 'redis', 'minio'];
private const PRODUCTION_DATA_POLICY = 'production_shared';
private const BETA_PRODUCTION_DATA_SOURCE_CHANNELS = ['stable', 'master', 'production', 'prod'];
private const PRODUCTION_SERVICE_POLICY = 'production_shared';
private const PRODUCTION_SERVICE_CHANNELS = ['beta'];
private const RELEASE_STATUS_SERVICES = ['frontend', 'api', 'database', 'redis', 'minio'];
private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod'];
private const DEFAULT_COOLIFY_APPLICATION_PORT = '80';
private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
private const CRON_WORKER_APP = 'cron';
private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker';
private const CRON_WORKER_DESIRED_COUNT = 1;
private const CRON_WORKER_HEARTBEAT_GRACE_SECONDS = 180;
private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io'];
private const RELEASE_GATE_MAX_PATHS = 10;
private const RELEASE_GATE_MAX_ASSETS = 50;
private const RELEASE_API_RUNTIME_ENV_KEYS = [
'USE_ENV',
'DEBUG',
'ENCRYPTION_KEY',
'CORS',
'CONFIG_TIMEZONE',
'CONFIG_DB_TARGET',
'CONFIG_DB_HOST',
'CONFIG_DB_USER',
'CONFIG_DB_PASSWORD',
'CONFIG_DB_DATABASE',
'CONFIG_DB_PORT',
'CONFIG_DB_SSL_MODE',
'CONFIG_DB_DEBUG_HOST',
'CONFIG_DB_DEBUG_USER',
'CONFIG_DB_DEBUG_PASSWORD',
'CONFIG_DB_DEBUG_DATABASE',
'CONFIG_DB_DEBUG_PORT',
'CONFIG_DB_DEBUG_SSL_MODE',
'REDIS_CONFIG_HOST',
'REDIS_CONFIG_USER',
'REDIS_CONFIG_PASSWORD',
'REDIS_CONFIG_DATABASE',
'REDIS_CONFIG_PORT',
'REDIS_CONFIG_DEBUG_HOST',
'REDIS_CONFIG_DEBUG_USER',
'REDIS_CONFIG_DEBUG_PASSWORD',
'REDIS_CONFIG_DEBUG_DATABASE',
'REDIS_CONFIG_DEBUG_PORT',
'ECONOMIC_API_APP_ACCESS_GRANT',
'ECONOMIC_API_APP_ACCESS_GRANT2',
'ECONOMIC_API_APP_SECRET_TOKEN',
'WORDPRESS_STATIC_TOKEN',
'EMAIL_WASH_CERTIFICATE_TOKEN',
'WORDPRESS_API_URL',
'MINIO_ENDPOINT',
'MINIO_ACCESS_KEY',
'MINIO_SECRET_KEY',
'SLACK_DEFAULT_WEBHOOK',
'API_COMMIT_SHA',
'COMMIT_SHA',
'GITHUB_SHA',
'RELEASE_COMMIT_SHA',
];
private const RELEASE_API_RUNTIME_ENV_PREFIXES = [
'EDGE_',
'RELEASE_MANAGER_',
'COOLIFY_',
'HETZNER_',
'OPENAI_',
'STRIPE_',
'FXRATES_',
'WEATHER_',
'MOTOR_',
'BIRD_',
'OCR_',
'LICENSE_',
'VIRK_',
'LIMBLE_',
'ENTRA_',
'REQUEST_QUEUE_',
'WORKFEED_',
];
private const SUBJECT_TYPES = ['user', 'subuser', 'customer'];
private const CAPTURE_LEVELS = ['metadata', 'full_redacted', 'full'];
private const MODULE_KEYS = [
'economic',
'reCAPTCHA',
'email',
'backups',
'motorapi',
'stripe',
'fxratesapi',
'weatherapi',
'workfeed',
'gatewayapi',
'xlvask',
'entra',
'limble',
'ocrspace',
'openai',
'licenseplaterecognizer',
'virkdata',
'shelly',
'coolify',
'failover',
'edgegateway',
'selfserve',
'bird',
'auth',
'worker',
'requestqueue',
'moduleactionlogs',
'releasemanager',
];
private bool $schemaEnsured = false;
private array $inProcessPassedReleaseGates = [];
/** @var callable|null */
private $coolifyClientFactory;
public function __construct(?callable $coolifyClientFactory = null)
{
$this->coolifyClientFactory = $coolifyClientFactory;
}
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' => $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 [];
}
$routeSlug = self::safeSlug((string)$matches[1]);
$channelSlug = self::channelSlugForRoute($routeSlug);
$enabled = array_flip(array_values(array_filter(array_map(
static fn(mixed $value): string => self::safeSlug((string)$value),
$enabledChannelSlugs
))));
if ($routeSlug === '' || $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['release_route_slug'] = $routeSlug;
$context['ingress_prefix_stripped'] = true;
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
return [
'channel_slug' => $channelSlug,
'route_slug' => $routeSlug,
'original_request_uri' => $requestUri,
'normalized_request_uri' => $normalizedUri,
'normalized_path' => $normalizedPath,
];
}
public static function routeSlugForChannel(string $channelSlug): string
{
$slug = self::safeSlug($channelSlug);
if ($slug === '') {
return '';
}
return self::RELEASE_ROUTE_SLUGS[$slug] ?? $slug;
}
public static function channelSlugForRoute(string $routeSlug): string
{
$slug = self::safeSlug($routeSlug);
if ($slug === '') {
return '';
}
return self::RELEASE_ROUTE_CHANNELS[$slug] ?? $slug;
}
public static function moduleKeys(): array
{
return self::MODULE_KEYS;
}
public static function backendVersion(): string
{
foreach (['RELEASE_VERSION', 'GITHUB_SHA', 'COMMIT_SHA', 'VITE_COMMIT_HASH'] as $key) {
$value = self::runtimeEnvValue($key);
if ($value !== '') {
return self::safeIdentifier($value, 128);
}
}
return 'unknown';
}
public static function backendCommitSha(): string
{
foreach (['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'] as $key) {
$sha = self::normalizeCommitSha(self::runtimeEnvValue($key));
if ($sha !== '') {
return $sha;
}
}
$sha = self::localGitCommitSha();
return $sha !== '' ? $sha : 'unknown';
}
private static function runtimeEnvValue(string $key): string
{
$value = getenv($key);
if (($value === false || trim((string)$value) === '') && array_key_exists($key, $_ENV ?? [])) {
$value = $_ENV[$key];
}
if (($value === false || trim((string)$value) === '') && array_key_exists($key, $_SERVER ?? [])) {
$value = $_SERVER[$key];
}
return is_scalar($value) ? trim((string)$value) : '';
}
private static function normalizeCommitSha(string $value): string
{
$value = strtolower(trim($value));
return preg_match('/^[a-f0-9]{7,40}$/', $value) === 1 ? $value : '';
}
private static function localGitCommitSha(): string
{
$base = defined('WD') ? (string)WD : dirname(__DIR__);
$candidates = [];
$current = $base;
for ($i = 0; $i < 6; $i++) {
if ($current === '' || isset($candidates[$current])) {
break;
}
$candidates[$current] = true;
$parent = dirname($current);
if ($parent === $current) {
break;
}
$current = $parent;
}
foreach (array_keys($candidates) as $directory) {
$gitPath = $directory . DIRECTORY_SEPARATOR . '.git';
if (!is_dir($directory) || (!is_dir($gitPath) && !is_file($gitPath))) {
continue;
}
$output = [];
$exitCode = 1;
@exec('git -C ' . escapeshellarg($directory) . ' rev-parse HEAD 2>&1', $output, $exitCode);
if ($exitCode !== 0 || !isset($output[0])) {
continue;
}
$sha = self::normalizeCommitSha((string)$output[0]);
if ($sha !== '') {
return $sha;
}
}
return '';
}
public static function verifyGithubSignature(string $secret, string $payload, string $signatureHeader): bool
{
$secret = trim($secret);
$signatureHeader = trim($signatureHeader);
if ($secret === '' || $signatureHeader === '' || !str_starts_with($signatureHeader, 'sha256=')) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signatureHeader);
}
public function verifyReleaseGateToken(string $token): bool
{
$token = trim($token);
if ($token === '') {
return false;
}
$expected = trim((string)(getenv('RELEASE_MANAGER_GATE_TOKEN') ?: ($_SERVER['RELEASE_MANAGER_GATE_TOKEN'] ?? '')));
if ($expected === '') {
$expected = trim((string)$this->moduleConfigValue('ReleaseManager', 'release_gate_token', ''));
}
if ($expected !== '' && str_starts_with($expected, 'twsec:v1:') && class_exists(replication_secret_box::class)) {
try {
$expected = replication_secret_box::decrypt($expected);
} catch (Throwable) {
$expected = '';
}
}
return $expected !== '' && hash_equals($expected, $token);
}
public static function normalizeFrontendVersionGateInput(array $payload): array
{
$version = strtolower(trim((string)($payload['version'] ?? '')));
if (preg_match('/^[a-f0-9]{40}$/', $version) !== 1) {
throw new RuntimeException('Frontend release version must be a full commit SHA.');
}
$repository = self::normalizeGithubRepositoryName((string)($payload['repository'] ?? ''));
$expectedRepository = self::normalizeGithubRepositoryName(
self::runtimeEnvValue('RELEASE_MANAGER_FRONTEND_REPOSITORY') ?: 'copenhagentruckwash/pleno-vue'
);
if ($repository === '' || strtolower($repository) !== strtolower($expectedRepository)) {
throw new RuntimeException('Frontend release repository is not authorized.');
}
$branch = strtolower(trim((string)($payload['branch'] ?? '')));
if ($branch !== self::DEFAULT_BRANCH) {
throw new RuntimeException('Frontend release branch must be master.');
}
$buildId = trim((string)($payload['build_id'] ?? ''));
if (preg_match('/^[1-9][0-9]*-[1-9][0-9]*$/', $buildId) !== 1) {
throw new RuntimeException('Frontend release build id is invalid.');
}
return [
'version' => $version,
'repository' => $repository,
'branch' => $branch,
'build_id' => $buildId,
];
}
public static function normalizeGithubRepositoryName(string $value): string
{
$repository = trim($value);
if ($repository === '') {
return '';
}
if (preg_match('#^git@github\.com:(.+)$#i', $repository, $matches) === 1) {
$repository = $matches[1];
} elseif (preg_match('#^https?://#i', $repository) === 1) {
$path = parse_url($repository, PHP_URL_PATH);
$repository = is_string($path) ? ltrim($path, '/') : $repository;
} else {
$repository = preg_replace('#^github\.com/#i', '', $repository) ?? $repository;
}
$repository = preg_replace('#\.git$#i', '', $repository) ?? $repository;
$repository = trim($repository, "/ \t\n\r\0\x0B");
return preg_match('/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/', $repository) === 1 ? $repository : '';
}
public static function redactPayload(mixed $value, int $depth = 0): mixed
{
if ($depth > 8) {
return '[depth-limit]';
}
if (is_array($value)) {
$redacted = [];
$index = 0;
foreach ($value as $key => $item) {
$index++;
if ($index > 80) {
$redacted['[truncated]'] = 'More than 80 keys omitted.';
break;
}
$keyString = (string)$key;
if (self::isSensitiveKey($keyString)) {
$redacted[$key] = '[redacted]';
continue;
}
$redacted[$key] = self::redactPayload($item, $depth + 1);
}
return $redacted;
}
if (is_object($value)) {
return self::redactPayload((array)$value, $depth + 1);
}
if (is_string($value)) {
if (strlen($value) > 4000) {
return substr($value, 0, 4000) . "\n... [truncated]";
}
return $value;
}
return $value;
}
public static function deploymentCanBePromoted(string $status): bool
{
return in_array(strtolower(trim($status)), ['deployed'], true);
}
public static function deploymentPromotionBlockedReason(array $deployment): string
{
$status = strtolower(trim((string)($deployment['status'] ?? 'unknown'))) ?: 'unknown';
$result = self::jsonDecode($deployment['result_json'] ?? null);
if ($result === [] && is_array($deployment['result'] ?? null)) {
$result = $deployment['result'];
}
$failure = is_array($result['failure_summary'] ?? null) ? $result['failure_summary'] : [];
$rootCause = trim((string)($failure['root_cause'] ?? $deployment['error_message'] ?? ''));
if ($status === 'active') {
return 'Deployment is already active.';
}
if ($status === 'failed') {
return 'Deployment failed and cannot be promoted.' . ($rootCause !== '' ? ' Cause: ' . $rootCause : '');
}
return 'Only successfully deployed release deployments can be promoted. Current status: ' . $status . '.';
}
public static function deploymentFailureSummary(Throwable $throwable, array $context = []): array
{
$message = trim($throwable->getMessage()) ?: 'Deployment failed without an error message.';
$normalized = strtolower($message);
$category = 'unknown';
$stage = trim((string)($context['stage'] ?? 'deployment')) ?: 'deployment';
$nextAction = 'Open the Coolify deployment logs for the service and compare the failing commit with the last successful deployment.';
if (str_contains($normalized, 'github repository access') || str_contains($normalized, 'github api')) {
$category = 'github_access';
$stage = 'source_access';
$nextAction = 'Verify the Release Manager GitHub token, repository, branch, and selected commit before deploying again.';
} elseif (
str_contains($normalized, 'coolify instance')
|| str_contains($normalized, 'base url')
|| str_contains($normalized, 'api token')
) {
$category = 'coolify_connection';
$stage = 'provider_connection';
$nextAction = 'Test the configured Coolify instance and API token from Release Manager settings.';
} elseif (
str_contains($normalized, 'service uuid')
|| str_contains($normalized, 'select an existing coolify service')
|| str_contains($normalized, 'http 404')
) {
$category = 'coolify_target';
$stage = 'provider_target';
$nextAction = 'Check that the saved deployment target points at the correct Coolify service UUID and instance.';
} elseif (
str_contains($normalized, 'docker_compose')
|| str_contains($normalized, 'explicit image')
|| str_contains($normalized, 'image/repository')
|| str_contains($normalized, 'pull access denied')
|| str_contains($normalized, 'manifest')
|| str_contains($normalized, 'denied')
|| str_contains($normalized, 'validation')
) {
$category = 'configuration';
$stage = 'provider_configuration';
$nextAction = 'Review the deployment target image, registry access, compose payload, and required environment variables.';
} elseif (str_contains($normalized, 'health') || str_contains($normalized, 'smoke')) {
$category = 'smoke_test';
$stage = 'post_deploy_smoke_test';
$nextAction = 'Check container startup logs and the configured health URL before promoting the deployment.';
} elseif (
str_contains($normalized, 'timeout')
|| str_contains($normalized, 'timed out')
|| str_contains($normalized, 'could not connect')
|| str_contains($normalized, 'network')
) {
$category = 'network';
$stage = 'provider_connection';
$nextAction = 'Check network access from the API container to GitHub and Coolify, then retry the deployment.';
}
$evidence = array_filter([
'message' => $message,
'app' => $context['app'] ?? null,
'repository' => $context['repository'] ?? null,
'branch' => $context['branch'] ?? null,
'commit_sha' => $context['commit_sha'] ?? null,
'target_id' => $context['target_id'] ?? null,
'coolify_instance_id' => $context['coolify_instance_id'] ?? null,
'coolify_service_uuid' => $context['coolify_service_uuid'] ?? null,
], static fn(mixed $value): bool => $value !== null && $value !== '');
return [
'category' => $category,
'stage' => $stage,
'root_cause' => $message,
'next_action' => $nextAction,
'promotion_blocked' => true,
'captured_at' => date('c'),
'evidence' => self::redactPayload($evidence),
];
}
public static function recordBackendFailure(bool $success, mixed $data, ?int $status): void
{
if ($success || ($status !== null && $status < 400)) {
return;
}
$uri = (string)($_SERVER['REQUEST_URI'] ?? '');
if (str_starts_with($uri, '/release/timeline/events')) {
return;
}
try {
if (!isset($GLOBALS['db']) || !release_manager_schema_bootstrap::tablesExist()) {
return;
}
$context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null)
? $GLOBALS['RELEASE_REQUEST_CONTEXT']
: self::initializeRequestContext();
$manager = new self();
$principalContext = $manager->currentPrincipalContext();
$channel = $manager->resolveChannel($principalContext);
$manager->ingestTimelineEvents([
[
'type' => 'backend_response_failed',
'severity' => ($status ?? 500) >= 500 ? 'error' : 'warning',
'module_key' => $manager->inferModuleKeyFromUri($uri),
'route' => explode('?', $uri)[0] ?: '/',
'occurred_at' => date('c'),
'payload' => [
'status' => $status,
'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
'response' => $data,
],
],
], [
'trace_id' => $context['trace_id'] ?? '',
'channel_slug' => $channel['slug'] ?? '',
'principal_type' => $principalContext['principal_type'] ?? null,
'principal_id' => $principalContext['principal_id'] ?? null,
'customer_number' => $principalContext['customer_number'] ?? null,
], false);
} catch (Throwable) {
// Release telemetry must never block API responses.
}
}
public function bootstrap(): array
{
$this->ensureSchema();
$channel = $this->defaultChannel();
$versions = $this->currentVersionsForChannel((int)$channel['id']);
$urls = $this->releaseRuntimeUrls($channel, $versions);
return [
'source' => 'deployment',
'generated_at' => date('c'),
'trace_id' => $this->requestTraceId(),
'channel' => $this->publicRuntimeChannel($channel),
'versions' => $versions,
'frontend_base_url' => $urls['frontend_base_url'],
'api_base_url' => $urls['api_base_url'],
'urls' => $urls,
'availability' => $this->channelAvailability($channel),
'capture_policy' => [
'enabled' => false,
'capture_level' => 'metadata',
'all_failure_metadata' => true,
'retention_days' => (int)($channel['retention_days'] ?? 14),
],
'available_channels' => $this->publicRuntimeChannelOptions([$channel]),
'selected_channel_slug' => (string)($channel['slug'] ?? ''),
];
}
public function runtimeForPayload(array $payload, array $input = []): array
{
$this->ensureSchema();
$context = [
'principal_type' => 'user',
'principal_id' => isset($payload['id']) ? (string)$payload['id'] : null,
'customer_number' => isset($payload['customer_number']) ? (int)$payload['customer_number'] : null,
];
$resolvedChannel = $this->resolveChannel($context);
$availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel);
$channel = $this->chooseRuntimeChannel(
$resolvedChannel,
$availableChannels,
$this->requestedRuntimeChannelSlug($input)
);
$serviceChannel = $this->runtimeServiceChannelFor($channel);
$versions = $this->currentVersionsForChannel((int)$serviceChannel['id']);
$capturePolicy = $this->capturePolicyFor($context, $channel);
$urls = $this->releaseRuntimeUrls($serviceChannel, $versions);
return [
'source' => 'deployment',
'generated_at' => date('c'),
'trace_id' => $this->requestTraceId(),
'channel' => $this->publicRuntimeChannel($channel),
'service_channel' => $this->publicRuntimeChannel($serviceChannel),
'versions' => $versions,
'frontend_base_url' => $urls['frontend_base_url'],
'api_base_url' => $urls['api_base_url'],
'urls' => $urls,
'availability' => $this->channelAvailability($channel),
'capture_policy' => $capturePolicy,
'available_channels' => $this->publicRuntimeChannelOptions($availableChannels),
'selected_channel_slug' => (string)($channel['slug'] ?? ''),
'selected_service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
'module_keys' => self::MODULE_KEYS,
];
}
public function runtimeForCurrentPrincipal(array $input = []): array
{
$this->ensureSchema();
$context = $this->currentPrincipalContext();
$resolvedChannel = $this->resolveChannel($context);
$availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel);
$channel = $this->chooseRuntimeChannel(
$resolvedChannel,
$availableChannels,
$this->requestedRuntimeChannelSlug($input)
);
$serviceChannel = $this->runtimeServiceChannelFor($channel);
$versions = $this->currentVersionsForChannel((int)$serviceChannel['id']);
$urls = $this->releaseRuntimeUrls($serviceChannel, $versions);
return [
'source' => 'deployment',
'generated_at' => date('c'),
'trace_id' => $this->requestTraceId(),
'channel' => $this->publicRuntimeChannel($channel),
'service_channel' => $this->publicRuntimeChannel($serviceChannel),
'versions' => $versions,
'frontend_base_url' => $urls['frontend_base_url'],
'api_base_url' => $urls['api_base_url'],
'urls' => $urls,
'availability' => $this->channelAvailability($channel),
'capture_policy' => $this->capturePolicyFor($context, $channel),
'available_channels' => $this->publicRuntimeChannelOptions($availableChannels),
'selected_channel_slug' => (string)($channel['slug'] ?? ''),
'selected_service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
'module_keys' => self::MODULE_KEYS,
];
}
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();
$summary = [
'generated_at' => date('c'),
'channels' => $this->listChannels(),
'assignments' => $this->listAssignments(),
'deployment_targets' => $this->listDeploymentTargets(),
'service_sets' => $this->listServiceSets(),
'bundles' => $this->listBundles(25),
'deployments' => $this->listDeployments(25),
'operations' => $this->listOperations(['limit' => 20]),
'data_services' => $this->releaseDataServicesSummary(),
'replication_policy' => $this->releaseReplicationPolicySummary(),
'coolify' => $this->releaseCoolifySummary(),
'failover' => $this->releaseFailoverSummary(),
'timeline' => $this->timelineSummary(),
'module_health' => $this->latestModuleHealth(),
'module_keys' => self::MODULE_KEYS,
'suggestions' => $this->releaseSuggestions(),
];
$summary['status_overview'] = $this->releaseStatusOverview($summary);
return $summary;
}
public function suggestions(): array
{
$this->ensureSchema();
return $this->releaseSuggestions();
}
public function listOperations(array $filters = []): array
{
if (!release_manager_schema_bootstrap::tablesExist()) {
return [];
}
$limit = max(1, min(100, (int)($filters['limit'] ?? 50)));
$where = [];
$types = '';
$params = [];
$channelId = $this->nullablePositiveInt($filters['channel_id'] ?? null);
if ($channelId !== null) {
$where[] = 'r.channel_id = ?';
$types .= 'i';
$params[] = $channelId;
}
$operationType = self::safeIdentifier((string)($filters['operation_type'] ?? $filters['type'] ?? ''), 64);
if ($operationType !== '') {
$where[] = 'r.operation_type = ?';
$types .= 's';
$params[] = $operationType;
}
$status = self::safeIdentifier((string)($filters['status'] ?? ''), 32);
if ($status !== '') {
$where[] = 'r.status = ?';
$types .= 's';
$params[] = $status;
}
$whereSql = $where !== [] ? 'WHERE ' . implode(' AND ', $where) : '';
$rows = $this->selectRows(
"SELECT r.*, c.slug AS channel_slug, c.name AS channel_name,
(SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id) AS step_count,
(SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('passed', 'deployed')) AS passed_step_count,
(SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status = 'failed') AS failed_step_count,
(SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('warning', 'skipped')) AS warning_step_count
FROM release_operation_runs r
LEFT JOIN release_channels c ON c.id = r.channel_id
$whereSql
ORDER BY r.created_at DESC, r.id DESC
LIMIT $limit",
$types,
$params
);
return array_map(fn(array $row): array => $this->publicOperationRun($row, false), $rows);
}
public function operationDetail(int $id): array
{
$this->ensureSchema();
$operation = $this->getOperationRun($id);
return $this->publicOperationRun($operation, true);
}
public function runReleaseTest(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$channel = null;
try {
if ($this->nullablePositiveInt($input['channel_id'] ?? null) !== null || trim((string)($input['channel_slug'] ?? $input['channel'] ?? '')) !== '') {
$channel = $this->channelFromInput($input);
}
} catch (Throwable $throwable) {
$channel = null;
}
$gateInput = $this->normalizeReleaseGateInput($input, $channel);
$operationId = $this->createOperationRun('release_test', [
'subject_type' => $channel !== null ? 'channel' : 'release_manager',
'subject_id' => $channel !== null ? (string)$channel['id'] : null,
'channel_id' => $channel !== null ? (int)$channel['id'] : null,
'title' => $channel !== null
? sprintf('Release checks for %s', (string)($channel['name'] ?? $channel['slug'] ?? 'channel'))
: 'Release Manager checks',
'actor_user_id' => $actorUserId,
'context' => array_replace(self::redactPayload($input), [
'release_gate' => self::redactPayload($gateInput),
]),
]);
$statuses = [];
$this->recordOperationStep($operationId, 'dashboard_contract', 'Dashboard data contract', 'passed', 'Release Manager exposes channels, operations, Coolify, failover, and data-service state.', null, null, [
'summary_keys' => ['channels', 'operations', 'coolify', 'failover', 'data_services'],
]);
$statuses[] = 'passed';
if ($this->releaseGateAutoSyncRequested($gateInput)) {
foreach ($this->releaseGateAutoSyncValidationSteps($gateInput, $channel) as $autoSyncStep) {
$this->recordOperationStep(
$operationId,
(string)$autoSyncStep['step_key'],
(string)$autoSyncStep['label'],
(string)$autoSyncStep['status'],
$autoSyncStep['message'] ?? null,
$autoSyncStep['diagnostic'] ?? null,
$autoSyncStep['solution_hint'] ?? null,
is_array($autoSyncStep['context'] ?? null) ? $autoSyncStep['context'] : []
);
$statuses[] = (string)$autoSyncStep['status'];
}
}
if (($gateInput['required_checks'] ?? []) !== []) {
$this->recordOperationStep(
$operationId,
'release_gate_inputs',
'Release gate payload',
'passed',
'Release gate payload includes CI deploy metadata and required checks.',
null,
null,
$gateInput
);
$statuses[] = 'passed';
foreach ($this->runReleaseGateChecks($gateInput) as $gateStep) {
$this->recordOperationStep(
$operationId,
(string)$gateStep['step_key'],
(string)$gateStep['label'],
(string)$gateStep['status'],
$gateStep['message'] ?? null,
$gateStep['diagnostic'] ?? null,
$gateStep['solution_hint'] ?? null,
is_array($gateStep['context'] ?? null) ? $gateStep['context'] : []
);
$statuses[] = (string)$gateStep['status'];
}
}
$appsToCheck = $this->releaseTestAppsFromInput($input);
$channels = $channel !== null ? [$channel] : $this->selectRows("SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 ORDER BY default_channel DESC, slug");
foreach ($channels as $testChannel) {
foreach ($appsToCheck as $app) {
$target = $this->deploymentTargetForChannelApp((int)$testChannel['id'], $app);
$repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app)));
$branch = self::releaseBranchForChannel($testChannel);
if ($target === null) {
$this->recordOperationStep(
$operationId,
sprintf('%s_%s_target', (string)$testChannel['slug'], $app),
sprintf('%s %s target', (string)$testChannel['name'], strtoupper($app)),
'warning',
'No Coolify deployment target is configured for this app/channel pair.',
'Release Manager cannot deploy this app until a target exists.',
'Create or repair the channel deployment target, then run the test again.',
['channel_slug' => $testChannel['slug'], 'app' => $app, 'retry_action' => 'configure_target']
);
$statuses[] = 'warning';
continue;
}
$access = $this->githubRepositoryAccess([
'repository' => $repository,
'branch' => $branch,
'commit_mode' => 'latest',
]);
$ok = (bool)($access['ok'] ?? false);
$status = $ok ? 'passed' : 'warning';
$statuses[] = $status;
$this->recordOperationStep(
$operationId,
sprintf('%s_%s_branch', (string)$testChannel['slug'], $app),
sprintf('%s %s branch', (string)$testChannel['name'], strtoupper($app)),
$status,
$ok
? sprintf('Branch %s is reachable and resolves to the latest commit.', $branch)
: sprintf('Branch %s could not be verified and will be skipped by sync.', $branch),
$ok ? null : (string)($access['message'] ?? 'GitHub branch access failed.'),
$ok ? null : 'Create the missing branch or repair the Release Manager GitHub token, then use Retry.',
[
'channel_slug' => $testChannel['slug'],
'route_slug' => self::routeSlugForChannel((string)$testChannel['slug']),
'app' => $app,
'repository' => $repository,
'branch' => $branch,
'github_access' => $access,
'retry_action' => 'retry_branch_check',
]
);
}
}
$dataSummary = $channel !== null
? $this->channelDataServicesSummary($channel)
: $this->releaseDataServicesSummary();
$this->recordOperationStep($operationId, 'data_services', 'Production-shared data services', 'passed', 'Normal channel sync keeps MariaDB, Redis, and MinIO on production_shared unless an explicit data-service action changes that mode.', null, null, [
'data_services' => $dataSummary,
]);
$statuses[] = 'passed';
$finalStatus = in_array('failed', $statuses, true)
? 'failed'
: (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed');
if ($finalStatus === 'passed' && $this->releaseGateAutoSyncRequested($gateInput)) {
try {
$this->inProcessPassedReleaseGates[$operationId] = [
'channel_id' => $channel !== null ? (int)$channel['id'] : null,
'release_gate' => $gateInput,
];
$autoSyncResult = $this->processReleaseGateAutoSync($gateInput, $channel, $operationId, $actorUserId);
$autoSyncStepStatus = (string)($autoSyncResult['step_status'] ?? 'passed');
$this->recordOperationStep(
$operationId,
'auto_sync',
'Automatic container update',
$autoSyncStepStatus,
(string)($autoSyncResult['message'] ?? 'Automatic container update completed.'),
$autoSyncResult['diagnostic'] ?? null,
$autoSyncResult['solution_hint'] ?? null,
$autoSyncResult
);
$statuses[] = $autoSyncStepStatus;
} catch (Throwable $throwable) {
$this->recordOperationStep(
$operationId,
'auto_sync',
'Automatic container update',
'failed',
'Automatic container update failed after the release gate passed.',
$throwable->getMessage(),
'Open the channel sync operation or Release Manager target diagnostics, fix the failure, then rerun the gate.',
$gateInput
);
$statuses[] = 'failed';
}
$finalStatus = in_array('failed', $statuses, true)
? 'failed'
: (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed');
}
$this->completeOperationRun(
$operationId,
$finalStatus,
match ($finalStatus) {
'passed' => 'Release Manager tests completed without detected issues.',
'failed' => 'Release Manager tests failed. Promotion is blocked until the failed gate checks pass.',
default => 'Release Manager tests completed with warnings. Open the failed or warning steps for fixes.',
},
$finalStatus === 'passed' ? null : 'Resolve the failed or warning steps, then run the test again.'
);
return $this->operationDetail($operationId);
}
private function releaseTestAppsFromInput(array $input): array
{
$raw = $input['apps'] ?? $input['app'] ?? null;
$values = $this->releaseGateStringArray($raw);
$apps = [];
foreach ($values as $value) {
try {
$app = $this->normalizeApp($value);
} catch (Throwable) {
continue;
}
if (!in_array($app, $apps, true)) {
$apps[] = $app;
}
}
return $apps !== [] ? $apps : self::APPS;
}
private function normalizeReleaseGateInput(array $input, ?array $channel): array
{
$channelSlug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? ($channel['slug'] ?? '')));
$environmentUrl = $this->normalizeReleaseGateUrl((string)($input['environment_url'] ?? $input['frontend_url'] ?? ''));
$apiBaseUrl = $this->normalizeReleaseGateUrl((string)($input['api_base_url'] ?? 'https://api-v2.truckwash.io'));
$requiredChecks = $this->normalizeReleaseGateChecks($input, $environmentUrl);
$routeSlug = self::routeSlugForChannel($channelSlug ?: 'stable') ?: 'master';
$app = '';
if (trim((string)($input['app'] ?? '')) !== '') {
try {
$app = $this->normalizeApp((string)$input['app']);
} catch (Throwable) {
$app = '';
}
}
$repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? $input['repo'] ?? ''));
$branch = trim((string)($input['branch'] ?? ''));
$workflowUrl = $this->normalizeReleaseGateUrl((string)($input['workflow_url'] ?? $input['build_url'] ?? ''));
return [
'environment_url' => $environmentUrl,
'channel_slug' => $channelSlug,
'route_slug' => $routeSlug,
'app' => $app,
'apps' => $this->releaseTestAppsFromInput($input),
'repository' => $repository,
'branch' => $branch,
'auto_sync' => $this->toBool($input['auto_sync'] ?? false),
'workflow_url' => $workflowUrl,
'expected_commit' => self::safeIdentifier((string)($input['expected_commit'] ?? $input['commit_sha'] ?? ''), 128),
'build_id' => substr(trim((string)($input['build_id'] ?? '')), 0, 128),
'wait_timeout_seconds' => max(0, min(300, (int)($input['wait_timeout_seconds'] ?? 300))),
'poll_interval_seconds' => max(1, min(60, (int)($input['poll_interval_seconds'] ?? 10))),
'required_checks' => $requiredChecks,
'api_base_url' => $apiBaseUrl,
'api_ping_paths' => $this->releaseGateStringArray(
$input['api_ping_paths']
?? $input['api_paths']
?? ['/master/api/ping'],
self::RELEASE_GATE_MAX_PATHS
),
'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash'], self::RELEASE_GATE_MAX_PATHS),
];
}
private function normalizeReleaseGateChecks(array $input, string $environmentUrl): array
{
$checks = $this->releaseGateStringArray($input['required_checks'] ?? []);
if ($checks === [] && $environmentUrl !== '') {
$checks = ['static_artifact'];
}
$allowed = ['static_artifact', 'api_gateway'];
$normalized = [];
foreach ($checks as $check) {
$check = self::safeIdentifier(strtolower($check), 64);
if (in_array($check, $allowed, true) && !in_array($check, $normalized, true)) {
$normalized[] = $check;
}
}
return $normalized;
}
private function releaseGateAutoSyncRequested(array $gateInput): bool
{
return (bool)($gateInput['auto_sync'] ?? false);
}
private function releaseGateAutoSyncValidationSteps(array $gateInput, ?array $channel): array
{
$steps = [];
$requiredChecks = is_array($gateInput['required_checks'] ?? null) ? $gateInput['required_checks'] : [];
$context = [
'channel_slug' => $gateInput['channel_slug'] ?? null,
'app' => $gateInput['app'] ?? null,
'repository' => $gateInput['repository'] ?? null,
'branch' => $gateInput['branch'] ?? null,
'expected_commit' => $gateInput['expected_commit'] ?? null,
'workflow_url' => $gateInput['workflow_url'] ?? null,
'required_checks' => $requiredChecks,
];
if ($channel === null) {
$steps[] = [
'step_key' => 'auto_sync_channel',
'label' => 'Automatic update channel',
'status' => 'failed',
'message' => 'Automatic container updates require a release channel.',
'diagnostic' => 'The gate payload did not resolve to a configured release channel.',
'solution_hint' => 'Pass channel_slug from CI, for example stable for master.',
'context' => $context,
];
}
if (trim((string)($gateInput['app'] ?? '')) === '') {
$steps[] = [
'step_key' => 'auto_sync_app',
'label' => 'Automatic update app',
'status' => 'failed',
'message' => 'Automatic container updates require an app.',
'diagnostic' => 'The gate payload must identify frontend or api so Release Manager updates exactly one container.',
'solution_hint' => 'Pass app=frontend from the frontend workflow or app=api from the backend workflow.',
'context' => $context,
];
}
if (trim((string)($gateInput['expected_commit'] ?? '')) === '') {
$steps[] = [
'step_key' => 'auto_sync_commit',
'label' => 'Automatic update commit',
'status' => 'failed',
'message' => 'Automatic container updates require the CI-verified commit SHA.',
'diagnostic' => 'expected_commit was empty.',
'solution_hint' => 'Pass github.sha as expected_commit in the release gate payload.',
'context' => $context,
];
}
if ($requiredChecks === []) {
$steps[] = [
'step_key' => 'auto_sync_required_checks',
'label' => 'Automatic update required checks',
'status' => 'failed',
'message' => 'Automatic container updates require at least one release gate check.',
'diagnostic' => 'required_checks was empty.',
'solution_hint' => 'Include required_checks (for example static_artifact and/or api_gateway) in the release gate payload.',
'context' => $context,
];
}
if ($steps === []) {
$steps[] = [
'step_key' => 'auto_sync_inputs',
'label' => 'Automatic update inputs',
'status' => 'passed',
'message' => 'Release gate payload includes app, channel, and exact commit metadata for automatic container updates.',
'context' => $context,
];
}
return $steps;
}
private function releaseGateStringArray(mixed $value, int $limit = 50): array
{
if (is_string($value)) {
$value = preg_split('/\s*,\s*/', trim($value)) ?: [];
}
if (!is_array($value)) {
return [];
}
$values = [];
foreach ($value as $item) {
$item = trim((string)$item);
if ($item !== '' && !in_array($item, $values, true)) {
$values[] = $item;
}
}
return array_slice($values, 0, max(0, $limit));
}
private function normalizeReleaseGateUrl(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if (preg_match('#^https?://#i', $value) !== 1) {
$value = 'https://' . ltrim($value, '/');
}
$parts = parse_url($value);
if (!is_array($parts) || empty($parts['host'])) {
return '';
}
return rtrim($value, '/');
}
private function runReleaseGateChecks(array $gateInput): array
{
$steps = [];
foreach ($gateInput['required_checks'] as $check) {
$steps[] = match ($check) {
'static_artifact' => $this->verifyReleaseStaticArtifact($gateInput),
'api_gateway' => $this->verifyReleaseApiGateway($gateInput),
default => [
'step_key' => $check,
'label' => 'Unknown release gate check',
'status' => 'skipped',
'message' => 'Unknown release gate check was skipped.',
'context' => ['check' => $check],
],
};
}
return $steps;
}
private function assertReleaseGatePassedForPromotion(int $channelId, ?string $expectedCommit = null, ?string $buildId = null, ?string $app = null): void
{
if (!$this->releaseGateRequiredForPromotion()) {
return;
}
$expectedCommit = trim((string)$expectedCommit);
$buildId = trim((string)$buildId);
$app = trim((string)$app) !== '' ? $this->normalizeApp((string)$app) : '';
foreach ($this->inProcessPassedReleaseGates as $inProcessGate) {
if ((int)($inProcessGate['channel_id'] ?? 0) !== $channelId) {
continue;
}
$gate = is_array($inProcessGate['release_gate'] ?? null) ? $inProcessGate['release_gate'] : [];
if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) {
continue;
}
if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) {
continue;
}
if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) {
continue;
}
return;
}
$rows = $this->selectRows(
"SELECT id, context_json, completed_at
FROM release_operation_runs
WHERE operation_type = 'release_test'
AND channel_id = ?
AND status = 'passed'
AND completed_at >= DATE_SUB(NOW(), INTERVAL 12 HOUR)
ORDER BY completed_at DESC, id DESC
LIMIT 20",
'i',
[$channelId]
);
foreach ($rows as $row) {
$context = json_decode((string)($row['context_json'] ?? ''), true);
$gate = is_array($context['release_gate'] ?? null) ? $context['release_gate'] : [];
if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) {
continue;
}
if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) {
continue;
}
if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) {
continue;
}
return;
}
throw new RuntimeException('A passing Release Manager gate is required before promotion. Run the dev upload, public live smoke, credentialed smoke, and api-v2 health checks, then retry promotion.');
}
private function releaseGateAppMatches(array $gate, string $app): bool
{
$app = $this->normalizeApp($app);
$gateApp = trim((string)($gate['app'] ?? ''));
if ($gateApp !== '') {
try {
return $this->normalizeApp($gateApp) === $app;
} catch (Throwable) {
return false;
}
}
$gateApps = $this->releaseGateStringArray($gate['apps'] ?? []);
if ($gateApps !== []) {
foreach ($gateApps as $value) {
try {
if ($this->normalizeApp($value) === $app) {
return true;
}
} catch (Throwable) {
}
}
return false;
}
// Gates recorded before app-specific payloads existed were frontend release gates.
return $app === 'frontend';
}
private function releaseGateRequiredForPromotion(): bool
{
$envValue = trim((string)(getenv('RELEASE_GATE_REQUIRED_FOR_PROMOTION') ?: ($_SERVER['RELEASE_GATE_REQUIRED_FOR_PROMOTION'] ?? '')));
if ($envValue !== '') {
return $this->toBool($envValue);
}
return $this->toBool($this->moduleConfigValue('ReleaseManager', 'release_gate_required_for_promotion', 'true'));
}
private function verifyReleaseStaticArtifact(array $gateInput): array
{
if (($gateInput['environment_url'] ?? '') === '') {
return [
'step_key' => 'static_artifact',
'label' => 'Static artifact deployment',
'status' => 'failed',
'message' => 'environment_url is required for static artifact verification.',
'solution_hint' => 'Pass the dev or channel frontend URL from CI.',
'context' => $gateInput,
];
}
$deadline = time() + (int)$gateInput['wait_timeout_seconds'];
$pollInterval = (int)$gateInput['poll_interval_seconds'];
$attempts = 0;
$lastMessage = 'Static artifact verification did not run.';
do {
$attempts++;
try {
$context = $this->releaseStaticArtifactAttempt($gateInput);
$context['attempts'] = $attempts;
return [
'step_key' => 'static_artifact',
'label' => 'Static artifact deployment',
'status' => 'passed',
'message' => 'The exact release manifest, app shell, JS, CSS, PWA assets, and release entry are reachable.',
'context' => $context,
];
} catch (Throwable $throwable) {
$lastMessage = $throwable->getMessage();
if (time() >= $deadline) {
break;
}
sleep($pollInterval);
}
} while (true);
return [
'step_key' => 'static_artifact',
'label' => 'Static artifact deployment',
'status' => 'failed',
'message' => 'The uploaded frontend artifact is not ready or does not match the expected build.',
'diagnostic' => $lastMessage,
'solution_hint' => 'Upload hashed assets first, keep old hashed assets, upload release-entry.json and index.html last, then rerun the gate.',
'context' => [
'environment_url' => $gateInput['environment_url'],
'attempts' => $attempts,
'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'],
'poll_interval_seconds' => $gateInput['poll_interval_seconds'],
],
];
}
private function releaseStaticArtifactAttempt(array $gateInput): array
{
$baseUrl = (string)$gateInput['environment_url'];
$manifest = $this->releaseGateFetchJson($baseUrl, 'release-manifest.json');
$releaseEntry = $this->releaseGateFetchJson($baseUrl, 'release-entry.json');
$manifestData = $manifest['json'];
$releaseEntryData = $releaseEntry['json'];
if (trim((string)($manifestData['build_id'] ?? '')) === '') {
throw new RuntimeException('release-manifest.json is missing build_id.');
}
if (!$this->releaseGateCommitMatches((string)($manifestData['commit_sha'] ?? ''), (string)$gateInput['expected_commit'])) {
throw new RuntimeException(sprintf(
'release-manifest.json commit_sha %s did not match expected commit %s.',
(string)($manifestData['commit_sha'] ?? '(missing)'),
(string)$gateInput['expected_commit']
));
}
if ((string)$gateInput['build_id'] !== '' && (string)($manifestData['build_id'] ?? '') !== (string)$gateInput['build_id']) {
throw new RuntimeException(sprintf(
'release-manifest.json build_id %s did not match expected build_id %s.',
(string)($manifestData['build_id'] ?? '(missing)'),
(string)$gateInput['build_id']
));
}
if ((string)($releaseEntryData['entry'] ?? '') !== (string)($manifestData['entry'] ?? '')) {
throw new RuntimeException('release-entry.json entry does not match release-manifest.json.');
}
if (json_encode($releaseEntryData['css'] ?? []) !== json_encode($manifestData['css'] ?? [])) {
throw new RuntimeException('release-entry.json css does not match release-manifest.json.');
}
foreach ($gateInput['shell_paths'] as $shellPath) {
$shell = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $shellPath));
if (($shell['status'] ?? 0) !== 200) {
throw new RuntimeException(sprintf('%s returned HTTP %d.', $shellPath, (int)($shell['status'] ?? 0)));
}
if (!str_contains(strtolower((string)($shell['content_type'] ?? '')), 'text/html')) {
throw new RuntimeException(sprintf('%s did not return HTML.', $shellPath));
}
$body = (string)($shell['body'] ?? '');
if (strlen(trim(preg_replace('/\s+/', '', $body) ?? '')) < 40) {
throw new RuntimeException(sprintf('%s returned an empty app shell.', $shellPath));
}
if (!str_contains($body, '<div id="app"></div>')) {
throw new RuntimeException(sprintf('%s did not include the Vue app root.', $shellPath));
}
}
$assetUrls = array_slice($this->releaseGateUniqueStrings(array_merge(
['release-manifest.json', 'release-entry.json'],
[(string)($manifestData['entry'] ?? '')],
is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [],
is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [],
is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [],
is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : []
)), 0, self::RELEASE_GATE_MAX_ASSETS);
$verifiedAssets = 0;
foreach ($assetUrls as $assetUrl) {
if ($assetUrl === '/index.html') {
continue;
}
$this->releaseGateVerifyStaticAsset($baseUrl, $assetUrl, is_array($manifestData['asset_hashes'] ?? null) ? $manifestData['asset_hashes'] : []);
$verifiedAssets++;
}
return [
'environment_url' => $baseUrl,
'build_id' => (string)$manifestData['build_id'],
'commit_sha' => (string)($manifestData['commit_sha'] ?? ''),
'entry' => (string)$manifestData['entry'],
'verified_assets' => $verifiedAssets,
];
}
private function verifyReleaseApiGateway(array $gateInput): array
{
$apiBaseUrl = (string)($gateInput['api_base_url'] ?? '');
if ($apiBaseUrl === '') {
return [
'step_key' => 'api_gateway',
'label' => 'api-v2 channel health',
'status' => 'failed',
'message' => 'api_base_url is required for API gateway verification.',
'solution_hint' => 'Pass the api-v2 base URL from CI.',
'context' => $gateInput,
];
}
$expectedCommit = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
$enforceExpectedCommit = $expectedCommit !== '' && !$this->releaseGateAutoSyncRequested($gateInput);
$checked = [];
try {
foreach ($gateInput['api_ping_paths'] as $path) {
$json = $this->releaseGateFetchJson($apiBaseUrl, $path);
$payload = $json['json'];
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
throw new RuntimeException(sprintf('%s returned success=false.', $path));
}
$actualCommit = $this->releaseGateApiPayloadCommitSha($payload);
if ($enforceExpectedCommit && !$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) {
throw new RuntimeException(sprintf(
'%s returned commit %s, expected %s.',
$path,
$actualCommit !== '' ? $actualCommit : 'unknown',
$expectedCommit
));
}
$checked[] = [
'path' => $path,
'status' => $json['status'],
'commit_sha' => $actualCommit !== '' ? $actualCommit : null,
];
}
} catch (Throwable $throwable) {
return [
'step_key' => 'api_gateway',
'label' => 'api-v2 channel health',
'status' => 'failed',
'message' => 'api-v2 gateway or channel API health failed.',
'diagnostic' => $throwable->getMessage(),
'solution_hint' => 'Repair api-v2 routing so the configured channel API ping endpoints return 200 JSON before frontend promotion.',
'context' => [
'api_base_url' => $apiBaseUrl,
'checked' => $checked,
'api_ping_paths' => $gateInput['api_ping_paths'],
],
];
}
return [
'step_key' => 'api_gateway',
'label' => 'api-v2 channel health',
'status' => 'passed',
'message' => 'api-v2 gateway and channel API prefixes returned 200 JSON.',
'context' => [
'api_base_url' => $apiBaseUrl,
'checked' => $checked,
'expected_commit' => $expectedCommit !== '' ? $expectedCommit : null,
],
];
}
private function releaseGateApiPayloadCommitSha(array $payload): string
{
$data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload;
foreach (['api_commit_sha', 'backend_version', 'commit_sha', 'version'] as $key) {
$commit = self::normalizeCommitSha((string)($data[$key] ?? ''));
if ($commit !== '') {
return $commit;
}
}
return '';
}
private function releaseGateFetchJson(string $baseUrl, string $path): array
{
$result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path));
if (($result['status'] ?? 0) !== 200) {
throw new RuntimeException(sprintf('%s returned HTTP %d.', $path, (int)($result['status'] ?? 0)));
}
if (str_contains(strtolower((string)($result['content_type'] ?? '')), 'text/html')) {
throw new RuntimeException(sprintf('%s was served as HTML.', $path));
}
$decoded = json_decode((string)($result['body'] ?? ''), true);
if (!is_array($decoded)) {
throw new RuntimeException(sprintf('%s did not return valid JSON.', $path));
}
$result['json'] = $decoded;
return $result;
}
private function releaseGateVerifyStaticAsset(string $baseUrl, string $assetUrl, array $assetHashes): void
{
$result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $assetUrl));
if (($result['status'] ?? 0) !== 200) {
throw new RuntimeException(sprintf('%s returned HTTP %d.', $assetUrl, (int)($result['status'] ?? 0)));
}
$body = (string)($result['body'] ?? '');
if ($body === '') {
throw new RuntimeException(sprintf('%s returned an empty body.', $assetUrl));
}
if ($this->releaseGateRejectsHtml($assetUrl) && str_contains(strtolower((string)($result['content_type'] ?? '')), 'text/html')) {
throw new RuntimeException(sprintf('%s was served as HTML.', $assetUrl));
}
$hashKey = str_starts_with($assetUrl, '/') ? $assetUrl : '/' . ltrim($assetUrl, '/');
if (is_array($assetHashes[$hashKey] ?? null) && !empty($assetHashes[$hashKey]['sha256'])) {
$actualHash = hash('sha256', $body);
if (!hash_equals((string)$assetHashes[$hashKey]['sha256'], $actualHash)) {
throw new RuntimeException(sprintf('%s sha256 hash mismatch.', $assetUrl));
}
}
}
private function releaseGateFetch(string $url): array
{
$this->assertReleaseGateFetchUrlAllowed($url);
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Could not initialize release gate request.');
}
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($curl, CURLOPT_MAXREDIRS, 0);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3);
curl_setopt($curl, CURLOPT_TIMEOUT, 8);
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Accept: application/json, text/html, */*',
'Cache-Control: no-cache',
'Pragma: no-cache',
'User-Agent: Truckwash-Release-Gate',
]);
$body = curl_exec($curl);
$error = curl_error($curl);
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
$contentType = (string)curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
curl_close($curl);
if ($body === false) {
throw new RuntimeException('Release gate request failed: ' . $error);
}
return [
'url' => $url,
'status' => $status,
'content_type' => $contentType,
'body' => (string)$body,
];
}
private function releaseGateJoinUrl(string $baseUrl, string $path): string
{
$path = trim($path);
$parts = parse_url($path);
if (is_array($parts) && (!empty($parts['scheme']) || !empty($parts['host']))) {
throw new RuntimeException('Release gate paths must be relative to the configured Truckwash release host.');
}
if (str_starts_with($path, '//')) {
throw new RuntimeException('Release gate paths must not be protocol-relative URLs.');
}
return rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
}
private function assertReleaseGateFetchUrlAllowed(string $url): void
{
$parts = parse_url($url);
$scheme = strtolower((string)($parts['scheme'] ?? ''));
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
throw new RuntimeException('Release gate checks may only fetch HTTP(S) URLs from Truckwash release hosts.');
}
if (!$this->releaseGateFetchHostAllowed($host)) {
throw new RuntimeException('Release gate checks may only fetch configured Truckwash release hosts.');
}
$addresses = $this->releaseGateResolveHost($host);
if ($addresses === []) {
throw new RuntimeException('Release gate host could not be resolved.');
}
foreach ($addresses as $address) {
if (!$this->releaseGatePublicIpAllowed($address)) {
throw new RuntimeException('Release gate host resolved to a private, loopback, or reserved address.');
}
}
}
private function releaseGateFetchHostAllowed(string $host): bool
{
$host = strtolower(rtrim($host, '.'));
foreach (self::RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES as $allowedSuffix) {
$allowedSuffix = strtolower($allowedSuffix);
if ($host === $allowedSuffix || str_ends_with($host, '.' . $allowedSuffix)) {
return true;
}
}
return false;
}
private function releaseGateResolveHost(string $host): array
{
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return [$host];
}
$addresses = gethostbynamel($host) ?: [];
if (function_exists('dns_get_record')) {
foreach (dns_get_record($host, DNS_AAAA) ?: [] as $record) {
if (is_array($record) && !empty($record['ipv6'])) {
$addresses[] = (string)$record['ipv6'];
}
}
}
return array_values(array_unique(array_filter($addresses, fn(string $address): bool => filter_var($address, FILTER_VALIDATE_IP) !== false)));
}
private function releaseGatePublicIpAllowed(string $address): bool
{
return filter_var(
$address,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
private function releaseGateCommitMatches(string $actual, string $expected): bool
{
$expected = strtolower(trim($expected));
if ($expected === '') {
return true;
}
$actual = strtolower(trim($actual));
return $actual !== '' && ($actual === $expected || str_starts_with($actual, $expected));
}
private function verifyReleaseDeploymentReadiness(array $deployment, array $target, string $app, string $expectedCommit, array $options = []): array
{
$app = $this->normalizeApp($app);
$baseUrl = $this->normalizeReleasePublicBaseUrl($deployment['deployment_url'] ?? null, $app)
?? $this->releaseTargetPublicBaseUrl($target);
if ($baseUrl === null || $baseUrl === '') {
throw new RuntimeException(sprintf('%s deployment has no public URL for readiness verification.', strtoupper($app)));
}
$timeout = max(0, min(300, (int)($options['wait_timeout_seconds'] ?? 300)));
$pollInterval = max(1, min(60, (int)($options['poll_interval_seconds'] ?? 10)));
$deadline = time() + $timeout;
$attempts = 0;
$lastMessage = 'Readiness verification did not run.';
do {
$attempts++;
try {
if ($app === 'frontend') {
$context = $this->releaseStaticArtifactAttempt([
'environment_url' => $baseUrl,
'expected_commit' => $expectedCommit,
'build_id' => (string)($options['build_id'] ?? ''),
'shell_paths' => $this->releaseGateStringArray($options['shell_paths'] ?? ['/', '/guest/book/wash']),
]);
$context['app'] = $app;
$context['base_url'] = $baseUrl;
$context['attempts'] = $attempts;
return $context;
}
$json = $this->releaseGateFetchJson($baseUrl, 'ping');
$payload = $json['json'];
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
throw new RuntimeException('API ping returned success=false.');
}
$data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload;
$actualCommit = (string)(
$data['api_commit_sha']
?? $data['backend_commit_sha']
?? $data['commit_sha']
?? $data['backend_version']
?? ''
);
if (!$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) {
throw new RuntimeException(sprintf(
'API ping commit %s did not match expected commit %s.',
$actualCommit !== '' ? $actualCommit : '(missing)',
$expectedCommit
));
}
return [
'app' => $app,
'base_url' => $baseUrl,
'path' => 'ping',
'status' => $json['status'] ?? null,
'commit_sha' => $actualCommit,
'attempts' => $attempts,
];
} catch (Throwable $throwable) {
$lastMessage = $throwable->getMessage();
if (time() >= $deadline) {
break;
}
sleep($pollInterval);
}
} while (time() <= $deadline);
throw new RuntimeException(sprintf(
'%s container readiness did not match commit %s after %d attempts: %s',
strtoupper($app),
$expectedCommit,
$attempts,
$lastMessage
));
}
private function releaseGateRejectsHtml(string $assetUrl): bool
{
return preg_match('/\.(js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i', parse_url($assetUrl, PHP_URL_PATH) ?: '') === 1;
}
private function releaseGateUniqueStrings(array $values): array
{
$unique = [];
foreach ($values as $value) {
$value = trim((string)$value);
if ($value !== '' && !in_array($value, $unique, true)) {
$unique[] = $value;
}
}
return $unique;
}
public function syncChannel(int $channelId, ?int $actorUserId = null, array $options = []): array
{
$this->ensureSchema();
$channel = $this->getChannel($channelId);
if ((int)($channel['enabled'] ?? 0) !== 1) {
throw new RuntimeException('Release channel is disabled.');
}
$requestedApp = '';
if (trim((string)($options['app'] ?? '')) !== '') {
$requestedApp = $this->normalizeApp((string)$options['app']);
}
$apps = $requestedApp !== '' ? [$requestedApp] : self::APPS;
$branch = trim((string)($options['branch'] ?? '')) ?: self::releaseBranchForChannel($channel);
$routeSlug = self::routeSlugForChannel((string)$channel['slug']);
$requestedCommitSha = self::normalizeCommitSha((string)($options['commit_sha'] ?? $options['commit'] ?? ''));
$commitMode = $requestedCommitSha !== '' ? 'specific' : 'latest';
$requireReadiness = $this->toBool($options['require_readiness'] ?? false);
$operationId = $this->createOperationRun('channel_sync', [
'subject_type' => 'channel',
'subject_id' => (string)$channelId,
'channel_id' => $channelId,
'app' => $requestedApp !== '' ? $requestedApp : null,
'title' => sprintf('Sync %s release channel', (string)($channel['name'] ?? $channel['slug'])),
'actor_user_id' => $actorUserId,
'context' => [
'channel_slug' => $channel['slug'],
'route_slug' => $routeSlug,
'branch' => $branch,
'apps' => $apps,
'source' => $options['source'] ?? 'manual',
'repository' => $options['repository'] ?? null,
'commit_mode' => $commitMode,
'commit_sha' => $requestedCommitSha !== '' ? $requestedCommitSha : null,
'workflow_url' => $options['workflow_url'] ?? $options['build_url'] ?? null,
'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null,
'auto_sync_event' => $options['auto_sync_event'] ?? null,
'gate_operation_id' => $options['gate_operation_id'] ?? null,
],
]);
$statuses = [];
$deployments = [];
$this->recordOperationStep(
$operationId,
'channel_mapping',
'Channel route and branch mapping',
'passed',
sprintf('Channel %s syncs from branch %s and publishes under /%s/{api|frontend}.', (string)$channel['slug'], $branch, $routeSlug),
null,
null,
['channel_slug' => $channel['slug'], 'route_slug' => $routeSlug, 'branch' => $branch]
);
$statuses[] = 'passed';
$this->recordOperationStep(
$operationId,
'data_services_guard',
'Data services guard',
'passed',
'Code sync will not deploy or replace MariaDB, Redis, or MinIO.',
null,
null,
['data_services' => $this->channelDataServicesSummary($channel)]
);
$statuses[] = 'passed';
if ($this->channelUsesProductionServices($channel)) {
$serviceChannel = $this->productionServiceChannel();
$this->recordOperationStep(
$operationId,
'production_services',
'Production frontend and API services',
'passed',
sprintf(
'%s uses the %s production frontend and API services; channel sync does not deploy separate release services.',
(string)($channel['name'] ?? $channel['slug']),
(string)($serviceChannel['name'] ?? $serviceChannel['slug'])
),
null,
null,
[
'channel_slug' => (string)($channel['slug'] ?? ''),
'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
'service_policy' => self::PRODUCTION_SERVICE_POLICY,
]
);
$statuses[] = 'passed';
$this->completeOperationRun(
$operationId,
'passed',
'Channel sync completed; production services remain active.',
null
);
$operation = $this->operationDetail($operationId);
$operation['deployments'] = [];
$operation['channel'] = $this->publicChannel($this->getChannel($channelId));
return $operation;
}
foreach ($apps as $app) {
$target = $this->deploymentTargetForChannelApp($channelId, $app);
if ($target === null) {
$this->recordOperationStep(
$operationId,
$app . '_target',
strtoupper($app) . ' Coolify target',
'failed',
'No Coolify deployment target exists for this channel/app.',
'The channel cannot receive a new ' . $app . ' deployment.',
'Create the missing deployment target and use Retry.',
['channel_id' => $channelId, 'channel_slug' => $channel['slug'], 'app' => $app, 'retry_action' => 'configure_target']
);
$statuses[] = 'failed';
continue;
}
$target = $this->prepareChannelSyncApplicationTarget($target, $actorUserId);
if (($target['_release_auto_prepared_application'] ?? false) === true) {
$this->recordOperationStep(
$operationId,
$app . '_target_prepared',
strtoupper($app) . ' Coolify application target',
'passed',
sprintf('%s target was prepared to create a path-routed Coolify application.', strtoupper($app)),
null,
null,
['target_id' => (int)$target['id'], 'app' => $app]
);
$statuses[] = 'passed';
}
$repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app)));
$requestedRepository = self::normalizeGithubRepositoryName((string)($options['repository'] ?? ''));
if ($requestedRepository !== '') {
$repository = $requestedRepository;
}
if ($repository === '') {
$repository = self::defaultRepositoryForApp($app);
}
$access = $this->githubRepositoryAccess([
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $requestedCommitSha,
'commit_mode' => $commitMode,
]);
if (!($access['ok'] ?? false)) {
$this->recordOperationStep(
$operationId,
$app . '_branch',
strtoupper($app) . ' branch',
'warning',
sprintf('%s branch %s was not deployed because it could not be verified.', strtoupper($app), $branch),
(string)($access['message'] ?? 'GitHub branch access failed.'),
'Create the branch from master or repair GitHub access, then use Retry.',
[
'channel_slug' => $channel['slug'],
'route_slug' => $routeSlug,
'app' => $app,
'repository' => $repository,
'branch' => $branch,
'github_access' => $access,
'retry_action' => 'retry_sync',
]
);
$statuses[] = 'warning';
continue;
}
$commitSha = trim((string)($access['commit_sha'] ?? $access['latest_commit_sha'] ?? ''));
$current = $this->currentDeploymentForChannelApp($channelId, $app);
if ($commitSha !== '' && $current !== null && trim((string)($current['commit_sha'] ?? '')) === $commitSha) {
$this->recordOperationStep(
$operationId,
$app . '_already_current',
strtoupper($app) . ' deployment',
'skipped',
sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)),
null,
null,
['deployment' => $this->publicDeployment($current), 'github_access' => $access]
);
$statuses[] = 'skipped';
continue;
}
try {
$deployment = $this->startDeployment([
'target_id' => (int)$target['id'],
'channel_id' => $channelId,
'app' => $app,
'repository' => $repository,
'branch' => $branch,
'commit_mode' => $commitMode,
'commit_sha' => $commitSha,
'version_label' => $commitSha !== '' ? substr($commitSha, 0, 12) : date('Ymd-His'),
'build_url' => $options['build_url'] ?? null,
'metadata' => [
'release_operation_id' => $operationId,
'sync_source' => $options['source'] ?? 'manual',
'webhook_commit_sha' => $options['commit_sha'] ?? null,
'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null,
'gate_operation_id' => $options['gate_operation_id'] ?? null,
'workflow_url' => $options['workflow_url'] ?? null,
],
], $actorUserId);
if (($deployment['status'] ?? '') === 'deployed' && !empty($deployment['id'])) {
if ($requireReadiness) {
$readiness = $this->verifyReleaseDeploymentReadiness($deployment, $target, $app, $commitSha, $options);
$this->recordOperationStep(
$operationId,
$app . '_readiness',
strtoupper($app) . ' container readiness',
'passed',
sprintf('%s container readiness matched commit %s.', strtoupper($app), substr($commitSha, 0, 12)),
null,
null,
$readiness
);
}
$promoted = $this->promoteDeployment((int)$deployment['id'], $actorUserId);
$deployment = $promoted['deployment'] ?? $deployment;
}
$deployments[] = $deployment;
$this->recordOperationStep(
$operationId,
$app . '_deploy',
strtoupper($app) . ' deploy and activate',
(($deployment['status'] ?? '') === 'failed') ? 'failed' : 'passed',
(($deployment['status'] ?? '') === 'failed')
? sprintf('%s deployment failed before activation.', strtoupper($app))
: sprintf('%s deployment was recorded and the latest deployed revision is active for this channel.', strtoupper($app)),
(($deployment['status'] ?? '') === 'failed') ? (string)($deployment['error_message'] ?? 'Deployment failed.') : null,
(($deployment['status'] ?? '') === 'failed') ? 'Open the deployment result diagnostics, fix the provider error, then use Retry.' : null,
['deployment' => $deployment, 'github_access' => $access]
);
$statuses[] = (($deployment['status'] ?? '') === 'failed') ? 'failed' : 'passed';
} catch (Throwable $throwable) {
$this->recordOperationStep(
$operationId,
$app . '_deploy',
strtoupper($app) . ' deploy and activate',
'failed',
sprintf('%s deployment failed before activation.', strtoupper($app)),
$throwable->getMessage(),
'Review the deployment target, Coolify service, and GitHub branch, then use Retry.',
['app' => $app, 'repository' => $repository, 'branch' => $branch]
);
$statuses[] = 'failed';
}
}
$finalStatus = in_array('failed', $statuses, true)
? 'failed'
: (count(array_intersect($statuses, ['warning'])) > 0 ? 'warning' : 'passed');
$this->completeOperationRun(
$operationId,
$finalStatus,
$finalStatus === 'passed'
? 'Channel sync completed.'
: 'Channel sync finished with failures or warnings. Open the operation steps for exact diagnostics.',
$finalStatus === 'passed' ? null : 'Use the step retry action after fixing the reported target, branch, or provider issue.'
);
$operation = $this->operationDetail($operationId);
$operation['deployments'] = $deployments;
$operation['channel'] = $this->publicChannel($this->getChannel($channelId));
return $operation;
}
private function processReleaseGateAutoSync(array $gateInput, ?array $channel, int $gateOperationId, ?int $actorUserId): array
{
if ($channel === null) {
throw new RuntimeException('Automatic container update requires a release channel.');
}
$channelId = (int)$channel['id'];
$app = $this->normalizeApp((string)($gateInput['app'] ?? ''));
$commitSha = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
if ($commitSha === '') {
throw new RuntimeException('Automatic container update requires a 7-40 character Git commit SHA.');
}
$branch = trim((string)($gateInput['branch'] ?? '')) ?: self::releaseBranchForChannel($channel);
$repository = self::normalizeGithubRepositoryName((string)($gateInput['repository'] ?? ''));
if ($repository === '') {
$repository = self::defaultRepositoryForApp($app);
}
$target = $this->deploymentTargetForChannelApp($channelId, $app);
if ($target === null) {
throw new RuntimeException(sprintf('No %s deployment target is configured for %s.', strtoupper($app), (string)$channel['slug']));
}
if (!$this->toBool($target['auto_deploy'] ?? false)) {
throw new RuntimeException(sprintf('%s automatic deployments are disabled for %s.', strtoupper($app), (string)$channel['slug']));
}
$targetRepository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? ''));
$targetBranch = trim((string)($target['branch'] ?? ''));
if ($targetRepository !== '' && $repository !== $targetRepository) {
throw new RuntimeException(sprintf('Gate repository %s does not match target repository %s.', $repository, $targetRepository));
}
if ($targetBranch !== '' && $branch !== $targetBranch) {
throw new RuntimeException(sprintf('Gate branch %s does not match target branch %s.', $branch, $targetBranch));
}
$event = $this->upsertReleaseAutoSyncEvent([
'channel_id' => $channelId,
'app' => $app,
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'status' => 'gate_passed',
'source' => 'release_gate',
'workflow_url' => $gateInput['workflow_url'] ?? null,
'gate_operation_id' => $gateOperationId,
'metadata' => [
'release_gate' => $gateInput,
],
]);
$eventId = (int)$event['id'];
if (!$this->acquireReleaseAutoSyncLock($eventId)) {
return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);
}
try {
$event = $this->releaseAutoSyncEventById($eventId) ?? $event;
if (in_array((string)($event['status'] ?? ''), ['promoted', 'deployed'], true)) {
return [
'step_status' => 'passed',
'message' => 'Automatic container update was already completed for this commit.',
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
];
}
$current = $this->currentDeploymentForChannelApp($channelId, $app);
if ($current !== null && $this->releaseGateCommitMatches((string)($current['commit_sha'] ?? ''), $commitSha)) {
$event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [
'deployment_id' => (int)$current['id'],
'metadata' => ['already_current' => true],
]);
return [
'step_status' => 'passed',
'message' => sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)),
'deployment' => $this->publicDeployment($current),
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
];
}
$event = $this->updateReleaseAutoSyncEvent($eventId, 'syncing');
$operation = $this->syncChannel($channelId, $actorUserId, [
'app' => $app,
'source' => 'release_gate',
'repository' => $repository,
'branch' => $branch,
'commit_mode' => 'specific',
'commit_sha' => $commitSha,
'build_url' => $gateInput['workflow_url'] ?? null,
'workflow_url' => $gateInput['workflow_url'] ?? null,
'gate_operation_id' => $gateOperationId,
'auto_sync_event_id' => $eventId,
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
'require_readiness' => true,
'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'] ?? 300,
'poll_interval_seconds' => $gateInput['poll_interval_seconds'] ?? 10,
'build_id' => $gateInput['build_id'] ?? '',
'shell_paths' => $gateInput['shell_paths'] ?? [],
]);
if ((string)($operation['status'] ?? '') !== 'passed') {
throw new RuntimeException((string)($operation['summary'] ?? 'Automatic channel sync did not pass.'));
}
$deployment = $this->currentDeploymentForChannelApp($channelId, $app);
if ($deployment === null || !$this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
throw new RuntimeException(sprintf('%s was deployed but was not promoted as the active %s release.', strtoupper($app), (string)$channel['slug']));
}
$event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [
'sync_operation_id' => (int)($operation['id'] ?? 0) ?: null,
'deployment_id' => (int)$deployment['id'],
'metadata' => [
'sync_operation_id' => $operation['id'] ?? null,
'deployment_id' => $deployment['id'] ?? null,
],
]);
return [
'step_status' => 'passed',
'message' => sprintf('%s container was deployed and promoted at %s.', strtoupper($app), substr($commitSha, 0, 12)),
'sync_operation' => $operation,
'deployment' => $this->publicDeployment($deployment),
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
];
} catch (Throwable $throwable) {
$this->updateReleaseAutoSyncEvent($eventId, 'failed', [
'error_message' => $throwable->getMessage(),
]);
throw $throwable;
} finally {
$this->releaseReleaseAutoSyncLock($eventId);
}
}
private function waitForReleaseAutoSyncEventResult(int $eventId, int $channelId, string $app, string $commitSha, array $gateInput): array
{
$timeout = max(0, min(300, (int)($gateInput['wait_timeout_seconds'] ?? 300)));
$pollInterval = max(1, min(60, (int)($gateInput['poll_interval_seconds'] ?? 10)));
$deadline = time() + $timeout;
$attempts = 0;
$lastStatus = 'unknown';
do {
$attempts++;
$event = $this->releaseAutoSyncEventById($eventId);
if ($event === null) {
throw new RuntimeException('Automatic container update disappeared while another request was processing it.');
}
$lastStatus = (string)($event['status'] ?? 'unknown');
if (in_array($lastStatus, ['promoted', 'deployed'], true)) {
$deployment = null;
$deploymentId = $this->nullablePositiveInt($event['deployment_id'] ?? null);
if ($deploymentId !== null) {
$deployment = $this->getDeployment($deploymentId);
}
$deployment ??= $this->currentDeploymentForChannelApp($channelId, $app);
if ($deployment !== null && $this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
return [
'step_status' => 'passed',
'message' => sprintf('%s container update completed by an in-flight request at %s.', strtoupper($app), substr($commitSha, 0, 12)),
'deployment' => $this->publicDeployment($deployment),
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
'attempts' => $attempts,
];
}
throw new RuntimeException(sprintf(
'Automatic container update completed for event %d but the active %s deployment does not match %s.',
$eventId,
strtoupper($app),
$commitSha
));
}
if ($lastStatus === 'failed') {
$message = trim((string)($event['error_message'] ?? 'Automatic container update failed in another request.'));
throw new RuntimeException($message !== '' ? $message : 'Automatic container update failed in another request.');
}
if (time() >= $deadline) {
break;
}
sleep($pollInterval);
} while (true);
throw new RuntimeException(sprintf(
'Automatic container update is already being processed for event %d but did not finish within %d seconds; last status was %s.',
$eventId,
$timeout,
$lastStatus
));
}
private function upsertReleaseAutoSyncEvent(array $input): array
{
$channelId = (int)$input['channel_id'];
$app = $this->normalizeApp((string)$input['app']);
$repository = self::normalizeGithubRepositoryName((string)$input['repository']);
$branch = trim((string)$input['branch']);
$commitSha = self::normalizeCommitSha((string)$input['commit_sha']);
$status = self::safeIdentifier((string)($input['status'] ?? 'pending'), 32) ?: 'pending';
$source = self::safeIdentifier((string)($input['source'] ?? ''), 64) ?: null;
$workflowUrl = $this->nullableString($input['workflow_url'] ?? null, 512);
$gateOperationId = $this->nullablePositiveInt($input['gate_operation_id'] ?? null);
$metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : [];
if ($channelId <= 0 || $repository === '' || $branch === '' || $commitSha === '') {
throw new RuntimeException('Automatic sync event requires channel, app, repository, branch, and commit.');
}
$existing = $this->releaseAutoSyncEventFor($channelId, $app, $repository, $branch, $commitSha);
if ($existing === null) {
$this->execute(
"INSERT INTO release_auto_sync_events (
channel_id, app, repository, branch, commit_sha, status, source,
workflow_url, gate_operation_id, metadata_json, gate_passed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = 'gate_passed' THEN NOW() ELSE NULL END)",
'isssssssiss',
[
$channelId,
$app,
$repository,
$branch,
$commitSha,
$status,
$source,
$workflowUrl,
$gateOperationId,
self::jsonEncode(self::redactPayload($metadata)),
$status,
]
);
return $this->releaseAutoSyncEventById($this->insertId()) ?? [];
}
$existingStatus = (string)($existing['status'] ?? 'pending');
if ($status === 'pending' && !in_array($existingStatus, ['pending', 'failed'], true)) {
$status = $existingStatus;
}
if ($status === 'gate_passed' && in_array($existingStatus, ['syncing', 'promoted', 'deployed'], true)) {
$status = $existingStatus;
}
$this->execute(
"UPDATE release_auto_sync_events
SET status = ?,
source = COALESCE(?, source),
workflow_url = COALESCE(?, workflow_url),
gate_operation_id = COALESCE(NULLIF(?, 0), gate_operation_id),
error_message = NULL,
metadata_json = ?,
gate_passed_at = CASE WHEN ? = 'gate_passed' THEN COALESCE(gate_passed_at, NOW()) ELSE gate_passed_at END,
updated_at = NOW()
WHERE id = ?",
'sssissi',
[
$status,
$source,
$workflowUrl,
$gateOperationId ?? 0,
self::jsonEncode(self::redactPayload($metadata)),
$status,
(int)$existing['id'],
]
);
return $this->releaseAutoSyncEventById((int)$existing['id']) ?? [];
}
private function updateReleaseAutoSyncEvent(int $id, string $status, array $input = []): array
{
$status = self::safeIdentifier($status, 32) ?: 'pending';
$syncOperationId = $this->nullablePositiveInt($input['sync_operation_id'] ?? null);
$deploymentId = $this->nullablePositiveInt($input['deployment_id'] ?? null);
$errorMessage = isset($input['error_message']) ? substr((string)$input['error_message'], 0, 4096) : null;
$metadata = is_array($input['metadata'] ?? null) ? self::jsonEncode(self::redactPayload($input['metadata'])) : null;
$this->execute(
"UPDATE release_auto_sync_events
SET status = ?,
sync_operation_id = COALESCE(NULLIF(?, 0), sync_operation_id),
deployment_id = COALESCE(NULLIF(?, 0), deployment_id),
error_message = ?,
metadata_json = COALESCE(?, metadata_json),
synced_at = CASE WHEN ? IN ('deployed', 'promoted') THEN COALESCE(synced_at, NOW()) ELSE synced_at END,
promoted_at = CASE WHEN ? = 'promoted' THEN COALESCE(promoted_at, NOW()) ELSE promoted_at END,
failed_at = CASE WHEN ? = 'failed' THEN NOW() ELSE failed_at END,
updated_at = NOW()
WHERE id = ?",
'siisssssi',
[$status, $syncOperationId ?? 0, $deploymentId ?? 0, $errorMessage, $metadata, $status, $status, $status, $id]
);
return $this->releaseAutoSyncEventById($id) ?? [];
}
private function releaseAutoSyncEventFor(int $channelId, string $app, string $repository, string $branch, string $commitSha): ?array
{
return $this->selectOne(
"SELECT e.*, c.slug AS channel_slug, c.name AS channel_name
FROM release_auto_sync_events e
INNER JOIN release_channels c ON c.id = e.channel_id
WHERE e.channel_id = ? AND e.app = ? AND e.repository = ? AND e.branch = ? AND e.commit_sha = ?
LIMIT 1",
'issss',
[$channelId, $app, $repository, $branch, $commitSha]
);
}
private function releaseAutoSyncEventById(int $id): ?array
{
return $this->selectOne(
"SELECT e.*, c.slug AS channel_slug, c.name AS channel_name
FROM release_auto_sync_events e
INNER JOIN release_channels c ON c.id = e.channel_id
WHERE e.id = ?
LIMIT 1",
'i',
[$id]
);
}
private function publicReleaseAutoSyncEvent(array $event): array
{
return [
'id' => (int)($event['id'] ?? 0),
'channel_id' => (int)($event['channel_id'] ?? 0),
'channel_slug' => $event['channel_slug'] ?? null,
'channel_name' => $event['channel_name'] ?? null,
'app' => (string)($event['app'] ?? ''),
'repository' => (string)($event['repository'] ?? ''),
'branch' => (string)($event['branch'] ?? ''),
'commit_sha' => (string)($event['commit_sha'] ?? ''),
'status' => (string)($event['status'] ?? 'unknown'),
'source' => $event['source'] ?? null,
'workflow_url' => $event['workflow_url'] ?? null,
'gate_operation_id' => isset($event['gate_operation_id']) ? (int)$event['gate_operation_id'] : null,
'sync_operation_id' => isset($event['sync_operation_id']) ? (int)$event['sync_operation_id'] : null,
'deployment_id' => isset($event['deployment_id']) ? (int)$event['deployment_id'] : null,
'error_message' => $event['error_message'] ?? null,
'metadata' => self::jsonDecode($event['metadata_json'] ?? null),
'received_at' => $event['received_at'] ?? null,
'gate_passed_at' => $event['gate_passed_at'] ?? null,
'synced_at' => $event['synced_at'] ?? null,
'promoted_at' => $event['promoted_at'] ?? null,
'failed_at' => $event['failed_at'] ?? null,
];
}
private function acquireReleaseAutoSyncLock(int $eventId): bool
{
$lockName = 'release_auto_sync:' . $eventId;
$row = $this->selectOne('SELECT GET_LOCK(?, 0) AS acquired', 's', [$lockName]);
return (int)($row['acquired'] ?? 0) === 1;
}
private function releaseReleaseAutoSyncLock(int $eventId): void
{
try {
$this->selectOne('SELECT RELEASE_LOCK(?) AS released', 's', ['release_auto_sync:' . $eventId]);
} catch (Throwable) {
}
}
public function runIssueAction(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$issueKey = trim((string)($input['issue_key'] ?? $input['key'] ?? ''));
$actionId = self::safeIdentifier((string)($input['action_id'] ?? $input['action'] ?? ''), 64);
$actionInputs = is_array($input['inputs'] ?? null) ? $input['inputs'] : [];
$confirmed = $this->toBool($input['confirm'] ?? false);
$summary = $this->summary();
$issue = $this->releaseStatusIssueByKey($summary, $issueKey);
if ($issue === null) {
$this->audit(null, null, 'release_issue_action_attempted', $actorUserId, 'warning', [
'issue_key' => $issueKey,
'action_id' => $actionId,
'inputs' => $actionInputs,
'status' => 'stale_issue',
]);
return [
'status' => 'failed',
'message' => 'This release issue is no longer active. Refresh Release Manager and review the current state.',
'result' => null,
'summary' => $summary,
];
}
$action = $this->releaseStatusActionById($issue, $actionId);
if ($action === null) {
$this->audit(
$this->nullablePositiveInt($issue['channel_id'] ?? null),
$this->nullablePositiveInt($issue['deployment_id'] ?? null),
'release_issue_action_attempted',
$actorUserId,
'warning',
[
'issue_key' => $issueKey,
'issue' => $issue,
'action_id' => $actionId,
'inputs' => $actionInputs,
'status' => 'unavailable_action',
]
);
return [
'status' => 'failed',
'message' => 'This release issue action is no longer available.',
'issue' => $issue,
'result' => null,
'summary' => $summary,
];
}
if (trim((string)($action['disabled_reason'] ?? '')) !== '') {
$this->audit(
$this->nullablePositiveInt($issue['channel_id'] ?? null),
$this->nullablePositiveInt($issue['deployment_id'] ?? null),
'release_issue_action_attempted',
$actorUserId,
'warning',
[
'issue_key' => $issueKey,
'issue' => $issue,
'action_id' => $actionId,
'inputs' => $actionInputs,
'status' => 'disabled',
'disabled_reason' => (string)$action['disabled_reason'],
]
);
return [
'status' => 'needs_input',
'message' => (string)$action['disabled_reason'],
'issue' => $issue,
'action' => $action,
'result' => null,
'summary' => $summary,
];
}
if (($action['requires_confirmation'] ?? false) && !$confirmed) {
$this->audit(
$this->nullablePositiveInt($issue['channel_id'] ?? null),
$this->nullablePositiveInt($issue['deployment_id'] ?? null),
'release_issue_action_attempted',
$actorUserId,
'warning',
[
'issue_key' => $issueKey,
'issue' => $issue,
'action_id' => $actionId,
'inputs' => $actionInputs,
'status' => 'confirmation_required',
]
);
return [
'status' => 'needs_input',
'message' => 'Confirm this release issue action before Release Manager changes deployment state.',
'issue' => $issue,
'action' => $action,
'result' => null,
'summary' => $summary,
];
}
$this->audit(
$this->nullablePositiveInt($issue['channel_id'] ?? null),
$this->nullablePositiveInt($issue['deployment_id'] ?? null),
'release_issue_action_attempted',
$actorUserId,
'info',
[
'issue_key' => $issueKey,
'issue' => $issue,
'action_id' => $actionId,
'inputs' => $actionInputs,
]
);
try {
$result = $this->executeReleaseIssueAction($issue, $actionId, $actionInputs, $actorUserId);
$status = (string)($result['status'] ?? 'completed');
$message = (string)($result['message'] ?? 'Release issue action completed.');
$this->audit(
$this->nullablePositiveInt($issue['channel_id'] ?? null),
$this->nullablePositiveInt($issue['deployment_id'] ?? null),
'release_issue_action_completed',
$actorUserId,
$status === 'failed' ? 'error' : 'info',
[
'issue_key' => $issueKey,
'action_id' => $actionId,
'status' => $status,
'result' => $result['result'] ?? null,
]
);
return [
'status' => $status,
'message' => $message,
'issue' => $issue,
'action' => $action,
'result' => $result['result'] ?? null,
'summary' => $this->summary(),
];
} catch (Throwable $throwable) {
$this->audit(
$this->nullablePositiveInt($issue['channel_id'] ?? null),
$this->nullablePositiveInt($issue['deployment_id'] ?? null),
'release_issue_action_failed',
$actorUserId,
'error',
[
'issue_key' => $issueKey,
'action_id' => $actionId,
'error' => $throwable->getMessage(),
]
);
return [
'status' => 'failed',
'message' => $throwable->getMessage(),
'issue' => $issue,
'action' => $action,
'result' => null,
'summary' => $this->summary(),
];
}
}
private function executeReleaseIssueAction(array $issue, string $actionId, array $inputs, ?int $actorUserId): array
{
return match ($actionId) {
'retry_deployment' => $this->retryReleaseIssueDeployment($issue, $inputs, $actorUserId),
'deploy_missing_version' => $this->deployReleaseIssueMissingVersion($issue, $inputs, $actorUserId),
'set_bundle' => $this->setReleaseIssueBundle($issue, $inputs, $actorUserId),
'complete_data_services' => $this->completeReleaseIssueDataServices($issue, $inputs, $actorUserId),
'reconcile_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'reconcile', $actorUserId),
'redeploy_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'deploy', $actorUserId),
'restart_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'restart', $actorUserId),
'prepare_application_target' => $this->prepareReleaseIssueApplicationTarget($issue, $actorUserId),
'refresh_status' => [
'status' => 'completed',
'message' => 'Release status refreshed.',
'result' => null,
],
default => throw new RuntimeException('Unknown release issue action.'),
};
}
private function retryReleaseIssueDeployment(array $issue, array $inputs, ?int $actorUserId): array
{
$deploymentId = $this->nullablePositiveInt($issue['deployment_id'] ?? null);
if ($deploymentId === null) {
throw new RuntimeException('The failed deployment record is missing.');
}
$deployment = $this->getDeployment($deploymentId);
$payload = self::jsonDecode($deployment['requested_payload_json'] ?? null);
$payload = is_array($payload) ? $payload : [];
foreach (['version_id'] as $key) {
if (array_key_exists($key, $inputs)) {
$payload[$key] = $inputs[$key];
}
}
$payload = array_replace($payload, [
'channel_id' => (int)$deployment['channel_id'],
'target_id' => $this->nullablePositiveInt($deployment['target_id'] ?? null),
'app' => (string)$deployment['app'],
'repository' => (string)($deployment['repository'] ?? ''),
'branch' => (string)($deployment['branch'] ?? self::DEFAULT_BRANCH),
'commit_mode' => trim((string)($deployment['commit_sha'] ?? '')) !== '' ? 'specific' : 'latest',
'commit_sha' => (string)($deployment['commit_sha'] ?? ''),
'service_set_id' => $this->nullablePositiveInt($deployment['service_set_id'] ?? null),
'bundle_id' => $this->nullablePositiveInt($deployment['bundle_id'] ?? null),
'deployment_kind' => (string)($deployment['deployment_kind'] ?? 'single_app'),
]);
$newDeployment = $this->startDeployment($payload, $actorUserId);
$status = strtolower((string)($newDeployment['status'] ?? ''));
return [
'status' => $status === 'failed' ? 'failed' : (in_array($status, ['queued', 'deploying'], true) ? 'queued' : 'completed'),
'message' => $status === 'failed' ? 'Deployment retry failed.' : 'Deployment retry started.',
'result' => ['deployment' => $newDeployment],
];
}
private function deployReleaseIssueMissingVersion(array $issue, array $inputs, ?int $actorUserId): array
{
$targetId = $this->nullablePositiveInt($inputs['target_id'] ?? $issue['target_id'] ?? null);
if ($targetId === null) {
return [
'status' => 'needs_input',
'message' => 'Select or create a deployment target before deploying the missing version.',
'result' => [
'required_inputs' => ['target_id'],
'channel_id' => $issue['channel_id'] ?? null,
'app' => $issue['service_key'] ?? null,
],
];
}
$target = $this->getDeploymentTarget($targetId);
$deployment = $this->startDeployment([
'channel_id' => (int)$target['channel_id'],
'target_id' => $targetId,
'app' => (string)$target['app'],
'repository' => (string)$target['repository'],
'branch' => (string)$target['branch'],
'commit_mode' => (string)($inputs['commit_mode'] ?? 'latest'),
'commit_sha' => (string)($inputs['commit_sha'] ?? ''),
'version_label' => (string)($inputs['version_label'] ?? ''),
], $actorUserId);
$status = strtolower((string)($deployment['status'] ?? ''));
return [
'status' => $status === 'failed' ? 'failed' : (in_array($status, ['queued', 'deploying'], true) ? 'queued' : 'completed'),
'message' => $status === 'failed' ? 'Missing version deployment failed.' : 'Missing version deployment started.',
'result' => ['deployment' => $deployment],
];
}
private function setReleaseIssueBundle(array $issue, array $inputs, ?int $actorUserId): array
{
$channelId = $this->nullablePositiveInt($issue['channel_id'] ?? null);
if ($channelId === null) {
throw new RuntimeException('Release channel is missing.');
}
$bundleId = $this->nullablePositiveInt($inputs['bundle_id'] ?? null);
if ($bundleId === null) {
$eligible = self::releaseStatusEligibleBundles(array_filter(
$this->listBundles(250),
static fn(array $bundle): bool => (int)($bundle['channel_id'] ?? 0) === $channelId
));
if (count($eligible) !== 1) {
return [
'status' => 'needs_input',
'message' => $eligible === [] ? 'No deployed bundle is available for this channel.' : 'Choose which deployed bundle to set.',
'result' => [
'required_inputs' => ['bundle_id'],
'bundle_choices' => $eligible,
],
];
}
$bundleId = (int)$eligible[0]['id'];
}
return [
'status' => 'completed',
'message' => 'Release bundle set for channel.',
'result' => $this->setChannelBundle($channelId, ['bundle_id' => $bundleId], $actorUserId),
];
}
private function completeReleaseIssueDataServices(array $issue, array $inputs, ?int $actorUserId): array
{
$serviceSetId = $this->nullablePositiveInt($inputs['service_set_id'] ?? $issue['service_set_id'] ?? null);
if ($serviceSetId === null) {
return [
'status' => 'needs_input',
'message' => 'Select the isolated service set before creating missing data services.',
'result' => ['required_inputs' => ['service_set_id']],
];
}
return [
'status' => 'queued',
'message' => 'Missing isolated data services were requested.',
'result' => $this->completeIsolatedStackDataServices($serviceSetId, ['deploy_data_targets' => true], $actorUserId),
];
}
private function runReleaseIssueCoolifyTargetAction(array $issue, string $operation, ?int $actorUserId): array
{
$targetId = $this->nullablePositiveInt($issue['coolify_target_id'] ?? null);
if ($targetId === null) {
throw new RuntimeException('Coolify target is missing.');
}
if (!class_exists(coolify_manager::class) && function_exists('app_require')) {
app_require('classes/coolify_manager.php');
}
if (!class_exists(coolify_manager::class)) {
throw new RuntimeException('Coolify manager is not available.');
}
$manager = new coolify_manager();
$result = match ($operation) {
'reconcile' => $manager->reconcileTarget($targetId, $actorUserId),
'deploy' => $manager->deployTarget($targetId, $actorUserId),
'restart' => $manager->restartTarget($targetId, $actorUserId),
default => throw new RuntimeException('Unknown Coolify target action.'),
};
return [
'status' => 'queued',
'message' => 'Coolify target action requested.',
'result' => $result,
];
}
private function prepareReleaseIssueApplicationTarget(array $issue, ?int $actorUserId): array
{
$targetId = $this->nullablePositiveInt($issue['target_id'] ?? null);
if ($targetId === null) {
throw new RuntimeException('Release deployment target is missing.');
}
return $this->prepareDeploymentTargetAsApplication($targetId, $actorUserId, 'warning');
}
private function prepareDeploymentTargetAsApplication(int $targetId, ?int $actorUserId, string $severity = 'warning'): array
{
$target = $this->getDeploymentTarget($targetId);
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
$context = is_array($context) ? $context : [];
$context['coolify_resource_type'] = 'application';
$context['coolify_auto_create'] = true;
$context['coolify_enable_ssl'] = $this->toBool($context['coolify_enable_ssl'] ?? true);
$replacedLegacyUuid = trim((string)($target['coolify_service_uuid'] ?? '')) !== '';
$this->execute(
'UPDATE release_deployment_targets SET coolify_service_uuid = NULL, deploy_context_json = ? WHERE id = ?',
'si',
[self::jsonEncode($context), $targetId]
);
$this->audit((int)$target['channel_id'], null, 'deployment_target_prepared_as_application', $actorUserId, $severity, [
'target_id' => $targetId,
'replaced_legacy_service_uuid' => $replacedLegacyUuid,
'severity' => $severity,
]);
return [
'status' => 'completed',
'message' => 'Deployment target will create a Coolify application on the next deployment.',
'result' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)),
];
}
private function prepareChannelSyncApplicationTarget(array $target, ?int $actorUserId): array
{
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
$context = is_array($context) ? $context : [];
if (!$this->releaseTargetNeedsApplicationAutoCreate($target, $context)) {
return $target;
}
$this->prepareDeploymentTargetAsApplication((int)$target['id'], $actorUserId, 'info');
$prepared = $this->getDeploymentTarget((int)$target['id']);
$prepared['_release_auto_prepared_application'] = true;
return $prepared;
}
private function releaseStatusIssueByKey(array $summary, string $issueKey): ?array
{
foreach (is_array($summary['status_overview']['issues'] ?? null) ? $summary['status_overview']['issues'] : [] as $issue) {
if (is_array($issue) && (string)($issue['key'] ?? '') === $issueKey) {
return $issue;
}
}
return null;
}
private function releaseStatusActionById(array $issue, string $actionId): ?array
{
foreach (is_array($issue['actions'] ?? null) ? $issue['actions'] : [] as $action) {
if (is_array($action) && (string)($action['id'] ?? '') === $actionId) {
return $action;
}
}
return null;
}
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'] : []
);
$bundlesByChannel = $this->releaseStatusBundlesByChannel(
is_array($summary['bundles'] ?? null) ? $summary['bundles'] : []
);
$productionServiceChannel = $this->releaseStatusProductionServiceChannel($channels);
$channelRows = [];
$issues = [];
foreach ($channels as $channel) {
$row = $this->releaseStatusChannelRow(
$channel,
$productionServiceChannel,
$targetsByChannelApp,
$deployments,
$deploymentsByChannelApp,
$serviceSetsByChannel,
$bundlesByChannel
);
$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 $productionServiceChannel,
array $targetsByChannelApp,
array $deployments,
array $deploymentsByChannelApp,
array $serviceSetsByChannel,
array $bundlesByChannel
): array {
$channelId = (int)($channel['id'] ?? 0);
$channelSlug = (string)($channel['slug'] ?? '');
$channelName = (string)($channel['name'] ?? $channelSlug);
$channel = $this->releaseStatusChannelWithProductionServices($channel, $productionServiceChannel, $targetsByChannelApp);
$channelWithTargetEndpoints = $this->releaseStatusChannelWithTargetEndpoints($channel, $targetsByChannelApp);
$availability = $this->releaseStatusChannelAvailability($channelWithTargetEndpoints);
$services = $this->releaseStatusServicesForChannel(
$channelWithTargetEndpoints,
$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,
'service_set_id' => $service['service_set_id'] ?? null,
]);
}
$issues = array_map(
fn(array $issue): array => $this->releaseStatusIssueWithActions(
$issue,
$channel,
$services,
$bundlesByChannel[$channelId] ?? []
),
$issues
);
$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)($channelWithTargetEndpoints['default_channel'] ?? false),
'enabled' => (bool)($channelWithTargetEndpoints['enabled'] ?? true),
'service_policy' => (string)($channelWithTargetEndpoints['service_policy'] ?? 'channel'),
'service_channel_id' => $channelWithTargetEndpoints['_service_channel_id'] ?? $channelId,
'service_channel_slug' => $channelWithTargetEndpoints['_service_channel_slug'] ?? $channelSlug,
'severity' => $severity,
'readiness' => $readiness,
'message' => self::releaseStatusChannelMessage($readiness, count($issues)),
'availability' => $availability,
'missing_values' => $missingValues,
'services' => $services,
'versions' => is_array($channelWithTargetEndpoints['versions'] ?? null) ? $channelWithTargetEndpoints['versions'] : [],
'replay' => [
'enabled' => (bool)($channelWithTargetEndpoints['replay_enabled'] ?? false),
'capture_level' => (string)($channelWithTargetEndpoints['capture_level'] ?? 'metadata'),
],
'latest_deployments' => $latestDeployments,
'issues' => $issues,
];
}
private function releaseStatusProductionServiceChannel(array $channels): ?array
{
$normalized = array_values(array_filter($channels, static fn(mixed $channel): bool => is_array($channel)));
foreach ($normalized as $channel) {
if (
((bool)($channel['default_channel'] ?? false) || (int)($channel['default_channel'] ?? 0) === 1)
&& !$this->channelUsesProductionServices($channel)
) {
return $channel;
}
}
foreach (self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS as $slug) {
foreach ($normalized as $channel) {
if (
self::safeSlug((string)($channel['slug'] ?? '')) === $slug
&& !$this->channelUsesProductionServices($channel)
) {
return $channel;
}
}
}
foreach ($normalized as $channel) {
if (!$this->channelUsesProductionServices($channel)) {
return $channel;
}
}
return null;
}
private function releaseStatusChannelWithProductionServices(
array $channel,
?array $productionServiceChannel,
array $targetsByChannelApp
): array {
if (!$this->channelUsesProductionServices($channel) || $productionServiceChannel === null) {
return $channel;
}
$source = $this->releaseStatusChannelWithTargetEndpoints($productionServiceChannel, $targetsByChannelApp);
$channel['_uses_production_services'] = true;
$channel['_service_channel_id'] = (int)($source['id'] ?? 0);
$channel['_service_channel_slug'] = (string)($source['slug'] ?? '');
$channel['service_policy'] = self::PRODUCTION_SERVICE_POLICY;
$channel['versions'] = is_array($source['versions'] ?? null) ? $source['versions'] : [];
foreach (['frontend_base_url', 'api_base_url'] as $field) {
if (!empty($source[$field])) {
$channel[$field] = $source[$field];
}
}
return $channel;
}
private function releaseStatusChannelWithTargetEndpoints(array $channel, array $targetsByChannelApp): array
{
$channelId = (int)($channel['id'] ?? 0);
if ($channelId <= 0) {
return $channel;
}
foreach (self::APPS as $app) {
$field = $app === 'frontend' ? 'frontend_base_url' : 'api_base_url';
if (!empty($channel[$field])) {
continue;
}
$target = $targetsByChannelApp[$channelId . ':' . $app] ?? null;
if (!is_array($target)) {
continue;
}
$endpointUrl = is_array($target['endpoint'] ?? null)
? $this->normalizeReleasePublicBaseUrl($target['endpoint']['url'] ?? null, $app)
: null;
$endpointUrl ??= $this->releaseTargetPublicBaseUrl($target + [
'channel_slug' => $channel['slug'] ?? $target['channel_slug'] ?? '',
]);
if ($endpointUrl !== null) {
$channel[$field] = $endpointUrl;
}
}
return $channel;
}
private function releaseStatusServicesForChannel(
array $channel,
array $availability,
array $targetsByChannelApp,
array $deploymentsByChannelApp,
array $serviceSetsByChannel
): array {
$channelId = (int)($channel['id'] ?? 0);
$appServiceChannelId = (int)($channel['_service_channel_id'] ?? $channelId);
$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 = $appServiceChannelId . ':' . $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,
]);
}
if (($channel['_uses_production_services'] ?? false) === true) {
return array_replace($row, [
'status' => self::PRODUCTION_SERVICE_POLICY,
'service_policy' => self::PRODUCTION_SERVICE_POLICY,
'service_channel_slug' => (string)($channel['_service_channel_slug'] ?? ''),
'message' => $serviceLabel . ' uses the production service for this channel.',
]);
}
$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'] ?? '');
$dataPolicy = $this->serviceSetDataPolicy($serviceSet);
$usesSharedProduction = $dataPolicy === self::PRODUCTION_DATA_POLICY;
$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' => $usesSharedProduction ? 'production_shared' : 'ready',
'data_policy' => $dataPolicy,
'data_service_mode' => $dataPolicy,
'state' => 'ready',
'severity' => 'ok',
'message' => $usesSharedProduction
? $serviceLabel . ' uses the production-shared service and is not replaced by channel sync.'
: $serviceLabel . ' release service is ready.',
'next_action' => '',
'target_tab' => 'data-services',
'service_set_id' => isset($serviceSet['id']) ? (int)$serviceSet['id'] : null,
'coolify_target_id' => $service['id'] ?? null,
'resource_uuid' => $service['resource_uuid'] ?? null,
'resource_name' => $service['resource_name'] ?? $service['label'] ?? null,
'issue_type' => null,
];
if ($usesSharedProduction) {
return $row;
}
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 releaseStatusBundlesByChannel(array $bundles): array
{
$indexed = [];
foreach ($bundles as $bundle) {
if (!is_array($bundle)) {
continue;
}
$channelId = (int)($bundle['channel_id'] ?? 0);
if ($channelId > 0) {
$indexed[$channelId][] = $bundle;
}
}
return $indexed;
}
private function releaseStatusChannelAvailability(array $channel): array
{
if (($channel['_uses_production_services'] ?? false) !== true && is_array($channel['availability'] ?? null)) {
$availability = $channel['availability'];
$missing = self::releaseStatusReadinessMissingValues(
is_array($availability['missing'] ?? null) ? $availability['missing'] : []
);
$status = (string)($availability['status'] ?? '');
if ($status === '' || ($missing === [] && in_array($status, ['unconfigured', 'missing_target'], true))) {
$status = $missing === [] ? 'ready' : 'unconfigured';
}
return [
'configured' => $missing === []
? true
: (($availability['configured'] ?? null) === null ? false : (bool)$availability['configured']),
'missing' => $missing,
'status' => $status,
'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['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 releaseStatusReadinessMissingValues(array $missing): array
{
$normalized = [];
foreach ($missing as $key) {
$value = trim((string)$key);
if ($value === '' || $value === 'release_bundle') {
continue;
}
$normalized[] = $value;
}
return array_values(array_unique($normalized));
}
private static function releaseStatusIssue(array $issue): array
{
$normalized = [
'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,
'service_set_id' => $issue['service_set_id'] ?? null,
'missing_key' => $issue['missing_key'] ?? null,
'impact' => (string)($issue['impact'] ?? ''),
'resolution_state' => (string)($issue['resolution_state'] ?? 'open'),
'actions' => is_array($issue['actions'] ?? null) ? $issue['actions'] : [],
];
$normalized['key'] = (string)($issue['key'] ?? self::releaseStatusIssueKey($normalized));
if ($normalized['impact'] === '') {
$normalized['impact'] = self::releaseStatusIssueImpact($normalized);
}
return $normalized;
}
private function releaseStatusIssueWithActions(array $issue, array $channel, array $services, array $channelBundles): array
{
$issue = self::releaseStatusIssue($issue);
$service = null;
foreach ($services as $candidate) {
if ((string)($candidate['service_key'] ?? '') === (string)($issue['service_key'] ?? '')) {
$service = $candidate;
break;
}
}
if (($issue['target_id'] ?? null) === null && isset($service['target_id'])) {
$issue['target_id'] = $service['target_id'];
}
if (($issue['deployment_id'] ?? null) === null && isset($service['deployment_id'])) {
$issue['deployment_id'] = $service['deployment_id'];
}
if (($issue['coolify_target_id'] ?? null) === null && isset($service['coolify_target_id'])) {
$issue['coolify_target_id'] = $service['coolify_target_id'];
}
if (($issue['service_set_id'] ?? null) === null && isset($service['service_set_id'])) {
$issue['service_set_id'] = $service['service_set_id'];
}
$issue['key'] = self::releaseStatusIssueKey($issue);
$issue['impact'] = $issue['impact'] !== '' ? $issue['impact'] : self::releaseStatusIssueImpact($issue);
$issue['resolution_state'] = self::releaseStatusResolutionState($issue);
$issue['actions'] = $this->releaseStatusIssueActions($issue, $channel, $service, $channelBundles);
return $issue;
}
private function releaseStatusIssueActions(array $issue, array $channel, ?array $service, array $channelBundles): array
{
$type = (string)($issue['type'] ?? '');
$missingKey = (string)($issue['missing_key'] ?? '');
$serviceKey = (string)($issue['service_key'] ?? '');
$actions = [];
if ($type === 'failed_deployment') {
$actions[] = self::releaseStatusAction(
'retry_deployment',
'Retry deployment',
'mutation',
true,
empty($issue['deployment_id']),
empty($issue['deployment_id']) ? 'The failed deployment record is missing.' : ''
);
if (self::releaseStatusIssueNeedsApplicationTarget($issue)) {
$actions[] = self::releaseStatusAction(
'prepare_application_target',
'Prepare application target',
'mutation',
true,
empty($issue['target_id']),
empty($issue['target_id']) ? 'The deployment target is missing.' : ''
);
}
}
if ($type === 'missing_value' && in_array($missingKey, ['frontend_version', 'api_version'], true)) {
$actions[] = self::releaseStatusAction(
'deploy_missing_version',
'Deploy missing version',
'mutation',
true,
empty($issue['target_id']),
empty($issue['target_id']) ? 'Select or create a deployment target first.' : ''
);
}
if ($type === 'missing_value' && $missingKey === 'release_bundle') {
$eligibleBundles = self::releaseStatusEligibleBundles($channelBundles);
$actions[] = self::releaseStatusAction(
'set_bundle',
count($eligibleBundles) === 1 ? 'Set available bundle' : 'Choose release bundle',
'mutation',
true,
count($eligibleBundles) !== 1,
$eligibleBundles === [] ? 'No deployed bundle is available for this channel.' : '',
['bundle_choices' => $eligibleBundles]
);
}
if ($type === 'missing_value' && in_array($missingKey, ['database_service', 'redis_service', 'minio_service'], true)) {
$actions[] = self::releaseStatusAction(
'complete_data_services',
'Create missing data services',
'mutation',
true,
empty($issue['service_set_id']),
empty($issue['service_set_id']) ? 'The isolated service set is missing.' : ''
);
}
if ($type === 'service_unhealthy' && in_array($serviceKey, self::STACK_DATA_KINDS, true)) {
foreach ([
'reconcile_coolify_target' => 'Reconcile target',
'redeploy_coolify_target' => 'Redeploy target',
'restart_coolify_target' => 'Restart target',
] as $id => $label) {
$actions[] = self::releaseStatusAction(
$id,
$label,
'mutation',
true,
empty($issue['coolify_target_id']),
empty($issue['coolify_target_id']) ? 'The Coolify target is missing.' : ''
);
}
}
if ($type === 'deployment_in_progress') {
$actions[] = self::releaseStatusAction('refresh_status', 'Refresh status', 'refresh', false, false);
}
return $actions;
}
private static function releaseStatusAction(
string $id,
string $label,
string $kind,
bool $requiresConfirmation,
bool $requiresInput,
string $disabledReason = '',
array $extra = []
): array {
return array_replace([
'id' => $id,
'label' => $label,
'kind' => $kind,
'requires_confirmation' => $requiresConfirmation,
'requires_input' => $requiresInput,
'disabled_reason' => $disabledReason,
'permission' => 'superuser_release_manager_deploy',
], $extra);
}
private static function releaseStatusIssueKey(array $issue): string
{
return implode(':', [
self::safeIdentifier((string)($issue['type'] ?? 'unknown'), 32) ?: 'unknown',
self::safeIdentifier((string)($issue['channel_id'] ?? $issue['channel_slug'] ?? ''), 64),
self::safeIdentifier((string)($issue['service_key'] ?? ''), 32),
self::safeIdentifier((string)($issue['missing_key'] ?? ''), 64),
self::safeIdentifier((string)($issue['deployment_id'] ?? ''), 64),
self::safeIdentifier((string)($issue['coolify_target_id'] ?? ''), 64),
]);
}
private static function releaseStatusIssueImpact(array $issue): string
{
return match ((string)($issue['type'] ?? '')) {
'failed_deployment' => 'This channel cannot be promoted until the failed deployment is replaced by a successful one.',
'missing_value' => 'This channel is incomplete and cannot receive traffic safely.',
'service_unhealthy' => 'This channel has an unhealthy runtime service and should not be promoted.',
'deployment_in_progress' => 'Promotion should wait until the deployment or provisioning job finishes.',
default => 'Review this release issue before publishing or promoting the channel.',
};
}
private static function releaseStatusResolutionState(array $issue): string
{
if ((string)($issue['severity'] ?? '') === 'critical') {
return 'blocked';
}
if ((string)($issue['severity'] ?? '') === 'warning') {
return 'action_available';
}
return 'open';
}
private static function releaseStatusIssueNeedsApplicationTarget(array $issue): bool
{
$text = strtolower(trim((string)($issue['message'] ?? '') . ' ' . (string)($issue['next_action'] ?? '')));
return str_contains($text, 'stripprefix')
|| str_contains($text, 'path-routed')
|| str_contains($text, 'service creation')
|| str_contains($text, 'coolify service');
}
private static function releaseStatusEligibleBundles(array $bundles): array
{
$eligible = [];
foreach ($bundles as $bundle) {
$status = strtolower((string)($bundle['status'] ?? ''));
if (!in_array($status, ['deployed', 'promoted', 'active'], true)) {
continue;
}
$eligible[] = [
'id' => (int)($bundle['id'] ?? 0),
'label' => (string)($bundle['version_label'] ?? ('Bundle #' . (int)($bundle['id'] ?? 0))),
'status' => (string)($bundle['status'] ?? ''),
];
}
return $eligible;
}
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();
$storedToken = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_token', ''));
$storedWebhookSecret = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_webhook_secret', ''));
return [
'github_token_configured' => $this->hasGithubApiToken(),
'github_token_env_configured' => $this->githubEnvToken() !== '',
'github_token_module_configured' => $storedToken !== '',
'github_token_variable' => 'ReleaseManager.github_token',
'github_token_env_variable' => 'RELEASE_MANAGER_GITHUB_TOKEN',
'github_api_url' => $this->githubApiBaseUrl(),
'github_api_url_variable' => 'ReleaseManager.github_api_url',
'github_webhook_secret_configured' => $storedWebhookSecret !== '',
'github_webhook_secret_variable' => 'ReleaseManager.github_webhook_secret',
];
}
public function updateReleaseConfig(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$updated = [];
if (array_key_exists('github_api_url', $input)) {
$apiUrl = rtrim(trim((string)$input['github_api_url']), '/');
if ($apiUrl === '') {
$apiUrl = 'https://api.github.com';
}
if (preg_match('#^https?://#i', $apiUrl) !== 1) {
throw new RuntimeException('GitHub API URL must start with http:// or https://.');
}
$this->upsertModuleConfigValue('ReleaseManager', 'github_api_url', $apiUrl, 'string');
$updated[] = 'github_api_url';
}
if (array_key_exists('github_token', $input)) {
$token = trim((string)$input['github_token']);
if ($token !== '' && $token !== '[redacted]' && !str_starts_with($token, 'twsec:v1:') && class_exists(replication_secret_box::class)) {
$token = replication_secret_box::encrypt($token);
}
if ($token !== '' && $token !== '[redacted]') {
$this->upsertModuleConfigValue('ReleaseManager', 'github_token', $token, 'string');
$updated[] = 'github_token';
}
}
if ($this->toBool($input['clear_github_token'] ?? false)) {
$this->upsertModuleConfigValue('ReleaseManager', 'github_token', '', 'string');
$updated[] = 'github_token';
}
if (array_key_exists('github_webhook_secret', $input)) {
$secret = trim((string)$input['github_webhook_secret']);
if ($secret !== '' && $secret !== '[redacted]') {
$this->upsertModuleConfigValue('ReleaseManager', 'github_webhook_secret', $secret, 'string');
$updated[] = 'github_webhook_secret';
}
}
$this->audit(null, null, 'release_config_updated', $actorUserId, 'info', [
'updated' => array_values(array_unique($updated)),
]);
return $this->releaseConfig();
}
public function listGithubRepositories(array $filters = []): array
{
$this->ensureSchema();
if (!$this->hasGithubApiToken()) {
return $this->githubTokenMissingResponse();
}
$query = strtolower(trim((string)($filters['query'] ?? $filters['search'] ?? '')));
$repositories = [];
for ($page = 1; $page <= 5; $page++) {
$rows = $this->githubRequest('GET', '/user/repos', [
'visibility' => 'all',
'affiliation' => 'owner,collaborator,organization_member',
'sort' => 'updated',
'direction' => 'desc',
'per_page' => 100,
'page' => $page,
]);
if (!is_array($rows)) {
break;
}
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$repository = $this->publicGithubRepository($row);
if ($query !== '') {
$haystack = strtolower(($repository['full_name'] ?? '') . ' ' . ($repository['description'] ?? ''));
if (!str_contains($haystack, $query)) {
continue;
}
}
$repositories[$repository['full_name']] = $repository;
}
if (count($rows) < 100) {
break;
}
}
return [
'token_configured' => true,
'github_api_url' => $this->githubApiBaseUrl(),
'repositories' => array_values($repositories),
];
}
public function listGithubBranches(array $input): array
{
$this->ensureSchema();
$repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? ''));
if ($repository === '') {
throw new RuntimeException('GitHub repository must use owner/repo format.');
}
if (!$this->hasGithubApiToken()) {
return $this->githubTokenMissingResponse($repository);
}
$branches = [];
for ($page = 1; $page <= 5; $page++) {
$rows = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/branches', [
'per_page' => 100,
'page' => $page,
]);
if (!is_array($rows)) {
break;
}
foreach ($rows as $row) {
if (is_array($row)) {
$branch = $this->publicGithubBranch($row);
$branches[$branch['name']] = $branch;
}
}
if (count($rows) < 100) {
break;
}
}
return [
'token_configured' => true,
'repository' => $repository,
'branches' => array_values($branches),
];
}
public function listGithubCommits(array $input): array
{
$this->ensureSchema();
$repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? ''));
if ($repository === '') {
throw new RuntimeException('GitHub repository must use owner/repo format.');
}
if (!$this->hasGithubApiToken()) {
return $this->githubTokenMissingResponse($repository, (string)($input['branch'] ?? ''));
}
$branch = trim((string)($input['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
$commit = trim((string)($input['commit_sha'] ?? $input['commit'] ?? ''));
$query = strtolower(trim((string)($input['query'] ?? $input['search'] ?? '')));
if ($commit !== '' && !in_array(strtolower($commit), ['latest', 'head'], true)) {
$row = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($commit));
$publicCommit = is_array($row) ? $this->publicGithubCommit($row) : [];
return [
'token_configured' => true,
'repository' => $repository,
'branch' => $branch,
'commits' => $publicCommit !== [] ? [$publicCommit] : [],
'latest' => $publicCommit !== [] ? $publicCommit : null,
];
}
$rows = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits', [
'sha' => $branch,
'per_page' => 25,
]);
$commits = [];
foreach (is_array($rows) ? $rows : [] as $row) {
if (!is_array($row)) {
continue;
}
$publicCommit = $this->publicGithubCommit($row);
if ($query !== '') {
$haystack = strtolower(($publicCommit['sha'] ?? '') . ' ' . ($publicCommit['message'] ?? '') . ' ' . ($publicCommit['author_name'] ?? ''));
if (!str_contains($haystack, $query)) {
continue;
}
}
$commits[] = $publicCommit;
}
return [
'token_configured' => true,
'repository' => $repository,
'branch' => $branch,
'commits' => $commits,
'latest' => $commits[0] ?? null,
];
}
public function testGithubRepositoryAccess(array $input): array
{
$this->ensureSchema();
return $this->githubRepositoryAccess($input);
}
public function listChannels(): array
{
if (!release_manager_schema_bootstrap::tablesExist()) {
return [];
}
$channels = $this->selectRows(
"SELECT * FROM release_channels WHERE deleted_at IS NULL ORDER BY default_channel DESC, slug"
);
return array_map(function (array $channel): array {
$public = $this->publicChannel($channel);
$serviceChannel = $this->runtimeServiceChannelFor($channel);
$public['service_channel'] = $this->publicChannel($serviceChannel);
$public['service_policy'] = $this->channelUsesProductionServices($channel)
? self::PRODUCTION_SERVICE_POLICY
: 'channel';
$public['versions'] = $this->currentVersionsForChannel((int)$serviceChannel['id']);
$public['current_deployments'] = $this->channelCurrentDeployments((int)$serviceChannel['id']);
$public['branch_status'] = $this->channelBranchStatus($channel);
$public['data_services'] = $this->channelDataServicesSummary($channel);
$public['replication_policy'] = $public['data_services']['policy'] ?? $this->replicationPolicyForMode('production_shared');
return $public;
}, $channels);
}
public function createChannel(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$normalized = $this->normalizeChannelInput($input, true);
$this->execute(
"INSERT INTO release_channels (
slug, name, description, enabled, default_channel, rollout_percent,
frontend_base_url, api_base_url, replay_enabled, capture_level, retention_days, metadata_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
'sssiidssisis',
[
$normalized['slug'],
$normalized['name'],
$normalized['description'],
$normalized['enabled'],
$normalized['default_channel'],
$normalized['rollout_percent'],
$normalized['frontend_base_url'],
$normalized['api_base_url'],
$normalized['replay_enabled'],
$normalized['capture_level'],
$normalized['retention_days'],
self::jsonEncode($normalized['metadata']),
]
);
$id = $this->insertId();
if ($normalized['default_channel'] === 1) {
$this->clearOtherDefaultChannels($id);
}
$this->audit($id, null, 'channel_created', $actorUserId, 'info', $normalized);
return $this->publicChannel($this->getChannel($id));
}
public function updateChannel(int $id, array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$channel = $this->getChannel($id);
$normalized = $this->normalizeChannelInput(array_replace($channel, $input), false);
$this->execute(
"UPDATE release_channels
SET slug = ?, name = ?, description = ?, enabled = ?, default_channel = ?, rollout_percent = ?,
frontend_base_url = ?, api_base_url = ?, replay_enabled = ?, capture_level = ?,
retention_days = ?, metadata_json = ?
WHERE id = ?",
'sssiidssisisi',
[
$normalized['slug'],
$normalized['name'],
$normalized['description'],
$normalized['enabled'],
$normalized['default_channel'],
$normalized['rollout_percent'],
$normalized['frontend_base_url'],
$normalized['api_base_url'],
$normalized['replay_enabled'],
$normalized['capture_level'],
$normalized['retention_days'],
self::jsonEncode($normalized['metadata']),
$id,
]
);
if ($normalized['default_channel'] === 1) {
$this->clearOtherDefaultChannels($id);
}
$this->audit($id, null, 'channel_updated', $actorUserId, 'info', $normalized);
return $this->publicChannel($this->getChannel($id));
}
public function listAssignments(): array
{
if (!release_manager_schema_bootstrap::tablesExist()) {
return [];
}
return array_map(
fn(array $row): array => $this->publicAssignment($row),
$this->selectRows(
"SELECT a.*, c.slug AS channel_slug, c.name AS channel_name
FROM release_assignments a
INNER JOIN release_channels c ON c.id = a.channel_id
WHERE a.deleted_at IS NULL AND (a.expires_at IS NULL OR a.expires_at > NOW())
ORDER BY a.created_at DESC
LIMIT 250"
)
);
}
public function searchAssignmentSubjects(array $input): array
{
$query = self::normalizeAssignmentSubjectSearch($input['search'] ?? $input['query'] ?? '');
if ($query === '') {
return [];
}
$limit = self::normalizeAssignmentSubjectLimit($input['limit'] ?? 5);
$subjects = array_merge(
$this->searchAssignmentUsers($query, $limit),
$this->searchAssignmentSubusers($query, $limit),
$this->searchAssignmentCustomers($query, $limit)
);
$seen = [];
$normalized = [];
foreach ($subjects as $subject) {
$item = self::publicAssignmentSubjectSuggestion($subject);
if ($item === null) {
continue;
}
$key = $item['subject_type'] . ':' . $item['subject_id'];
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$normalized[] = $item;
}
return $normalized;
}
public function createAssignment(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$subjectType = strtolower(trim((string)($input['subject_type'] ?? '')));
if (!in_array($subjectType, self::SUBJECT_TYPES, true)) {
throw new RuntimeException('Invalid release assignment subject type.');
}
$subjectId = trim((string)($input['subject_id'] ?? ''));
if ($subjectId === '') {
throw new RuntimeException('Release assignment subject_id is required.');
}
$channel = $this->channelFromInput($input);
$reason = trim((string)($input['reason'] ?? ''));
$expiresAt = $this->normalizeDateTime($input['expires_at'] ?? null);
$this->execute(
"INSERT INTO release_assignments (subject_type, subject_id, channel_id, reason, expires_at, actor_user_id)
VALUES (?, ?, ?, ?, ?, ?)",
'ssissi',
[$subjectType, $subjectId, (int)$channel['id'], $reason !== '' ? $reason : null, $expiresAt, $actorUserId]
);
$id = $this->insertId();
$this->clearAssignmentCache($subjectType, $subjectId);
$this->audit((int)$channel['id'], null, 'assignment_created', $actorUserId, 'info', [
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'channel_slug' => $channel['slug'],
]);
$row = $this->selectOne(
"SELECT a.*, c.slug AS channel_slug, c.name AS channel_name
FROM release_assignments a INNER JOIN release_channels c ON c.id = a.channel_id
WHERE a.id = ?",
'i',
[$id]
);
return $this->publicAssignment($row ?? []);
}
public function deleteAssignment(int $id, ?int $actorUserId = null): array
{
$this->ensureSchema();
$assignment = $this->selectOne('SELECT * FROM release_assignments WHERE id = ? AND deleted_at IS NULL', 'i', [$id]);
if ($assignment === null) {
throw new RuntimeException('Release assignment not found.');
}
$this->execute('UPDATE release_assignments SET deleted_at = NOW() WHERE id = ?', 'i', [$id]);
$this->clearAssignmentCache((string)$assignment['subject_type'], (string)$assignment['subject_id']);
$this->audit((int)$assignment['channel_id'], null, 'assignment_deleted', $actorUserId, 'info', [
'assignment_id' => $id,
]);
return ['deleted' => true, 'id' => $id];
}
public function listDeploymentTargets(): array
{
if (!release_manager_schema_bootstrap::tablesExist()) {
return [];
}
return array_map(
fn(array $row): array => $this->publicDeploymentTarget($row),
$this->selectRows(
"SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label
FROM release_deployment_targets t
INNER JOIN release_channels c ON c.id = t.channel_id
LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id
WHERE t.deleted_at IS NULL
ORDER BY c.slug, FIELD(t.app, 'frontend', 'api'), t.repository"
)
);
}
public function upsertDeploymentTarget(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$id = (int)($input['id'] ?? 0);
$channel = $this->channelFromInput($input);
$app = $this->normalizeApp((string)($input['app'] ?? ''));
$repository = trim((string)($input['repository'] ?? ''));
$branch = trim((string)($input['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
if ($repository === '') {
throw new RuntimeException('Repository is required for release deployment targets.');
}
$normalizedRepository = self::normalizeGithubRepositoryName($repository);
if ($normalizedRepository !== '') {
$repository = $normalizedRepository;
}
$githubAccess = $this->githubRepositoryAccess([
'repository' => $repository,
'branch' => $branch,
'commit_mode' => 'latest',
]);
if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) {
throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.'));
}
$deployContext = is_array($input['deploy_context'] ?? null) ? $input['deploy_context'] : [];
foreach (['coolify_auto_create', 'coolify_enable_ssl', 'coolify_deploy_now'] as $key) {
if (array_key_exists($key, $input)) {
$deployContext[$key] = $this->toBool($input[$key]);
}
}
foreach (['coolify_domain', 'coolify_public_url', 'coolify_url_name', 'manual_endpoint_host', 'coolify_ports_exposes'] as $key) {
if (array_key_exists($key, $input)) {
$deployContext[$key] = trim((string)$input[$key]);
}
}
if (array_key_exists('endpoint_mode', $input)) {
$mode = strtolower(trim((string)$input['endpoint_mode']));
$deployContext['endpoint_mode'] = $mode === 'manual' ? 'manual' : 'auto';
} elseif (!isset($deployContext['endpoint_mode'])) {
$deployContext['endpoint_mode'] = 'auto';
}
if (array_key_exists('manual_endpoint_port', $input)) {
$port = trim((string)$input['manual_endpoint_port']);
$deployContext['manual_endpoint_port'] = $port;
}
$endpointMode = strtolower(trim((string)($deployContext['endpoint_mode'] ?? 'auto'))) === 'manual' ? 'manual' : 'auto';
$deployContext['endpoint_mode'] = $endpointMode;
if ($endpointMode === 'manual') {
$manualHost = self::normalizeEndpointHost($deployContext['manual_endpoint_host'] ?? '');
if ($manualHost === '') {
throw new RuntimeException('Manual endpoint mode requires a public host.');
}
$deployContext['manual_endpoint_host'] = $manualHost;
}
if (array_key_exists('manual_endpoint_port', $deployContext)) {
$manualPort = trim((string)$deployContext['manual_endpoint_port']);
if ($manualPort !== '' && (filter_var($manualPort, FILTER_VALIDATE_INT) === false || (int)$manualPort < 1 || (int)$manualPort > 65535)) {
throw new RuntimeException('Manual endpoint port must be between 1 and 65535.');
}
$deployContext['manual_endpoint_port'] = $manualPort;
}
foreach ([
'coolify_project_uuid',
'project_uuid',
'coolify_environment_uuid',
'environment_uuid',
'coolify_environment_name',
'environment_name',
'coolify_github_app_uuid',
'github_app_uuid',
'coolify_git_app_uuid',
'git_app_uuid',
'coolify_build_pack',
'build_pack',
] as $key) {
if (array_key_exists($key, $input)) {
$deployContext[$key] = trim((string)$input[$key]);
}
}
$payload = [
'channel_id' => (int)$channel['id'],
'app' => $app,
'coolify_instance_id' => $this->nullablePositiveInt($input['coolify_instance_id'] ?? null),
'coolify_service_uuid' => trim((string)($input['coolify_service_uuid'] ?? '')) ?: null,
'repository' => $repository,
'branch' => $branch,
'auto_deploy' => $this->toBool($input['auto_deploy'] ?? true) ? 1 : 0,
'health_url' => trim((string)($input['health_url'] ?? '')) ?: null,
'deploy_context' => $deployContext,
];
if ($id > 0) {
$this->execute(
"UPDATE release_deployment_targets
SET channel_id = ?, app = ?, coolify_instance_id = ?, coolify_service_uuid = ?,
repository = ?, branch = ?, auto_deploy = ?, health_url = ?, deploy_context_json = ?
WHERE id = ? AND deleted_at IS NULL",
'isisssissi',
[
$payload['channel_id'],
$payload['app'],
$payload['coolify_instance_id'],
$payload['coolify_service_uuid'],
$payload['repository'],
$payload['branch'],
$payload['auto_deploy'],
$payload['health_url'],
self::jsonEncode($payload['deploy_context']),
$id,
]
);
$targetId = $id;
$action = 'deployment_target_updated';
} else {
$this->execute(
"INSERT INTO release_deployment_targets (
channel_id, app, coolify_instance_id, coolify_service_uuid,
repository, branch, auto_deploy, health_url, deploy_context_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
'isisssiss',
[
$payload['channel_id'],
$payload['app'],
$payload['coolify_instance_id'],
$payload['coolify_service_uuid'],
$payload['repository'],
$payload['branch'],
$payload['auto_deploy'],
$payload['health_url'],
self::jsonEncode($payload['deploy_context']),
]
);
$targetId = $this->insertId();
$action = 'deployment_target_created';
}
$this->audit((int)$channel['id'], null, $action, $actorUserId, 'info', $payload + [
'github_access' => $githubAccess,
]);
return $this->publicDeploymentTarget($this->getDeploymentTarget($targetId));
}
public function deleteDeploymentTarget(int $id, ?int $actorUserId = null): array
{
$this->ensureSchema();
$target = $this->getDeploymentTarget($id);
$this->execute('UPDATE release_deployment_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$id]);
$this->audit((int)$target['channel_id'], null, 'deployment_target_deleted', $actorUserId, 'warning', [
'target_id' => $id,
]);
return ['deleted' => true, 'id' => $id];
}
public function cronWorkerStatus(array $input = []): array
{
$this->ensureSchema();
$channel = $this->channelFromInputOrDefault($input);
$channelId = (int)$channel['id'];
$apiTarget = $this->deploymentTargetFromInput(['channel_id' => $channelId], $channelId, 'api');
$target = $this->cronWorkerTargetForChannel($channelId);
$publicTarget = $target !== null ? $this->publicDeploymentTarget($target) : null;
$publicApiTarget = $apiTarget !== null ? $this->publicDeploymentTarget($apiTarget) : null;
$workerRows = (new cron_worker())->listWorkers();
$workers = $this->cronWorkersForTarget(
is_array($workerRows['workers'] ?? null) ? $workerRows['workers'] : [],
$channelId,
$target
);
$summary = $this->cronWorkerSummary($workers);
$recentDeployments = $this->cronWorkerDeployments($channelId);
$latestDeployment = $recentDeployments[0] ?? null;
$providerStatus = $this->cronWorkerProviderStatus($target, $input);
$health = $this->cronWorkerHealth($apiTarget, $target, $workers, $summary, $latestDeployment);
$readiness = $this->cronWorkerDeploymentReadiness($apiTarget, $target, $providerStatus);
$issues = $this->cronWorkerMergeIssues(
is_array($health['issues'] ?? null) ? $health['issues'] : [],
is_array($readiness['issues'] ?? null) ? $readiness['issues'] : []
);
$channelPayload = [
'id' => $channelId,
'slug' => (string)($channel['slug'] ?? ''),
'name' => (string)($channel['name'] ?? ''),
];
$deploymentPayload = [
'ok' => true,
'state' => $health['state'],
'desired_workers' => self::CRON_WORKER_DESIRED_COUNT,
'channel' => $channelPayload,
'api_target' => $publicApiTarget,
'target' => $publicTarget,
'latest_deployment' => $latestDeployment,
'provider' => $providerStatus,
'action' => $readiness['action'],
'can_deploy' => $readiness['can_deploy'],
'issues' => $issues,
];
return [
'ok' => true,
'state' => $health['state'],
'desired_workers' => self::CRON_WORKER_DESIRED_COUNT,
'channel' => $channelPayload,
'channels' => $this->cronWorkerChannels(),
'api_target' => $publicApiTarget,
'cron_target' => $publicTarget,
'target' => $publicTarget,
'workers' => $workers,
'summary' => $summary + [
'desired' => self::CRON_WORKER_DESIRED_COUNT,
'state' => $health['state'],
],
'latest_deployment' => $latestDeployment,
'recent_deployments' => $recentDeployments,
'provider' => $providerStatus,
'issues' => $issues,
'deployment' => $deploymentPayload,
];
}
public function deployCronWorker(array $input = [], ?int $actorUserId = null): array
{
$this->ensureSchema();
$dryRun = $this->toBool($input['dry_run'] ?? false);
$apiTarget = null;
$channel = null;
$existingCronTarget = null;
$targetId = $this->nullablePositiveInt($input['api_target_id'] ?? $input['target_id'] ?? null);
if ($targetId !== null) {
$apiTarget = $this->getDeploymentTarget($targetId);
if ((string)($apiTarget['app'] ?? '') !== 'api') {
throw new RuntimeException('Cron workers must be deployed from an API deployment target.');
}
} else {
$channel = $this->channelFromInputOrDefault($input);
$channelId = (int)$channel['id'];
$existingCronTarget = $this->cronWorkerTargetForChannel($channelId);
$apiTarget = $this->deploymentTargetFromInput(['channel_id' => $channelId], $channelId, 'api');
if ($apiTarget === null && $this->cronWorkerTargetCanDeployWithoutApiTarget($existingCronTarget)) {
$apiTarget = $this->cronWorkerSourceFromCronTarget($existingCronTarget, $channel);
}
}
if ($apiTarget === null) {
throw new RuntimeException('No API deployment target is configured for this release channel.');
}
$commitSha = self::normalizeCommitSha((string)($input['commit_sha'] ?? $input['commit'] ?? ''));
return $this->deployCronWorkerForApiTarget($apiTarget, $commitSha !== '' ? $commitSha : null, $actorUserId, $dryRun);
}
private function cronWorkerSourceFromCronTarget(array $target, ?array $channel = null): array
{
return array_replace($target, [
'app' => self::CRON_WORKER_APP,
'channel_id' => (int)($target['channel_id'] ?? $channel['id'] ?? 0),
'channel_slug' => (string)($target['channel_slug'] ?? $channel['slug'] ?? ''),
'channel_name' => (string)($target['channel_name'] ?? $channel['name'] ?? ''),
]);
}
private function deployCronWorkerForApiTarget(
array $apiTarget,
?string $commitSha = null,
?int $actorUserId = null,
bool $dryRun = false,
?int $parentDeploymentId = null
): array {
$channelId = (int)($apiTarget['channel_id'] ?? 0);
if ($channelId <= 0) {
throw new RuntimeException('API deployment target has no release channel.');
}
if ((int)($apiTarget['coolify_instance_id'] ?? 0) <= 0) {
throw new RuntimeException('API deployment target has no Coolify instance for cron worker deployment.');
}
$channel = $this->getChannel($channelId);
$apiTarget = $apiTarget + [
'channel_slug' => (string)($channel['slug'] ?? ''),
'channel_name' => (string)($channel['name'] ?? ''),
];
$existing = $this->cronWorkerTargetForChannel($channelId);
$targetId = $existing !== null ? (int)$existing['id'] : 0;
$context = $this->cronWorkerDeployContext($apiTarget, $existing, $commitSha, $targetId);
$repository = self::normalizeGithubRepositoryName((string)($apiTarget['repository'] ?? ''));
if ($repository === '') {
$repository = trim((string)($apiTarget['repository'] ?? ''));
}
$branch = trim((string)($apiTarget['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
$serviceUuid = trim((string)($existing['coolify_service_uuid'] ?? ''));
$providerStatus = $existing !== null
? $this->cronWorkerProviderStatus($existing, ['include_provider' => true])
: ['configured' => false, 'missing' => false];
$repairMissingResource = $existing !== null
&& $serviceUuid !== ''
&& $this->toBool($providerStatus['missing'] ?? false);
$sourceApp = (string)($apiTarget['app'] ?? 'api');
$sourceTargetId = (int)($apiTarget['id'] ?? 0);
if ($repairMissingResource) {
$context['cron_worker_orphaned_coolify_service_uuid'] = $serviceUuid;
$context['cron_worker_orphaned_at'] = date('Y-m-d H:i:s');
$context['cron_worker_repair_reason'] = 'coolify_resource_missing';
$serviceUuid = '';
}
$planAction = $targetId > 0
? ($repairMissingResource ? 'repair' : ($serviceUuid === '' ? 'create' : 'update'))
: 'create';
$plan = [
'type' => 'deploy_cron_worker',
'channel_id' => $channelId,
'channel_slug' => (string)($channel['slug'] ?? ''),
'api_target_id' => $sourceApp === 'api' && $sourceTargetId > 0 ? $sourceTargetId : null,
'source_target_id' => $sourceTargetId > 0 ? $sourceTargetId : null,
'source_app' => $sourceApp,
'target_id' => $targetId > 0 ? $targetId : null,
'action' => $planAction,
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'coolify_instance_id' => (int)$apiTarget['coolify_instance_id'],
'coolify_service_uuid' => $serviceUuid !== '' ? $serviceUuid : null,
'orphaned_coolify_service_uuid' => $repairMissingResource
? (string)($existing['coolify_service_uuid'] ?? '')
: null,
'start_command' => self::CRON_WORKER_START_COMMAND,
'dry_run' => $dryRun,
];
if ($dryRun) {
return [
'ok' => true,
'dry_run' => true,
'mutated' => false,
'planned' => [$plan],
'target' => $existing !== null ? $this->publicDeploymentTarget($existing) : null,
'worker_status' => $this->cronWorkerStatus(['channel_id' => $channelId]),
];
}
$cronDeploymentId = $this->createCronWorkerDeploymentRecord(
$channelId,
$targetId > 0 ? $targetId : null,
$repository,
$branch,
$commitSha,
$actorUserId,
['plan' => $plan, 'parent_deployment_id' => $parentDeploymentId]
);
try {
if ($targetId > 0) {
$this->execute(
"UPDATE release_deployment_targets
SET coolify_instance_id = ?, coolify_service_uuid = ?, repository = ?, branch = ?, auto_deploy = 0,
health_url = NULL, deploy_context_json = ?
WHERE id = ? AND deleted_at IS NULL",
'issssi',
[
(int)$apiTarget['coolify_instance_id'],
$serviceUuid !== '' ? $serviceUuid : null,
$repository,
$branch,
self::jsonEncode($context),
$targetId,
]
);
$action = $repairMissingResource ? 'cron_worker_target_repaired' : 'cron_worker_target_updated';
} else {
$this->execute(
"INSERT INTO release_deployment_targets (
channel_id, app, coolify_instance_id, coolify_service_uuid,
repository, branch, auto_deploy, health_url, deploy_context_json
) VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?)",
'isissss',
[
$channelId,
self::CRON_WORKER_APP,
(int)$apiTarget['coolify_instance_id'],
$serviceUuid !== '' ? $serviceUuid : null,
$repository,
$branch,
self::jsonEncode($context),
]
);
$targetId = $this->insertId();
$context = $this->cronWorkerDeployContext($apiTarget, null, $commitSha, $targetId);
$this->execute(
'UPDATE release_deployment_targets SET deploy_context_json = ? WHERE id = ?',
'si',
[self::jsonEncode($context), $targetId]
);
$this->execute('UPDATE release_deployments SET target_id = ? WHERE id = ?', 'ii', [$targetId, $cronDeploymentId]);
$action = 'cron_worker_target_created';
}
$target = $this->getDeploymentTarget($targetId);
$deployTarget = array_replace($target, [
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha ?? '',
]);
$deployment = $this->deployCoolifyReleaseTarget($deployTarget);
$providerOperationId = $this->coolifyDeploymentOperationId($deployment);
$this->execute(
"UPDATE release_deployments
SET status = 'deployed', provider_operation_id = ?, result_json = ?, completed_at = NOW()
WHERE id = ?",
'ssi',
[$providerOperationId, self::jsonEncode(self::redactPayload($deployment)), $cronDeploymentId]
);
$this->audit($channelId, $cronDeploymentId, $action, $actorUserId, 'info', [
'api_target_id' => (int)($apiTarget['id'] ?? 0),
'cron_target_id' => $targetId,
'commit_sha' => $commitSha,
'parent_deployment_id' => $parentDeploymentId,
'deployment' => $deployment,
]);
return [
'ok' => true,
'dry_run' => false,
'mutated' => true,
'planned' => [$plan],
'applied' => [[
'target_id' => $targetId,
'deployment_id' => $cronDeploymentId,
'action' => $action,
'deployment' => $deployment,
]],
'deployment' => $this->publicDeployment($this->getDeployment($cronDeploymentId)),
'target' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)),
'worker_status' => $this->cronWorkerStatus(['channel_id' => $channelId]),
];
} catch (Throwable $throwable) {
$this->execute(
"UPDATE release_deployments
SET status = 'failed', result_json = ?, error_message = ?, completed_at = NOW()
WHERE id = ?",
'ssi',
[
self::jsonEncode([
'message' => 'Cron worker deployment failed before a worker heartbeat was observed.',
'failure_summary' => self::deploymentFailureSummary($throwable, [
'app' => self::CRON_WORKER_APP,
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'target_id' => $targetId > 0 ? $targetId : null,
'coolify_instance_id' => (int)($apiTarget['coolify_instance_id'] ?? 0),
'coolify_service_uuid' => $serviceUuid,
]),
]),
$throwable->getMessage(),
$cronDeploymentId,
]
);
$this->audit($channelId, $cronDeploymentId, 'cron_worker_deploy_failed', $actorUserId, 'warning', [
'api_target_id' => (int)($apiTarget['id'] ?? 0),
'cron_target_id' => $targetId > 0 ? $targetId : null,
'commit_sha' => $commitSha,
'parent_deployment_id' => $parentDeploymentId,
'error' => $throwable->getMessage(),
]);
throw $throwable;
}
}
private function cronWorkerTargetForChannel(int $channelId): ?array
{
return $this->selectOne(
"SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label
FROM release_deployment_targets t
INNER JOIN release_channels c ON c.id = t.channel_id
LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id
WHERE t.deleted_at IS NULL AND t.channel_id = ? AND t.app = ?
ORDER BY t.id DESC
LIMIT 1",
'is',
[$channelId, self::CRON_WORKER_APP]
);
}
private function createCronWorkerDeploymentRecord(
int $channelId,
?int $targetId,
string $repository,
string $branch,
?string $commitSha,
?int $actorUserId,
array $requestedPayload
): int {
$this->execute(
"INSERT INTO release_deployments (
channel_id, target_id, deployment_kind, app, provider, repository, branch,
commit_sha, status, actor_user_id, requested_payload_json, started_at
) VALUES (?, ?, 'cron_worker', ?, 'coolify', ?, ?, ?, 'deploying', ?, ?, NOW())",
'iissssis',
[
$channelId,
$targetId,
self::CRON_WORKER_APP,
$repository,
$branch,
$commitSha,
$actorUserId,
self::jsonEncode(self::redactPayload($requestedPayload)),
]
);
return $this->insertId();
}
private function cronWorkerDeployments(int $channelId, int $limit = 5): array
{
$limit = max(1, min(25, $limit));
return array_map(
fn(array $deployment): array => $this->publicDeployment($deployment),
$this->selectRows(
"SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
FROM release_deployments d
INNER JOIN release_channels c ON c.id = d.channel_id
LEFT JOIN release_versions v ON v.id = d.version_id
WHERE d.channel_id = ? AND d.app = ? AND d.deployment_kind = 'cron_worker'
ORDER BY d.id DESC
LIMIT $limit",
'is',
[$channelId, self::CRON_WORKER_APP]
)
);
}
private function cronWorkerChannels(): array
{
return array_map(
static fn(array $channel): array => [
'id' => (int)($channel['id'] ?? 0),
'slug' => (string)($channel['slug'] ?? ''),
'name' => (string)($channel['name'] ?? ''),
'default_channel' => (bool)((int)($channel['default_channel'] ?? 0)),
],
$this->selectRows(
"SELECT id, slug, name, default_channel
FROM release_channels
WHERE deleted_at IS NULL AND enabled = 1
ORDER BY default_channel DESC, slug"
)
);
}
private function cronWorkersForTarget(array $workers, int $channelId, ?array $target): array
{
$targetId = $target !== null ? (int)($target['id'] ?? 0) : 0;
$resourceUuid = $target !== null ? trim((string)($target['coolify_service_uuid'] ?? '')) : '';
$matched = [];
foreach ($workers as $worker) {
if (!is_array($worker)) {
continue;
}
$workerChannelId = (int)($worker['release_channel_id'] ?? 0);
$workerTargetId = (int)($worker['release_target_id'] ?? 0);
$workerResourceUuid = trim((string)($worker['coolify_resource_uuid'] ?? ''));
if (
($workerChannelId > 0 && $workerChannelId === $channelId)
|| ($targetId > 0 && $workerTargetId === $targetId)
|| ($resourceUuid !== '' && $workerResourceUuid === $resourceUuid)
) {
$matched[] = $worker;
}
}
return $matched;
}
private function cronWorkerSummary(array $workers): array
{
$running = 0;
$stale = 0;
$failed = 0;
foreach ($workers as $worker) {
$status = (string)($worker['status'] ?? '');
$isStale = (bool)($worker['stale'] ?? false);
if ($status === 'running' && !$isStale) {
$running++;
}
if ($isStale) {
$stale++;
}
if ($status === 'failed') {
$failed++;
}
}
return [
'total' => count($workers),
'running' => $running,
'stale' => $stale,
'failed' => $failed,
];
}
private function cronWorkerHealth(
?array $apiTarget,
?array $target,
array $workers,
array $summary,
?array $latestDeployment
): array {
$issues = [];
if ($apiTarget === null) {
$issues[] = $this->cronWorkerIssue('missing_api_target', 'danger', 'No API deployment target is configured for this release channel.');
return ['state' => 'needs_deploy', 'issues' => $issues];
}
if ($target === null) {
$issues[] = $this->cronWorkerIssue('missing_cron_target', 'warning', 'No cron worker Coolify target exists for this release channel.');
return ['state' => 'needs_deploy', 'issues' => $issues];
}
$deploymentStatus = (string)($latestDeployment['status'] ?? '');
if ($deploymentStatus === 'failed') {
$issues[] = $this->cronWorkerIssue(
'latest_deployment_failed',
'danger',
(string)($latestDeployment['error_message'] ?? 'Latest cron worker deployment failed.')
);
return ['state' => 'failed', 'issues' => $issues];
}
if (in_array($deploymentStatus, ['queued', 'deploying'], true)) {
$issues[] = $this->cronWorkerIssue('deployment_in_progress', 'info', 'Cron worker deployment is in progress.');
return ['state' => 'deploying', 'issues' => $issues];
}
$running = (int)($summary['running'] ?? 0);
$stale = (int)($summary['stale'] ?? 0);
$failed = (int)($summary['failed'] ?? 0);
if ($running >= self::CRON_WORKER_DESIRED_COUNT && $stale === 0 && $failed === 0) {
return ['state' => 'healthy', 'issues' => []];
}
if ($running > 0) {
if ($stale > 0) {
$issues[] = $this->cronWorkerIssue('stale_workers_present', 'warning', 'At least one cron worker heartbeat is stale.');
}
if ($failed > 0) {
$issues[] = $this->cronWorkerIssue('failed_workers_present', 'warning', 'At least one cron worker reported a failed loop.');
}
if ($running < self::CRON_WORKER_DESIRED_COUNT) {
$issues[] = $this->cronWorkerIssue('below_desired_worker_count', 'warning', 'Fewer cron workers are running than desired.');
}
return ['state' => 'degraded', 'issues' => $issues];
}
if ($workers === []) {
$age = $this->cronWorkerDeploymentAgeSeconds($latestDeployment);
if ($deploymentStatus === 'deployed' && ($age === null || $age <= self::CRON_WORKER_HEARTBEAT_GRACE_SECONDS)) {
$issues[] = $this->cronWorkerIssue('waiting_for_first_heartbeat', 'info', 'Coolify accepted the deployment; waiting for the worker to write its first heartbeat.');
return ['state' => 'waiting_for_heartbeat', 'issues' => $issues];
}
$issues[] = $this->cronWorkerIssue('no_worker_heartbeat', 'danger', 'Cron worker target exists, but no worker heartbeat has been recorded.');
return ['state' => $deploymentStatus === 'deployed' ? 'failed' : 'degraded', 'issues' => $issues];
}
if ($stale > 0 || $failed > 0) {
$issues[] = $this->cronWorkerIssue('no_fresh_running_worker', 'danger', 'Cron workers exist, but none have a fresh running heartbeat.');
return ['state' => 'failed', 'issues' => $issues];
}
$issues[] = $this->cronWorkerIssue('worker_not_running', 'warning', 'Cron worker is not currently running.');
return ['state' => 'degraded', 'issues' => $issues];
}
private function cronWorkerDeploymentReadiness(?array $apiTarget, ?array $target, array $providerStatus): array
{
$issues = [];
if ($target === null) {
if ($apiTarget === null) {
$issues[] = $this->cronWorkerIssue(
'missing_api_target',
'danger',
'No API deployment target is configured for this release channel.'
);
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
}
return ['action' => 'create', 'can_deploy' => true, 'issues' => []];
}
$resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
$canUseCronTargetContext = $this->cronWorkerTargetCanDeployWithoutApiTarget($target);
if ($resourceUuid === '') {
if ($apiTarget !== null || $canUseCronTargetContext) {
if ($apiTarget === null) {
$issues[] = $this->cronWorkerIssue(
'repairable_cron_target',
'info',
'No API target is configured, but the cron target has enough deployment context to deploy a worker.'
);
}
return ['action' => 'create', 'can_deploy' => true, 'issues' => $issues];
}
$issues[] = $this->cronWorkerIssue(
'missing_api_target',
'danger',
'No API deployment target is configured for this release channel.'
);
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
}
if ($this->toBool($providerStatus['missing'] ?? false)) {
$issues[] = $this->cronWorkerIssue(
'missing_coolify_worker_resource',
'danger',
'The stored Coolify cron worker resource was not found and must be recreated.'
);
if ($apiTarget !== null || $canUseCronTargetContext) {
if ($apiTarget === null) {
$issues[] = $this->cronWorkerIssue(
'repairable_cron_target',
'info',
'No API target is configured, but the cron target has enough deployment context to repair itself.'
);
}
return ['action' => 'repair', 'can_deploy' => true, 'issues' => $issues];
}
$issues[] = $this->cronWorkerIssue(
'missing_api_target',
'danger',
'No API deployment target is configured for this release channel.'
);
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
}
if ($apiTarget === null && !$canUseCronTargetContext) {
$issues[] = $this->cronWorkerIssue(
'missing_api_target',
'danger',
'No API deployment target is configured for this release channel.'
);
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
}
return ['action' => 'update', 'can_deploy' => true, 'issues' => $issues];
}
private function cronWorkerTargetCanDeployWithoutApiTarget(?array $target): bool
{
if ($target === null) {
return false;
}
$repository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? ''));
if ($repository === '') {
$repository = trim((string)($target['repository'] ?? ''));
}
return $this->nullablePositiveInt($target['coolify_instance_id'] ?? null) !== null
&& $repository !== '';
}
private function cronWorkerMergeIssues(array ...$issueGroups): array
{
$merged = [];
$seen = [];
foreach ($issueGroups as $issues) {
foreach ($issues as $issue) {
if (!is_array($issue)) {
continue;
}
$key = trim((string)($issue['code'] ?? ''));
if ($key === '') {
$key = trim((string)($issue['message'] ?? ''));
}
if ($key !== '' && isset($seen[$key])) {
continue;
}
if ($key !== '') {
$seen[$key] = true;
}
$merged[] = $issue;
}
}
return $merged;
}
private function cronWorkerIssue(string $code, string $severity, string $message): array
{
return [
'code' => $code,
'severity' => $severity,
'message' => $message,
];
}
private function cronWorkerDeploymentAgeSeconds(?array $deployment): ?int
{
if ($deployment === null) {
return null;
}
foreach (['completed_at', 'started_at', 'created_at'] as $key) {
$value = trim((string)($deployment[$key] ?? ''));
if ($value === '') {
continue;
}
$timestamp = strtotime($value);
if ($timestamp !== false) {
return max(0, time() - $timestamp);
}
}
return null;
}
private function cronWorkerProviderStatus(?array $target, array $input): array
{
if ($target === null) {
return [
'configured' => false,
'resource' => null,
];
}
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
$resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
$resourceType = trim((string)($context['coolify_resource_type'] ?? 'application')) ?: 'application';
$status = [
'configured' => $resourceUuid !== '',
'instance_id' => isset($target['coolify_instance_id']) ? (int)$target['coolify_instance_id'] : null,
'instance_label' => $target['coolify_instance_label'] ?? null,
'resource_uuid' => $resourceUuid !== '' ? $resourceUuid : null,
'resource_type' => $resourceType,
'resource' => null,
'checked' => false,
'missing' => false,
];
if (!$this->toBool($input['include_provider'] ?? $input['include_provider_status'] ?? false) || $resourceUuid === '') {
return $status;
}
try {
$status['checked'] = true;
$instance = $this->selectOne(
'SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL',
'i',
[(int)($target['coolify_instance_id'] ?? 0)]
);
if ($instance === null) {
throw new RuntimeException('Coolify instance for cron worker target was not found.');
}
$client = $this->coolifyClientForInstance($instance, 3);
$resource = $resourceType === 'service'
? $client->getService($resourceUuid)
: $client->getApplication($resourceUuid);
$status['resource'] = [
'ok' => true,
'uuid' => $resource['uuid'] ?? $resourceUuid,
'name' => $resource['name'] ?? null,
'status' => $resource['status'] ?? $resource['state'] ?? null,
'fqdn' => $resource['fqdn'] ?? $resource['domains'] ?? null,
];
} catch (Throwable $throwable) {
$status['checked'] = true;
$status['missing'] = self::coolifyResourceMissing($throwable);
$status['resource'] = [
'ok' => false,
'error' => $throwable->getMessage(),
'missing' => $status['missing'],
];
}
return $status;
}
private static function coolifyResourceMissing(Throwable $throwable): bool
{
$message = strtolower($throwable->getMessage());
return str_contains($message, 'http 404')
|| str_contains($message, 'not found')
|| preg_match('/\b404\b/', $message) === 1;
}
private function coolifyDeploymentOperationId(array $deployment): ?string
{
foreach ([$deployment, $deployment['deployment'] ?? null, $deployment['data'] ?? null] as $candidate) {
if (!is_array($candidate)) {
continue;
}
foreach (['deployment_uuid', 'operation_id', 'deployment_id', 'uuid', 'id'] as $key) {
$value = trim((string)($candidate[$key] ?? ''));
if ($value !== '') {
return substr($value, 0, 128);
}
}
}
return null;
}
private function cronWorkerDeployContext(array $apiTarget, ?array $existing, ?string $commitSha, int $targetId): array
{
$apiContext = self::jsonDecode($apiTarget['deploy_context_json'] ?? null);
$context = self::jsonDecode($existing['deploy_context_json'] ?? null);
foreach ([
'coolify_project_uuid',
'project_uuid',
'coolify_environment_uuid',
'environment_uuid',
'coolify_environment_name',
'environment_name',
'coolify_github_app_uuid',
'github_app_uuid',
'coolify_git_app_uuid',
'git_app_uuid',
'coolify_server_uuid',
'server_uuid',
'coolify_destination_uuid',
'destination_uuid',
] as $key) {
if (!array_key_exists($key, $context) && array_key_exists($key, $apiContext)) {
$context[$key] = $apiContext[$key];
}
}
$channelSlug = self::safeSlug((string)($apiTarget['channel_slug'] ?? $apiTarget['channel_id'] ?? 'release')) ?: 'release';
$workerName = self::safeIdentifier('release-' . $channelSlug . '-cron-worker', 64);
$context['coolify_auto_create'] = true;
$context['coolify_resource_type'] = 'application';
$context['coolify_build_pack'] = 'dockerfile';
$context['coolify_dockerfile_location'] = self::DEFAULT_COOLIFY_API_DOCKERFILE;
$context['coolify_ports_exposes'] = self::DEFAULT_COOLIFY_APPLICATION_PORT;
$context['coolify_start_command'] = self::CRON_WORKER_START_COMMAND;
$context['coolify_deploy_now'] = true;
$context['coolify_enable_ssl'] = false;
$context['coolify_force_rebuild'] = true;
$context['coolify_is_auto_deploy_enabled'] = false;
$context['coolify_service_name'] = $workerName;
$context['cron_worker_autoprovision'] = true;
if ((string)($apiTarget['app'] ?? 'api') === 'api') {
$context['cron_worker_source_api_target_id'] = (int)($apiTarget['id'] ?? 0);
unset($context['cron_worker_source_cron_target_id']);
} else {
$context['cron_worker_source_cron_target_id'] = (int)($apiTarget['id'] ?? 0);
unset($context['cron_worker_source_api_target_id']);
}
unset(
$context['coolify_domain'],
$context['coolify_public_url'],
$context['domains'],
$context['manual_endpoint_host'],
$context['manual_endpoint_port']
);
if ($commitSha !== null && trim($commitSha) !== '') {
$context['coolify_git_commit_sha'] = $commitSha;
}
$env = is_array($context['coolify_env'] ?? null) ? $context['coolify_env'] : [];
$context['coolify_env'] = array_replace($env, [
'CRON_WORKER_ENABLED' => 'true',
'CRON_WORKER_NAME' => $workerName,
'CRON_WORKER_SOURCE' => 'coolify_worker',
'CRON_WORKER_POLL_SECONDS' => '15',
'CRON_WORKER_HEARTBEAT_SECONDS' => '30',
'CRON_WORKER_RELEASE_CHANNEL_ID' => (string)(int)($apiTarget['channel_id'] ?? 0),
'CRON_WORKER_RELEASE_TARGET_ID' => $targetId > 0 ? (string)$targetId : '',
'CRON_WORKER_COOLIFY_RESOURCE_TYPE' => 'application',
]);
return $context;
}
public function listServiceSets(): array
{
if (!release_manager_schema_bootstrap::tablesExist()) {
return [];
}
return array_map(
fn(array $row): array => $this->publicServiceSet($row),
$this->selectRows(
"SELECT s.*, c.slug AS channel_slug, c.name AS channel_name
FROM release_service_sets s
LEFT JOIN release_channels c ON c.id = s.channel_id
WHERE s.deleted_at IS NULL
ORDER BY s.updated_at DESC, s.created_at DESC, s.id DESC"
)
);
}
public function createServiceSet(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$mode = $this->normalizeServiceSetMode((string)($input['mode'] ?? $input['dataset_mode'] ?? 'attach_existing'));
$sourceId = $this->nullablePositiveInt($input['source_service_set_id'] ?? $input['source_id'] ?? null);
if ($mode === 'isolated_stack') {
$sourceId = null;
}
$source = $sourceId !== null ? $this->getServiceSet($sourceId) : null;
$dataSourceId = $mode === 'isolated_stack'
? null
: $this->nullablePositiveInt($input['data_source_service_set_id'] ?? $input['data_source_id'] ?? null);
$dataSource = $dataSourceId !== null ? $this->getServiceSet($dataSourceId) : null;
$channel = $this->channelFromInputOrDefault($input, $source);
$isBetaChannel = $this->isBetaChannel($channel);
if ($isBetaChannel && $mode !== 'attach_existing') {
throw new RuntimeException('Beta release service sets must use production-shared data services.');
}
if ($isBetaChannel && $this->serviceSetInputHasExplicitDataTargets($input)) {
throw new RuntimeException('Beta data-only service sets must copy data targets from Stable/Master or leave them production_shared.');
}
$frontendTargetId = $this->serviceSetTargetIdFromInput($input, 'frontend', $source);
$apiTargetId = $this->serviceSetTargetIdFromInput($input, 'api', $source);
$dataTargets = [];
$dataTargetSource = $isBetaChannel ? $dataSource : ($dataSource ?? $source);
foreach (self::STACK_DATA_KINDS as $kind) {
$dataTargets[$kind] = $this->serviceSetDataTargetIdFromInput($input, $kind, $dataTargetSource);
}
if ($isBetaChannel) {
$this->assertBetaDataSourceChannel($dataSource);
}
$name = trim((string)($input['name'] ?? ''));
if ($name === '') {
$name = $source !== null
? sprintf('%s %s', (string)($source['name'] ?? 'Release service set'), str_replace('_', ' ', $mode))
: sprintf('%s service set', ucfirst(str_replace('_', ' ', $mode)));
}
if ($mode === 'isolated_stack') {
$this->assertIsolatedStackTarget($frontendTargetId, 'frontend');
$this->assertIsolatedStackTarget($apiTargetId, 'api');
$createDataTargets = $this->toBool(
$input['create_data_targets']
?? $input['create_isolated_data_targets']
?? $input['create_empty_data_targets']
?? true
);
foreach (self::STACK_DATA_KINDS as $kind) {
if ($dataTargets[$kind] !== null) {
$this->assertIsolatedStackDataTarget($dataTargets[$kind], $kind);
continue;
}
if ($createDataTargets) {
$dataTargets[$kind] = $this->createIsolatedStackDataTarget(
$kind,
$input,
$channel,
$name,
$frontendTargetId,
$apiTargetId,
$actorUserId
);
}
}
}
if (!$isBetaChannel && !in_array($mode, ['fresh_empty', 'isolated_stack'], true) && $source === null && $frontendTargetId === null && $apiTargetId === null) {
throw new RuntimeException('Select an existing release deployment or target before creating a reusable service set.');
}
$metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null);
$metadata['dataset_mode'] = $mode;
$metadata['source_service_set_id'] = $sourceId;
$metadata['data_source_service_set_id'] = $dataSourceId;
if ($dataSource !== null) {
$metadata['data_source_channel_slug'] = (string)($dataSource['channel_slug'] ?? '');
}
if ($mode === 'attach_existing') {
$metadata['data_policy'] = self::PRODUCTION_DATA_POLICY;
$metadata['data_service_mode'] = self::PRODUCTION_DATA_POLICY;
}
$metadata['replica_integration'] = $this->replicaProvisioningPlan($mode, $dataTargetSource, $dataTargets);
if ($mode === 'fresh_empty') {
$metadata['isolated_empty_services'] = true;
$metadata['production_replication_attached'] = false;
}
if ($mode === 'isolated_stack') {
$metadata['isolated_stack'] = true;
$metadata['isolated_empty_services'] = true;
$metadata['production_replication_attached'] = false;
$metadata['production_code_targets_attached'] = false;
}
$slug = $this->uniqueServiceSetSlug(self::safeSlug((string)($input['slug'] ?? $name)));
$status = $isBetaChannel ? 'ready' : $this->serviceSetStatus($mode, $frontendTargetId, $apiTargetId, $dataTargets);
$stackComplete = $isBetaChannel || ($frontendTargetId !== null
&& $apiTargetId !== null
&& (!in_array(null, $dataTargets, true) || $mode === 'attach_existing'));
$health = [
'status' => $status,
'stack_complete' => $stackComplete,
'data_policy' => $this->serviceSetDataPolicy([
'mode' => $mode,
'metadata_json' => self::jsonEncode($metadata),
]),
'checked_at' => date('c'),
];
$this->execute(
"INSERT INTO release_service_sets (
channel_id, name, slug, mode, source_service_set_id,
frontend_target_id, api_target_id,
database_coolify_target_id, redis_coolify_target_id, minio_coolify_target_id,
status, health_json, metadata_json, actor_user_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
'isssiiiiiisssi',
[
(int)$channel['id'],
substr($name, 0, 128),
$slug,
$mode,
$sourceId,
$frontendTargetId,
$apiTargetId,
$dataTargets['database'],
$dataTargets['redis'],
$dataTargets['minio'],
$status,
self::jsonEncode($health),
self::jsonEncode($metadata),
$actorUserId,
]
);
$id = $this->insertId();
$this->audit((int)$channel['id'], null, 'service_set_created', $actorUserId, 'info', [
'service_set_id' => $id,
'mode' => $mode,
'source_service_set_id' => $sourceId,
'data_source_service_set_id' => $dataSourceId,
'data_policy' => $this->serviceSetDataPolicy($this->getServiceSet($id)),
'data_targets' => $dataTargets,
]);
return $this->publicServiceSet($this->getServiceSet($id));
}
public function deleteServiceSet(int $id, array $input = [], ?int $actorUserId = null): array
{
$this->ensureSchema();
$serviceSet = $this->getServiceSet($id);
if ((string)($serviceSet['mode'] ?? '') !== 'isolated_stack') {
throw new RuntimeException('Only isolated stack service sets can be removed from Release Manager.');
}
if ($this->serviceSetIsActive($id)) {
throw new RuntimeException('The active release service set cannot be removed.');
}
$frontendTargetId = $this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null);
$apiTargetId = $this->nullablePositiveInt($serviceSet['api_target_id'] ?? null);
$dataTargetIds = [];
foreach (self::STACK_DATA_KINDS as $kind) {
$dataTargetIds[$kind] = $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null);
}
$this->execute(
"UPDATE release_bundles
SET status = 'removed', deleted_at = NOW()
WHERE service_set_id = ? AND deleted_at IS NULL",
'i',
[$id]
);
$this->execute(
"UPDATE release_deployments
SET status = 'removed'
WHERE service_set_id = ?",
'i',
[$id]
);
$this->execute(
"UPDATE release_service_sets
SET status = 'removed', deleted_at = NOW(), actor_user_id = ?
WHERE id = ?",
'ii',
[$actorUserId, $id]
);
foreach ([$frontendTargetId, $apiTargetId] as $targetId) {
if ($this->isolatedDeploymentTargetCanBeForgotten($targetId, $id)) {
$this->execute('UPDATE release_deployment_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]);
}
}
foreach ($dataTargetIds as $targetId) {
if ($this->isolatedCoolifyTargetCanBeForgotten($targetId, $id)) {
$this->execute('UPDATE coolify_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]);
}
}
$this->audit((int)$serviceSet['channel_id'], null, 'service_set_removed', $actorUserId, 'warning', [
'service_set_id' => $id,
'mode' => 'isolated_stack',
'provider_resources_deleted' => false,
'frontend_target_id' => $frontendTargetId,
'api_target_id' => $apiTargetId,
'data_target_ids' => $dataTargetIds,
]);
return [
'id' => $id,
'removed' => true,
'provider_resources_deleted' => false,
];
}
public function completeIsolatedStackDataServices(int $serviceSetId, array $input = [], ?int $actorUserId = null): array
{
$this->ensureSchema();
$serviceSet = $this->getServiceSet($serviceSetId);
if ((string)($serviceSet['mode'] ?? '') !== 'isolated_stack') {
throw new RuntimeException('Only isolated stack service sets can create isolated data services.');
}
$frontendTargetId = $this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null);
$apiTargetId = $this->nullablePositiveInt($serviceSet['api_target_id'] ?? null);
$this->assertIsolatedStackTarget($frontendTargetId, 'frontend', true);
$this->assertIsolatedStackTarget($apiTargetId, 'api', true);
$channel = $this->getChannel((int)$serviceSet['channel_id']);
$name = trim((string)($input['name'] ?? $serviceSet['name'] ?? 'Isolated stack'));
$dataTargets = [];
foreach (self::STACK_DATA_KINDS as $kind) {
$dataTargets[$kind] = $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null);
if ($dataTargets[$kind] !== null) {
$this->assertIsolatedStackDataTarget($dataTargets[$kind], $kind);
continue;
}
$dataTargets[$kind] = $this->createIsolatedStackDataTarget(
$kind,
$input,
$channel,
$name,
$frontendTargetId,
$apiTargetId,
$actorUserId
);
}
$metadata = self::jsonDecode($serviceSet['metadata_json'] ?? null);
$metadata['dataset_mode'] = 'isolated_stack';
$metadata['isolated_stack'] = true;
$metadata['isolated_empty_services'] = true;
$metadata['production_replication_attached'] = false;
$metadata['production_code_targets_attached'] = false;
$metadata['replica_integration'] = $this->replicaProvisioningPlan('isolated_stack', null, $dataTargets);
$status = $this->serviceSetStatus('isolated_stack', $frontendTargetId, $apiTargetId, $dataTargets);
$health = [
'status' => $status,
'stack_complete' => $frontendTargetId !== null && $apiTargetId !== null && !in_array(null, $dataTargets, true),
'checked_at' => date('c'),
];
$this->execute(
"UPDATE release_service_sets
SET database_coolify_target_id = ?, redis_coolify_target_id = ?, minio_coolify_target_id = ?,
status = ?, health_json = ?, metadata_json = ?, actor_user_id = ?
WHERE id = ?",
'iiisssii',
[
$dataTargets['database'],
$dataTargets['redis'],
$dataTargets['minio'],
$status,
self::jsonEncode($health),
self::jsonEncode($metadata),
$actorUserId,
$serviceSetId,
]
);
$this->audit((int)$channel['id'], null, 'isolated_stack_data_services_created', $actorUserId, 'info', [
'service_set_id' => $serviceSetId,
'data_targets' => $dataTargets,
]);
return $this->publicServiceSet($this->getServiceSet($serviceSetId));
}
public function previewCoolifyCleanup(array $input = [], ?int $actorUserId = null): array
{
$this->ensureSchema();
return $this->buildCoolifyCleanupPreview($this->normalizeCoolifyCleanupInput($input));
}
public function applyCoolifyCleanup(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$policy = $this->normalizeCoolifyCleanupInput($input);
$preview = $this->buildCoolifyCleanupPreview($policy);
$selectionHash = self::safeIdentifier((string)($input['selection_hash'] ?? ''), 128);
$confirmation = trim((string)($input['confirm'] ?? $input['confirmation'] ?? ''));
if ($preview['candidates'] === []) {
throw new RuntimeException('Coolify cleanup has no eligible release-managed resources to apply.');
}
if ($selectionHash === '' || !hash_equals((string)$preview['selection_hash'], $selectionHash)) {
throw new RuntimeException('Coolify cleanup selection changed. Preview again before applying cleanup.');
}
if ($confirmation === '' || !hash_equals((string)$preview['confirmation_phrase'], $confirmation)) {
throw new RuntimeException('Coolify cleanup confirmation phrase does not match the current preview.');
}
$operationId = $this->createOperationRun('coolify_cleanup', [
'channel_id' => $policy['channel_id'],
'app' => $policy['app'] ?: null,
'title' => 'Coolify cleanup',
'actor_user_id' => $actorUserId,
'context' => [
'policy' => $policy,
'selection_hash' => $preview['selection_hash'],
'candidate_count' => count($preview['candidates']),
],
]);
$this->recordOperationStep(
$operationId,
'preview',
'Verified cleanup preview',
'passed',
sprintf('%d release-managed Coolify resource(s) matched the confirmed selection.', count($preview['candidates'])),
null,
null,
[
'selection_hash' => $preview['selection_hash'],
'candidate_count' => count($preview['candidates']),
'protected_count' => count($preview['protected']),
'blocked_count' => count($preview['blocked']),
]
);
$results = [];
$failures = 0;
foreach ($preview['candidates'] as $candidate) {
try {
$result = $this->applyCoolifyCleanupResource($candidate, $policy['action']);
$stepStatus = ($result['status'] ?? '') === 'already_absent' ? 'warning' : 'passed';
$this->recordOperationStep(
$operationId,
'resource_' . substr(hash('sha256', (string)$candidate['resource_key']), 0, 16),
sprintf('%s %s', ucfirst($policy['action']), (string)$candidate['name']),
$stepStatus,
(string)($result['message'] ?? 'Coolify cleanup action completed.'),
null,
null,
[
'resource' => $candidate,
'result' => $result,
]
);
$results[] = $candidate + ['result' => $result];
} catch (Throwable $throwable) {
$failures++;
$result = [
'status' => 'failed',
'message' => $throwable->getMessage(),
];
$this->recordOperationStep(
$operationId,
'resource_' . substr(hash('sha256', (string)$candidate['resource_key']), 0, 16),
sprintf('%s %s', ucfirst($policy['action']), (string)$candidate['name']),
'failed',
$throwable->getMessage(),
null,
'Inspect the Coolify resource directly, then preview cleanup again before retrying.',
[
'resource' => $candidate,
]
);
$results[] = $candidate + ['result' => $result];
}
}
$status = $failures > 0 ? 'failed' : 'completed';
$summary = sprintf(
'Coolify cleanup %s: %d processed, %d failed.',
$status,
count($results),
$failures
);
$this->completeOperationRun(
$operationId,
$status,
$summary,
$failures > 0 ? 'Review failed cleanup steps before retrying. Successful steps may already have changed Coolify state.' : null
);
$this->audit($policy['channel_id'], null, 'coolify_cleanup_applied', $actorUserId, $failures > 0 ? 'warning' : 'info', [
'policy' => $policy,
'selection_hash' => $preview['selection_hash'],
'processed_count' => count($results),
'failure_count' => $failures,
]);
return [
'status' => $status,
'operation_id' => $operationId,
'summary' => $summary,
'policy' => $policy,
'selection_hash' => $preview['selection_hash'],
'processed_count' => count($results),
'failure_count' => $failures,
'results' => $results,
'protected' => $preview['protected'],
'blocked' => $preview['blocked'],
];
}
private function normalizeCoolifyCleanupInput(array $input): array
{
$instanceId = $this->nullablePositiveInt($input['instance_id'] ?? $input['coolify_instance_id'] ?? null);
$channelId = $this->nullablePositiveInt($input['channel_id'] ?? null);
$channelSlug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? ''));
if ($channelId === null && $channelSlug !== '') {
$channel = $this->findChannelBySlug($channelSlug);
if ($channel === null) {
throw new RuntimeException('Release channel was not found for Coolify cleanup.');
}
$channelId = (int)$channel['id'];
}
$appValue = strtolower(trim((string)($input['app'] ?? '')));
$app = null;
if ($appValue !== '' && !in_array($appValue, ['all', '*'], true)) {
$app = $this->normalizeApp($appValue);
}
$resourceType = strtolower(trim((string)($input['resource_type'] ?? $input['type'] ?? '')));
if (!in_array($resourceType, ['service', 'application'], true)) {
$resourceType = null;
}
$action = strtolower(trim((string)($input['action'] ?? 'delete')));
if (!in_array($action, ['delete', 'stop'], true)) {
throw new RuntimeException('Coolify cleanup action must be stop or delete.');
}
return [
'instance_id' => $instanceId,
'channel_id' => $channelId,
'channel_slug' => $channelId !== null ? (string)($this->getChannel($channelId)['slug'] ?? '') : null,
'app' => $app,
'resource_type' => $resourceType,
'action' => $action,
];
}
private function buildCoolifyCleanupPreview(array $policy): array
{
$instances = $this->coolifyCleanupInstances($policy);
$graph = $this->coolifyCleanupOwnershipGraph();
$candidates = [];
$protected = [];
$blocked = [];
$scanned = 0;
if (!$this->tableExists('coolify_instances')) {
$blocked[] = [
'reason' => 'coolify_instances_missing',
'message' => 'Coolify infrastructure tables are not available in this environment.',
];
}
foreach ($instances as $instance) {
$instanceId = (int)($instance['id'] ?? 0);
$rowsByType = [];
try {
$client = $this->coolifyClientForInstance($instance, 8);
if ($policy['resource_type'] === null || $policy['resource_type'] === 'application') {
$rowsByType['application'] = $this->payloadRows($client->listApplications());
}
if ($policy['resource_type'] === null || $policy['resource_type'] === 'service') {
$rowsByType['service'] = $this->payloadRows($client->listServices());
}
} catch (Throwable $throwable) {
$blocked[] = [
'reason' => 'inventory_failed',
'instance_id' => $instanceId,
'instance_label' => (string)($instance['label'] ?? ''),
'message' => $throwable->getMessage(),
];
continue;
}
foreach ($rowsByType as $type => $rows) {
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$resource = $this->normalizeCoolifyCleanupResource($row, $instance, $type);
if ($resource === null) {
continue;
}
$scanned++;
$key = (string)$resource['resource_key'];
$reference = $graph['references'][$key] ?? null;
if ($reference !== null && $this->coolifyCleanupReferenceMatchesPolicy($reference, $policy, (string)$resource['type'])) {
$publicResource = $this->publicCoolifyCleanupResource($resource, $reference, $graph['protected'][$key] ?? [], $policy);
if (!empty($graph['protected'][$key])) {
$protected[] = $publicResource;
} else {
$candidates[] = $publicResource;
}
continue;
}
if ($reference === null && $this->coolifyCleanupResourceLooksReleaseManaged($resource, $policy)) {
$blocked[] = [
'reason' => 'unowned_live_resource',
'message' => 'The Coolify resource looks release-related, but Release Manager has no UUID ownership record for it.',
'resource' => $this->publicCoolifyCleanupResource($resource, null, [], $policy),
];
}
}
}
}
$this->sortCoolifyCleanupResources($candidates);
$this->sortCoolifyCleanupResources($protected);
usort($blocked, static fn(array $a, array $b): int => strcmp((string)($a['reason'] ?? ''), (string)($b['reason'] ?? '')));
$selectionHash = $this->coolifyCleanupSelectionHash($policy, $candidates);
return [
'policy' => $policy,
'summary' => [
'instances_scanned' => count($instances),
'live_resources_scanned' => $scanned,
'candidates' => count($candidates),
'protected' => count($protected),
'blocked' => count($blocked),
],
'candidates' => $candidates,
'protected' => $protected,
'blocked' => $blocked,
'selection_hash' => $selectionHash,
'confirmation_phrase' => $this->coolifyCleanupConfirmationPhrase($selectionHash),
'generated_at' => date('c'),
];
}
private function coolifyCleanupInstances(array $policy): array
{
if (!$this->tableExists('coolify_instances')) {
return [];
}
if ($policy['instance_id'] !== null) {
return $this->selectRows(
'SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL ORDER BY label, id',
'i',
[(int)$policy['instance_id']]
);
}
return $this->selectRows('SELECT * FROM coolify_instances WHERE deleted_at IS NULL ORDER BY label, id');
}
private function coolifyCleanupOwnershipGraph(): array
{
$references = [];
$protected = [];
foreach ($this->coolifyCleanupDeploymentTargets() as $row) {
$instanceId = (int)($row['coolify_instance_id'] ?? 0);
$uuid = trim((string)($row['coolify_service_uuid'] ?? ''));
$context = self::jsonDecode($row['deploy_context_json'] ?? null);
$this->addCoolifyCleanupReference(
$references,
$protected,
$instanceId,
$uuid,
[
'source' => 'deployment_target',
'target_id' => (int)($row['id'] ?? 0),
'channel_id' => (int)($row['channel_id'] ?? 0),
'channel_slug' => (string)($row['channel_slug'] ?? ''),
'app' => (string)($row['app'] ?? ''),
'resource_type' => $this->releaseCoolifyResourceType($context, $uuid),
'deleted' => !empty($row['deleted_at']),
],
empty($row['deleted_at']),
'configured_target'
);
}
foreach ($this->coolifyCleanupServiceSetTargets() as $row) {
$instanceId = (int)($row['coolify_instance_id'] ?? 0);
$uuid = trim((string)($row['coolify_service_uuid'] ?? ''));
$context = self::jsonDecode($row['deploy_context_json'] ?? null);
$this->addCoolifyCleanupReference(
$references,
$protected,
$instanceId,
$uuid,
[
'source' => 'service_set_target',
'service_set_id' => (int)($row['service_set_id'] ?? 0),
'service_set_slug' => (string)($row['service_set_slug'] ?? ''),
'channel_id' => (int)($row['channel_id'] ?? 0),
'channel_slug' => (string)($row['channel_slug'] ?? ''),
'app' => (string)($row['app'] ?? ''),
'resource_type' => $this->releaseCoolifyResourceType($context, $uuid),
],
true,
'active_service_set_target'
);
}
$deploymentProtection = $this->coolifyCleanupDeploymentProtection();
foreach ($this->coolifyCleanupDeployments() as $row) {
$deploymentId = (int)($row['id'] ?? 0);
$instanceId = (int)($row['coolify_instance_id'] ?? 0);
$result = self::jsonDecode($row['result_json'] ?? null);
$serviceUuid = trim((string)($result['service_uuid'] ?? $result['resource_uuid'] ?? ''));
$context = self::jsonDecode($row['deploy_context_json'] ?? null);
$resourceType = strtolower(trim((string)($result['resource_type'] ?? ''))) ?: $this->releaseCoolifyResourceType($context, $serviceUuid);
$protectReason = null;
if (in_array($deploymentId, $deploymentProtection['active_ids'], true)) {
$protectReason = 'active_deployment';
} elseif (in_array($deploymentId, $deploymentProtection['rollback_ids'], true)) {
$protectReason = 'rollback_deployment';
}
$this->addCoolifyCleanupReference(
$references,
$protected,
$instanceId,
$serviceUuid,
[
'source' => 'deployment',
'deployment_id' => $deploymentId,
'channel_id' => (int)($row['channel_id'] ?? 0),
'channel_slug' => (string)($row['channel_slug'] ?? ''),
'app' => (string)($row['app'] ?? ''),
'status' => (string)($row['status'] ?? ''),
'resource_type' => $resourceType,
],
$protectReason !== null,
$protectReason ?? ''
);
$previousApplications = is_array($result['previous_applications'] ?? null) ? $result['previous_applications'] : [];
$previousIndex = 0;
foreach ($previousApplications as $previousApplication) {
$previousIndex++;
$previousUuid = is_array($previousApplication)
? trim((string)($previousApplication['uuid'] ?? $previousApplication['id'] ?? ''))
: trim((string)$previousApplication);
$protectPrevious = $protectReason === 'active_deployment' && $previousIndex === 1;
$this->addCoolifyCleanupReference(
$references,
$protected,
$instanceId,
$previousUuid,
[
'source' => 'deployment_previous_application',
'deployment_id' => $deploymentId,
'channel_id' => (int)($row['channel_id'] ?? 0),
'channel_slug' => (string)($row['channel_slug'] ?? ''),
'app' => (string)($row['app'] ?? ''),
'status' => (string)($row['status'] ?? ''),
'resource_type' => 'application',
],
$protectPrevious,
$protectPrevious ? 'rollback_deployment' : ''
);
}
}
foreach ($this->coolifyCleanupCoolifyTargets() as $row) {
$kind = strtolower(trim((string)($row['kind'] ?? '')));
$resourceUuid = trim((string)($row['resource_uuid'] ?? ''));
$isReleaseApp = in_array($kind, self::APPS, true);
$this->addCoolifyCleanupReference(
$references,
$protected,
(int)($row['instance_id'] ?? 0),
$resourceUuid,
[
'source' => 'coolify_target',
'coolify_target_id' => (int)($row['id'] ?? 0),
'app' => $isReleaseApp ? $kind : null,
'kind' => $kind,
'resource_type' => (string)($row['resource_type'] ?? 'service'),
'deleted' => !empty($row['deleted_at']),
],
empty($row['deleted_at']),
$isReleaseApp ? 'active_coolify_target' : 'active_data_service'
);
}
return [
'references' => $references,
'protected' => $protected,
];
}
private function coolifyCleanupDeploymentTargets(): array
{
return $this->selectRows(
"SELECT t.*, c.slug AS channel_slug
FROM release_deployment_targets t
LEFT JOIN release_channels c ON c.id = t.channel_id
WHERE t.coolify_instance_id IS NOT NULL
AND COALESCE(t.coolify_service_uuid, '') <> ''"
);
}
private function coolifyCleanupServiceSetTargets(): array
{
return array_merge(
$this->selectRows(
"SELECT s.id AS service_set_id, s.slug AS service_set_slug, s.channel_id,
c.slug AS channel_slug, 'frontend' AS app,
t.coolify_instance_id, t.coolify_service_uuid, t.deploy_context_json
FROM release_service_sets s
INNER JOIN release_deployment_targets t ON t.id = s.frontend_target_id
LEFT JOIN release_channels c ON c.id = s.channel_id
WHERE s.deleted_at IS NULL
AND t.coolify_instance_id IS NOT NULL
AND COALESCE(t.coolify_service_uuid, '') <> ''"
),
$this->selectRows(
"SELECT s.id AS service_set_id, s.slug AS service_set_slug, s.channel_id,
c.slug AS channel_slug, 'api' AS app,
t.coolify_instance_id, t.coolify_service_uuid, t.deploy_context_json
FROM release_service_sets s
INNER JOIN release_deployment_targets t ON t.id = s.api_target_id
LEFT JOIN release_channels c ON c.id = s.channel_id
WHERE s.deleted_at IS NULL
AND t.coolify_instance_id IS NOT NULL
AND COALESCE(t.coolify_service_uuid, '') <> ''"
)
);
}
private function coolifyCleanupDeployments(): array
{
return $this->selectRows(
"SELECT d.*, c.slug AS channel_slug, t.coolify_instance_id, t.deploy_context_json
FROM release_deployments d
LEFT JOIN release_channels c ON c.id = d.channel_id
LEFT JOIN release_deployment_targets t ON t.id = d.target_id
WHERE d.provider = 'coolify'
AND d.result_json IS NOT NULL
AND t.coolify_instance_id IS NOT NULL"
);
}
private function coolifyCleanupDeploymentProtection(): array
{
$activeIds = [];
$rollbackIds = [];
$rollbackSeen = [];
$rows = $this->selectRows(
"SELECT id, channel_id, app, status, active_channel_app_key, completed_at, created_at
FROM release_deployments
WHERE provider = 'coolify'
AND status IN ('active', 'deployed')
ORDER BY channel_id ASC, app ASC, COALESCE(completed_at, created_at) DESC, id DESC"
);
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
$group = (int)($row['channel_id'] ?? 0) . ':' . (string)($row['app'] ?? '');
$isActive = trim((string)($row['active_channel_app_key'] ?? '')) !== '' || (string)($row['status'] ?? '') === 'active';
if ($isActive) {
$activeIds[] = $id;
continue;
}
if (!isset($rollbackSeen[$group])) {
$rollbackIds[] = $id;
$rollbackSeen[$group] = true;
}
}
return [
'active_ids' => array_values(array_unique($activeIds)),
'rollback_ids' => array_values(array_unique($rollbackIds)),
];
}
private function coolifyCleanupCoolifyTargets(): array
{
if (!$this->tableExists('coolify_targets')) {
return [];
}
return $this->selectRows(
"SELECT id, instance_id, kind, resource_uuid, resource_type, resource_name, deleted_at
FROM coolify_targets
WHERE resource_uuid IS NOT NULL
AND resource_uuid <> ''"
);
}
private function addCoolifyCleanupReference(
array &$references,
array &$protected,
int $instanceId,
string $uuid,
array $evidence,
bool $protect,
string $protectReason
): void {
$uuid = trim($uuid);
if ($instanceId <= 0 || $uuid === '') {
return;
}
$key = $this->coolifyCleanupResourceKey($instanceId, $uuid);
if (!isset($references[$key])) {
$references[$key] = [
'instance_id' => $instanceId,
'uuid' => $uuid,
'apps' => [],
'channels' => [],
'channel_slugs' => [],
'resource_types' => [],
'evidence' => [],
];
}
$app = strtolower(trim((string)($evidence['app'] ?? '')));
if (in_array($app, self::APPS, true)) {
$references[$key]['apps'][$app] = $app;
}
$channelId = $this->nullablePositiveInt($evidence['channel_id'] ?? null);
if ($channelId !== null) {
$references[$key]['channels'][$channelId] = $channelId;
}
$channelSlug = self::safeSlug((string)($evidence['channel_slug'] ?? ''));
if ($channelSlug !== '') {
$references[$key]['channel_slugs'][$channelSlug] = $channelSlug;
}
$resourceType = strtolower(trim((string)($evidence['resource_type'] ?? '')));
if (in_array($resourceType, ['service', 'application'], true)) {
$references[$key]['resource_types'][$resourceType] = $resourceType;
}
$references[$key]['evidence'][] = self::redactPayload($evidence);
if ($protect && $protectReason !== '') {
$protected[$key][] = [
'reason' => $protectReason,
'source' => (string)($evidence['source'] ?? ''),
'channel_id' => $channelId,
'channel_slug' => $channelSlug !== '' ? $channelSlug : null,
'app' => in_array($app, self::APPS, true) ? $app : null,
'deployment_id' => isset($evidence['deployment_id']) ? (int)$evidence['deployment_id'] : null,
'target_id' => isset($evidence['target_id']) ? (int)$evidence['target_id'] : null,
'service_set_id' => isset($evidence['service_set_id']) ? (int)$evidence['service_set_id'] : null,
];
}
}
private function normalizeCoolifyCleanupResource(array $row, array $instance, string $type): ?array
{
$uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
if ($uuid === '') {
return null;
}
$urls = [];
foreach (['fqdn', 'domain', 'url', 'base_url'] as $key) {
$this->appendSuggestion($urls, $row[$key] ?? null);
}
foreach (['urls', 'domains', 'fqdns'] as $key) {
if (!is_array($row[$key] ?? null)) {
continue;
}
foreach ($row[$key] as $url) {
if (is_array($url)) {
$this->appendSuggestion($urls, $url['url'] ?? $url['fqdn'] ?? $url['domain'] ?? null);
} else {
$this->appendSuggestion($urls, $url);
}
}
}
$name = trim((string)($row['name'] ?? $row['service_name'] ?? $row['application_name'] ?? ''));
if ($name === '') {
$name = $urls[0] ?? $uuid;
}
$instanceId = (int)($instance['id'] ?? 0);
return [
'resource_key' => $this->coolifyCleanupResourceKey($instanceId, $uuid),
'instance_id' => $instanceId,
'instance_label' => (string)($instance['label'] ?? ''),
'instance_base_url' => (string)($instance['base_url'] ?? ''),
'type' => $type,
'uuid' => $uuid,
'name' => $name,
'status' => (string)($row['status'] ?? $row['deployment_status'] ?? $row['state'] ?? 'unknown'),
'project_uuid' => $row['project_uuid'] ?? null,
'environment_uuid' => $row['environment_uuid'] ?? null,
'server_uuid' => $row['server_uuid'] ?? null,
'destination_uuid' => $row['destination_uuid'] ?? null,
'urls' => array_values($urls),
];
}
private function publicCoolifyCleanupResource(array $resource, ?array $reference, array $protection, array $policy): array
{
$apps = $reference !== null ? array_values($reference['apps']) : [];
$channels = $reference !== null ? array_values($reference['channels']) : [];
$channelSlugs = $reference !== null ? array_values($reference['channel_slugs']) : [];
$evidence = $reference !== null ? array_slice($reference['evidence'], 0, 20) : [];
return [
'resource_key' => (string)$resource['resource_key'],
'instance_id' => (int)$resource['instance_id'],
'instance_label' => (string)$resource['instance_label'],
'type' => (string)$resource['type'],
'uuid' => (string)$resource['uuid'],
'name' => (string)$resource['name'],
'status' => (string)$resource['status'],
'urls' => array_values($resource['urls'] ?? []),
'apps' => $apps,
'channels' => $channels,
'channel_slugs' => $channelSlugs,
'action' => (string)$policy['action'],
'evidence' => $evidence,
'protection' => array_values($protection),
];
}
private function coolifyCleanupReferenceMatchesPolicy(array $reference, array $policy, ?string $liveType = null): bool
{
$apps = array_values(array_intersect(array_values($reference['apps'] ?? []), self::APPS));
if ($apps === []) {
return false;
}
if ($policy['app'] !== null && !in_array($policy['app'], $apps, true)) {
return false;
}
if ($policy['channel_id'] !== null && !in_array((int)$policy['channel_id'], array_values($reference['channels'] ?? []), true)) {
return false;
}
if (
$policy['resource_type'] !== null
&& $liveType !== $policy['resource_type']
&& !in_array($policy['resource_type'], array_values($reference['resource_types'] ?? []), true)
) {
return false;
}
return true;
}
private function coolifyCleanupResourceLooksReleaseManaged(array $resource, array $policy): bool
{
$haystack = strtolower(implode(' ', array_filter([
$resource['name'] ?? '',
$resource['uuid'] ?? '',
implode(' ', array_values($resource['urls'] ?? [])),
])));
if ($haystack === '') {
return false;
}
$appNeedles = $policy['app'] !== null
? [$policy['app']]
: ['frontend', 'front-end', 'vue', 'api', 'backend'];
$matchesApp = false;
foreach ($appNeedles as $needle) {
if (str_contains($haystack, $needle)) {
$matchesApp = true;
break;
}
}
if (!$matchesApp) {
return false;
}
foreach (['pleno', 'truckwash', 'release', 'stable', 'master', 'beta', 'canary', 'internal'] as $needle) {
if (str_contains($haystack, $needle)) {
return true;
}
}
return false;
}
private function sortCoolifyCleanupResources(array &$resources): void
{
usort($resources, static function (array $a, array $b): int {
return strcmp(
implode(':', [
implode(',', $a['channel_slugs'] ?? []),
implode(',', $a['apps'] ?? []),
(string)($a['instance_label'] ?? ''),
(string)($a['name'] ?? ''),
(string)($a['uuid'] ?? ''),
]),
implode(':', [
implode(',', $b['channel_slugs'] ?? []),
implode(',', $b['apps'] ?? []),
(string)($b['instance_label'] ?? ''),
(string)($b['name'] ?? ''),
(string)($b['uuid'] ?? ''),
])
);
});
}
private function coolifyCleanupSelectionHash(array $policy, array $candidates): string
{
$selection = array_map(static function (array $candidate): array {
return [
'instance_id' => (int)$candidate['instance_id'],
'type' => (string)$candidate['type'],
'uuid' => (string)$candidate['uuid'],
'action' => (string)$candidate['action'],
];
}, $candidates);
usort($selection, static fn(array $a, array $b): int => strcmp(self::jsonEncode($a), self::jsonEncode($b)));
return hash('sha256', self::jsonEncode([
'policy' => [
'instance_id' => $policy['instance_id'],
'channel_id' => $policy['channel_id'],
'app' => $policy['app'],
'resource_type' => $policy['resource_type'],
'action' => $policy['action'],
],
'selection' => $selection,
]));
}
private function coolifyCleanupConfirmationPhrase(string $hash): string
{
return 'cleanup-coolify-' . substr($hash, 0, 12);
}
private function applyCoolifyCleanupResource(array $candidate, string $action): array
{
$instanceId = (int)($candidate['instance_id'] ?? 0);
$uuid = trim((string)($candidate['uuid'] ?? ''));
$type = strtolower(trim((string)($candidate['type'] ?? '')));
if ($instanceId <= 0 || $uuid === '' || !in_array($type, ['service', 'application'], true)) {
throw new RuntimeException('Cleanup candidate is missing a Coolify instance, type, or UUID.');
}
$instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]);
if ($instance === null) {
throw new RuntimeException('Coolify instance for cleanup was not found.');
}
$client = $this->coolifyClientForInstance($instance, 20);
try {
if ($action === 'stop') {
$result = $type === 'application'
? $client->stopApplication($uuid)
: $client->stopService($uuid);
return [
'status' => 'stop_requested',
'message' => 'Coolify stop was requested.',
'response' => self::redactPayload($result),
];
}
$result = $type === 'application'
? $client->deleteApplication($uuid)
: $client->deleteService($uuid);
return [
'status' => 'delete_requested',
'message' => 'Coolify delete was requested.',
'response' => self::redactPayload($result),
];
} catch (Throwable $throwable) {
if ($this->coolifyCleanupThrowableIsNotFound($throwable)) {
return [
'status' => 'already_absent',
'message' => 'Coolify resource was already absent.',
];
}
throw $throwable;
}
}
private function coolifyCleanupThrowableIsNotFound(Throwable $throwable): bool
{
$message = strtolower($throwable->getMessage());
return str_contains($message, 'http 404') || str_contains($message, 'not found') || str_contains($message, '404');
}
private function coolifyCleanupResourceKey(int $instanceId, string $uuid): string
{
return $instanceId . ':' . trim($uuid);
}
public function listBundles(int $limit = 50): array
{
if (!release_manager_schema_bootstrap::tablesExist()) {
return [];
}
$limit = max(1, min(250, $limit));
return array_map(
fn(array $row): array => $this->publicBundle($row),
$this->selectRows(
"SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug
FROM release_bundles b
INNER JOIN release_channels c ON c.id = b.channel_id
INNER JOIN release_service_sets s ON s.id = b.service_set_id
WHERE b.deleted_at IS NULL
ORDER BY b.created_at DESC, b.id DESC
LIMIT $limit"
)
);
}
public function createBundle(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$serviceSetId = $this->nullablePositiveInt($input['service_set_id'] ?? null);
if ($serviceSetId === null) {
throw new RuntimeException('A release service set is required before creating a bundle.');
}
$serviceSet = $this->getServiceSet($serviceSetId);
$channel = $this->channelFromInputOrDefault($input, $serviceSet);
if ($this->channelUsesProductionServices($channel)) {
throw new RuntimeException('Beta release channel uses production services and does not create separate release bundles.');
}
$this->assertBetaProductionDataPolicy($channel, $serviceSet);
$versionLabel = trim((string)($input['version_label'] ?? ''));
if ($versionLabel === '') {
$versionLabel = sprintf('%s-bundle-%s', (string)($channel['slug'] ?? 'release'), date('Ymd-His'));
}
$frontendTarget = $this->nullableDeploymentTarget((int)($serviceSet['frontend_target_id'] ?? 0) ?: null);
$apiTarget = $this->nullableDeploymentTarget((int)($serviceSet['api_target_id'] ?? 0) ?: null);
$frontend = $this->bundleAppInput($input, 'frontend', $frontendTarget, $versionLabel);
$api = $this->bundleAppInput($input, 'api', $apiTarget, $versionLabel);
$frontendVersionId = $this->createBundleVersion($frontend, 'frontend');
$apiVersionId = $this->createBundleVersion($api, 'api');
$metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null);
$metadata['service_set_id'] = $serviceSetId;
$metadata['stack_services'] = array_merge(['frontend', 'api'], self::STACK_DATA_KINDS);
$metadata['promotion_policy'] = 'attach_code_and_service_set_only';
$this->execute(
"INSERT INTO release_bundles (
channel_id, service_set_id, version_label,
frontend_version_id, api_version_id,
frontend_repository, frontend_branch, frontend_commit_sha,
api_repository, api_branch, api_commit_sha,
metadata_json, actor_user_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
'iisiisssssssi',
[
(int)$channel['id'],
$serviceSetId,
$versionLabel,
$frontendVersionId,
$apiVersionId,
$frontend['repository'],
$frontend['branch'],
$frontend['commit_sha'],
$api['repository'],
$api['branch'],
$api['commit_sha'],
self::jsonEncode($metadata),
$actorUserId,
]
);
$id = $this->insertId();
$this->audit((int)$channel['id'], null, 'bundle_created', $actorUserId, 'info', [
'bundle_id' => $id,
'service_set_id' => $serviceSetId,
'frontend_repository' => $frontend['repository'],
'api_repository' => $api['repository'],
]);
return $this->publicBundle($this->getBundle($id));
}
public function deployBundle(int $bundleId, ?int $actorUserId = null): array
{
$this->ensureSchema();
$bundle = $this->getBundle($bundleId);
$serviceSet = $this->getServiceSet((int)$bundle['service_set_id']);
$channel = $this->getChannel((int)$bundle['channel_id']);
if ($this->channelUsesProductionServices($channel)) {
throw new RuntimeException('Beta release channel uses production services and does not deploy separate release bundles.');
}
$this->assertBetaProductionDataPolicy($channel, $serviceSet);
$results = [];
$deploymentIds = ['frontend' => null, 'api' => null];
foreach (['frontend', 'api'] as $app) {
$versionId = $this->nullablePositiveInt($bundle[$app . '_version_id'] ?? null);
if ($versionId === null) {
$results[$app] = ['status' => 'skipped', 'message' => 'No release version is attached to this app.'];
continue;
}
$deployment = $this->startDeployment([
'channel_id' => (int)$bundle['channel_id'],
'target_id' => $this->nullablePositiveInt($serviceSet[$app . '_target_id'] ?? null),
'version_id' => $versionId,
'service_set_id' => (int)$bundle['service_set_id'],
'bundle_id' => $bundleId,
'deployment_kind' => 'bundle_member',
'app' => $app,
'repository' => $bundle[$app . '_repository'] ?? '',
'branch' => $bundle[$app . '_branch'] ?? self::DEFAULT_BRANCH,
'commit_mode' => trim((string)($bundle[$app . '_commit_sha'] ?? '')) !== '' ? 'specific' : 'latest',
'commit_sha' => $bundle[$app . '_commit_sha'] ?? '',
'version_label' => $bundle['version_label'] ?? null,
], $actorUserId);
$deploymentIds[$app] = (int)($deployment['id'] ?? 0) ?: null;
$results[$app] = $deployment;
}
$statuses = array_map(static fn(array $result): string => strtolower((string)($result['status'] ?? 'unknown')), $results);
$status = in_array('failed', $statuses, true)
? 'failed'
: (count(array_intersect($statuses, ['queued', 'deploying', 'unknown', 'skipped'])) > 0 ? 'deploying' : 'deployed');
$this->execute(
"UPDATE release_bundles
SET status = ?, frontend_deployment_id = ?, api_deployment_id = ?,
deployment_result_json = ?, deployed_at = CASE WHEN ? IN ('deployed', 'deploying') THEN NOW() ELSE deployed_at END
WHERE id = ?",
'siissi',
[
$status,
$deploymentIds['frontend'],
$deploymentIds['api'],
self::jsonEncode(self::redactPayload($results)),
$status,
$bundleId,
]
);
$this->audit((int)$bundle['channel_id'], null, 'bundle_deployed', $actorUserId, $status === 'failed' ? 'error' : 'info', [
'bundle_id' => $bundleId,
'service_set_id' => (int)$bundle['service_set_id'],
'status' => $status,
]);
return $this->publicBundle($this->getBundle($bundleId));
}
public function promoteBundle(int $bundleId, ?int $actorUserId = null): array
{
$this->ensureSchema();
$bundle = $this->getBundle($bundleId);
$status = strtolower((string)($bundle['status'] ?? ''));
if (!in_array($status, ['deployed', 'active', 'promoted'], true)) {
throw new RuntimeException('Only deployed release bundles can be promoted.');
}
$channelId = (int)$bundle['channel_id'];
$channel = $this->getChannel($channelId);
$serviceSetId = (int)$bundle['service_set_id'];
$serviceSet = $this->getServiceSet($serviceSetId);
if ($this->channelUsesProductionServices($channel)) {
throw new RuntimeException('Beta release channel uses production services and does not promote separate release bundles.');
}
$this->assertBetaProductionDataPolicy($channel, $serviceSet);
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
$this->assertReleaseGatePassedForPromotion(
$channelId,
(string)($bundle['frontend_commit_sha'] ?? ''),
null,
'frontend'
);
if ($apiVersionId !== null) {
$this->assertReleaseGatePassedForPromotion(
$channelId,
(string)($bundle['api_commit_sha'] ?? ''),
null,
'api'
);
}
$deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null)
?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null);
$this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]);
$this->execute(
"UPDATE release_bundles
SET status = 'superseded'
WHERE channel_id = ? AND id <> ? AND status = 'promoted' AND deleted_at IS NULL",
'ii',
[$channelId, $bundleId]
);
$this->execute(
"UPDATE release_deployments
SET status = 'superseded', active_channel_app_key = NULL
WHERE channel_id = ? AND bundle_id IS NOT NULL AND bundle_id <> ? AND status = 'active'",
'ii',
[$channelId, $bundleId]
);
$this->execute(
"INSERT INTO release_channel_versions (
channel_id, frontend_version_id, api_version_id, deployment_id,
service_set_id, bundle_id, actor_user_id, active
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
'iiiiiii',
[$channelId, $frontendVersionId, $apiVersionId, $deploymentId, $serviceSetId, $bundleId, $actorUserId]
);
$this->execute(
"UPDATE release_bundles SET status = 'promoted', promoted_at = NOW() WHERE id = ?",
'i',
[$bundleId]
);
foreach ($this->selectRows('SELECT id, app FROM release_deployments WHERE bundle_id = ?', 'i', [$bundleId]) as $bundleDeployment) {
$bundleDeploymentId = (int)($bundleDeployment['id'] ?? 0);
$app = (string)($bundleDeployment['app'] ?? '');
if ($bundleDeploymentId > 0 && in_array($app, self::APPS, true)) {
$this->activateDeploymentForChannelApp($bundleDeploymentId, $channelId, $app);
}
}
foreach ([$frontendVersionId, $apiVersionId] as $versionId) {
if ($versionId !== null) {
$this->execute(
"UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?",
'i',
[$versionId]
);
}
}
$this->audit($channelId, $deploymentId, 'bundle_promoted', $actorUserId, 'info', [
'bundle_id' => $bundleId,
'service_set_id' => $serviceSetId,
'data_promotion' => false,
'replica_failover' => false,
]);
return [
'channel' => $this->publicChannel($this->getChannel($channelId)),
'versions' => $this->currentVersionsForChannel($channelId),
'service_set' => $this->publicServiceSet($this->getServiceSet($serviceSetId)),
'bundle' => $this->publicBundle($this->getBundle($bundleId)),
];
}
public function setChannelBundle(int $channelId, array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$channel = $this->getChannel($channelId);
$bundleId = $this->nullablePositiveInt($input['bundle_id'] ?? null);
if ($bundleId === null) {
throw new RuntimeException('A release bundle is required.');
}
$bundle = $this->getBundle($bundleId);
if ((int)$bundle['channel_id'] !== (int)$channel['id']) {
throw new RuntimeException('Release bundle does not belong to this channel.');
}
$status = strtolower((string)($bundle['status'] ?? ''));
if (!in_array($status, ['deployed', 'promoted', 'active'], true)) {
throw new RuntimeException('Only deployed release bundles can be set on a channel.');
}
$previous = $this->currentChannelVersionRow($channelId);
$result = $this->promoteBundle($bundleId, $actorUserId);
$this->audit($channelId, null, 'channel_bundle_set', $actorUserId, 'info', [
'bundle_id' => $bundleId,
'previous_bundle_id' => $this->nullablePositiveInt($previous['bundle_id'] ?? null),
]);
return $result;
}
public function listDeployments(int $limit = 50): array
{
if (!release_manager_schema_bootstrap::tablesExist()) {
return [];
}
$limit = max(1, min(250, $limit));
return array_map(
fn(array $row): array => $this->publicDeployment($row),
$this->selectRows(
"SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
FROM release_deployments d
INNER JOIN release_channels c ON c.id = d.channel_id
LEFT JOIN release_versions v ON v.id = d.version_id
ORDER BY d.created_at DESC
LIMIT $limit"
)
);
}
public function startDeployment(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$channel = $this->channelFromInput($input);
if ($this->channelUsesProductionServices($channel)) {
throw new RuntimeException('Beta release channel uses production services and cannot deploy separate frontend or API services.');
}
$app = $this->normalizeApp((string)($input['app'] ?? ''));
$target = $this->deploymentTargetFromInput($input, (int)$channel['id'], $app);
$repository = trim((string)($input['repository'] ?? $target['repository'] ?? ''));
$branch = trim((string)($input['branch'] ?? $target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
$normalizedRepository = self::normalizeGithubRepositoryName($repository);
if ($normalizedRepository !== '') {
$repository = $normalizedRepository;
}
$rawCommitSha = trim((string)($input['commit_sha'] ?? $input['commit'] ?? ''));
$commitMode = $this->normalizeCommitMode((string)($input['commit_mode'] ?? ''), $rawCommitSha);
$commitSha = $commitMode === 'specific' && $rawCommitSha !== '' ? $rawCommitSha : null;
$githubAccess = null;
if ($repository !== '') {
$githubAccess = $this->githubRepositoryAccess([
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'commit_mode' => $commitMode,
]);
if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) {
throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.'));
}
if (($githubAccess['ok'] ?? false) && !empty($githubAccess['commit_sha'])) {
$commitSha = (string)$githubAccess['commit_sha'];
$branch = (string)($githubAccess['branch'] ?? $branch);
}
}
$versionLabel = trim((string)($input['version_label'] ?? $input['tag'] ?? $commitSha ?? date('Ymd-His'))) ?: date('Ymd-His');
$targetPublicUrl = is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null;
$deployedUrl = $this->normalizeReleasePublicBaseUrl(
$input['deployed_url'] ?? $targetPublicUrl ?? $target['health_url'] ?? null,
$app
);
$versionId = $this->nullablePositiveInt($input['version_id'] ?? null);
if ($versionId !== null) {
$version = $this->getVersion($versionId);
if ((string)($version['app'] ?? '') !== $app) {
throw new RuntimeException('Release bundle version does not match the deployment app.');
}
$this->execute(
"UPDATE release_versions
SET repository = COALESCE(NULLIF(?, ''), repository),
branch = COALESCE(NULLIF(?, ''), branch),
commit_sha = COALESCE(?, commit_sha),
version_label = COALESCE(NULLIF(?, ''), version_label),
deployed_url = COALESCE(?, deployed_url),
status = 'deploying'
WHERE id = ?",
'sssssi',
[$repository, $branch, $commitSha, $versionLabel, $deployedUrl, $versionId]
);
} else {
$versionId = $this->createVersion([
'app' => $app,
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'tag' => trim((string)($input['tag'] ?? '')) ?: null,
'version_label' => $versionLabel,
'build_url' => trim((string)($input['build_url'] ?? '')) ?: null,
'artifact_url' => trim((string)($input['artifact_url'] ?? '')) ?: null,
'deployed_url' => $deployedUrl,
'status' => 'deploying',
'metadata' => is_array($input['metadata'] ?? null) ? $input['metadata'] : [],
]);
}
$requestedPayload = self::redactPayload($input);
if (is_array($requestedPayload)) {
$requestedPayload['commit_mode'] = $commitMode;
$requestedPayload['github_access'] = $githubAccess;
}
$targetId = isset($target['id']) ? (int)$target['id'] : null;
$serviceSetId = $this->nullablePositiveInt($input['service_set_id'] ?? null);
$bundleId = $this->nullablePositiveInt($input['bundle_id'] ?? null);
$deploymentKind = self::safeIdentifier((string)($input['deployment_kind'] ?? 'single_app'), 32) ?: 'single_app';
$this->execute(
"INSERT INTO release_deployments (
channel_id, target_id, version_id, service_set_id, bundle_id, deployment_kind, app, provider, repository, branch,
commit_sha, status, actor_user_id, requested_payload_json, started_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'coolify', ?, ?, ?, 'deploying', ?, ?, NOW())",
'iiiiisssssis',
[
(int)$channel['id'],
$targetId,
$versionId,
$serviceSetId,
$bundleId,
$deploymentKind,
$app,
$repository,
$branch,
$commitSha,
$actorUserId,
self::jsonEncode($requestedPayload),
]
);
$deploymentId = $this->insertId();
try {
$result = ['message' => 'Deployment recorded; no Coolify service target is configured.'];
$status = 'queued';
if ($target !== null && !empty($target['coolify_instance_id'])) {
$coolifyTarget = array_replace($target, [
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha ?? '',
]);
$result = $this->deployCoolifyReleaseTarget($coolifyTarget);
$status = 'deployed';
if ($app === 'api') {
$result['cron_worker'] = $this->deployCronWorkerAfterApiDeployment(
$coolifyTarget,
$commitSha,
$actorUserId,
$deploymentId
);
}
}
$effectiveDeployedUrl = $this->normalizeReleasePublicBaseUrl($result['public_url'] ?? $deployedUrl, $app);
$this->execute(
"UPDATE release_deployments
SET status = ?, result_json = ?, deployment_url = ?, completed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE NULL END
WHERE id = ?",
'ssssi',
[$status, self::jsonEncode(self::redactPayload($result)), $effectiveDeployedUrl, $status, $deploymentId]
);
$this->execute(
"UPDATE release_versions
SET status = ?,
deployed_url = COALESCE(?, deployed_url),
deployed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE deployed_at END
WHERE id = ?",
'sssi',
[$status === 'deployed' ? 'deployed' : 'deploying', $effectiveDeployedUrl, $status, $versionId]
);
$this->audit((int)$channel['id'], $deploymentId, 'deployment_started', $actorUserId, 'info', [
'app' => $app,
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'commit_mode' => $commitMode,
'github_access_status' => $githubAccess['status'] ?? null,
'status' => $status,
]);
} catch (Throwable $throwable) {
$failureSummary = self::deploymentFailureSummary($throwable, [
'app' => $app,
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'target_id' => $targetId,
'coolify_instance_id' => is_array($target) ? ($target['coolify_instance_id'] ?? null) : null,
'coolify_service_uuid' => is_array($target) ? ($target['coolify_service_uuid'] ?? null) : null,
]);
$failureResult = [
'message' => 'Deployment failed before promotion. A successful deployment is required before promotion.',
'failure_summary' => $failureSummary,
];
$this->execute(
"UPDATE release_deployments SET status = 'failed', result_json = ?, error_message = ?, completed_at = NOW() WHERE id = ?",
'ssi',
[self::jsonEncode($failureResult), $throwable->getMessage(), $deploymentId]
);
$this->execute("UPDATE release_versions SET status = 'failed' WHERE id = ?", 'i', [$versionId]);
$this->audit((int)$channel['id'], $deploymentId, 'deployment_failed', $actorUserId, 'error', [
'error' => $throwable->getMessage(),
'failure_summary' => $failureSummary,
'app' => $app,
]);
}
return $this->publicDeployment($this->getDeployment($deploymentId));
}
private function deployCronWorkerAfterApiDeployment(array $apiTarget, ?string $commitSha, ?int $actorUserId, int $deploymentId): array
{
if (!$this->cronWorkerAutoprovisionEnabled($apiTarget)) {
return [
'ok' => true,
'skipped' => true,
'required' => false,
'reason' => 'cron_worker_autoprovision_disabled',
];
}
try {
$result = $this->deployCronWorkerForApiTarget($apiTarget, $commitSha, $actorUserId, false, $deploymentId);
$result['required'] = $this->cronWorkerAutoprovisionRequired($apiTarget);
return $result;
} catch (Throwable $throwable) {
$channelId = (int)($apiTarget['channel_id'] ?? 0) ?: null;
$this->audit($channelId, $deploymentId, 'cron_worker_deploy_failed', $actorUserId, 'warning', [
'api_target_id' => (int)($apiTarget['id'] ?? 0),
'commit_sha' => $commitSha,
'error' => $throwable->getMessage(),
'required' => $this->cronWorkerAutoprovisionRequired($apiTarget),
]);
if ($this->cronWorkerAutoprovisionRequired($apiTarget)) {
throw new RuntimeException(
'Cron worker deployment is required for API deployments but did not complete: ' . $throwable->getMessage(),
0,
$throwable
);
}
return [
'ok' => false,
'required' => false,
'warning' => 'API deployment completed, but the cron worker deployment did not complete.',
'error' => $throwable->getMessage(),
];
}
}
private function cronWorkerAutoprovisionEnabled(array $apiTarget): bool
{
$context = self::jsonDecode($apiTarget['deploy_context_json'] ?? null);
foreach (['cron_worker_autoprovision', 'cron_worker_enabled'] as $key) {
if (array_key_exists($key, $context)) {
return $this->toBool($context[$key]);
}
}
return true;
}
private function cronWorkerAutoprovisionRequired(array $apiTarget): bool
{
if (!$this->cronWorkerAutoprovisionEnabled($apiTarget)) {
return false;
}
$context = self::jsonDecode($apiTarget['deploy_context_json'] ?? null);
foreach (['cron_worker_autoprovision_required', 'cron_worker_required'] as $key) {
if (array_key_exists($key, $context)) {
return $this->toBool($context[$key]);
}
}
return true;
}
public function promoteDeployment(int $deploymentId, ?int $actorUserId = null): array
{
$this->ensureSchema();
$deployment = $this->getDeployment($deploymentId);
if (!self::deploymentCanBePromoted((string)($deployment['status'] ?? ''))) {
throw new RuntimeException(self::deploymentPromotionBlockedReason($deployment));
}
$versionId = (int)($deployment['version_id'] ?? 0);
if ($versionId <= 0) {
throw new RuntimeException('Deployment has no release version to promote.');
}
$channelId = (int)$deployment['channel_id'];
if ($this->channelUsesProductionServices($this->getChannel($channelId))) {
throw new RuntimeException('Beta release channel uses production services and does not promote separate deployments.');
}
$this->assertReleaseGatePassedForPromotion(
$channelId,
(string)($deployment['commit_sha'] ?? ''),
null,
(string)($deployment['app'] ?? '')
);
$current = $this->currentChannelVersionRow($channelId);
$frontendVersionId = (int)($current['frontend_version_id'] ?? 0) ?: null;
$apiVersionId = (int)($current['api_version_id'] ?? 0) ?: null;
if ((string)$deployment['app'] === 'frontend') {
$frontendVersionId = $versionId;
} else {
$apiVersionId = $versionId;
}
$this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]);
$this->execute(
"INSERT INTO release_channel_versions (channel_id, frontend_version_id, api_version_id, deployment_id, actor_user_id, active)
VALUES (?, ?, ?, ?, ?, 1)",
'iiiii',
[$channelId, $frontendVersionId, $apiVersionId, $deploymentId, $actorUserId]
);
$this->activateDeploymentForChannelApp($deploymentId, $channelId, (string)$deployment['app']);
$this->execute("UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?", 'i', [$versionId]);
$this->audit($channelId, $deploymentId, 'deployment_promoted', $actorUserId, 'info', [
'app' => $deployment['app'],
'version_id' => $versionId,
]);
return [
'channel' => $this->publicChannel($this->getChannel($channelId)),
'versions' => $this->currentVersionsForChannel($channelId),
'deployment' => $this->publicDeployment($this->getDeployment($deploymentId)),
];
}
public function rollbackChannel(int $channelId, ?int $actorUserId = null): array
{
$this->ensureSchema();
$channel = $this->getChannel($channelId);
$previous = $this->selectOne(
"SELECT * FROM release_channel_versions
WHERE channel_id = ? AND active = 0
ORDER BY activated_at DESC, id DESC
LIMIT 1",
'i',
[$channelId]
);
if ($previous === null) {
throw new RuntimeException('No previous release version exists for this channel.');
}
$this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]);
$this->execute(
"INSERT INTO release_channel_versions (
channel_id, frontend_version_id, api_version_id, deployment_id,
service_set_id, bundle_id, actor_user_id, active
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
'iiiiiii',
[
$channelId,
(int)($previous['frontend_version_id'] ?? 0) ?: null,
(int)($previous['api_version_id'] ?? 0) ?: null,
(int)($previous['deployment_id'] ?? 0) ?: null,
(int)($previous['service_set_id'] ?? 0) ?: null,
(int)($previous['bundle_id'] ?? 0) ?: null,
$actorUserId,
]
);
foreach (self::APPS as $app) {
$versionId = $this->nullablePositiveInt($previous[$app . '_version_id'] ?? null);
if ($versionId === null) {
continue;
}
$deployment = $this->selectOne(
"SELECT id FROM release_deployments
WHERE channel_id = ? AND app = ? AND version_id = ?
ORDER BY completed_at DESC, id DESC
LIMIT 1",
'isi',
[$channelId, $app, $versionId]
);
if ($deployment !== null) {
$this->activateDeploymentForChannelApp((int)$deployment['id'], $channelId, $app);
}
$this->execute(
"UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?",
'i',
[$versionId]
);
}
$this->audit($channelId, (int)($previous['deployment_id'] ?? 0) ?: null, 'channel_rolled_back', $actorUserId, 'warning', [
'previous_channel_version_id' => $previous['id'] ?? null,
'frontend_version_id' => $this->nullablePositiveInt($previous['frontend_version_id'] ?? null),
'api_version_id' => $this->nullablePositiveInt($previous['api_version_id'] ?? null),
'service_set_id' => $this->nullablePositiveInt($previous['service_set_id'] ?? null),
'bundle_id' => $this->nullablePositiveInt($previous['bundle_id'] ?? null),
'public_smoke_required' => true,
]);
return [
'channel' => $this->publicChannel($channel),
'versions' => $this->currentVersionsForChannel($channelId),
];
}
public function setReplayTarget(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$targetType = strtolower(trim((string)($input['target_type'] ?? '')));
if (!in_array($targetType, ['user', 'subuser', 'customer', 'channel'], true)) {
throw new RuntimeException('Invalid replay target type.');
}
$targetId = trim((string)($input['target_id'] ?? '')) ?: null;
$channel = null;
if ($targetType === 'channel' || isset($input['channel_id']) || isset($input['channel_slug'])) {
$channel = $this->channelFromInput($input);
$targetId = $targetId ?: (string)$channel['slug'];
}
$captureLevel = $this->normalizeCaptureLevel((string)($input['capture_level'] ?? 'full_redacted'));
$enabled = $this->toBool($input['enabled'] ?? true) ? 1 : 0;
$expiresAt = $this->normalizeDateTime($input['expires_at'] ?? null);
$this->execute(
"INSERT INTO release_replay_targets (target_type, target_id, channel_id, capture_level, enabled, expires_at, actor_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)",
'ssisisi',
[$targetType, $targetId, $channel['id'] ?? null, $captureLevel, $enabled, $expiresAt, $actorUserId]
);
$id = $this->insertId();
$this->audit($channel !== null ? (int)$channel['id'] : null, null, 'replay_target_created', $actorUserId, 'warning', [
'target_type' => $targetType,
'target_id' => $targetId,
'capture_level' => $captureLevel,
'enabled' => (bool)$enabled,
]);
return $this->selectOne('SELECT * FROM release_replay_targets WHERE id = ?', 'i', [$id]) ?? [];
}
public function ingestTimelineEvents(array $events, array $context = [], bool $ensureSchema = true): array
{
if ($ensureSchema) {
$this->ensureSchema();
}
if ($events === [] || !isset($events[0])) {
$events = [$events];
}
$traceId = self::safeIdentifier((string)($context['trace_id'] ?? $this->requestTraceId()), 64);
if ($traceId === '') {
$traceId = $this->requestTraceId();
}
$principalContext = $this->currentPrincipalContext();
$context = array_replace($principalContext, array_filter($context, static fn(mixed $value): bool => $value !== null && $value !== ''));
$channelSlug = self::safeSlug((string)($context['channel_slug'] ?? $context['release_channel'] ?? ''));
$channel = $channelSlug !== '' ? $this->findChannelBySlug($channelSlug) : null;
if ($channel === null) {
$channel = $this->resolveChannel($context);
}
if (empty($context['route_path']) && empty($context['route'])) {
foreach (array_reverse($events) as $eventForRoute) {
if (!is_array($eventForRoute)) {
continue;
}
$route = trim((string)($eventForRoute['route_path'] ?? $eventForRoute['route'] ?? ''));
if ($route !== '') {
$context['route_path'] = $route;
break;
}
}
}
$sessionId = $this->timelineSessionId($traceId, $context, $channel);
$accepted = 0;
foreach ($events as $event) {
if (!is_array($event)) {
continue;
}
$eventType = self::safeIdentifier((string)($event['type'] ?? $event['event_type'] ?? 'event'), 64) ?: 'event';
$severity = self::safeIdentifier((string)($event['severity'] ?? 'info'), 16) ?: 'info';
$payload = self::redactPayload($event['payload'] ?? $event);
$occurredAt = $this->normalizeDateTime($event['occurred_at'] ?? null) ?? date('Y-m-d H:i:s');
$this->execute(
"INSERT INTO release_timeline_events (
timeline_session_id, trace_id, event_type, severity, module_key,
route_path, component, request_id, occurred_at, payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
'isssssssss',
[
$sessionId,
$traceId,
$eventType,
$severity,
self::safeIdentifier((string)($event['module_key'] ?? ''), 64) ?: null,
trim((string)($event['route'] ?? $event['route_path'] ?? '')) ?: null,
trim((string)($event['component'] ?? '')) ?: null,
trim((string)($event['request_id'] ?? '')) ?: null,
$occurredAt,
self::jsonEncode($payload),
]
);
$accepted++;
}
return ['accepted' => $accepted, 'trace_id' => $traceId, 'timeline_session_id' => $sessionId];
}
public function searchTimeline(array $filters = []): array
{
$this->ensureSchema();
$this->cleanupExpiredReplayData();
$types = '';
$params = [];
$where = ['1 = 1'];
foreach ([
'trace_id' => 'e.trace_id',
'event_type' => 'e.event_type',
'severity' => 'e.severity',
'module_key' => 'e.module_key',
'channel_slug' => 's.channel_slug',
'principal_type' => 's.principal_type',
'principal_id' => 's.principal_id',
] as $filterKey => $column) {
$value = trim((string)($filters[$filterKey] ?? ''));
if ($value === '') {
continue;
}
$where[] = "$column = ?";
$types .= 's';
$params[] = $value;
}
if (isset($filters['customer_number']) && is_numeric($filters['customer_number'])) {
$where[] = 's.customer_number = ?';
$types .= 'i';
$params[] = (int)$filters['customer_number'];
}
$limit = max(1, min(500, (int)($filters['limit'] ?? 100)));
$rows = $this->selectRows(
"SELECT e.*, s.principal_type, s.principal_id, s.customer_number, s.channel_slug
FROM release_timeline_events e
LEFT JOIN release_timeline_sessions s ON s.id = e.timeline_session_id
WHERE " . implode(' AND ', $where) . "
ORDER BY e.occurred_at DESC, e.id DESC
LIMIT $limit",
$types,
$params
);
return array_map(fn(array $row): array => $this->publicTimelineEvent($row), $rows);
}
public function listTimelineSessions(array $filters = []): array
{
$this->ensureSchema();
$this->cleanupExpiredReplayData();
$types = '';
$params = [];
$where = ['1 = 1'];
foreach ([
'trace_id' => 's.trace_id',
'principal_type' => 's.principal_type',
'principal_id' => 's.principal_id',
'device_type' => 's.device_type',
'channel_slug' => 's.channel_slug',
'frontend_version' => 's.frontend_version_label',
'api_version' => 's.api_version_label',
'event_type' => 'e.event_type',
'severity' => 'e.severity',
'module_key' => 'e.module_key',
] as $filterKey => $column) {
$value = trim((string)($filters[$filterKey] ?? ''));
if ($value === '') {
continue;
}
$where[] = "$column = ?";
$types .= 's';
$params[] = $value;
}
if (isset($filters['customer_number']) && is_numeric($filters['customer_number'])) {
$where[] = 's.customer_number = ?';
$types .= 'i';
$params[] = (int)$filters['customer_number'];
}
foreach (['date_from' => '>=', 'date_to' => '<='] as $filterKey => $operator) {
$date = $this->normalizeDateTime($filters[$filterKey] ?? null);
if ($date === null) {
continue;
}
$where[] = "s.last_seen_at $operator ?";
$types .= 's';
$params[] = $date;
}
$hasErrorReport = $this->toBool($filters['has_error_report'] ?? false);
$hasErrorReportTable = $this->tableExists('error_reports');
if ($hasErrorReport && $hasErrorReportTable) {
$where[] = 'EXISTS (SELECT 1 FROM error_reports er_filter WHERE er_filter.release_trace_id = s.trace_id)';
} elseif ($hasErrorReport) {
$where[] = '1 = 0';
}
$errorReportCountSelect = $hasErrorReportTable
? "(SELECT COUNT(*) FROM error_reports er_count WHERE er_count.release_trace_id = s.trace_id) AS error_report_count"
: '0 AS error_report_count';
$limit = max(1, min(500, (int)($filters['limit'] ?? 100)));
$rows = $this->selectRows(
"SELECT
s.*,
COUNT(e.id) AS event_count,
SUM(CASE WHEN e.severity = 'error' THEN 1 ELSE 0 END) AS error_count,
MIN(e.occurred_at) AS first_event_at,
MAX(e.occurred_at) AS last_event_at,
GROUP_CONCAT(DISTINCT e.module_key ORDER BY e.module_key SEPARATOR ',') AS module_keys,
$errorReportCountSelect
FROM release_timeline_sessions s
LEFT JOIN release_timeline_events e ON e.timeline_session_id = s.id
WHERE " . implode(' AND ', $where) . "
GROUP BY s.id
ORDER BY COALESCE(MAX(e.occurred_at), s.last_seen_at) DESC, s.id DESC
LIMIT $limit",
$types,
$params
);
return array_map(fn(array $row): array => $this->publicTimelineSession($row), $rows);
}
public function timelineSessionDetail(string $traceId): array
{
$this->ensureSchema();
$this->cleanupExpiredReplayData();
$traceId = self::safeIdentifier($traceId, 64);
if ($traceId === '') {
throw new RuntimeException('Invalid timeline trace id.');
}
$session = $this->selectOne(
'SELECT * FROM release_timeline_sessions WHERE trace_id = ? LIMIT 1',
's',
[$traceId]
);
if ($session === null) {
throw new RuntimeException('Timeline session not found.');
}
$events = $this->selectRows(
"SELECT e.*, s.principal_type, s.principal_id, s.customer_number, s.channel_slug
FROM release_timeline_events e
LEFT JOIN release_timeline_sessions s ON s.id = e.timeline_session_id
WHERE e.trace_id = ?
ORDER BY e.occurred_at ASC, e.id ASC",
's',
[$traceId]
);
$channel = isset($session['channel_id']) ? $this->selectOne('SELECT * FROM release_channels WHERE id = ? LIMIT 1', 'i', [(int)$session['channel_id']]) : null;
return [
'session' => $this->publicTimelineSession($session),
'events' => array_map(fn(array $row): array => $this->publicTimelineEvent($row), $events),
'error_reports' => $this->timelineErrorReports($traceId),
'release' => $this->timelineReleaseContext($session, $channel),
];
}
public function handleGithubWebhook(array $headers, string $rawBody): array
{
$this->ensureSchema();
$secret = (string)$this->moduleConfigValue('ReleaseManager', 'github_webhook_secret', '');
$signature = self::headerValue($headers, 'X-Hub-Signature-256');
if (!self::verifyGithubSignature($secret, $rawBody, $signature)) {
throw new RuntimeException('Invalid GitHub webhook signature.');
}
$event = self::headerValue($headers, 'X-GitHub-Event') ?: 'unknown';
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
throw new RuntimeException('Invalid GitHub webhook JSON payload.');
}
if ($event !== 'push') {
$this->audit(null, null, 'github_webhook_ignored', null, 'info', ['event' => $event]);
return ['event' => $event, 'deployments' => [], 'ignored' => true];
}
$repository = (string)($payload['repository']['full_name'] ?? $payload['repository']['name'] ?? '');
$branch = preg_replace('#^refs/heads/#', '', (string)($payload['ref'] ?? ''));
$commitSha = (string)($payload['after'] ?? '');
if ($repository === '' || $branch === '' || $commitSha === '') {
throw new RuntimeException('GitHub push payload is missing repository, branch, or commit.');
}
$normalizedRepository = self::normalizeGithubRepositoryName($repository);
if ($normalizedRepository !== '') {
$repository = $normalizedRepository;
}
$mappedChannelSlug = self::channelSlugForRoute($branch);
$mappedChannel = $this->findChannelBySlug($mappedChannelSlug);
$mappedApp = '';
if ($repository === self::defaultRepositoryForApp('frontend')) {
$mappedApp = 'frontend';
} elseif ($repository === self::defaultRepositoryForApp('api')) {
$mappedApp = 'api';
}
$targets = $this->selectRows(
"SELECT * FROM release_deployment_targets
WHERE deleted_at IS NULL AND auto_deploy = 1 AND repository = ? AND branch = ?",
'ss',
[$repository, $branch]
);
$autoSyncEvents = [];
foreach ($targets as $target) {
$autoSyncEvents[] = $this->publicReleaseAutoSyncEvent($this->upsertReleaseAutoSyncEvent([
'channel_id' => (int)$target['channel_id'],
'app' => (string)$target['app'],
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'status' => 'pending',
'source' => 'github_webhook',
'workflow_url' => (string)($payload['compare'] ?? ''),
'metadata' => [
'github_event' => $event,
'head_commit' => self::redactPayload($payload['head_commit'] ?? []),
],
]));
}
$this->audit(null, null, 'github_webhook_processed', null, 'info', [
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'auto_sync_event_count' => count($autoSyncEvents),
]);
return [
'event' => $event,
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'mapped_channel_slug' => $mappedChannelSlug,
'mapped_app' => $mappedApp !== '' ? $mappedApp : null,
'auto_sync_events' => $autoSyncEvents,
'operations' => [],
'deployments' => [],
];
}
public function healthProbe(): array
{
$startedAt = microtime(true);
try {
$summary = $this->summary();
$channels = $summary['channels'] ?? [];
$deployments = $summary['deployments'] ?? [];
$failedDeployments = array_values(array_filter($deployments, static fn(array $deployment): bool => ($deployment['status'] ?? '') === 'failed'));
$channelsWithoutVersions = array_values(array_filter($channels, static function (array $channel): bool {
if (($channel['enabled'] ?? false) !== true) {
return false;
}
$versions = $channel['versions'] ?? [];
return empty($versions['frontend']) && empty($versions['api']);
}));
$status = 'ok';
$reason = 'Release channels and deployment telemetry are available.';
$reasonKey = 'release_manager_available';
if ($failedDeployments !== []) {
$status = 'degraded';
$reason = 'One or more recent release deployments failed.';
$reasonKey = 'release_deployments_failed';
} elseif ($channelsWithoutVersions !== []) {
$status = 'degraded';
$reason = 'One or more enabled release channels have no active versions yet.';
$reasonKey = 'release_channels_without_versions';
}
return [
'status' => $status,
'status_reason' => $reason,
'status_reason_key' => $reasonKey,
'status_reason_params' => [
'channels' => count($channels),
'deployment_targets' => count($summary['deployment_targets'] ?? []),
'recent_failed_deployments' => count($failedDeployments),
],
'checked_at' => date('c'),
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
} catch (Throwable $throwable) {
return [
'status' => 'down',
'status_reason' => 'Release manager probe failed: ' . $throwable->getMessage(),
'status_reason_key' => 'release_manager_probe_failed',
'status_reason_params' => ['error' => $throwable->getMessage()],
'checked_at' => date('c'),
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
}
}
private function ensureSchema(): void
{
if ($this->schemaEnsured) {
return;
}
release_manager_schema_bootstrap::ensureTables();
$this->schemaEnsured = true;
}
private function resolveChannel(array $context): array
{
$cacheKey = $this->assignmentCacheKey($context);
if ($cacheKey !== '' && defined('redis')) {
try {
$cached = redis->get($cacheKey);
if (is_string($cached) && $cached !== '') {
$decoded = json_decode($cached, true);
if (is_array($decoded) && !empty($decoded['id'])) {
return $decoded;
}
}
} catch (Throwable) {
}
}
$candidates = [];
if (!empty($context['principal_type']) && !empty($context['principal_id'])) {
$candidates[] = [(string)$context['principal_type'], (string)$context['principal_id']];
}
if (!empty($context['customer_number'])) {
$candidates[] = ['customer', (string)(int)$context['customer_number']];
}
foreach ($candidates as [$subjectType, $subjectId]) {
$row = $this->selectOne(
"SELECT c.*
FROM release_assignments a
INNER JOIN release_channels c ON c.id = a.channel_id
WHERE a.deleted_at IS NULL
AND c.deleted_at IS NULL
AND c.enabled = 1
AND a.subject_type = ?
AND a.subject_id = ?
AND (a.expires_at IS NULL OR a.expires_at > NOW())
ORDER BY a.created_at DESC, a.id DESC
LIMIT 1",
'ss',
[$subjectType, $subjectId]
);
if ($row !== null) {
$this->cacheResolvedChannel($cacheKey, $row);
return $row;
}
}
$rolloutChannel = $this->rolloutChannelForContext($context);
if ($rolloutChannel !== null) {
$this->cacheResolvedChannel($cacheKey, $rolloutChannel);
return $rolloutChannel;
}
$default = $this->defaultChannel();
$this->cacheResolvedChannel($cacheKey, $default);
return $default;
}
private function runtimeChannelsForContext(array $context, ?array $resolvedChannel = null): array
{
$channelsByKey = [];
$this->addRuntimeChannel($channelsByKey, $this->defaultChannel());
foreach ($this->assignmentCandidates($context) as [$subjectType, $subjectId]) {
$rows = $this->selectRows(
"SELECT c.*
FROM release_assignments a
INNER JOIN release_channels c ON c.id = a.channel_id
WHERE a.deleted_at IS NULL
AND c.deleted_at IS NULL
AND c.enabled = 1
AND a.subject_type = ?
AND a.subject_id = ?
AND (a.expires_at IS NULL OR a.expires_at > NOW())
ORDER BY a.created_at DESC, a.id DESC",
'ss',
[$subjectType, $subjectId]
);
foreach ($rows as $row) {
$this->addRuntimeChannel($channelsByKey, $row);
}
}
if ($resolvedChannel !== null) {
$this->addRuntimeChannel($channelsByKey, $resolvedChannel);
}
return array_values($channelsByKey);
}
private function assignmentCandidates(array $context): array
{
$candidates = [];
if (!empty($context['principal_type']) && !empty($context['principal_id'])) {
$candidates[] = [(string)$context['principal_type'], (string)$context['principal_id']];
}
if (!empty($context['customer_number'])) {
$candidates[] = ['customer', (string)(int)$context['customer_number']];
}
return $candidates;
}
private function addRuntimeChannel(array &$channelsByKey, array $channel): void
{
$id = (int)($channel['id'] ?? 0);
$slug = self::safeSlug((string)($channel['slug'] ?? ''));
$key = $id > 0 ? 'id:' . $id : ($slug !== '' ? 'slug:' . $slug : '');
if ($key === '' || isset($channelsByKey[$key])) {
return;
}
$channelsByKey[$key] = $channel;
}
private function chooseRuntimeChannel(array $resolvedChannel, array $availableChannels, string $requestedSlug): array
{
if ($requestedSlug === '') {
return $resolvedChannel;
}
foreach ($availableChannels as $channel) {
if (self::safeSlug((string)($channel['slug'] ?? '')) === $requestedSlug) {
return $channel;
}
}
return $resolvedChannel;
}
private function runtimeServiceChannelFor(array $channel): array
{
if (!$this->channelUsesProductionServices($channel)) {
return $channel;
}
return $this->productionServiceChannel();
}
private function productionServiceChannel(): array
{
$channel = $this->selectOne(
"SELECT *
FROM release_channels
WHERE deleted_at IS NULL
AND enabled = 1
AND default_channel = 1
AND slug <> 'beta'
ORDER BY id
LIMIT 1"
);
if ($channel !== null) {
return $channel;
}
foreach (self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS as $slug) {
$channel = $this->findChannelBySlug($slug);
if ($channel !== null && (int)($channel['enabled'] ?? 0) === 1 && !$this->channelUsesProductionServices($channel)) {
return $channel;
}
}
$default = $this->defaultChannel();
if ($this->channelUsesProductionServices($default)) {
throw new RuntimeException('No production release channel is configured for beta services.');
}
return $default;
}
private function requestedRuntimeChannelSlug(array $input = []): string
{
$value = $input['release_channel']
?? $input['channel_slug']
?? ($_GET['release_channel'] ?? $_GET['channel_slug'] ?? '');
return self::safeSlug((string)$value);
}
private function rolloutChannelForContext(array $context): ?array
{
$seed = (string)($context['principal_id'] ?? $context['customer_number'] ?? '');
if ($seed === '') {
return null;
}
$bucket = (hexdec(substr(hash('sha256', $seed), 0, 8)) % 10000) / 100;
$channels = $this->selectRows(
"SELECT * FROM release_channels
WHERE deleted_at IS NULL AND enabled = 1 AND default_channel = 0 AND rollout_percent > 0
ORDER BY rollout_percent DESC, slug"
);
foreach ($channels as $channel) {
if ($bucket < (float)$channel['rollout_percent']) {
return $channel;
}
}
return null;
}
private function capturePolicyFor(array $context, array $channel): array
{
$enabled = (bool)((int)($channel['replay_enabled'] ?? 0));
$captureLevel = $this->normalizeCaptureLevel((string)($channel['capture_level'] ?? 'metadata'));
$retentionDays = max(1, (int)($channel['retention_days'] ?? 14));
$targets = [];
if (!empty($context['principal_type']) && !empty($context['principal_id'])) {
$targets[] = [(string)$context['principal_type'], (string)$context['principal_id']];
}
if (!empty($context['customer_number'])) {
$targets[] = ['customer', (string)(int)$context['customer_number']];
}
$targets[] = ['channel', (string)$channel['slug']];
foreach ($targets as [$targetType, $targetId]) {
$row = $this->selectOne(
"SELECT capture_level
FROM release_replay_targets
WHERE deleted_at IS NULL
AND enabled = 1
AND target_type = ?
AND (target_id = ? OR (target_type = 'channel' AND channel_id = ?))
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY created_at DESC, id DESC
LIMIT 1",
'ssi',
[$targetType, $targetId, (int)$channel['id']]
);
if ($row !== null) {
$enabled = true;
$captureLevel = $this->normalizeCaptureLevel((string)$row['capture_level']);
break;
}
}
return [
'enabled' => $enabled,
'capture_level' => $captureLevel,
'all_failure_metadata' => true,
'retention_days' => $retentionDays,
];
}
private function currentPrincipalContext(): array
{
try {
$auth = new authentication();
$subuser = $auth->get_subuser();
if ($subuser !== false) {
return [
'principal_type' => 'subuser',
'principal_id' => (string)$subuser->id,
'customer_number' => $auth->get_subuser_customer_number_target() ?: null,
];
}
$user = $auth->get_user();
if ($user !== false) {
return [
'principal_type' => 'user',
'principal_id' => (string)$user->id,
'customer_number' => isset($user->customer_number) ? (int)$user->customer_number->value() : null,
];
}
} catch (Throwable) {
}
return [
'principal_type' => null,
'principal_id' => null,
'customer_number' => null,
];
}
private function defaultChannel(): array
{
$channel = $this->selectOne(
"SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 AND default_channel = 1 ORDER BY id LIMIT 1"
);
if ($channel !== null) {
return $channel;
}
$channel = $this->selectOne(
"SELECT * FROM release_channels WHERE deleted_at IS NULL AND slug = 'stable' ORDER BY id LIMIT 1"
);
if ($channel !== null) {
return $channel;
}
throw new RuntimeException('No release channel is configured.');
}
private function currentVersionsForChannel(int $channelId): array
{
$current = $this->currentChannelVersionRow($channelId);
$frontend = null;
$api = null;
if (!empty($current['frontend_version_id'])) {
$frontend = $this->publicVersion($this->getVersion((int)$current['frontend_version_id']));
}
if (!empty($current['api_version_id'])) {
$api = $this->publicVersion($this->getVersion((int)$current['api_version_id']));
}
$serviceSet = null;
if (!empty($current['service_set_id'])) {
try {
$serviceSet = $this->publicServiceSet($this->getServiceSet((int)$current['service_set_id']), false);
} catch (Throwable) {
$serviceSet = null;
}
}
$bundle = null;
if (!empty($current['bundle_id'])) {
try {
$bundle = $this->publicBundle($this->getBundle((int)$current['bundle_id']), false);
} catch (Throwable) {
$bundle = null;
}
}
return [
'frontend' => $frontend,
'api' => $api,
'service_set' => $serviceSet,
'bundle_id' => isset($current['bundle_id']) ? (int)$current['bundle_id'] : null,
'bundle' => $bundle,
];
}
private function channelAvailability(array $channel): array
{
$channelId = (int)($channel['id'] ?? 0);
if ($this->channelUsesProductionServices($channel)) {
$serviceChannel = $this->productionServiceChannel();
$versions = $this->currentVersionsForChannel((int)$serviceChannel['id']);
$urls = $this->releaseRuntimeUrls($serviceChannel, $versions);
$availability = $this->channelAvailability($serviceChannel);
return array_replace($availability, [
'configured' => (bool)($availability['configured'] ?? false),
'missing' => is_array($availability['missing'] ?? null) ? $availability['missing'] : [],
'frontend_base_url' => $urls['frontend_base_url'],
'api_base_url' => $urls['api_base_url'],
'service_policy' => self::PRODUCTION_SERVICE_POLICY,
'service_channel_id' => (int)($serviceChannel['id'] ?? 0),
'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
]);
}
$isDefault = ((int)($channel['default_channel'] ?? 0) === 1) || (string)($channel['slug'] ?? '') === 'stable';
if ($isDefault || $channelId <= 0) {
return [
'configured' => true,
'missing' => [],
'status' => 'ready',
];
}
$versions = $this->currentVersionsForChannel($channelId);
$urls = $this->releaseRuntimeUrls($channel, $versions);
$missing = [];
if (empty($versions['frontend'])) {
$missing[] = 'frontend_version';
} elseif (empty($urls['frontend_base_url'])) {
$missing[] = 'frontend_base_url';
}
if (empty($versions['api'])) {
$missing[] = 'api_version';
} elseif (empty($urls['api_base_url'])) {
$missing[] = 'api_base_url';
}
return [
'configured' => count($missing) === 0,
'missing' => $missing,
'bundle_id' => $versions['bundle_id'] ?? null,
'frontend_base_url' => $urls['frontend_base_url'],
'api_base_url' => $urls['api_base_url'],
'status' => count($missing) === 0 ? 'ready' : 'unconfigured',
];
}
private function releaseRuntimeUrls(array $channel, array $versions): array
{
$frontend = is_array($versions['frontend'] ?? null) ? $versions['frontend'] : [];
$api = is_array($versions['api'] ?? null) ? $versions['api'] : [];
$serviceSet = is_array($versions['service_set'] ?? null) ? $versions['service_set'] : [];
$targets = is_array($serviceSet['targets'] ?? null) ? $serviceSet['targets'] : [];
$frontendTarget = is_array($targets['frontend'] ?? null) ? $targets['frontend'] : null;
$apiTarget = is_array($targets['api'] ?? null) ? $targets['api'] : null;
return [
'frontend_base_url' => $this->normalizeReleasePublicBaseUrl($frontend['deployed_url'] ?? null, 'frontend')
?? $this->normalizeReleasePublicBaseUrl($channel['frontend_base_url'] ?? null, 'frontend')
?? (is_array($frontendTarget) ? $this->releaseTargetPublicBaseUrl($frontendTarget) : null),
'api_base_url' => $this->normalizeReleasePublicBaseUrl($api['deployed_url'] ?? null, 'api')
?? $this->normalizeReleasePublicBaseUrl($channel['api_base_url'] ?? null, 'api')
?? (is_array($apiTarget) ? $this->releaseTargetPublicBaseUrl($apiTarget) : null),
];
}
private function normalizeReleasePublicBaseUrl(mixed $value, string $app = ''): ?string
{
$raw = trim((string)$value);
if ($raw === '') {
return null;
}
$raw = preg_replace('#/(health|ping)$#i', '', rtrim($raw, '/')) ?: $raw;
if (preg_match('#^https?://#i', $raw) !== 1) {
$raw = 'https://' . ltrim($raw, '/');
}
$parts = parse_url($raw);
if (!is_array($parts) || empty($parts['host'])) {
return null;
}
$scheme = strtolower((string)($parts['scheme'] ?? 'https'));
if (!in_array($scheme, ['http', 'https'], true)) {
return null;
}
$path = isset($parts['path']) ? '/' . trim((string)$parts['path'], '/') : '';
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
return rtrim($scheme . '://' . strtolower((string)$parts['host']) . $port . $path, '/');
}
private function currentChannelVersionRow(int $channelId): ?array
{
return $this->selectOne(
"SELECT * FROM release_channel_versions
WHERE channel_id = ? AND active = 1
ORDER BY activated_at DESC, id DESC
LIMIT 1",
'i',
[$channelId]
);
}
private function createVersion(array $input): int
{
$this->execute(
"INSERT INTO release_versions (
app, repository, branch, commit_sha, tag, version_label,
build_url, artifact_url, deployed_url, status, metadata_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
'sssssssssss',
[
$input['app'],
$input['repository'] ?? null,
$input['branch'] ?? null,
$input['commit_sha'] ?? null,
$input['tag'] ?? null,
$input['version_label'] ?? null,
$input['build_url'] ?? null,
$input['artifact_url'] ?? null,
$input['deployed_url'] ?? null,
$input['status'] ?? 'discovered',
self::jsonEncode($input['metadata'] ?? []),
]
);
return $this->insertId();
}
private function restartCoolifyService(int $instanceId, string $serviceUuid): array
{
$instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]);
if ($instance === null) {
throw new RuntimeException('Coolify instance for release deployment was not found.');
}
$client = $this->coolifyClientForInstance($instance, 20);
return $client->restartService($serviceUuid);
}
private function coolifyClientForInstance(array $instance, int $timeoutSeconds = 20): coolify_api_client
{
if ($this->coolifyClientFactory !== null) {
$client = ($this->coolifyClientFactory)($instance, $timeoutSeconds);
if (!$client instanceof coolify_api_client) {
throw new RuntimeException('Release Manager Coolify client factory returned an invalid client.');
}
return $client;
}
$token = replication_secret_box::decrypt((string)$instance['api_token_secret']);
return new coolify_api_client((string)$instance['base_url'], $token, $timeoutSeconds);
}
private function deployCoolifyReleaseTarget(array $target): array
{
$instanceId = (int)($target['coolify_instance_id'] ?? 0);
$instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]);
if ($instance === null) {
throw new RuntimeException('Coolify instance for release deployment was not found.');
}
$client = $this->coolifyClientForInstance($instance, 20);
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
$serviceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
$resourceType = $this->releaseCoolifyResourceType($context, $serviceUuid);
$created = null;
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
$runtimeEnvUpdate = null;
if ($serviceUuid === '' && $this->toBool($context['coolify_auto_create'] ?? false)) {
$githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true);
if ($githubAppUuid !== '') {
$context['coolify_github_app_uuid'] = $githubAppUuid;
$applicationPayload = $this->releaseCoolifyApplicationPayload($target, $context, $instance);
$applicationPayload['instant_deploy'] = false;
$created = $client->createPrivateGithubAppApplication($applicationPayload);
$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.');
}
$servicePayload = $this->releaseCoolifyServicePayload($target, $context, $instance);
$servicePayload['instant_deploy'] = false;
$created = $client->createService($servicePayload);
$resourceType = 'service';
}
$serviceUuid = trim((string)($created['uuid'] ?? ''));
if ($serviceUuid === '') {
throw new RuntimeException('Coolify did not return a resource UUID for the release target.');
}
$context['coolify_resource_type'] = $resourceType;
$target['coolify_service_uuid'] = $serviceUuid;
$this->execute(
'UPDATE release_deployment_targets SET coolify_service_uuid = ?, deploy_context_json = ? WHERE id = ?',
'ssi',
[$serviceUuid, self::jsonEncode($context), (int)$target['id']]
);
}
if ($serviceUuid === '') {
throw new RuntimeException('Select an existing Coolify service or enable Coolify service creation before deployment.');
}
$update = null;
if ($resourceType === 'application') {
$applicationUpdate = $this->releaseCoolifyApplicationUpdatePayload($target, $context);
if ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
$resource = [];
try {
$resource = $client->getApplication($serviceUuid);
} catch (Throwable) {
$resource = is_array($created) ? $created : [];
}
$applicationUpdate = array_replace(
$applicationUpdate,
$this->releaseCoolifyApplicationRoutePayload(
$target,
$context,
$publicUrl,
$serviceUuid,
$resource['custom_labels'] ?? null
)
);
}
if ($applicationUpdate !== []) {
$update = $client->updateApplication($serviceUuid, $applicationUpdate);
}
$runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'application', $target, $context);
} 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' => [
[
'name' => (string)($target['app'] ?? 'release'),
'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)),
],
],
'force_domain_override' => true,
]);
$runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'service', $target, $context);
}
if ($runtimeEnvUpdate === null) {
$runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, $resourceType, $target, $context);
}
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
$previousApplications = $this->stopCoolifyPreviousApplications($client, $context, $serviceUuid);
return [
'service_uuid' => $serviceUuid,
'resource_type' => $resourceType,
'ssl_enabled' => $this->toBool($context['coolify_enable_ssl'] ?? false),
'public_url' => $publicUrl,
'created' => self::redactPayload($created ?? []),
'updated' => self::redactPayload($update ?? []),
'runtime_env' => $runtimeEnvUpdate,
'deployment' => self::redactPayload($deployment),
'previous_applications' => self::redactPayload($previousApplications),
];
}
private function stopCoolifyPreviousApplications(coolify_api_client $client, array $context, string $activeUuid): array
{
$stopped = [];
foreach ($this->releaseCoolifyPreviousApplicationUuids($context, $activeUuid) as $uuid) {
try {
$stopped[] = [
'uuid' => $uuid,
'status' => 'stop_requested',
'result' => $client->stopApplication($uuid),
];
} catch (Throwable $throwable) {
$stopped[] = [
'uuid' => $uuid,
'status' => 'warning',
'error' => $throwable->getMessage(),
];
}
}
return $stopped;
}
private function releaseCoolifyPreviousApplicationUuids(array $context, string $activeUuid): array
{
$values = [];
foreach ([
'coolify_previous_application_uuid',
'coolify_previous_artifact_app_uuid',
'coolify_previous_artifact_application_uuid',
'previous_application_uuid',
'previous_app_uuid',
] as $key) {
if (is_scalar($context[$key] ?? null)) {
$values[] = (string)$context[$key];
}
}
foreach ([
'coolify_previous_application_uuids',
'coolify_previous_artifact_app_uuids',
'previous_application_uuids',
'previous_app_uuids',
] as $key) {
if (!is_array($context[$key] ?? null)) {
continue;
}
foreach ($context[$key] as $value) {
if (is_scalar($value)) {
$values[] = (string)$value;
}
}
}
$activeUuid = trim($activeUuid);
$uuids = [];
foreach ($values as $value) {
$uuid = trim((string)$value);
if ($uuid === '' || $uuid === $activeUuid || in_array($uuid, $uuids, true)) {
continue;
}
$uuids[] = $uuid;
}
return $uuids;
}
private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array
{
$env = $this->releaseCoolifyRuntimeEnv($target, $context);
if ($env === []) {
return null;
}
if ($resourceType === 'application') {
$this->deleteCoolifyGeneratedCommitEnvs($client, $resourceUuid, $target, $context);
$client->updateApplicationEnvsBulk($resourceUuid, $env);
} else {
$client->updateServiceEnvsBulk($resourceUuid, $env);
}
return [
'resource_type' => $resourceType,
'count' => count($env),
'keys' => array_keys($env),
];
}
private function deleteCoolifyGeneratedCommitEnvs(coolify_api_client $client, string $resourceUuid, array $target, array $context): void
{
$keys = $this->releaseCoolifyGeneratedCommitEnvKeys($target, $context);
if ($keys === []) {
return;
}
try {
$rows = $this->payloadRows($client->listApplicationEnvs($resourceUuid));
} catch (Throwable) {
return;
}
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$key = trim((string)($row['key'] ?? $row['name'] ?? ''));
$uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
if ($key === '' || $uuid === '' || !in_array($key, $keys, true)) {
continue;
}
try {
$client->deleteApplicationEnv($resourceUuid, $uuid);
} catch (Throwable) {
}
}
}
private function releaseCoolifyRuntimeEnv(array $target, array $context): array
{
$contextEnv = $this->releaseCoolifyContextEnv($context);
$env = $contextEnv;
$app = strtolower(trim((string)($target['app'] ?? '')));
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
if (!in_array($app, ['api', self::CRON_WORKER_APP], true)) {
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
return $env;
}
$env['USE_ENV'] = $env['USE_ENV'] ?? 'true';
foreach (self::RELEASE_API_RUNTIME_ENV_KEYS as $key) {
$this->appendRuntimeEnvValue($env, $key);
}
$runtime = array_replace(
is_array($_ENV ?? null) ? $_ENV : [],
is_array($_SERVER ?? null) ? $_SERVER : [],
is_array(getenv()) ? getenv() : []
);
foreach ($runtime as $key => $value) {
$key = (string)$key;
if (!$this->releaseRuntimeEnvKeyAllowed($key)) {
continue;
}
$this->appendRuntimeEnvValue($env, $key, $value);
}
$env = array_replace($env, $contextEnv);
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
if ($app === self::CRON_WORKER_APP) {
$resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
if ($resourceUuid !== '') {
$env['CRON_WORKER_COOLIFY_RESOURCE_UUID'] = $resourceUuid;
}
}
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
return $this->normalizeCoolifyRuntimeEnv($env);
}
private function applyReleaseCoolifyCommitRuntimeEnv(array &$env, string $app, string $deploymentCommitSha): void
{
if ($deploymentCommitSha === '') {
return;
}
foreach ($this->releaseCoolifyGeneratedCommitEnvKeys(['app' => $app], []) as $key) {
$env[$key] = $deploymentCommitSha;
}
}
private function releaseCoolifyGeneratedCommitEnvKeys(array $target, array $context): array
{
$app = strtolower(trim((string)($target['app'] ?? $context['app'] ?? '')));
if ($app === 'frontend') {
return ['SOURCE_COMMIT', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH'];
}
if ($app === 'api') {
return ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
}
if ($app === self::CRON_WORKER_APP) {
return ['CRON_WORKER_COMMIT_SHA', 'API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
}
return [];
}
private function releaseCoolifyContextEnv(array $context): array
{
$env = [];
foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) {
if (is_array($context[$key] ?? null)) {
foreach ($context[$key] as $envKey => $value) {
$this->appendRuntimeEnvValue($env, (string)$envKey, $value);
}
}
}
foreach (['coolify_env_file', 'env'] as $key) {
$raw = $context[$key] ?? null;
if (!is_string($raw) || trim($raw) === '') {
continue;
}
foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) {
$line = trim((string)$line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$envKey, $value] = explode('=', $line, 2);
$this->appendRuntimeEnvValue($env, trim($envKey), $value);
}
}
return $this->normalizeCoolifyRuntimeEnv($env);
}
private function appendRuntimeEnvValue(array &$env, string $key, mixed $value = null): void
{
$key = trim($key);
if ($key === '' || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) {
return;
}
if ($value === null) {
$value = getenv($key);
if ($value === false && array_key_exists($key, $_ENV ?? [])) {
$value = $_ENV[$key];
}
if ($value === false && array_key_exists($key, $_SERVER ?? [])) {
$value = $_SERVER[$key];
}
}
if ($value === false || $value === null || is_array($value) || is_object($value)) {
return;
}
$env[$key] = (string)$value;
}
private function releaseRuntimeEnvKeyAllowed(string $key): bool
{
if (in_array($key, self::RELEASE_API_RUNTIME_ENV_KEYS, true)) {
return true;
}
foreach (self::RELEASE_API_RUNTIME_ENV_PREFIXES as $prefix) {
if (str_starts_with($key, $prefix)) {
return true;
}
}
return false;
}
private function normalizeCoolifyRuntimeEnv(array $env): array
{
$normalized = [];
foreach ($env as $key => $value) {
$this->appendRuntimeEnvValue($normalized, (string)$key, $value);
}
ksort($normalized);
return $normalized;
}
private function releaseCoolifyApplicationPayload(array $target, array $context, array $instance): array
{
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
$projectUuid = trim((string)($context['coolify_project_uuid'] ?? $context['project_uuid'] ?? $instance['default_project_uuid'] ?? ''));
if ($projectUuid === '') {
throw new RuntimeException('Select a Coolify project for this release target before creating an application.');
}
$githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true);
if ($githubAppUuid === '') {
throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App UUID so Coolify can pull with the app token.');
}
$serverUuid = $this->releaseCoolifyServerUuid($context, $instance);
if ($serverUuid === '') {
throw new RuntimeException('Release Manager could not resolve a Coolify server UUID automatically for this instance.');
}
$repository = trim((string)($target['repository'] ?? ''));
if ($repository === '') {
throw new RuntimeException('Repository is required before creating a Coolify GitHub App application.');
}
$environment = $this->releaseCoolifyEnvironment($target, $context, $instance);
$payload = [
'name' => $this->releaseCoolifyResourceName($target, $context),
'description' => 'Truckwash release manager target for ' . $repository,
'project_uuid' => $projectUuid,
'environment_name' => $environment['name'],
'environment_uuid' => $environment['uuid'],
'server_uuid' => $serverUuid,
'destination_uuid' => trim((string)($context['coolify_destination_uuid'] ?? $context['destination_uuid'] ?? $instance['default_destination_uuid'] ?? '')),
'github_app_uuid' => $githubAppUuid,
'git_repository' => $repository,
'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH,
'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context),
'build_pack' => $this->releaseCoolifyBuildPack($target, $context),
'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context),
'instant_deploy' => $this->toBool($context['coolify_deploy_now'] ?? true),
'is_auto_deploy_enabled' => $this->toBool($target['auto_deploy'] ?? true),
'force_domain_override' => true,
];
foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) {
$payload[$key] = $value;
}
if ($publicUrl !== null) {
$payload['domains'] = self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context));
$payload['is_force_https_enabled'] = $this->toBool($context['coolify_enable_ssl'] ?? false);
}
foreach ($this->releaseCoolifyApplicationOptionalFields($context) as $key => $value) {
$payload[$key] = $value;
}
return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== '');
}
private function releaseCoolifyApplicationRoutePayload(
array $target,
array $context,
string $publicUrl,
string $resourceUuid,
mixed $existingLabels = null
): array
{
$decodedLabels = self::decodeCoolifyLabels($existingLabels);
$routePort = $this->releaseCoolifyProxyPort($target, $context)
?? self::coolifyLabelFirstServicePort($decodedLabels, $resourceUuid);
$payload = [
'domains' => self::coolifyProxyUrl($publicUrl, $routePort),
'is_force_https_enabled' => true,
'force_domain_override' => true,
];
$labels = self::releaseCoolifyApplicationLabels(
$publicUrl,
$resourceUuid,
$routePort,
self::gatewayRouteDefaultCertResolver($publicUrl),
(string)($target['app'] ?? ''),
(string)($this->releaseCoolifyRuntimeEnv($target, $context)['CORS'] ?? '')
);
if ($labels !== []) {
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
$decodedLabels,
$labels
)));
}
return $payload;
}
private function releaseCoolifyApplicationUpdatePayload(array $target, array $context): array
{
$app = strtolower(trim((string)($target['app'] ?? '')));
$buildPack = $this->releaseCoolifyBuildPack($target, $context);
$payload = [
'git_repository' => trim((string)($target['repository'] ?? '')),
'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH,
'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context),
'build_pack' => $buildPack,
'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context),
];
foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) {
$payload[$key] = $value;
}
foreach ($this->releaseCoolifyApplicationOptionalFields($context) as $key => $value) {
$payload[$key] = $value;
}
if ($app === 'frontend' && $buildPack === 'dockerfile') {
$payload['install_command'] = '';
$payload['build_command'] = '';
$payload['start_command'] = '';
$payload['publish_directory'] = '';
$payload['is_static'] = false;
$payload['is_spa'] = false;
}
return array_filter(
$payload,
static fn(mixed $value, string $key): bool => $value !== null
&& ($value !== '' || in_array($key, ['install_command', 'build_command', 'start_command', 'publish_directory'], true)),
ARRAY_FILTER_USE_BOTH
);
}
private static function releaseCoolifyApplicationLabels(
string $publicUrl,
string $resourceUuid,
?int $port = null,
?string $certResolver = null,
string $app = '',
string $corsConfig = ''
): array
{
$resourceUuid = self::coolifyRouteLabelId($resourceUuid);
if ($resourceUuid === '') {
return [];
}
$parts = parse_url($publicUrl);
$host = trim((string)($parts['host'] ?? ''));
if ($host === '') {
return [];
}
$scheme = strtolower((string)($parts['scheme'] ?? 'https'));
$path = trim((string)($parts['path'] ?? '/'));
$path = $path !== '' ? $path : '/';
if ($path[0] !== '/') {
$path = '/' . $path;
}
$routePort = $port ?? self::firstInteger($parts['port'] ?? null);
$certResolver = trim((string)($certResolver ?? ''));
$httpLabel = 'http-0-' . $resourceUuid;
$httpsLabel = 'https-0-' . $resourceUuid;
$priority = (string)(1000 + strlen($path));
$isApi = strtolower(trim($app)) === 'api';
$labels = [
'traefik.enable=true',
'traefik.http.middlewares.gzip.compress=true',
'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https',
];
if ($scheme === 'https') {
$labels[] = "traefik.http.routers.{$httpsLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
$labels[] = "traefik.http.routers.{$httpsLabel}.entryPoints=https";
$labels[] = "traefik.http.routers.{$httpsLabel}.priority={$priority}";
if ($routePort !== null) {
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
}
$httpsMiddlewares = [];
if ($isApi) {
$corsMiddleware = "{$httpsLabel}-cors";
$labels = array_merge($labels, cors_policy::traefikHeadersMiddlewareLabels($corsMiddleware, $corsConfig));
$httpsMiddlewares[] = $corsMiddleware;
}
if ($path !== '/') {
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
$httpsMiddlewares[] = "{$httpsLabel}-stripprefix";
}
$httpsMiddlewares[] = 'gzip';
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares);
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
if ($certResolver !== '') {
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
}
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.domains[0].main={$host}";
$labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
$labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http";
$labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}";
if ($routePort !== null) {
$labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
$labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
}
$labels[] = "traefik.http.routers.{$httpLabel}.middlewares=redirect-to-https";
} else {
$labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
$labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http";
$labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}";
if ($routePort !== null) {
$labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
$labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
}
if ($path !== '/') {
$labels[] = "traefik.http.middlewares.{$httpLabel}-stripprefix.stripprefix.prefixes={$path}";
$labels[] = "traefik.http.routers.{$httpLabel}.middlewares={$httpLabel}-stripprefix,gzip";
} else {
$labels[] = "traefik.http.routers.{$httpLabel}.middlewares=gzip";
}
}
sort($labels);
return $labels;
}
private static function mergeCoolifyLabels(array $existingLabels, array $generatedLabels): array
{
$merged = [];
foreach (array_merge($existingLabels, $generatedLabels) as $label) {
$label = trim((string)$label);
if ($label === '') {
continue;
}
$merged[self::coolifyLabelKey($label)] = $label;
}
return array_values($merged);
}
private static function coolifyLabelFirstServicePort(array $labels, string $resourceUuid): ?int
{
$resourceUuid = self::coolifyRouteLabelId($resourceUuid);
$fallback = null;
foreach ($labels as $label) {
if (preg_match('/^traefik\.http\.services\.([^=]+)\.loadbalancer\.server\.port=(\d+)$/', trim((string)$label), $matches) !== 1) {
continue;
}
$port = (int)$matches[2];
if ($port <= 0) {
continue;
}
if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) {
return $port;
}
$fallback ??= $port;
}
return $fallback;
}
private static function coolifyLabelCertResolver(array $labels, string $resourceUuid): ?string
{
$resourceUuid = self::coolifyRouteLabelId($resourceUuid);
$fallback = null;
foreach ($labels as $label) {
if (preg_match('/^traefik\.http\.routers\.([^=]+)\.tls\.certresolver=([A-Za-z0-9_.-]+)$/', trim((string)$label), $matches) !== 1) {
continue;
}
$resolver = trim((string)$matches[2]);
if ($resolver === '') {
continue;
}
if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) {
return $resolver;
}
$fallback ??= $resolver;
}
return $fallback;
}
private static function gatewayRouteDefaultCertResolver(string $publicUrl): string
{
return 'letsencrypt';
}
private static function decodeCoolifyLabels(mixed $labels): array
{
if (!is_scalar($labels)) {
return [];
}
$raw = trim((string)$labels);
if ($raw === '') {
return [];
}
$decoded = base64_decode($raw, true);
$content = $decoded !== false ? $decoded : $raw;
return array_values(array_filter(
preg_split('/\r\n|\r|\n/', (string)$content) ?: [],
static fn(string $label): bool => trim($label) !== ''
));
}
private static function coolifyLabelKey(string $label): string
{
$position = strpos($label, '=');
return $position === false ? trim($label) : trim(substr($label, 0, $position));
}
private static function coolifyRouteLabelId(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9-]+/', '-', $value) ?: '';
return trim($value, '-');
}
private static function firstInteger(mixed $value): ?int
{
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (is_float($value)) {
return $value > 0 ? (int)$value : null;
}
if (is_array($value)) {
foreach ($value as $item) {
$integer = self::firstInteger($item);
if ($integer !== null) {
return $integer;
}
}
return null;
}
if (!is_scalar($value)) {
return null;
}
if (preg_match('/\d+/', (string)$value, $matches) !== 1) {
return null;
}
$integer = (int)$matches[0];
return $integer > 0 ? $integer : null;
}
private static function coolifyProxyUrl(string $publicUrl, ?int $port): string
{
if ($port === null || $port <= 0) {
return $publicUrl;
}
$parts = parse_url($publicUrl);
if (!is_array($parts) || trim((string)($parts['host'] ?? '')) === '' || isset($parts['port'])) {
return $publicUrl;
}
$scheme = trim((string)($parts['scheme'] ?? 'https')) ?: 'https';
$host = trim((string)$parts['host']);
$path = (string)($parts['path'] ?? '');
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
$fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
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);
$projectUuid = trim((string)($context['coolify_project_uuid'] ?? $context['project_uuid'] ?? $instance['default_project_uuid'] ?? ''));
if ($projectUuid === '') {
throw new RuntimeException('Select a Coolify project for this release target before creating a service.');
}
$environment = $this->releaseCoolifyEnvironment($target, $context, $instance);
$serverUuid = $this->releaseCoolifyServerUuid($context, $instance);
if ($serverUuid === '') {
throw new RuntimeException('Release Manager could not resolve a Coolify server UUID automatically for this instance.');
}
$name = $this->releaseCoolifyResourceName($target, $context);
$compose = (string)($context['docker_compose_raw'] ?? '');
if (trim($compose) === '') {
$image = $this->releaseCoolifyExplicitImage($context);
if ($image === '' && $this->toBool($context['coolify_assume_ghcr_image'] ?? $context['assume_ghcr_image'] ?? false)) {
$repository = strtolower(trim((string)($target['repository'] ?? '')));
$branch = self::safeIdentifier((string)($target['branch'] ?? self::DEFAULT_BRANCH), 64);
if ($repository !== '') {
$image = 'ghcr.io/' . $repository . ':' . ($branch !== '' ? $branch : self::DEFAULT_BRANCH);
}
}
if ($image === '') {
throw new RuntimeException('Coolify service creation needs docker_compose_raw or an explicit image in deploy_context. Release Manager will not assume a GHCR image from repository and branch.');
}
$compose = "services:\n app:\n image: " . $image . "\n restart: unless-stopped\n";
}
$payload = [
'name' => $name,
'description' => 'Truckwash release manager target for ' . (string)($target['repository'] ?? ''),
'project_uuid' => $projectUuid,
'environment_name' => $environment['name'],
'environment_uuid' => $environment['uuid'],
'server_uuid' => $serverUuid,
'instant_deploy' => $this->toBool($context['coolify_deploy_now'] ?? true),
'docker_compose_raw' => base64_encode($compose),
'force_domain_override' => true,
];
if ($publicUrl !== null) {
$payload['urls'] = [
[
'name' => (string)($target['app'] ?? 'release'),
'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)),
],
];
}
return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== '');
}
private function releaseCoolifyResourceName(array $target, array $context): string
{
$requestedName = trim((string)($context['coolify_service_name'] ?? $context['service_name'] ?? $context['coolify_application_name'] ?? $context['application_name'] ?? ''));
return self::safeIdentifier(
$requestedName !== ''
? $requestedName
: 'release-' . (string)($target['channel_slug'] ?? $target['channel_id'] ?? 'channel') . '-' . (string)($target['app'] ?? 'app'),
64
);
}
private function releaseCoolifyServiceSourceIsMissing(array $context): bool
{
return trim((string)($context['docker_compose_raw'] ?? '')) === ''
&& $this->releaseCoolifyExplicitImage($context) === ''
&& !$this->toBool($context['coolify_assume_ghcr_image'] ?? $context['assume_ghcr_image'] ?? false);
}
private function releaseCoolifyGithubAppUuid(array $context, array $target = [], array $instance = [], bool $discover = false): string
{
foreach ([
'coolify_github_app_uuid',
'github_app_uuid',
'coolify_git_app_uuid',
'git_app_uuid',
'default_github_app_uuid',
'default_coolify_github_app_uuid',
] as $key) {
$value = trim((string)($context[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
if (!$discover) {
return '';
}
return $this->releaseCoolifyDefaultGithubAppUuid($target, $context, $instance);
}
private function releaseCoolifyDefaultGithubAppUuid(array $target, array $context, array $instance): string
{
foreach ([
'default_github_app_uuid',
'default_coolify_github_app_uuid',
'coolify_github_app_uuid',
'github_app_uuid',
] as $key) {
$value = trim((string)($instance[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
foreach ([
getenv('RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID'] ?? null),
getenv('COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['COOLIFY_GITHUB_APP_UUID'] ?? null),
$this->moduleConfigValue('ReleaseManager', 'coolify_github_app_uuid', ''),
$this->moduleConfigValue('Coolify', 'github_app_uuid', ''),
] as $value) {
$value = trim((string)$value);
if ($value !== '') {
return $value;
}
}
$tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
if ($tokenSecret === '') {
return '';
}
try {
$token = replication_secret_box::decrypt($tokenSecret);
$apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps();
} catch (Throwable) {
return '';
}
$rows = array_values(array_filter($this->payloadRows($apps), static function (mixed $row): bool {
return is_array($row) && trim((string)($row['uuid'] ?? '')) !== '';
}));
if (count($rows) === 1) {
return trim((string)$rows[0]['uuid']);
}
$repository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? ''));
$owner = strtolower(trim(strtok($repository, '/') ?: ''));
if ($owner === '') {
return '';
}
$matches = array_values(array_filter($rows, static function (array $row) use ($owner): bool {
foreach (['organization', 'name', 'custom_user', 'html_url'] as $key) {
$value = strtolower(trim((string)($row[$key] ?? '')));
if ($value === '') {
continue;
}
if ($value === $owner || str_contains($value, '/' . $owner) || str_contains($value, $owner . '-')) {
return true;
}
}
return false;
}));
return count($matches) === 1 ? trim((string)$matches[0]['uuid']) : '';
}
private function releaseCoolifyResourceType(array $context, string $serviceUuid = ''): string
{
$type = strtolower(trim((string)($context['coolify_resource_type'] ?? $context['resource_type'] ?? '')));
if (in_array($type, ['application', 'app'], true)) {
return 'application';
}
if ($type === '' && trim($serviceUuid) === '' && $this->releaseCoolifyGithubAppUuid($context) !== '') {
return 'application';
}
return 'service';
}
private function releaseTargetNeedsApplicationAutoCreate(array $target, array $context): bool
{
if (!in_array(strtolower(trim((string)($target['app'] ?? ''))), self::APPS, true)) {
return false;
}
if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) === null) {
return false;
}
if (trim((string)($target['coolify_service_uuid'] ?? '')) !== '') {
return false;
}
if ($this->toBool($context['coolify_auto_create'] ?? false)) {
return false;
}
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
return self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)
|| $this->releaseCoolifyResourceType($context, '') === 'application';
}
private function releaseCoolifyBuildPack(array $target, array $context): string
{
$app = strtolower(trim((string)($target['app'] ?? '')));
$buildPack = strtolower(trim((string)($context['coolify_build_pack'] ?? $context['build_pack'] ?? '')));
if ($buildPack !== '') {
if ($app === 'frontend' && $buildPack === 'nixpacks') {
return 'dockerfile';
}
return $buildPack;
}
return in_array($app, ['api', 'frontend', self::CRON_WORKER_APP], true) ? 'dockerfile' : 'static';
}
private function releaseCoolifyPortsExposes(array $target, array $context): string
{
foreach ([
'coolify_ports_exposes',
'ports_exposes',
'coolify_exposed_port',
'exposed_port',
'coolify_port',
'port',
] as $key) {
$value = trim((string)($context[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
$app = strtolower(trim((string)($target['app'] ?? '')));
$envKeys = in_array($app, ['api', self::CRON_WORKER_APP], true)
? ['RELEASE_MANAGER_API_PORTS_EXPOSES', 'RELEASE_API_PORTS_EXPOSES', 'API_PORTS_EXPOSES']
: ['RELEASE_MANAGER_FRONTEND_PORTS_EXPOSES', 'RELEASE_FRONTEND_PORTS_EXPOSES', 'FRONTEND_PORTS_EXPOSES'];
foreach ($envKeys as $key) {
$value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? '')));
if ($value !== '') {
return $value;
}
}
return self::DEFAULT_COOLIFY_APPLICATION_PORT;
}
private function releaseCoolifyProxyPort(array $target, array $context): ?int
{
foreach ([
'coolify_ports_exposes',
'ports_exposes',
'coolify_exposed_port',
'exposed_port',
'coolify_port',
'port',
] as $key) {
$port = self::firstInteger($context[$key] ?? null);
if ($port !== null) {
return $port;
}
}
$app = strtolower(trim((string)($target['app'] ?? '')));
if ($app !== 'api') {
return null;
}
return self::firstInteger($this->releaseCoolifyPortsExposes($target, $context));
}
private function releaseCoolifyGitCommitSha(array $target, array $context): string
{
foreach (['commit_sha', 'commit'] as $key) {
$value = trim((string)($target[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
foreach ([
'coolify_git_commit_sha',
'git_commit_sha',
'commit_sha',
'commit',
] as $key) {
$value = trim((string)($context[$key] ?? $target[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
private function releaseCoolifyForceRebuild(array $context): bool
{
if (array_key_exists('coolify_force_rebuild', $context) || array_key_exists('force_rebuild', $context)) {
return $this->toBool($context['coolify_force_rebuild'] ?? $context['force_rebuild'] ?? false);
}
return true;
}
private function releaseCoolifyApplicationDefaultFields(array $target, array $context): array
{
$app = strtolower(trim((string)($target['app'] ?? '')));
$buildPack = $this->releaseCoolifyBuildPack($target, $context);
if (in_array($app, ['api', self::CRON_WORKER_APP], true) && $buildPack === 'dockerfile') {
return [
'dockerfile_location' => self::DEFAULT_COOLIFY_API_DOCKERFILE,
];
}
if ($app === 'frontend' && $buildPack === 'dockerfile') {
return [
'dockerfile_location' => '/Dockerfile.coolify-frontend',
];
}
if ($app !== 'frontend' || $buildPack !== 'static') {
return [];
}
return [
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'publish_directory' => 'dist',
'is_static' => true,
'is_spa' => true,
];
}
private function releaseCoolifyApplicationOptionalFields(array $context): array
{
$fields = [];
foreach ([
'base_directory',
'publish_directory',
'dockerfile',
'dockerfile_location',
'docker_compose_location',
'ports_exposes',
'ports_mappings',
'install_command',
'build_command',
'start_command',
] as $key) {
$value = trim((string)($context['coolify_' . $key] ?? $context[$key] ?? ''));
if ($value !== '') {
$fields[$key] = $value;
}
}
foreach ([
'is_static',
'is_spa',
'is_force_https_enabled',
'is_auto_deploy_enabled',
] as $key) {
if (array_key_exists('coolify_' . $key, $context) || array_key_exists($key, $context)) {
$fields[$key] = $this->toBool($context['coolify_' . $key] ?? $context[$key] ?? false);
}
}
return $fields;
}
private function releaseCoolifyEnvironment(array $target, array $context, array $instance): array
{
$explicitUuid = trim((string)($context['coolify_environment_uuid'] ?? $context['environment_uuid'] ?? ''));
$explicitName = trim((string)($context['coolify_environment_name'] ?? $context['environment_name'] ?? ''));
$releaseEnvironmentName = $this->releaseBranchCoolifyEnvironmentName($target);
if ($explicitUuid !== '' || ($explicitName !== '' && !($releaseEnvironmentName !== null && strtolower($explicitName) === 'production'))) {
return [
'uuid' => $explicitUuid !== '' ? $explicitUuid : null,
'name' => $explicitName !== '' ? $explicitName : (trim((string)($instance['default_environment_name'] ?? 'production')) ?: 'production'),
];
}
if ($releaseEnvironmentName !== null) {
return [
'uuid' => null,
'name' => $releaseEnvironmentName,
];
}
return [
'uuid' => trim((string)($instance['default_environment_uuid'] ?? '')) ?: null,
'name' => trim((string)($instance['default_environment_name'] ?? 'production')) ?: 'production',
];
}
private function releaseBranchCoolifyEnvironmentName(array $target): ?string
{
$channelSlug = self::safeSlug((string)($target['channel_slug'] ?? $target['channel'] ?? ''));
if ($channelSlug !== '' && in_array($channelSlug, self::DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS, true)) {
return null;
}
$branchSlug = self::safeSlug(preg_replace('#^refs/heads/#', '', (string)($target['branch'] ?? '')) ?? '');
if ($channelSlug === '' && $branchSlug === '') {
return null;
}
if ($branchSlug !== '' && !in_array($branchSlug, ['main', 'master'], true)) {
return $branchSlug;
}
return $channelSlug !== '' ? $channelSlug : null;
}
private function releaseCoolifyExplicitImage(array $context): string
{
foreach (['image', 'docker_image', 'coolify_image', 'coolify_docker_image', 'registry_image'] as $key) {
$value = $context[$key] ?? null;
if (is_scalar($value)) {
$image = trim((string)$value);
if ($image !== '') {
return $image;
}
}
}
return '';
}
private function releaseCoolifyServerUuid(array $context, array $instance): string
{
$explicit = trim((string)($context['coolify_server_uuid'] ?? $context['server_uuid'] ?? ''));
if ($explicit !== '') {
return $explicit;
}
$default = trim((string)($instance['default_server_uuid'] ?? ''));
if ($default !== '') {
return $default;
}
$tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
if ($tokenSecret === '') {
return '';
}
try {
$token = replication_secret_box::decrypt($tokenSecret);
$servers = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listServers();
} catch (Throwable) {
return '';
}
$firstServerUuid = '';
foreach ($this->payloadRows($servers) as $server) {
if (!is_array($server)) {
continue;
}
$uuid = trim((string)($server['uuid'] ?? ''));
if ($uuid === '') {
continue;
}
if ($firstServerUuid === '') {
$firstServerUuid = $uuid;
}
$settings = is_array($server['settings'] ?? null) ? $server['settings'] : [];
if (($settings['is_reachable'] ?? true) !== false && ($settings['is_usable'] ?? true) !== false) {
return $uuid;
}
}
return $firstServerUuid;
}
private function releaseDeploymentEndpoint(array $target): array
{
$context = is_array($target['deploy_context'] ?? null)
? $target['deploy_context']
: self::jsonDecode($target['deploy_context_json'] ?? null);
$mode = strtolower(trim((string)($context['endpoint_mode'] ?? 'auto'))) === 'manual' ? 'manual' : 'auto';
$app = strtolower(trim((string)($target['app'] ?? '')));
if ($app === self::CRON_WORKER_APP) {
return self::releasePendingEndpoint(
'auto',
'private_worker',
'Cron worker targets do not expose a public endpoint.'
);
}
if ($mode === 'manual') {
$host = self::normalizeEndpointHost($context['manual_endpoint_host'] ?? '');
$port = $this->releaseEndpointPort($context['manual_endpoint_port'] ?? null);
if ($host !== '') {
return $this->releaseEndpointFromParts(
'manual',
'resolved',
$host,
$port,
'manual',
'Manual endpoint override is configured.'
);
}
return self::releasePendingEndpoint(
'manual',
'manual',
'Manual endpoint mode needs a public host before deployment.'
);
}
foreach ([
'coolify_public_url' => 'coolify_public_url',
'health_url' => 'health_url',
'coolify_domain' => 'coolify_domain',
] as $key => $source) {
$value = $key === 'health_url'
? ($target['health_url'] ?? null)
: ($context[$key] ?? null);
$url = $key === 'coolify_domain'
? $this->releaseRoutedPublicBaseUrl($value, $target, $context)
: ($key === 'coolify_public_url'
? $this->releaseRoutedPublicBaseUrl($value, $target, $context)
: $this->normalizeReleasePublicBaseUrl($value, $app));
if ($url !== null) {
return $this->releaseEndpointFromUrl(
$url,
'auto',
'resolved',
$source,
'Automatic endpoint resolved from ' . str_replace('_', ' ', $source) . '.'
);
}
}
$gatewayUrl = $this->releaseAutoGatewayPublicBaseUrl($target, $context);
if ($gatewayUrl !== null) {
return $this->releaseEndpointFromUrl(
$gatewayUrl,
'auto',
'pending',
'auto_gateway',
'Automatic gateway endpoint will be used when Coolify routing is ready.'
);
}
foreach ([
'coolify_deployed_public_url',
'deployed_public_url',
'resource_public_url',
'public_url',
'coolify_deployed_url',
'deployed_url',
] as $key) {
$url = $this->normalizeReleasePublicBaseUrl($context[$key] ?? null, $app);
if ($url !== null) {
return $this->releaseEndpointFromUrl(
$url,
'auto',
'resolved',
'coolify_resource_metadata',
'Automatic endpoint resolved from deployed Coolify resource metadata.'
);
}
}
return self::releasePendingEndpoint(
'auto',
'auto',
'Automatic endpoint resolution is pending deployment metadata.'
);
}
private static function releasePendingEndpoint(string $mode, string $source, string $message): array
{
return [
'mode' => $mode,
'status' => 'pending',
'host' => null,
'port' => null,
'url' => null,
'source' => $source,
'message' => $message,
];
}
private function releaseEndpointFromUrl(string $url, string $mode, string $status, string $source, string $message): array
{
$normalized = $this->normalizeReleasePublicBaseUrl($url);
if ($normalized === null) {
return self::releasePendingEndpoint($mode, $source, $message);
}
$parts = parse_url($normalized);
if (!is_array($parts) || empty($parts['host'])) {
return self::releasePendingEndpoint($mode, $source, $message);
}
$scheme = strtolower((string)($parts['scheme'] ?? 'https'));
$port = isset($parts['port']) ? (int)$parts['port'] : ($scheme === 'http' ? 80 : 443);
return [
'mode' => $mode,
'status' => $status,
'host' => strtolower((string)$parts['host']),
'port' => $port,
'url' => $normalized,
'source' => $source,
'message' => $message,
];
}
private function releaseEndpointFromParts(
string $mode,
string $status,
string $host,
?int $port,
string $source,
string $message
): array {
$host = self::normalizeEndpointHost($host);
if ($host === '') {
return self::releasePendingEndpoint($mode, $source, $message);
}
$url = 'https://' . $host . ($port !== null && $port !== 443 ? ':' . $port : '');
return [
'mode' => $mode,
'status' => $status,
'host' => strtolower($host),
'port' => $port,
'url' => $url,
'source' => $source,
'message' => $message,
];
}
private function releaseEndpointPort(mixed $value): ?int
{
$raw = trim((string)$value);
if ($raw === '') {
return null;
}
if (filter_var($raw, FILTER_VALIDATE_INT) === false) {
return null;
}
$port = (int)$raw;
return $port >= 1 && $port <= 65535 ? $port : null;
}
private static function normalizeEndpointHost(mixed $value): string
{
$raw = trim((string)$value);
if ($raw === '') {
return '';
}
if (preg_match('#^https?://#i', $raw) === 1) {
$parts = parse_url($raw);
$raw = is_array($parts) ? (string)($parts['host'] ?? '') : '';
}
$raw = trim($raw);
$raw = preg_replace('#[/\s].*$#', '', $raw) ?? '';
if (str_contains($raw, ':') && preg_match('/^\x5b[^\x5d]+\x5d:(\d+)$/', $raw) !== 1) {
$parts = parse_url('https://' . $raw);
if (is_array($parts) && !empty($parts['host'])) {
$raw = (string)$parts['host'];
}
}
return strtolower(trim($raw, " \t\n\r\0\x0B[]"));
}
private function releaseAutoGatewayPublicBaseUrl(array $target, array $context): ?string
{
$host = $this->releasePublicGatewayHost($context);
if ($host === '') {
return null;
}
return $this->releaseRoutedPublicBaseUrl('https://' . $host, $target, array_replace($context, [
'gateway_route_autoprovision' => false,
]));
}
private function releasePublicGatewayHost(array $context = []): string
{
foreach ([
$context['public_gateway_host'] ?? null,
$context['coolify_public_gateway_host'] ?? null,
] as $value) {
$host = self::normalizeEndpointHost($value);
if ($host !== '') {
return $host;
}
}
foreach ([
getenv('RELEASE_MANAGER_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['RELEASE_MANAGER_PUBLIC_GATEWAY_HOST'] ?? null),
getenv('COOLIFY_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['COOLIFY_PUBLIC_GATEWAY_HOST'] ?? null),
] as $value) {
$host = self::normalizeEndpointHost($value);
if ($host !== '') {
return $host;
}
}
try {
if ($this->tableExists('module_config')) {
$host = self::normalizeEndpointHost($this->moduleConfigValue('Coolify', 'public_gateway_host', ''));
if ($host !== '') {
return $host;
}
}
} catch (Throwable) {
}
return 'api-v2.truckwash.io';
}
private function releaseCoolifyPublicUrl(array $target, array $context): ?string
{
$endpoint = $this->releaseDeploymentEndpoint($target + ['deploy_context' => $context]);
if (!empty($endpoint['url']) && in_array((string)($endpoint['source'] ?? ''), [
'manual',
'coolify_public_url',
'health_url',
'coolify_domain',
'auto_gateway',
'coolify_resource_metadata',
], true)) {
return (string)$endpoint['url'];
}
$explicitPublicUrl = $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context);
if ($explicitPublicUrl !== null) {
return $explicitPublicUrl;
}
$raw = trim((string)($context['coolify_domain'] ?? ''));
$hasCoolifyDomain = $raw !== '';
if ($raw === '') {
$raw = trim((string)($target['health_url'] ?? ''));
}
if ($raw === '') {
return null;
}
if ($this->toBool($context['coolify_enable_ssl'] ?? false)) {
$domain = self::domainSuggestionHost($raw);
if ($domain === null) {
throw new RuntimeException('Coolify SSL requires a DNS domain routed to the load balancer.');
}
return $hasCoolifyDomain
? $this->releaseRoutedPublicBaseUrl('https://' . $domain, $target, $context)
: $this->normalizeReleasePublicBaseUrl('https://' . $domain, (string)($target['app'] ?? ''));
}
if (preg_match('#^https?://#i', $raw) !== 1) {
$raw = 'http://' . $raw;
}
return $hasCoolifyDomain
? $this->releaseRoutedPublicBaseUrl($raw, $target, $context)
: $this->normalizeReleasePublicBaseUrl($raw, (string)($target['app'] ?? ''));
}
private function releaseRoutedPublicBaseUrl(mixed $value, array $target, array $context = []): ?string
{
$app = (string)($target['app'] ?? '');
$baseUrl = $this->normalizeReleasePublicBaseUrl($value, $app);
if ($baseUrl === null) {
return null;
}
$parts = parse_url($baseUrl);
if (!is_array($parts) || empty($parts['host'])) {
return $baseUrl;
}
if ($this->toBool($context['gateway_route_autoprovision'] ?? false)) {
return $baseUrl;
}
$path = trim((string)($parts['path'] ?? ''), '/');
if ($path !== '') {
return $baseUrl;
}
$channelSlug = self::safeSlug((string)(
$target['channel_slug']
?? $context['channel_slug']
?? $target['release_channel']
?? $context['release_channel']
?? $target['channel']
?? $context['channel']
?? ''
));
$appSlug = self::safeSlug($app);
if ($channelSlug === '' || $appSlug === '') {
return $baseUrl;
}
$routeSlug = self::routeSlugForChannel($channelSlug);
$scheme = strtolower((string)($parts['scheme'] ?? 'https'));
$host = strtolower((string)$parts['host']);
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
return sprintf('%s://%s%s/%s/%s', $scheme, $host, $port, $routeSlug, $appSlug);
}
private function releaseTargetPublicBaseUrl(array $target): ?string
{
$context = is_array($target['deploy_context'] ?? null)
? $target['deploy_context']
: self::jsonDecode($target['deploy_context_json'] ?? null);
$app = (string)($target['app'] ?? '');
$endpoint = is_array($target['endpoint'] ?? null)
? $target['endpoint']
: $this->releaseDeploymentEndpoint($target + ['deploy_context' => $context]);
return $this->normalizeReleasePublicBaseUrl($endpoint['url'] ?? null, $app)
?? $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context)
?? $this->normalizeReleasePublicBaseUrl($target['health_url'] ?? null, $app)
?? $this->releaseRoutedPublicBaseUrl($context['coolify_domain'] ?? null, $target, $context)
?? (is_array($target['endpoint'] ?? null) ? ($target['endpoint']['url'] ?? null) : null)
?? ($this->releaseDeploymentEndpoint($target)['url'] ?? null);
}
private function timelineSessionContext(array $context): array
{
$device = is_array($context['device'] ?? null) ? $context['device'] : [];
$browser = is_array($context['browser'] ?? null) ? $context['browser'] : [];
$os = is_array($context['os'] ?? null) ? $context['os'] : [];
$viewport = is_array($context['viewport'] ?? null) ? $context['viewport'] : [];
$frontend = is_array($context['frontend'] ?? null) ? $context['frontend'] : [];
$api = is_array($context['api'] ?? null) ? $context['api'] : [];
return [
'device_type' => $this->nullableIdentifier($context['device_type'] ?? $device['type'] ?? null, 16),
'browser_name' => $this->nullableString($context['browser_name'] ?? $browser['name'] ?? null, 64),
'browser_version' => $this->nullableString($context['browser_version'] ?? $browser['version'] ?? null, 64),
'os_name' => $this->nullableString($context['os_name'] ?? $os['name'] ?? null, 64),
'os_version' => $this->nullableString($context['os_version'] ?? $os['version'] ?? null, 64),
'viewport_width' => $this->nullableInt($context['viewport_width'] ?? $viewport['width'] ?? null),
'viewport_height' => $this->nullableInt($context['viewport_height'] ?? $viewport['height'] ?? null),
'device_pixel_ratio' => $this->nullableFloat($context['device_pixel_ratio'] ?? $viewport['device_pixel_ratio'] ?? null),
'frontend_version_label' => $this->nullableString(
$context['frontend_version_label'] ?? $frontend['version_label'] ?? $context['frontend_version'] ?? null,
128
),
'frontend_commit_sha' => $this->nullableString(
$context['frontend_commit_sha'] ?? $frontend['commit_sha'] ?? $context['frontend_commit'] ?? null,
128
),
'api_version_label' => $this->nullableString(
$context['api_version_label'] ?? $api['version_label'] ?? $context['api_version'] ?? null,
128
),
'api_commit_sha' => $this->nullableString(
$context['api_commit_sha'] ?? $api['commit_sha'] ?? $context['backend_version'] ?? null,
128
),
'last_route_path' => $this->nullableString($context['route_path'] ?? $context['route'] ?? null, 255),
];
}
private function latestRouteFromContext(array $context): ?string
{
foreach (['route_path', 'route'] as $key) {
$value = $this->nullableString($context[$key] ?? null, 255);
if ($value !== null) {
return $value;
}
}
return null;
}
private function timelineSessionId(string $traceId, array $context, ?array $channel): int
{
$existing = $this->selectOne('SELECT id FROM release_timeline_sessions WHERE trace_id = ? LIMIT 1', 's', [$traceId]);
$userAgent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 512);
$principalType = trim((string)($context['principal_type'] ?? '')) ?: null;
$principalId = trim((string)($context['principal_id'] ?? '')) ?: null;
$customerNumber = isset($context['customer_number']) && is_numeric($context['customer_number'])
? (int)$context['customer_number']
: null;
$sessionContext = $this->timelineSessionContext($context);
$lastRoutePath = $sessionContext['last_route_path'] ?? null;
if ($lastRoutePath === null) {
$lastRoutePath = $this->latestRouteFromContext($context);
}
if ($existing !== null) {
$this->execute(
"UPDATE release_timeline_sessions
SET last_seen_at = NOW(), channel_id = COALESCE(?, channel_id), channel_slug = COALESCE(?, channel_slug),
principal_type = COALESCE(?, principal_type), principal_id = COALESCE(?, principal_id),
customer_number = COALESCE(?, customer_number),
device_type = COALESCE(?, device_type), browser_name = COALESCE(?, browser_name),
browser_version = COALESCE(?, browser_version), os_name = COALESCE(?, os_name),
os_version = COALESCE(?, os_version), viewport_width = COALESCE(?, viewport_width),
viewport_height = COALESCE(?, viewport_height), device_pixel_ratio = COALESCE(?, device_pixel_ratio),
frontend_version_label = COALESCE(?, frontend_version_label),
frontend_commit_sha = COALESCE(?, frontend_commit_sha),
api_version_label = COALESCE(?, api_version_label),
api_commit_sha = COALESCE(?, api_commit_sha),
last_route_path = COALESCE(?, last_route_path),
user_agent = COALESCE(?, user_agent)
WHERE id = ?",
'isssisssssiidssssssi',
[
$channel['id'] ?? null,
$channel['slug'] ?? null,
$principalType,
$principalId,
$customerNumber,
$sessionContext['device_type'],
$sessionContext['browser_name'],
$sessionContext['browser_version'],
$sessionContext['os_name'],
$sessionContext['os_version'],
$sessionContext['viewport_width'],
$sessionContext['viewport_height'],
$sessionContext['device_pixel_ratio'],
$sessionContext['frontend_version_label'],
$sessionContext['frontend_commit_sha'],
$sessionContext['api_version_label'],
$sessionContext['api_commit_sha'],
$lastRoutePath,
$userAgent !== '' ? $userAgent : null,
(int)$existing['id'],
]
);
return (int)$existing['id'];
}
$this->execute(
"INSERT INTO release_timeline_sessions (
trace_id, session_hash, principal_type, principal_id, customer_number,
channel_id, channel_slug, device_type, browser_name, browser_version, os_name,
os_version, viewport_width, viewport_height, device_pixel_ratio,
frontend_version_label, frontend_commit_sha, api_version_label, api_commit_sha,
last_route_path, user_agent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
'ssssiisssssiidsssssss',
[
$traceId,
$this->sessionHash(),
$principalType,
$principalId,
$customerNumber,
$channel['id'] ?? null,
$channel['slug'] ?? null,
$sessionContext['device_type'],
$sessionContext['browser_name'],
$sessionContext['browser_version'],
$sessionContext['os_name'],
$sessionContext['os_version'],
$sessionContext['viewport_width'],
$sessionContext['viewport_height'],
$sessionContext['device_pixel_ratio'],
$sessionContext['frontend_version_label'],
$sessionContext['frontend_commit_sha'],
$sessionContext['api_version_label'],
$sessionContext['api_commit_sha'],
$lastRoutePath,
$userAgent !== '' ? $userAgent : null,
]
);
return $this->insertId();
}
private function sessionHash(): ?string
{
$authorization = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? '');
if ($authorization === '') {
return null;
}
return hash('sha256', str_replace('Bearer ', '', $authorization));
}
private function inferModuleKeyFromUri(string $uri): ?string
{
$path = strtolower(explode('?', $uri)[0] ?? '');
$map = [
'/auth' => 'auth',
'/superuser/releases' => 'releasemanager',
'/release' => 'releasemanager',
'/superuser/coolify' => 'coolify',
'/coolify' => 'coolify',
'/failover' => 'failover',
'/edge-gateway' => 'edgegateway',
'/edgegateway' => 'edgegateway',
'/modules/action-logs' => 'moduleactionlogs',
'/worker' => 'worker',
'/economic' => 'economic',
'/stripe' => 'stripe',
'/selfserve' => 'selfserve',
'/bird' => 'bird',
'/xlvask' => 'xlvask',
];
foreach ($map as $prefix => $moduleKey) {
if (str_starts_with($path, $prefix)) {
return $moduleKey;
}
}
return null;
}
private function timelineSummary(): array
{
$events = $this->selectOne(
"SELECT COUNT(*) AS total,
SUM(CASE WHEN severity = 'error' THEN 1 ELSE 0 END) AS errors,
MAX(created_at) AS last_event_at
FROM release_timeline_events"
) ?? [];
$sessions = $this->selectOne('SELECT COUNT(*) AS total FROM release_timeline_sessions') ?? [];
return [
'sessions' => (int)($sessions['total'] ?? 0),
'events' => (int)($events['total'] ?? 0),
'errors' => (int)($events['errors'] ?? 0),
'last_event_at' => $events['last_event_at'] ?? null,
];
}
private function latestModuleHealth(): array
{
return $this->selectRows(
"SELECT h.*
FROM release_module_health_snapshots h
INNER JOIN (
SELECT module_key, MAX(checked_at) AS checked_at
FROM release_module_health_snapshots
GROUP BY module_key
) latest ON latest.module_key = h.module_key AND latest.checked_at = h.checked_at
ORDER BY h.module_key"
);
}
private static function releaseBranchForChannel(array|string $channel): string
{
$slug = is_array($channel) ? (string)($channel['slug'] ?? '') : $channel;
$routeSlug = self::routeSlugForChannel($slug);
return $routeSlug !== '' ? $routeSlug : self::DEFAULT_BRANCH;
}
private static function defaultRepositoryForApp(string $app): string
{
return match (strtolower(trim($app))) {
'frontend' => 'copenhagentruckwash/pleno-vue',
'api' => 'copenhagentruckwash/api',
default => '',
};
}
private function getOperationRun(int $id): array
{
$row = $this->selectOne(
"SELECT r.*, c.slug AS channel_slug, c.name AS channel_name,
(SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id) AS step_count,
(SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('passed', 'deployed')) AS passed_step_count,
(SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status = 'failed') AS failed_step_count,
(SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('warning', 'skipped')) AS warning_step_count
FROM release_operation_runs r
LEFT JOIN release_channels c ON c.id = r.channel_id
WHERE r.id = ?
LIMIT 1",
'i',
[$id]
);
if ($row === null) {
throw new RuntimeException('Release operation not found.');
}
return $row;
}
private function createOperationRun(string $operationType, array $input): int
{
$operationType = self::safeIdentifier($operationType, 64) ?: 'operation';
$subjectType = self::safeIdentifier((string)($input['subject_type'] ?? ''), 64) ?: null;
$subjectId = trim((string)($input['subject_id'] ?? '')) ?: null;
$channelId = $this->nullablePositiveInt($input['channel_id'] ?? null);
$app = trim((string)($input['app'] ?? '')) !== '' ? $this->normalizeApp((string)$input['app']) : null;
$title = substr(trim((string)($input['title'] ?? $operationType)), 0, 255);
$context = is_array($input['context'] ?? null) ? $input['context'] : [];
$actorUserId = $this->nullablePositiveInt($input['actor_user_id'] ?? null);
$this->execute(
"INSERT INTO release_operation_runs (
operation_type, subject_type, subject_id, channel_id, app, status,
title, actor_user_id, context_json, started_at
) VALUES (?, ?, ?, ?, ?, 'running', ?, ?, ?, NOW())",
'sssissis',
[
$operationType,
$subjectType,
$subjectId,
$channelId,
$app,
$title,
$actorUserId,
self::jsonEncode(self::redactPayload($context)),
]
);
return $this->insertId();
}
private function recordOperationStep(
int $operationId,
string $stepKey,
string $label,
string $status,
?string $message = null,
?string $diagnostic = null,
?string $solutionHint = null,
array $context = []
): void {
$stepKey = self::safeIdentifier($stepKey, 64) ?: 'step';
$status = self::safeIdentifier($status, 32) ?: 'queued';
$completedSql = in_array($status, ['passed', 'deployed', 'failed', 'warning', 'skipped'], true) ? 'NOW()' : 'NULL';
$this->execute(
"INSERT INTO release_operation_steps (
operation_run_id, step_key, label, status, message, diagnostic,
solution_hint, context_json, started_at, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), $completedSql)",
'isssssss',
[
$operationId,
$stepKey,
substr($label, 0, 255),
$status,
$message,
$diagnostic,
$solutionHint,
self::jsonEncode(self::redactPayload($context)),
]
);
}
private function completeOperationRun(int $operationId, string $status, string $summary, ?string $solutionHint = null): void
{
$status = self::safeIdentifier($status, 32) ?: 'completed';
$this->execute(
"UPDATE release_operation_runs
SET status = ?, summary = ?, solution_hint = ?, completed_at = NOW()
WHERE id = ?",
'sssi',
[$status, $summary, $solutionHint, $operationId]
);
}
private function publicOperationRun(array $operation, bool $includeSteps = true): array
{
$operationId = (int)($operation['id'] ?? 0);
$steps = [];
if ($includeSteps && $operationId > 0) {
$steps = array_map(
fn(array $row): array => $this->publicOperationStep($row),
$this->selectRows(
'SELECT * FROM release_operation_steps WHERE operation_run_id = ? ORDER BY id ASC',
'i',
[$operationId]
)
);
}
return [
'id' => $operationId,
'operation_type' => (string)($operation['operation_type'] ?? ''),
'subject_type' => $operation['subject_type'] ?? null,
'subject_id' => $operation['subject_id'] ?? null,
'channel_id' => isset($operation['channel_id']) ? (int)$operation['channel_id'] : null,
'channel_slug' => $operation['channel_slug'] ?? null,
'channel_name' => $operation['channel_name'] ?? null,
'app' => $operation['app'] ?? null,
'status' => (string)($operation['status'] ?? 'unknown'),
'title' => $operation['title'] ?? null,
'summary' => $operation['summary'] ?? null,
'solution_hint' => $operation['solution_hint'] ?? null,
'context' => self::jsonDecode($operation['context_json'] ?? null),
'step_count' => (int)($operation['step_count'] ?? count($steps)),
'passed_step_count' => (int)($operation['passed_step_count'] ?? 0),
'failed_step_count' => (int)($operation['failed_step_count'] ?? 0),
'warning_step_count' => (int)($operation['warning_step_count'] ?? 0),
'steps' => $includeSteps ? $steps : null,
'actor_user_id' => isset($operation['actor_user_id']) ? (int)$operation['actor_user_id'] : null,
'started_at' => $operation['started_at'] ?? null,
'completed_at' => $operation['completed_at'] ?? null,
'created_at' => $operation['created_at'] ?? null,
'updated_at' => $operation['updated_at'] ?? null,
];
}
private function publicOperationStep(array $step): array
{
return [
'id' => (int)($step['id'] ?? 0),
'operation_run_id' => (int)($step['operation_run_id'] ?? 0),
'step_key' => (string)($step['step_key'] ?? ''),
'label' => (string)($step['label'] ?? ''),
'status' => (string)($step['status'] ?? 'unknown'),
'message' => $step['message'] ?? null,
'diagnostic' => $step['diagnostic'] ?? null,
'solution_hint' => $step['solution_hint'] ?? null,
'context' => self::jsonDecode($step['context_json'] ?? null),
'started_at' => $step['started_at'] ?? null,
'completed_at' => $step['completed_at'] ?? null,
'created_at' => $step['created_at'] ?? null,
'updated_at' => $step['updated_at'] ?? null,
];
}
private function activeDeploymentKey(int $channelId, string $app): string
{
return $channelId . ':' . $this->normalizeApp($app);
}
private function activateDeploymentForChannelApp(int $deploymentId, int $channelId, string $app): void
{
$app = $this->normalizeApp($app);
$key = $this->activeDeploymentKey($channelId, $app);
$this->execute(
"UPDATE release_deployments
SET status = 'superseded', active_channel_app_key = NULL
WHERE active_channel_app_key = ? AND id <> ?",
'si',
[$key, $deploymentId]
);
$this->execute(
"UPDATE release_deployments
SET status = 'superseded', active_channel_app_key = NULL
WHERE channel_id = ? AND app = ? AND id <> ? AND status = 'active'",
'isi',
[$channelId, $app, $deploymentId]
);
$this->execute(
"UPDATE release_deployments
SET status = 'active', active_channel_app_key = ?, completed_at = COALESCE(completed_at, NOW())
WHERE id = ?",
'si',
[$key, $deploymentId]
);
}
private function currentDeploymentForChannelApp(int $channelId, string $app): ?array
{
$app = $this->normalizeApp($app);
$key = $this->activeDeploymentKey($channelId, $app);
$row = $this->selectOne(
"SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
FROM release_deployments d
INNER JOIN release_channels c ON c.id = d.channel_id
LEFT JOIN release_versions v ON v.id = d.version_id
WHERE d.active_channel_app_key = ?
LIMIT 1",
's',
[$key]
);
if ($row !== null) {
return $row;
}
return $this->selectOne(
"SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
FROM release_deployments d
INNER JOIN release_channels c ON c.id = d.channel_id
LEFT JOIN release_versions v ON v.id = d.version_id
WHERE d.channel_id = ? AND d.app = ? AND d.status = 'active'
ORDER BY d.completed_at DESC, d.id DESC
LIMIT 1",
'is',
[$channelId, $app]
);
}
private function deploymentTargetForChannelApp(int $channelId, string $app): ?array
{
return $this->selectOne(
"SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label
FROM release_deployment_targets t
INNER JOIN release_channels c ON c.id = t.channel_id
LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id
WHERE t.deleted_at IS NULL AND t.channel_id = ? AND t.app = ?
ORDER BY t.auto_deploy DESC, t.id DESC
LIMIT 1",
'is',
[$channelId, $this->normalizeApp($app)]
);
}
private function channelCurrentDeployments(int $channelId): array
{
$deployments = [];
foreach (self::APPS as $app) {
$row = $this->currentDeploymentForChannelApp($channelId, $app);
$deployments[$app] = $row !== null ? $this->publicDeployment($row) : null;
}
return $deployments;
}
private function channelBranchStatus(array $channel): array
{
$channelId = (int)($channel['id'] ?? 0);
if ($this->channelUsesProductionServices($channel)) {
$serviceChannel = $this->productionServiceChannel();
$serviceChannelId = (int)($serviceChannel['id'] ?? 0);
$branch = self::releaseBranchForChannel($serviceChannel);
$status = [];
foreach (self::APPS as $app) {
$target = $this->deploymentTargetForChannelApp($serviceChannelId, $app);
$repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app)));
$status[$app] = [
'repository' => $repository,
'branch' => $branch,
'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')),
'target_configured' => true,
'target_branch' => $target['branch'] ?? $branch,
'state' => self::PRODUCTION_SERVICE_POLICY,
'retry_action' => null,
'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
];
}
return $status;
}
$branch = self::releaseBranchForChannel($channel);
$status = [];
foreach (self::APPS as $app) {
$target = $this->deploymentTargetForChannelApp($channelId, $app);
$repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app)));
$status[$app] = [
'repository' => $repository,
'branch' => $branch,
'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')),
'target_configured' => $target !== null,
'target_branch' => $target['branch'] ?? null,
'state' => $target !== null ? 'ready_to_check' : 'missing_target',
'retry_action' => $target !== null ? 'sync_channel' : 'configure_target',
];
}
return $status;
}
private function releaseDataServicesSummary(): array
{
$summary = [];
foreach ($this->selectRows("SELECT * FROM release_channels WHERE deleted_at IS NULL ORDER BY default_channel DESC, slug") as $channel) {
$summary[(string)$channel['slug']] = $this->channelDataServicesSummary($channel);
}
return $summary;
}
private function channelDataServicesSummary(array $channel): array
{
$serviceChannel = $this->channelUsesProductionServices($channel)
? $this->productionServiceChannel()
: $channel;
$serviceSet = $this->activeServiceSetForChannel((int)($serviceChannel['id'] ?? 0));
$serviceSetMode = $serviceSet !== null ? (string)($serviceSet['mode'] ?? 'attach_existing') : self::PRODUCTION_DATA_POLICY;
$mode = $this->serviceSetDataPolicy($serviceSet);
$policy = $this->replicationPolicyForMode($mode);
$services = [];
foreach (self::STACK_DATA_KINDS as $kind) {
$target = $serviceSet !== null
? $this->nullableCoolifyTarget($this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null))
: null;
$replication = is_array($target['replication'] ?? null) ? $target['replication'] : [];
$lastStatus = is_array($replication['last_status'] ?? null) ? $replication['last_status'] : [];
$services[$kind] = [
'kind' => $kind,
'mode' => $mode,
'service_set_mode' => $serviceSetMode,
'data_policy' => $mode,
'state' => $target !== null ? (string)($target['availability_state'] ?? 'configured') : 'production_shared',
'target' => $target,
'replication_policy' => $policy,
'default_shared_production' => $mode === self::PRODUCTION_DATA_POLICY,
'change_requires_explicit_action' => true,
'entity_facts' => [
'service' => $kind,
'mode' => $mode,
'service_set_mode' => $serviceSetMode,
'data_policy' => $mode,
'node' => $target['instance_label'] ?? null,
'online_state' => $target['deployment_status'] ?? $target['availability_state'] ?? 'production_shared',
'hostname' => $replication['host'] ?? null,
'port' => $replication['port'] ?? null,
'uptime' => $lastStatus['uptime'] ?? $lastStatus['uptime_text'] ?? null,
'version' => $lastStatus['version'] ?? null,
'replication_role' => $replication['role'] ?? null,
'replication_lag' => $lastStatus['lag'] ?? $lastStatus['lag_seconds'] ?? null,
'last_check' => $replication['last_checked_at'] ?? $target['last_reconciled_at'] ?? null,
],
];
}
return [
'channel_id' => (int)($channel['id'] ?? 0),
'channel_slug' => (string)($channel['slug'] ?? ''),
'service_channel_id' => (int)($serviceChannel['id'] ?? 0),
'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
'mode' => $serviceSet === null ? 'production_shared' : $mode,
'service_set_mode' => $serviceSetMode,
'data_policy' => $mode,
'data_service_mode' => $mode,
'policy' => $policy,
'services' => $services,
];
}
private function releaseReplicationPolicySummary(): array
{
return [
'default_mode' => 'production_shared',
'normal_sync_changes_data_services' => false,
'allowed_modes' => [
'production_shared',
'attach_existing',
'clone_existing',
'fresh_empty',
'isolated_stack',
],
'service_kinds' => self::STACK_DATA_KINDS,
'change_control' => 'Data service mode changes are only allowed through explicit replication/failover actions.',
];
}
private function replicationPolicyForMode(string $mode): array
{
return [
'mode' => $mode,
'production_shared' => in_array($mode, ['production_shared', 'attach_existing'], true),
'replication_configurable' => true,
'failover_configurable' => true,
'normal_sync_changes_service' => false,
];
}
private function activeServiceSetForChannel(int $channelId): ?array
{
return $this->selectOne(
"SELECT s.*, c.slug AS channel_slug, c.name AS channel_name
FROM release_channel_versions v
INNER JOIN release_service_sets s ON s.id = v.service_set_id
LEFT JOIN release_channels c ON c.id = s.channel_id
WHERE v.channel_id = ? AND v.active = 1 AND s.deleted_at IS NULL
ORDER BY v.activated_at DESC, v.id DESC
LIMIT 1",
'i',
[$channelId]
);
}
private function releaseCoolifySummary(): array
{
$targetCount = (int)($this->selectOne('SELECT COUNT(*) AS total FROM release_deployment_targets WHERE deleted_at IS NULL')['total'] ?? 0);
$instanceCount = $this->tableExists('coolify_instances')
? (int)($this->selectOne('SELECT COUNT(*) AS total FROM coolify_instances WHERE deleted_at IS NULL')['total'] ?? 0)
: 0;
return [
'integrated' => $this->tableExists('coolify_instances'),
'panel' => 'release_manager',
'instances' => $instanceCount,
'deployment_targets' => $targetCount,
'legacy_route' => '/superuser/configuration/coolify',
'redirect_panel' => '/superuser/configuration/releases/integrations?panel=coolify',
'entity_facts' => [
'instances' => $instanceCount,
'deployment_targets' => $targetCount,
'status' => $this->tableExists('coolify_instances') ? 'integrated' : 'not_configured',
'last_check' => date('c'),
],
];
}
private function releaseFailoverSummary(): array
{
$replicationHosts = $this->tableExists('replication_hosts')
? (int)($this->selectOne('SELECT COUNT(*) AS total FROM replication_hosts')['total'] ?? 0)
: 0;
return [
'integrated' => $this->tableExists('replication_hosts'),
'panel' => 'release_manager',
'replication_hosts' => $replicationHosts,
'default_data_mode' => 'production_shared',
'normal_sync_triggers_failover' => false,
'legacy_route' => '/superuser/configuration/failover',
'redirect_panel' => '/superuser/configuration/releases/data-services?panel=failover',
'entity_facts' => [
'replication_hosts' => $replicationHosts,
'default_data_mode' => 'production_shared',
'readiness' => $this->tableExists('replication_hosts') ? 'ready' : 'not_configured',
'normal_sync_triggers_failover' => false,
'last_check' => date('c'),
],
];
}
private function githubRepositoryAccess(array $input): array
{
$tokenConfigured = $this->hasGithubApiToken();
$repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? ''));
$branch = trim((string)($input['branch'] ?? ''));
$rawCommitSha = trim((string)($input['commit_sha'] ?? $input['commit'] ?? ''));
$commitMode = $this->normalizeCommitMode((string)($input['commit_mode'] ?? ''), $rawCommitSha);
if ($repository === '') {
return [
'ok' => false,
'status' => 'invalid_repository',
'token_configured' => $tokenConfigured,
'message' => 'GitHub repository must use owner/repo format.',
'repository' => trim((string)($input['repository'] ?? '')),
'branch' => $branch,
'commit_mode' => $commitMode,
];
}
if ($commitMode === 'specific' && $rawCommitSha === '') {
return [
'ok' => false,
'status' => 'commit_required',
'token_configured' => $tokenConfigured,
'message' => 'Specific commit deployment requires a commit SHA.',
'repository' => $repository,
'branch' => $branch,
'commit_mode' => $commitMode,
];
}
if (!$tokenConfigured) {
$response = $this->githubTokenMissingResponse($repository, $branch);
$response['commit_mode'] = $commitMode;
return $response;
}
try {
$repo = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository));
$defaultBranch = trim((string)($repo['default_branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
$branch = $branch !== '' ? $branch : $defaultBranch;
$branchRow = $this->githubRequest(
'GET',
'/repos/' . $this->githubRepositoryPath($repository) . '/branches/' . rawurlencode($branch)
);
$latestCommitSha = trim((string)($branchRow['commit']['sha'] ?? ''));
$commitSha = $latestCommitSha;
$commitUrl = (string)($branchRow['commit']['url'] ?? '');
$latestCommit = [];
if ($latestCommitSha !== '') {
$latestCommitRow = $this->githubRequest(
'GET',
'/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($latestCommitSha)
);
$latestCommit = is_array($latestCommitRow) ? $this->publicGithubCommit($latestCommitRow) : [];
$commitUrl = (string)($latestCommit['html_url'] ?? $commitUrl);
}
$commit = $latestCommit;
if ($commitMode === 'specific') {
$commitRow = $this->githubRequest(
'GET',
'/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($rawCommitSha)
);
$commit = is_array($commitRow) ? $this->publicGithubCommit($commitRow) : [];
$commitSha = trim((string)($commit['sha'] ?? (is_array($commitRow) ? ($commitRow['sha'] ?? null) : null) ?? $rawCommitSha));
$commitUrl = (string)($commit['html_url'] ?? (is_array($commitRow) ? ($commitRow['html_url'] ?? null) : null) ?? $commitUrl);
if ($latestCommitSha !== '' && $commitSha !== '' && $commitSha !== $latestCommitSha) {
$comparison = $this->githubRequest(
'GET',
'/repos/' . $this->githubRepositoryPath($repository) . '/compare/' . rawurlencode($commitSha) . '...' . rawurlencode($latestCommitSha)
);
$comparisonStatus = (string)($comparison['status'] ?? '');
if (!in_array($comparisonStatus, ['behind', 'identical'], true)) {
throw new RuntimeException(sprintf('Commit %s is not reachable from branch %s.', $commitSha, $branch));
}
}
}
return [
'ok' => true,
'status' => 'accessible',
'token_configured' => true,
'message' => $commitMode === 'specific'
? 'Repository, branch, and commit are accessible with the configured GitHub token.'
: 'Repository and branch are accessible with the configured GitHub token.',
'repository' => $repository,
'branch' => $branch,
'default_branch' => $defaultBranch,
'private' => (bool)($repo['private'] ?? false),
'html_url' => $repo['html_url'] ?? null,
'commit_mode' => $commitMode,
'commit_sha' => $commitSha !== '' ? $commitSha : null,
'latest_commit_sha' => $latestCommitSha !== '' ? $latestCommitSha : null,
'commit' => $commit !== [] ? $commit : null,
'latest_commit' => $latestCommit !== [] ? $latestCommit : null,
'commit_authored_at' => $commit['authored_at'] ?? null,
'commit_url' => $commitUrl !== '' ? $commitUrl : null,
];
} catch (Throwable $throwable) {
return [
'ok' => false,
'status' => 'inaccessible',
'token_configured' => true,
'message' => $throwable->getMessage(),
'repository' => $repository,
'branch' => $branch,
'commit_mode' => $commitMode,
];
}
}
private function normalizeCommitMode(string $commitMode, string $commitSha): string
{
$mode = strtolower(trim($commitMode));
$commit = strtolower(trim($commitSha));
if ($mode === 'specific') {
return 'specific';
}
if ($mode === 'latest' || $mode === 'head' || $commit === '' || in_array($commit, ['latest', 'head'], true)) {
return 'latest';
}
return 'specific';
}
private function githubTokenMissingResponse(?string $repository = null, ?string $branch = null): array
{
return [
'ok' => false,
'status' => 'not_configured',
'token_configured' => false,
'message' => 'Configure ReleaseManager github_token or RELEASE_MANAGER_GITHUB_TOKEN before using private GitHub repositories.',
'repository' => $repository,
'branch' => $branch,
'repositories' => [],
'branches' => [],
'commits' => [],
];
}
private function hasGithubApiToken(): bool
{
return $this->githubApiToken() !== '';
}
private function githubEnvToken(): string
{
foreach (['RELEASE_MANAGER_GITHUB_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN'] as $key) {
$value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? '')));
if ($value !== '') {
return $value;
}
}
return '';
}
private function githubApiToken(): string
{
$envToken = $this->githubEnvToken();
if ($envToken !== '') {
return $envToken;
}
$token = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_token', ''));
if ($token !== '' && str_starts_with($token, 'twsec:v1:') && class_exists(replication_secret_box::class)) {
try {
$token = replication_secret_box::decrypt($token);
} catch (Throwable) {
$token = '';
}
}
return trim($token);
}
private function githubApiBaseUrl(): string
{
$value = trim((string)(getenv('RELEASE_MANAGER_GITHUB_API_URL') ?: ($_SERVER['RELEASE_MANAGER_GITHUB_API_URL'] ?? '')));
if ($value === '') {
$value = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_api_url', 'https://api.github.com'));
}
$value = rtrim($value, '/');
return preg_match('#^https?://#i', $value) === 1 ? $value : 'https://api.github.com';
}
private function githubRepositoryPath(string $repository): string
{
[$owner, $name] = explode('/', $repository, 2);
return rawurlencode($owner) . '/' . rawurlencode($name);
}
private function githubRequest(string $method, string $path, array $query = []): array
{
$token = $this->githubApiToken();
if ($token === '') {
throw new RuntimeException('GitHub token is not configured.');
}
$url = $this->githubApiBaseUrl() . '/' . ltrim($path, '/');
if ($query !== []) {
$url .= '?' . http_build_query($query);
}
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Could not initialize GitHub API request.');
}
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3);
curl_setopt($curl, CURLOPT_TIMEOUT, 10);
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Accept: application/vnd.github+json',
'Authorization: Bearer ' . $token,
'User-Agent: Truckwash-Release-Manager',
'X-GitHub-Api-Version: 2022-11-28',
]);
$raw = curl_exec($curl);
$error = curl_error($curl);
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($raw === false) {
throw new RuntimeException('GitHub API request failed: ' . $error);
}
$decoded = [];
if (trim((string)$raw) !== '') {
$decodedJson = json_decode((string)$raw, true);
$decoded = is_array($decodedJson) ? $decodedJson : ['raw' => (string)$raw];
}
if ($status < 200 || $status >= 300) {
$message = is_array($decoded)
? (string)($decoded['message'] ?? $decoded['error'] ?? ('HTTP ' . $status))
: ('HTTP ' . $status);
throw new RuntimeException('GitHub API request failed: ' . $message);
}
return $decoded;
}
private function publicGithubRepository(array $row): array
{
$fullName = self::normalizeGithubRepositoryName((string)($row['full_name'] ?? ''));
return [
'id' => isset($row['id']) ? (int)$row['id'] : null,
'name' => (string)($row['name'] ?? ''),
'full_name' => $fullName,
'private' => (bool)($row['private'] ?? false),
'default_branch' => (string)($row['default_branch'] ?? self::DEFAULT_BRANCH),
'description' => $row['description'] ?? null,
'html_url' => $row['html_url'] ?? null,
'clone_url' => $row['clone_url'] ?? null,
'ssh_url' => $row['ssh_url'] ?? null,
'pushed_at' => $row['pushed_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
}
private function publicGithubBranch(array $row): array
{
return [
'name' => (string)($row['name'] ?? ''),
'commit_sha' => $row['commit']['sha'] ?? null,
'protected' => (bool)($row['protected'] ?? false),
];
}
private function publicGithubCommit(array $row): array
{
$sha = (string)($row['sha'] ?? '');
$message = (string)($row['commit']['message'] ?? '');
$title = trim(strtok($message, "\n") ?: $message);
return [
'sha' => $sha,
'short_sha' => substr($sha, 0, 12),
'message' => $title,
'author_name' => $row['commit']['author']['name'] ?? $row['author']['login'] ?? null,
'authored_at' => $row['commit']['author']['date'] ?? null,
'html_url' => $row['html_url'] ?? null,
];
}
private function releaseSuggestions(): array
{
$channels = $this->listChannels();
$targets = $this->listDeploymentTargets();
$deployments = $this->listDeployments(50);
$versions = release_manager_schema_bootstrap::tablesExist()
? $this->selectRows(
"SELECT app, repository, branch, deployed_url, build_url
FROM release_versions
WHERE repository IS NOT NULL OR branch IS NOT NULL OR deployed_url IS NOT NULL
ORDER BY created_at DESC
LIMIT 100"
)
: [];
$repositories = [];
$branches = [self::DEFAULT_BRANCH, 'main', 'develop', 'staging'];
$frontendUrls = [];
$apiUrls = [];
$healthUrls = [];
$loadBalancerDomains = [];
$serviceUuids = [];
foreach ([
$this->moduleConfigValue('Coolify', 'public_gateway_host', 'api-v2.truckwash.io'),
getenv('COOLIFY_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['COOLIFY_PUBLIC_GATEWAY_HOST'] ?? null),
getenv('PUBLIC_GATEWAY_HOST') ?: ($_SERVER['PUBLIC_GATEWAY_HOST'] ?? null),
getenv('RELEASE_LOAD_BALANCER_DOMAIN') ?: ($_SERVER['RELEASE_LOAD_BALANCER_DOMAIN'] ?? null),
getenv('RELEASE_LOAD_BALANCER_DOMAINS') ?: ($_SERVER['RELEASE_LOAD_BALANCER_DOMAINS'] ?? null),
] as $domain) {
$this->appendDomainSuggestion($loadBalancerDomains, $domain);
}
foreach (array_merge($targets, $deployments, $versions) as $row) {
$this->appendSuggestion($repositories, $row['repository'] ?? null);
$this->appendSuggestion($branches, $row['branch'] ?? null);
$this->appendSuggestion($healthUrls, $row['health_url'] ?? null);
$this->appendSuggestion($healthUrls, $row['deployment_url'] ?? null);
$this->appendSuggestion($healthUrls, $row['deployed_url'] ?? null);
$this->appendSuggestion($serviceUuids, $row['coolify_service_uuid'] ?? null);
}
foreach ($channels as $channel) {
$slug = (string)($channel['slug'] ?? '');
$this->appendSuggestion($branches, $slug !== '' ? 'release/' . $slug : null);
$this->appendSuggestion($frontendUrls, $channel['frontend_base_url'] ?? null);
$this->appendSuggestion($apiUrls, $channel['api_base_url'] ?? null);
if (!empty($channel['frontend_base_url'])) {
$this->appendSuggestion($healthUrls, rtrim((string)$channel['frontend_base_url'], '/') . '/health');
}
if (!empty($channel['api_base_url'])) {
$this->appendSuggestion($healthUrls, rtrim((string)$channel['api_base_url'], '/') . '/ping');
}
}
foreach ([
'GITHUB_REPOSITORY',
'RELEASE_FRONTEND_REPOSITORY',
'RELEASE_API_REPOSITORY',
'FRONTEND_GITHUB_REPOSITORY',
'API_GITHUB_REPOSITORY',
] as $key) {
$this->appendSuggestion($repositories, getenv($key) ?: ($_SERVER[$key] ?? null));
}
foreach (['GITHUB_REF_NAME', 'RELEASE_BRANCH', 'FRONTEND_BRANCH', 'API_BRANCH'] as $key) {
$this->appendSuggestion($branches, getenv($key) ?: ($_SERVER[$key] ?? null));
}
foreach (['FRONTEND_URL', 'APP_URL', 'VITE_APP_URL'] as $key) {
$this->appendSuggestion($frontendUrls, getenv($key) ?: ($_SERVER[$key] ?? null));
}
foreach (['API_URL', 'BACKEND_URL', 'PUBLIC_API_URL'] as $key) {
$this->appendSuggestion($apiUrls, getenv($key) ?: ($_SERVER[$key] ?? null));
}
$origin = trim((string)($_SERVER['HTTP_ORIGIN'] ?? ''));
if ($origin !== '') {
$this->appendSuggestion($frontendUrls, $origin);
}
$host = trim((string)($_SERVER['HTTP_HOST'] ?? ''));
if ($host !== '') {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$this->appendSuggestion($apiUrls, $scheme . '://' . $host);
}
$coolifyInstances = [];
$coolifyProjects = [];
$coolifyServices = [];
$coolifyGithubApps = [];
if ($this->tableExists('coolify_instances')) {
$instanceRows = $this->selectRows(
'SELECT id, label, base_url, api_token_secret, status, default_project_uuid, default_environment_uuid, default_environment_name, default_server_uuid
FROM coolify_instances
WHERE deleted_at IS NULL
ORDER BY status = \'ok\' DESC, label'
);
$coolifyInstances = array_map(static function (array $row): array {
return [
'id' => (int)($row['id'] ?? 0),
'label' => (string)($row['label'] ?? ''),
'base_url' => (string)($row['base_url'] ?? ''),
'status' => (string)($row['status'] ?? 'unknown'),
'default_project_uuid' => $row['default_project_uuid'] ?? null,
'default_environment_uuid' => $row['default_environment_uuid'] ?? null,
'default_environment_name' => $row['default_environment_name'] ?? null,
'default_server_uuid' => $row['default_server_uuid'] ?? null,
];
}, $instanceRows);
foreach ($instanceRows as $instanceRow) {
foreach ($this->coolifyProjectSuggestions($instanceRow) as $project) {
$coolifyProjects[] = $project;
}
foreach ($this->coolifyGithubAppSuggestions($instanceRow) as $githubApp) {
$coolifyGithubApps[] = $githubApp;
}
foreach ($this->coolifyServiceSuggestions($instanceRow) as $service) {
$coolifyServices[] = $service;
$this->appendSuggestion($serviceUuids, $service['uuid'] ?? null);
foreach (($service['urls'] ?? []) as $url) {
$this->appendSuggestion($healthUrls, $url);
}
}
}
}
$channelPresets = [
[
'slug' => 'stable',
'name' => 'Stable',
'description' => 'Default production release channel.',
'rollout_percent' => 100,
'default_channel' => true,
'replay_enabled' => false,
'capture_level' => 'metadata',
'retention_days' => 14,
],
[
'slug' => 'canary',
'name' => 'Canary',
'description' => 'Small early-access channel for validating a release before broad rollout.',
'rollout_percent' => 5,
'default_channel' => false,
'replay_enabled' => true,
'capture_level' => 'full_redacted',
'retention_days' => 7,
],
[
'slug' => 'beta',
'name' => 'Beta',
'description' => 'Customer or staff opt-in channel for release candidate validation.',
'rollout_percent' => 0,
'default_channel' => false,
'replay_enabled' => true,
'capture_level' => 'metadata',
'retention_days' => 14,
],
[
'slug' => 'internal',
'name' => 'Internal',
'description' => 'Staff-only channel for internal verification and support replay.',
'rollout_percent' => 0,
'default_channel' => false,
'replay_enabled' => true,
'capture_level' => 'full_redacted',
'retention_days' => 14,
],
];
$frontendRepository = $this->firstSuggestion($repositories, ['front-end', 'frontend', 'vue']) ?? ($repositories[0] ?? '');
$apiRepository = $this->firstSuggestion($repositories, ['backend', 'api', 'php']) ?? ($repositories[1] ?? $repositories[0] ?? '');
return [
'github_token_configured' => $this->hasGithubApiToken(),
'github_api_url' => $this->githubApiBaseUrl(),
'repositories' => array_values($repositories),
'branches' => array_values($branches),
'frontend_base_urls' => array_values($frontendUrls),
'api_base_urls' => array_values($apiUrls),
'health_urls' => array_values($healthUrls),
'load_balancer_domains' => array_values($loadBalancerDomains),
'coolify_instances' => $coolifyInstances,
'coolify_projects' => $coolifyProjects,
'coolify_github_apps' => $coolifyGithubApps,
'coolify_services' => $coolifyServices,
'coolify_service_uuids' => array_values($serviceUuids),
'channel_presets' => $channelPresets,
'target_presets' => [
[
'label' => 'Frontend target',
'app' => 'frontend',
'repository' => $frontendRepository,
'branch' => $branches[0] ?? self::DEFAULT_BRANCH,
'health_url' => $healthUrls[0] ?? '',
'auto_deploy' => true,
],
[
'label' => 'API target',
'app' => 'api',
'repository' => $apiRepository,
'branch' => $branches[0] ?? self::DEFAULT_BRANCH,
'health_url' => $this->firstSuggestion($healthUrls, ['/ping'])
?? $this->firstSuggestion($healthUrls, ['/health'])
?? '',
'auto_deploy' => true,
],
],
'setup_steps' => [
['key' => 'channels', 'done' => count($channels) > 0],
['key' => 'targets', 'done' => count($targets) > 0],
['key' => 'deployments', 'done' => count($deployments) > 0],
['key' => 'timeline', 'done' => (int)($this->timelineSummary()['events'] ?? 0) > 0],
],
];
}
private function appendSuggestion(array &$values, mixed $value): void
{
$value = trim((string)($value ?? ''));
if ($value === '') {
return;
}
foreach (preg_split('/\s*,\s*/', $value) ?: [] as $part) {
$part = trim($part);
if ($part !== '' && !in_array($part, $values, true)) {
$values[] = $part;
}
}
}
private function appendDomainSuggestion(array &$values, mixed $value): void
{
$value = trim((string)($value ?? ''));
if ($value === '') {
return;
}
foreach (preg_split('/\s*,\s*/', $value) ?: [] as $part) {
$domain = self::domainSuggestionHost($part);
if ($domain !== null && !in_array($domain, $values, true)) {
$values[] = $domain;
}
}
}
private static function domainSuggestionHost(mixed $value): ?string
{
$raw = trim((string)($value ?? ''));
if ($raw === '') {
return null;
}
$candidate = preg_match('#^https?://#i', $raw) === 1 ? $raw : 'https://' . $raw;
$host = parse_url($candidate, PHP_URL_HOST);
$port = parse_url($candidate, PHP_URL_PORT);
$host = strtolower(trim((string)$host, "[] \t\n\r\0\x0B."));
if (
$host === ''
|| $port !== null
|| $host === 'localhost'
|| str_ends_with($host, '.localhost')
|| str_contains($host, '/')
|| filter_var($host, FILTER_VALIDATE_IP) !== false
) {
return null;
}
return $host;
}
private function firstSuggestion(array $values, array $needles): ?string
{
foreach ($values as $value) {
foreach ($needles as $needle) {
if (stripos((string)$value, $needle) !== false) {
return (string)$value;
}
}
}
return null;
}
private function coolifyProjectSuggestions(array $instance): array
{
$tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
if ($tokenSecret === '') {
return [];
}
try {
$token = replication_secret_box::decrypt($tokenSecret);
$projects = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listProjects();
} catch (Throwable) {
return [];
}
$suggestions = [];
foreach ($this->payloadRows($projects) as $row) {
if (!is_array($row)) {
continue;
}
$uuid = trim((string)($row['uuid'] ?? ''));
if ($uuid === '') {
continue;
}
$suggestions[] = [
'instance_id' => (int)($instance['id'] ?? 0),
'instance_label' => (string)($instance['label'] ?? ''),
'uuid' => $uuid,
'name' => (string)($row['name'] ?? $uuid),
'description' => (string)($row['description'] ?? ''),
'default' => $uuid === trim((string)($instance['default_project_uuid'] ?? '')),
];
}
return array_slice($suggestions, 0, 50);
}
private function coolifyGithubAppSuggestions(array $instance): array
{
$tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
if ($tokenSecret === '') {
return [];
}
try {
$token = replication_secret_box::decrypt($tokenSecret);
$apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps();
} catch (Throwable) {
return [];
}
$suggestions = [];
foreach ($this->payloadRows($apps) as $row) {
if (!is_array($row)) {
continue;
}
$uuid = trim((string)($row['uuid'] ?? ''));
if ($uuid === '') {
continue;
}
$suggestions[] = [
'instance_id' => (int)($instance['id'] ?? 0),
'instance_label' => (string)($instance['label'] ?? ''),
'uuid' => $uuid,
'name' => (string)($row['name'] ?? $uuid),
'organization' => (string)($row['organization'] ?? ''),
'type' => (string)($row['type'] ?? ''),
'is_system_wide' => (bool)($row['is_system_wide'] ?? false),
'html_url' => (string)($row['html_url'] ?? ''),
];
}
return array_slice($suggestions, 0, 50);
}
private function coolifyServiceSuggestions(array $instance): array
{
$tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
if ($tokenSecret === '') {
return [];
}
try {
$token = replication_secret_box::decrypt($tokenSecret);
$services = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listServices();
} catch (Throwable) {
return [];
}
$rows = $this->payloadRows($services);
$suggestions = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
if ($uuid === '') {
continue;
}
$urls = [];
foreach (['fqdn', 'domain', 'url'] as $key) {
$this->appendSuggestion($urls, $row[$key] ?? null);
}
foreach (['urls', 'domains'] as $key) {
if (!is_array($row[$key] ?? null)) {
continue;
}
foreach ($row[$key] as $url) {
if (is_array($url)) {
$this->appendSuggestion($urls, $url['url'] ?? $url['domain'] ?? $url['fqdn'] ?? null);
} else {
$this->appendSuggestion($urls, $url);
}
}
}
$suggestions[] = [
'instance_id' => (int)($instance['id'] ?? 0),
'instance_label' => (string)($instance['label'] ?? ''),
'uuid' => $uuid,
'name' => (string)($row['name'] ?? $row['service_name'] ?? $uuid),
'status' => (string)($row['status'] ?? $row['deployment_status'] ?? 'unknown'),
'urls' => array_values($urls),
];
}
return array_slice($suggestions, 0, 50);
}
private function payloadRows(array $payload): array
{
if ($payload === []) {
return [];
}
if (array_keys($payload) === range(0, count($payload) - 1)) {
return $payload;
}
foreach (['data', 'services', 'applications', 'projects', 'servers', 'github_apps', 'envs', 'environment_variables', 'results'] as $key) {
if (is_array($payload[$key] ?? null)) {
return $this->payloadRows($payload[$key]);
}
}
return [];
}
private function normalizeChannelInput(array $input, bool $creating): array
{
$slug = self::safeSlug((string)($input['slug'] ?? ''));
if ($slug === '') {
throw new RuntimeException('Release channel slug is required.');
}
$name = trim((string)($input['name'] ?? ($creating ? '' : $slug)));
if ($name === '') {
throw new RuntimeException('Release channel name is required.');
}
$retention = (int)($input['retention_days'] ?? 14);
return [
'slug' => $slug,
'name' => substr($name, 0, 128),
'description' => trim((string)($input['description'] ?? '')) ?: null,
'enabled' => $this->toBool($input['enabled'] ?? true) ? 1 : 0,
'default_channel' => $this->toBool($input['default_channel'] ?? false) ? 1 : 0,
'rollout_percent' => max(0, min(100, (float)($input['rollout_percent'] ?? 0))),
'frontend_base_url' => trim((string)($input['frontend_base_url'] ?? '')) ?: null,
'api_base_url' => trim((string)($input['api_base_url'] ?? '')) ?: null,
'replay_enabled' => $this->toBool($input['replay_enabled'] ?? false) ? 1 : 0,
'capture_level' => $this->normalizeCaptureLevel((string)($input['capture_level'] ?? 'metadata')),
'retention_days' => max(1, min(365, $retention > 0 ? $retention : 14)),
'metadata' => is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null),
];
}
private function channelFromInput(array $input): array
{
$id = $this->nullablePositiveInt($input['channel_id'] ?? null);
if ($id !== null) {
return $this->getChannel($id);
}
$slug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? ''));
if ($slug !== '') {
$channel = $this->findChannelBySlug(self::channelSlugForRoute($slug));
if ($channel !== null) {
return $channel;
}
}
throw new RuntimeException('Release channel is required.');
}
private function deploymentTargetFromInput(array $input, int $channelId, string $app): ?array
{
$targetId = $this->nullablePositiveInt($input['target_id'] ?? null);
if ($targetId !== null) {
return $this->getDeploymentTarget($targetId);
}
return $this->selectOne(
"SELECT * FROM release_deployment_targets
WHERE deleted_at IS NULL AND channel_id = ? AND app = ?
ORDER BY auto_deploy DESC, id DESC
LIMIT 1",
'is',
[$channelId, $app]
);
}
private function normalizeServiceSetMode(string $value): string
{
$mode = strtolower(trim($value));
if (!in_array($mode, self::SERVICE_SET_MODES, true)) {
throw new RuntimeException('Release service set mode must be attach_existing, clone_existing, fresh_empty, or isolated_stack.');
}
return $mode;
}
private function channelFromInputOrDefault(array $input, ?array $source = null): array
{
foreach (['channel_id', 'channel_slug', 'channel'] as $key) {
if (array_key_exists($key, $input) && trim((string)$input[$key]) !== '') {
return $this->channelFromInput($input);
}
}
if ($source !== null && !empty($source['channel_id'])) {
return $this->getChannel((int)$source['channel_id']);
}
return $this->defaultChannel();
}
private function serviceSetTargetIdFromInput(array $input, string $app, ?array $source): ?int
{
$aliases = $app === 'api'
? ['api_target_id', 'php_target_id', 'backend_target_id']
: ['frontend_target_id'];
$targets = is_array($input['targets'] ?? null) ? $input['targets'] : [];
if (is_array($targets[$app] ?? null)) {
foreach (['target_id', 'id'] as $key) {
$aliases[] = $app . '.' . $key;
}
}
foreach ($aliases as $key) {
$value = str_contains($key, '.')
? ($targets[$app][substr($key, strpos($key, '.') + 1)] ?? null)
: ($input[$key] ?? null);
$id = $this->nullablePositiveInt($value);
if ($id === null) {
continue;
}
$target = $this->getDeploymentTarget($id);
if ((string)($target['app'] ?? '') !== $app) {
throw new RuntimeException(sprintf('Selected %s target does not match the requested app.', $app));
}
return $id;
}
$sourceKey = $app . '_target_id';
return $source !== null ? $this->nullablePositiveInt($source[$sourceKey] ?? null) : null;
}
private function serviceSetDataTargetIdFromInput(array $input, string $kind, ?array $source): ?int
{
$dataTargets = is_array($input['data_targets'] ?? null) ? $input['data_targets'] : [];
$value = $input[$kind . '_coolify_target_id']
?? $input[$kind . '_target_id']
?? $dataTargets[$kind . '_coolify_target_id']
?? $dataTargets[$kind . '_target_id']
?? $dataTargets[$kind]
?? null;
$id = $this->nullablePositiveInt($value);
if ($id !== null) {
$target = $this->nullableCoolifyTarget($id);
if ($target !== null && (string)($target['kind'] ?? '') !== $kind) {
throw new RuntimeException(sprintf('Selected %s data service target has the wrong replica kind.', $kind));
}
return $id;
}
$sourceKey = $kind . '_coolify_target_id';
return $source !== null ? $this->nullablePositiveInt($source[$sourceKey] ?? null) : null;
}
private function serviceSetInputHasExplicitDataTargets(array $input): bool
{
$dataTargets = is_array($input['data_targets'] ?? null) ? $input['data_targets'] : [];
foreach (self::STACK_DATA_KINDS as $kind) {
foreach ([
$input[$kind . '_coolify_target_id'] ?? null,
$input[$kind . '_target_id'] ?? null,
$dataTargets[$kind . '_coolify_target_id'] ?? null,
$dataTargets[$kind . '_target_id'] ?? null,
$dataTargets[$kind] ?? null,
] as $value) {
if ($this->nullablePositiveInt($value) !== null) {
return true;
}
}
}
return false;
}
private function isBetaChannel(array $channel): bool
{
return self::safeSlug((string)($channel['slug'] ?? '')) === 'beta';
}
private function channelUsesProductionServices(array $channel): bool
{
return in_array(
self::safeSlug((string)($channel['slug'] ?? '')),
self::PRODUCTION_SERVICE_CHANNELS,
true
);
}
private function serviceSetDataPolicy(?array $serviceSet): string
{
if ($serviceSet === null) {
return self::PRODUCTION_DATA_POLICY;
}
$mode = strtolower(trim((string)($serviceSet['mode'] ?? '')));
if (in_array($mode, ['clone_existing', 'fresh_empty', 'isolated_stack'], true)) {
return $mode;
}
if (in_array($mode, ['', 'attach_existing', self::PRODUCTION_DATA_POLICY], true)) {
return self::PRODUCTION_DATA_POLICY;
}
$metadata = is_array($serviceSet['metadata'] ?? null)
? $serviceSet['metadata']
: self::jsonDecode($serviceSet['metadata_json'] ?? null);
$policy = strtolower(trim((string)($serviceSet['data_policy'] ?? $serviceSet['data_service_mode'] ?? $metadata['data_policy'] ?? $metadata['data_service_mode'] ?? '')));
if ($policy === self::PRODUCTION_DATA_POLICY) {
return self::PRODUCTION_DATA_POLICY;
}
return $policy !== '' ? $policy : self::PRODUCTION_DATA_POLICY;
}
private function assertBetaProductionDataPolicy(array $channel, array $serviceSet): void
{
if (!$this->isBetaChannel($channel)) {
return;
}
if ($this->serviceSetDataPolicy($serviceSet) !== self::PRODUCTION_DATA_POLICY) {
throw new RuntimeException('Beta release bundles must use production-shared data services.');
}
}
private function assertBetaDataSourceChannel(?array $source): void
{
if ($source === null) {
return;
}
$slug = self::safeSlug((string)($source['channel_slug'] ?? ''));
if (!in_array($slug, self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS, true)) {
throw new RuntimeException('Beta data-only service sets can copy data targets only from Stable/Master production service sets.');
}
}
private function assertServiceSetTargetBelongsToChannel(?int $targetId, string $app, array $channel): void
{
if ($targetId === null) {
return;
}
$target = $this->getDeploymentTarget($targetId);
if ((string)($target['app'] ?? '') !== $app) {
throw new RuntimeException(sprintf('Selected %s target does not match the requested app.', $app));
}
if ((int)($target['channel_id'] ?? 0) !== (int)($channel['id'] ?? 0)) {
throw new RuntimeException(sprintf('Beta production-data service sets require %s targets from the beta channel.', $app));
}
}
private function assertIsolatedStackTarget(?int $targetId, string $app, bool $allowCreatedService = false): void
{
if ($targetId === null) {
throw new RuntimeException(sprintf('Isolated stack deployments require a new %s Coolify target.', $app));
}
$target = $this->getDeploymentTarget($targetId);
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
if ((string)($target['app'] ?? '') !== $app) {
throw new RuntimeException(sprintf('Isolated stack %s target does not match the requested app.', $app));
}
if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) === null) {
throw new RuntimeException(sprintf('Isolated stack %s target must select a Coolify instance.', $app));
}
if (trim((string)($target['coolify_service_uuid'] ?? '')) !== '' && !$allowCreatedService) {
throw new RuntimeException(sprintf('Isolated stack %s target must not point at an existing Coolify service.', $app));
}
if (!$this->toBool($context['coolify_auto_create'] ?? false)) {
throw new RuntimeException(sprintf('Isolated stack %s target must create a new Coolify service.', $app));
}
if (!$this->toBool($context['isolated_stack'] ?? false)) {
throw new RuntimeException(sprintf('Isolated stack %s target must be marked as isolated.', $app));
}
}
private function assertIsolatedStackDataTarget(?int $targetId, string $kind): void
{
if ($targetId === null) {
throw new RuntimeException(sprintf('Isolated stack deployments require a new %s data target.', $kind));
}
$target = $this->nullableCoolifyTarget($targetId);
if ($target === null) {
throw new RuntimeException(sprintf('Selected %s isolated data target was not found.', $kind));
}
if ((string)($target['kind'] ?? '') !== $kind) {
throw new RuntimeException(sprintf('Selected %s isolated data target has the wrong kind.', $kind));
}
$options = is_array($target['options'] ?? null) ? $target['options'] : [];
if (!$this->toBool($options['isolated_stack'] ?? false)) {
throw new RuntimeException(sprintf('Selected %s data target is not marked as an isolated stack target.', $kind));
}
if ($this->toBool($options['production_data_attached'] ?? true)) {
throw new RuntimeException(sprintf('Selected %s data target must not attach production data.', $kind));
}
if (!$this->toBool($options['skip_replication_provisioning'] ?? false)) {
throw new RuntimeException(sprintf('Selected %s data target must skip production replication provisioning.', $kind));
}
}
private function createIsolatedStackDataTarget(
string $kind,
array $input,
array $channel,
string $serviceSetName,
?int $frontendTargetId,
?int $apiTargetId,
?int $actorUserId
): int {
if (!class_exists(coolify_manager::class) && function_exists('app_require')) {
app_require('classes/coolify_manager.php');
}
if (!class_exists(coolify_manager::class)) {
throw new RuntimeException('Coolify integration is required to create isolated stack data services.');
}
$placementTarget = $this->isolatedStackPlacementTarget($apiTargetId, $frontendTargetId);
$context = self::jsonDecode($placementTarget['deploy_context_json'] ?? null);
$dataServicesInput = is_array($input['data_services'] ?? null) ? $input['data_services'] : [];
$dataInput = is_array($dataServicesInput[$kind] ?? null) ? $dataServicesInput[$kind] : [];
$placementContext = array_replace($context, $dataInput);
$instanceId = $this->nullablePositiveInt($placementTarget['coolify_instance_id'] ?? null);
if ($instanceId === null) {
throw new RuntimeException('Isolated stack data services require a Coolify instance.');
}
$instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]);
if ($instance === null) {
throw new RuntimeException('Coolify instance for isolated stack data services was not found.');
}
$serverUuid = $this->releaseCoolifyServerUuid($placementContext, $instance);
if ($serverUuid === '') {
throw new RuntimeException('Release Manager could not resolve a Coolify server UUID for isolated data services.');
}
$placementTarget['channel_slug'] = $placementTarget['channel_slug'] ?? $channel['slug'] ?? '';
$environment = $this->releaseCoolifyEnvironment($placementTarget, $placementContext, $instance);
$stackSlug = self::safeSlug($serviceSetName !== '' ? $serviceSetName : ((string)($channel['slug'] ?? 'release') . '-isolated-stack'));
$serviceName = substr('release-' . ($stackSlug !== '' ? $stackSlug : 'isolated-stack') . '-' . $kind, 0, 64);
$payload = array_replace($dataInput, [
'kind' => $kind,
'role' => 'replica',
'instance_id' => $instanceId,
'server_uuid' => $serverUuid,
'project_uuid' => trim((string)($placementContext['coolify_project_uuid'] ?? $placementContext['project_uuid'] ?? $instance['default_project_uuid'] ?? '')),
'environment_uuid' => $environment['uuid'] ?? '',
'environment_name' => $environment['name'],
'destination_uuid' => trim((string)($placementContext['coolify_destination_uuid'] ?? $placementContext['destination_uuid'] ?? $instance['default_destination_uuid'] ?? '')),
'label' => $serviceName,
'service_name' => $serviceName,
'resource_name' => $serviceName,
'isolated_stack' => true,
'skip_replication_provisioning' => true,
'deploy' => $this->toBool($input['deploy_data_targets'] ?? $input['deploy_isolated_data_targets'] ?? true),
'options' => [
'isolated_stack' => true,
'skip_replication_provisioning' => true,
'production_data_attached' => false,
'release_service_set_name' => $serviceSetName,
'release_channel_slug' => (string)($channel['slug'] ?? ''),
],
]);
$created = (new coolify_manager())->createTarget($payload, $actorUserId);
$targetId = $this->nullablePositiveInt($created['target']['id'] ?? null);
if ($targetId === null) {
throw new RuntimeException(sprintf('Coolify did not return a %s isolated data target id.', $kind));
}
$this->assertIsolatedStackDataTarget($targetId, $kind);
return $targetId;
}
private function isolatedStackPlacementTarget(?int $apiTargetId, ?int $frontendTargetId): array
{
foreach ([$apiTargetId, $frontendTargetId] as $targetId) {
if ($targetId === null) {
continue;
}
$target = $this->getDeploymentTarget($targetId);
if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) !== null) {
return $target;
}
}
throw new RuntimeException('Isolated stack data services require a frontend or API Coolify target.');
}
private function uniqueServiceSetSlug(string $slug): string
{
$base = $slug !== '' ? $slug : 'service-set-' . date('Ymd-His');
$candidate = substr($base, 0, 64);
$suffix = 2;
while ($this->selectOne('SELECT id FROM release_service_sets WHERE slug = ? LIMIT 1', 's', [$candidate]) !== null) {
$tail = '-' . $suffix;
$candidate = substr($base, 0, 64 - strlen($tail)) . $tail;
$suffix++;
}
return $candidate;
}
private function serviceSetStatus(string $mode, ?int $frontendTargetId, ?int $apiTargetId, array $dataTargets): string
{
$hasCode = $frontendTargetId !== null && $apiTargetId !== null;
$hasData = !in_array(null, $dataTargets, true);
if ($mode === 'isolated_stack') {
return $hasCode && $hasData ? 'isolated_stack' : 'needs_isolated_targets';
}
if ($mode === 'attach_existing') {
return $hasCode ? 'ready' : 'needs_configuration';
}
if ($hasCode && $hasData) {
return $mode === 'clone_existing' ? 'provisioning' : 'ready';
}
if ($mode === 'fresh_empty') {
return 'isolated_empty';
}
return $mode === 'clone_existing' ? 'needs_clone_targets' : 'needs_configuration';
}
private function replicaProvisioningPlan(string $mode, ?array $source, array $dataTargets): array
{
$plan = [];
foreach (self::STACK_DATA_KINDS as $kind) {
$sourceTargetId = $source !== null ? $this->nullablePositiveInt($source[$kind . '_coolify_target_id'] ?? null) : null;
$sourceTarget = $this->nullableCoolifyTarget($sourceTargetId);
$target = $this->nullableCoolifyTarget($dataTargets[$kind] ?? null);
$plan[$kind] = [
'action' => match ($mode) {
'clone_existing' => 'clone_replica_from_source',
'isolated_stack' => 'create_isolated_empty_stack_service',
'fresh_empty' => 'register_isolated_empty_service',
default => 'attach_existing_service',
},
'source_coolify_target_id' => $sourceTargetId,
'source_replication_host_id' => $sourceTarget['replication']['id'] ?? null,
'target_coolify_target_id' => $dataTargets[$kind] ?? null,
'target_replication_host_id' => $target['replication']['id'] ?? null,
'production_replication_attached' => $mode === 'attach_existing',
];
}
return $plan;
}
private function bundleAppInput(array $input, string $app, ?array $target, string $versionLabel): array
{
$appPayload = is_array($input[$app] ?? null) ? $input[$app] : [];
if ($app === 'api') {
$appPayload = array_replace(
is_array($input['php'] ?? null) ? $input['php'] : [],
is_array($input['backend'] ?? null) ? $input['backend'] : [],
$appPayload
);
}
$repository = trim((string)($appPayload['repository'] ?? $input[$app . '_repository'] ?? $target['repository'] ?? ''));
$normalizedRepository = self::normalizeGithubRepositoryName($repository);
if ($normalizedRepository !== '') {
$repository = $normalizedRepository;
}
if ($repository === '') {
throw new RuntimeException(sprintf('%s repository is required for bundle releases.', $app === 'api' ? 'PHP backend' : 'Frontend'));
}
$branch = trim((string)($appPayload['branch'] ?? $input[$app . '_branch'] ?? $target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
$rawCommitSha = trim((string)($appPayload['commit_sha'] ?? $appPayload['commit'] ?? $input[$app . '_commit_sha'] ?? ''));
$commitMode = $this->normalizeCommitMode((string)($appPayload['commit_mode'] ?? $input[$app . '_commit_mode'] ?? ''), $rawCommitSha);
$commitSha = $commitMode === 'specific' && $rawCommitSha !== '' ? $rawCommitSha : null;
$githubAccess = $this->githubRepositoryAccess([
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'commit_mode' => $commitMode,
]);
if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) {
throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.'));
}
if (($githubAccess['ok'] ?? false) && !empty($githubAccess['commit_sha'])) {
$commitSha = (string)$githubAccess['commit_sha'];
$branch = (string)($githubAccess['branch'] ?? $branch);
}
return [
'app' => $app,
'repository' => $repository,
'branch' => $branch,
'commit_mode' => $commitMode,
'commit_sha' => $commitSha,
'version_label' => sprintf('%s-%s', $versionLabel, $app === 'api' ? 'php' : 'frontend'),
'deployed_url' => is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null,
'github_access' => $githubAccess,
];
}
private function createBundleVersion(array $input, string $app): int
{
return $this->createVersion([
'app' => $app,
'repository' => $input['repository'],
'branch' => $input['branch'],
'commit_sha' => $input['commit_sha'],
'version_label' => $input['version_label'],
'deployed_url' => $input['deployed_url'] ?? null,
'status' => 'draft',
'metadata' => [
'commit_mode' => $input['commit_mode'],
'github_access' => self::releaseVersionGithubAccessMetadata($input['github_access'] ?? null),
'bundle_member' => true,
],
]);
}
private function getChannel(int $id): array
{
$row = $this->selectOne('SELECT * FROM release_channels WHERE id = ? AND deleted_at IS NULL', 'i', [$id]);
if ($row === null) {
throw new RuntimeException('Release channel not found.');
}
return $row;
}
private function findChannelBySlug(string $slug): ?array
{
return $this->selectOne(
'SELECT * FROM release_channels WHERE slug = ? AND deleted_at IS NULL LIMIT 1',
's',
[$slug]
);
}
private function getVersion(int $id): array
{
return $this->selectOne('SELECT * FROM release_versions WHERE id = ?', 'i', [$id]) ?? [];
}
private function getDeployment(int $id): array
{
$row = $this->selectOne(
"SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
FROM release_deployments d
INNER JOIN release_channels c ON c.id = d.channel_id
LEFT JOIN release_versions v ON v.id = d.version_id
WHERE d.id = ?",
'i',
[$id]
);
if ($row === null) {
throw new RuntimeException('Release deployment not found.');
}
return $row;
}
private function getServiceSet(int $id): array
{
$row = $this->selectOne(
"SELECT s.*, c.slug AS channel_slug, c.name AS channel_name
FROM release_service_sets s
LEFT JOIN release_channels c ON c.id = s.channel_id
WHERE s.id = ? AND s.deleted_at IS NULL",
'i',
[$id]
);
if ($row === null) {
throw new RuntimeException('Release service set not found.');
}
return $row;
}
private function getBundle(int $id): array
{
$row = $this->selectOne(
"SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug
FROM release_bundles b
INNER JOIN release_channels c ON c.id = b.channel_id
INNER JOIN release_service_sets s ON s.id = b.service_set_id
WHERE b.id = ? AND b.deleted_at IS NULL",
'i',
[$id]
);
if ($row === null) {
throw new RuntimeException('Release bundle not found.');
}
return $row;
}
private function getDeploymentTarget(int $id): array
{
$row = $this->selectOne(
"SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label
FROM release_deployment_targets t
INNER JOIN release_channels c ON c.id = t.channel_id
LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id
WHERE t.id = ? AND t.deleted_at IS NULL",
'i',
[$id]
);
if ($row === null) {
throw new RuntimeException('Release deployment target not found.');
}
return $row;
}
private function publicChannel(array $channel): array
{
return [
'id' => (int)($channel['id'] ?? 0),
'slug' => (string)($channel['slug'] ?? ''),
'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')),
'name' => (string)($channel['name'] ?? ''),
'description' => $channel['description'] ?? null,
'enabled' => (bool)((int)($channel['enabled'] ?? 0)),
'default_channel' => (bool)((int)($channel['default_channel'] ?? 0)),
'rollout_percent' => (float)($channel['rollout_percent'] ?? 0),
'frontend_base_url' => $channel['frontend_base_url'] ?? null,
'api_base_url' => $channel['api_base_url'] ?? null,
'replay_enabled' => (bool)((int)($channel['replay_enabled'] ?? 0)),
'capture_level' => (string)($channel['capture_level'] ?? 'metadata'),
'retention_days' => (int)($channel['retention_days'] ?? 14),
'metadata' => self::jsonDecode($channel['metadata_json'] ?? null),
'created_at' => $channel['created_at'] ?? null,
'updated_at' => $channel['updated_at'] ?? null,
];
}
private function publicRuntimeChannel(array $channel): array
{
$public = $this->publicChannel($channel);
unset($public['frontend_base_url'], $public['api_base_url']);
return $public;
}
private function publicRuntimeChannelOptions(array $channels): array
{
return array_map(function (array $channel): array {
$serviceChannel = $this->runtimeServiceChannelFor($channel);
return [
'channel' => $this->publicRuntimeChannel($channel),
'service_channel' => $this->publicRuntimeChannel($serviceChannel),
'versions' => $this->currentVersionsForChannel((int)($serviceChannel['id'] ?? 0)),
'availability' => $this->channelAvailability($channel),
];
}, $channels);
}
public static function publicAssignmentSubjectSuggestion(array $candidate): ?array
{
$subjectType = strtolower(trim((string)($candidate['subject_type'] ?? '')));
if (!in_array($subjectType, self::SUBJECT_TYPES, true)) {
return null;
}
$subjectId = self::safeIdentifier((string)($candidate['subject_id'] ?? ''), 64);
if ($subjectId === '') {
return null;
}
$title = self::safeDisplayText($candidate['title'] ?? '', 120);
if ($title === '') {
$title = $subjectType . ':' . $subjectId;
}
$description = self::safeDisplayText($candidate['description'] ?? '', 180);
$icon = self::safeIconClass($candidate['icon'] ?? self::assignmentSubjectIcon($subjectType));
$source = self::safeIdentifier((string)($candidate['source'] ?? $subjectType), 32) ?: $subjectType;
return [
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'label' => $description !== '' ? $title . ' - ' . $description : $title,
'title' => $title,
'description' => $description,
'icon' => $icon,
'source' => $source,
];
}
private function searchAssignmentUsers(string $query, int $limit): array
{
$like = '%' . $query . '%';
$rows = $this->selectRows(
"SELECT id, customer_number, display_name, email, phone_country_code, phone
FROM users
WHERE CAST(id AS CHAR) LIKE ?
OR CAST(customer_number AS CHAR) LIKE ?
OR display_name LIKE ?
OR email LIKE ?
ORDER BY id DESC
LIMIT ?",
'ssssi',
[$like, $like, $like, $like, $limit]
);
return array_map(function (array $row): array {
$id = (string)($row['id'] ?? '');
$customerNumber = trim((string)($row['customer_number'] ?? ''));
$displayName = self::safeDisplayText($row['display_name'] ?? '', 80);
$email = self::safeDisplayText($row['email'] ?? '', 80);
$parts = array_filter([
$customerNumber !== '' ? 'Customer #' . $customerNumber : '',
$email,
$this->phoneLabel($row),
]);
return [
'subject_type' => 'user',
'subject_id' => $id,
'title' => $displayName !== '' ? $displayName : 'User #' . $id,
'description' => implode(' / ', $parts),
'icon' => self::assignmentSubjectIcon('user'),
'source' => 'users',
];
}, $rows);
}
private function searchAssignmentSubusers(string $query, int $limit): array
{
$like = '%' . $query . '%';
$rows = $this->selectRows(
"SELECT id, username, name, email, phone_country_code, phone
FROM subusers
WHERE CAST(id AS CHAR) LIKE ?
OR username LIKE ?
OR name LIKE ?
OR email LIKE ?
OR CAST(phone AS CHAR) LIKE ?
ORDER BY id DESC
LIMIT ?",
'sssssi',
[$like, $like, $like, $like, $like, $limit]
);
return array_map(function (array $row): array {
$id = (string)($row['id'] ?? '');
$name = self::safeDisplayText($row['name'] ?? '', 80);
$username = self::safeDisplayText($row['username'] ?? '', 80);
$email = self::safeDisplayText($row['email'] ?? '', 80);
$parts = array_filter([
$username !== '' ? '@' . ltrim($username, '@') : '',
$email,
$this->phoneLabel($row),
]);
return [
'subject_type' => 'subuser',
'subject_id' => $id,
'title' => $name !== '' ? $name : 'Subuser #' . $id,
'description' => implode(' / ', $parts),
'icon' => self::assignmentSubjectIcon('subuser'),
'source' => 'subusers',
];
}, $rows);
}
private function searchAssignmentCustomers(string $query, int $limit): array
{
try {
$result = (new economicCustomers())->listCustomers(1, $limit, $query, null);
} catch (Throwable) {
return [];
}
$customers = is_array($result->collection ?? null) ? $result->collection : [];
return array_map(static function (object $customer): array {
$customerNumber = (string)($customer->customerNumber ?? $customer->customer_number ?? '');
$name = self::safeDisplayText($customer->name ?? $customer->customer_name ?? '', 100);
$email = self::safeDisplayText($customer->email ?? '', 80);
$city = self::safeDisplayText($customer->city ?? '', 80);
$parts = array_filter([
$customerNumber !== '' ? 'Customer #' . $customerNumber : '',
$email,
$city,
]);
return [
'subject_type' => 'customer',
'subject_id' => $customerNumber,
'title' => $name !== '' ? $name : 'Customer #' . $customerNumber,
'description' => implode(' / ', $parts),
'icon' => self::assignmentSubjectIcon('customer'),
'source' => 'customers',
];
}, $customers);
}
private static function normalizeAssignmentSubjectSearch(mixed $value): string
{
$query = self::safeDisplayText($value, 80);
return trim($query);
}
private static function normalizeAssignmentSubjectLimit(mixed $value): int
{
$limit = (int)$value;
if ($limit <= 0) {
return 5;
}
return min(10, max(1, $limit));
}
private static function safeDisplayText(mixed $value, int $maxLength): string
{
$text = trim(strip_tags((string)$value));
$text = preg_replace('/\s+/', ' ', $text) ?? '';
return substr($text, 0, max(1, $maxLength));
}
private static function safeIconClass(mixed $value): string
{
$icon = trim((string)$value);
if (!preg_match('/^[a-z0-9 _-]+$/i', $icon)) {
return 'fas fa-tag';
}
return $icon;
}
private static function assignmentSubjectIcon(string $subjectType): string
{
return match ($subjectType) {
'customer' => 'fas fa-building',
'subuser' => 'fas fa-id-badge',
default => 'fas fa-user',
};
}
private function phoneLabel(array $row): string
{
$countryCode = trim((string)($row['phone_country_code'] ?? ''));
$phone = trim((string)($row['phone'] ?? ''));
if ($phone === '') {
return '';
}
return $countryCode !== '' ? '+' . $countryCode . ' ' . $phone : $phone;
}
private function publicVersion(?array $version): ?array
{
if (!$version || empty($version['id'])) {
return null;
}
$metadata = self::publicReleaseVersionMetadata(self::jsonDecode($version['metadata_json'] ?? null));
$commit = $this->versionGithubCommit(['metadata' => $metadata]);
return [
'id' => (int)$version['id'],
'app' => (string)$version['app'],
'repository' => $version['repository'] ?? null,
'branch' => $version['branch'] ?? null,
'commit_sha' => $version['commit_sha'] ?? null,
'commit' => $commit,
'commit_authored_at' => $commit['authored_at'] ?? null,
'tag' => $version['tag'] ?? null,
'version_label' => $version['version_label'] ?? null,
'build_url' => $version['build_url'] ?? null,
'artifact_url' => $version['artifact_url'] ?? null,
'deployed_url' => $version['deployed_url'] ?? null,
'status' => (string)($version['status'] ?? 'unknown'),
'metadata' => $metadata,
'created_at' => $version['created_at'] ?? null,
'deployed_at' => $version['deployed_at'] ?? null,
];
}
private static function publicReleaseVersionMetadata(mixed $metadata): array
{
if (!is_array($metadata)) {
return [];
}
unset($metadata['github_access']);
return $metadata;
}
private static function releaseVersionGithubAccessMetadata(mixed $githubAccess): ?array
{
if (!is_array($githubAccess)) {
return null;
}
unset($githubAccess['commit'], $githubAccess['latest_commit'], $githubAccess['commit_authored_at']);
return $githubAccess;
}
private function publicAssignment(array $assignment): array
{
return [
'id' => (int)($assignment['id'] ?? 0),
'subject_type' => (string)($assignment['subject_type'] ?? ''),
'subject_id' => (string)($assignment['subject_id'] ?? ''),
'channel_id' => (int)($assignment['channel_id'] ?? 0),
'channel_slug' => (string)($assignment['channel_slug'] ?? ''),
'channel_name' => (string)($assignment['channel_name'] ?? ''),
'reason' => $assignment['reason'] ?? null,
'expires_at' => $assignment['expires_at'] ?? null,
'actor_user_id' => isset($assignment['actor_user_id']) ? (int)$assignment['actor_user_id'] : null,
'created_at' => $assignment['created_at'] ?? null,
];
}
private function publicDeploymentTarget(array $target): array
{
$deployContext = self::jsonDecode($target['deploy_context_json'] ?? null);
$targetWithContext = $target + ['deploy_context' => $deployContext];
return [
'id' => (int)($target['id'] ?? 0),
'channel_id' => (int)($target['channel_id'] ?? 0),
'channel_slug' => (string)($target['channel_slug'] ?? ''),
'channel_name' => (string)($target['channel_name'] ?? ''),
'app' => (string)($target['app'] ?? ''),
'coolify_instance_id' => isset($target['coolify_instance_id']) ? (int)$target['coolify_instance_id'] : null,
'coolify_instance_label' => $target['coolify_instance_label'] ?? null,
'coolify_service_uuid' => $target['coolify_service_uuid'] ?? null,
'repository' => (string)($target['repository'] ?? ''),
'branch' => (string)($target['branch'] ?? ''),
'auto_deploy' => (bool)((int)($target['auto_deploy'] ?? 0)),
'health_url' => $target['health_url'] ?? null,
'deploy_context' => $deployContext,
'endpoint' => $this->releaseDeploymentEndpoint($targetWithContext),
'created_at' => $target['created_at'] ?? null,
'updated_at' => $target['updated_at'] ?? null,
];
}
private function publicServiceSet(array $serviceSet, bool $includeBundles = true): array
{
$dataServices = [];
foreach (self::STACK_DATA_KINDS as $kind) {
$dataServices[$kind] = $this->nullableCoolifyTarget(
$this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null)
);
}
$attachedBundles = $includeBundles ? $this->serviceSetBundles((int)($serviceSet['id'] ?? 0)) : [];
$serviceSetId = (int)($serviceSet['id'] ?? 0);
$metadata = self::jsonDecode($serviceSet['metadata_json'] ?? null);
$dataPolicy = $this->serviceSetDataPolicy(array_replace($serviceSet, ['metadata' => $metadata]));
return [
'id' => $serviceSetId,
'channel_id' => isset($serviceSet['channel_id']) ? (int)$serviceSet['channel_id'] : null,
'channel_slug' => $serviceSet['channel_slug'] ?? null,
'channel_name' => $serviceSet['channel_name'] ?? null,
'name' => (string)($serviceSet['name'] ?? ''),
'slug' => (string)($serviceSet['slug'] ?? ''),
'mode' => (string)($serviceSet['mode'] ?? 'attach_existing'),
'data_policy' => $dataPolicy,
'data_service_mode' => $dataPolicy,
'source_service_set_id' => isset($serviceSet['source_service_set_id']) ? (int)$serviceSet['source_service_set_id'] : null,
'data_source_service_set_id' => $this->nullablePositiveInt($metadata['data_source_service_set_id'] ?? null),
'data_source_channel_slug' => $metadata['data_source_channel_slug'] ?? null,
'status' => (string)($serviceSet['status'] ?? 'unknown'),
'active' => $serviceSetId > 0 && $this->serviceSetIsActive($serviceSetId),
'targets' => [
'frontend' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null)),
'api' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['api_target_id'] ?? null)),
],
'data_services' => $dataServices,
'stack' => [
'frontend' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null)),
'api' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['api_target_id'] ?? null)),
'database' => $dataServices['database'],
'redis' => $dataServices['redis'],
'minio' => $dataServices['minio'],
],
'health' => self::jsonDecode($serviceSet['health_json'] ?? null),
'metadata' => $metadata,
'attached_bundle_count' => count($attachedBundles),
'attached_bundles' => $attachedBundles,
'created_at' => $serviceSet['created_at'] ?? null,
'updated_at' => $serviceSet['updated_at'] ?? null,
];
}
private function publicBundle(array $bundle, bool $includeServiceSet = true): array
{
$bundleId = (int)($bundle['id'] ?? 0);
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
$frontendDeploymentId = $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null);
$apiDeploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null);
$frontendVersion = $frontendVersionId !== null ? $this->publicVersion($this->getVersion($frontendVersionId)) : null;
$apiVersion = $apiVersionId !== null ? $this->publicVersion($this->getVersion($apiVersionId)) : null;
$frontendCommit = $this->versionGithubCommit($frontendVersion);
$apiCommit = $this->versionGithubCommit($apiVersion);
$active = $bundleId > 0 && $this->bundleIsActive($bundleId);
$status = (string)($bundle['status'] ?? 'draft');
if ($status === 'promoted' && !$active) {
$status = 'superseded';
}
return [
'id' => $bundleId,
'channel_id' => (int)($bundle['channel_id'] ?? 0),
'channel_slug' => (string)($bundle['channel_slug'] ?? ''),
'channel_name' => (string)($bundle['channel_name'] ?? ''),
'service_set_id' => (int)($bundle['service_set_id'] ?? 0),
'service_set_name' => $bundle['service_set_name'] ?? null,
'service_set_slug' => $bundle['service_set_slug'] ?? null,
'service_set' => $includeServiceSet ? $this->publicServiceSet($this->getServiceSet((int)$bundle['service_set_id']), false) : null,
'version_label' => $bundle['version_label'] ?? null,
'status' => $status,
'active' => $active,
'apps' => [
'frontend' => [
'version_id' => $frontendVersionId,
'deployment_id' => $frontendDeploymentId,
'repository' => $bundle['frontend_repository'] ?? null,
'branch' => $bundle['frontend_branch'] ?? null,
'commit_sha' => $bundle['frontend_commit_sha'] ?? null,
'commit' => $frontendCommit,
'commit_authored_at' => $frontendCommit['authored_at'] ?? null,
'version' => $frontendVersion,
'deployment' => $this->nullableDeployment($frontendDeploymentId),
],
'api' => [
'version_id' => $apiVersionId,
'deployment_id' => $apiDeploymentId,
'repository' => $bundle['api_repository'] ?? null,
'branch' => $bundle['api_branch'] ?? null,
'commit_sha' => $bundle['api_commit_sha'] ?? null,
'commit' => $apiCommit,
'commit_authored_at' => $apiCommit['authored_at'] ?? null,
'version' => $apiVersion,
'deployment' => $this->nullableDeployment($apiDeploymentId),
],
],
'deployment_result' => self::jsonDecode($bundle['deployment_result_json'] ?? null),
'metadata' => self::jsonDecode($bundle['metadata_json'] ?? null),
'actor_user_id' => isset($bundle['actor_user_id']) ? (int)$bundle['actor_user_id'] : null,
'deployed_at' => $bundle['deployed_at'] ?? null,
'promoted_at' => $bundle['promoted_at'] ?? null,
'created_at' => $bundle['created_at'] ?? null,
'updated_at' => $bundle['updated_at'] ?? null,
];
}
private function versionGithubCommit(?array $version): ?array
{
$metadata = is_array($version['metadata'] ?? null) ? $version['metadata'] : [];
$access = is_array($metadata['github_access'] ?? null) ? $metadata['github_access'] : [];
foreach (['commit', 'latest_commit'] as $key) {
$commit = is_array($access[$key] ?? null) ? $access[$key] : [];
if (trim((string)($commit['sha'] ?? '')) !== '') {
return $commit;
}
}
return null;
}
private function serviceSetBundles(int $serviceSetId): array
{
if ($serviceSetId <= 0 || !release_manager_schema_bootstrap::tablesExist()) {
return [];
}
return array_map(
fn(array $row): array => $this->publicBundle($row, false),
$this->selectRows(
"SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug
FROM release_bundles b
INNER JOIN release_channels c ON c.id = b.channel_id
INNER JOIN release_service_sets s ON s.id = b.service_set_id
WHERE b.deleted_at IS NULL AND b.service_set_id = ?
ORDER BY b.created_at DESC, b.id DESC
LIMIT 10",
'i',
[$serviceSetId]
)
);
}
private function serviceSetIsActive(int $serviceSetId): bool
{
return $this->selectOne(
'SELECT id FROM release_channel_versions WHERE service_set_id = ? AND active = 1 LIMIT 1',
'i',
[$serviceSetId]
) !== null;
}
private function bundleIsActive(int $bundleId): bool
{
return $this->selectOne(
'SELECT id FROM release_channel_versions WHERE bundle_id = ? AND active = 1 LIMIT 1',
'i',
[$bundleId]
) !== null;
}
private function isolatedDeploymentTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool
{
if ($targetId === null || $targetId <= 0) {
return false;
}
$target = $this->nullableDeploymentTarget($targetId);
if ($target === null) {
return false;
}
$context = is_array($target['deploy_context'] ?? null) ? $target['deploy_context'] : [];
if (!$this->toBool($context['isolated_stack'] ?? false)) {
return false;
}
if ($this->toBool($context['production_data_attached'] ?? false)) {
return false;
}
return $this->selectOne(
"SELECT id
FROM release_service_sets
WHERE id <> ? AND deleted_at IS NULL AND (frontend_target_id = ? OR api_target_id = ?)
LIMIT 1",
'iii',
[$serviceSetId, $targetId, $targetId]
) === null;
}
private function isolatedCoolifyTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool
{
if ($targetId === null || $targetId <= 0 || !$this->tableExists('coolify_targets')) {
return false;
}
$target = $this->nullableCoolifyTarget($targetId);
if ($target === null) {
return false;
}
$options = is_array($target['options'] ?? null) ? $target['options'] : [];
if (!$this->toBool($options['isolated_stack'] ?? false)) {
return false;
}
if ($this->toBool($options['production_data_attached'] ?? false)) {
return false;
}
return $this->selectOne(
"SELECT id
FROM release_service_sets
WHERE id <> ? AND deleted_at IS NULL
AND (
database_coolify_target_id = ?
OR redis_coolify_target_id = ?
OR minio_coolify_target_id = ?
)
LIMIT 1",
'iiii',
[$serviceSetId, $targetId, $targetId, $targetId]
) === null;
}
private function nullableDeploymentTarget(?int $id): ?array
{
if ($id === null || $id <= 0) {
return null;
}
try {
return $this->publicDeploymentTarget($this->getDeploymentTarget($id));
} catch (Throwable) {
return null;
}
}
private function nullableDeployment(?int $id): ?array
{
if ($id === null || $id <= 0) {
return null;
}
try {
return $this->publicDeployment($this->getDeployment($id));
} catch (Throwable) {
return null;
}
}
private function nullableCoolifyTarget(?int $id): ?array
{
if ($id === null || $id <= 0 || !$this->tableExists('coolify_targets')) {
return null;
}
$hasReplicationHosts = $this->tableExists('replication_hosts');
$replicationColumns = $hasReplicationHosts
? "h.id AS host_id, h.kind AS host_kind, h.label AS host_label, h.host AS host_host,
h.port AS host_port, h.role AS host_role, h.status AS host_status,
h.replication_source_id AS host_replication_source_id,
h.last_status_json AS host_last_status_json, h.last_checked_at AS host_last_checked_at"
: "NULL AS host_id, NULL AS host_kind, NULL AS host_label, NULL AS host_host,
NULL AS host_port, NULL AS host_role, NULL AS host_status,
NULL AS host_replication_source_id,
NULL AS host_last_status_json, NULL AS host_last_checked_at";
$replicationJoin = $hasReplicationHosts ? 'LEFT JOIN replication_hosts h ON h.id = t.replication_host_id' : '';
$row = $this->selectOne(
"SELECT t.*, i.label AS instance_label, i.base_url AS instance_base_url,
$replicationColumns
FROM coolify_targets t
LEFT JOIN coolify_instances i ON i.id = t.instance_id
$replicationJoin
WHERE t.id = ? AND t.deleted_at IS NULL",
'i',
[$id]
);
if ($row === null) {
return null;
}
$replication = !empty($row['host_id']) ? [
'id' => (int)$row['host_id'],
'kind' => (string)($row['host_kind'] ?? $row['kind'] ?? ''),
'label' => (string)($row['host_label'] ?? ''),
'host' => $row['host_host'] ?? null,
'port' => isset($row['host_port']) ? (int)$row['host_port'] : null,
'role' => (string)($row['host_role'] ?? 'unknown'),
'status' => (string)($row['host_status'] ?? 'unknown'),
'source_host_id' => isset($row['host_replication_source_id']) ? (int)$row['host_replication_source_id'] : null,
'last_status' => self::jsonDecode($row['host_last_status_json'] ?? null),
'last_checked_at' => $row['host_last_checked_at'] ?? null,
] : null;
$endpoint = $replication !== null && !empty($replication['host'])
? $this->releaseEndpointFromParts(
'auto',
'resolved',
(string)$replication['host'],
isset($replication['port']) ? (int)$replication['port'] : null,
'replication_host',
'Endpoint resolved from the attached replication host.'
)
: self::releasePendingEndpoint('auto', 'coolify_target', 'Automatic endpoint resolution is pending Coolify target metadata.');
return [
'id' => (int)($row['id'] ?? 0),
'kind' => (string)($row['kind'] ?? ''),
'label' => (string)($row['label'] ?? ''),
'role' => (string)($row['role'] ?? ''),
'instance_id' => isset($row['instance_id']) ? (int)$row['instance_id'] : null,
'instance_label' => $row['instance_label'] ?? null,
'resource_uuid' => $row['resource_uuid'] ?? null,
'resource_name' => $row['resource_name'] ?? null,
'deployment_status' => (string)($row['deployment_status'] ?? 'unknown'),
'availability_state' => (string)($row['availability_state'] ?? 'unknown'),
'last_reconcile_status' => $row['last_reconcile_status'] ?? null,
'last_reconciled_at' => $row['last_reconciled_at'] ?? null,
'endpoint' => $endpoint,
'replication' => $replication,
'options' => self::jsonDecode($row['options_json'] ?? null),
];
}
private function publicDeployment(array $deployment): array
{
$status = (string)($deployment['status'] ?? 'unknown');
$result = self::jsonDecode($deployment['result_json'] ?? null);
$failureSummary = is_array($result['failure_summary'] ?? null) ? $result['failure_summary'] : null;
$promotable = self::deploymentCanBePromoted($status);
return [
'id' => (int)($deployment['id'] ?? 0),
'channel_id' => (int)($deployment['channel_id'] ?? 0),
'channel_slug' => (string)($deployment['channel_slug'] ?? ''),
'channel_name' => (string)($deployment['channel_name'] ?? ''),
'target_id' => isset($deployment['target_id']) ? (int)$deployment['target_id'] : null,
'version_id' => isset($deployment['version_id']) ? (int)$deployment['version_id'] : null,
'service_set_id' => isset($deployment['service_set_id']) ? (int)$deployment['service_set_id'] : null,
'bundle_id' => isset($deployment['bundle_id']) ? (int)$deployment['bundle_id'] : null,
'deployment_kind' => (string)($deployment['deployment_kind'] ?? 'single_app'),
'version_label' => $deployment['version_label'] ?? null,
'app' => (string)($deployment['app'] ?? ''),
'active_channel_app_key' => $deployment['active_channel_app_key'] ?? null,
'active_current' => trim((string)($deployment['active_channel_app_key'] ?? '')) !== '',
'provider' => (string)($deployment['provider'] ?? 'coolify'),
'provider_operation_id' => $deployment['provider_operation_id'] ?? null,
'repository' => $deployment['repository'] ?? null,
'branch' => $deployment['branch'] ?? null,
'commit_sha' => $deployment['commit_sha'] ?? null,
'status' => $status,
'deployment_url' => $deployment['deployment_url'] ?? $deployment['deployed_url'] ?? null,
'actor_user_id' => isset($deployment['actor_user_id']) ? (int)$deployment['actor_user_id'] : null,
'result' => $result,
'failure_summary' => $failureSummary,
'error_message' => $deployment['error_message'] ?? null,
'promotable' => $promotable,
'promotion_blocked_reason' => $promotable ? null : self::deploymentPromotionBlockedReason($deployment),
'started_at' => $deployment['started_at'] ?? null,
'completed_at' => $deployment['completed_at'] ?? null,
'created_at' => $deployment['created_at'] ?? null,
'updated_at' => $deployment['updated_at'] ?? null,
];
}
private function publicTimelineEvent(array $event): array
{
return [
'id' => (int)($event['id'] ?? 0),
'timeline_session_id' => isset($event['timeline_session_id']) ? (int)$event['timeline_session_id'] : null,
'trace_id' => (string)($event['trace_id'] ?? ''),
'event_type' => (string)($event['event_type'] ?? ''),
'severity' => (string)($event['severity'] ?? 'info'),
'module_key' => $event['module_key'] ?? null,
'route_path' => $event['route_path'] ?? null,
'component' => $event['component'] ?? null,
'request_id' => $event['request_id'] ?? null,
'occurred_at' => $event['occurred_at'] ?? null,
'payload' => self::jsonDecode($event['payload_json'] ?? null),
'principal_type' => $event['principal_type'] ?? null,
'principal_id' => $event['principal_id'] ?? null,
'customer_number' => isset($event['customer_number']) ? (int)$event['customer_number'] : null,
'channel_slug' => $event['channel_slug'] ?? null,
];
}
private function publicTimelineSession(array $session): array
{
$moduleKeys = array_filter(array_map('trim', explode(',', (string)($session['module_keys'] ?? ''))));
$principal = $this->timelinePrincipal($session);
return [
'id' => (int)($session['id'] ?? 0),
'trace_id' => (string)($session['trace_id'] ?? ''),
'principal_type' => $session['principal_type'] ?? null,
'principal_id' => $session['principal_id'] ?? null,
'customer_number' => isset($session['customer_number']) ? (int)$session['customer_number'] : null,
'user' => $principal,
'channel_id' => isset($session['channel_id']) ? (int)$session['channel_id'] : null,
'channel_slug' => $session['channel_slug'] ?? null,
'release' => [
'frontend' => [
'version_label' => $session['frontend_version_label'] ?? null,
'commit_sha' => $session['frontend_commit_sha'] ?? null,
],
'api' => [
'version_label' => $session['api_version_label'] ?? null,
'commit_sha' => $session['api_commit_sha'] ?? null,
],
],
'device' => [
'type' => $session['device_type'] ?? null,
'browser_name' => $session['browser_name'] ?? null,
'browser_version' => $session['browser_version'] ?? null,
'os_name' => $session['os_name'] ?? null,
'os_version' => $session['os_version'] ?? null,
'viewport_width' => isset($session['viewport_width']) ? (int)$session['viewport_width'] : null,
'viewport_height' => isset($session['viewport_height']) ? (int)$session['viewport_height'] : null,
'device_pixel_ratio' => isset($session['device_pixel_ratio']) ? (float)$session['device_pixel_ratio'] : null,
'user_agent' => $session['user_agent'] ?? null,
],
'last_route_path' => $session['last_route_path'] ?? null,
'event_count' => isset($session['event_count']) ? (int)$session['event_count'] : 0,
'error_count' => isset($session['error_count']) ? (int)$session['error_count'] : 0,
'error_report_count' => isset($session['error_report_count']) ? (int)$session['error_report_count'] : 0,
'module_keys' => array_values($moduleKeys),
'first_event_at' => $session['first_event_at'] ?? null,
'last_event_at' => $session['last_event_at'] ?? $session['last_seen_at'] ?? null,
'created_at' => $session['created_at'] ?? null,
'last_seen_at' => $session['last_seen_at'] ?? null,
];
}
private function timelinePrincipal(array $session): array
{
$type = $session['principal_type'] ?? null;
$id = $session['principal_id'] ?? null;
$customerNumber = isset($session['customer_number']) ? (int)$session['customer_number'] : null;
$label = trim(implode(':', array_filter([(string)$type, (string)$id])));
$name = null;
$email = null;
if ($type === 'user' && is_numeric($id) && $this->tableExists('users')) {
$row = $this->selectOne(
'SELECT id, customer_number, display_name, email FROM users WHERE id = ? LIMIT 1',
'i',
[(int)$id]
);
if ($row !== null) {
$name = $row['display_name'] ?? null;
$email = $row['email'] ?? null;
$customerNumber = isset($row['customer_number']) ? (int)$row['customer_number'] : $customerNumber;
}
}
if ($type === 'subuser' && is_numeric($id) && $this->tableExists('subusers')) {
$row = $this->selectOne(
'SELECT id, username, name, email FROM subusers WHERE id = ? LIMIT 1',
'i',
[(int)$id]
);
if ($row !== null) {
$name = $row['name'] ?? $row['username'] ?? null;
$email = $row['email'] ?? null;
}
}
$displayLabel = trim((string)($name ?: $email ?: $label));
if ($displayLabel === '') {
$displayLabel = $customerNumber !== null ? 'customer:' . $customerNumber : 'unknown';
}
return [
'type' => $type,
'id' => $id,
'customer_number' => $customerNumber,
'name' => $name,
'email' => $email,
'label' => $displayLabel,
];
}
private function timelineErrorReports(string $traceId): array
{
if (!$this->tableExists('error_reports')) {
return [];
}
$rows = $this->selectRows(
"SELECT id, status, reporter_type, reporter_user_id, reporter_subuser_id,
reporter_customer_number, reporter_customer_number_context, reporter_name,
reporter_email, route_path, page_url, release_trace_id, frontend_version,
api_version, request_error_count, vue_error_count, created_at, updated_at
FROM error_reports
WHERE release_trace_id = ?
ORDER BY created_at DESC, id DESC
LIMIT 25",
's',
[$traceId]
);
return array_map(static fn(array $row): array => [
'id' => (int)($row['id'] ?? 0),
'status' => (string)($row['status'] ?? ''),
'reporter' => [
'type' => $row['reporter_type'] ?? null,
'user_id' => isset($row['reporter_user_id']) ? (int)$row['reporter_user_id'] : null,
'subuser_id' => isset($row['reporter_subuser_id']) ? (int)$row['reporter_subuser_id'] : null,
'customer_number' => isset($row['reporter_customer_number']) ? (int)$row['reporter_customer_number'] : null,
'customer_number_context' => isset($row['reporter_customer_number_context']) ? (int)$row['reporter_customer_number_context'] : null,
'name' => $row['reporter_name'] ?? null,
'email' => $row['reporter_email'] ?? null,
],
'route_path' => $row['route_path'] ?? null,
'page_url' => $row['page_url'] ?? null,
'release_trace_id' => $row['release_trace_id'] ?? null,
'frontend_version' => $row['frontend_version'] ?? null,
'api_version' => $row['api_version'] ?? null,
'request_error_count' => isset($row['request_error_count']) ? (int)$row['request_error_count'] : 0,
'vue_error_count' => isset($row['vue_error_count']) ? (int)$row['vue_error_count'] : 0,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
], $rows);
}
private function timelineReleaseContext(array $session, ?array $channel): array
{
$frontend = $this->timelineAppReleaseContext(
'frontend',
$session['frontend_version_label'] ?? null,
$session['frontend_commit_sha'] ?? null
);
$api = $this->timelineAppReleaseContext(
'api',
$session['api_version_label'] ?? null,
$session['api_commit_sha'] ?? null
);
$bundle = $this->timelineBundleReference(
$this->nullablePositiveInt($frontend['version']['id'] ?? null),
$this->nullablePositiveInt($api['version']['id'] ?? null),
$this->nullablePositiveInt($frontend['deployment']['bundle_id'] ?? $api['deployment']['bundle_id'] ?? null)
);
return [
'channel' => $channel !== null ? $this->publicChannel($channel) : null,
'frontend' => $frontend,
'api' => $api,
'bundle' => $bundle,
];
}
private function timelineAppReleaseContext(string $app, mixed $versionLabel, mixed $commitSha): array
{
$versionLabel = $this->nullableString($versionLabel, 128);
$commitSha = $this->nullableString($commitSha, 128);
$context = [
'version_label' => $versionLabel,
'commit_sha' => $commitSha,
'version' => null,
'deployment' => null,
];
$where = ['app = ?'];
$types = 's';
$params = [$app];
if ($versionLabel !== null && $commitSha !== null) {
$where[] = '(version_label = ? OR commit_sha = ?)';
$types .= 'ss';
$params[] = $versionLabel;
$params[] = $commitSha;
} elseif ($versionLabel !== null) {
$where[] = 'version_label = ?';
$types .= 's';
$params[] = $versionLabel;
} elseif ($commitSha !== null) {
$where[] = 'commit_sha = ?';
$types .= 's';
$params[] = $commitSha;
} else {
return $context;
}
$version = $this->selectOne(
"SELECT id, app, repository, branch, commit_sha, tag, version_label, build_url,
artifact_url, deployed_url, status, created_at, deployed_at
FROM release_versions
WHERE " . implode(' AND ', $where) . "
ORDER BY deployed_at DESC, id DESC
LIMIT 1",
$types,
$params
);
if ($version === null) {
return $context;
}
$context['version_label'] = $version['version_label'] ?? $versionLabel;
$context['commit_sha'] = $version['commit_sha'] ?? $commitSha;
$context['version'] = $this->publicTimelineVersionReference($version);
$deployment = $this->selectOne(
"SELECT id, channel_id, target_id, version_id, service_set_id, bundle_id,
deployment_kind, app, provider, repository, branch, commit_sha,
status, deployment_url, started_at, completed_at, created_at
FROM release_deployments
WHERE version_id = ?
ORDER BY id DESC
LIMIT 1",
'i',
[(int)$version['id']]
);
if ($deployment !== null) {
$context['deployment'] = $this->publicTimelineDeploymentReference($deployment);
}
return $context;
}
private function timelineBundleReference(?int $frontendVersionId, ?int $apiVersionId, ?int $bundleId): ?array
{
if ($bundleId !== null) {
$bundle = $this->selectOne(
"SELECT id, channel_id, service_set_id, version_label, frontend_version_id,
api_version_id, frontend_deployment_id, api_deployment_id,
status, deployed_at, promoted_at, created_at
FROM release_bundles
WHERE id = ? AND deleted_at IS NULL
LIMIT 1",
'i',
[$bundleId]
);
return $bundle !== null ? $this->publicTimelineBundleReference($bundle) : null;
}
$where = ['deleted_at IS NULL'];
$types = '';
$params = [];
if ($frontendVersionId !== null && $apiVersionId !== null) {
$where[] = 'frontend_version_id = ?';
$where[] = 'api_version_id = ?';
$types .= 'ii';
$params[] = $frontendVersionId;
$params[] = $apiVersionId;
} elseif ($frontendVersionId !== null) {
$where[] = 'frontend_version_id = ?';
$types .= 'i';
$params[] = $frontendVersionId;
} elseif ($apiVersionId !== null) {
$where[] = 'api_version_id = ?';
$types .= 'i';
$params[] = $apiVersionId;
} else {
return null;
}
$bundle = $this->selectOne(
"SELECT id, channel_id, service_set_id, version_label, frontend_version_id,
api_version_id, frontend_deployment_id, api_deployment_id,
status, deployed_at, promoted_at, created_at
FROM release_bundles
WHERE " . implode(' AND ', $where) . "
ORDER BY id DESC
LIMIT 1",
$types,
$params
);
return $bundle !== null ? $this->publicTimelineBundleReference($bundle) : null;
}
private function publicTimelineVersionReference(array $version): array
{
return [
'id' => (int)($version['id'] ?? 0),
'app' => (string)($version['app'] ?? ''),
'repository' => $version['repository'] ?? null,
'branch' => $version['branch'] ?? null,
'commit_sha' => $version['commit_sha'] ?? null,
'tag' => $version['tag'] ?? null,
'version_label' => $version['version_label'] ?? null,
'build_url' => $version['build_url'] ?? null,
'artifact_url' => $version['artifact_url'] ?? null,
'deployed_url' => $version['deployed_url'] ?? null,
'status' => (string)($version['status'] ?? ''),
'created_at' => $version['created_at'] ?? null,
'deployed_at' => $version['deployed_at'] ?? null,
];
}
private function publicTimelineDeploymentReference(array $deployment): array
{
return [
'id' => (int)($deployment['id'] ?? 0),
'channel_id' => isset($deployment['channel_id']) ? (int)$deployment['channel_id'] : null,
'target_id' => isset($deployment['target_id']) ? (int)$deployment['target_id'] : null,
'version_id' => isset($deployment['version_id']) ? (int)$deployment['version_id'] : null,
'service_set_id' => isset($deployment['service_set_id']) ? (int)$deployment['service_set_id'] : null,
'bundle_id' => isset($deployment['bundle_id']) ? (int)$deployment['bundle_id'] : null,
'deployment_kind' => (string)($deployment['deployment_kind'] ?? ''),
'app' => (string)($deployment['app'] ?? ''),
'provider' => (string)($deployment['provider'] ?? ''),
'repository' => $deployment['repository'] ?? null,
'branch' => $deployment['branch'] ?? null,
'commit_sha' => $deployment['commit_sha'] ?? null,
'status' => (string)($deployment['status'] ?? ''),
'deployment_url' => $deployment['deployment_url'] ?? null,
'started_at' => $deployment['started_at'] ?? null,
'completed_at' => $deployment['completed_at'] ?? null,
'created_at' => $deployment['created_at'] ?? null,
];
}
private function publicTimelineBundleReference(array $bundle): array
{
return [
'id' => (int)($bundle['id'] ?? 0),
'channel_id' => isset($bundle['channel_id']) ? (int)$bundle['channel_id'] : null,
'service_set_id' => isset($bundle['service_set_id']) ? (int)$bundle['service_set_id'] : null,
'version_label' => $bundle['version_label'] ?? null,
'frontend_version_id' => isset($bundle['frontend_version_id']) ? (int)$bundle['frontend_version_id'] : null,
'api_version_id' => isset($bundle['api_version_id']) ? (int)$bundle['api_version_id'] : null,
'frontend_deployment_id' => isset($bundle['frontend_deployment_id']) ? (int)$bundle['frontend_deployment_id'] : null,
'api_deployment_id' => isset($bundle['api_deployment_id']) ? (int)$bundle['api_deployment_id'] : null,
'status' => (string)($bundle['status'] ?? ''),
'deployed_at' => $bundle['deployed_at'] ?? null,
'promoted_at' => $bundle['promoted_at'] ?? null,
'created_at' => $bundle['created_at'] ?? null,
];
}
private function clearOtherDefaultChannels(int $channelId): void
{
$this->execute('UPDATE release_channels SET default_channel = 0 WHERE id <> ?', 'i', [$channelId]);
}
private function normalizeApp(string $value): string
{
$app = strtolower(trim($value));
if (!in_array($app, self::APPS, true)) {
throw new RuntimeException('Release app must be frontend or api.');
}
return $app;
}
private function normalizeCaptureLevel(string $value): string
{
$level = strtolower(trim($value));
return in_array($level, self::CAPTURE_LEVELS, true) ? $level : 'metadata';
}
private function nullableString(mixed $value, int $maxLength): ?string
{
if ($value === null) {
return null;
}
$value = trim((string)$value);
if ($value === '') {
return null;
}
return substr($value, 0, max(1, $maxLength));
}
private function nullableIdentifier(mixed $value, int $maxLength): ?string
{
$identifier = self::safeIdentifier((string)($value ?? ''), $maxLength);
return $identifier === '' ? null : $identifier;
}
private function nullableInt(mixed $value): ?int
{
if ($value === null || $value === '' || !is_numeric($value)) {
return null;
}
return (int)$value;
}
private function nullableFloat(mixed $value): ?float
{
if ($value === null || $value === '' || !is_numeric($value)) {
return null;
}
return (float)$value;
}
private function normalizeDateTime(mixed $value): ?string
{
if (!is_string($value) || trim($value) === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
private function nullablePositiveInt(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
$int = (int)$value;
return $int > 0 ? $int : null;
}
private function toBool(mixed $value): bool
{
return self::normalizeBoolean($value);
}
private function requestTraceId(): string
{
$context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null)
? $GLOBALS['RELEASE_REQUEST_CONTEXT']
: self::initializeRequestContext();
return (string)($context['trace_id'] ?? '');
}
private function assignmentCacheKey(array $context): string
{
if (!empty($context['principal_type']) && !empty($context['principal_id'])) {
return 'release_manager:assignment:' . $context['principal_type'] . ':' . $context['principal_id'];
}
if (!empty($context['customer_number'])) {
return 'release_manager:assignment:customer:' . (int)$context['customer_number'];
}
return '';
}
private function cacheResolvedChannel(string $cacheKey, array $channel): void
{
if ($cacheKey === '' || !defined('redis')) {
return;
}
try {
redis->setEx($cacheKey, self::jsonEncode($channel), 60);
} catch (Throwable) {
}
}
private function clearAssignmentCache(string $subjectType, string $subjectId): void
{
if (!defined('redis')) {
return;
}
try {
redis->delete('release_manager:assignment:' . $subjectType . ':' . $subjectId);
} catch (Throwable) {
}
}
private function moduleConfigValue(string $module, string $variable, mixed $default = null): mixed
{
$row = $this->selectOne(
'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1',
'ss',
[$module, $variable]
);
return $row['value'] ?? $default;
}
private function upsertModuleConfigValue(string $module, string $variable, string $value, string $type): void
{
$existing = $this->selectOne(
'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1',
'ss',
[$module, $variable]
);
if ($existing === null) {
$this->execute(
'INSERT INTO module_config (module, variable, value, type) VALUES (?, ?, ?, ?)',
'ssss',
[$module, $variable, $value, $type]
);
return;
}
$this->execute(
'UPDATE module_config SET value = ?, type = ? WHERE module = ? AND variable = ?',
'ssss',
[$value, $type, $module, $variable]
);
}
private function audit(?int $channelId, ?int $deploymentId, string $action, ?int $actorUserId, string $severity, array $context): void
{
$this->execute(
"INSERT INTO release_audit_logs (channel_id, deployment_id, action, actor_user_id, severity, context_json)
VALUES (?, ?, ?, ?, ?, ?)",
'iisiss',
[$channelId, $deploymentId, $action, $actorUserId, $severity, self::jsonEncode(self::redactPayload($context))]
);
}
private function selectOne(string $sql, string $types = '', array $params = []): ?array
{
$rows = $this->selectRows($sql, $types, $params);
return $rows[0] ?? null;
}
private function selectRows(string $sql, string $types = '', array $params = []): array
{
global $db;
if ($types === '') {
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
$stmt = $db->prepare($sql);
if ($stmt === false) {
throw new RuntimeException('Could not prepare release manager query.');
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
private function tableExists(string $table): bool
{
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? '';
if ($table === '') {
return false;
}
try {
return $this->selectOne(
'SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1',
's',
[$table]
) !== null;
} catch (Throwable) {
return false;
}
}
private function cleanupExpiredReplayData(): void
{
try {
$this->execute(
"UPDATE release_replay_targets
SET deleted_at = NOW(), enabled = 0
WHERE deleted_at IS NULL AND expires_at IS NOT NULL AND expires_at <= NOW()"
);
$this->execute(
"DELETE e
FROM release_timeline_events e
INNER JOIN release_timeline_sessions s ON s.id = e.timeline_session_id
LEFT JOIN release_channels c ON c.id = s.channel_id
WHERE s.last_seen_at < DATE_SUB(NOW(), INTERVAL COALESCE(c.retention_days, 14) DAY)"
);
$this->execute(
"DELETE s
FROM release_timeline_sessions s
LEFT JOIN release_channels c ON c.id = s.channel_id
WHERE s.last_seen_at < DATE_SUB(NOW(), INTERVAL COALESCE(c.retention_days, 14) DAY)"
);
} catch (Throwable) {
// Retention cleanup should never block release debugging reads.
}
}
private function execute(string $sql, string $types = '', array $params = []): void
{
global $db;
if ($types === '') {
$db->query($sql);
return;
}
$stmt = $db->prepare($sql);
if ($stmt === false) {
throw new RuntimeException('Could not prepare release manager statement.');
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
}
private function insertId(): int
{
global $db;
return (int)$db->insert_id();
}
private static function headerValue(array $headers, string $name): string
{
foreach ($headers as $key => $value) {
if (strcasecmp((string)$key, $name) === 0) {
return trim((string)$value);
}
}
$serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
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));
$slug = preg_replace('/[^a-z0-9_-]/', '-', $slug) ?? '';
$slug = trim(preg_replace('/-+/', '-', $slug) ?? '', '-');
return substr($slug, 0, 64);
}
private static function safeIdentifier(string $value, int $maxLength): string
{
$value = trim($value);
$value = preg_replace('/[^a-zA-Z0-9_.:-]/', '', $value) ?? '';
return substr($value, 0, max(1, $maxLength));
}
private static function isSensitiveKey(string $key): bool
{
return preg_match('/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i', $key) === 1;
}
private static function jsonEncode(mixed $value): string
{
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Could not encode release manager JSON payload.');
}
return $json;
}
private static function jsonDecode(mixed $value): array
{
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
}