mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(cache): write config and route cache files atomically
Laravel writes bootstrap/cache/config.php in place, so the file is empty until the write ends. app:init rebuilds the cache on every container start while PHP-FPM already serves requests. A request could read the empty file (TypeError in LoadConfiguration), and OPcache with validate_timestamps=0 kept it, so every request failed until a restart. Write to a temporary file in the same directory and rename it over the target. A failed write now keeps the old file instead of an empty one. Fixes #12120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8c4aa7bb00
commit
b850a06d26
@@ -8,7 +8,10 @@ use App\Auth\Oidc\Socialite\OidcProvider;
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\Server;
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use App\Support\AtomicFilesystem;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Console\ConfigCacheCommand;
|
||||
use Illuminate\Foundation\Console\RouteCacheCommand;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -25,6 +28,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));
|
||||
|
||||
// Requests must never read a partly written cache file. See AtomicFilesystem.
|
||||
$this->app->extend(ConfigCacheCommand::class, fn () => new ConfigCacheCommand(new AtomicFilesystem));
|
||||
$this->app->extend(RouteCacheCommand::class, fn () => new RouteCacheCommand(new AtomicFilesystem));
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Laravel writes the config and route cache files in place: the file is empty until the write ends.
|
||||
* PHP-FPM already serves requests while app:init caches them, and OPcache (validate_timestamps=0)
|
||||
* keeps an empty file it read, so every request fails until a restart (#12120).
|
||||
*/
|
||||
class AtomicFilesystem extends Filesystem
|
||||
{
|
||||
/**
|
||||
* Write to a temporary file in the same directory, then rename it over the target.
|
||||
* Readers see the old or the new complete file. When the write fails, the old file stays.
|
||||
*/
|
||||
public function put($path, $contents, $lock = false)
|
||||
{
|
||||
$directory = dirname($path);
|
||||
$tempPath = @tempnam($directory, basename($path));
|
||||
|
||||
// tempnam() falls back to the system temp directory, where rename() is not atomic.
|
||||
if ($tempPath === false || dirname($tempPath) !== realpath($directory)) {
|
||||
if ($tempPath !== false) {
|
||||
@unlink($tempPath);
|
||||
}
|
||||
|
||||
throw new RuntimeException("Could not create a temporary file to write [{$path}].");
|
||||
}
|
||||
|
||||
$bytes = file_put_contents($tempPath, $contents);
|
||||
if ($bytes !== strlen($contents)) {
|
||||
@unlink($tempPath);
|
||||
|
||||
throw new RuntimeException("Could not write [{$path}].");
|
||||
}
|
||||
|
||||
// tempnam() creates the file with 0600. Use the permissions file_put_contents() would give.
|
||||
@chmod($tempPath, 0666 & ~umask());
|
||||
|
||||
if (! rename($tempPath, $path)) {
|
||||
@unlink($tempPath);
|
||||
|
||||
throw new RuntimeException("Could not replace [{$path}].");
|
||||
}
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use App\Support\AtomicFilesystem;
|
||||
use Illuminate\Foundation\Console\ConfigCacheCommand;
|
||||
use Illuminate\Foundation\Console\RouteCacheCommand;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->directory = sys_get_temp_dir().'/atomic-filesystem-'.bin2hex(random_bytes(4));
|
||||
mkdir($this->directory);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
array_map('unlink', glob($this->directory.'/*'));
|
||||
rmdir($this->directory);
|
||||
});
|
||||
|
||||
it('writes the config and route caches with the atomic filesystem', function (string $command) {
|
||||
$files = (fn () => $this->files)->call(app($command));
|
||||
|
||||
expect($files)->toBeInstanceOf(AtomicFilesystem::class);
|
||||
})->with([
|
||||
'config:cache' => ConfigCacheCommand::class,
|
||||
'route:cache' => RouteCacheCommand::class,
|
||||
]);
|
||||
|
||||
it('replaces the file with a new complete file and leaves no temporary file', function () {
|
||||
$path = $this->directory.'/config.php';
|
||||
file_put_contents($path, '<?php return [];');
|
||||
$oldInode = fileinode($path);
|
||||
$previousUmask = umask(0022);
|
||||
|
||||
try {
|
||||
$bytes = (new AtomicFilesystem)->put($path, "<?php return ['app' => ['name' => 'Coolify']];");
|
||||
} finally {
|
||||
umask($previousUmask);
|
||||
}
|
||||
|
||||
clearstatcache();
|
||||
expect($bytes)->toBe(46)
|
||||
->and(require $path)->toBe(['app' => ['name' => 'Coolify']])
|
||||
->and(fileinode($path))->not->toBe($oldInode)
|
||||
->and(fileperms($path) & 0777)->toBe(0644)
|
||||
->and(glob($this->directory.'/*'))->toBe([$path]);
|
||||
});
|
||||
|
||||
it('does not write the file outside its directory', function () {
|
||||
$path = $this->directory.'/missing/config.php';
|
||||
|
||||
expect(fn () => (new AtomicFilesystem)->put($path, '<?php return [];'))
|
||||
->toThrow(RuntimeException::class, 'Could not create a temporary file');
|
||||
|
||||
expect(file_exists($path))->toBeFalse();
|
||||
});
|
||||
Reference in New Issue
Block a user