mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(storage): stop syncing file storages on every compose parse
The compose parser queued ServerFilesFromServerJob for every volume on each parse, and pages that only show data parse the Compose file. Each sync also sent two SSH commands for each file. - Queue the sync only for a compose deployment, and for each service resource with file storages when the service starts or deploys. - Read the compose service names without a parse on the Domains and scheduled task pages. - Check all file storages with one remote command, and stop when a symbolic link, a special file, or an unknown state is at a file path. - Keep the application path in a file storage when a pull request preview is parsed. Preview deployments of the current parser write their `-pr-<id>` copies through LocalFileVolume::forPullRequest(). - Remove the unreachable file sync from the service resource page.
This commit is contained in:
@@ -34,6 +34,8 @@
|
||||
- The root team (id 0) has `personal_team = true`, so `personal_team` does not identify a user's own team. To clean up test users, detach them from shared teams and then delete the user; never delete a team selected by a filter. A Team delete revokes the team's API tokens and deletes its private keys before any guard can stop it.
|
||||
- SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely.
|
||||
|
||||
- The Compose parsers (`parse()`, `applicationParser`, `serviceParser`) run on page mounts, refreshes, and deployments. Do not dispatch jobs or run server commands from them; let the caller that changes data start the work. Code that only needs the service names reads `docker_compose_raw` (`Application::composeServicesWithoutParse()`).
|
||||
|
||||
## Verify the complete user flow
|
||||
- Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure.
|
||||
- Verify the exact live flow, persisted state, relevant logs, and queue state when they affect the result.
|
||||
|
||||
@@ -20,6 +20,7 @@ class DeployServiceApplication
|
||||
$composeServiceName = $serviceApplication->name;
|
||||
|
||||
$service->parse();
|
||||
StartService::queueFileStorageSync($service);
|
||||
$service->saveComposeConfigs();
|
||||
$service->isConfigurationChanged(save: true);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Jobs\ServerFilesFromServerJob;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
@@ -24,6 +25,7 @@ class StartService
|
||||
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
|
||||
{
|
||||
$service->parse();
|
||||
self::queueFileStorageSync($service);
|
||||
// Fetch remote secrets before stopping: if the secret manager fails, the service keeps running.
|
||||
// saveComposeConfigs() below reuses the fetched secrets.
|
||||
$service->ensureRemoteSecretsResolvable($service->environment_variables()->get());
|
||||
@@ -103,6 +105,18 @@ class StartService
|
||||
return "{$connectedNetworks} | grep -qxF {$network} || docker network connect --alias {$containerName} {$network} {$containerName} || echo {$warning}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues one sync of the file storages with the server for each resource of the service that has
|
||||
* file storages. The sync marks each storage as a file or a directory, as it is on the server.
|
||||
*/
|
||||
public static function queueFileStorageSync(Service $service): void
|
||||
{
|
||||
$service->applications()->get()
|
||||
->concat($service->databases()->get())
|
||||
->filter(fn (ServiceApplication|ServiceDatabase $resource): bool => $resource->fileStorages()->exists())
|
||||
->each(fn (ServiceApplication|ServiceDatabase $resource) => ServerFilesFromServerJob::dispatch($resource));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the volume warnings of the last parse (for example an external volume that the
|
||||
* service does not use yet) in the start log.
|
||||
|
||||
@@ -810,14 +810,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$fileStorage->is_directory = true;
|
||||
$fileStorage->content = null;
|
||||
$fileStorage->save();
|
||||
$fileStorage->deleteStorageOnServer($this->mainServer);
|
||||
$fileStorage->saveStorageOnServer($this->mainServer);
|
||||
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->deleteStorageOnServer($this->mainServer);
|
||||
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer);
|
||||
} elseif ($fileStat->value() === 'regular file' && $fileStorage->is_directory) {
|
||||
$fileStorage->is_directory = false;
|
||||
$fileStorage->is_based_on_git = true;
|
||||
$fileStorage->save();
|
||||
$fileStorage->deleteStorageOnServer($this->mainServer);
|
||||
$fileStorage->saveStorageOnServer($this->mainServer);
|
||||
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->deleteStorageOnServer($this->mainServer);
|
||||
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -994,12 +994,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
/**
|
||||
* Parses the Compose file for this deployment and writes the volume warnings of the parser
|
||||
* (for example an external volume that the resource does not use yet) to the deployment log.
|
||||
* A deployment of the application also queues one sync of the file storages with the server, which
|
||||
* loads the content of the files that come from the repository. A preview uses its own copies of the
|
||||
* files, so its deployment does not sync the storages of the application.
|
||||
*
|
||||
* @return Collection<array-key, mixed>
|
||||
*/
|
||||
private function parseComposeFileForDeployment(): Collection
|
||||
{
|
||||
$composeFile = collect($this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit));
|
||||
if ($this->pull_request_id === 0) {
|
||||
ServerFilesFromServerJob::dispatch($this->application);
|
||||
}
|
||||
foreach ($this->application->composeVolumeWarnings() as $warning) {
|
||||
$this->application_deployment_queue->addLogEntry("Warning: {$warning}", 'stderr');
|
||||
}
|
||||
@@ -1158,6 +1164,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pull request whose file storage paths this deployment writes. Only the current Compose parser
|
||||
* mounts the `-pr-<id>` copies in a preview; other build packs and the legacy parser mount the
|
||||
* paths of the application.
|
||||
*/
|
||||
private function previewFileStoragePullRequestId(): int
|
||||
{
|
||||
return $this->application->build_pack === 'dockercompose' && (int) $this->application->compose_parsing_version >= 3
|
||||
? $this->pull_request_id
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The queued ServerStorageSaveJob writes new content files, but it can run after the containers
|
||||
* start. Preserve-repository deployments write all file storages in write_deployment_configurations().
|
||||
@@ -1171,7 +1189,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
|
||||
EnsureContentFilesOnServer::run(
|
||||
$this->application->fileStorages()->get(),
|
||||
$this->application->fileStorages()->get()->map->forPullRequest($this->previewFileStoragePullRequestId()),
|
||||
$this->mainServer,
|
||||
function (string $message, string $type): void {
|
||||
$this->application_deployment_queue->addLogEntry($message, $type);
|
||||
@@ -1334,7 +1352,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
foreach ($this->application->fileStorages as $fileStorage) {
|
||||
if (! $fileStorage->is_host_file && ! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
|
||||
$fileStorage->saveStorageOnServer($this->mainServer);
|
||||
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer);
|
||||
}
|
||||
}
|
||||
if ($this->use_build_server) {
|
||||
|
||||
@@ -18,8 +18,7 @@ class ServerFilesFromServerJob implements ShouldBeEncrypted, ShouldBeUniqueUntil
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Only one sync per resource can wait in the queue. The compose parser dispatches
|
||||
* this job once per volume on every parse, so without this the queue fills with copies.
|
||||
* Only one sync per resource can wait in the queue, also when deployments follow each other quickly.
|
||||
*/
|
||||
public int $uniqueFor = 600;
|
||||
|
||||
|
||||
@@ -321,16 +321,10 @@ class Domains extends Component
|
||||
if (! $this->isCompose) {
|
||||
$this->composeServices = [];
|
||||
} elseif ($parseCompose) {
|
||||
try {
|
||||
$parsed = $this->application->parse() ?? [];
|
||||
} catch (\Throwable) {
|
||||
$parsed = [];
|
||||
}
|
||||
$this->composeServices = [];
|
||||
$services = data_get($parsed, 'services', []);
|
||||
foreach ($services as $serviceName => $service) {
|
||||
foreach ($this->application->composeServicesWithoutParse() as $serviceName => $service) {
|
||||
if (! isDatabaseImage(data_get($service, 'image'))) {
|
||||
$this->composeServices[] = $serviceName;
|
||||
$this->composeServices[] = (string) $serviceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,8 +168,6 @@ class Index extends Component
|
||||
return;
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->query = request()->query();
|
||||
$this->currentRoute = request()->route()->getName();
|
||||
$project = currentTeam()
|
||||
->projects()
|
||||
->select('id', 'uuid', 'team_id')
|
||||
@@ -181,32 +179,12 @@ class Index extends Component
|
||||
->firstOrFail();
|
||||
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->authorize('view', $this->service);
|
||||
if (in_array($this->currentRoute, ['project.service.index', 'project.service.index.advanced'], true)) {
|
||||
return redirect()->route(
|
||||
'project.service.configuration',
|
||||
collect($this->parameters)->except('stack_service_uuid')->all(),
|
||||
);
|
||||
}
|
||||
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
|
||||
if ($service) {
|
||||
$this->serviceApplication = $service;
|
||||
$this->resourceType = 'application';
|
||||
$this->serviceApplication->getFilesFromServer();
|
||||
$this->initializeApplicationProperties();
|
||||
} else {
|
||||
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
|
||||
if (! $this->serviceDatabase) {
|
||||
return redirect()->route('project.service.configuration', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'environment_uuid' => $this->parameters['environment_uuid'],
|
||||
'service_uuid' => $this->parameters['service_uuid'],
|
||||
]);
|
||||
}
|
||||
$this->resourceType = 'database';
|
||||
$this->serviceDatabase->getFilesFromServer();
|
||||
$this->initializeDatabaseProperties();
|
||||
}
|
||||
$this->s3s = $this->serviceTeamS3Storages();
|
||||
|
||||
// The settings of a stack service open in a modal on the configuration page.
|
||||
return redirect()->route(
|
||||
'project.service.configuration',
|
||||
collect($this->parameters)->except('stack_service_uuid')->all(),
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -44,9 +44,7 @@ class All extends Component
|
||||
$this->containerNames = $this->containerNames->merge($this->resource->databases()->pluck('name'));
|
||||
} elseif ($this->resource->type() === 'application') {
|
||||
if ($this->resource->build_pack === 'dockercompose') {
|
||||
$parsed = $this->resource->parse();
|
||||
$containers = collect(data_get($parsed, 'services'))->keys();
|
||||
$this->containerNames = $containers;
|
||||
$this->containerNames = collect($this->resource->composeServicesWithoutParse())->keys();
|
||||
} else {
|
||||
$this->containerNames = collect([]);
|
||||
}
|
||||
|
||||
@@ -2206,6 +2206,23 @@ class Application extends BaseModel
|
||||
instant_remote_process($commands, $this->destination->server, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The services of the Compose file, read without parse(). A parse saves volumes and variables, so
|
||||
* code that only needs the service names uses this.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function composeServicesWithoutParse(): array
|
||||
{
|
||||
try {
|
||||
$services = data_get(parseDockerComposeYaml((string) $this->docker_compose_raw), 'services');
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($services) ? $services : [];
|
||||
}
|
||||
|
||||
public function parse(int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null)
|
||||
{
|
||||
if ((int) $this->compose_parsing_version >= 3) {
|
||||
|
||||
@@ -72,6 +72,11 @@ class LocalFileVolume extends BaseModel
|
||||
done
|
||||
SH;
|
||||
|
||||
/**
|
||||
* The pull request whose preview this copy writes. Not saved: see forPullRequest().
|
||||
*/
|
||||
protected int $previewPullRequestId = 0;
|
||||
|
||||
protected $casts = [
|
||||
// 'fs_path' => 'encrypted',
|
||||
// 'mount_path' => 'encrypted',
|
||||
@@ -343,10 +348,17 @@ class LocalFileVolume extends BaseModel
|
||||
validateShellSafePath($path, 'storage path');
|
||||
$escapedPath = escapeshellarg($path);
|
||||
|
||||
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
|
||||
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
|
||||
$state = self::remoteFileStates([(string) $path], $server)[0];
|
||||
if ($state === 'unknown') {
|
||||
throw new \Exception("Coolify cannot check the file {$path} on the server.");
|
||||
}
|
||||
if ($state === 'other' && ! $this->is_directory) {
|
||||
throw new \Exception("A symbolic link or a special file is at {$path} on the server. Coolify did not write the file.");
|
||||
}
|
||||
$isFile = $state === 'file';
|
||||
$isDir = in_array($state, ['directory', 'empty-directory'], true);
|
||||
$replacesEmptyDirectory = false;
|
||||
if ($isFile === 'OK' && $this->is_directory) {
|
||||
if ($isFile && $this->is_directory) {
|
||||
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
|
||||
$this->content = self::TOO_LARGE_PLACEHOLDER;
|
||||
} else {
|
||||
@@ -356,7 +368,7 @@ class LocalFileVolume extends BaseModel
|
||||
$this->save();
|
||||
FileStorageChanged::dispatch(data_get($server, 'team_id'));
|
||||
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');
|
||||
} elseif ($isDir === 'OK' && ! $this->is_directory) {
|
||||
} elseif ($isDir && ! $this->is_directory) {
|
||||
if ($path === '/' || $path === '.' || $path === '..' || $path === '' || str($path)->isEmpty() || is_null($path)) {
|
||||
$this->is_directory = true;
|
||||
$this->save();
|
||||
@@ -364,12 +376,12 @@ class LocalFileVolume extends BaseModel
|
||||
}
|
||||
// Docker creates a missing bind source as an empty directory. Replace only an empty
|
||||
// directory inside the resource directory; never delete files that are on the server.
|
||||
if ($isOutsideResourceDirectory || self::remoteFileStates([(string) $path], $server)[0] !== 'empty-directory') {
|
||||
if ($isOutsideResourceDirectory || $state !== 'empty-directory') {
|
||||
throw new \Exception("The following file is a directory on the server, but you are trying to mark it as a file: {$path}<br><br>Please delete the directory on the server or mark it as directory.");
|
||||
}
|
||||
$replacesEmptyDirectory = true;
|
||||
}
|
||||
if (($isDir === 'NOK' || $replacesEmptyDirectory) && ! $this->is_directory) {
|
||||
if ((! $isDir || $replacesEmptyDirectory) && ! $this->is_directory) {
|
||||
if ($replacesEmptyDirectory) {
|
||||
$commands->push("rmdir {$escapedPath}");
|
||||
}
|
||||
@@ -388,7 +400,7 @@ class LocalFileVolume extends BaseModel
|
||||
if ($chmod) {
|
||||
$commands->push('chmod -- '.escapeshellarg($chmod)." {$escapedPath}");
|
||||
}
|
||||
} elseif ($isDir === 'NOK' && $this->is_directory) {
|
||||
} elseif (! $isDir && $this->is_directory) {
|
||||
$commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
|
||||
}
|
||||
|
||||
@@ -405,22 +417,24 @@ class LocalFileVolume extends BaseModel
|
||||
* front of it and never changes the script. See REMOTE_FILE_STATE_SCRIPT for the output.
|
||||
*
|
||||
* @param list<string> $paths
|
||||
* @param (Closure(string): string)|null $quote Makes a shell argument of a path. The default is escapeshellarg().
|
||||
*/
|
||||
public static function remoteFileStateCommand(array $paths): string
|
||||
public static function remoteFileStateCommand(array $paths, ?Closure $quote = null): string
|
||||
{
|
||||
return 'sh -c '.escapeshellarg(self::REMOTE_FILE_STATE_SCRIPT).' sh '.implode(' ', array_map('escapeshellarg', $paths));
|
||||
return 'sh -c '.escapeshellarg(self::REMOTE_FILE_STATE_SCRIPT).' sh '.implode(' ', array_map($quote ?? escapeshellarg(...), $paths));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the state of each path with one server command. A path without a state in the output is `unknown`.
|
||||
*
|
||||
* @param list<string> $paths
|
||||
* @param (Closure(string): string)|null $quote Makes a shell argument of a path. The default is escapeshellarg().
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function remoteFileStates(array $paths, Server $server): array
|
||||
public static function remoteFileStates(array $paths, Server $server, ?Closure $quote = null): array
|
||||
{
|
||||
$states = [];
|
||||
$output = (string) instant_remote_process([self::remoteFileStateCommand($paths)], $server, false);
|
||||
$output = (string) instant_remote_process([self::remoteFileStateCommand($paths, $quote)], $server, false);
|
||||
foreach (preg_split('/\R/', trim($output)) as $line) {
|
||||
if (preg_match('/^(\d+):([a-z-]+)$/', trim($line), $matches)) {
|
||||
$states[(int) $matches[1] - 1] = $matches[2];
|
||||
@@ -560,16 +574,28 @@ class LocalFileVolume extends BaseModel
|
||||
public function resolvedFsPath(string $workdir): Stringable
|
||||
{
|
||||
$path = trim((string) $this->fs_path);
|
||||
if (str_starts_with($path, '/') || str_starts_with($path, '~')) {
|
||||
return str($path);
|
||||
}
|
||||
if (str_starts_with($path, '.')) {
|
||||
$path = substr($path, 1);
|
||||
if (! str_starts_with($path, '/') && ! str_starts_with($path, '~')) {
|
||||
if (str_starts_with($path, '.')) {
|
||||
$path = substr($path, 1);
|
||||
}
|
||||
$path = ltrim($path, '/');
|
||||
$path = rtrim($workdir, '/').($path === '' ? '' : '/'.$path);
|
||||
}
|
||||
|
||||
$path = ltrim($path, '/');
|
||||
return str(addPreviewDeploymentSuffix($path, $this->previewPullRequestId));
|
||||
}
|
||||
|
||||
return str(rtrim($workdir, '/').($path === '' ? '' : '/'.$path));
|
||||
/**
|
||||
* A copy that uses the path of the preview of a pull request, as the Compose parser mounts it
|
||||
* (`<path>-pr-<id>` unless the PR suffix is off for this storage). The row keeps the path of
|
||||
* the application, so saving the copy never stores the preview path.
|
||||
*/
|
||||
public function forPullRequest(int $pullRequestId): static
|
||||
{
|
||||
$copy = clone $this;
|
||||
$copy->previewPullRequestId = $pullRequestId !== 0 && ($this->is_preview_suffix_enabled ?? true) ? $pullRequestId : 0;
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Jobs\ServerFilesFromServerJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\LocalFileVolume;
|
||||
@@ -1602,6 +1601,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
|
||||
}
|
||||
$source = resolveComposeBindSource($source, $mainDirectory, $foundConfig?->fs_path);
|
||||
// The file storage keeps the path of the application; a preview deployment adds the suffix (LocalFileVolume::forPullRequest()).
|
||||
$storedSource = $source;
|
||||
$isPreviewSuffixEnabled = $foundConfig
|
||||
? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true)
|
||||
: true;
|
||||
@@ -1617,7 +1618,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
'resource_type' => get_class($originalResource),
|
||||
],
|
||||
[
|
||||
'fs_path' => $source,
|
||||
'fs_path' => $storedSource,
|
||||
'mount_path' => $target,
|
||||
'content' => $content,
|
||||
'is_directory' => $isDirectory,
|
||||
@@ -1698,7 +1699,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
? ['name' => $name]
|
||||
: composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume, $isPullRequest));
|
||||
}
|
||||
dispatch(new ServerFilesFromServerJob($originalResource));
|
||||
$volumesParsed->put($index, $volume);
|
||||
}
|
||||
}
|
||||
@@ -3121,7 +3121,6 @@ function serviceParser(Service $resource): Collection
|
||||
);
|
||||
$topLevel->get('volumes')->put($name, composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume));
|
||||
}
|
||||
dispatch(new ServerFilesFromServerJob($originalResource));
|
||||
$volumesParsed->put($index, $volume);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,45 +301,53 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
|
||||
$server = $oneService->service->server;
|
||||
}
|
||||
$fileVolumes = $oneService->fileStorages()->get();
|
||||
$escapedWorkdir = escapeshellarg($workdir);
|
||||
$commands = collect([
|
||||
"mkdir -p -- {$escapedWorkdir} > /dev/null 2>&1 || true",
|
||||
]);
|
||||
instant_remote_process($commands, $server);
|
||||
foreach ($fileVolumes as $fileVolume) {
|
||||
$content = data_get($fileVolume, 'content');
|
||||
// A relative path is inside the resource directory, never in the SSH user's working
|
||||
// directory. A persisted Compose expression (`${VAR:-/path}`) stays as it is.
|
||||
if ($isInit) {
|
||||
instant_remote_process(['mkdir -p -- '.escapeshellarg($workdir).' > /dev/null 2>&1 || true'], $server);
|
||||
}
|
||||
if ($fileVolumes->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// A relative path is inside the resource directory, never in the SSH user's working
|
||||
// directory. A persisted Compose expression (`${VAR:-/path}`) stays as it is.
|
||||
$fileLocations = $fileVolumes->map(function (LocalFileVolume $fileVolume) use ($workdir): string {
|
||||
$fsPath = trim((string) $fileVolume->fs_path);
|
||||
$fileLocation = str_starts_with($fsPath, '$') ? $fsPath : $fileVolume->resolvedFsPath($workdir)->value();
|
||||
$escapedFileLocation = filesystemVolumeShellArgument((string) $fileLocation);
|
||||
|
||||
return str_starts_with($fsPath, '$') ? $fsPath : $fileVolume->resolvedFsPath($workdir)->value();
|
||||
})->values()->all();
|
||||
$states = LocalFileVolume::remoteFileStates($fileLocations, $server, filesystemVolumeShellArgument(...));
|
||||
foreach ($fileVolumes->values() as $index => $fileVolume) {
|
||||
$content = data_get($fileVolume, 'content');
|
||||
$fileLocation = $fileLocations[$index];
|
||||
$escapedFileLocation = filesystemVolumeShellArgument($fileLocation);
|
||||
// A token with only the write permission can save a Compose file, so Coolify does not
|
||||
// write outside the resource directory here. A deployment or start writes those paths.
|
||||
$isOutsideResourceDirectory = fn (): bool => $fileVolume->isOutsideResourceDirectory();
|
||||
// Exists and is a file
|
||||
$isFile = instant_remote_process(["test -f {$escapedFileLocation} && echo OK || echo NOK"], $server);
|
||||
// Exists and is a directory
|
||||
$isDir = instant_remote_process(["test -d {$escapedFileLocation} && echo OK || echo NOK"], $server);
|
||||
$state = $states[$index];
|
||||
// A symbolic link to a directory, a special file, or a failed check: Coolify changes nothing.
|
||||
if ($state === 'other' || $state === 'unknown') {
|
||||
continue;
|
||||
}
|
||||
$isDir = in_array($state, ['directory', 'empty-directory'], true);
|
||||
|
||||
if ($isFile === 'OK') {
|
||||
if ($state === 'file') {
|
||||
$fileVolume->is_directory = false;
|
||||
$fileVolume->save();
|
||||
if ($fileVolume->is_based_on_git) {
|
||||
$fileVolume->loadStorageOnServer();
|
||||
}
|
||||
} elseif ($isDir === 'OK' && ! $fileVolume->is_directory && filled($content)) {
|
||||
} elseif ($isDir && ! $fileVolume->is_directory && filled($content)) {
|
||||
// A configured file must not lose its content because a directory is at its path.
|
||||
// Docker leaves an empty directory when it starts before the file exists: replace it.
|
||||
// A directory with files stays; the start or deployment shows a warning for it.
|
||||
if (! $isOutsideResourceDirectory() && LocalFileVolume::remoteFileStates([(string) $fileLocation], $server)[0] === 'empty-directory') {
|
||||
if (! $isOutsideResourceDirectory() && $state === 'empty-directory') {
|
||||
$fileVolume->saveStorageOnServer();
|
||||
}
|
||||
} elseif ($isDir === 'OK') {
|
||||
} elseif ($isDir) {
|
||||
// If its a directory & exists
|
||||
$fileVolume->content = null;
|
||||
$fileVolume->is_directory = true;
|
||||
$fileVolume->save();
|
||||
} elseif ($isFile === 'NOK' && $isDir === 'NOK' && ! $fileVolume->is_directory && $isInit && $content) {
|
||||
} elseif ($state === 'missing' && ! $fileVolume->is_directory && $isInit && $content) {
|
||||
// Does not exists (no dir or file), not flagged as directory, is init, has content
|
||||
if ($isOutsideResourceDirectory()) {
|
||||
continue;
|
||||
@@ -352,7 +360,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
|
||||
'mkdir -p -- "$(dirname -- '.$escapedFileLocation.')"',
|
||||
"echo '$content' | base64 -d | tee -- {$escapedFileLocation}",
|
||||
], $server);
|
||||
} elseif ($isFile === 'NOK' && $isDir === 'NOK' && $fileVolume->is_directory && $isInit) {
|
||||
} elseif ($state === 'missing' && $fileVolume->is_directory && $isInit) {
|
||||
// Does not exists (no dir or file), flagged as directory, is init
|
||||
$fileVolume->content = null;
|
||||
$fileVolume->is_directory = true;
|
||||
@@ -360,7 +368,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
|
||||
if (! $isOutsideResourceDirectory()) {
|
||||
instant_remote_process(["mkdir -p -- {$escapedFileLocation}"], $server);
|
||||
}
|
||||
} elseif ($isFile === 'NOK' && $isDir === 'NOK' && ! $fileVolume->is_directory && $isInit && is_null($content)) {
|
||||
} elseif ($state === 'missing' && ! $fileVolume->is_directory && $isInit && is_null($content)) {
|
||||
// Does not exists (no dir or file), not flagged as directory, is init, has no content => create directory
|
||||
$fileVolume->content = null;
|
||||
$fileVolume->is_directory = true;
|
||||
|
||||
@@ -3697,7 +3697,7 @@ it('offers dns provider record creation for a public server ip', function () {
|
||||
->not->toContain('The server has no public IP address; add the DNS record manually.');
|
||||
});
|
||||
|
||||
it('does not queue file sync jobs when polling dns checks for compose applications', function () {
|
||||
it('does not parse the compose file or queue file sync jobs when it shows compose application domains', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->application->update([
|
||||
@@ -3710,12 +3710,10 @@ it('does not queue file sync jobs when polling dns checks for compose applicatio
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
|
||||
$queuedOnMount = Queue::pushed(ServerFilesFromServerJob::class)->count();
|
||||
|
||||
$component->call('pollDnsChecks')->call('pollDnsChecks')->call('pollDnsChecks');
|
||||
|
||||
expect($queuedOnMount)->toBe(1)
|
||||
->and(Queue::pushed(ServerFilesFromServerJob::class))->toHaveCount(1)
|
||||
expect(Queue::pushed(ServerFilesFromServerJob::class))->toHaveCount(0)
|
||||
->and($this->application->persistentStorages()->count())->toBe(0)
|
||||
->and($component->get('composeServices'))->toBe(['web', 'api']);
|
||||
});
|
||||
|
||||
|
||||
@@ -127,11 +127,12 @@ function contentHostPathUseServerUser(string $user): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Fakes the server: `test -f`/`test -d` print NOK (the file is missing), the symlink check prints OK.
|
||||
* Fakes the server: every checked path is missing, the symlink check prints OK.
|
||||
*/
|
||||
function contentHostPathFakeServer(): void
|
||||
{
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('missing'),
|
||||
str_contains(remoteProcessScript($process), 'readlink -f') => 'OK',
|
||||
str_contains(remoteProcessScript($process), 'test -') => 'NOK',
|
||||
default => '',
|
||||
|
||||
@@ -125,6 +125,9 @@ function fakeContentFilesServer(array $states): void
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The file state checks: one for all content files, then one by each write of a missing file.
|
||||
*/
|
||||
function contentFilesStateChecks(): array
|
||||
{
|
||||
return array_values(array_filter(ContentFilesUpRecorder::ssh(), fn (string $command) => str_contains($command, 'empty-directory')));
|
||||
@@ -251,7 +254,7 @@ test('a Compose deployment checks all content files with one server command', fu
|
||||
|
||||
runComposeStart($this);
|
||||
|
||||
expect(contentFilesStateChecks())->toHaveCount(1)
|
||||
expect(contentFilesStateChecks())->toHaveCount(2)
|
||||
->and(contentFilesStateChecks()[0])->toContain(escapeshellarg($first->fs_path))
|
||||
->toContain(escapeshellarg($second->fs_path))
|
||||
->toContain(escapeshellarg($third->fs_path))
|
||||
@@ -322,14 +325,28 @@ test('a Compose deployment does not write storages without Coolify content', fun
|
||||
]);
|
||||
|
||||
test('a pull request deployment writes the preview content file path', function () {
|
||||
$volume = contentFilesVolume($this->application, 'config/app.conf-pr-7', ['is_preview_suffix_enabled' => true]);
|
||||
$volume = contentFilesVolume($this->application, 'config/app.conf');
|
||||
$previewPath = $volume->fs_path.'-pr-7';
|
||||
fakeContentFilesServer([$volume->fs_path => 'file', $previewPath => 'missing']);
|
||||
|
||||
runComposeStart($this, ['pull_request_id' => 7]);
|
||||
|
||||
$write = ContentFilesUpRecorder::indexOf('ssh', 'base64 -d | tee '.escapeshellarg($previewPath));
|
||||
expect($write)->not->toBeNull()
|
||||
->and($write)->toBeLessThan(ContentFilesUpRecorder::indexOf('deployment', ' up -d'))
|
||||
->and(ContentFilesUpRecorder::indexOf('ssh', 'tee '.escapeshellarg($volume->fs_path).' '))->toBeNull()
|
||||
->and($volume->fresh()->fs_path)->toBe($this->application->workdir().'/config/app.conf');
|
||||
});
|
||||
|
||||
test('a pull request deployment of the legacy compose parser writes the content file path of the application', function () {
|
||||
$this->application->update(['compose_parsing_version' => '2']);
|
||||
$volume = contentFilesVolume($this->application, 'config/app.conf');
|
||||
fakeContentFilesServer([$volume->fs_path => 'missing']);
|
||||
|
||||
runComposeStart($this, ['pull_request_id' => 7]);
|
||||
|
||||
$write = ContentFilesUpRecorder::indexOf('ssh', 'tee '.escapeshellarg($this->application->workdir().'/config/app.conf-pr-7'));
|
||||
expect($write)->not->toBeNull()
|
||||
->and($write)->toBeLessThan(ContentFilesUpRecorder::indexOf('deployment', ' up -d'));
|
||||
expect(ContentFilesUpRecorder::indexOf('ssh', 'base64 -d | tee '.escapeshellarg($volume->fs_path)))->not->toBeNull()
|
||||
->and(ContentFilesUpRecorder::indexOf('ssh', '-pr-7'))->toBeNull();
|
||||
});
|
||||
|
||||
test('a preserve-repository deployment writes content files before docker compose up', function () {
|
||||
@@ -355,7 +372,7 @@ test('a deployment to an additional server writes missing content files on that
|
||||
runComposeStart($this, ['server' => $otherServer, 'mainServer' => $otherServer]);
|
||||
|
||||
$write = ContentFilesUpRecorder::indexOf('ssh', 'base64 -d | tee '.escapeshellarg($volume->fs_path));
|
||||
expect(contentFilesStateChecks())->toHaveCount(1)
|
||||
expect(contentFilesStateChecks())->toHaveCount(2)
|
||||
->and(contentFilesStateChecks()[0])->toContain("@'{$otherServer->ip}'")
|
||||
->and($write)->not->toBeNull()
|
||||
->and(ContentFilesUpRecorder::ssh()[$write])->toContain("@'{$otherServer->ip}'")
|
||||
@@ -441,7 +458,7 @@ test('starting a service writes missing content files before docker compose up',
|
||||
$activity = StartService::run($service->fresh());
|
||||
|
||||
$command = RemoteProcessCommand::read($activity);
|
||||
expect(contentFilesStateChecks())->toHaveCount(1)
|
||||
expect(contentFilesStateChecks())->toHaveCount(2)
|
||||
->and($command)->toContain("echo 'Writing 1 missing configuration file.'")
|
||||
->and($command)->toContain("echo 'Warning: A directory that is not empty is at {$workerConf->fs_path} on the server.")
|
||||
->and(strpos($command, 'Writing 1 missing configuration file.'))->toBeLessThan(strpos($command, ' up -d'))
|
||||
@@ -469,7 +486,7 @@ test('deploying one service application writes only its missing content files',
|
||||
$activity = DeployServiceApplication::run($service->applications()->where('name', 'app')->firstOrFail());
|
||||
|
||||
$command = RemoteProcessCommand::read($activity);
|
||||
expect(contentFilesStateChecks())->toHaveCount(1)
|
||||
expect(contentFilesStateChecks())->toHaveCount(2)
|
||||
->and(contentFilesStateChecks()[0])->not->toContain(escapeshellarg($workerConf->fs_path))
|
||||
->and(strpos($command, 'Writing 1 missing configuration file.'))->toBeLessThan(strpos($command, ' up -d'));
|
||||
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($appConf->fs_path)));
|
||||
@@ -577,6 +594,20 @@ test('the server file sync replaces an empty directory with the content file', f
|
||||
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'base64 -d'));
|
||||
});
|
||||
|
||||
test('the server file sync checks all file storages with one server command', function () {
|
||||
$first = contentFilesVolume($this->application, 'conf/app.conf');
|
||||
$second = contentFilesVolume($this->application, 'conf/worker.conf');
|
||||
$directory = contentFilesVolume($this->application, 'data/cache', ['content' => null]);
|
||||
fakeContentFilesServer([$first->fs_path => 'file', $second->fs_path => 'file', $directory->fs_path => 'directory']);
|
||||
|
||||
getFilesystemVolumesFromServer($this->application->fresh());
|
||||
|
||||
expect(ContentFilesUpRecorder::ssh())->toHaveCount(1)
|
||||
->and(contentFilesStateChecks())->toHaveCount(1)
|
||||
->and($directory->fresh()->is_directory)->toBeTruthy()
|
||||
->and($first->fresh()->is_directory)->toBeFalsy();
|
||||
});
|
||||
|
||||
test('the server file sync still marks a storage without content as a directory', function () {
|
||||
$volume = contentFilesVolume($this->application, 'data/cache', ['content' => null]);
|
||||
fakeContentFilesServer([$volume->fs_path => 'directory']);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Jobs\ServerFilesFromServerJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
const SYNC_TRIGGERS_COMPOSE = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- app-data:/data
|
||||
- ./data:/app/data
|
||||
- type: bind
|
||||
source: ./config/app.conf
|
||||
target: /etc/app.conf
|
||||
content: 'listen 8080;'
|
||||
volumes:
|
||||
app-data:
|
||||
YAML;
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $server->id)->first();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => SYNC_TRIGGERS_COMPOSE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not queue a server file sync when it parses a compose application', function () {
|
||||
$this->application->parse();
|
||||
$this->application->parse(pull_request_id: 7);
|
||||
|
||||
Bus::assertNotDispatched(ServerFilesFromServerJob::class);
|
||||
});
|
||||
|
||||
it('does not queue a server file sync when it parses a service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'server_id' => $this->destination->server_id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'docker_compose_raw' => SYNC_TRIGGERS_COMPOSE,
|
||||
]);
|
||||
ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]);
|
||||
|
||||
$service->parse();
|
||||
|
||||
Bus::assertNotDispatched(ServerFilesFromServerJob::class);
|
||||
});
|
||||
|
||||
it('queues one server file sync for a compose deployment of the application only', function (int $pullRequestId, int $expectedSyncs) {
|
||||
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
||||
$queue = Mockery::mock(ApplicationDeploymentQueue::class);
|
||||
$queue->shouldReceive('addLogEntry')->andReturnNull();
|
||||
foreach ([
|
||||
'application' => $this->application,
|
||||
'application_deployment_queue' => $queue,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'commit' => 'HEAD',
|
||||
] as $property => $value) {
|
||||
(new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value);
|
||||
}
|
||||
|
||||
(new ReflectionMethod(ApplicationDeploymentJob::class, 'parseComposeFileForDeployment'))->invoke($job);
|
||||
|
||||
Bus::assertDispatchedTimes(ServerFilesFromServerJob::class, $expectedSyncs);
|
||||
})->with([
|
||||
'application' => [0, 1],
|
||||
'pull request preview' => [7, 0],
|
||||
]);
|
||||
|
||||
it('reads the compose service names without a parse', function () {
|
||||
$this->application->update(['docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n db:\n image: postgres:16\n"]);
|
||||
|
||||
expect(array_keys($this->application->composeServicesWithoutParse()))->toBe(['web', 'db'])
|
||||
->and($this->application->fileStorages()->count())->toBe(0)
|
||||
->and($this->application->persistentStorages()->count())->toBe(0);
|
||||
|
||||
$this->application->update(['docker_compose_raw' => "services: [\n"]);
|
||||
expect($this->application->composeServicesWithoutParse())->toBe([]);
|
||||
});
|
||||
|
||||
it('queues one server file sync for each service resource with file storages when the service starts', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'server_id' => $this->destination->server_id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n volumes:\n - ./data:/app/data\n cache:\n image: redis:7\n",
|
||||
]);
|
||||
$app = ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]);
|
||||
ServiceApplication::create(['name' => 'cache', 'service_id' => $service->id]);
|
||||
$service->parse();
|
||||
|
||||
StartService::queueFileStorageSync($service);
|
||||
|
||||
Bus::assertDispatchedTimes(ServerFilesFromServerJob::class, 1);
|
||||
Bus::assertDispatched(ServerFilesFromServerJob::class, fn (ServerFilesFromServerJob $job) => $job->resource->is($app));
|
||||
});
|
||||
@@ -43,6 +43,7 @@ beforeEach(function () {
|
||||
$this->serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $this->service->id]);
|
||||
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('missing'),
|
||||
str_contains(remoteProcessScript($process), 'readlink -f') => 'OK',
|
||||
str_contains(remoteProcessScript($process), 'test -') => 'NOK',
|
||||
default => '',
|
||||
@@ -106,6 +107,7 @@ test('an absolute directory path keeps working unchanged', function () {
|
||||
|
||||
test('deleting a relative path removes it from the resource directory', function () {
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('file'),
|
||||
str_contains(remoteProcessScript($process), 'readlink -f') => 'OK',
|
||||
str_contains(remoteProcessScript($process), 'test -f') => 'OK',
|
||||
default => 'NOK',
|
||||
|
||||
@@ -24,9 +24,11 @@ uses(RefreshDatabase::class);
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
|
||||
Process::fake(fn ($process) => Process::result(
|
||||
output: str_contains(remoteProcessScript($process), 'readlink -f') ? 'OK' : ''
|
||||
));
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('missing'),
|
||||
str_contains(remoteProcessScript($process), 'readlink -f') => 'OK',
|
||||
default => '',
|
||||
}));
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['id' => 0, 'is_dns_validation_enabled' => false]
|
||||
|
||||
@@ -14,9 +14,7 @@ use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\UniqueLock;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -1103,14 +1101,9 @@ it('does not queue file sync jobs when polling dns checks for compose previews',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(PreviewDomains::class, ['preview' => $preview]);
|
||||
$queuedOnMount = Queue::pushed(ServerFilesFromServerJob::class);
|
||||
|
||||
// A worker that starts the queued job releases its unique lock, so the next parse queues a new one.
|
||||
Cache::lock(UniqueLock::getKey($queuedOnMount->first()))->forceRelease();
|
||||
$component->call('pollDnsChecks')->call('pollDnsChecks')->call('pollDnsChecks');
|
||||
|
||||
expect($queuedOnMount)->toHaveCount(1)
|
||||
->and(Queue::pushed(ServerFilesFromServerJob::class))->toHaveCount(1)
|
||||
expect(Queue::pushed(ServerFilesFromServerJob::class))->toHaveCount(0)
|
||||
->and($component->get('composeServices'))->toBe(['web', 'api']);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
const PREVIEW_FILE_STORAGE_COMPOSE = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- type: bind
|
||||
source: ./config/app.conf
|
||||
target: /etc/app.conf
|
||||
content: 'listen 8080;'
|
||||
YAML;
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => PREVIEW_FILE_STORAGE_COMPOSE,
|
||||
]);
|
||||
$this->workdir = $this->application->workdir();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<string, string> mount path => stored host path
|
||||
*/
|
||||
function previewFileStoragePaths(Application $application): array
|
||||
{
|
||||
return LocalFileVolume::query()->where('resource_id', $application->id)->orderBy('mount_path')->pluck('fs_path', 'mount_path')->all();
|
||||
}
|
||||
|
||||
it('keeps the paths of the application file storages when it parses a pull request preview', function () {
|
||||
$this->application->parse();
|
||||
|
||||
$preview = $this->application->parse(pull_request_id: 7);
|
||||
|
||||
expect(previewFileStoragePaths($this->application))->toBe([
|
||||
'/app/data' => "{$this->workdir}/data",
|
||||
'/etc/app.conf' => "{$this->workdir}/config/app.conf",
|
||||
]);
|
||||
$volumes = collect(data_get($preview, 'services.app-pr-7.volumes'))
|
||||
->map(fn ($volume) => is_array($volume) ? $volume['source'].':'.$volume['target'] : $volume)
|
||||
->all();
|
||||
expect($volumes)->toContain("{$this->workdir}/data-pr-7:/app/data")
|
||||
->toContain("{$this->workdir}/config/app.conf-pr-7:/etc/app.conf");
|
||||
});
|
||||
|
||||
it('stores the application path when the first parse is a pull request preview', function () {
|
||||
$this->application->parse(pull_request_id: 7);
|
||||
|
||||
expect(previewFileStoragePaths($this->application))->toBe([
|
||||
'/app/data' => "{$this->workdir}/data",
|
||||
'/etc/app.conf' => "{$this->workdir}/config/app.conf",
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes the file of a pull request preview to its own path without saving that path', function () {
|
||||
$this->application->parse();
|
||||
$storage = LocalFileVolume::query()->where('mount_path', '/etc/app.conf')->firstOrFail();
|
||||
|
||||
$preview = $storage->forPullRequest(7);
|
||||
$preview->is_directory = false;
|
||||
$preview->save();
|
||||
|
||||
expect($preview->contentPathOnServer())->toBe("{$this->workdir}/config/app.conf-pr-7")
|
||||
->and($storage->contentPathOnServer())->toBe("{$this->workdir}/config/app.conf")
|
||||
->and($storage->forPullRequest(0)->contentPathOnServer())->toBe("{$this->workdir}/config/app.conf")
|
||||
->and($storage->fresh()->fs_path)->toBe("{$this->workdir}/config/app.conf");
|
||||
|
||||
$storage->update(['is_preview_suffix_enabled' => false]);
|
||||
expect($storage->fresh()->forPullRequest(7)->contentPathOnServer())->toBe("{$this->workdir}/config/app.conf");
|
||||
});
|
||||
@@ -37,9 +37,11 @@ beforeEach(function () {
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
Process::fake(fn ($process) => Process::result(
|
||||
output: str_contains(remoteProcessScript($process), 'readlink -f') ? 'OK' : 'NOK'
|
||||
));
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('missing'),
|
||||
str_contains(remoteProcessScript($process), 'readlink -f') => 'OK',
|
||||
default => 'NOK',
|
||||
}));
|
||||
|
||||
$this->processedSaves = 0;
|
||||
Event::listen(JobProcessed::class, function (JobProcessed $event): void {
|
||||
|
||||
@@ -45,10 +45,12 @@ beforeEach(function () {
|
||||
$this->withoutDefer();
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
Bus::fake();
|
||||
// Remote `test -f` / `test -d` probes report a missing path; path confinement checks pass.
|
||||
Process::fake(fn ($process) => Process::result(
|
||||
output: str_contains(implode(' ', (array) remoteProcessScript($process)), '&& echo OK || echo NOK') ? 'NOK' : 'OK'
|
||||
));
|
||||
// Remote file probes report a missing path; path confinement checks pass.
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('missing'),
|
||||
str_contains(remoteProcessScript($process), '&& echo OK || echo NOK') => 'NOK',
|
||||
default => 'OK',
|
||||
}));
|
||||
Server::flushIdentityMap();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
|
||||
@@ -59,11 +59,12 @@ beforeEach(function () {
|
||||
});
|
||||
|
||||
/**
|
||||
* `test -L` prints $symlink and `test -f`/`test -d` print $exists.
|
||||
* `test -L` prints $symlink and `test -f`/`test -d` print $exists. The file state check sees a file when $exists is OK.
|
||||
*/
|
||||
function storageHostPathFakeServer(string $symlink = 'OK', string $exists = 'NOK'): void
|
||||
{
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput($exists === 'OK' ? 'file' : 'missing'),
|
||||
str_contains(remoteProcessScript($process), 'test -L') => $symlink,
|
||||
str_contains(remoteProcessScript($process), 'readlink -f') => 'OK',
|
||||
str_contains(remoteProcessScript($process), 'test -') => $exists,
|
||||
@@ -333,6 +334,7 @@ test('the delete dialog offers deletion on the host only for mounts inside the r
|
||||
|
||||
test('a file outside the resource directory never replaces a directory on the host', function () {
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('directory'),
|
||||
str_contains(remoteProcessScript($process), 'test -L') => 'OK',
|
||||
str_contains(remoteProcessScript($process), 'test -d') => 'OK',
|
||||
str_contains(remoteProcessScript($process), 'test -f') => 'NOK',
|
||||
|
||||
@@ -1359,15 +1359,15 @@
|
||||
- 'referenceapplication0001_web-data-pr-7:/app/data'
|
||||
files:
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config
|
||||
mount_path: /app/config
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux
|
||||
mount_path: /app/selinux
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh
|
||||
mount_path: /docker-entrypoint.d/init.sh
|
||||
is_directory: false
|
||||
content: |
|
||||
@@ -2060,15 +2060,15 @@
|
||||
- 'referenceapplication0001_web-data-pr-8:/app/data'
|
||||
files:
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config
|
||||
mount_path: /app/config
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux
|
||||
mount_path: /app/selinux
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh
|
||||
mount_path: /docker-entrypoint.d/init.sh
|
||||
is_directory: false
|
||||
content: |
|
||||
|
||||
@@ -1425,15 +1425,15 @@
|
||||
- 'referenceapplication0001_web-data-pr-7:/app/data'
|
||||
files:
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config
|
||||
mount_path: /app/config
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux
|
||||
mount_path: /app/selinux
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh
|
||||
mount_path: /docker-entrypoint.d/init.sh
|
||||
is_directory: false
|
||||
content: |
|
||||
@@ -2177,15 +2177,15 @@
|
||||
- 'referenceapplication0001_web-data-pr-8:/app/data'
|
||||
files:
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config
|
||||
mount_path: /app/config
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux
|
||||
mount_path: /app/selinux
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh
|
||||
mount_path: /docker-entrypoint.d/init.sh
|
||||
is_directory: false
|
||||
content: |
|
||||
|
||||
@@ -1621,15 +1621,15 @@
|
||||
- 'referenceapplication0001_web-data-pr-7:/app/data'
|
||||
files:
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config
|
||||
mount_path: /app/config
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux
|
||||
mount_path: /app/selinux
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh
|
||||
mount_path: /docker-entrypoint.d/init.sh
|
||||
is_directory: false
|
||||
content: |
|
||||
@@ -2483,15 +2483,15 @@
|
||||
- 'referenceapplication0001_web-data-pr-8:/app/data'
|
||||
files:
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config
|
||||
mount_path: /app/config
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux
|
||||
mount_path: /app/selinux
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh
|
||||
mount_path: /docker-entrypoint.d/init.sh
|
||||
is_directory: false
|
||||
content: |
|
||||
|
||||
@@ -1503,15 +1503,15 @@
|
||||
- 'referenceapplication0001_web-data-pr-7:/app/data'
|
||||
files:
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config
|
||||
mount_path: /app/config
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux
|
||||
mount_path: /app/selinux
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh-pr-7
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh
|
||||
mount_path: /docker-entrypoint.d/init.sh
|
||||
is_directory: false
|
||||
content: |
|
||||
@@ -2303,15 +2303,15 @@
|
||||
- 'referenceapplication0001_web-data-pr-8:/app/data'
|
||||
files:
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/config
|
||||
mount_path: /app/config
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/selinux
|
||||
mount_path: /app/selinux
|
||||
is_directory: true
|
||||
-
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh-pr-8
|
||||
fs_path: /data/coolify/applications/referenceapplication0001/web-init.sh
|
||||
mount_path: /docker-entrypoint.d/init.sh
|
||||
is_directory: false
|
||||
content: |
|
||||
|
||||
@@ -34,3 +34,11 @@ function fakeRemoteProcesses(array $handlers): void
|
||||
return Process::result();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The output of LocalFileVolume::remoteFileStateCommand() when every checked path has the same state.
|
||||
*/
|
||||
function remoteFileStatesOutput(string $state, int $paths = 20): string
|
||||
{
|
||||
return implode("\n", array_map(fn (int $index) => "{$index}:{$state}", range(1, $paths)));
|
||||
}
|
||||
|
||||
@@ -89,22 +89,13 @@ it('does not read regular bind-mounted file contents while loading service setti
|
||||
$application->shouldReceive('fileStorages')->once()->andReturn($fileStorages);
|
||||
$application->setRelation('destination', (object) ['server' => $server]);
|
||||
|
||||
Process::fake(function ($process) {
|
||||
if (str_contains(remoteProcessScript($process), "test -f '/data/large.bin'")) {
|
||||
return Process::result(output: 'OK');
|
||||
}
|
||||
|
||||
if (str_contains(remoteProcessScript($process), "test -d '/data/large.bin'")) {
|
||||
return Process::result(output: 'NOK');
|
||||
}
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
Process::fake(fn ($process) => Process::result(
|
||||
output: str_contains(remoteProcessScript($process), 'empty-directory') ? '1:file' : ''
|
||||
));
|
||||
|
||||
getFilesystemVolumesFromServer($application);
|
||||
|
||||
expect($volume->is_directory)->toBeFalse();
|
||||
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "test -f '/data/large.bin'"));
|
||||
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'cat /data/large.bin') || str_contains(remoteProcessScript($process), 'head -c'));
|
||||
});
|
||||
|
||||
@@ -194,13 +185,15 @@ it('quotes literal file-storage paths and safely expands persisted expressions',
|
||||
$expression->setRelation('resource', $application);
|
||||
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('missing'),
|
||||
str_contains(remoteProcessScript($process), 'readlink -f') => 'OK',
|
||||
str_contains(remoteProcessScript($process), 'test -') => 'NOK',
|
||||
default => '',
|
||||
}));
|
||||
getFilesystemVolumesFromServer($application, true);
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "test -f '/data/application/my files/config.yaml'"));
|
||||
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'empty-directory')
|
||||
&& str_contains(remoteProcessScript($process), 'my files/config.yaml')
|
||||
&& str_contains(remoteProcessScript($process), '"${DATA_PATH:-/srv/app/config.yaml}"'));
|
||||
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p -- '/data/application/my files/config.yaml'"));
|
||||
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "dirname -- '/data/application/my files/settings.json'"));
|
||||
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "tee -- '/data/application/my files/settings.json'"));
|
||||
|
||||
Reference in New Issue
Block a user