Skip to content

Commit 2d3a32a

Browse files
committed
fix(compose): fail fast on unusable and missing external volumes
Named volumes now reach the container as a bind mount, so a name that cannot become a host path, or an external volume that was never created, has to stop the project before anything starts instead of surfacing as a raw path error once some services are already up. The check mirrors the external-network one and runs before networks or volumes are created. - reject volume names containing ':', which would misparse inside the -v source:destination argument - resolveVolumeMounts takes the already-resolved backing directories instead of re-deriving them from the project name and volume store - drop the hand-written changelog entry; release-please owns that file
1 parent 5d6f5af commit 2d3a32a

5 files changed

Lines changed: 49 additions & 82 deletions

File tree

CHANGELOG.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
* **image inspect:** `mocker image inspect` and `mocker inspect --type=image` now return Docker-compatible `ImageInspect` JSON arrays with PascalCase keys instead of the previous lowercase `ImageInfo` object shape.
1313
* **MockerKit:** `ImageManager.inspect(_:platform:)` returns `ImageInspect` instead of `ImageInfo`.
1414

15-
### Bug Fixes
16-
17-
* **compose:** named volumes are now mounted into containers, so their data survives `compose up --force-recreate` instead of silently living in the container layer and being discarded. Volumes resolve to their backing directory under the volume store (`<volumesPath>/<runtimeName>/_data`), matching Docker's behaviour; `down --volumes` still removes them.
18-
1915
## [0.9.1](https://github.com/us/mocker/compare/v0.9.0...v0.9.1) (2026-08-09)
2016

2117

Sources/MockerKit/Compose/ComposeOrchestrator.swift

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ public actor ComposeOrchestrator {
8181
}
8282
}
8383

