fix(previews): do not bind preview volumes to the production host device

A preview volume no longer gets driver_opts that name a host device, so it
does not mount the same folder or disk as the production volume. tmpfs
options are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-10-02 16:01:20 +02:00
1 parent a0fa21b448
commit 7ec46faecb
2 files changed
+63 -7

No files matched your search

+11 -3
View File
@@ -494,15 +494,23 @@ function composeRenamedVolumeDeclaration(mixed $declaration, string $name): arra
* name-only declaration: Docker created it without the options, and Docker Compose would otherwise
* ask to recreate it on every deployment.
*
* A preview volume does not get `driver_opts` that name a host device (for example a bind mount of a
* host folder): the preview would otherwise mount the same folder or disk as the production volume.
*
* @return array<string, mixed>
*/
function composeRenamedVolumeDeclarationFor(mixed $declaration, string $name, ?LocalPersistentVolume $volume): array
function composeRenamedVolumeDeclarationFor(mixed $declaration, string $name, ?LocalPersistentVolume $volume, bool $isPreview = false): array
{
if ($volume?->ignores_compose_driver_options) {
return ['name' => $name];
}
return composeRenamedVolumeDeclaration($declaration, $name);
$renamed = composeRenamedVolumeDeclaration($declaration, $name);
if ($isPreview && filled(data_get($renamed, 'driver_opts.device')) && data_get($renamed, 'driver_opts.type') !== 'tmpfs') {
unset($renamed['driver_opts']);
}
return $renamed;
}
/**
@@ -1509,7 +1517,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resource_type' => get_class($originalResource),
]
);
$topLevel->get('volumes')->put($name, composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume));
$topLevel->get('volumes')->put($name, composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume, $isPullRequest));
}
dispatch(new ServerFilesFromServerJob($originalResource));
$volumesParsed->put($index, $volume);
@@ -90,6 +90,21 @@ volumes:
app-data: {}
YAML;
const DRIVER_OPTIONS_TMPFS_COMPOSE = <<<'YAML'
services:
web:
image: nginx:alpine
volumes:
- 'opts:/data'
volumes:
opts:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=100m
YAML;
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
@@ -172,6 +187,22 @@ function expectedDriverOptionsVolume(string $name): array
];
}
/**
* The declaration of a preview volume: the bind device of the production volume is not shared.
*
* @return array<string, mixed>
*/
function expectedPreviewDriverOptionsVolume(string $name): array
{
return [
'driver' => 'local',
'labels' => [
'com.example.purpose' => 'host-folder',
],
'name' => $name,
];
}
/**
* @param array<int, mixed> $volumes
* @return list<string>
@@ -208,7 +239,8 @@ describe('applicationParser', function () {
'long syntax' => [DRIVER_OPTIONS_LONG_COMPOSE],
]);
it('keeps driver options on the preview volume', function () {
it('does not bind the preview volume to the host folder of the production volume', function () {
// A preview volume with the same bind device would mount the production data.
$application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE);
$uuid = $application->uuid;
$preview = driverOptionsPreview($application);
@@ -217,7 +249,21 @@ describe('applicationParser', function () {
$name = addPreviewDeploymentSuffix("{$uuid}_opts", 42);
expect(driverOptionsVolumeSources($parsed['services']['web-pr-42']['volumes']))->toBe([$name])
->and($parsed['volumes'][$name])->toBe(expectedDriverOptionsVolume($name));
->and($parsed['volumes'][$name])->toBe(expectedPreviewDriverOptionsVolume($name));
});
it('keeps tmpfs driver options on the preview volume', function () {
$application = driverOptionsApplication(DRIVER_OPTIONS_TMPFS_COMPOSE);
$preview = driverOptionsPreview($application);
$name = addPreviewDeploymentSuffix("{$application->uuid}_opts", 42);
$parsed = applicationParser($application, 42, $preview->id)->toArray();
expect($parsed['volumes'][$name])->toBe([
'driver' => 'local',
'driver_opts' => ['type' => 'tmpfs', 'device' => 'tmpfs', 'o' => 'size=100m'],
'name' => $name,
]);
});
it('replaces the name of the declaration with the renamed volume', function () {
@@ -349,7 +395,9 @@ describe('volumes created before driver options were kept', function () {
expect($parsed['volumes'][$name])->toBe(['name' => $name]);
});
it('keeps driver options on a new preview volume of an existing application', function () {
it('applies the preview driver options to a new preview volume of an existing application', function () {
// The new preview volume is a new Docker volume, so the flag of the production volume does not apply;
// the bind device of the production volume is still not shared with the preview.
$application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE);
applicationParser($application);
markVolumesAsCreatedBeforeDriverOptions();
@@ -358,7 +406,7 @@ describe('volumes created before driver options were kept', function () {
$parsed = applicationParser($application->fresh(), 42, $preview->id)->toArray();
expect($parsed['volumes'][$name])->toBe(expectedDriverOptionsVolume($name));
expect($parsed['volumes'][$name])->toBe(expectedPreviewDriverOptionsVolume($name));
});
it('keeps the name-only declaration of an existing service volume', function () {