Files
coolify/tests/Feature/ComposeLongBindOptionsTest.php
T
Andras Bacsai 4e55c58a28 fix(storage): keep long-form bind sources with variable or vault references
Docker Compose resolves these values from the .env file, so the parser no
longer rejects or misresolves them. Also copy the expected parser fixtures
when parser-snapshot-diff compares with a git ref.
2026-10-20 13:47:41 +02:00

238 lines
10 KiB
PHP

<?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.
*
* @param array<string, string> $variables Environment variables that the resource sets before the parse
* @return array{volume: mixed, volumes: array<int, mixed>, storages: Collection, directory: string}
*/
function parseLongBind(string $kind, string $compose, array $variables = []): 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();
foreach ($variables as $key => $value) {
$resource->environment_variables()->create(['key' => $key, 'value' => $value]);
}
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();
foreach ($variables as $key => $value) {
$resource->environment_variables()->create(['key' => $key, 'value' => $value]);
}
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']);
it('uses the default of a ${VAR:-default} long-form bind source as the host path', function (string $kind) {
$result = parseLongBind($kind, longBindCompose("'\${AO_CONFIG_FILE:-./agent-orchestrator.yaml}'", LONG_BIND_READ_ONLY_OPTIONS));
expect($result['volume']['source'])->toBe($result['directory'].'/agent-orchestrator.yaml')
->and($result['storages']->pluck('fs_path')->all())->toBe([$result['directory'].'/agent-orchestrator.yaml']);
})->with(['service', 'application']);
it('keeps the volume name of a long-form named volume with a ${VAR:-default} source', function (string $kind) {
$result = parseLongBind($kind, <<<'YAML'
services:
probe:
image: alpine:3.22
volumes:
- type: volume
source: '${DATA_VOLUME:-pgdata}'
target: /fixture
YAML);
expect($result['volume']['source'])->toEndWith('_data-volume-pgdata');
})->with(['service', 'application']);
it('uses the value that the resource sets for a ${VAR:-default} long-form bind source', function (string $kind, string $value, string $expected) {
$result = parseLongBind($kind, longBindCompose("'\${AO_CONFIG_FILE:-./agent-orchestrator.yaml}'", LONG_BIND_READ_ONLY_OPTIONS), ['AO_CONFIG_FILE' => $value]);
$expected = str_replace('{directory}', $result['directory'], $expected);
expect($result['volume']['source'])->toBe($expected)
->and($result['storages']->pluck('fs_path')->all())->toBe([$expected]);
})->with(['service', 'application'])->with([
'absolute value' => ['/opt/ao-custom.yaml', '/opt/ao-custom.yaml'],
'relative value' => ['./custom.yaml', '{directory}/custom.yaml'],
'empty value' => ['', '{directory}/agent-orchestrator.yaml'],
]);
it('rejects an unsafe value for a ${VAR:-default} long-form bind source', function (string $kind) {
parseLongBind($kind, longBindCompose("'\${AO_CONFIG_FILE:-./agent-orchestrator.yaml}'", LONG_BIND_READ_ONLY_OPTIONS), ['AO_CONFIG_FILE' => '/opt/x;touch /tmp/pwned']);
})->with(['service', 'application'])->throws(Exception::class, 'the value of AO_CONFIG_FILE is not a safe path');
it('keeps a ${VAR:-default} long-form bind source for Docker Compose when the value of the variable is not a plain path', function (string $kind, string $value) {
$source = '${AO_CONFIG_FILE:-./agent-orchestrator.yaml}';
$result = parseLongBind($kind, longBindCompose("'{$source}'", LONG_BIND_READ_ONLY_OPTIONS), ['BASE_DIR' => '/srv', 'AO_CONFIG_FILE' => $value]);
expect($result['volume']['source'])->toBe($source);
})->with(['service', 'application'])->with([
'braced variable reference' => ['${BASE_DIR}/ao.yaml'],
'variable reference' => ['$BASE_DIR/ao.yaml'],
'vault reference' => ['{{vault.AO_CONFIG_FILE}}'],
]);