84+
// Named volumes are bind-mounted from their backing directory, so both an
85+
// unusable name and a missing external volume have to fail here — before any
86+
// service starts — rather than as a raw runtime path error half-way through.
87+
if !composeFile.volumes.isEmpty {
88+
let existing = Set(await volumeManager.list().map(\.name))
89+
for volume in composeFile.volumes.values {
90+
let name = volume.runtimeName(projectName: projectName)
91+
_ = try volumeManager.mountpoint(name)
92+
guard !volume.external || existing.contains(name) else {
93+
throw MockerError.operationFailed(
94+
"volume \(name) declared as external, but could not be found")
95+
}
96+
}
97+
}
98+
8499
// Create networks
85100
for (fullName, driver) in Self.networksToCreate(composeFile: composeFile, projectName: projectName) {
86101
do {
@@ -523,12 +538,14 @@ public actor ComposeOrchestrator {
523538
// Parse port mappings
524539
let ports = try service.ports.map { try PortMapping.parse($0) }
525540

541+
// Named volumes bind-mount their backing directory, exactly as Docker does
542+
// internally, so the data survives container recreation.
526543
let volumes = try Self.resolveVolumeMounts(
527544
service.volumes,
528545
projectDir: projectDir,
529-
projectName: projectName,
530-
declaredVolumes: composeFile.volumes,
531-
volumesPath: volumeManager.mountpointPath
546+
namedVolumeSources: composeFile.volumes.mapValues {
547+
try volumeManager.mountpoint($0.runtimeName(projectName: projectName))
548+
}
532549
)
533550

534551
let config = ContainerConfig(
@@ -569,13 +586,11 @@ public actor ComposeOrchestrator {
569586

570587
/// Resolve volume spec strings from a compose service into `VolumeMount` values.
571588
///
572-
/// Bind-mount host paths (absolute, relative or `~`-anchored) and anonymous
573-
/// volumes (container paths only) are included as-is. Named volumes are
574-
/// resolved to their backing directory under `volumesPath`
575-
/// (`<volumesPath>/<runtimeName>/_data`, where `runtimeName` applies the
576-
/// project prefix unless the volume declares an explicit `name:` or is
577-
/// `external:`), and bind-mounted — exactly what Docker does internally, and
578-
/// what keeps the data alive across `compose up --force-recreate`.
589+
/// Bind-mount host paths and anonymous volumes (container paths only) are
590+
/// included as-is. A name declared in the file's top-level `volumes:` section is
591+
/// bind-mounted from its backing directory (`namedVolumeSources`), which is what
592+
/// keeps the data alive across `compose up --force-recreate`; an undeclared bare
593+
/// name has no backing directory and is dropped.
579594
///
580595
/// Relative paths (`./foo`, `../bar`, `data/dir`) are resolved to absolute paths
581596
/// against `projectDir` (the Compose `--project-directory`, i.e. the directory
@@ -585,37 +600,25 @@ public actor ComposeOrchestrator {
585600
static func resolveVolumeMounts(
586601
_ volSpecs: [String],
587602
projectDir: URL,
588-
projectName: String,
589-
declaredVolumes: [String: ComposeVolume],
590-
volumesPath: String
603+
namedVolumeSources: [String: String]
591604
) throws -> [VolumeMount] {
592605
var volumes: [VolumeMount] = []
593606
for volSpec in volSpecs {
594607
var mount = try VolumeMount.parse(volSpec)
595-
if mount.source.isEmpty {
596-
// Anonymous volume: just a container path.
597-
volumes.append(mount)
598-
} else if mount.source.hasPrefix("/") {
599-
// Absolute bind mount.
608+
if mount.source.isEmpty || mount.source.hasPrefix("/") {
600609
volumes.append(mount)
601610
} else if mount.source.hasPrefix("~") {
602-
// Home-relative bind mount.
603611
mount.source = (mount.source as NSString).expandingTildeInPath
604612
volumes.append(mount)
605613
} else if mount.source.hasPrefix(".")
606614
|| mount.source.contains("/") {
607615
// Relative bind mount, anchored to the project directory.
608616
mount.source = projectDir.appendingPathComponent(mount.source).standardized.path
609617
volumes.append(mount)
610-
} else if let declared = declaredVolumes[mount.source] {
611-
// Named volume: bind-mount the volume's backing directory so the
612-
// data survives container recreation (issue #XX).
613-
let runtimeName = declared.runtimeName(projectName: projectName)
614-
mount.source = "\(volumesPath)/\(runtimeName)/_data"
618+
} else if let source = namedVolumeSources[mount.source] {
619+
mount.source = source
615620
volumes.append(mount)
616621
}
617-
// Anything else (e.g. an undeclared bare name) is silently dropped,
618-
// matching the previous behaviour for non-declared names.
619622
}
620623
return volumes
621624
}

Sources/MockerKit/Volume/VolumeManager.swift

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,23 @@ public actor VolumeManager {
2828
}
2929
}
3030

31-
/// Root directory where volume data lives (`<dataRoot>/volumes`). Every named
32-
/// volume is stored under `<mountpointPath>/<name>/_data`, which is what compose
33-
/// bind-mounts into containers so named volumes survive container recreation.
34-
public nonisolated var mountpointPath: String { storagePath }
31+
/// Backing directory holding a volume's data. Compose bind-mounts it so named
32+
/// volumes survive container recreation.
33+
public nonisolated func mountpoint(_ name: String) throws -> String {
34+
try Self.validateName(name)
35+
return "\(storagePath)/\(name)/_data"
36+
}
3537

3638
/// Reject names that would escape the volumes directory. Every volume path is
3739
/// built by interpolating the name, and a compose file can supply it verbatim
3840
/// (`volumes: {data: {name: ...}}`), so `../` must never get through.
3941
static func validateName(_ name: String) throws {
40-
// Only path escape is rejected — anything else stays removable, including
41-
// volumes created before this check existed.
42+
// Path escape and `:` are rejected — the latter because the backing directory
43+
// goes into a `-v source:destination` argument, where it would misparse.
44+
// Anything else stays removable, including volumes created before this check.
4245
let valid = !name.isEmpty
4346
&& !name.contains("/")
47+
&& !name.contains(":")
4448
&& name != "."
4549
&& name != ".."
4650
guard valid else {
@@ -50,12 +54,11 @@ public actor VolumeManager {
5054

5155
/// Create a new volume.
5256
public func create(name: String, driver: String = "local", labels: [String: String] = [:]) throws -> VolumeInfo {
53-
try Self.validateName(name)
5457
guard volumes[name] == nil else {
5558
throw MockerError.operationFailed("Volume \(name) already exists")
5659
}
5760

58-
let mountpoint = "\(config.volumesPath)/\(name)/_data"
61+
let mountpoint = try mountpoint(name)
5962
let fm = FileManager.default
6063
if !fm.fileExists(atPath: mountpoint) {
6164
try fm.createDirectory(atPath: mountpoint, withIntermediateDirectories: true)

Tests/MockerKitTests/ComposeOrchestratorTests.swift

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -185,30 +185,12 @@ struct ComposeOrchestratorTests {
185185

186186
@Test("Declared named volume resolved to its backing directory")
187187
func resolveDeclaredNamedVolume() throws {
188-
let mounts = try Self.resolve(["mydata:/container/data"], declared: ["mydata"])
188+
let mounts = try Self.resolve(["mydata:/container/data"], named: ["mydata": "/volumes/proj-mydata/_data"])
189189
#expect(mounts.count == 1)
190190
#expect(mounts[0].source == "/volumes/proj-mydata/_data")
191191
#expect(mounts[0].destination == "/container/data")
192192
}
193193

194-
@Test("Named volume with explicit name uses it verbatim")
195-
func resolveNamedVolumeCustomName() throws {
196-
let vol = ComposeVolume(name: "mydata", customName: "shared-data")
197-
let mounts = try Self.resolve(["mydata:/container/data"], declaredVolumes: ["mydata": vol])
198-
#expect(mounts.count == 1)
199-
#expect(mounts[0].source == "/volumes/shared-data/_data")
200-
#expect(mounts[0].destination == "/container/data")
201-
}
202-
203-
@Test("External named volume keeps its declared key")
204-
func resolveExternalNamedVolume() throws {
205-
let vol = ComposeVolume(name: "mydata", external: true)
206-
let mounts = try Self.resolve(["mydata:/container/data"], declaredVolumes: ["mydata": vol])
207-
#expect(mounts.count == 1)
208-
#expect(mounts[0].source == "/volumes/mydata/_data")
209-
#expect(mounts[0].destination == "/container/data")
210-
}
211-
212194
@Test("Undeclared bare name is dropped")
213195
func dropUndeclaredName() throws {
214196
let mounts = try Self.resolve(["mydata:/container/data"])
@@ -231,7 +213,7 @@ struct ComposeOrchestratorTests {
231213
"namedvol:/app/named",
232214
"/app/anon",
233215
"sub/dir:/app/sub",
234-
], declared: ["namedvol"])
216+
], named: ["namedvol": "/volumes/proj-namedvol/_data"])
235217
#expect(mounts.count == 5)
236218
let sources = mounts.map(\.source)
237219
#expect(sources.contains("/abs/path"))
@@ -259,29 +241,16 @@ struct ComposeOrchestratorTests {
259241
#expect(mounts[0].destination == "/container/data")
260242
}
261243

262-
/// Convenience wrapper: resolve specs with a fixed project name/volumes path
263-
/// and no declared volumes.
244+
/// Resolve specs against the test project directory, with named volumes already
245+
/// mapped to their backing directories.
264246
private static func resolve(
265247
_ specs: [String],
266-
declared declaredKeys: [String] = []
267-
) throws -> [VolumeMount] {
268-
let declared = Dictionary(uniqueKeysWithValues: declaredKeys.map {
269-
($0, ComposeVolume(name: $0))
270-
})
271-
return try resolve(specs, declaredVolumes: declared)
272-
}
273-
274-
/// Convenience wrapper with an explicit volume declaration map.
275-
private static func resolve(
276-
_ specs: [String],
277-
declaredVolumes: [String: ComposeVolume]
248+
named: [String: String] = [:]
278249
) throws -> [VolumeMount] {
279250
try ComposeOrchestrator.resolveVolumeMounts(
280251
specs,
281252
projectDir: Self.cwd,
282-
projectName: "proj",
283-
declaredVolumes: declaredVolumes,
284-
volumesPath: "/volumes"
253+
namedVolumeSources: named
285254
)
286255
}
287256

@@ -542,9 +511,7 @@ struct ComposeOrchestratorTests {
542511
let mounts = try ComposeOrchestrator.resolveVolumeMounts(
543512
["./data:/container/data"],
544513
projectDir: projectDir,
545-
projectName: "proj",
546-
declaredVolumes: [:],
547-
volumesPath: "/volumes"
514+
namedVolumeSources: [:]
548515
)
549516
#expect(mounts.count == 1)
550517
#expect(mounts[0].source == "/tmp/mocker-issue-60-project/data")
@@ -557,9 +524,7 @@ struct ComposeOrchestratorTests {
557524
let mounts = try ComposeOrchestrator.resolveVolumeMounts(
558525
["../shared:/container/shared"],
559526
projectDir: projectDir,
560-
projectName: "proj",
561-
declaredVolumes: [:],
562-
volumesPath: "/volumes"
527+
namedVolumeSources: [:]
563528
)
564529
#expect(mounts.count == 1)
565530
#expect(mounts[0].source == "/tmp/mocker-issue-60-project/shared")

Tests/MockerKitTests/ComposeVolumeLifecycleTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ struct ComposeVolumeLifecycleTests {
9494
}
9595

9696
@Test("Volume names that would escape the volumes directory are rejected", arguments: [
97-
"../etc", "a/b", "..", ".", "",
97+
"../etc", "a/b", "..", ".", "", "host:path",
9898
])
9999
func rejectsEscapingVolumeNames(name: String) {
100100
#expect(throws: MockerError.self) {

0 commit comments

Comments
 (0)