Add tests and methods for enhanced Coolify app handling, include isolated stack support and schema updates

This commit is contained in:
Jeppe Bundgaard
2026-05-19 14:45:45 +02:00
parent 00a8723347
commit cbf3c2d2b9
7 changed files with 1495 additions and 69 deletions
+85 -5
View File
@@ -246,6 +246,7 @@ class coolify_manager
if ($instanceId <= 0) {
$instanceId = $this->defaultInstanceId();
}
$isolatedStack = $this->isIsolatedStackTargetRequest($input);
$instance = $this->getInstance($instanceId);
$input = $this->applyCoolifyDeploymentDefaults($input, $instance);
$input = $this->applyCoolifyPortDefaults($kind, $input, $instance);
@@ -293,11 +294,14 @@ class coolify_manager
$targetId = $this->insertId();
$this->attachTargetToReplicationHost($kind, $replicationHostId, $targetId, $instanceId);
$this->ensureFailoverEnabled($kind);
if (!$isolatedStack) {
$this->ensureFailoverEnabled($kind);
}
$this->audit($targetId, $instanceId, $replicationHostId, 'target_created', $actorUserId, 'info', [
'kind' => $kind,
'role' => $role,
'resource_name' => $resourceName,
'isolated_stack' => $isolatedStack,
]);
$target = $this->getTarget($targetId);
@@ -424,6 +428,22 @@ class coolify_manager
$provision = null;
if ($hostId > 0) {
if ($this->targetSkipsReplicationProvisioning($target)) {
$provision = [
'ok' => true,
'skipped' => true,
'status' => 'isolated_stack_empty_data_service',
'message' => 'Isolated stack data services are intentionally not attached to production replication.',
];
return [
'ok' => true,
'reconcile' => $reconcile,
'provision' => $provision,
'target' => $this->publicTarget($this->getTarget($targetId)),
];
}
$reconcileAction = (string)($reconcile['status'] ?? $reconcile['context']['action'] ?? '');
if (in_array($reconcileAction, ['created', 'updated'], true)) {
$provision = (string)($target['kind'] ?? '') === 'minio'
@@ -1251,6 +1271,7 @@ class coolify_manager
private function composeInputFromRequest(string $kind, array $input, array $instance): array
{
$composeRole = $this->isIsolatedStackTargetRequest($input) ? 'primary' : 'replica';
$hostPort = (int)($input['host_port'] ?? $input['port'] ?? match ($kind) {
'database' => 3307,
'redis' => 6380,
@@ -1259,7 +1280,7 @@ class coolify_manager
$base = [
'kind' => $kind,
'role' => 'replica',
'role' => $composeRole,
'service_name' => $input['service_name'] ?? $input['resource_name'] ?? null,
'host_port' => $hostPort,
];
@@ -1291,6 +1312,7 @@ class coolify_manager
if ($host === '') {
throw new RuntimeException('Target host is required so replication can reach the Coolify-managed container.');
}
$isolatedStack = $this->isIsolatedStackTargetRequest($input);
$payload = [
'label' => trim((string)($input['label'] ?? $credentials['label'] ?? $template['service_name'] ?? '')),
@@ -1307,9 +1329,19 @@ class coolify_manager
$payload['replication_username'] = (string)($credentials['replication_username'] ?? $input['replication_username'] ?? 'replication');
$payload['replication_password'] = (string)($credentials['replication_password'] ?? $input['replication_password'] ?? '');
$payload['ssl_mode'] = (string)($credentials['ssl_mode'] ?? $input['ssl_mode'] ?? 'DISABLED');
$payload['options'] = ['allow_preseeded_replica' => true];
$payload['options'] = [
'allow_preseeded_replica' => !$isolatedStack,
'isolated_stack' => $isolatedStack,
'skip_replication_provisioning' => $isolatedStack,
'production_data_attached' => false,
];
} elseif ($kind === 'redis') {
$payload['database'] = (int)($credentials['database'] ?? $input['database'] ?? 0);
$payload['options'] = [
'isolated_stack' => $isolatedStack,
'skip_replication_provisioning' => $isolatedStack,
'production_data_attached' => false,
];
} else {
$payload['scheme'] = (string)($credentials['scheme'] ?? $input['scheme'] ?? 'http');
$payload['buckets'] = $credentials['buckets'] ?? $this->normalizeBuckets($input['buckets'] ?? null);
@@ -1323,6 +1355,9 @@ class coolify_manager
'console_port' => $payload['console_port'],
'replication_transfer_limit' => $payload['replication_transfer_limit'],
'space_headroom_percent' => (float)($credentials['space_headroom_percent'] ?? 20.0),
'isolated_stack' => $isolatedStack,
'skip_replication_provisioning' => $isolatedStack,
'production_data_attached' => false,
];
}
@@ -1336,7 +1371,7 @@ class coolify_manager
$credentials = $this->hostCredentials($host);
$input = is_array($options['compose_input'] ?? null) ? $options['compose_input'] : [];
$input['kind'] = $kind;
$input['role'] = 'replica';
$input['role'] = $this->targetComposeRole($target, $options);
$input['service_name'] = $target['resource_name'] ?? $target['label'] ?? null;
$input['host_port'] = (int)($host['port'] ?? $input['host_port'] ?? 0);
@@ -1456,8 +1491,10 @@ class coolify_manager
private function targetOptions(array $input, array $template, array $composeInput): array
{
return [
$isolatedStack = $this->isIsolatedStackTargetRequest($input);
$options = [
'compose_input' => $composeInput,
'compose_role' => (string)($composeInput['role'] ?? 'replica'),
'compose_service_name' => (string)($template['service_name'] ?? ''),
'engine' => (string)($template['engine'] ?? ''),
'coolify_docs' => [
@@ -1465,6 +1502,49 @@ class coolify_manager
'envs_bulk_endpoint' => '/api/v1/services/{uuid}/envs/bulk',
],
];
if ($isolatedStack) {
$options['isolated_stack'] = true;
$options['skip_replication_provisioning'] = true;
$options['production_data_attached'] = false;
}
return $options;
}
private function isIsolatedStackTargetRequest(array $input): bool
{
$options = is_array($input['options'] ?? null) ? $input['options'] : [];
return $this->toBool(
$input['isolated_stack']
?? $input['isolated_empty_service']
?? $input['skip_replication_provisioning']
?? $options['isolated_stack']
?? $options['skip_replication_provisioning']
?? false,
false
);
}
private function targetComposeRole(array $target, array $options): string
{
$composeInput = is_array($options['compose_input'] ?? null) ? $options['compose_input'] : [];
$composeRole = strtolower(trim((string)($options['compose_role'] ?? $composeInput['role'] ?? '')));
if (in_array($composeRole, ['primary', 'replica'], true)) {
return $composeRole;
}
return $this->toBool($options['isolated_stack'] ?? $options['skip_replication_provisioning'] ?? false, false)
? 'primary'
: 'replica';
}
private function targetSkipsReplicationProvisioning(array $target): bool
{
$options = self::jsonDecode($target['options_json'] ?? null);
return $this->toBool($options['skip_replication_provisioning'] ?? $options['isolated_stack'] ?? false, false);
}
private function attachTargetToReplicationHost(string $kind, int $hostId, int $targetId, int $instanceId): void