12862 lines
536 KiB
PHP
12862 lines
536 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use customers\economicCustomers;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
require_once __DIR__ . '/cors_policy.php';
|
|
|
|
class release_manager
|
|
{
|
|
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 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->channelBySlug($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)
|
|
);
|
|
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
|
|
): 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));
|
|
$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}";
|
|
}
|
|
if ($path !== '/') {
|
|
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
|
|
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip";
|
|
} else {
|
|
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
|
|
}
|
|
$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('/^\[[^\]]+\]:(\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
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
|
}
|
|
|
|
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 : [];
|
|
}
|
|
}
|