Merge remote-tracking branch 'origin/main' into pr/11528

This commit is contained in:
peaklabs-dev
2026-09-04 18:25:18 +02:00
322 changed files with 12132 additions and 1641 deletions
@@ -0,0 +1,58 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationSetting;
use Illuminate\Support\Collection;
function containerNamingJob(Application $application, int $pullRequestId = 0): array
{
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$reflection->getProperty('application')->setValue($job, $application);
$reflection->getProperty('pull_request_id')->setValue($job, $pullRequestId);
return [$job, $reflection];
}
function applicationWithContainerNaming(string $customName = 'shadowuw'): Application
{
$application = new Application;
$application->forceFill(['uuid' => 'application-uuid']);
$application->setRelation('settings', new ApplicationSetting([
'custom_internal_name' => $customName,
'is_consistent_container_name_enabled' => true,
]));
return $application;
}
it('uses the custom container name when consistent naming is enabled', function () {
$application = applicationWithContainerNaming();
[$job, $reflection] = containerNamingJob($application);
expect($reflection->getMethod('resolveContainerName')->invoke($job))->toBe('shadowuw');
});
it('adds the pull request suffix to a custom container name', function () {
$application = applicationWithContainerNaming();
[$job, $reflection] = containerNamingJob($application, 42);
expect($reflection->getMethod('resolveContainerName')->invoke($job))->toBe('shadowuw-pr-42');
});
it('includes old generated containers when cleaning up a consistent deployment', function () {
$application = applicationWithContainerNaming();
[$job, $reflection] = containerNamingJob($application);
$reflection->getProperty('container_name')->setValue($job, 'shadowuw');
$containers = new Collection([
['Names' => 'application-uuid-192238854305'],
['Names' => 'shadowuw'],
]);
expect($reflection->getMethod('containerNamesToRemove')->invoke($job, $containers)->all())
->toBe(['application-uuid-192238854305', 'shadowuw']);
});
@@ -0,0 +1,94 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
class TestableCoolifyUrlDeploymentJob extends ApplicationDeploymentJob
{
public function __construct() {}
public function execute_remote_command(...$commands): void {}
}
function coolifyVariablesForFqdn(string $fqdn, string $composeParsingVersion = '3'): string
{
$team = Team::create([
'name' => 'Coolify Url Team',
'personal_team' => false,
'show_boarding' => false,
]);
$project = Project::create([
'name' => 'Coolify Url Project',
'team_id' => $team->id,
]);
$environment = Environment::where('project_id', $project->id)->firstOrFail();
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = $server->standaloneDockers()->firstOrFail();
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'fqdn' => $fqdn,
]);
// The created hook resets this, so it has to be set afterwards.
$application->compose_parsing_version = $composeParsingVersion;
$application->save();
$job = new TestableCoolifyUrlDeploymentJob;
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
foreach ([
'application' => $application->fresh(),
'pull_request_id' => 0,
'commit' => 'HEAD',
] as $property => $value) {
$reflection->getProperty($property)->setValue($job, $value);
}
$reflection->getMethod('set_coolify_variables')->invoke($job);
return $reflection->getProperty('coolify_variables')->getValue($job);
}
it('keeps every domain intact when an application has multiple domains', function () {
$variables = coolifyVariablesForFqdn('https://a.example.com,https://b.example.com');
expect($variables)
->toContain("COOLIFY_URL='https://a.example.com,https://b.example.com'")
->toContain("COOLIFY_FQDN='a.example.com,b.example.com'");
});
it('strips the port from every domain when an application has multiple domains', function () {
$variables = coolifyVariablesForFqdn('https://a.example.com:8080,https://b.example.com:9000');
expect($variables)
->toContain("COOLIFY_URL='https://a.example.com,https://b.example.com'")
->toContain("COOLIFY_FQDN='a.example.com,b.example.com'");
});
it('keeps every domain intact on the legacy compose parsing version', function () {
$variables = coolifyVariablesForFqdn('https://a.example.com,https://b.example.com', '2');
expect($variables)
->toContain("COOLIFY_URL='a.example.com,b.example.com'")
->toContain("COOLIFY_FQDN='https://a.example.com,https://b.example.com'");
});
it('still resolves a single domain', function () {
$variables = coolifyVariablesForFqdn('https://a.example.com');
expect($variables)
->toContain("COOLIFY_URL='https://a.example.com'")
->toContain("COOLIFY_FQDN='a.example.com'");
});
@@ -64,7 +64,7 @@ function generateComposeServiceWithCustomDockerOptions(string $customDockerOptio
'server' => $server,
'mainServer' => $server,
'pull_request_id' => 0,
'container_name' => $application->uuid,
'container_name' => 'custom-internal-name',
'production_image_name' => 'example/app:latest',
'deployment_uuid' => 'deployment-uuid',
'workdir' => '/artifacts/custom-docker-options-app',
@@ -82,7 +82,8 @@ function generateComposeServiceWithCustomDockerOptions(string $customDockerOptio
it('applies an entrypoint when consistent naming and a custom internal name are configured', function () {
expect(generateComposeServiceWithCustomDockerOptions('--entrypoint "/bin/echo hello world"'))
->toHaveKey('entrypoint', '/bin/echo hello world');
->toHaveKey('entrypoint', '/bin/echo hello world')
->toHaveKey('container_name', 'custom-internal-name');
});
it('preserves custom network aliases when a static IP is configured', function () {
@@ -26,3 +26,14 @@ it('generates commit links for direct repository remotes', function (string $rep
'https://bitbucket.org/coollabsio/coolify/commits/1234567890abcdef',
],
]);
it('does not generate commit links from incomplete repository URLs', function (string $repository) {
$application = new Application;
$application->setRelation('source', null);
$application->git_repository = $repository;
expect($application->gitCommitLink('1234567890abcdef'))->toBeNull();
})->with([
'missing host' => 'https://',
'missing scheme' => 'github.com/coollabsio/coolify',
]);
@@ -0,0 +1,74 @@
<?php
use App\Jobs\CleanupHelperContainersJob;
use App\Models\Server;
use Symfony\Component\Process\Process;
it('matches helper image references without matching similarly named images', function () {
$command = (new ReflectionMethod(CleanupHelperContainersJob::class, 'helperContainersCommand'))->invoke(null);
$directory = sys_get_temp_dir().'/coolify-helper-filter-'.bin2hex(random_bytes(4));
$docker = $directory.'/docker';
$images = [
'coollabsio/coolify-helper:1.0.15',
'docker.io/coollabsio/coolify-helper:1.0.16',
'ghcr.io/coollabsio/coolify-helper@sha256:abc',
'registry.example/team/coollabsio/coolify-helper:latest',
'coollabsio/coolify:latest',
'coollabsio/coolify:4.3.12',
'coollabsio/coolify-realtime:1.0.10',
'coolify-helper:latest',
'someone/coolify-helper:1.0.16',
'evil/coollabsio/coolify-helper-copy:latest',
'coollabsio/not-coolify-helper:latest',
];
mkdir($directory);
file_put_contents($docker, "#!/bin/sh\n".implode("\n", array_map(
fn (string $image): string => 'echo '.escapeshellarg(json_encode(['Image' => $image], JSON_THROW_ON_ERROR)),
$images
))."\n");
chmod($docker, 0755);
try {
$process = new Process(['/bin/sh', '-c', $command], env: [
'PATH' => $directory.':'.getenv('PATH'),
]);
$process->mustRun();
expect(array_column(json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR), 'Image'))
->toBe(array_slice($images, 0, 4));
} finally {
unlink($docker);
rmdir($directory);
}
});
it('preserves the helper image filter for non-root servers', function () {
$command = (new ReflectionMethod(CleanupHelperContainersJob::class, 'helperContainersCommand'))->invoke(null);
$server = Mockery::mock(Server::class)->makePartial();
$server->user = 'ubuntu';
$command = parseCommandsByLineForSudo(collect([$command]), $server)[0];
$directory = sys_get_temp_dir().'/coolify-helper-sudo-filter-'.bin2hex(random_bytes(4));
$docker = $directory.'/docker';
$sudo = $directory.'/sudo';
mkdir($directory);
file_put_contents($docker, "#!/bin/sh\necho '{\"Image\":\"coollabsio/coolify-helper:1.0.15\"}'\n");
file_put_contents($sudo, "#!/bin/sh\nexec \"\$@\"\n");
chmod($docker, 0755);
chmod($sudo, 0755);
try {
$process = new Process(['/bin/sh', '-c', $command], env: [
'PATH' => $directory.':'.getenv('PATH'),
]);
$process->mustRun();
expect(array_column(json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR), 'Image'))
->toBe(['coollabsio/coolify-helper:1.0.15']);
} finally {
unlink($docker);
unlink($sudo);
rmdir($directory);
}
});
+15 -17
View File
@@ -70,12 +70,12 @@ describe('aggregateFromStrings', function () {
expect($result)->toBe('running:unknown');
});
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
test('returns exited for an exited container with a restart count', function () {
$statuses = collect(['exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
expect($result)->toBe('degraded:unhealthy');
expect($result)->toBe('exited');
});
test('returns exited for exited containers without restart count', function () {
@@ -214,12 +214,12 @@ describe('aggregateFromStrings', function () {
expect($result)->toBe('degraded:unhealthy');
});
test('prioritizes crash loop over running containers', function () {
test('returns exited when all containers are exited with restart counts', function () {
$statuses = collect(['exited', 'exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 3);
expect($result)->toBe('degraded:unhealthy');
expect($result)->toBe('exited');
});
test('prioritizes mixed state over healthy running', function () {
@@ -238,12 +238,12 @@ describe('aggregateFromStrings', function () {
expect($result)->toBe('starting:unknown');
});
test('prioritizes running over paused/exited when no starting', function () {
test('returns degraded for mixed running and exited containers', function () {
$statuses = collect(['running:healthy', 'paused', 'exited']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('running:healthy');
expect($result)->toBe('degraded:unhealthy');
});
test('prioritizes dead over paused/starting/exited', function () {
@@ -357,7 +357,7 @@ describe('aggregateFromContainers', function () {
expect($result)->toBe('degraded:unhealthy');
});
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
test('returns exited for an exited container object with a restart count', function () {
$containers = collect([
(object) [
'State' => (object) [
@@ -368,7 +368,7 @@ describe('aggregateFromContainers', function () {
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: 5);
expect($result)->toBe('degraded:unhealthy');
expect($result)->toBe('exited');
});
test('returns exited for exited containers without restart count', function () {
@@ -501,7 +501,7 @@ describe('state priority enforcement', function () {
expect($result)->toBe('degraded:unhealthy');
});
test('crash loop has third highest priority', function () {
test('mixed running and exited containers are degraded before paused or starting states', function () {
$statuses = collect([
'exited',
'running:healthy',
@@ -602,31 +602,29 @@ describe('maxRestartCount validation', function () {
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
// Zero is valid default - no crash loop detection
expect($result)->toBe('exited');
});
test('positive maxRestartCount works correctly', function () {
test('positive maxRestartCount does not override an exited state', function () {
$statuses = collect(['exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
// Positive value enables crash loop detection
expect($result)->toBe('degraded:unhealthy');
expect($result)->toBe('exited');
});
test('crash loop detection still functions after validation', function () {
test('exited state is preserved for any positive restart count', function () {
$statuses = collect(['exited']);
// Test with various positive restart counts
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 1))
->toBe('degraded:unhealthy');
->toBe('exited');
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 100))
->toBe('degraded:unhealthy');
->toBe('exited');
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 999))
->toBe('degraded:unhealthy');
->toBe('exited');
});
test('default maxRestartCount parameter works', function () {
@@ -29,6 +29,14 @@ it('ensures label parsing converts array values to strings', function () {
->toContain('$removedLabel = (string) collect($removedLabel)->first();');
});
it('falls back to the template port for service application proxy labels', function () {
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
expect($sharedFile)->toContain(
'? ($savedService->getRequiredPort() ?? $predefinedPort)'
);
});
it('verifies label parsing array check occurs before preg_match', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
@@ -27,6 +27,14 @@ it('StandaloneDocker accepts valid network names', function (string $network) {
'alphanumeric' => 'network123',
]);
it('creates standalone destinations with a bridge network', function () {
$model = new StandaloneDocker;
$model->network = 'test-network';
expect($model->networkCreateCommand())
->toBe("docker network inspect 'test-network' >/dev/null 2>&1 || docker network create --attachable 'test-network' >/dev/null");
});
it('SwarmDocker rejects network names with shell metacharacters', function (string $network) {
$model = new SwarmDocker;
$model->network = $network;
+36
View File
@@ -0,0 +1,36 @@
<?php
use App\Support\DomainPortOverrides;
it('copies the source port override to an automatically paired domain', function (string $source, string $counterpart) {
$result = DomainPortOverrides::normalize(
"$source,$counterpart",
[$source => 8080],
);
expect($result['overrides'])->toBe([
$source => 8080,
$counterpart => 8080,
]);
})->with([
'www redirect' => ['https://example.com', 'https://www.example.com'],
'non-www redirect' => ['https://www.example.com', 'https://example.com'],
]);
it('keeps an explicit override on the paired domain', function (string $source, string $counterpart) {
$result = DomainPortOverrides::normalize(
"$source,$counterpart",
[
$source => 8080,
$counterpart => 9090,
],
);
expect($result['overrides'])->toBe([
$source => 8080,
$counterpart => 9090,
]);
})->with([
'www redirect' => ['https://example.com', 'https://www.example.com'],
'non-www redirect' => ['https://www.example.com', 'https://example.com'],
]);
@@ -0,0 +1,49 @@
<?php
it('includes an Executor one-click service template', function () {
$templatePath = __DIR__.'/../../templates/compose/executor.yaml';
expect($templatePath)->toBeFile();
$compose = file_get_contents($templatePath);
expect($compose)
->toContain('ghcr.io/rhyssullivan/executor-selfhost:${EXECUTOR_VERSION:-latest}')
->toContain('SERVICE_URL_EXECUTOR_4788')
->toContain('EXECUTOR_WEB_BASE_URL=${SERVICE_URL_EXECUTOR_4788}')
->toContain('executor-data:/data')
->toContain('http://127.0.0.1:${process.env.PORT||4788}/api/health');
foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) {
$templateCatalogPath = __DIR__."/../../templates/{$templateFile}";
$templateCatalog = file_get_contents($templateCatalogPath);
if ($templateCatalog === false) {
throw new RuntimeException("Unable to read service template catalog at {$templateCatalogPath}.");
}
$templates = json_decode(
$templateCatalog,
associative: true,
flags: JSON_THROW_ON_ERROR,
);
expect($templates)->toHaveKey('executor');
expect($templates['executor']['port'] ?? null)->toBe('4788');
expect($templates['executor']['logo'] ?? null)->toBe('svgs/executor.png');
expect($templates['executor']['category'] ?? null)->toBe('development');
$generatedCompose = base64_decode($templates['executor']['compose'], strict: true);
expect($generatedCompose)
->toContain('ghcr.io/rhyssullivan/executor-selfhost:${EXECUTOR_VERSION:-latest}')
->toContain('http://127.0.0.1:${process.env.PORT||4788}/api/health')
->toContain($templateFile === 'service-templates.json'
? 'EXECUTOR_WEB_BASE_URL=${SERVICE_FQDN_EXECUTOR_4788}'
: 'EXECUTOR_WEB_BASE_URL=${SERVICE_URL_EXECUTOR_4788}');
}
});
it('ships the Executor service icon from the public path used by the service picker', function () {
expect(__DIR__.'/../../public/svgs/executor.png')->toBeFile();
});
+52
View File
@@ -232,3 +232,55 @@ describe('Caddy noindex header', function () {
)->all())->toBeEmpty();
});
});
test('fqdnLabelsForCaddy routes each portless domain to its override port', function () {
$labels = fqdnLabelsForCaddy(
network: 'testnetwork',
uuid: 'appuuid',
domains: collect(['https://one.example.com', 'https://two.example.com']),
onlyPort: 80,
is_force_https_enabled: true,
domainPortOverrides: [
'https://one.example.com' => 3000,
'https://two.example.com' => 8080,
],
)->values()->all();
expect($labels)
->toContain('caddy_0=https://one.example.com')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}')
->toContain('caddy_1=https://two.example.com')
->toContain('caddy_1.handle_path.1_reverse_proxy={{upstreams 8080}}')
->not->toContain('caddy_0=https://one.example.com:3000')
->not->toContain('caddy_1=https://two.example.com:8080');
});
test('fqdnLabelsForCaddy uses onlyPort when a portless domain has no override', function () {
$labels = fqdnLabelsForCaddy(
network: 'testnetwork',
uuid: 'appuuid',
domains: collect(['https://plain.example.com']),
onlyPort: 4000,
is_force_https_enabled: true,
domainPortOverrides: [],
)->values()->all();
expect($labels)
->toContain('caddy_0=https://plain.example.com')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 4000}}');
});
test('fqdnLabelsForCaddy keeps routing a legacy port-bearing FQDN without an override map', function () {
$labels = fqdnLabelsForCaddy(
network: 'testnetwork',
uuid: 'appuuid',
domains: collect(['https://legacy.example.com:9090']),
onlyPort: 80,
is_force_https_enabled: true,
domainPortOverrides: [],
)->values()->all();
expect($labels)
->toContain('caddy_0=https://legacy.example.com')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 9090}}');
});
@@ -0,0 +1,43 @@
<?php
it('uses Livewire navigation after deleting or converting page resources', function (string $path, array $redirects) {
$contents = file_get_contents(dirname(__DIR__, 2).'/'.$path);
foreach ($redirects as $redirect) {
expect($contents)->toContain($redirect);
}
})->with([
'S3 storage' => [
'app/Livewire/Storage/Show.php',
["redirectRoute(\$this, 'storage.index')"],
],
'database backup schedule' => [
'app/Livewire/Project/Database/BackupEdit.php',
[
"redirectRoute(\$this, 'project.service.database.backups'",
"redirectRoute(\$this, 'project.database.backup.index'",
],
],
'scheduled task' => [
'app/Livewire/Project/Shared/ScheduledTask/Show.php',
[
"redirectRoute(\$this, 'project.application.scheduled-tasks.show'",
"redirectRoute(\$this, 'project.service.scheduled-tasks.show'",
],
],
'GitHub source' => [
'app/Livewire/Source/Github/Change.php',
["redirectRoute(\$this, 'source.all')"],
],
'GitLab source' => [
'app/Livewire/Source/Gitlab/Change.php',
["redirectRoute(\$this, 'source.all')"],
],
'service resources' => [
'app/Livewire/Project/Service/Index.php',
[
"return redirectRoute(\$this, 'project.service.configuration', \$this->parameters);",
"return redirectRoute(\$this, 'project.service.configuration', \$redirectParams);",
],
],
]);
@@ -0,0 +1,196 @@
<?php
use PhpParser\Node;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\If_;
use PhpParser\Node\Stmt\Return_;
use PhpParser\Node\Stmt\TryCatch;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
/** @return list<array{path: string, method: ClassMethod}> */
function livewireSyncDataMethods(string $directory): array
{
$methods = [];
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
$parser = (new ParserFactory)->createForNewestSupportedVersion();
$finder = new NodeFinder;
foreach ($iterator as $file) {
if (! $file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$path = $file->getPathname();
$contents = file_get_contents($path);
if ($contents === false) {
continue;
}
foreach ($finder->findInstanceOf($parser->parse($contents) ?? [], ClassMethod::class) as $method) {
if (preg_match('/^sync(Data|DatabaseData|ApplicationData)/', $method->name->toString())) {
$methods[] = ['path' => $path, 'method' => $method];
}
}
}
usort($methods, fn (array $a, array $b): int => [$a['path'], $a['method']->name->toString()] <=> [$b['path'], $b['method']->name->toString()]);
return $methods;
}
function livewireMethodCallName(MethodCall $call): ?string
{
return $call->name instanceof Identifier ? $call->name->toString() : null;
}
function livewireIsWriteSyncCall(MethodCall $call, string $syncMethod): bool
{
if (livewireMethodCallName($call) !== $syncMethod) {
return false;
}
foreach ($call->args as $position => $argument) {
if (($position === 0 || $argument->name?->toString() === 'toModel') && $argument->value instanceof ConstFetch && $argument->value->name->toLowerString() === 'true') {
return true;
}
}
return false;
}
/** @param list<Node\Stmt> $statements @param list<int> $unauthorizedLines */
function livewireScanStatements(array $statements, string $syncMethod, bool $authorized, array &$unauthorizedLines): bool
{
foreach ($statements as $statement) {
$authorized = livewireScanNode($statement, $syncMethod, $authorized, $unauthorizedLines);
}
return $authorized;
}
/** @param list<int> $unauthorizedLines */
function livewireScanNode(mixed $node, string $syncMethod, bool $authorized, array &$unauthorizedLines): bool
{
if (! $node instanceof Node && ! is_array($node)) {
return $authorized;
}
if (is_array($node)) {
return livewireScanStatements($node, $syncMethod, $authorized, $unauthorizedLines);
}
if ($node instanceof TryCatch) {
$tryAuthorized = livewireScanStatements($node->stmts, $syncMethod, $authorized, $unauthorizedLines);
$allCatchesTerminate = $node->catches !== [];
foreach ($node->catches as $catch) {
livewireScanStatements($catch->stmts, $syncMethod, $authorized, $unauthorizedLines);
$allCatchesTerminate = $allCatchesTerminate && collect($catch->stmts)->contains(fn (Node\Stmt $statement): bool => $statement instanceof Return_);
}
if ($node->finally !== null) {
livewireScanStatements($node->finally->stmts, $syncMethod, $authorized, $unauthorizedLines);
}
return $allCatchesTerminate ? $tryAuthorized : $authorized;
}
if ($node instanceof If_) {
livewireScanNode($node->cond, $syncMethod, $authorized, $unauthorizedLines);
livewireScanStatements($node->stmts, $syncMethod, $authorized, $unauthorizedLines);
foreach ($node->elseifs as $elseif) {
livewireScanNode($elseif->cond, $syncMethod, $authorized, $unauthorizedLines);
livewireScanStatements($elseif->stmts, $syncMethod, $authorized, $unauthorizedLines);
}
if ($node->else !== null) {
livewireScanStatements($node->else->stmts, $syncMethod, $authorized, $unauthorizedLines);
}
return $authorized;
}
if ($node instanceof MethodCall) {
$name = livewireMethodCallName($node);
if ($name === 'authorize') {
return true;
}
if (livewireIsWriteSyncCall($node, $syncMethod) && ! $authorized) {
$unauthorizedLines[] = $node->getStartLine();
}
}
foreach ($node->getSubNodeNames() as $name) {
$authorized = livewireScanNode($node->{$name}, $syncMethod, $authorized, $unauthorizedLines);
}
return $authorized;
}
/** @return list<int> */
function livewireUnauthorizedSyncWriteLines(ClassMethod $method, string $syncMethod): array
{
$lines = [];
livewireScanStatements($method->stmts ?? [], $syncMethod, false, $lines);
return $lines;
}
function livewireMethodFromSource(string $source, string $method): ClassMethod
{
$nodes = (new ParserFactory)->createForNewestSupportedVersion()->parse($source) ?? [];
return (new NodeFinder)->findFirst($nodes, fn (Node $node): bool => $node instanceof ClassMethod && $node->name->toString() === $method);
}
it('keeps every Livewire syncData helper private', function () {
$violations = [];
foreach (livewireSyncDataMethods(dirname(__DIR__, 2).'/app/Livewire') as $syncMethod) {
if (! $syncMethod['method']->isPrivate()) {
$relative = str_replace(dirname(__DIR__, 2).'/', '', $syncMethod['path']);
$violations[] = "{$relative}::{$syncMethod['method']->name}() is not private";
}
}
expect($violations)->toBeEmpty("Livewire syncData helpers must be private:\n".implode("\n", $violations));
});
it('discovers files that only define syncApplicationData helpers', function () {
$directory = sys_get_temp_dir().'/livewire-sync-'.uniqid();
mkdir($directory);
file_put_contents($directory.'/Example.php', '<?php class Example { private function syncApplicationData(bool $toModel = false) {} }');
$methods = livewireSyncDataMethods($directory);
unlink($directory.'/Example.php');
rmdir($directory);
expect($methods)->toHaveCount(1)
->and($methods[0]['method']->name->toString())->toBe('syncApplicationData');
});
it('detects named write arguments and does not carry authorization into catch paths', function () {
$method = livewireMethodFromSource(<<<'PHP'
<?php
class Example {
public function instantSave() {
try {
$this->authorize('update', $this->resource);
$this->syncData(toModel: true);
} catch (Throwable $exception) {
$this->syncData(toModel: true);
}
}
}
PHP, 'instantSave');
expect(livewireUnauthorizedSyncWriteLines($method, 'syncData'))->toHaveCount(1);
});
it('authorizes before every syncData write call', function () {
$violations = [];
$root = dirname(__DIR__, 2);
foreach (livewireSyncDataMethods($root.'/app/Livewire') as $syncMethod) {
$contents = file_get_contents($syncMethod['path']);
$nodes = (new ParserFactory)->createForNewestSupportedVersion()->parse($contents) ?? [];
foreach ((new NodeFinder)->findInstanceOf($nodes, ClassMethod::class) as $caller) {
foreach (livewireUnauthorizedSyncWriteLines($caller, $syncMethod['method']->name->toString()) as $line) {
$relative = str_replace($root.'/', '', $syncMethod['path']);
$violations[] = "{$relative}:{$line}::{$caller->name}() calls {$syncMethod['method']->name}(true) without prior authorization";
}
}
}
expect($violations)->toBeEmpty("Missing authorization before syncData write calls:\n".implode("\n", array_unique($violations)));
});
+2 -2
View File
@@ -23,8 +23,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve
->toContain('ARG COOLIFY_VERSION')
->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}')
->and($constants)
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.11'")
->and($versions['coolify']['v4']['version'])->toBe('4.3.11')
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.18'")
->and($versions['coolify']['v4']['version'])->toBe('4.3.18')
->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1')
->and($nightlyVersions)->toBe($versions);
});
@@ -0,0 +1,9 @@
<?php
use App\Jobs\PushServerUpdateJob;
test('database status update declares a void return type', function () {
$method = new ReflectionMethod(PushServerUpdateJob::class, 'updateDatabaseStatus');
expect($method->getReturnType()?->getName())->toBe('void');
});
@@ -0,0 +1,11 @@
<?php
it('does not keep the unreliable generic stopped container notification path', function () {
$statusAction = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
$telegramChannel = file_get_contents(__DIR__.'/../../app/Notifications/Channels/TelegramChannel.php');
expect($statusAction)->not->toContain('ContainerStopped')
->and($telegramChannel)->not->toContain('ContainerStopped')
->and(file_exists(__DIR__.'/../../app/Notifications/Container/ContainerStopped.php'))->toBeFalse()
->and(file_exists(__DIR__.'/../../resources/views/emails/container-stopped.blade.php'))->toBeFalse();
});
+95
View File
@@ -0,0 +1,95 @@
<?php
use App\Services\RestartCountTracker;
it('starts a new generation when an active container restart count drops', function () {
$result = (new RestartCountTracker)->evaluate(
previousRestartCount: 11,
observedRestartCount: 0,
maxRestartCount: 2,
newGenerationConfirmed: true,
);
expect($result)->toMatchArray([
'restart_count' => 0,
'restart_count_changed' => true,
'restart_limit_reached' => false,
'new_generation' => true,
]);
});
it('evaluates the restart limit immediately in a new generation', function () {
$result = (new RestartCountTracker)->evaluate(
previousRestartCount: 11,
observedRestartCount: 3,
maxRestartCount: 2,
newGenerationConfirmed: true,
);
expect($result)->toMatchArray([
'restart_count' => 3,
'restart_count_changed' => true,
'restart_limit_reached' => true,
'new_generation' => true,
]);
});
it('does not reset the generation without explicit confirmation', function () {
$result = (new RestartCountTracker)->evaluate(
previousRestartCount: 11,
observedRestartCount: 0,
maxRestartCount: 2,
);
expect($result)->toMatchArray([
'restart_count' => 11,
'restart_count_changed' => false,
'restart_limit_reached' => false,
'new_generation' => false,
]);
});
it('preserves the previous count when an active payload omits the container with the previous maximum', function () {
$result = (new RestartCountTracker)->evaluate(
previousRestartCount: 11,
observedRestartCount: 3,
maxRestartCount: 20,
);
expect($result)->toMatchArray([
'restart_count' => 11,
'restart_count_changed' => false,
'restart_limit_reached' => false,
'new_generation' => false,
]);
});
it('detects a normal threshold crossing', function () {
$result = (new RestartCountTracker)->evaluate(
previousRestartCount: 1,
observedRestartCount: 2,
maxRestartCount: 2,
);
expect($result)->toMatchArray([
'restart_count' => 2,
'restart_count_changed' => true,
'restart_limit_reached' => true,
'new_generation' => false,
]);
});
it('detects a limit that is enabled below the current observed restart count', function () {
$result = (new RestartCountTracker)->evaluate(
previousRestartCount: 17,
observedRestartCount: 17,
maxRestartCount: 10,
);
expect($result)->toMatchArray([
'restart_count' => 17,
'restart_count_changed' => false,
'restart_limit_reached' => true,
'new_generation' => false,
]);
});
+41
View File
@@ -198,6 +198,47 @@ YAML;
expect($result)->toBe(3000);
});
it('falls back to the one-click template port when SERVICE_URL has no port suffix', function () {
$yaml = <<<'YAML'
services:
wordpress:
environment:
- SERVICE_URL_WORDPRESS
- WORDPRESS_DB_HOST=mysql
YAML;
$service = Mockery::mock(Service::class)->makePartial();
$service->docker_compose_raw = $yaml;
$service->shouldReceive('getRequiredPort')->andReturn(80);
$app = Mockery::mock(ServiceApplication::class)->makePartial();
$app->name = 'wordpress';
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
$app->service = $service;
expect($app->getRequiredPort())->toBe(80);
});
it('does not apply the template port to a container without SERVICE_URL or SERVICE_FQDN', function () {
$yaml = <<<'YAML'
services:
mysql:
environment:
- MYSQL_DATABASE=wordpress
YAML;
$service = Mockery::mock(Service::class)->makePartial();
$service->docker_compose_raw = $yaml;
$service->shouldReceive('getRequiredPort')->andReturn(80);
$app = Mockery::mock(ServiceApplication::class)->makePartial();
$app->name = 'mysql';
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
$app->service = $service;
expect($app->getRequiredPort())->toBeNull();
});
it('returns null for map-style environment without port', function () {
$yaml = <<<'YAML'
services:
@@ -15,3 +15,9 @@ test('sentinel startup regenerates an empty endpoint from instance settings', fu
->and($component)
->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;');
});
test('sentinel startup allows legacy metrics migrations to finish before health checks fail', function () {
$action = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php');
expect($action)->toContain('--health-start-period 120s');
});
+18 -4
View File
@@ -6,12 +6,22 @@ it('persists exited status when stopping standalone databases', function () {
expect($action)->toContain("'status' => 'exited'");
});
it('does not change Docker restart policies when retaining stopped containers', function (string $actionPath) {
$action = file_get_contents(__DIR__.'/../../'.$actionPath);
expect($action)->not->toContain('docker update --restart=no');
})->with([
'applications' => 'app/Actions/Application/StopApplication.php',
'application previews' => 'app/Actions/Application/StopApplicationPreview.php',
'service applications' => 'app/Actions/Service/StopServiceApplication.php',
'standalone databases' => 'app/Actions/Database/StopDatabase.php',
]);
it('persists exited status for every full application stop path', function () {
$action = file_get_contents(__DIR__.'/../../app/Actions/Application/StopApplication.php');
expect($action)
->toContain("\$status = ['status' => 'exited'];")
->toContain('$application->update($status);')
->toMatch('/\$status\s*=\s*\[\s*\'status\'\s*=>\s*\'exited\',.*?\];.*?\$application->update\(\$status\);/s')
->not->toMatch('/docker stack rm .*?return;/s');
});
@@ -19,8 +29,10 @@ it('persists exited status for all children when stopping a service', function (
$action = file_get_contents(__DIR__.'/../../app/Actions/Service/StopService.php');
expect($action)
->toContain("\$applications->each->update(['status' => 'exited']);")
->toContain("\$dbs->each->update(['status' => 'exited']);");
->toContain("\$application->update(['status' => 'exited']);")
->toContain('$application->resetRestartLimit();')
->toContain("\$database->update(['status' => 'exited']);")
->not->toContain('$database->resetRestartLimit();');
});
it('persists exited status when stopping an individual service resource', function () {
@@ -28,6 +40,8 @@ it('persists exited status when stopping an individual service resource', functi
expect($action)
->toContain("\$serviceApplication->update(['status' => 'exited']);")
->toContain('$commands = ["docker rm -f {$containerName}"];')
->toContain('throwError: ! $removeContainer')
->toContain('ServiceStatusChanged::dispatch($service->environment->project->team->id);');
});
+12
View File
@@ -0,0 +1,12 @@
<?php
use App\Actions\Database\StopDatabase;
use App\Models\BaseModel;
it('declares strict method types', function () {
$handle = new ReflectionMethod(StopDatabase::class, 'handle');
$stopContainer = new ReflectionMethod(StopDatabase::class, 'stopContainer');
expect($handle->getReturnType()?->getName())->toBe('string')
->and($stopContainer->getParameters()[0]->getType()?->getName())->toBe(BaseModel::class);
});
@@ -107,3 +107,49 @@ test('application labels keep redirect capture groups single escaped before comp
expect($labels)
->toContain('traefik.http.middlewares.0-application-uuid-to-www.redirectregex.replacement=${1}://www.${2}');
});
test('fqdnLabelsForTraefik routes each portless domain to its override port', function () {
$labels = fqdnLabelsForTraefik(
uuid: 'appuuid',
domains: collect(['https://one.example.com', 'https://two.example.com']),
onlyPort: 80,
domainPortOverrides: [
'https://one.example.com' => 3000,
'https://two.example.com' => 8080,
],
);
expect($labels)
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`one.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=3000')
->toContain('traefik.http.routers.https-1-appuuid.rule=Host(`two.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-1-appuuid.loadbalancer.server.port=8080')
->not->toContain('Host(`one.example.com:3000`)')
->not->toContain('Host(`two.example.com:8080`)');
});
test('fqdnLabelsForTraefik uses onlyPort when a portless domain has no override', function () {
$labels = fqdnLabelsForTraefik(
uuid: 'appuuid',
domains: collect(['https://plain.example.com']),
onlyPort: 4000,
domainPortOverrides: [],
);
expect($labels)
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`plain.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=4000');
});
test('fqdnLabelsForTraefik keeps routing a legacy port-bearing FQDN without an override map', function () {
$labels = fqdnLabelsForTraefik(
uuid: 'appuuid',
domains: collect(['https://legacy.example.com:9090']),
onlyPort: 80,
domainPortOverrides: [],
);
expect($labels)
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`legacy.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=9090');
});
+47
View File
@@ -161,6 +161,33 @@ KEY',
'empty' => '',
]);
it('accepts shell-safe keys for sourced build-time env files', function (string $key) {
expect(ValidationPatterns::validatedShellEnvironmentVariableKey($key))->toBe($key);
})->with([
'letters' => 'APP_ENV',
'leading underscore' => '_TOKEN',
'digits after first character' => 'NODE_VERSION_20',
]);
it('rejects keys that bash would interpret when sourcing a build-time env file', function (string $key) {
expect(fn () => ValidationPatterns::validatedShellEnvironmentVariableKey($key))
->toThrow(InvalidArgumentException::class);
})->with([
'command substitution' => 'X$(id)',
'dot notation' => 'X.VALUE',
'command substitution with arguments' => 'X$(docker run --rm -v /:/mnt alpine true)',
]);
it('makes unsafe environment variable keys safe to show in logs', function () {
expect(ValidationPatterns::displayShellEnvironmentVariableKey('APP_ENV'))->toBe('APP_ENV');
expect(ValidationPatterns::displayShellEnvironmentVariableKey("X\nid"))->toBe('X\\nid');
expect(ValidationPatterns::displayShellEnvironmentVariableKey("X\e[2Jid\x7F"))->toBe('X\\x1B[2Jid\\x7F');
expect(ValidationPatterns::displayShellEnvironmentVariableKey(''))->toBe('(empty)');
expect(ValidationPatterns::displayShellEnvironmentVariableKey(str_repeat('A', 100)))
->toEndWith('...')
->toBe(str_repeat('A', 80).'...');
});
it('generates environment variable key rules with correct defaults', function () {
$rules = ValidationPatterns::environmentVariableKeyRules();
@@ -191,3 +218,23 @@ it('normalizes application domain scheme and host without lowercasing path query
it('validates application domains with underscores in the hostname', function () {
expect(ValidationPatterns::validateApplicationDomains('https://myapp_service.example.com'))->toBeEmpty();
});
it('rejects single-label application hostnames but allows IP addresses', function () {
expect(ValidationPatterns::validateApplicationDomains('https://aaa'))->not->toBeEmpty()
->and(ValidationPatterns::validateApplicationDomains('https://localhost'))->not->toBeEmpty()
->and(ValidationPatterns::validateApplicationDomains('http://192.0.2.10:8000'))->toBeEmpty();
});
it('rejects application domain ports outside the valid TCP range', function (string $domain) {
expect(ValidationPatterns::validateApplicationDomains($domain))->not->toBeEmpty();
})->with([
'zero' => 'https://example.com:0',
'above maximum' => 'https://example.com:65536',
]);
it('accepts application domain ports at the TCP range boundaries', function (string $domain) {
expect(ValidationPatterns::validateApplicationDomains($domain))->toBeEmpty();
})->with([
'minimum' => 'https://example.com:1',
'maximum' => 'https://example.com:65535',
]);