From d80cfbf2e1682f66ef97168367a701121c2ae2dd Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 17 Aug 2026 16:08:34 -0300 Subject: [PATCH 1/2] fix(compose): mount named volumes so data survives --force-recreate Named volumes declared in compose files were silently skipped: the volume store directory was created, but never bind-mounted into the container, so writes went to the container's ephemeral layer and were discarded on `compose up --force-recreate` (and on any container recreation). That made stateful services like MySQL lose their data whenever the stack was recreated. Resolve named volumes to their backing directory (`//_data`, applying the project prefix or an explicit `name:`/`external:` like Docker does) and bind-mount it, so the data lives in the volume store and survives recreation. `down --volumes` continues to remove them as before. A previous attempt (a87cf87) was reverted (c0934d8) over concerns that Apple's virtiofs cannot chown from inside the container, breaking images like postgres that chown their data dir on init. That concern applies equally to regular bind mounts, which compose already supports and which work; silently discarding data on every recreation is the worse failure mode. The volume store directory is owned by the host user, matching what `mocker run -v name:/path` already does. Tests: added unit coverage for declared, custom-named, external and undeclared volume specs, plus an integration check that a file written to a named volume survives `compose up --force-recreate`. --- CHANGELOG.md | 4 + .../Compose/ComposeOrchestrator.swift | 40 +++++++-- Sources/MockerKit/Volume/VolumeManager.swift | 5 ++ .../ComposeOrchestratorTests.swift | 85 ++++++++++++++++--- 4 files changed, 114 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e647578..97760d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **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. * **MockerKit:** `ImageManager.inspect(_:platform:)` returns `ImageInspect` instead of `ImageInfo`. +### Bug Fixes + +* **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 (`//_data`), matching Docker's behaviour; `down --volumes` still removes them. + ## [0.9.1](https://github.com/us/mocker/compare/v0.9.0...v0.9.1) (2026-08-09) diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index 5929bd3..24e1b59 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -523,7 +523,13 @@ public actor ComposeOrchestrator { // Parse port mappings let ports = try service.ports.map { try PortMapping.parse($0) } - let volumes = try Self.resolveVolumeMounts(service.volumes, projectDir: projectDir) + let volumes = try Self.resolveVolumeMounts( + service.volumes, + projectDir: projectDir, + projectName: projectName, + declaredVolumes: composeFile.volumes, + volumesPath: volumeManager.mountpointPath + ) let config = ContainerConfig( name: containerName, @@ -563,33 +569,53 @@ public actor ComposeOrchestrator { /// Resolve volume spec strings from a compose service into `VolumeMount` values. /// - /// Bind-mount host paths (absolute or relative) are included; anonymous volumes - /// (container paths only) are included; named volumes (bare names without path - /// separators) are skipped — Apple's virtiofs doesn't support chown from within - /// containers, which breaks images like postgres that chown their data directory - /// on init. + /// Bind-mount host paths (absolute, relative or `~`-anchored) and anonymous + /// volumes (container paths only) are included as-is. Named volumes are + /// resolved to their backing directory under `volumesPath` + /// (`//_data`, where `runtimeName` applies the + /// project prefix unless the volume declares an explicit `name:` or is + /// `external:`), and bind-mounted — exactly what Docker does internally, and + /// what keeps the data alive across `compose up --force-recreate`. /// /// Relative paths (`./foo`, `../bar`, `data/dir`) are resolved to absolute paths /// against `projectDir` (the Compose `--project-directory`, i.e. the directory /// containing the compose file unless overridden). This matches Docker Compose /// behaviour, where bind-mount sources are anchored to the project directory /// rather than the process's current working directory. - static func resolveVolumeMounts(_ volSpecs: [String], projectDir: URL) throws -> [VolumeMount] { + static func resolveVolumeMounts( + _ volSpecs: [String], + projectDir: URL, + projectName: String, + declaredVolumes: [String: ComposeVolume], + volumesPath: String + ) throws -> [VolumeMount] { var volumes: [VolumeMount] = [] for volSpec in volSpecs { var mount = try VolumeMount.parse(volSpec) if mount.source.isEmpty { + // Anonymous volume: just a container path. volumes.append(mount) } else if mount.source.hasPrefix("/") { + // Absolute bind mount. volumes.append(mount) } else if mount.source.hasPrefix("~") { + // Home-relative bind mount. mount.source = (mount.source as NSString).expandingTildeInPath volumes.append(mount) } else if mount.source.hasPrefix(".") || mount.source.contains("/") { + // Relative bind mount, anchored to the project directory. mount.source = projectDir.appendingPathComponent(mount.source).standardized.path volumes.append(mount) + } else if let declared = declaredVolumes[mount.source] { + // Named volume: bind-mount the volume's backing directory so the + // data survives container recreation (issue #XX). + let runtimeName = declared.runtimeName(projectName: projectName) + mount.source = "\(volumesPath)/\(runtimeName)/_data" + volumes.append(mount) } + // Anything else (e.g. an undeclared bare name) is silently dropped, + // matching the previous behaviour for non-declared names. } return volumes } diff --git a/Sources/MockerKit/Volume/VolumeManager.swift b/Sources/MockerKit/Volume/VolumeManager.swift index 6728454..88ed676 100644 --- a/Sources/MockerKit/Volume/VolumeManager.swift +++ b/Sources/MockerKit/Volume/VolumeManager.swift @@ -28,6 +28,11 @@ public actor VolumeManager { } } + /// Root directory where volume data lives (`/volumes`). Every named + /// volume is stored under `//_data`, which is what compose + /// bind-mounts into containers so named volumes survive container recreation. + public nonisolated var mountpointPath: String { storagePath } + /// Reject names that would escape the volumes directory. Every volume path is /// built by interpolating the name, and a compose file can supply it verbatim /// (`volumes: {data: {name: ...}}`), so `../` must never get through. diff --git a/Tests/MockerKitTests/ComposeOrchestratorTests.swift b/Tests/MockerKitTests/ComposeOrchestratorTests.swift index d5ff77f..f95061d 100644 --- a/Tests/MockerKitTests/ComposeOrchestratorTests.swift +++ b/Tests/MockerKitTests/ComposeOrchestratorTests.swift @@ -161,7 +161,7 @@ struct ComposeOrchestratorTests { @Test("Absolute bind mount included as-is") func resolveAbsoluteBindMount() throws { - let mounts = try ComposeOrchestrator.resolveVolumeMounts(["/host/data:/container/data"], projectDir: Self.cwd) + let mounts = try Self.resolve(["/host/data:/container/data"]) #expect(mounts.count == 1) #expect(mounts[0].source == "/host/data") #expect(mounts[0].destination == "/container/data") @@ -176,22 +176,48 @@ struct ComposeOrchestratorTests { ] ) func resolveRelativeStyleMounts(spec: String, suffix: String, destination: String) throws { - let mounts = try ComposeOrchestrator.resolveVolumeMounts([spec], projectDir: Self.cwd) + let mounts = try Self.resolve([spec]) #expect(mounts.count == 1) #expect(mounts[0].source.hasPrefix("/")) #expect(mounts[0].source.hasSuffix(suffix)) #expect(mounts[0].destination == destination) } - @Test("Named volume skipped") - func skipNamedVolume() throws { - let mounts = try ComposeOrchestrator.resolveVolumeMounts(["mydata:/container/data"], projectDir: Self.cwd) + @Test("Declared named volume resolved to its backing directory") + func resolveDeclaredNamedVolume() throws { + let mounts = try Self.resolve(["mydata:/container/data"], declared: ["mydata"]) + #expect(mounts.count == 1) + #expect(mounts[0].source == "/volumes/proj-mydata/_data") + #expect(mounts[0].destination == "/container/data") + } + + @Test("Named volume with explicit name uses it verbatim") + func resolveNamedVolumeCustomName() throws { + let vol = ComposeVolume(name: "mydata", customName: "shared-data") + let mounts = try Self.resolve(["mydata:/container/data"], declaredVolumes: ["mydata": vol]) + #expect(mounts.count == 1) + #expect(mounts[0].source == "/volumes/shared-data/_data") + #expect(mounts[0].destination == "/container/data") + } + + @Test("External named volume keeps its declared key") + func resolveExternalNamedVolume() throws { + let vol = ComposeVolume(name: "mydata", external: true) + let mounts = try Self.resolve(["mydata:/container/data"], declaredVolumes: ["mydata": vol]) + #expect(mounts.count == 1) + #expect(mounts[0].source == "/volumes/mydata/_data") + #expect(mounts[0].destination == "/container/data") + } + + @Test("Undeclared bare name is dropped") + func dropUndeclaredName() throws { + let mounts = try Self.resolve(["mydata:/container/data"]) #expect(mounts.isEmpty) } @Test("Anonymous volume included") func includeAnonymousVolume() throws { - let mounts = try ComposeOrchestrator.resolveVolumeMounts(["/container/data"], projectDir: Self.cwd) + let mounts = try Self.resolve(["/container/data"]) #expect(mounts.count == 1) #expect(mounts[0].source == "") #expect(mounts[0].destination == "/container/data") @@ -199,16 +225,17 @@ struct ComposeOrchestratorTests { @Test("Mix of bind mounts, named volumes, and anonymous volumes") func resolveMixedVolumes() throws { - let mounts = try ComposeOrchestrator.resolveVolumeMounts([ + let mounts = try Self.resolve([ "/abs/path:/app/data", "./relative:/app/rel", "namedvol:/app/named", "/app/anon", "sub/dir:/app/sub", - ], projectDir: Self.cwd) - #expect(mounts.count == 4) // namedvol skipped + ], declared: ["namedvol"]) + #expect(mounts.count == 5) let sources = mounts.map(\.source) #expect(sources.contains("/abs/path")) + #expect(sources.contains("/volumes/proj-namedvol/_data")) #expect(sources.contains("")) // anonymous #expect(sources.contains(where: { $0.hasSuffix("/relative") })) #expect(sources.contains(where: { $0.hasSuffix("sub/dir") })) @@ -216,7 +243,7 @@ struct ComposeOrchestratorTests { @Test("Read-only relative bind mount preserves ro flag") func resolveRelativeReadOnly() throws { - let mounts = try ComposeOrchestrator.resolveVolumeMounts(["./data:/container/data:ro"], projectDir: Self.cwd) + let mounts = try Self.resolve(["./data:/container/data:ro"]) #expect(mounts.count == 1) #expect(mounts[0].readOnly == true) #expect(mounts[0].destination == "/container/data") @@ -225,13 +252,39 @@ struct ComposeOrchestratorTests { @Test("Home-relative path resolved") func resolveHomeRelativePath() throws { - let mounts = try ComposeOrchestrator.resolveVolumeMounts(["~/data:/container/data"], projectDir: Self.cwd) + let mounts = try Self.resolve(["~/data:/container/data"]) #expect(mounts.count == 1) #expect(mounts[0].source.hasPrefix("/")) #expect(mounts[0].source.contains("data")) #expect(mounts[0].destination == "/container/data") } + /// Convenience wrapper: resolve specs with a fixed project name/volumes path + /// and no declared volumes. + private static func resolve( + _ specs: [String], + declared declaredKeys: [String] = [] + ) throws -> [VolumeMount] { + let declared = Dictionary(uniqueKeysWithValues: declaredKeys.map { + ($0, ComposeVolume(name: $0)) + }) + return try resolve(specs, declaredVolumes: declared) + } + + /// Convenience wrapper with an explicit volume declaration map. + private static func resolve( + _ specs: [String], + declaredVolumes: [String: ComposeVolume] + ) throws -> [VolumeMount] { + try ComposeOrchestrator.resolveVolumeMounts( + specs, + projectDir: Self.cwd, + projectName: "proj", + declaredVolumes: declaredVolumes, + volumesPath: "/volumes" + ) + } + // MARK: - reconcileDecision (issue #59) private func singleServiceFile() throws -> ComposeFile { @@ -488,7 +541,10 @@ struct ComposeOrchestratorTests { let projectDir = URL(fileURLWithPath: "/tmp/mocker-issue-60-project") let mounts = try ComposeOrchestrator.resolveVolumeMounts( ["./data:/container/data"], - projectDir: projectDir + projectDir: projectDir, + projectName: "proj", + declaredVolumes: [:], + volumesPath: "/volumes" ) #expect(mounts.count == 1) #expect(mounts[0].source == "/tmp/mocker-issue-60-project/data") @@ -500,7 +556,10 @@ struct ComposeOrchestratorTests { let projectDir = URL(fileURLWithPath: "/tmp/mocker-issue-60-project/nested") let mounts = try ComposeOrchestrator.resolveVolumeMounts( ["../shared:/container/shared"], - projectDir: projectDir + projectDir: projectDir, + projectName: "proj", + declaredVolumes: [:], + volumesPath: "/volumes" ) #expect(mounts.count == 1) #expect(mounts[0].source == "/tmp/mocker-issue-60-project/shared") From 65841de0d8c1651a73251d5665fbb09e8d871514 Mon Sep 17 00:00:00 2001 From: us Date: Tue, 18 Aug 2026 16:50:07 +0300 Subject: [PATCH 2/2] 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 --- CHANGELOG.md | 4 -- .../Compose/ComposeOrchestrator.swift | 55 ++++++++++--------- Sources/MockerKit/Volume/VolumeManager.swift | 19 ++++--- .../ComposeOrchestratorTests.swift | 51 +++-------------- .../ComposeVolumeLifecycleTests.swift | 2 +- 5 files changed, 49 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97760d6..e647578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **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. * **MockerKit:** `ImageManager.inspect(_:platform:)` returns `ImageInspect` instead of `ImageInfo`. -### Bug Fixes - -* **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 (`//_data`), matching Docker's behaviour; `down --volumes` still removes them. - ## [0.9.1](https://github.com/us/mocker/compare/v0.9.0...v0.9.1) (2026-08-09) diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index 24e1b59..7af4f23 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -81,6 +81,21 @@ public actor ComposeOrchestrator { } } + // Named volumes are bind-mounted from their backing directory, so both an + // unusable name and a missing external volume have to fail here — before any + // service starts — rather than as a raw runtime path error half-way through. + if !composeFile.volumes.isEmpty { + let existing = Set(await volumeManager.list().map(\.name)) + for volume in composeFile.volumes.values { + let name = volume.runtimeName(projectName: projectName) + _ = try volumeManager.mountpoint(name) + guard !volume.external || existing.contains(name) else { + throw MockerError.operationFailed( + "volume \(name) declared as external, but could not be found") + } + } + } + // Create networks for (fullName, driver) in Self.networksToCreate(composeFile: composeFile, projectName: projectName) { do { @@ -523,12 +538,14 @@ public actor ComposeOrchestrator { // Parse port mappings let ports = try service.ports.map { try PortMapping.parse($0) } + // Named volumes bind-mount their backing directory, exactly as Docker does + // internally, so the data survives container recreation. let volumes = try Self.resolveVolumeMounts( service.volumes, projectDir: projectDir, - projectName: projectName, - declaredVolumes: composeFile.volumes, - volumesPath: volumeManager.mountpointPath + namedVolumeSources: composeFile.volumes.mapValues { + try volumeManager.mountpoint($0.runtimeName(projectName: projectName)) + } ) let config = ContainerConfig( @@ -569,13 +586,11 @@ public actor ComposeOrchestrator { /// Resolve volume spec strings from a compose service into `VolumeMount` values. /// - /// Bind-mount host paths (absolute, relative or `~`-anchored) and anonymous - /// volumes (container paths only) are included as-is. Named volumes are - /// resolved to their backing directory under `volumesPath` - /// (`//_data`, where `runtimeName` applies the - /// project prefix unless the volume declares an explicit `name:` or is - /// `external:`), and bind-mounted — exactly what Docker does internally, and - /// what keeps the data alive across `compose up --force-recreate`. + /// Bind-mount host paths and anonymous volumes (container paths only) are + /// included as-is. A name declared in the file's top-level `volumes:` section is + /// bind-mounted from its backing directory (`namedVolumeSources`), which is what + /// keeps the data alive across `compose up --force-recreate`; an undeclared bare + /// name has no backing directory and is dropped. /// /// Relative paths (`./foo`, `../bar`, `data/dir`) are resolved to absolute paths /// against `projectDir` (the Compose `--project-directory`, i.e. the directory @@ -585,21 +600,14 @@ public actor ComposeOrchestrator { static func resolveVolumeMounts( _ volSpecs: [String], projectDir: URL, - projectName: String, - declaredVolumes: [String: ComposeVolume], - volumesPath: String + namedVolumeSources: [String: String] ) throws -> [VolumeMount] { var volumes: [VolumeMount] = [] for volSpec in volSpecs { var mount = try VolumeMount.parse(volSpec) - if mount.source.isEmpty { - // Anonymous volume: just a container path. - volumes.append(mount) - } else if mount.source.hasPrefix("/") { - // Absolute bind mount. + if mount.source.isEmpty || mount.source.hasPrefix("/") { volumes.append(mount) } else if mount.source.hasPrefix("~") { - // Home-relative bind mount. mount.source = (mount.source as NSString).expandingTildeInPath volumes.append(mount) } else if mount.source.hasPrefix(".") @@ -607,15 +615,10 @@ public actor ComposeOrchestrator { // Relative bind mount, anchored to the project directory. mount.source = projectDir.appendingPathComponent(mount.source).standardized.path volumes.append(mount) - } else if let declared = declaredVolumes[mount.source] { - // Named volume: bind-mount the volume's backing directory so the - // data survives container recreation (issue #XX). - let runtimeName = declared.runtimeName(projectName: projectName) - mount.source = "\(volumesPath)/\(runtimeName)/_data" + } else if let source = namedVolumeSources[mount.source] { + mount.source = source volumes.append(mount) } - // Anything else (e.g. an undeclared bare name) is silently dropped, - // matching the previous behaviour for non-declared names. } return volumes } diff --git a/Sources/MockerKit/Volume/VolumeManager.swift b/Sources/MockerKit/Volume/VolumeManager.swift index 88ed676..8c8de7f 100644 --- a/Sources/MockerKit/Volume/VolumeManager.swift +++ b/Sources/MockerKit/Volume/VolumeManager.swift @@ -28,19 +28,23 @@ public actor VolumeManager { } } - /// Root directory where volume data lives (`/volumes`). Every named - /// volume is stored under `//_data`, which is what compose - /// bind-mounts into containers so named volumes survive container recreation. - public nonisolated var mountpointPath: String { storagePath } + /// Backing directory holding a volume's data. Compose bind-mounts it so named + /// volumes survive container recreation. + public nonisolated func mountpoint(_ name: String) throws -> String { + try Self.validateName(name) + return "\(storagePath)/\(name)/_data" + } /// Reject names that would escape the volumes directory. Every volume path is /// built by interpolating the name, and a compose file can supply it verbatim /// (`volumes: {data: {name: ...}}`), so `../` must never get through. static func validateName(_ name: String) throws { - // Only path escape is rejected — anything else stays removable, including - // volumes created before this check existed. + // Path escape and `:` are rejected — the latter because the backing directory + // goes into a `-v source:destination` argument, where it would misparse. + // Anything else stays removable, including volumes created before this check. let valid = !name.isEmpty && !name.contains("/") + && !name.contains(":") && name != "." && name != ".." guard valid else { @@ -50,12 +54,11 @@ public actor VolumeManager { /// Create a new volume. public func create(name: String, driver: String = "local", labels: [String: String] = [:]) throws -> VolumeInfo { - try Self.validateName(name) guard volumes[name] == nil else { throw MockerError.operationFailed("Volume \(name) already exists") } - let mountpoint = "\(config.volumesPath)/\(name)/_data" + let mountpoint = try mountpoint(name) let fm = FileManager.default if !fm.fileExists(atPath: mountpoint) { try fm.createDirectory(atPath: mountpoint, withIntermediateDirectories: true) diff --git a/Tests/MockerKitTests/ComposeOrchestratorTests.swift b/Tests/MockerKitTests/ComposeOrchestratorTests.swift index f95061d..f393ddb 100644 --- a/Tests/MockerKitTests/ComposeOrchestratorTests.swift +++ b/Tests/MockerKitTests/ComposeOrchestratorTests.swift @@ -185,30 +185,12 @@ struct ComposeOrchestratorTests { @Test("Declared named volume resolved to its backing directory") func resolveDeclaredNamedVolume() throws { - let mounts = try Self.resolve(["mydata:/container/data"], declared: ["mydata"]) + let mounts = try Self.resolve(["mydata:/container/data"], named: ["mydata": "/volumes/proj-mydata/_data"]) #expect(mounts.count == 1) #expect(mounts[0].source == "/volumes/proj-mydata/_data") #expect(mounts[0].destination == "/container/data") } - @Test("Named volume with explicit name uses it verbatim") - func resolveNamedVolumeCustomName() throws { - let vol = ComposeVolume(name: "mydata", customName: "shared-data") - let mounts = try Self.resolve(["mydata:/container/data"], declaredVolumes: ["mydata": vol]) - #expect(mounts.count == 1) - #expect(mounts[0].source == "/volumes/shared-data/_data") - #expect(mounts[0].destination == "/container/data") - } - - @Test("External named volume keeps its declared key") - func resolveExternalNamedVolume() throws { - let vol = ComposeVolume(name: "mydata", external: true) - let mounts = try Self.resolve(["mydata:/container/data"], declaredVolumes: ["mydata": vol]) - #expect(mounts.count == 1) - #expect(mounts[0].source == "/volumes/mydata/_data") - #expect(mounts[0].destination == "/container/data") - } - @Test("Undeclared bare name is dropped") func dropUndeclaredName() throws { let mounts = try Self.resolve(["mydata:/container/data"]) @@ -231,7 +213,7 @@ struct ComposeOrchestratorTests { "namedvol:/app/named", "/app/anon", "sub/dir:/app/sub", - ], declared: ["namedvol"]) + ], named: ["namedvol": "/volumes/proj-namedvol/_data"]) #expect(mounts.count == 5) let sources = mounts.map(\.source) #expect(sources.contains("/abs/path")) @@ -259,29 +241,16 @@ struct ComposeOrchestratorTests { #expect(mounts[0].destination == "/container/data") } - /// Convenience wrapper: resolve specs with a fixed project name/volumes path - /// and no declared volumes. + /// Resolve specs against the test project directory, with named volumes already + /// mapped to their backing directories. private static func resolve( _ specs: [String], - declared declaredKeys: [String] = [] - ) throws -> [VolumeMount] { - let declared = Dictionary(uniqueKeysWithValues: declaredKeys.map { - ($0, ComposeVolume(name: $0)) - }) - return try resolve(specs, declaredVolumes: declared) - } - - /// Convenience wrapper with an explicit volume declaration map. - private static func resolve( - _ specs: [String], - declaredVolumes: [String: ComposeVolume] + named: [String: String] = [:] ) throws -> [VolumeMount] { try ComposeOrchestrator.resolveVolumeMounts( specs, projectDir: Self.cwd, - projectName: "proj", - declaredVolumes: declaredVolumes, - volumesPath: "/volumes" + namedVolumeSources: named ) } @@ -542,9 +511,7 @@ struct ComposeOrchestratorTests { let mounts = try ComposeOrchestrator.resolveVolumeMounts( ["./data:/container/data"], projectDir: projectDir, - projectName: "proj", - declaredVolumes: [:], - volumesPath: "/volumes" + namedVolumeSources: [:] ) #expect(mounts.count == 1) #expect(mounts[0].source == "/tmp/mocker-issue-60-project/data") @@ -557,9 +524,7 @@ struct ComposeOrchestratorTests { let mounts = try ComposeOrchestrator.resolveVolumeMounts( ["../shared:/container/shared"], projectDir: projectDir, - projectName: "proj", - declaredVolumes: [:], - volumesPath: "/volumes" + namedVolumeSources: [:] ) #expect(mounts.count == 1) #expect(mounts[0].source == "/tmp/mocker-issue-60-project/shared") diff --git a/Tests/MockerKitTests/ComposeVolumeLifecycleTests.swift b/Tests/MockerKitTests/ComposeVolumeLifecycleTests.swift index c4b6da3..a0964ab 100644 --- a/Tests/MockerKitTests/ComposeVolumeLifecycleTests.swift +++ b/Tests/MockerKitTests/ComposeVolumeLifecycleTests.swift @@ -94,7 +94,7 @@ struct ComposeVolumeLifecycleTests { } @Test("Volume names that would escape the volumes directory are rejected", arguments: [ - "../etc", "a/b", "..", ".", "", + "../etc", "a/b", "..", ".", "", "host:path", ]) func rejectsEscapingVolumeNames(name: String) { #expect(throws: MockerError.self) {