diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cc84f3e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g., v1.0.0)' + required: false + type: string + +jobs: + build-release: + runs-on: macos-26 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Swift + uses: maartene/setup-swift@main + with: + swift-version: '6.2' + + - name: Build Release + run: swift build -c release + + - name: Create Release + if: github.event_name == 'push' + uses: softprops/action-gh-release@v1 + with: + name: ${{ github.ref_name }} + draft: false + prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') }} + files: | + .build/release/container-compose + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload to Release + if: github.event_name == 'workflow_dispatch' + run: | + # Create release if it doesn't exist + VERSION="${{ github.inputs.version || 'latest' }}" + TAG="v${VERSION#v}" + + # Create or get release ID + RELEASE_ID=$(gh api repos/${{ github.repository }}/releases/tags/$TAG --jq '.id' 2>/dev/null || echo "") + + if [ -z "$RELEASE_ID" ]; then + RELEASE_ID=$(gh api repos/${{ github.repository }}/releases -X POST \ + --field tag_name="$TAG" \ + --field name="$TAG" \ + --field draft=false \ + --jq '.id') + fi + + # Upload asset + gh api repos/${{ github.repository }}/releases/$RELEASE_ID/assets \ + -F "file=@.build/release/container-compose" \ + -F "name=container-compose" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 857bedd..e4a4e07 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,56 +1,88 @@ -name: Tests +name: Tests and Release on: # Manual trigger only - run via workflow_dispatch workflow_dispatch: # Required status check for PRs (but doesn't auto-run on commits) push: - branches: [ main ] + branches: [main] paths: - - 'Sources/**' - - 'Tests/**' - - 'Package.swift' - - '.github/workflows/tests.yml' + - "Sources/**" + - "Tests/**" + - "Package.swift" + - ".github/workflows/tests.yml" pull_request: - branches: [ main ] + branches: [main] paths: - - 'Sources/**' - - 'Tests/**' - - 'Package.swift' - - '.github/workflows/tests.yml' + - "Sources/**" + - "Tests/**" + - "Package.swift" + - ".github/workflows/tests.yml" jobs: test: name: Run Swift Static Tests runs-on: macos-26 - + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Swift + uses: maartene/setup-swift@main + with: + swift-version: "6.2" + + - name: Cache Swift dependencies + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Build + run: swift build --build-tests + + - name: Run static tests + run: swift test --filter Container-Compose-StaticTests. + + - name: Upload static test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: .build/debug/*.xctest + if-no-files-found: ignore + + build-release: + name: Build Release Binary + runs-on: macos-26 + needs: test + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Swift - uses: maartene/setup-swift@main - with: - swift-version: "6.2" - - - name: Cache Swift dependencies - uses: actions/cache@v4 - with: - path: .build - key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} - restore-keys: | - ${{ runner.os }}-spm- - - - name: Build - run: swift build --build-tests - - - name: Run static tests - run: swift test --filter Container-Compose-StaticTests. - - - name: Upload static test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-results - path: .build/debug/*.xctest - if-no-files-found: ignore + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Swift + uses: maartene/setup-swift@main + with: + swift-version: "6.2" + + - name: Cache Swift dependencies + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-release-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm-release- + + - name: Build Release + run: swift build -c release + + - name: Upload Binary + uses: actions/upload-artifact@v4 + with: + name: container-compose-release + path: .build/release/container-compose + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8f11e6e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# CHANGELOG + +## v0.9.1 - Fork release (explicitcontextualunderstanding) + +This release bundles several upstream fixes and improvements merged into this fork. Highlights and user-facing notes: + +- dnsSearch support + - Commit: https://github.com/Mcrich23/Container-Compose/commit/d509f8af30f9d2382c1804f575ea0f22eb4e5734 + - User note: Services can now specify dns_search/dnsSearch entries so containers can resolve each other by name using custom DNS search domains. Configure in your service's networks or service definition. + +- Multi-stage Docker build target support + - Commit: https://github.com/Mcrich23/Container-Compose/commit/02ca6462b84121c1553bd7adb862ee22aabc4997 + - User note: When using build: with Dockerfiles that include multiple stages, the `target` field is respected so you can build a specific stage (e.g., `build: { context: ".", target: "release" }`). + +- Improved volume handling and named-volume destination preservation + - Commits/PRs: https://github.com/Mcrich23/Container-Compose/commit/b1badf86a4faf5c6ed512643e255760073d38988, https://github.com/Mcrich23/Container-Compose/pull/32, https://github.com/Mcrich23/Container-Compose/pull/42 + - User note: Named volumes now preserve full destination paths (e.g., `- elasticsearch-data:/usr/share/elasticsearch/data`), and relative host paths are normalized to absolute paths for bind mounts. + +- Correct --entrypoint placement + - Commit: https://github.com/Mcrich23/Container-Compose/commit/84201f9416f4a5f1bd383763679f8e2fd7579e94 + - User note: Entrypoint overrides in compose files are now passed to the container run command properly (as `--entrypoint ` before the image), preventing unexpected immediate container exit. + +- Startup/wait fixes and improved command debugging + - Commits: https://github.com/Mcrich23/Container-Compose/commit/8a4e5bb0e634155d122ac5d93905a75dcbf5b3da, https://github.com/Mcrich23/Container-Compose/commit/eeddb266a45686c99f53f300c2c5d049b1f3b157, https://github.com/Mcrich23/Container-Compose/commit/4968a8669babe7822ada82cc90328f102edfd02e + - User note: Waiting logic no longer times out incorrectly when a container is already running; the tool prints the exact container run command being executed to aid debugging. + +- CI and release automation (fork-specific) + - Origin commits: https://github.com/explicitcontextualunderstanding/Container-Compose/commit/3f20dbf6a6268a93fa196632caa2c178214892f7 and https://github.com/explicitcontextualunderstanding/Container-Compose/commit/98b7fc4a50467067158d15eb47d9acca78121719 + - User note: This fork adds GitHub Actions for release automation used by the maintainers of this fork. + + +For full details and links to the source commits/PRs, see FORK_CHANGES.md. diff --git a/FORK_CHANGES.md b/FORK_CHANGES.md new file mode 100644 index 0000000..9697751 --- /dev/null +++ b/FORK_CHANGES.md @@ -0,0 +1,140 @@ +Summary of patches incorporated into this fork (expanded with upstream links) + +This file summarizes notable patches and upstream PRs/commits that were incorporated into this fork (explicitcontextualunderstanding/Container-Compose) relative to the upstream repository (Mcrich23/Container-Compose). + +Notable changes included in this fork (with links): + +- fix: remove RuntimeStatus type that doesn't exist (commit: c509a2f) + - Origin commit: https://github.com/explicitcontextualunderstanding/Container-Compose/commit/c509a2f07c2fe251deb66f0a0a920739e39c21a4 + - Description: Removes a reference to a RuntimeStatus type that wasn't present in the container library; cleans up status tracking used only for error messages. + +- fixed incorrect waiting for running container (commit: 8a4e5bb) + - Upstream commit: https://github.com/Mcrich23/Container-Compose/commit/8a4e5bb0e634155d122ac5d93905a75dcbf5b3da + - Description: Fixes wait logic so waiting for container startup no longer always times out; user is informed when the container is already running. + +- there is no longer 30 second timeout when container is already started (commit: eeddb26) + - Upstream commit: https://github.com/Mcrich23/Container-Compose/commit/eeddb266a45686c99f53f300c2c5d049b1f3b157 + - Description: Removes unnecessary fixed timeout when the container is already running. + +- added support for dnsSearch to enable communication between containers using their names (commit: d509f8a) + - Upstream commit: https://github.com/Mcrich23/Container-Compose/commit/d509f8af30f9d2382c1804f575ea0f22eb4e5734 + - Description: Adds dns_search/dnsSearch support in the Service model and ComposeUp handling so containers can resolve each other by name when using custom DNS search domains. + +- added support for multi stage build target (commit: 02ca646) + - Upstream commit: https://github.com/Mcrich23/Container-Compose/commit/02ca6462b84121c1553bd7adb862ee22aabc4997 + - Description: Adds support for specifying a build target (multi-stage Dockerfile target) when using the build: configuration in compose files. + +- added information about what command is being run for easier debugging (commit: 4968a86) + - Upstream commit: https://github.com/Mcrich23/Container-Compose/commit/4968a8669babe7822ada82cc90328f102edfd02e + - Description: Outputs the exact container tool command being executed to aid debugging of failed runs. + +- fix: place --entrypoint flag before image name in container run (commit: 84201f9) + - Upstream commit: https://github.com/Mcrich23/Container-Compose/commit/84201f9416f4a5f1bd383763679f8e2fd7579e94 + - Description: Ensures --entrypoint is passed before the image name so it is interpreted as a run flag (prevents immediate container exit when overriding entrypoint). + +- test: add named volume full path preservation test (commit: 8edb8a9) + - Upstream commit: https://github.com/Mcrich23/Container-Compose/commit/8edb8a9be0cb5b820eca78c86d6a70b79ac459c1 + - Related upstream PRs: https://github.com/Mcrich23/Container-Compose/pull/22 (tests overhaul) + - Description: Adds unit/regression tests to preserve full destination paths for named volumes. + +- fix: use full destination path for named volumes (commit: b1badf8) + - Upstream commit: https://github.com/Mcrich23/Container-Compose/commit/b1badf86a4faf5c6ed512643e255760073d38988 + - Related upstream PRs: https://github.com/Mcrich23/Container-Compose/pull/32 (fixed wrong named volume destination), https://github.com/Mcrich23/Container-Compose/pull/42 (improve volume mount handling) + - Description: Corrects handling of named volume destination paths so a named volume mapped to /path/subpath preserves the full destination. + +- CI / release workflow additions (commits: 3f20dbf, 98b7fc4, 1d284fb) + - Origin commits: + - https://github.com/explicitcontextualunderstanding/Container-Compose/commit/3f20dbf6a6268a93fa196632caa2c178214892f7 + - https://github.com/explicitcontextualunderstanding/Container-Compose/commit/98b7fc4a50467067158d15eb47d9acca78121719 + - https://github.com/explicitcontextualunderstanding/Container-Compose/commit/1d284fbc58e1abb0ff793e0eef0993fbeaf26189 + - Description: Adds and configures GitHub Actions workflows for release automation and CI build steps used by this fork. + +Additional upstream PRs of interest (not exhaustive): + +- Tests overhaul / fixes: https://github.com/Mcrich23/Container-Compose/pull/22 +- Named volume fixes & volume mount handling: https://github.com/Mcrich23/Container-Compose/pull/32 and https://github.com/Mcrich23/Container-Compose/pull/42 +- ComposeDown tests and container_name handling: https://github.com/Mcrich23/Container-Compose/pull/50 + +Notes and suggested next steps: + +- Upstream apple/container v0.10.0 already includes many of the core engine changes referenced above (notably: ClientContainer rework [#1139], runtime flag for create/run [#1109], --init and --init-image support [#1244, #937], container export/commit [#1172], support for multiple network plugins [#1151], build --pull [#844], named-volume auto-create warning [#1108], memory validation [#1208], and related CLI/output changes such as a --format option for system status [#1237]). + +- Items present in this fork but NOT included in apple/container v0.10.0 (should be tracked or upstreamed): + - Remove RuntimeStatus type (commit: c509a2f) + - Fix incorrect waiting when container is already running (commit: 8a4e5bb) + - Remove unnecessary 30s timeout when container already started (commit: eeddb26) + - dnsSearch / dns_search support for service name resolution (commit: d509f8a) + - Multi-stage build target support (build.target) (commit: 02ca646) + - Debug output showing the exact container CLI command being executed (commit: 4968a86) + - Ensure --entrypoint is passed before image name in run (commit: 84201f9) + - Named-volume full-destination-path preservation and regression test (commits: b1badf8, 8edb8a9) + - Fork-specific CI/release workflow additions (commits: 3f20dbf, 98b7fc4, 1d284fb) + +- Recommended actions: + 1. Update this FORK_CHANGES.md and add a short CHANGELOG.md that clearly separates what was upstreamed in apple/container@0.10.0 and what remains unique to this fork. + 2. Update README and CLI --help strings for fork-only features (dns_search, build.target, entrypoint behavior, named-volume handling) and add migration notes where appropriate. + 3. For each fork-only item, decide whether to upstream as a PR against apple/container or keep it as a fork patch; open PRs for items that are broadly useful (dns_search, build.target, entrypoint fix, named-volume behavior). + +TODOs: +- Create a detailed CHANGELOG.md entry describing user-facing changes and migration notes, split into "Upstream in container@0.10.0" and "Fork-only changes". +- Update README and CLI --help strings to reflect fork capabilities and any CLI differences. +- Audit tests that depend on fork-only behavior and mark or adapt them for upstream compatibility. + +(Generated by repository inspection against apple/container v0.10.0.) + +--- + +Proposed features to target for the next Apple Containers release + +Based on the active development in the apple/container main branch (post-0.9.0), several high-impact features are landing that the Container-Compose fork is uniquely positioned to capitalize on. To stay ahead of the next release, focus development and testing on the following areas. + +### 1. Robust Service Lifecycle (Restart Policies) + +The Change: PR #1258 adds a native `--restart` policy to the `container run` command. + +- Compose Feature to Add: Implement the `restart: always`, `restart: on-failure`, and `restart: unless-stopped` keys in docker-compose.yaml so the fork maps those keys to the new engine `--restart` flag. +- Testing Priority: Test "zombie" container cleanup. Since the engine is adding native restart support, ensure that `container-compose down` correctly stops and removes containers that the engine might be trying to restart automatically. + +### 2. High-Performance Host-Container File Transfer + +The Change: PR #1190 introduces a native `container cp` command. + +- Compose Feature to Add: Use this to implement a "Sync" or "Hot Reload" feature that programmatically moves files into a running service container as an alternative to bind mounts for improved performance. +- Testing Priority: Verify large file transfers and directory structures. This is a significant improvement over the current "mount-only" storage strategy in 0.9.0. + +### 3. Native "Init" Process Management + +The Change: PR #1244 adds an `--init` flag to `run/create`. + +- Compose Feature to Add: Add an `init: true` boolean to the service definition that maps to the engine `--init` flag when starting containers. +- Testing Priority: Test applications that spawn many child processes (Node.js, Python with workers). Using the native `--init` flag will prevent orphan processes from remaining in the micro-VM after the service stops. + +### 4. Advanced Networking & Multi-Plugin Support + +The Change: PR #1151 and #1227 enable multiple network plugins and loading configurations from files. + +- Compose Feature to Add: Support complex `networks:` definitions in Compose to allow combinations of bridge, host-only, and routed networks for services within the same stack. +- Testing Priority: IPv6 connectivity. PR #1174 adds IPv6 gateway support — validate IPv6 addressing, routing, and DNS resolution across custom networks. + +### 5. "Snapshot-based" Deployments + +The Change: PR #1172 adds `container commit` (exporting a container to an image). + +- Compose Feature to Add: Implement a `container-compose checkpoint ` command that commits a running container to a local image for future `up` commands or for fast rollbacks. +- Testing Priority: Validate database checkpoints and restore flows; ensure image metadata and layers are handled consistently across commits. + +### Suggested Testing Matrix for the Fork + +| Feature | Target PR | Test Case | +| --- | --- | --- | +| **Persistence** | #1108 / #1190 | Verify that named volumes aren't "lost" and `cp` works across them. | +| **Security** | #1152 / #1166 | Ensure Compose-generated containers respect the new SELinux-off-by-default boot. | +| **Reliability** | #1208 | Launch a Compose stack with `mem_limit: 128mb` and verify the CLI surfaces validation errors correctly. | + +### Strategic Recommendation + +The most valuable addition would be **Auto-Start support**. With Apple adding `LaunchAgent` support (#1176) and a `--system-start` flag (#1201), the fork could introduce a `container-compose install-service` command that generates macOS LaunchAgents to auto-start stacks on boot. + +--- + +Would you like help drafting the Swift logic to map `restart: always` and related Compose keys to the engine `--restart` flag? (Can produce a focused patch for Sources/Container-Compose/Commands/ComposeUp.swift.) diff --git a/Sources/Container-Compose/Application.swift b/Sources/Container-Compose/Application.swift index 5106f30..a5aa532 100644 --- a/Sources/Container-Compose/Application.swift +++ b/Sources/Container-Compose/Application.swift @@ -19,13 +19,13 @@ import ArgumentParser public struct Main: AsyncParsableCommand { private static let commandName: String = "container-compose" - private static let version: String = "0.9.0" + private static let version: String = "0.9.1" public static var versionString: String { "\(commandName) version \(version)" } public static let configuration: CommandConfiguration = .init( commandName: Self.commandName, - abstract: "A tool to use manage Docker Compose files with Apple Container", + abstract: "A tool to manage Docker Compose files using Apple Container. This fork adds dnsSearch support, multi-stage build target support, improved volume handling, and better entrypoint/command debugging.", version: Self.versionString, subcommands: [ ComposeUp.self, diff --git a/Sources/Container-Compose/Codable Structs/Build.swift b/Sources/Container-Compose/Codable Structs/Build.swift index 0c389f5..3147fc3 100644 --- a/Sources/Container-Compose/Codable Structs/Build.swift +++ b/Sources/Container-Compose/Codable Structs/Build.swift @@ -30,7 +30,9 @@ public struct Build: Codable, Hashable { public let dockerfile: String? /// Build arguments public let args: [String: String]? - + /// Target stage to build in a multi-stage Dockerfile + public let target: String? + /// Custom initializer to handle `build: .` (string) or `build: { context: . }` (object) public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() @@ -38,15 +40,17 @@ public struct Build: Codable, Hashable { self.context = contextString self.dockerfile = nil self.args = nil + self.target = nil } else { let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) self.context = try keyedContainer.decode(String.self, forKey: .context) self.dockerfile = try keyedContainer.decodeIfPresent(String.self, forKey: .dockerfile) self.args = try keyedContainer.decodeIfPresent([String: String].self, forKey: .args) + self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) } } enum CodingKeys: String, CodingKey { - case context, dockerfile, args + case context, dockerfile, args, target } } diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 82bfa36..733d887 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -86,6 +86,15 @@ public struct Service: Codable, Hashable { /// Platform architecture for the service public let platform: String? + /// Runtime to pass to the container engine (maps to `--runtime`) + public let runtime: String? + + /// Native init flag to request an init process (maps to container --init) + public let `init`: Bool? + + /// Init image to pass to the engine (maps to `--init-image`) + public let init_image: String? + /// Service-specific config usage (primarily for Swarm) public let configs: [ServiceConfig]? @@ -97,14 +106,17 @@ public struct Service: Codable, Hashable { /// Allocate a pseudo-TTY (-t flag for `container run`) public let tty: Bool? - + + /// DNS search domain for container-to-container name resolution + public let dns_search: String? + /// Other services that depend on this service public var dependedBy: [String] = [] // Defines custom coding keys to map YAML keys to Swift properties enum CodingKeys: String, CodingKey { case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, - container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform + container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform, runtime, `init`, init_image, dns_search } /// Public memberwise initializer for testing @@ -129,10 +141,14 @@ public struct Service: Codable, Hashable { read_only: Bool? = nil, working_dir: String? = nil, platform: String? = nil, + runtime: String? = nil, + `init`: Bool? = nil, + init_image: String? = nil, configs: [ServiceConfig]? = nil, secrets: [ServiceSecret]? = nil, stdin_open: Bool? = nil, tty: Bool? = nil, + dns_search: String? = nil, dependedBy: [String] = [] ) { self.image = image @@ -155,10 +171,14 @@ public struct Service: Codable, Hashable { self.read_only = read_only self.working_dir = working_dir self.platform = platform + self.runtime = runtime + self.`init` = `init` + self.init_image = init_image self.configs = configs self.secrets = secrets self.stdin_open = stdin_open self.tty = tty + self.dns_search = dns_search self.dependedBy = dependedBy } @@ -218,6 +238,13 @@ public struct Service: Codable, Hashable { stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open) tty = try container.decodeIfPresent(Bool.self, forKey: .tty) platform = try container.decodeIfPresent(String.self, forKey: .platform) + runtime = try container.decodeIfPresent(String.self, forKey: .runtime) + // Decode optional init flag (YAML key: init) + `init` = try container.decodeIfPresent(Bool.self, forKey: .`init`) + init_image = try container.decodeIfPresent(String.self, forKey: .init_image) + + dns_search = try container.decodeIfPresent(String.self, forKey: .dns_search) + dependedBy = [] } /// Returns the services in topological order based on `depends_on` relationships. diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index d274ff9..f4150b0 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -181,11 +181,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { fatalError("unreachable") } - private func getIPForRunningService(_ serviceName: String) async throws -> String? { - guard let projectName else { return nil } - - let containerName = "\(projectName)-\(serviceName)" - + private func getIPForContainer(_ containerName: String) async throws -> String? { let container = try await ClientContainer.get(id: containerName) let ip = container.networks.compactMap { $0.ipv4Gateway.description }.first @@ -197,17 +193,18 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). /// - timeout: Max seconds to wait before failing. /// - interval: How often to poll (in seconds). - /// - Returns: `true` if the container reached "running" state within the timeout. - private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { - guard let projectName else { return } - let containerName = "\(projectName)-\(serviceName)" - + private func waitUntilContainerIsRunning(_ containerName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - let container = try? await ClientContainer.get(id: containerName) - if container?.status == .running { - return + do { + let container = try await ClientContainer.get(id: containerName) + if container.status == .running { + print("Container '\(containerName)' is now running.") + return + } + } catch { + // Container doesn't exist yet, keep polling } try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) @@ -245,8 +242,8 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // MARK: Compose Top Level Functions - private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { - let ip = try await getIPForRunningService(serviceName) + private mutating func updateEnvironmentWithServiceIP(_ serviceName: String, containerName: String) async throws { + let ip = try await getIPForContainer(containerName) self.containerIps[serviceName] = ip for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { self.environmentVariables[key] = ip ?? value @@ -531,12 +528,25 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append("-t") // --tty } - runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint + // Configure DNS for container-to-container name resolution + if let dnsSearch = service.dns_search { + runCommandArgs.append("--dns-search") + runCommandArgs.append(dnsSearch) + } - // Add entrypoint or command - if let entrypointParts = service.entrypoint { + // Add entrypoint override BEFORE image name (must be a flag to `container run`) + if let entrypointParts = service.entrypoint, let entrypointCmd = entrypointParts.first { + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint runCommandArgs.append("--entrypoint") - runCommandArgs.append(contentsOf: entrypointParts) + runCommandArgs.append(entrypointCmd) + } else { + runCommandArgs.append(imageToRun) // Image name separates `container run` flags from container arguments + } + + // Add entrypoint arguments or command AFTER image name (these become container process args) + if let entrypointParts = service.entrypoint { + // First element was used as --entrypoint above, rest are arguments + runCommandArgs.append(contentsOf: entrypointParts.dropFirst()) } else if let commandParts = service.command { runCommandArgs.append(contentsOf: commandParts) } @@ -551,6 +561,18 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { self.containerConsoleColors[serviceName] = serviceColor + // Check if container already exists + if let existingContainer = try? await ClientContainer.get(id: containerName) { + if existingContainer.status == .running { + print("Container '\(containerName)' is already running.") + try await updateEnvironmentWithServiceIP(serviceName, containerName: containerName) + return + } else { + print("Error: Container '\(containerName)' already exists with status: \(existingContainer.status).") + return + } + } + Task { [self, serviceColor] in @Sendable func handleOutput(_ output: String) { @@ -564,8 +586,8 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } do { - try await waitUntilServiceIsRunning(serviceName) - try await updateEnvironmentWithServiceIP(serviceName) + try await waitUntilContainerIsRunning(containerName) + try await updateEnvironmentWithServiceIP(serviceName, containerName: containerName) } catch { print(error) } @@ -617,7 +639,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Add Dockerfile path commands.append(contentsOf: ["--file", "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")"]) - + + // Add target stage for multi-stage builds + if let target = buildConfig.target { + commands.append(contentsOf: ["--target", target]) + } + // Add caching options if noCache { commands.append("--no-cache") @@ -642,6 +669,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { let buildCommand = try Application.BuildCommand.parse(commands) print("\n----------------------------------------") print("Building image for service: \(serviceName) (Tag: \(imageToRun))") + print("Running: container build \(commands.joined(separator: " "))") try buildCommand.validate() try await buildCommand.run() print("Image build for \(serviceName) completed.") @@ -700,9 +728,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { 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." ) @@ -711,7 +736,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // 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 + runCommandArgs.append("\(volumePath):\(destination)") // Use original source for command argument } return runCommandArgs @@ -719,8 +744,74 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } // MARK: CommandLine Functions + extension ComposeUp { + /// Helper for building the `container run` argument list for a service. Used by tests. + public static func makeRunArgs(service: Service, serviceName: String, dockerCompose: DockerCompose, projectName: String, detach: Bool, cwd: String, environmentVariables: [String: String]) throws -> [String] { + var runArgs: [String] = [] + + // Add detach flag if specified + if detach { + runArgs.append("-d") + } + + // Determine container name + let containerName: String + if let explicit = service.container_name { + containerName = explicit + } else { + containerName = "\(projectName)-\(serviceName)" + } + runArgs.append("--name") + runArgs.append(containerName) + + // Map restart policy if present + if let restart = service.restart { + runArgs.append("--restart") + runArgs.append(restart) + } + + // Map runtime flag if present + if let runtime = service.runtime { + runArgs.append("--runtime") + runArgs.append(runtime) + } + + // Map init flag if present (support both explicit Bool and optional presence) + if let mirrorInit = Mirror(reflecting: service).children.first(where: { $0.label == "init" }), let value = mirrorInit.value as? Bool, value { + runArgs.append("--init") + } + + // Map init-image if present (must be passed before image name) + if let initImage = service.init_image { + runArgs.append("--init-image") + runArgs.append(initImage) + } + + // Ensure entrypoint flag is placed before the image name when provided + let imageToRun = service.image ?? "\(serviceName):latest" + if let entrypointParts = service.entrypoint, let entrypointCmd = entrypointParts.first { + runArgs.append("--entrypoint") + runArgs.append(entrypointCmd) + // image follows flags + runArgs.append(imageToRun) + // append any remaining entrypoint args or command after image + if entrypointParts.count > 1 { + runArgs.append(contentsOf: entrypointParts.dropFirst()) + } else if let commandParts = service.command { + runArgs.append(contentsOf: commandParts) + } + } else { + runArgs.append(imageToRun) + if let commandParts = service.command { + runArgs.append(contentsOf: commandParts) + } + } + + return runArgs + } + /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. /// /// - Parameters: diff --git a/Sources/Container-Compose/Commands/Version.swift b/Sources/Container-Compose/Commands/Version.swift index dcb423e..508703d 100644 --- a/Sources/Container-Compose/Commands/Version.swift +++ b/Sources/Container-Compose/Commands/Version.swift @@ -28,7 +28,7 @@ public struct Version: ParsableCommand { public static let configuration: CommandConfiguration = .init( commandName: "version", - abstract: "Display the version information" + abstract: "Display container-compose version and fork capabilities (dnsSearch, multi-stage build target, improved volume and entrypoint handling)" ) public func run() { diff --git a/Tests/Container-Compose-StaticTests/ComposeUpMappingTests.swift b/Tests/Container-Compose-StaticTests/ComposeUpMappingTests.swift new file mode 100644 index 0000000..8fbcfa8 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ComposeUpMappingTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import ContainerComposeCore +import Yams + +final class ComposeUpMappingTests: XCTestCase { + func testRestartPolicyMapping() throws { + let yaml = """ + services: + web: + image: nginx:latest + restart: always + """ + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + guard let service = dockerCompose.services["web"] ?? nil else { return XCTFail("Service 'web' missing") } + + // Expected: a helper that builds run args from a service. Tests written first (TDD). + let args = try ComposeUp.makeRunArgs(service: service, serviceName: "web", dockerCompose: dockerCompose, projectName: "proj", detach: false, cwd: "/tmp", environmentVariables: [:]) + + XCTAssertTrue(args.contains("--restart"), "Expected --restart flag present in args: \(args)") + XCTAssertTrue(args.contains("always"), "Expected restart value 'always' present in args: \(args)") + } + + func testInitFlagMapping() throws { + let yaml = """ + services: + app: + image: busybox:latest + init: true + """ + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + guard let service = dockerCompose.services["app"] ?? nil else { return XCTFail("Service 'app' missing") } + + let args = try ComposeUp.makeRunArgs(service: service, serviceName: "app", dockerCompose: dockerCompose, projectName: "proj", detach: false, cwd: "/tmp", environmentVariables: [:]) + + XCTAssertTrue(args.contains("--init"), "Expected --init flag present in args: \(args)") + } + + func testEntrypointPlacedBeforeImage() throws { + let yaml = """ + services: + api: + image: nginx:latest + entrypoint: ["/bin/sh", "-c"] + """ + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + guard let service = dockerCompose.services["api"] ?? nil else { return XCTFail("Service 'api' missing") } + + let args = try ComposeUp.makeRunArgs(service: service, serviceName: "api", dockerCompose: dockerCompose, projectName: "proj", detach: false, cwd: "/tmp", environmentVariables: [:]) + + guard let entryIdx = args.firstIndex(of: "--entrypoint"), let imageIdx = args.firstIndex(of: "nginx:latest") else { + return XCTFail("Expected both --entrypoint and image in args: \(args)") + } + + XCTAssertTrue(entryIdx < imageIdx, "Expected --entrypoint to appear before image, but args: \(args)") + } +} diff --git a/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift b/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift index 2749fe7..ec5de69 100644 --- a/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift +++ b/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift @@ -99,7 +99,32 @@ struct DockerComposeParsingTests { #expect(compose.services["db"]??.volumes?.count == 1) #expect(compose.services["db"]??.volumes?.first == "db-data:/var/lib/postgresql/data") } - + + @Test("Parse compose with named volume - full destination path preserved") + func parseComposeWithNamedVolumeFullPath() throws { + // This tests the fix for: https://github.com/Mcrich23/Container-Compose/issues/32 + // Named volumes with nested paths like /usr/share/elasticsearch/data were being truncated + let yaml = """ + version: '3.8' + services: + elasticsearch: + image: elasticsearch:8.0 + volumes: + - elasticsearch-data:/usr/share/elasticsearch/data + volumes: + elasticsearch-data: + """ + + let decoder = YAMLDecoder() + let compose = try decoder.decode(DockerCompose.self, from: yaml) + + #expect(compose.volumes != nil) + #expect(compose.volumes?["elasticsearch-data"] != nil) + #expect(compose.services["elasticsearch"]??.volumes?.count == 1) + // Critical: the FULL destination path must be preserved + #expect(compose.services["elasticsearch"]??.volumes?.first == "elasticsearch-data:/usr/share/elasticsearch/data") + } + @Test("Parse compose with networks") func parseComposeWithNetworks() throws { let yaml = """ diff --git a/Tests/Container-Compose-StaticTests/RuntimeInitTests.swift b/Tests/Container-Compose-StaticTests/RuntimeInitTests.swift new file mode 100644 index 0000000..29903d4 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/RuntimeInitTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import ContainerComposeCore +import Yams + +final class RuntimeInitTests: XCTestCase { + func testRuntimeFlagMapping() throws { + let yaml = """ + services: + worker: + image: busybox:latest + runtime: kata + """ + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + guard let service = dockerCompose.services["worker"] ?? nil else { return XCTFail("Service 'worker' missing") } + + let args = try ComposeUp.makeRunArgs(service: service, serviceName: "worker", dockerCompose: dockerCompose, projectName: "proj", detach: false, cwd: "/tmp", environmentVariables: [:]) + + XCTAssertTrue(args.contains("--runtime")) + XCTAssertTrue(args.contains("kata")) + } + + func testInitImageFlagMapping() throws { + let yaml = """ + services: + db: + image: postgres:latest + init: true + init_image: custom/init-img:1.2 + """ + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + guard let service = dockerCompose.services["db"] ?? nil else { return XCTFail("Service 'db' missing") } + + let args = try ComposeUp.makeRunArgs(service: service, serviceName: "db", dockerCompose: dockerCompose, projectName: "proj", detach: false, cwd: "/tmp", environmentVariables: [:]) + + XCTAssertTrue(args.contains("--init-image")) + XCTAssertTrue(args.contains("custom/init-img:1.2")) + XCTAssertTrue(args.contains("--init")) + } +} diff --git a/docs/FORK_README_UPDATE.md b/docs/FORK_README_UPDATE.md new file mode 100644 index 0000000..a323ae7 --- /dev/null +++ b/docs/FORK_README_UPDATE.md @@ -0,0 +1,10 @@ +Fork README additions (draft) + +Planned changes to leverage apple/container v0.10.0 features: + +- Map Compose `restart:` keys to engine `--restart` flag. +- Map `init: true` to engine `--init` flag and support `--init-image` selection. +- Ensure `--entrypoint` is passed in the correct position relative to the image name. +- Add a new `checkpoint` subcommand that uses `container commit`/export. + +Tests were added (ComposeUpMappingTests) to drive the implementation of the first set of changes. diff --git a/docs/runtime-init-image.md b/docs/runtime-init-image.md new file mode 100644 index 0000000..ebfd6d5 --- /dev/null +++ b/docs/runtime-init-image.md @@ -0,0 +1,18 @@ +# runtime and init-image support + +This document describes planned Compose mappings to engine flags added in apple/container v0.10.0: + +- `runtime: ` in a service maps to `container run --runtime `. +- `init: true` maps to `container run --init` (already supported by the fork via earlier work). +- `init_image: ` maps to `container run --init-image ` allowing selection of the init filesystem image for the micro-VM. + +Usage example in docker-compose.yml: + +services: + app: + image: myapp:latest + runtime: kata + init: true + init_image: some-init-image:latest + +Tests will assert that ComposeUp.makeRunArgs places these flags before the image name as required by the container CLI.