Skip to content
Open
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
10 changes: 6 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ Contributions are welcome! Please open issues or submit pull requests to help im

1. Fork the repository.
2. Create your feature branch (`git checkout -b feat/YourFeature`).
3. Commit your changes (`git commit -am 'Add new feature'`).
4. Add tests to you changes.
5. Push to the branch (`git push origin feature/YourFeature`).
6. Open a pull request.
3. `swift test` to be sure all tests are passing.
4. Add tests that prove something is broken or missing.
5. Commit your changes (`git commit -am 'Add new feature'`).
6. `swift test` to be sure the tests _you've added_ are passing now.
7. Push to the branch (`git push origin feat/YourFeature`).
8. Open a pull request.
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Container-Compose

Container-Compose brings (limited) Docker Compose support to [Apple Container](https://github.com/apple/container), allowing you to define and orchestrate multi-container applications on Apple platforms using familiar Compose files. This project is not a Docker or Docker Compose wrapper but a tool to bridge Compose workflows with Apple's container management ecosystem.
**Container-Compose is a (mostly) drop-in replacement for `docker-compose` that orchestrates [Apple Containers](https://opensource.apple.com/projects/container/) using the [`container`](https://github.com/apple/container) command.** It brings (currently limited) Docker Compose support, allowing you to define and orchestrate multi-container applications on Apple platforms using familiar compose files. This project aims to bridge Compose workflows with Apple's container management ecosystem. _It is not a Docker or Docker Compose wrapper._
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One interesting thing to note is that it does not wrap around the command executable itself but rather links in the commands as a shared library. I don't know if we want to make that distinction here but it's something to consider.


> **Note:** Container-Compose does not automatically configure DNS for macOS 15 (Sequoia). Use macOS 26 (Tahoe) for an optimal experience.

## Features

- **Compose file support:** Parse and interpret `docker-compose.yml` files to configure Apple Containers.
- **Compose file support:** Parse and interpret Docker Compose files (`docker-compose.yml`) to configure Apple Containers.
- **Apple Container orchestration:** Launch and manage multiple containerized services using Apple’s native container runtime.
- **Environment configuration:** Support for environment variable files (`.env`) to customize deployments.
- **Service dependencies:** Specify service dependencies and startup order.
Expand Down Expand Up @@ -61,18 +61,27 @@ After installation, simply run:
container-compose up
```

You may need to provide a path to your `docker-compose.yml` and `.env` file as arguments.
By default, `container-compose` looks for compose files in the current directory with any of these names:

- `compose.yml`
- `compose.yaml`
- `docker-compose.yml`
- `docker-compose.yaml`

If your compose file does not use one of these names, you will need to use the `--file` option to specify which compose file to use. If your environment file is not `./.env`, you may also need to use the `--env-file` option to specify its location.

## Contributing

Contributions are welcome! Please open issues or submit pull requests to help improve this project.

1. Fork the repository.
2. Create your feature branch (`git checkout -b feat/YourFeature`).
3. Commit your changes (`git commit -am 'Add new feature'`).
4. Add tests to you changes.
5. Push to the branch (`git push origin feature/YourFeature`).
6. Open a pull request.
3. `swift test` to be sure all tests are passing.
4. Add tests that prove something is broken or missing.
5. Commit your changes (`git commit -am 'Add new feature'`).
6. `swift test` to be sure the tests _you've added_ are passing now.
7. Push to the branch (`git push origin feat/YourFeature`).
8. Open a pull request.

## License

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
//


/// Represents the top-level structure of a docker-compose.yml file.
/// Represents the top-level structure of a Docker Compose file.
public struct DockerCompose: Codable {
/// The Compose file format version (e.g., '3.8')
public let version: String?
Expand Down
8 changes: 4 additions & 4 deletions Sources/Container-Compose/Commands/ComposeDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public struct ComposeDown: AsyncParsableCommand {

private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath }

@Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file")
@Option(name: [.customShort("f"), .customLong("file")], help: "The path to your compose file")
var composeFilename: String = "compose.yml"
private var composePath: String { "\(cwd)/\(composeFilename)" } // Path to compose.yml

Expand All @@ -66,7 +66,7 @@ public struct ComposeDown: AsyncParsableCommand {
}
}

// Read docker-compose.yml content
// Read compose file content
guard let yamlData = fileManager.contents(atPath: composePath) else {
let path = URL(fileURLWithPath: composePath)
.deletingLastPathComponent()
Expand All @@ -81,13 +81,13 @@ public struct ComposeDown: AsyncParsableCommand {
// Determine project name for container naming
if let name = dockerCompose.name {
projectName = name
print("Info: Docker Compose project name parsed as: \(name)")
print("Info: compose file project name parsed as: \(name)")
print(
"Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool."
)
} else {
projectName = deriveProjectName(cwd: cwd)
print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")")
print("Info: No 'name' field found in `\(composeFilename)`. Using directory name as project name: \(projectName ?? "")")
}

var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ serviceName, service in
Expand Down
16 changes: 8 additions & 8 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
help: "Detaches from container logs. Note: If you do NOT detach, killing this process will NOT kill the container. To kill the container, run container-compose down")
var detach: Bool = false

@Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file")
@Option(name: [.customShort("f"), .customLong("file")], help: "The path to your compose file")
var composeFilename: String = "compose.yml"
private var composePath: String { "\(cwd)/\(composeFilename)" } // Path to compose.yml

Expand Down Expand Up @@ -90,7 +90,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}
}

// Read compose.yml content
// Read compose file content
guard let yamlData = fileManager.contents(atPath: composePath) else {
let path = URL(fileURLWithPath: composePath)
.deletingLastPathComponent()
Expand All @@ -107,20 +107,20 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {

// Handle 'version' field
if let version = dockerCompose.version {
print("Info: Docker Compose file version parsed as: \(version)")
print("Info: compose file version parsed as: \(version)")
print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.")
}

// Determine project name for container naming
if let name = dockerCompose.name {
projectName = name
print("Info: Docker Compose project name parsed as: \(name)")
print("Info: compose project name parsed as: \(name)")
print(
"Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool."
)
} else {
projectName = deriveProjectName(cwd: cwd)
print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")")
print("Info: No 'name' field found in `\(composeFilename)`. Using directory name as project name: \(projectName ?? "")")
}

// Get Services to use
Expand All @@ -141,7 +141,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
try await stopOldStuff(services.map({ $0.serviceName }), remove: true)

// Process top-level networks
// This creates named networks defined in the docker-compose.yml
// This creates named networks defined in the compose file
if let networks = dockerCompose.networks {
print("\n--- Processing Networks ---")
for (networkName, networkConfig) in networks {
Expand All @@ -151,7 +151,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

// Process top-level volumes
// This creates named volumes defined in the docker-compose.yml
// This creates named volumes defined in the compose file
if let volumes = dockerCompose.volumes {
print("\n--- Processing Volumes ---")
for (volumeName, volumeConfig) in volumes {
Expand All @@ -161,7 +161,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
print("--- Volumes Processed ---\n")
}

// Process each service defined in the docker-compose.yml
// Process each service defined in the compose file
print("\n--- Processing Services ---")

print(services.map(\.serviceName))
Expand Down
2 changes: 1 addition & 1 deletion Sources/Container-Compose/Helper Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public func deriveProjectName(cwd: String) -> String {

/// Converts Docker Compose port specification into a container run -p format.
/// Handles various formats: "PORT", "HOST:PORT", "IP:HOST:PORT", and optional protocol.
/// - Parameter portSpec: The port specification string from docker-compose.yml.
/// - Parameter portSpec: The port specification string from Docker Compose file.
/// - Returns: A properly formatted port binding for `container run -p`.
public func composePortToRunArg(_ portSpec: String) -> String {
// Check for protocol suffix (e.g., "/tcp" or "/udp")
Expand Down
40 changes: 38 additions & 2 deletions Tests/Container-Compose-DynamicTests/ComposeUpTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,43 @@ struct ComposeUpTests {
var composeDown = try ComposeDown.parse(["--cwd", location.path(percentEncoded: false)])
try await composeDown.run()
}


// TODO: use eventual --dry-run flag for this test or factor out to tests
// for eventual `config` command
//
// TODO: iterate the test above for default file present in directory and
// for absolute paths, both of which are reportedly broken
// (see issues #33 and #63)
@Test("ComposeUp uses compose file from --file flag")
func testComposeUpUsesComposeFileFromFileFlag() async throws {
let yaml = DockerComposeYamlFiles.dockerComposeYaml0
let yamlFilename = "my_strangely_named_compose.yml"
let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml, filename: yamlFilename)

let testOutput = Pipe()
let origStdOut = dup(STDOUT_FILENO)
dup2(testOutput.fileHandleForWriting.fileDescriptor, STDOUT_FILENO)

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

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

dup2(origStdOut, STDOUT_FILENO)
close(origStdOut)
testOutput.fileHandleForWriting.closeFile()

let testOutputData = testOutput.fileHandleForReading.readDataToEndOfFile()
let outputText = String(data: testOutputData, encoding: .utf8) ?? ""

let expectedPattern = try Regex("No 'name' field found in `\(yamlFilename)`")
let matches = outputText.matches(of: expectedPattern)
#expect(matches.count == 2)

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

@Test("Test WordPress with MySQL compose file")
func testWordPressCompose() async throws {
let yaml = DockerComposeYamlFiles.dockerComposeYaml1
Expand Down Expand Up @@ -322,7 +358,7 @@ struct ComposeUpTests {

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

enum Errors: Error {
case containerNotFound
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import TestHelpers
@Suite("DockerCompose YAML Parsing Tests")
struct DockerComposeParsingTests {
// MARK: File Snippets
@Test("Parse basic docker-compose.yml with single service")
@Test("Parse basic compose file with single service")
func parseBasicCompose() throws {
let yaml = """
version: '3.8'
Expand Down
22 changes: 15 additions & 7 deletions Tests/TestHelpers/DockerComposeYamlFiles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
import Foundation

public struct DockerComposeYamlFiles {
// A very simple compose file; use for lightweight non-parsing tests
public static let dockerComposeYaml0 = """
version: '3.8'
services:
web:
image: nginx:alpine
"""

public static let dockerComposeYaml1 = """
version: '3.8'

Expand Down Expand Up @@ -259,25 +267,25 @@ public struct DockerComposeYamlFiles {
"""
}

/// Represents a temporary Docker Compose project copied to a temporary location for testing.
/// Represents a temporary compose project copied to a temporary location for testing.
public struct TemporaryProject {
/// The URL of the temporary docker-compose.yaml file.
/// The URL of the temporary compose file file.
public let url: URL

/// The base directory containing the temporary docker-compose.yaml file.
/// The base directory containing the temporary compose file.
public let base: URL

/// The project name derived from the temporary directory name.
public let name: String
}

/// Copies the provided Docker Compose YAML content to a temporary location and returns a
/// Copies the provided compose YAML content to a temporary location and returns a
/// TemporaryProject.
/// - Parameter yaml: The Docker Compose YAML content to copy.
/// - Parameter yaml: The compose YAML content to copy.
/// - Returns: A TemporaryProject containing the URL and project name.
public static func copyYamlToTemporaryLocation(yaml: String) throws -> TemporaryProject {
public static func copyYamlToTemporaryLocation(yaml: String, filename: String = "docker-compose.yaml") throws -> TemporaryProject {
let tempLocation = URL.temporaryDirectory.appending(
path: "Container-Compose_Tests_\(UUID().uuidString)/docker-compose.yaml")
path: "Container-Compose_Tests_\(UUID().uuidString)/\(filename)")
let tempBase = tempLocation.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: tempBase, withIntermediateDirectories: true)
try yaml.write(to: tempLocation, atomically: false, encoding: .utf8)
Expand Down