diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb00982..b0de45e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index 480fe1a..55944ad 100644 --- a/README.md +++ b/README.md @@ -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._ > **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. @@ -61,7 +61,14 @@ 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 @@ -69,10 +76,12 @@ 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. ## License diff --git a/Sources/Container-Compose/Codable Structs/DockerCompose.swift b/Sources/Container-Compose/Codable Structs/DockerCompose.swift index dad3c6e..9fee4aa 100644 --- a/Sources/Container-Compose/Codable Structs/DockerCompose.swift +++ b/Sources/Container-Compose/Codable Structs/DockerCompose.swift @@ -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? diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index a12faa8..6935b91 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -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 @@ -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() @@ -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 diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index dea941c..6027166 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -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 @@ -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() @@ -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 @@ -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 { @@ -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 { @@ -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)) diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index 0dfd152..1f45719 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -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") diff --git a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift index 62ef9e0..e069a73 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift @@ -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 @@ -322,7 +358,7 @@ struct ComposeUpTests { try? await stopInstance(location: project.base) } - + enum Errors: Error { case containerNotFound } diff --git a/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift b/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift index 2749fe7..a7c6c9c 100644 --- a/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift +++ b/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift @@ -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' diff --git a/Tests/TestHelpers/DockerComposeYamlFiles.swift b/Tests/TestHelpers/DockerComposeYamlFiles.swift index f1539b7..72127e6 100644 --- a/Tests/TestHelpers/DockerComposeYamlFiles.swift +++ b/Tests/TestHelpers/DockerComposeYamlFiles.swift @@ -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' @@ -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)