Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,49 @@ container-compose up

You may need to provide a path to your `docker-compose.yml` and `.env` file as arguments.

## Utilities

For guest-kernel swaps when testing SMB/NFS support with Apple `container`, this repo includes:

```sh
./scripts/setup-container-kernel.sh --binary /path/to/vmlinux --force
```

That script installs the kernel with `container system kernel set`, restarts the `container` services, and prints the configured kernel path before and after the change.
Omit `--force` to be prompted before replacing the configured kernel, or add `--dry-run` to preview the commands without changing your system.
After changing the default kernel, recreate existing test containers so their lightweight VMs boot with the new kernel.

### Building an SMB-ready guest kernel

SMB/CIFS mounts require a guest kernel with CIFS support enabled. Apple `containerization` includes that support in `kernel/config-arm64` as of apple/containerization#681, and Kata's common filesystem fragment also enables CIFS support.

Build a compatible kernel from `apple/containerization`:

```sh
git clone https://github.com/apple/containerization.git
cd containerization/kernel

# Optional sanity check: the config should include CIFS and NFS support.
grep -E 'CONFIG_(CIFS|NFS_FS)=' config-arm64

make
```

The build uses Apple `container` to build inside a containerized toolchain. When it completes, the kernel artifact is:

```sh
containerization/kernel/vmlinux
```

Install that kernel for Apple `container` with this repo's helper:

```sh
cd /path/to/Container-Compose
./scripts/setup-container-kernel.sh --binary /path/to/containerization/kernel/vmlinux --force
```

Then recreate existing test containers so their lightweight VMs boot with the new kernel.

## Contributing

Contributions are welcome! Please open issues or submit pull requests to help improve this project.
Expand Down
124 changes: 83 additions & 41 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
private var environmentVariables: [String: String] = [:]
private var containerIps: [String: String] = [:]
private var containerConsoleColors: [String: NamedColor] = [:]
private var composeVolumes: [String: Volume] = [:]

private static let availableContainerConsoleColors: Set<NamedColor> = [
.blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green,
Expand All @@ -120,6 +121,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
// Decode the YAML file into the DockerCompose struct
let dockerComposeString = String(data: yamlData, encoding: .utf8)!
let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString)
composeVolumes = dockerCompose.volumes?.compactMapValues { $0 } ?? [:]

// Load environment variables from .env file
environmentVariables = loadEnvFile(path: envFilePath)
Expand Down Expand Up @@ -175,7 +177,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
print("\n--- Processing Volumes ---")
for (volumeName, volumeConfig) in volumes {
guard let volumeConfig else { continue }
await createVolumeHardLink(name: volumeName, config: volumeConfig)
try await createVolume(name: volumeName, config: volumeConfig)
}
print("--- Volumes Processed ---\n")
}
Expand Down Expand Up @@ -274,17 +276,60 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}
}

private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async {
guard let projectName else { return }
let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name
private func createVolume(name volumeName: String, config volumeConfig: Volume) async throws {
let actualVolumeName = effectiveVolumeName(for: volumeName)
let normalizedVolume = VolumeConfigurationNormalizer.normalized(from: volumeConfig)

let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)")
let volumePath = volumeUrl.path(percentEncoded: false)
if let externalVolume = volumeConfig.external, externalVolume.isExternal {
print("Info: Volume '\(volumeName)' is declared as external.")
print("This tool assumes external volume '\(externalVolume.name ?? actualVolumeName)' already exists and will not attempt to create it.")
return
}

print(
"Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead."
)
try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true)
do {
let existingVolume = try await ClientVolume.inspect(actualVolumeName)
if existingVolume.driver != normalizedVolume.driver || existingVolume.options != normalizedVolume.driverOpts {
print(
"Error: Volume '\(volumeName)' already exists as '\(actualVolumeName)', but its configuration does not match the Compose file."
)
print(
"Existing driver/options: \(existingVolume.driver) \(existingVolume.options). Expected: \(normalizedVolume.driver) \(normalizedVolume.driverOpts)."
)
print("Delete the existing volume and re-run `container-compose up` to recreate it with the correct settings.")
throw ComposeError.volumeConfigurationMismatch(volumeName)
} else {
print("Volume '\(volumeName)' already exists as '\(actualVolumeName)'")
}
return
} catch {
// Fall through to creation.
}

