5444 lines
225 KiB
PHP
5444 lines
225 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
class release_manager
|
|
{
|
|
private const APPS = ['frontend', 'api'];
|
|
private const DEFAULT_BRANCH = 'master';
|
|
private const SERVICE_SET_MODES = ['attach_existing', 'clone_existing', 'fresh_empty', 'isolated_stack'];
|
|
private const STACK_DATA_KINDS = ['database', 'redis', 'minio'];
|
|
private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod', 'beta'];
|
|
private const DEFAULT_COOLIFY_APPLICATION_PORT = '80';
|
|
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;
|
|
|
|
public static function initializeRequestContext(): array
|
|
{
|
|
$context = [
|
|
'trace_id' => bin2hex(random_bytes(16)),
|
|
'requested_channel' => '',
|
|
'frontend_version' => '',
|
|
'backend_version' => self::backendVersion(),
|
|
'request_started_at' => date('c'),
|
|
];
|
|
|
|
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
|
|
return $context;
|
|
}
|
|
|
|
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 = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? '')));
|
|
if ($value !== '') {
|
|
return self::safeIdentifier($value, 128);
|
|
}
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
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 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']);
|
|
|
|
return [
|
|
'generated_at' => date('c'),
|
|
'trace_id' => $this->requestTraceId(),
|
|
'channel' => $this->publicChannel($channel),
|
|
'versions' => $versions,
|
|
'api_base_url' => $channel['api_base_url'] ?? null,
|
|
'frontend_base_url' => $channel['frontend_base_url'] ?? null,
|
|
'availability' => $this->channelAvailability($channel),
|
|
'capture_policy' => [
|
|
'enabled' => false,
|
|
'capture_level' => 'metadata',
|
|
'all_failure_metadata' => true,
|
|
'retention_days' => (int)($channel['retention_days'] ?? 14),
|
|
],
|
|
];
|
|
}
|
|
|
|
public function runtimeForPayload(array $payload): 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,
|
|
];
|
|
|
|
$channel = $this->resolveChannel($context);
|
|
$versions = $this->currentVersionsForChannel((int)$channel['id']);
|
|
$capturePolicy = $this->capturePolicyFor($context, $channel);
|
|
|
|
return [
|
|
'generated_at' => date('c'),
|
|
'trace_id' => $this->requestTraceId(),
|
|
'channel' => $this->publicChannel($channel),
|
|
'versions' => $versions,
|
|
'api_base_url' => $channel['api_base_url'] ?? null,
|
|
'frontend_base_url' => $channel['frontend_base_url'] ?? null,
|
|
'availability' => $this->channelAvailability($channel),
|
|
'capture_policy' => $capturePolicy,
|
|
'module_keys' => self::MODULE_KEYS,
|
|
];
|
|
}
|
|
|
|
public function runtimeForCurrentPrincipal(): array
|
|
{
|
|
$this->ensureSchema();
|
|
$context = $this->currentPrincipalContext();
|
|
$channel = $this->resolveChannel($context);
|
|
|
|
return [
|
|
'generated_at' => date('c'),
|
|
'trace_id' => $this->requestTraceId(),
|
|
'channel' => $this->publicChannel($channel),
|
|
'versions' => $this->currentVersionsForChannel((int)$channel['id']),
|
|
'api_base_url' => $channel['api_base_url'] ?? null,
|
|
'frontend_base_url' => $channel['frontend_base_url'] ?? null,
|
|
'availability' => $this->channelAvailability($channel),
|
|
'capture_policy' => $this->capturePolicyFor($context, $channel),
|
|
'module_keys' => self::MODULE_KEYS,
|
|
];
|
|
}
|
|
|
|
public function summary(): array
|
|
{
|
|
$this->ensureSchema();
|
|
$this->cleanupExpiredReplayData();
|
|
|
|
return [
|
|
'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),
|
|
'timeline' => $this->timelineSummary(),
|
|
'module_health' => $this->latestModuleHealth(),
|
|
'module_keys' => self::MODULE_KEYS,
|
|
'suggestions' => $this->releaseSuggestions(),
|
|
];
|
|
}
|
|
|
|
public function suggestions(): array
|
|
{
|
|
$this->ensureSchema();
|
|
return $this->releaseSuggestions();
|
|
}
|
|
|
|
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);
|
|
$public['versions'] = $this->currentVersionsForChannel((int)$channel['id']);
|
|
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 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'] as $key) {
|
|
if (array_key_exists($key, $input)) {
|
|
$deployContext[$key] = trim((string)$input[$key]);
|
|
}
|
|
}
|
|
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 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;
|
|
$channel = $this->channelFromInputOrDefault($input, $source);
|
|
|
|
$frontendTargetId = $this->serviceSetTargetIdFromInput($input, 'frontend', $source);
|
|
$apiTargetId = $this->serviceSetTargetIdFromInput($input, 'api', $source);
|
|
$dataTargets = [];
|
|
foreach (self::STACK_DATA_KINDS as $kind) {
|
|
$dataTargets[$kind] = $this->serviceSetDataTargetIdFromInput($input, $kind, $source);
|
|
}
|
|
|
|
$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 (!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['replica_integration'] = $this->replicaProvisioningPlan($mode, $source, $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 = $this->serviceSetStatus($mode, $frontendTargetId, $apiTargetId, $dataTargets);
|
|
$stackComplete = $frontendTargetId !== null && $apiTargetId !== null && !in_array(null, $dataTargets, true);
|
|
$health = [
|
|
'status' => $status,
|
|
'stack_complete' => $stackComplete,
|
|
'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_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 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);
|
|
|
|
$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']);
|
|
$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'];
|
|
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
|
|
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
|
|
$deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null)
|
|
?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null);
|
|
$serviceSetId = (int)$bundle['service_set_id'];
|
|
|
|
$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'
|
|
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]
|
|
);
|
|
$this->execute(
|
|
"UPDATE release_deployments SET status = 'active', completed_at = COALESCE(completed_at, NOW())
|
|
WHERE bundle_id = ? AND status IN ('deployed', 'queued', 'deploying')",
|
|
'i',
|
|
[$bundleId]
|
|
);
|
|
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 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);
|
|
$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');
|
|
$deployedUrl = trim((string)($input['deployed_url'] ?? $target['health_url'] ?? '')) ?: null;
|
|
|
|
$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';
|
|
}
|
|
|
|
$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)), $deployedUrl, $status, $deploymentId]
|
|
);
|
|
$this->execute(
|
|
"UPDATE release_versions SET status = ?, deployed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE deployed_at END WHERE id = ?",
|
|
'ssi',
|
|
[$status === 'deployed' ? 'deployed' : 'deploying', $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));
|
|
}
|
|
|
|
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'];
|
|
$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->execute("UPDATE release_deployments SET status = 'active', completed_at = COALESCE(completed_at, NOW()) WHERE id = ?", 'i', [$deploymentId]);
|
|
$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, actor_user_id, active)
|
|
VALUES (?, ?, ?, ?, ?, 1)",
|
|
'iiiii',
|
|
[
|
|
$channelId,
|
|
(int)($previous['frontend_version_id'] ?? 0) ?: null,
|
|
(int)($previous['api_version_id'] ?? 0) ?: null,
|
|
(int)($previous['deployment_id'] ?? 0) ?: null,
|
|
$actorUserId,
|
|
]
|
|
);
|
|
|
|
$this->audit($channelId, (int)($previous['deployment_id'] ?? 0) ?: null, 'channel_rolled_back', $actorUserId, 'warning', [
|
|
'previous_channel_version_id' => $previous['id'] ?? null,
|
|
]);
|
|
|
|
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.');
|
|
}
|
|
|
|
$targets = $this->selectRows(
|
|
"SELECT * FROM release_deployment_targets
|
|
WHERE deleted_at IS NULL AND auto_deploy = 1 AND repository = ? AND branch = ?",
|
|
'ss',
|
|
[$repository, $branch]
|
|
);
|
|
|
|
$deployments = [];
|
|
foreach ($targets as $target) {
|
|
$deployments[] = $this->startDeployment([
|
|
'target_id' => (int)$target['id'],
|
|
'channel_id' => (int)$target['channel_id'],
|
|
'app' => (string)$target['app'],
|
|
'repository' => $repository,
|
|
'branch' => $branch,
|
|
'commit_sha' => $commitSha,
|
|
'version_label' => substr($commitSha, 0, 12),
|
|
'build_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,
|
|
'deployment_count' => count($deployments),
|
|
]);
|
|
|
|
return [
|
|
'event' => $event,
|
|
'repository' => $repository,
|
|
'branch' => $branch,
|
|
'commit_sha' => $commitSha,
|
|
'deployments' => $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 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;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'frontend' => $frontend,
|
|
'api' => $api,
|
|
'service_set' => $serviceSet,
|
|
'bundle_id' => isset($current['bundle_id']) ? (int)$current['bundle_id'] : null,
|
|
];
|
|
}
|
|
|
|
private function channelAvailability(array $channel): array
|
|
{
|
|
$missing = [];
|
|
if (trim((string)($channel['frontend_base_url'] ?? '')) === '') {
|
|
$missing[] = 'frontend_base_url';
|
|
}
|
|
if (trim((string)($channel['api_base_url'] ?? '')) === '') {
|
|
$missing[] = 'api_base_url';
|
|
}
|
|
|
|
return [
|
|
'configured' => count($missing) === 0,
|
|
'missing' => $missing,
|
|
'status' => count($missing) === 0 ? 'ready' : 'unconfigured',
|
|
];
|
|
}
|
|
|
|
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.');
|
|
}
|
|
$token = replication_secret_box::decrypt((string)$instance['api_token_secret']);
|
|
$client = new coolify_api_client((string)$instance['base_url'], $token, 20);
|
|
return $client->restartService($serviceUuid);
|
|
}
|
|
|
|
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.');
|
|
}
|
|
|
|
$token = replication_secret_box::decrypt((string)$instance['api_token_secret']);
|
|
$client = new coolify_api_client((string)$instance['base_url'], $token, 20);
|
|
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
|
|
$serviceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
|
|
$resourceType = $this->releaseCoolifyResourceType($context, $serviceUuid);
|
|
$created = 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;
|
|
$created = $client->createPrivateGithubAppApplication($this->releaseCoolifyApplicationPayload($target, $context, $instance));
|
|
$resourceType = 'application';
|
|
} else {
|
|
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.');
|
|
}
|
|
$created = $client->createService($this->releaseCoolifyServicePayload($target, $context, $instance));
|
|
$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;
|
|
$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;
|
|
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
|
|
if ($resourceType === 'application') {
|
|
$applicationUpdate = $this->releaseCoolifyApplicationUpdatePayload($target, $context);
|
|
if ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
|
|
$applicationUpdate['domains'] = $publicUrl;
|
|
$applicationUpdate['is_force_https_enabled'] = true;
|
|
$applicationUpdate['force_domain_override'] = true;
|
|
}
|
|
if ($applicationUpdate !== []) {
|
|
$update = $client->updateApplication($serviceUuid, $applicationUpdate);
|
|
}
|
|
} elseif ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
|
|
$update = $client->updateService($serviceUuid, [
|
|
'urls' => [
|
|
[
|
|
'name' => (string)($target['app'] ?? 'release'),
|
|
'url' => $publicUrl,
|
|
],
|
|
],
|
|
'force_domain_override' => true,
|
|
]);
|
|
}
|
|
|
|
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
|
|
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 ?? []),
|
|
'deployment' => self::redactPayload($deployment),
|
|
];
|
|
}
|
|
|
|
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'] = $publicUrl;
|
|
$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 releaseCoolifyApplicationUpdatePayload(array $target, array $context): array
|
|
{
|
|
$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' => $this->releaseCoolifyBuildPack($target, $context),
|
|
'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;
|
|
}
|
|
|
|
return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== '');
|
|
}
|
|
|
|
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' => $publicUrl,
|
|
],
|
|
];
|
|
}
|
|
|
|
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 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 'static';
|
|
}
|
|
return $buildPack;
|
|
}
|
|
|
|
return $app === 'api' ? '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 = $app === 'api'
|
|
? ['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 releaseCoolifyGitCommitSha(array $target, array $context): string
|
|
{
|
|
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
|
|
{
|
|
if (strtolower(trim((string)($target['app'] ?? ''))) !== 'frontend') {
|
|
return [];
|
|
}
|
|
if ($this->releaseCoolifyBuildPack($target, $context) !== '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 releaseCoolifyPublicUrl(array $target, array $context): ?string
|
|
{
|
|
$raw = trim((string)($context['coolify_public_url'] ?? $context['coolify_domain'] ?? ''));
|
|
if ($raw === '' && !$this->toBool($context['coolify_enable_ssl'] ?? false)) {
|
|
$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 'https://' . $domain;
|
|
}
|
|
$raw = preg_replace('#/(health|ping)$#i', '', rtrim($raw, '/')) ?: $raw;
|
|
if (preg_match('#^https?://#i', $raw) !== 1) {
|
|
$raw = ($this->toBool($context['coolify_enable_ssl'] ?? false) ? 'https://' : 'http://') . $raw;
|
|
}
|
|
return rtrim($raw, '/');
|
|
}
|
|
|
|
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 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'] ?? '');
|
|
if ($commitMode === 'specific') {
|
|
$commitRow = $this->githubRequest(
|
|
'GET',
|
|
'/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($rawCommitSha)
|
|
);
|
|
$commitSha = trim((string)($commitRow['sha'] ?? $rawCommitSha));
|
|
$commitUrl = (string)($commitRow['html_url'] ?? $commitUrl);
|
|
}
|
|
|
|
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_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', 'projects', 'servers', 'github_apps', '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($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 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 ($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' => $target['health_url'] ?? 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' => $input['github_access'],
|
|
'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'] ?? ''),
|
|
'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 publicVersion(?array $version): ?array
|
|
{
|
|
if (!$version || empty($version['id'])) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => (int)$version['id'],
|
|
'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'] ?? 'unknown'),
|
|
'metadata' => self::jsonDecode($version['metadata_json'] ?? null),
|
|
'created_at' => $version['created_at'] ?? null,
|
|
'deployed_at' => $version['deployed_at'] ?? null,
|
|
];
|
|
}
|
|
|
|
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
|
|
{
|
|
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' => self::jsonDecode($target['deploy_context_json'] ?? null),
|
|
'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);
|
|
|
|
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'),
|
|
'source_service_set_id' => isset($serviceSet['source_service_set_id']) ? (int)$serviceSet['source_service_set_id'] : 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' => self::jsonDecode($serviceSet['metadata_json'] ?? null),
|
|
'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);
|
|
$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,
|
|
'version' => $frontendVersionId !== null ? $this->publicVersion($this->getVersion($frontendVersionId)) : null,
|
|
'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,
|
|
'version' => $apiVersionId !== null ? $this->publicVersion($this->getVersion($apiVersionId)) : null,
|
|
'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 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;
|
|
}
|
|
|
|
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,
|
|
'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,
|
|
'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'] ?? ''),
|
|
'provider' => (string)($deployment['provider'] ?? 'coolify'),
|
|
'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 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 : [];
|
|
}
|
|
}
|