Enhance Coolify API deployment with improved gateway route handling and extensive test coverage. Add new methods for service updates and ensure Composer vendor sanity checks in PHP container.

This commit is contained in:
Jeppe Bundgaard
2026-05-20 16:45:50 +02:00
parent 5ea12f5342
commit c5a1271798
16 changed files with 1477 additions and 68 deletions
+3 -2
View File
@@ -46,7 +46,8 @@ COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
# Runtime bootstrap: lightweight entrypoint to ensure Composer deps exist when app is bind-mounted
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh
# Install PHP dependencies through Composer (only where composer.json exists)
# Main app dependencies
@@ -72,4 +73,4 @@ EXPOSE 80 443
ENTRYPOINT ["docker-entrypoint.sh"]
# Start services when no command is provided (docker-compose overrides this with ["php-fpm"])
CMD ["php-fpm"]
CMD ["php-fpm"]
File diff suppressed because it is too large Load Diff
@@ -57,14 +57,42 @@ class hetzner_cloud_client
]);
}
public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort): array
public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_service', [
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_service', self::servicePayload(
$protocol,
$listenPort,
$destinationPort,
$options
));
}
public function updateService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/update_service', self::servicePayload(
$protocol,
$listenPort,
$destinationPort,
$options
));
}
private static function servicePayload(string $protocol, int $listenPort, int $destinationPort, array $options): array
{
$payload = [
'protocol' => strtolower($protocol),
'listen_port' => $listenPort,
'destination_port' => $destinationPort,
'proxyprotocol' => false,
]);
];
foreach (['health_check', 'http'] as $key) {
if (isset($options[$key]) && is_array($options[$key])) {
$payload[$key] = $options[$key];
}
}
return $payload;
}
private function request(string $method, string $path, ?array $payload = null): array
+137 -18
View File
@@ -14,6 +14,7 @@ class release_manager
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 DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
private const SUBJECT_TYPES = ['user', 'subuser', 'customer'];
private const CAPTURE_LEVELS = ['metadata', 'full_redacted', 'full'];
private const MODULE_KEYS = [
@@ -2703,7 +2704,7 @@ class release_manager
'urls' => [
[
'name' => (string)($target['app'] ?? 'release'),
'url' => $publicUrl,
'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)),
],
],
'force_domain_override' => true,
@@ -2770,7 +2771,7 @@ class release_manager
}
if ($publicUrl !== null) {
$payload['domains'] = $publicUrl;
$payload['domains'] = self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context));
$payload['is_force_https_enabled'] = $this->toBool($context['coolify_enable_ssl'] ?? false);
}
@@ -2789,8 +2790,11 @@ class release_manager
mixed $existingLabels = null
): array
{
$decodedLabels = self::decodeCoolifyLabels($existingLabels);
$routePort = $this->releaseCoolifyProxyPort($target, $context)
?? self::coolifyLabelFirstServicePort($decodedLabels, $resourceUuid);
$payload = [
'domains' => $publicUrl,
'domains' => self::coolifyProxyUrl($publicUrl, $routePort),
'is_force_https_enabled' => true,
'force_domain_override' => true,
];
@@ -2798,11 +2802,12 @@ class release_manager
$labels = self::releaseCoolifyApplicationLabels(
$publicUrl,
$resourceUuid,
self::firstInteger($this->releaseCoolifyPortsExposes($target, $context)) ?? 80
$routePort,
self::gatewayRouteDefaultCertResolver($publicUrl)
);
if ($labels !== []) {
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
self::decodeCoolifyLabels($existingLabels),
$decodedLabels,
$labels
)));
}
@@ -2831,7 +2836,12 @@ class release_manager
return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== '');
}
private static function releaseCoolifyApplicationLabels(string $publicUrl, string $resourceUuid, ?int $port = null): array
private static function releaseCoolifyApplicationLabels(
string $publicUrl,
string $resourceUuid,
?int $port = null,
?string $certResolver = null
): array
{
$resourceUuid = self::coolifyRouteLabelId($resourceUuid);
if ($resourceUuid === '') {
@@ -2851,7 +2861,8 @@ class release_manager
$path = '/' . $path;
}
$routePort = $port ?? self::firstInteger($parts['port'] ?? null) ?? 80;
$routePort = $port ?? self::firstInteger($parts['port'] ?? null);
$certResolver = trim((string)($certResolver ?? ''));
$httpLabel = 'http-0-' . $resourceUuid;
$httpsLabel = 'https-0-' . $resourceUuid;
$labels = [
@@ -2863,8 +2874,10 @@ class release_manager
if ($scheme === 'https') {
$labels[] = "traefik.http.routers.{$httpsLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
$labels[] = "traefik.http.routers.{$httpsLabel}.entryPoints=https";
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
if ($routePort !== null) {
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
}
if ($path !== '/') {
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip";
@@ -2872,18 +2885,24 @@ class release_manager
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
}
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver=letsencrypt";
if ($certResolver !== '') {
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
}
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.domains[0].main={$host}";
$labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
$labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http";
$labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
$labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
if ($routePort !== null) {
$labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
$labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
}
$labels[] = "traefik.http.routers.{$httpLabel}.middlewares=redirect-to-https";
} else {
$labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
$labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http";
$labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
$labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
if ($routePort !== null) {
$labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
$labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
}
if ($path !== '/') {
$labels[] = "traefik.http.middlewares.{$httpLabel}-stripprefix.stripprefix.prefixes={$path}";
$labels[] = "traefik.http.routers.{$httpLabel}.middlewares={$httpLabel}-stripprefix,gzip";
@@ -2910,6 +2929,53 @@ class release_manager
return array_values($merged);
}
private static function coolifyLabelFirstServicePort(array $labels, string $resourceUuid): ?int
{
$resourceUuid = self::coolifyRouteLabelId($resourceUuid);
$fallback = null;
foreach ($labels as $label) {
if (preg_match('/^traefik\.http\.services\.([^=]+)\.loadbalancer\.server\.port=(\d+)$/', trim((string)$label), $matches) !== 1) {
continue;
}
$port = (int)$matches[2];
if ($port <= 0) {
continue;
}
if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) {
return $port;
}
$fallback ??= $port;
}
return $fallback;
}
private static function coolifyLabelCertResolver(array $labels, string $resourceUuid): ?string
{
$resourceUuid = self::coolifyRouteLabelId($resourceUuid);
$fallback = null;
foreach ($labels as $label) {
if (preg_match('/^traefik\.http\.routers\.([^=]+)\.tls\.certresolver=([A-Za-z0-9_.-]+)$/', trim((string)$label), $matches) !== 1) {
continue;
}
$resolver = trim((string)$matches[2]);
if ($resolver === '') {
continue;
}
if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) {
return $resolver;
}
$fallback ??= $resolver;
}
return $fallback;
}
private static function gatewayRouteDefaultCertResolver(string $publicUrl): string
{
return 'letsencrypt';
}
private static function decodeCoolifyLabels(mixed $labels): array
{
if (!is_scalar($labels)) {
@@ -2970,6 +3036,25 @@ class release_manager
return $integer > 0 ? $integer : null;
}
private static function coolifyProxyUrl(string $publicUrl, ?int $port): string
{
if ($port === null || $port <= 0) {
return $publicUrl;
}
$parts = parse_url($publicUrl);
if (!is_array($parts) || trim((string)($parts['host'] ?? '')) === '' || isset($parts['port'])) {
return $publicUrl;
}
$scheme = trim((string)($parts['scheme'] ?? 'https')) ?: 'https';
$host = trim((string)$parts['host']);
$path = (string)($parts['path'] ?? '');
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
$fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
return "{$scheme}://{$host}:{$port}{$path}{$query}{$fragment}";
}
private function releaseCoolifyServicePayload(array $target, array $context, array $instance): array
{
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
@@ -3016,7 +3101,7 @@ class release_manager
$payload['urls'] = [
[
'name' => (string)($target['app'] ?? 'release'),
'url' => $publicUrl,
'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)),
],
];
}
@@ -3189,6 +3274,30 @@ class release_manager
return self::DEFAULT_COOLIFY_APPLICATION_PORT;
}
private function releaseCoolifyProxyPort(array $target, array $context): ?int
{
foreach ([
'coolify_ports_exposes',
'ports_exposes',
'coolify_exposed_port',
'exposed_port',
'coolify_port',
'port',
] as $key) {
$port = self::firstInteger($context[$key] ?? null);
if ($port !== null) {
return $port;
}
}
$app = strtolower(trim((string)($target['app'] ?? '')));
if ($app !== 'api') {
return null;
}
return self::firstInteger($this->releaseCoolifyPortsExposes($target, $context));
}
private function releaseCoolifyGitCommitSha(array $target, array $context): string
{
foreach ([
@@ -3217,10 +3326,16 @@ class release_manager
private function releaseCoolifyApplicationDefaultFields(array $target, array $context): array
{
if (strtolower(trim((string)($target['app'] ?? ''))) !== 'frontend') {
return [];
$app = strtolower(trim((string)($target['app'] ?? '')));
$buildPack = $this->releaseCoolifyBuildPack($target, $context);
if ($app === 'api' && $buildPack === 'dockerfile') {
return [
'dockerfile_location' => self::DEFAULT_COOLIFY_API_DOCKERFILE,
];
}
if ($this->releaseCoolifyBuildPack($target, $context) !== 'static') {
if ($app !== 'frontend' || $buildPack !== 'static') {
return [];
}
@@ -3417,6 +3532,10 @@ class release_manager
return $baseUrl;
}
if ($this->toBool($context['gateway_route_autoprovision'] ?? false)) {
return $baseUrl;
}
$path = trim((string)($parts['path'] ?? ''), '/');
if ($path !== '') {
return $baseUrl;
+4
View File
@@ -69,6 +69,10 @@
"modules/",
"routes/",
"statistics/"
],
"exclude-from-classmap": [
"modules/*/vendor/",
"modules/*/vendor/**"
]
},
"config": {
+3 -3
View File
@@ -7530,7 +7530,7 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
@@ -7539,6 +7539,6 @@
"ext-curl": "*",
"ext-json": "*"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"
"platform-dev": {},
"plugin-api-version": "2.9.0"
}
@@ -19,5 +19,8 @@
"setasign/fpdi": "^2.6",
"setasign/fpdf": "^1.8",
"ext-mysqli": "*"
},
"config": {
"secure-http": false
}
}
+7 -5
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5c2ab021deb58020ce7d1f7f06e14f19",
"content-hash": "d6a6015f0fa919d0d8fd2b111e901572",
"packages": [
{
"name": "dompdf/dompdf",
@@ -1366,10 +1366,12 @@
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.3.0"
"platform": {
"ext-mysqli": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}
+33
View File
@@ -11074,6 +11074,39 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/superuser/coolify/load-balancer/api/deploy:
post:
tags:
- Superuser
summary: Deploy the latest Coolify API code for the public gateway host
operationId: deploySuperuserCoolifyGatewayApiCode
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
dry_run:
type: boolean
default: true
enforce:
type: boolean
default: false
deploy_routes:
type: boolean
default: true
responses:
'200':
description: Gateway API code deployment result returned
content:
application/json:
schema:
$ref: '#/components/schemas/SuperuserCoolifyGatewayRouteDeployResponse'
'409': { $ref: '#/components/responses/Conflict' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/superuser/coolify/gateways:
get:
tags:
@@ -73,6 +73,30 @@ class superuserCoolifyRoute
'superuser_coolify_manage' => 'Deploy the Coolify API application route for the public gateway host',
]);
$this->post('/superuser/coolify/load-balancer/api/deploy', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
try {
$parameters = $this->getParametersAsArray();
$dryRun = array_key_exists('dry_run', $parameters)
? filter_var($parameters['dry_run'], FILTER_VALIDATE_BOOLEAN)
: !filter_var($parameters['enforce'] ?? false, FILTER_VALIDATE_BOOLEAN);
$deployRoutes = array_key_exists('deploy_routes', $parameters)
? filter_var($parameters['deploy_routes'], FILTER_VALIDATE_BOOLEAN)
: true;
$result = (new coolify_manager())->deployGatewayApiCode($dryRun, $deployRoutes, $this->actorUserId());
if (($result['ok'] ?? false) !== true) {
$response->error($result, 409);
}
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_coolify_manage' => 'Deploy the latest Coolify API code for the public gateway host',
]);
$this->get('/superuser/coolify/gateways', function () {
global $response;
@@ -2,8 +2,12 @@
set -eu
tmp_dir="$(mktemp -d)"
psr_tmp_dir=""
cleanup() {
rm -rf "$tmp_dir"
if [ -n "$psr_tmp_dir" ]; then
rm -rf "$psr_tmp_dir"
fi
}
trap cleanup EXIT HUP INT TERM
@@ -40,10 +44,75 @@ AUTO_COMPOSER_INSTALL=true \
APP_DIR="$tmp_dir" \
MODULE_DIR="$tmp_dir/no-module" \
LOG_FILE="$tmp_dir/composer-install.log" \
REDIS_CONFIG_HOST= \
/usr/local/bin/docker-entrypoint.sh \
php -r "require \$argv[1]; echo class_exists('FixtureClass') ? 'autoload-ok' . PHP_EOL : 'autoload-missing' . PHP_EOL;" \
"$tmp_dir/vendor/autoload.php" >/dev/null
php -d display_errors=1 -r "require \$argv[1]; exit(class_exists('FixtureClass') ? 0 : 1);" "$tmp_dir/vendor/autoload.php"
psr_tmp_dir="$(mktemp -d)"
cat > "$psr_tmp_dir/composer.json" <<'JSON'
{
"name": "truckwash/composer-entrypoint-psr-fixture",
"require": {
"psr/http-message": "^2.0"
}
}
JSON
COMPOSER_ALLOW_SUPERUSER=1 composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader \
--no-interaction \
-d "$psr_tmp_dir" >/dev/null 2>&1
cat > "$psr_tmp_dir/corrupt-autoload.php" <<'PHP'
<?php
$dir = $argv[1];
$replacements = [
"__DIR__ . '/..' . '/psr/http-message/src/StreamInterface.php'" => "__DIR__ . '/../..' . '/modules/washcertificates/vendor/psr/http-message/src/StreamInterface.php'",
"__DIR__ . '/..' . '/psr/http-message/src/UriInterface.php'" => "__DIR__ . '/../..' . '/modules/washcertificates/vendor/psr/http-message/src/UriInterface.php'",
"\$vendorDir . '/psr/http-message/src/StreamInterface.php'" => "\$vendorDir . '/../modules/washcertificates/vendor/psr/http-message/src/StreamInterface.php'",
"\$vendorDir . '/psr/http-message/src/UriInterface.php'" => "\$vendorDir . '/../modules/washcertificates/vendor/psr/http-message/src/UriInterface.php'",
];
foreach (['autoload_static.php', 'autoload_classmap.php'] as $file) {
$path = $dir . '/vendor/composer/' . $file;
$contents = file_get_contents($path);
if ($contents === false) {
fwrite(STDERR, "Unable to read $path\n");
exit(1);
}
$updated = str_replace(array_keys($replacements), array_values($replacements), $contents);
if ($updated === $contents) {
fwrite(STDERR, "Fixture did not corrupt $path\n");
exit(1);
}
file_put_contents($path, $updated);
}
PHP
php "$psr_tmp_dir/corrupt-autoload.php" "$psr_tmp_dir"
if php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') ? 0 : 1);" "$psr_tmp_dir/vendor/autoload.php" >/dev/null 2>&1; then
echo "fixture failed to corrupt psr/http-message autoload map" >&2
exit 1
fi
AUTO_COMPOSER_INSTALL=true \
APP_DIR="$psr_tmp_dir" \
MODULE_DIR="$psr_tmp_dir/no-module" \
LOG_FILE="$psr_tmp_dir/composer-install.log" \
REDIS_CONFIG_HOST= \
/usr/local/bin/docker-entrypoint.sh \
php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') && interface_exists('Psr\\\\Http\\\\Message\\\\StreamInterface') ? 0 : 1);" \
"$psr_tmp_dir/vendor/autoload.php"
php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') && interface_exists('Psr\\\\Http\\\\Message\\\\StreamInterface') ? 0 : 1);" "$psr_tmp_dir/vendor/autoload.php"
echo "composer-entrypoint-autoload-recovery-ok"
@@ -6,6 +6,69 @@ app_require('classes/coolify_manager.php');
use classes\coolify_api_client;
use classes\coolify_manager;
class CoolifyManagerHetznerTargetSetFake
{
public array $targets;
public function __construct(array $targets)
{
$this->targets = array_values($targets);
}
public function getLoadBalancer(int|string $id): array
{
return [
'id' => $id,
'targets' => array_map(
static fn(string $ip): array => ['type' => 'ip', 'ip' => ['ip' => $ip]],
$this->targets
),
'services' => [],
];
}
public function addIpTarget(int|string $loadBalancerId, string $ip): array
{
if (!in_array($ip, $this->targets, true)) {
$this->targets[] = $ip;
}
return [];
}
public function removeIpTarget(int|string $loadBalancerId, string $ip): array
{
$this->targets = array_values(array_filter($this->targets, static fn(string $target): bool => $target !== $ip));
return [];
}
public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array
{
return [];
}
public function updateService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array
{
return [];
}
}
function coolifyManagerTestLoadBalancerService(string $protocol, int $listenPort, int $destinationPort): array
{
return [
'protocol' => $protocol,
'listen_port' => $listenPort,
'destination_port' => $destinationPort,
'proxyprotocol' => false,
'health_check' => [
'protocol' => 'tcp',
'port' => $listenPort,
'interval' => 15,
'timeout' => 10,
'retries' => 3,
],
];
}
it('normalizes Coolify API base URLs to the v1 API root', function (): void {
expect(coolify_api_client::normalizeBaseUrl('https://coolify.example.com'))->toBe('https://coolify.example.com/api/v1');
expect(coolify_api_client::normalizeBaseUrl('https://coolify.example.com/api/v1'))->toBe('https://coolify.example.com/api/v1');
@@ -133,7 +196,7 @@ it('plans Hetzner load balancer target and service drift without mutating state'
['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']],
],
'services' => [
['protocol' => 'http', 'listen_port' => 80, 'destination_port' => 80, 'proxyprotocol' => false],
coolifyManagerTestLoadBalancerService('http', 80, 80),
],
], [
['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true],
@@ -146,13 +209,66 @@ it('plans Hetzner load balancer target and service drift without mutating state'
->and($plan['missing_targets'])->toContain('65.21.214.30')
->and($actionTypes)->toContain('add_target')
->and($actionTypes)->toContain('add_service')
->and($plan['missing_services'])->toContain([
->and($plan['missing_services'][0])->toMatchArray([
'protocol' => 'tcp',
'listen_port' => 443,
'destination_port' => 443,
]);
});
it('plans Hetzner load balancer service health check drift updates', function (): void {
$manager = new coolify_manager();
$method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile');
$method->setAccessible(true);
$plan = $method->invoke($manager, [
'targets' => [
['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']],
],
'services' => [
[
'protocol' => 'http',
'listen_port' => 80,
'destination_port' => 80,
'proxyprotocol' => false,
'health_check' => [
'protocol' => 'http',
'port' => 80,
'interval' => 15,
'timeout' => 10,
'retries' => 3,
'http' => [
'domain' => '',
'path' => '/',
'response' => '',
'status_codes' => ['2??', '3??'],
'tls' => false,
],
],
],
coolifyManagerTestLoadBalancerService('tcp', 443, 443),
],
], [
['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true],
]);
expect($plan['actions'])->toHaveCount(1)
->and($plan['actions'][0])->toMatchArray([
'type' => 'update_service',
'reason' => 'health_check_drift',
'protocol' => 'http',
'listen_port' => 80,
'destination_port' => 80,
'health_check' => [
'protocol' => 'tcp',
'port' => 80,
'interval' => 15,
'timeout' => 10,
'retries' => 3,
],
]);
});
it('does not plan removal of the last Hetzner load balancer target', function (): void {
$manager = new coolify_manager();
$method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile');
@@ -163,8 +279,8 @@ it('does not plan removal of the last Hetzner load balancer target', function ()
['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']],
],
'services' => [
['protocol' => 'http', 'listen_port' => 80, 'destination_port' => 80, 'proxyprotocol' => false],
['protocol' => 'tcp', 'listen_port' => 443, 'destination_port' => 443, 'proxyprotocol' => false],
coolifyManagerTestLoadBalancerService('http', 80, 80),
coolifyManagerTestLoadBalancerService('tcp', 443, 443),
],
], [
['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => false],
@@ -186,8 +302,8 @@ it('plans removal only for disabled or deleted Hetzner load balancer targets', f
['type' => 'ip', 'ip' => ['ip' => '65.21.214.30']],
],
'services' => [
['protocol' => 'http', 'listen_port' => 80, 'destination_port' => 80, 'proxyprotocol' => false],
['protocol' => 'tcp', 'listen_port' => 443, 'destination_port' => 443, 'proxyprotocol' => false],
coolifyManagerTestLoadBalancerService('http', 80, 80),
coolifyManagerTestLoadBalancerService('tcp', 443, 443),
],
], [
['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true],
@@ -222,6 +338,10 @@ it('builds gateway API auto-provision context for connected Coolify servers', fu
'channel_slug' => 'internal',
'deploy_context_json' => json_encode([
'coolify_project_uuid' => 'project-internal',
'coolify_base_directory' => 'services/nginx/app',
'coolify_dockerfile_location' => 'services/php/Dockerfile',
'coolify_ports_exposes' => '9000',
'coolify_start_command' => 'php-fpm',
'coolify_destination_uuid' => 'source-destination',
'coolify_git_commit_sha' => 'source-commit',
'coolify_enable_ssl' => false,
@@ -233,6 +353,10 @@ it('builds gateway API auto-provision context for connected Coolify servers', fu
'coolify_auto_create' => true,
'coolify_enable_ssl' => true,
'coolify_deploy_now' => true,
'coolify_build_pack' => 'dockerfile',
'coolify_dockerfile_location' => '/Dockerfile.coolify-api',
'coolify_ports_exposes' => '80',
'coolify_port' => '80',
'coolify_domain' => 'api-v2.truckwash.io',
'coolify_public_url' => 'https://api-v2.truckwash.io',
'coolify_server_uuid' => 'server-node1',
@@ -245,6 +369,8 @@ it('builds gateway API auto-provision context for connected Coolify servers', fu
'gateway_route_target_ip' => '94.130.142.41',
]);
expect($context)->not->toHaveKey('coolify_git_commit_sha');
expect($context)->not->toHaveKey('coolify_base_directory');
expect($context)->not->toHaveKey('coolify_start_command');
});
it('adds explicit Coolify application route labels for gateway API domains', function (): void {
@@ -254,10 +380,12 @@ it('adds explicit Coolify application route labels for gateway API domains', fun
$payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [
'custom.keep=true',
'traefik.http.routers.https-0-api-app-uuid.entryPoints=old',
'traefik.http.routers.https-0-api-app-uuid.tls.certresolver=dns-cloudflare',
'traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=9090',
])));
$labels = explode("\n", base64_decode($payload['custom_labels'], true));
expect($payload['domains'])->toBe('https://api-v2.truckwash.io')
expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080')
->and($payload['is_force_https_enabled'])->toBeTrue()
->and($payload['force_domain_override'])->toBeTrue()
->and($labels)->toContain('custom.keep=true')
@@ -268,6 +396,81 @@ it('adds explicit Coolify application route labels for gateway API domains', fun
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
});
it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void {
$manager = new coolify_manager();
$method = new ReflectionMethod(coolify_manager::class, 'setLoadBalancerIpTargets');
$method->setAccessible(true);
$client = new CoolifyManagerHetznerTargetSetFake([
'94.130.142.41',
'65.21.214.30',
'23.88.23.183',
]);
$method->invoke($manager, $client, '6366569', ['65.21.214.30']);
$isolated = $client->targets;
sort($isolated);
$method->invoke($manager, $client, '6366569', [
'94.130.142.41',
'65.21.214.30',
'23.88.23.183',
]);
$restored = $client->targets;
sort($restored);
expect($isolated)->toBe(['65.21.214.30'])
->and($restored)->toBe([
'23.88.23.183',
'65.21.214.30',
'94.130.142.41',
]);
});
it('requires gateway ping probes to return the API ping contract', function (): void {
$method = new ReflectionMethod(coolify_manager::class, 'gatewayProbePingContract');
$method->setAccessible(true);
expect($method->invoke(null, json_encode([
'success' => true,
'data' => ['message' => 'pong'],
])))->toMatchArray(['ok' => true, 'message' => 'pong']);
expect($method->invoke(null, '<b>Fatal error</b>'))->toMatchArray([
'ok' => false,
'reason' => 'invalid_json',
]);
});
it('returns structured errors for failed gateway certificate bootstrap and verification', function (): void {
$method = new ReflectionMethod(coolify_manager::class, 'gatewayRouteHealthErrors');
$method->setAccessible(true);
$errors = $method->invoke(null, [
'ok' => false,
'reason' => 'load_balancer_enforce_required',
'results' => [
['target_ip' => '94.130.142.41', 'ok' => false],
['target_ip' => '65.21.214.30', 'ok' => true],
['target_ip' => '23.88.23.183', 'ok' => false],
],
], [
'ok' => false,
'failed_target_ips' => ['94.130.142.41', '23.88.23.183'],
'results' => [],
]);
expect($errors)->toHaveCount(2)
->and($errors[0])->toMatchArray([
'type' => 'certificate_bootstrap_failed',
'reason' => 'load_balancer_enforce_required',
'failed_target_ips' => ['94.130.142.41', '23.88.23.183'],
])
->and($errors[1])->toMatchArray([
'type' => 'gateway_route_verification_failed',
'failed_target_ips' => ['94.130.142.41', '23.88.23.183'],
]);
});
it('defines Coolify schema, route permissions, and replication integration hooks', function (): void {
$schema = file_get_contents(app_path('classes/coolify_schema_bootstrap.php'));
$manager = file_get_contents(app_path('classes/coolify_manager.php'));
@@ -298,6 +501,7 @@ it('defines Coolify schema, route permissions, and replication integration hooks
expect($route)->toContain('/superuser/coolify/load-balancer');
expect($route)->toContain('/superuser/coolify/load-balancer/reconcile');
expect($route)->toContain('/superuser/coolify/load-balancer/routes/deploy');
expect($route)->toContain('/superuser/coolify/load-balancer/api/deploy');
expect($route)->toContain('/superuser/coolify/gateways');
expect($route)->toContain('/superuser/coolify/gateways/{id}/test');
expect($route)->toContain('/superuser/coolify/instances/{id}/test');
@@ -364,6 +568,9 @@ it('defines Coolify schema, route permissions, and replication integration hooks
expect($manager)->toContain('loadBalancerSummary');
expect($manager)->toContain('reconcileLoadBalancer');
expect($manager)->toContain('deployGatewayApplicationRoutes');
expect($manager)->toContain('deployGatewayApiCode');
expect($manager)->toContain('gateway_api_code_deploy');
expect($manager)->toContain('deploy_gateway_route_after_code');
expect($manager)->toContain('loadBalancerReleaseApiTargets');
expect($manager)->toContain('provisionMissingGatewayApiTargets');
expect($manager)->toContain('provision_gateway_api_target');
@@ -371,6 +578,13 @@ it('defines Coolify schema, route permissions, and replication integration hooks
expect($manager)->toContain('upsertDeploymentTarget');
expect($manager)->toContain('startDeployment');
expect($manager)->toContain('verifyGatewayRoutes');
expect($manager)->toContain('bootstrapGatewayCertificates');
expect($manager)->toContain('setLoadBalancerIpTargets');
expect($manager)->toContain('probeGatewayPublicHost');
expect($manager)->toContain('GATEWAY_CERT_BOOTSTRAP_ATTEMPTS');
expect($manager)->toContain('certificate_bootstrap');
expect($manager)->toContain('certificate_bootstrap_failed');
expect($manager)->toContain('gateway_route_verification_failed');
expect($manager)->toContain('recordGatewayProbe');
expect($manager)->toContain("Gateway route and Let's Encrypt certificate verification is still failing");
expect($manager)->toContain('CURLOPT_SSL_VERIFYHOST, 2');
@@ -378,6 +592,8 @@ it('defines Coolify schema, route permissions, and replication integration hooks
expect($manager)->toContain('CURLOPT_CERTINFO');
expect($manager)->toContain('CURLINFO_SSL_VERIFYRESULT');
expect($manager)->toContain("Gateway TLS certificate was not issued by Let's Encrypt.");
expect($manager)->toContain('gatewayProbePingContract');
expect($manager)->toContain('Gateway ping response did not match the expected API contract.');
expect($manager)->not->toContain('CURLOPT_SSL_VERIFYHOST, 0');
expect($manager)->not->toContain('CURLOPT_SSL_VERIFYPEER, false');
expect($manager)->toContain('information_schema.tables');
@@ -388,6 +604,9 @@ it('defines Coolify schema, route permissions, and replication integration hooks
expect($manager)->toContain('REQUIRED_LOAD_BALANCER_SERVICES');
expect($manager)->toContain('skip_remove_target');
$composer = json_decode((string)file_get_contents(app_path('composer.json')), true);
expect($composer['autoload']['exclude-from-classmap'] ?? [])->toContain('modules/*/vendor/');
$client = file_get_contents(app_path('classes/coolify_api_client.php'));
expect($client)->toContain("request('GET', '/health', null, false)");
expect($client)->toContain('/github-apps');
@@ -413,6 +632,7 @@ it('defines Coolify schema, route permissions, and replication integration hooks
expect($openapi)->toContain('operationId: getSuperuserCoolifyLoadBalancer');
expect($openapi)->toContain('operationId: reconcileSuperuserCoolifyLoadBalancer');
expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayRoutes');
expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayApiCode');
expect($openapi)->toContain('operationId: listSuperuserCoolifyGateways');
expect($openapi)->toContain('operationId: testSuperuserCoolifyGateway');
expect($openapi)->toContain('operationId: discoverSuperuserCoolifyInstancePlacement');
@@ -200,6 +200,38 @@ it('creates Coolify GitHub App application payloads so pulls use the app token',
expect($payload)->not->toHaveKey('docker_compose_raw');
});
it('uses the self-contained Coolify API Dockerfile for API applications', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'api',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
'auto_deploy' => 1,
], [
'coolify_service_name' => 'release-internal-api-node3-truckwash-io',
'coolify_project_uuid' => 'project-internal',
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
'coolify_deploy_now' => true,
'coolify_public_url' => 'https://api-v2.truckwash.io',
'coolify_enable_ssl' => true,
'gateway_route_autoprovision' => true,
], [
'default_environment_name' => 'production',
'default_server_uuid' => 'server-node3',
]);
expect($payload['build_pack'])->toBe('dockerfile');
expect($payload['ports_exposes'])->toBe('80');
expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api');
expect($payload['domains'])->toBe('https://api-v2.truckwash.io:80');
expect($payload)->not->toHaveKey('publish_directory');
expect($payload)->not->toHaveKey('is_static');
});
it('builds explicit Coolify application route labels for release API targets', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload');
@@ -215,7 +247,7 @@ it('builds explicit Coolify application route labels for release API targets', f
], 'https://api-v2.truckwash.io', 'api-app-uuid', base64_encode('custom.keep=true'));
$labels = explode("\n", base64_decode($payload['custom_labels'], true));
expect($payload['domains'])->toBe('https://api-v2.truckwash.io')
expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080')
->and($payload['is_force_https_enabled'])->toBeTrue()
->and($payload['force_domain_override'])->toBeTrue()
->and($labels)->toContain('custom.keep=true')
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
it('checks PSR HTTP message interfaces before trusting a Composer vendor tree', function (): void {
$entrypointPath = '/usr/local/bin/docker-entrypoint.sh';
if (!is_file($entrypointPath)) {
$this->markTestSkipped('Docker entrypoint is only available inside the PHP container.');
}
$entrypoint = (string)file_get_contents($entrypointPath);
expect($entrypoint)->toContain('http_message_sanity_ok')
->and($entrypoint)->toContain('composer_lock_has_package "$dir" "psr/http-message"')
->and($entrypoint)->toContain('UriInterface.php')
->and($entrypoint)->toContain('StreamInterface.php')
->and($entrypoint)->toContain('interface_exists("Psr\\\\Http\\\\Message\\\\UriInterface")')
->and($entrypoint)->toContain('interface_exists("Psr\\\\Http\\\\Message\\\\StreamInterface")')
->and($entrypoint)->toContain('if ! http_message_sanity_ok "$dir"; then');
});
+2 -1
View File
@@ -73,7 +73,8 @@ WORKDIR /var/www/html
# Copy and enable entrypoint that installs Composer deps on first run
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port 9000
EXPOSE 9000
+57
View File
@@ -36,6 +36,10 @@ vendor_sanity_ok() {
return 1
fi
if ! http_message_sanity_ok "$dir"; then
return 1
fi
if [ -f "$aws_s3_api_file" ] && ! php -l "$aws_s3_api_file" >/dev/null 2>&1; then
log "Vendor sanity check failed for $aws_s3_api_file"
return 1
@@ -44,6 +48,45 @@ vendor_sanity_ok() {
return 0
}
composer_lock_has_package() {
dir="$1"
package="$2"
if [ ! -f "$dir/composer.lock" ]; then
return 1
fi
grep -q "\"name\": \"$package\"" "$dir/composer.lock"
}
http_message_sanity_ok() {
dir="$1"
autoload_file="$dir/vendor/autoload.php"
uri_file="$dir/vendor/psr/http-message/src/UriInterface.php"
stream_file="$dir/vendor/psr/http-message/src/StreamInterface.php"
if ! composer_lock_has_package "$dir" "psr/http-message"; then
return 0
fi
if [ ! -f "$uri_file" ]; then
log "Vendor sanity check failed: missing $uri_file"
return 1
fi
if [ ! -f "$stream_file" ]; then
log "Vendor sanity check failed: missing $stream_file"
return 1
fi
if ! php -d display_errors=1 -r 'require $argv[1]; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);' "$autoload_file" >/dev/null 2>&1; then
log "Vendor sanity check failed: psr/http-message interfaces do not autoload in $dir"
return 1
fi
return 0
}
wait_for_redis() {
db_target="${CONFIG_DB_TARGET:-live}"
if [ "$db_target" = "debug" ]; then
@@ -130,6 +173,19 @@ install_if_needed() {
fi
}
refresh_root_autoload() {
if [ -f "$APP_DIR/composer.json" ] && [ -f "$APP_DIR/vendor/autoload.php" ]; then
log "Refreshing root Composer autoload after module dependency checks ..."
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
if ! COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload \
--optimize --no-interaction \
-d "$APP_DIR" 2>&1 | tee -a "$LOG_FILE"; then
log "ERROR: composer dump-autoload failed in $APP_DIR. See $LOG_FILE"
exit 1
fi
fi
}
if [ "$AUTO_COMPOSER_INSTALL" = "true" ]; then
if ! wait_for_file "$APP_DIR/composer.json" 120; then
log "WARNING: $APP_DIR/composer.json not found after waiting - skipping auto-install"
@@ -139,6 +195,7 @@ if [ "$AUTO_COMPOSER_INSTALL" = "true" ]; then
if [ -f "$MODULE_DIR/composer.json" ]; then
with_install_lock install_if_needed "$MODULE_DIR"
with_install_lock refresh_root_autoload
fi
else
log "AUTO_COMPOSER_INSTALL=false - skipping Composer auto-install"