do {
let labels = volumeConfig.labels ?? [:]
_ = try await ClientVolume.create(
name: actualVolumeName,
driver: normalizedVolume.driver,
driverOpts: normalizedVolume.driverOpts,
labels: labels
)
print("Created volume: \(volumeName) (Actual name: \(actualVolumeName), Driver: \(normalizedVolume.driver))")
} catch {
print("Error: Failed to create volume '\(volumeName)' as '\(actualVolumeName)': \(error.localizedDescription)")
throw error
}
}

private func effectiveVolumeName(for volumeName: String) -> String {
guard let config = composeVolumes[volumeName] else {
return volumeName
}

if let external = config.external, external.isExternal {
return external.name ?? config.name ?? volumeName
}

return config.name ?? volumeName
}

private func setupNetwork(name networkName: String, config networkConfig: Network?) async throws {
Expand Down Expand Up @@ -688,33 +733,34 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
private func configVolume(_ volume: String) async throws -> [String] {
let resolvedVolume = resolveVariable(volume, with: environmentVariables)

var runCommandArgs: [String] = []

// Parse the volume string: destination[:mode]
// Parse the volume string: source:destination[:mode] or destination for anonymous volumes.
let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init)

if components.count == 1 {
return ["-v", resolvedVolume]
}

guard components.count >= 2 else {
print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.")
print("Warning: Volume entry '\(resolvedVolume)' has an invalid format. Skipping.")
return []
}

let source = components[0]
let destination = components[1]
let mode = components.count == 3 ? components[2] : nil

// Check if the source looks like a host path (contains '/' or starts with '.')
// This heuristic helps distinguish bind mounts from named volume references.
if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") {
// This is likely a bind mount (local path to container path)
var isDirectory: ObjCBool = false
// Ensure the path is absolute or relative to the current directory for FileManager
let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source)
let expandedSource = NSString(string: source).expandingTildeInPath
let fullHostPath = source.starts(with: "/") || source.starts(with: "~") ? expandedSource : (cwd + "/" + source)

if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) {
if isDirectory.boolValue {
// Host path exists and is a directory, add the volume
runCommandArgs.append("-v")
// Reconstruct the volume string without mode, ensuring it's source:destination
runCommandArgs.append("\(source):\(destination)") // Use original source for command argument
return ["-v", resolvedVolume]
} else {
// Host path exists but is a file
print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.")
Expand All @@ -724,32 +770,23 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
do {
try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil)
print("Info: Created missing host directory for volume: \(fullHostPath)")
runCommandArgs.append("-v")
runCommandArgs.append("\(source):\(destination)") // Use original source for command argument
return ["-v", resolvedVolume]
} catch {
print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.")
}
}
} else {
guard let projectName else { return [] }
let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)")
let volumePath = volumeUrl.path(percentEncoded: false)

let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent()
let destinationPath = destinationUrl.path(percentEncoded: false)

print(
"Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead."
)
try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true)

// Host path exists and is a directory, add the volume
runCommandArgs.append("-v")
// Reconstruct the volume string without mode, ensuring it's source:destination
runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument
let actualSource = effectiveVolumeName(for: source)
let volumeArgument: String
if let mode, !mode.isEmpty {
volumeArgument = "\(actualSource):\(destination):\(mode)"
} else {
volumeArgument = "\(actualSource):\(destination)"
}
return ["-v", volumeArgument]
}

return runCommandArgs
return []
}
}

