mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(compose): preserve long bind options and non-creating sources (#12135)
This commit is contained in:
+120
-54
@@ -140,6 +140,56 @@ function composeResourceDirectory(Application|Service $resource): string
|
||||
return $resource->workdir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a long-syntax bind with its resolved source. It keeps the options that Docker Compose
|
||||
* accepts, such as read_only and bind, and removes other keys, which Compose rejects. Without
|
||||
* `bind.create_host_path`, a missing source is created, as with short syntax: Docker Compose
|
||||
* before v2.30 does not do that for long syntax.
|
||||
*
|
||||
* @param array<string, mixed> $volume
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function composeLongBindVolume(array $volume, string $source): array
|
||||
{
|
||||
$volume = array_filter(
|
||||
$volume,
|
||||
fn ($key) => in_array($key, ['type', 'source', 'target', 'read_only', 'consistency', 'bind'], true) || str_starts_with((string) $key, 'x-'),
|
||||
ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
$volume['source'] = $source;
|
||||
$volume['bind'] = is_array($volume['bind'] ?? null) ? $volume['bind'] : [];
|
||||
$volume['bind']['create_host_path'] ??= true;
|
||||
|
||||
return $volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* A long-syntax bind with `bind.create_host_path: false` must fail when its host path is missing.
|
||||
* Coolify must not create that path, so it does not add a file storage for it. A volume with
|
||||
* `content:` is still written by Coolify.
|
||||
*/
|
||||
function composeBindKeepsMissingHostPath(mixed $volume): bool
|
||||
{
|
||||
return is_array($volume)
|
||||
&& data_get($volume, 'bind.create_host_path') === false
|
||||
&& ! array_key_exists('content', $volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the file storage that an earlier save created for a bind that now keeps its missing host
|
||||
* path. Otherwise, the storage jobs still create that path. The files on the server stay. A storage
|
||||
* with a backup schedule stays, because it cannot be deleted before its schedule.
|
||||
*/
|
||||
function forgetComposeBindStorage(Application|ServiceApplication|ServiceDatabase $resource, string $mountPath): void
|
||||
{
|
||||
$resource->fileStorages()
|
||||
->where('mount_path', $mountPath)
|
||||
->where('is_host_file', false)
|
||||
->whereDoesntHave('scheduledBackups')
|
||||
->get()
|
||||
->each->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Coolify writes the `content:` of a Compose bind volume to the source path on the host. Only
|
||||
* administrators can edit a Compose file, and they can mount any host path, so the source can be
|
||||
@@ -1451,15 +1501,14 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
}
|
||||
}
|
||||
if ($type->value() === 'bind') {
|
||||
if ($source->value() === '/var/run/docker.sock') {
|
||||
$volume = $source->value().':'.$target->value();
|
||||
if (isset($parsed['mode']) && $parsed['mode']) {
|
||||
$volume .= ':'.$parsed['mode']->value();
|
||||
}
|
||||
} elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
|
||||
$volume = $source->value().':'.$target->value();
|
||||
if (isset($parsed['mode']) && $parsed['mode']) {
|
||||
$volume .= ':'.$parsed['mode']->value();
|
||||
if (in_array($source->value(), ['/var/run/docker.sock', '/tmp', '/tmp/'], true)) {
|
||||
if (is_string($volume)) {
|
||||
$volume = $source->value().':'.$target->value();
|
||||
if (isset($parsed['mode']) && $parsed['mode']) {
|
||||
$volume .= ':'.$parsed['mode']->value();
|
||||
}
|
||||
} else {
|
||||
$volume = composeLongBindVolume($volume, $source->value());
|
||||
}
|
||||
} else {
|
||||
if ((int) $resource->compose_parsing_version >= 4) {
|
||||
@@ -1474,26 +1523,35 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
if ($isPullRequest && $isPreviewSuffixEnabled) {
|
||||
$source = str(addPreviewDeploymentSuffix($source, $pull_request_id));
|
||||
}
|
||||
LocalFileVolume::updateOrCreate(
|
||||
[
|
||||
'mount_path' => $target,
|
||||
'resource_id' => $originalResource->id,
|
||||
'resource_type' => get_class($originalResource),
|
||||
],
|
||||
[
|
||||
'fs_path' => $source,
|
||||
'mount_path' => $target,
|
||||
'content' => $content,
|
||||
'is_directory' => $isDirectory,
|
||||
'resource_id' => $originalResource->id,
|
||||
'resource_type' => get_class($originalResource),
|
||||
]
|
||||
);
|
||||
// With `bind.create_host_path: false`, the host path must exist already: Coolify does not create or manage it.
|
||||
if (! composeBindKeepsMissingHostPath($volume)) {
|
||||
LocalFileVolume::updateOrCreate(
|
||||
[
|
||||
'mount_path' => $target,
|
||||
'resource_id' => $originalResource->id,
|
||||
'resource_type' => get_class($originalResource),
|
||||
],
|
||||
[
|
||||
'fs_path' => $source,
|
||||
'mount_path' => $target,
|
||||
'content' => $content,
|
||||
'is_directory' => $isDirectory,
|
||||
'resource_id' => $originalResource->id,
|
||||
'resource_type' => get_class($originalResource),
|
||||
]
|
||||
);
|
||||
} else {
|
||||
forgetComposeBindStorage($originalResource, $target->value());
|
||||
}
|
||||
// The file storage keeps the path that Coolify writes; the Docker daemon may need another one.
|
||||
$source = $source->replace($mainDirectory, devHostDockerPath($server, $mainDirectory->value()));
|
||||
$volume = "$source:$target";
|
||||
if (isset($parsed['mode']) && $parsed['mode']) {
|
||||
$volume .= ':'.$parsed['mode']->value();
|
||||
if (is_array($volume)) {
|
||||
$volume = composeLongBindVolume($volume, $source->value());
|
||||
} else {
|
||||
$volume = "$source:$target";
|
||||
if (isset($parsed['mode']) && $parsed['mode']) {
|
||||
$volume .= ':'.$parsed['mode']->value();
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($type->value() === 'volume') {
|
||||
@@ -2853,15 +2911,14 @@ function serviceParser(Service $resource): Collection
|
||||
}
|
||||
}
|
||||
if ($type->value() === 'bind') {
|
||||
if ($source->value() === '/var/run/docker.sock') {
|
||||
$volume = $source->value().':'.$target->value();
|
||||
if (isset($parsed['mode']) && $parsed['mode']) {
|
||||
$volume .= ':'.$parsed['mode']->value();
|
||||
}
|
||||
} elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
|
||||
$volume = $source->value().':'.$target->value();
|
||||
if (isset($parsed['mode']) && $parsed['mode']) {
|
||||
$volume .= ':'.$parsed['mode']->value();
|
||||
if (in_array($source->value(), ['/var/run/docker.sock', '/tmp', '/tmp/'], true)) {
|
||||
if (is_string($volume)) {
|
||||
$volume = $source->value().':'.$target->value();
|
||||
if (isset($parsed['mode']) && $parsed['mode']) {
|
||||
$volume .= ':'.$parsed['mode']->value();
|
||||
}
|
||||
} else {
|
||||
$volume = composeLongBindVolume($volume, $source->value());
|
||||
}
|
||||
} else {
|
||||
if ((int) $resource->compose_parsing_version >= 4) {
|
||||
@@ -2870,26 +2927,35 @@ function serviceParser(Service $resource): Collection
|
||||
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
|
||||
}
|
||||
$source = resolveComposeBindSource($source, $mainDirectory, $foundConfig?->fs_path);
|
||||
LocalFileVolume::updateOrCreate(
|
||||
[
|
||||
'mount_path' => $target,
|
||||
'resource_id' => $originalResource->id,
|
||||
'resource_type' => get_class($originalResource),
|
||||
],
|
||||
[
|
||||
'fs_path' => $source,
|
||||
'mount_path' => $target,
|
||||
'content' => $content,
|
||||
'is_directory' => $isDirectory,
|
||||
'resource_id' => $originalResource->id,
|
||||
'resource_type' => get_class($originalResource),
|
||||
]
|
||||
);
|
||||
// With `bind.create_host_path: false`, the host path must exist already: Coolify does not create or manage it.
|
||||
if (! composeBindKeepsMissingHostPath($volume)) {
|
||||
LocalFileVolume::updateOrCreate(
|
||||
[
|
||||
'mount_path' => $target,
|
||||
'resource_id' => $originalResource->id,
|
||||
'resource_type' => get_class($originalResource),
|
||||
],
|
||||
[
|
||||
'fs_path' => $source,
|
||||
'mount_path' => $target,
|
||||
'content' => $content,
|
||||
'is_directory' => $isDirectory,
|
||||
'resource_id' => $originalResource->id,
|
||||
'resource_type' => get_class($originalResource),
|
||||
]
|
||||
);
|
||||
} else {
|
||||
forgetComposeBindStorage($originalResource, $target->value());
|
||||
}
|
||||
// The file storage keeps the path that Coolify writes; the Docker daemon may need another one.
|
||||
$source = $source->replace($mainDirectory, devHostDockerPath($server, $mainDirectory->value()));
|
||||
$volume = "$source:$target";
|
||||
if (isset($parsed['mode']) && $parsed['mode']) {
|
||||
$volume .= ':'.$parsed['mode']->value();
|
||||
if (is_array($volume)) {
|
||||
$volume = composeLongBindVolume($volume, $source->value());
|
||||
} else {
|
||||
$volume = "$source:$target";
|
||||
if (isset($parsed['mode']) && $parsed['mode']) {
|
||||
$volume .= ':'.$parsed['mode']->value();
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($type->value() === 'volume') {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
config(['constants.ssh.mux_enabled' => false]);
|
||||
Bus::fake();
|
||||
Queue::fake();
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$this->environment = $project->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $project->id]);
|
||||
});
|
||||
|
||||
function longBindCompose(string $source, string $options): string
|
||||
{
|
||||
return <<<YAML
|
||||
services:
|
||||
probe:
|
||||
image: alpine:3.22
|
||||
volumes:
|
||||
- type: bind
|
||||
source: {$source}
|
||||
target: /fixture
|
||||
{$options}
|
||||
YAML;
|
||||
}
|
||||
|
||||
const LONG_BIND_STRICT_OPTIONS = <<<'YAML'
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
YAML;
|
||||
|
||||
const LONG_BIND_READ_ONLY_OPTIONS = <<<'YAML'
|
||||
read_only: true
|
||||
YAML;
|
||||
|
||||
/**
|
||||
* Parses the Compose file as a Service or a Compose Application, twice like a second save.
|
||||
*
|
||||
* @return array{volume: mixed, volumes: array<int, mixed>, storages: Collection, directory: string}
|
||||
*/
|
||||
function parseLongBind(string $kind, string $compose): array
|
||||
{
|
||||
if ($kind === 'service') {
|
||||
$resource = Service::factory()->create([
|
||||
'docker_compose_raw' => $compose,
|
||||
'environment_id' => test()->environment->id,
|
||||
'server_id' => test()->destination->server_id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
])->fresh();
|
||||
serviceParser($resource);
|
||||
$parsed = serviceParser($resource->fresh())->toArray();
|
||||
$owner = $resource->applications()->firstOrFail();
|
||||
$directory = base_configuration_dir().'/services/'.$resource->uuid;
|
||||
} else {
|
||||
$resource = Application::factory()->create([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $compose,
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
])->fresh();
|
||||
applicationParser($resource);
|
||||
$parsed = applicationParser($resource->fresh())->toArray();
|
||||
$owner = $resource;
|
||||
$directory = base_configuration_dir().'/applications/'.$resource->uuid;
|
||||
}
|
||||
|
||||
return [
|
||||
'volume' => $parsed['services']['probe']['volumes'][0],
|
||||
'volumes' => $parsed['services']['probe']['volumes'],
|
||||
'storages' => $owner->fileStorages()->where('mount_path', '/fixture')->get(),
|
||||
'directory' => $directory,
|
||||
];
|
||||
}
|
||||
|
||||
it('keeps a non-creating long-form bind and does not manage its host path', function (string $kind) {
|
||||
$result = parseLongBind($kind, longBindCompose('/opt/strict-bind-repro/missing-directory', LONG_BIND_STRICT_OPTIONS));
|
||||
|
||||
expect($result['volume'])->toBe([
|
||||
'type' => 'bind',
|
||||
'source' => '/opt/strict-bind-repro/missing-directory',
|
||||
'target' => '/fixture',
|
||||
'read_only' => true,
|
||||
'bind' => ['create_host_path' => false],
|
||||
])->and($result['storages'])->toBeEmpty();
|
||||
})->with(['service', 'application']);
|
||||
|
||||
it('resolves the relative source of a non-creating long-form bind', function (string $kind) {
|
||||
$result = parseLongBind($kind, longBindCompose('./missing', LONG_BIND_STRICT_OPTIONS));
|
||||
|
||||
expect($result['volume']['source'])->toBe($result['directory'].'/missing')
|
||||
->and($result['volume']['bind'])->toBe(['create_host_path' => false])
|
||||
->and($result['storages'])->toBeEmpty();
|
||||
})->with(['service', 'application']);
|
||||
|
||||
it('keeps the options of a long-form bind that Coolify manages', function (string $kind) {
|
||||
$result = parseLongBind($kind, longBindCompose('./data', LONG_BIND_READ_ONLY_OPTIONS));
|
||||
|
||||
expect($result['volume'])->toBe([
|
||||
'type' => 'bind',
|
||||
'source' => $result['directory'].'/data',
|
||||
'target' => '/fixture',
|
||||
'read_only' => true,
|
||||
// Short syntax always created a missing source; Docker Compose before v2.30 does not do that for long syntax.
|
||||
'bind' => ['create_host_path' => true],
|
||||
])->and($result['storages']->pluck('fs_path')->all())->toBe([$result['directory'].'/data']);
|
||||
})->with(['service', 'application']);
|
||||
|
||||
it('removes volume keys that Docker Compose rejects from a long-form bind', function (string $kind, string $source) {
|
||||
$result = parseLongBind($kind, longBindCompose($source, <<<'YAML'
|
||||
mode: "0755"
|
||||
YAML));
|
||||
|
||||
expect($result['volume'])->not->toHaveKey('mode')
|
||||
->and($result['volume']['bind'])->toBe(['create_host_path' => true]);
|
||||
})->with(['service', 'application'])->with(['./entrypoint.sh', '/var/run/docker.sock']);
|
||||
|
||||
it('removes the storage that an earlier save created when a bind keeps its missing host path', function (string $kind) {
|
||||
$first = parseLongBind($kind, longBindCompose('/opt/strict-bind-repro/missing-directory', LONG_BIND_READ_ONLY_OPTIONS));
|
||||
expect($first['storages'])->toHaveCount(1);
|
||||
|
||||
$owner = $first['storages']->first()->resource;
|
||||
$resource = $kind === 'service' ? $owner->service : $owner;
|
||||
$resource->update(['docker_compose_raw' => longBindCompose('/opt/strict-bind-repro/missing-directory', LONG_BIND_STRICT_OPTIONS)]);
|
||||
$kind === 'service' ? serviceParser($resource->fresh()) : applicationParser($resource->fresh());
|
||||
|
||||
expect($owner->fileStorages()->where('mount_path', '/fixture')->exists())->toBeFalse();
|
||||
})->with(['service', 'application']);
|
||||
|
||||
it('does not give a long-form bind the mode of an earlier short-syntax volume', function (string $kind) {
|
||||
$result = parseLongBind($kind, <<<'YAML'
|
||||
services:
|
||||
probe:
|
||||
image: alpine:3.22
|
||||
volumes:
|
||||
- 'database_data:/backup/data:ro'
|
||||
- '/var/run/docker.sock:/var/run/docker.sock:ro'
|
||||
- type: bind
|
||||
source: /var/backups
|
||||
target: /archive
|
||||
read_only: false
|
||||
YAML);
|
||||
|
||||
expect($result['volumes'][1])->toBe('/var/run/docker.sock:/var/run/docker.sock:ro')
|
||||
->and($result['volumes'][2])->toBe([
|
||||
'type' => 'bind',
|
||||
'source' => '/var/backups',
|
||||
'target' => '/archive',
|
||||
'read_only' => false,
|
||||
'bind' => ['create_host_path' => true],
|
||||
]);
|
||||
})->with(['service', 'application']);
|
||||
@@ -150,7 +150,12 @@ it('mounts application compose bind and content volumes from the server data pat
|
||||
$volumes = applicationParser($application)->get('services')->get('app')['volumes'];
|
||||
$directory = "{$base}/applications/{$application->uuid}";
|
||||
|
||||
expect(collect($volumes)->all())->toContain("{$directory}/data:/app/data", "{$directory}/config/app.conf:/etc/app.conf")
|
||||
expect(collect($volumes)->all())->toContain("{$directory}/data:/app/data", [
|
||||
'type' => 'bind',
|
||||
'source' => "{$directory}/config/app.conf",
|
||||
'target' => '/etc/app.conf',
|
||||
'bind' => ['create_host_path' => true],
|
||||
])
|
||||
// Coolify writes the files through SSH to the normal path.
|
||||
->and($application->fileStorages()->pluck('fs_path')->all())
|
||||
->toContain("/data/coolify/applications/{$application->uuid}/config/app.conf");
|
||||
@@ -171,7 +176,12 @@ it('mounts service compose bind and content volumes from the server data path',
|
||||
$volumes = serviceParser($service->fresh())->get('services')->get('app')['volumes'];
|
||||
$directory = "{$base}/services/{$service->uuid}";
|
||||
|
||||
expect(collect($volumes)->all())->toContain("{$directory}/data:/app/data", "{$directory}/config/app.conf:/etc/app.conf");
|
||||
expect(collect($volumes)->all())->toContain("{$directory}/data:/app/data", [
|
||||
'type' => 'bind',
|
||||
'source' => "{$directory}/config/app.conf",
|
||||
'target' => '/etc/app.conf',
|
||||
'bind' => ['create_host_path' => true],
|
||||
]);
|
||||
})->with(devPathKinds());
|
||||
|
||||
it('mounts the Traefik proxy and log rotation directory from the server data path', function (string $kind, string $base) {
|
||||
|
||||
Reference in New Issue
Block a user