fix(storage): support compound modes and safe volume index rollback

Parse comma-separated Docker volume modes correctly, preserve colons in
volume sources, and prevent unsafe rollback when sibling mounts conflict.
This commit is contained in:
Andras Bacsai
2026-08-22 17:24:29 +02:00
parent 2513e5e6d6
commit 42983da1c9
5 changed files with 83 additions and 7 deletions
+6 -4
View File
@@ -380,13 +380,15 @@ class LocalFileVolume extends BaseModel
foreach ($services as $service) {
foreach (data_get($service, 'volumes', []) as $volume) {
if (is_string($volume)) {
$parts = explode(':', $volume);
if (count($parts) < 2) {
$parsedVolume = parseDockerVolumeString($volume);
$source = $parsedVolume['source'];
$target = $parsedVolume['target'];
if ($source === null || $target === null) {
continue;
}
if ($this->matchesComposeVolume($parts[0], $parts[1], $mainDirectory)) {
$options = array_map('trim', explode(',', $parts[2] ?? ''));
if ($this->matchesComposeVolume($source->value(), $target->value(), $mainDirectory)) {
$options = array_map('trim', explode(',', $parsedVolume['mode']?->value() ?? ''));
return in_array('ro', $options, true);
}
+3 -3
View File
@@ -178,7 +178,7 @@ function parseDockerVolumeString(string $volumeString): array
$possibleMode = substr($remaining, $lastColon + 1);
$validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
if (in_array($possibleMode, $validModes)) {
if (array_diff(array_map('trim', explode(',', $possibleMode)), $validModes) === []) {
$mode = $possibleMode;
$target = substr($remaining, 0, $lastColon);
} else {
@@ -208,7 +208,7 @@ function parseDockerVolumeString(string $volumeString): array
// Check if the last part is a valid Docker volume mode
$validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
if (in_array($possibleMode, $validModes)) {
if (array_diff(array_map('trim', explode(',', $possibleMode)), $validModes) === []) {
// It's a mode
// Examples: "gitea:/data:ro" or "./data:/app/data:rw"
$mode = $possibleMode;
@@ -275,7 +275,7 @@ function parseDockerVolumeString(string $volumeString): array
$possibleMode = substr($remaining, $lastColon + 1);
$validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
if (in_array($possibleMode, $validModes)) {
if (array_diff(array_map('trim', explode(',', $possibleMode)), $validModes) === []) {
$mode = $possibleMode;
$target = substr($remaining, 0, $lastColon);
} else {
@@ -35,8 +35,24 @@ return new class extends Migration
public function down(): void
{
$hasIncompatibleSiblingMounts = DB::table('local_file_volumes')
->select(['mount_path', 'resource_id', 'resource_type'])
->groupBy(['mount_path', 'resource_id', 'resource_type'])
->havingRaw('COUNT(*) > 1')
->exists();
if ($hasIncompatibleSiblingMounts) {
throw new RuntimeException(
'Cannot roll back the local file volume unique index while sibling file mounts exist.'
);
}
Schema::table('local_file_volumes', function (Blueprint $table) {
$table->dropUnique('local_file_volumes_source_mount_resource_unique');
$table->unique(
['mount_path', 'resource_id', 'resource_type'],
'local_file_volumes_mount_path_resource_id_resource_type_unique'
);
$table->dropColumn('fs_path_hash');
});
}
@@ -171,6 +171,20 @@ YAML;
expect($volume->isReadOnlyVolume())->toBeTrue();
});
it('isReadOnlyVolume preserves colons in environment variable defaults', function () {
$compose = <<<'YAML'
services:
database:
image: 'postgres:alpine'
volumes:
- '${VOLUME_DB_PATH:-db}:/var/lib/data:ro'
YAML;
$volume = makeReadOnlyVolumeFixture($compose, 'db', '/var/lib/data');
expect($volume->isReadOnlyVolume())->toBeTrue();
});
it('isReadOnlyVolume disambiguates sibling rows with different :ro flags', function () {
$compose = <<<'YAML'
services:
@@ -0,0 +1,44 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
function localFileVolumesUniqueIndexMigration(): object
{
return require database_path('migrations/2026_08_18_120000_update_local_file_volumes_unique_index.php');
}
it('refuses to roll back when sibling file mounts violate the previous uniqueness contract', function () {
$migration = localFileVolumesUniqueIndexMigration();
foreach (['/host/one', '/host/two'] as $index => $fsPath) {
DB::table('local_file_volumes')->insert([
'uuid' => "volume-{$index}",
'fs_path' => $fsPath,
'fs_path_hash' => hash('sha256', $fsPath),
'mount_path' => '/container/file',
'resource_id' => 123,
'resource_type' => 'App\\Models\\Application',
]);
}
expect(fn () => $migration->down())
->toThrow(RuntimeException::class, 'Cannot roll back');
expect(Schema::hasColumn('local_file_volumes', 'fs_path_hash'))->toBeTrue();
expect(collect(Schema::getIndexes('local_file_volumes'))->pluck('name'))
->toContain('local_file_volumes_source_mount_resource_unique');
});
it('restores the previous unique index when rollback data is compatible', function () {
$migration = localFileVolumesUniqueIndexMigration();
$migration->down();
expect(Schema::hasColumn('local_file_volumes', 'fs_path_hash'))->toBeFalse();
expect(collect(Schema::getIndexes('local_file_volumes'))->pluck('name'))
->toContain('local_file_volumes_mount_path_resource_id_resource_type_unique');
});