Expand Down Expand Up @@ -783,9 +820,14 @@ extension ComposeUp {
process.standardOutput = stdoutPipe
process.standardError = stderrPipe

process.environment = ProcessInfo.processInfo.environment.merging([
"PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
]) { _, new in new }
let defaultSearchPath = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
var environment = ProcessInfo.processInfo.environment
if let inheritedPath = environment["PATH"], !inheritedPath.isEmpty {
environment["PATH"] = "\(inheritedPath):\(defaultSearchPath)"
} else {
environment["PATH"] = defaultSearchPath
}
process.environment = environment

let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading
Expand Down
3 changes: 3 additions & 0 deletions Sources/Container-Compose/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,16 @@ public enum YamlError: Error, LocalizedError {
public enum ComposeError: Error, LocalizedError {
case imageNotFound(String)
case invalidProjectName
case volumeConfigurationMismatch(String)

public var errorDescription: String? {
switch self {
case .imageNotFound(let name):
return "Service \(name) must define either 'image' or 'build'."
case .invalidProjectName:
return "Could not find project name."
case .volumeConfigurationMismatch(let name):
return "Volume \(name) already exists with a different configuration."
}
}
}
Expand Down
62 changes: 62 additions & 0 deletions Sources/Container-Compose/VolumeConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

struct NormalizedVolumeConfiguration: Equatable {
let driver: String
let driverOpts: [String: String]
}

enum VolumeConfigurationNormalizer {
static func normalized(from volumeConfig: Volume) -> NormalizedVolumeConfiguration {
let driver = volumeConfig.driver ?? "local"
let driverOpts = volumeConfig.driver_opts ?? [:]

guard driver == "local", let type = driverOpts["type"]?.lowercased() else {
return NormalizedVolumeConfiguration(driver: driver, driverOpts: driverOpts)
}

switch type {
case "cifs", "smb":
return NormalizedVolumeConfiguration(driver: "smb", driverOpts: normalizeNetworkDriverOptions(driverOpts))
case "nfs":
return NormalizedVolumeConfiguration(driver: "nfs", driverOpts: normalizeNetworkDriverOptions(driverOpts))
default:
return NormalizedVolumeConfiguration(driver: driver, driverOpts: driverOpts)
}
}

private static func normalizeNetworkDriverOptions(_ driverOpts: [String: String]) -> [String: String] {
var normalized = driverOpts

if let device = normalized.removeValue(forKey: "device") {
normalized["share"] = device
}

if let optionString = normalized.removeValue(forKey: "o") {
for rawOption in optionString.split(separator: ",").map(String.init) where !rawOption.isEmpty {
let parts = rawOption.split(separator: "=", maxSplits: 1).map(String.init)
if parts.count == 2 {
normalized[parts[0]] = parts[1]
} else {
normalized[rawOption] = ""
}
}
}

normalized.removeValue(forKey: "type")
return normalized
}
}
45 changes: 45 additions & 0 deletions Tests/Container-Compose-DynamicTests/ComposeUpTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,51 @@ struct ComposeUpTests {

try? await stopInstance(location: project.base)
}

@Test("Test compose up uses explicit top-level volume name")
func testComposeUpUsesTopLevelVolumeName() async throws {
let volumeName = "compose-volume-\(UUID().uuidString.lowercased())"
let yaml = """
version: "3.8"
services:
app:
image: nginx:alpine
volumes:
- app-data:/usr/share/nginx/html
volumes:
app-data:
name: \(volumeName)
"""

let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml)

var composeUp = try ComposeUp.parse(["-d", "--cwd", project.base.path(percentEncoded: false)])
try await composeUp.run()

let containers = try await ContainerClient().list()
.filter {
$0.configuration.id.contains(project.name)
}

guard let appContainer = containers.first(where: { $0.configuration.id == "\(project.name)-app" }) else {
throw Errors.containerNotFound
}

#expect(appContainer.status == .running)
#expect(appContainer.configuration.mounts.count == 1)
#expect(appContainer.configuration.mounts.first?.volumeName == volumeName)
#expect(appContainer.configuration.mounts.first?.destination == "/usr/share/nginx/html")

let createdVolume = try await ClientVolume.inspect(volumeName)
#expect(createdVolume.name == volumeName)
#expect(createdVolume.driver == "local")

var composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)])
try await composeDown.run()

try? await ClientVolume.delete(name: volumeName)
try? await stopInstance(location: project.base)
}

// A locally-built image whose reference contains a `/` (e.g. `myorg/foo:local`)
// must not trigger a registry pull when referenced from a compose file.
Expand Down
Loading