diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..196b0d8
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,15 @@
+# These are supported funding model platforms
+
+github: mcrich23 # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
+patreon: # Replace with a single Patreon username
+open_collective: # Replace with a single Open Collective username
+ko_fi: # Replace with a single Ko-fi username
+tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+liberapay: # Replace with a single Liberapay username
+issuehunt: # Replace with a single IssueHunt username
+lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
+polar: # Replace with a single Polar username
+buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
+thanks_dev: # Replace with a single thanks.dev username
+custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
new file mode 100644
index 0000000..4e3b8c9
--- /dev/null
+++ b/.github/workflows/README.md
@@ -0,0 +1,36 @@
+# GitHub Actions Workflows
+
+This directory contains GitHub Actions workflows for Container-Compose.
+
+## Available Workflows
+
+### Tests (`tests.yml`)
+
+A required status check for pull requests that must be run manually before merging.
+
+**How to run:**
+1. Go to the "Actions" tab in the GitHub repository
+2. Select "Tests" workflow from the left sidebar
+3. Click "Run workflow" button
+4. Select the branch (e.g., your PR branch)
+5. Click "Run workflow" to start the tests
+
+**Requirements:** macOS 15 runner (tests require macOS environment)
+
+**Note:** Tests are configured as a required check but do NOT run automatically on each commit. This allows you to control when tests run (e.g., after you're done with a series of commits) while still enforcing that tests must pass before merging.
+
+## Test Environment
+
+All tests run on macOS 15 with Swift 6.0+ because:
+- Container-Compose depends on `apple/container` package
+- The upstream dependency requires macOS-specific `os` module
+- Swift Package Manager dependencies are cached for faster builds
+
+## Troubleshooting
+
+If tests fail to run:
+1. Check that the workflow was triggered on the correct branch
+2. Verify Package.swift is valid
+3. Check the Actions tab for detailed logs
+4. Ensure macOS 15 runners are available
+5. If the workflow doesn't appear as a status check, you may need to run it once first
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
new file mode 100644
index 0000000..e4a4e07
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,88 @@
+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]
+ paths:
+ - "Sources/**"
+ - "Tests/**"
+ - "Package.swift"
+ - ".github/workflows/tests.yml"
+ pull_request:
+ branches: [main]
+ paths:
+ - "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-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/.gitignore b/.gitignore
index 0023a53..8818591 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
+.vscode/launch.json
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Container-Compose.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Container-Compose.xcscheme
index 558d996..c9d1c50 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/Container-Compose.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/Container-Compose.xcscheme
@@ -29,6 +29,38 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ allowLocationSimulation = "YES"
+ consoleMode = "0"
+ structuredConsoleMode = "2">
+
+
+
+
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/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..bb00982
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,10 @@
+## Contributing to Container-Compose
+
+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.
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/Makefile b/Makefile
new file mode 100644
index 0000000..0c31826
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,19 @@
+# Define variables.
+prefix ?= /usr/local
+bindir = $(prefix)/bin
+
+# Command building targets.
+build:
+ swift build -c release --disable-sandbox
+
+install: build
+ install -d "$(bindir)"
+ install ".build/release/container-compose" "$(bindir)"
+
+uninstall:
+ rm -rf "$(bindir)/container-compose"
+
+clean:
+ rm -rf .build
+
+.PHONY: build install uninstall clean
diff --git a/Package.resolved b/Package.resolved
index 7519aa7..0167755 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,15 +1,258 @@
{
- "originHash" : "d0e3b76d58503b44bb45d1cad6d3f2f38f4f19ffe449887942d12d26ae502758",
+ "originHash" : "9ca1c1795db0e555d1f1095df4202b0eb960a6c3dee57cc0edea8b45a08c369f",
"pins" : [
+ {
+ "identity" : "async-http-client",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swift-server/async-http-client.git",
+ "state" : {
+ "revision" : "4b99975677236d13f0754339864e5360142ff5a1",
+ "version" : "1.30.3"
+ }
+ },
+ {
+ "identity" : "container",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/mcrich23/container",
+ "state" : {
+ "branch" : "add-command-option-group-function-macro",
+ "revision" : "1d4e030cd537fd8325f2a91fd05e0bec9419753f"
+ }
+ },
+ {
+ "identity" : "containerization",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/containerization.git",
+ "state" : {
+ "revision" : "c3fe889a2f739ee4a9b0faccedd9f36f3862dc29",
+ "version" : "0.24.5"
+ }
+ },
+ {
+ "identity" : "grpc-swift",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/grpc/grpc-swift.git",
+ "state" : {
+ "revision" : "8f57f68b9d247fe3759fa9f18e1fe919911e6031",
+ "version" : "1.27.1"
+ }
+ },
+ {
+ "identity" : "rainbow",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/onevcat/Rainbow",
+ "state" : {
+ "revision" : "cdf146ae671b2624917648b61c908d1244b98ca1",
+ "version" : "4.2.1"
+ }
+ },
+ {
+ "identity" : "swift-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-algorithms.git",
+ "state" : {
+ "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
+ "version" : "1.2.1"
+ }
+ },
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/apple/swift-argument-parser",
+ "location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
- "revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b",
+ "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
+ "version" : "1.7.0"
+ }
+ },
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "810496cf121e525d660cd0ea89a758740476b85f",
"version" : "1.5.1"
}
},
+ {
+ "identity" : "swift-async-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-async-algorithms.git",
+ "state" : {
+ "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-atomics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-atomics.git",
+ "state" : {
+ "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-certificates",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-certificates.git",
+ "state" : {
+ "revision" : "7d5f6124c91a2d06fb63a811695a3400d15a100e",
+ "version" : "1.17.1"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections.git",
+ "state" : {
+ "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
+ "version" : "3.15.1"
+ }
+ },
+ {
+ "identity" : "swift-distributed-tracing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-distributed-tracing.git",
+ "state" : {
+ "revision" : "baa932c1336f7894145cbaafcd34ce2dd0b77c97",
+ "version" : "1.3.1"
+ }
+ },
+ {
+ "identity" : "swift-http-structured-headers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-http-structured-headers.git",
+ "state" : {
+ "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b",
+ "version" : "1.6.0"
+ }
+ },
+ {
+ "identity" : "swift-http-types",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-http-types.git",
+ "state" : {
+ "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
+ "version" : "1.5.1"
+ }
+ },
+ {
+ "identity" : "swift-log",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-log.git",
+ "state" : {
+ "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181",
+ "version" : "1.9.1"
+ }
+ },
+ {
+ "identity" : "swift-nio",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio.git",
+ "state" : {
+ "revision" : "5e72fc102906ebe75a3487595a653e6f43725552",
+ "version" : "2.94.0"
+ }
+ },
+ {
+ "identity" : "swift-nio-extras",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-extras.git",
+ "state" : {
+ "revision" : "3df009d563dc9f21a5c85b33d8c2e34d2e4f8c3b",
+ "version" : "1.32.1"
+ }
+ },
+ {
+ "identity" : "swift-nio-http2",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-http2.git",
+ "state" : {
+ "revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80",
+ "version" : "1.39.0"
+ }
+ },
+ {
+ "identity" : "swift-nio-ssl",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-ssl.git",
+ "state" : {
+ "revision" : "173cc69a058623525a58ae6710e2f5727c663793",
+ "version" : "2.36.0"
+ }
+ },
+ {
+ "identity" : "swift-nio-transport-services",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-transport-services.git",
+ "state" : {
+ "revision" : "60c3e187154421171721c1a38e800b390680fb5d",
+ "version" : "1.26.0"
+ }
+ },
+ {
+ "identity" : "swift-numerics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-numerics.git",
+ "state" : {
+ "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-protobuf",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-protobuf.git",
+ "state" : {
+ "revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
+ "version" : "1.33.3"
+ }
+ },
+ {
+ "identity" : "swift-service-context",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-service-context.git",
+ "state" : {
+ "revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6",
+ "version" : "1.2.1"
+ }
+ },
+ {
+ "identity" : "swift-service-lifecycle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swift-server/swift-service-lifecycle.git",
+ "state" : {
+ "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
+ "version" : "2.9.1"
+ }
+ },
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-syntax.git",
+ "state" : {
+ "revision" : "4799286537280063c85a32f09884cfbca301b1a1",
+ "version" : "602.0.0"
+ }
+ },
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
+ "version" : "1.6.4"
+ }
+ },
{
"identity" : "yams",
"kind" : "remoteSourceControl",
@@ -18,6 +261,15 @@
"revision" : "3d6871d5b4a5cd519adf233fbb576e0a2af71c17",
"version" : "5.4.0"
}
+ },
+ {
+ "identity" : "zstd",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/facebook/zstd.git",
+ "state" : {
+ "revision" : "f8745da6ff1ad1e7bab384bd1f9d742439278e99",
+ "version" : "1.5.7"
+ }
}
],
"version" : 3
diff --git a/Package.swift b/Package.swift
index 5e4c564..f171fc3 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,4 +1,4 @@
-// swift-tools-version: 6.2
+// swift-tools-version: 6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
@@ -7,12 +7,60 @@ let package = Package(
name: "Container-Compose",
platforms: [.macOS(.v15)],
dependencies: [
- .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.6"),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.1"),
+ .package(url: "https://github.com/mcrich23/container", branch: "add-command-option-group-function-macro"),
+ .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.6"),
+ .package(url: "https://github.com/onevcat/Rainbow", .upToNextMajor(from: "4.0.0")),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
- .executableTarget(name: "Container-Compose", dependencies: ["Yams", .product(name: "ArgumentParser", package: "swift-argument-parser")]),
+
+ // Library target containing core logic
+ .target(
+ name: "ContainerComposeCore",
+ dependencies: [
+ .product(
+ name: "ContainerCommands",
+ package: "container"
+ ),
+ .product(
+ name: "ArgumentParser",
+ package: "swift-argument-parser"
+ ),
+ "Yams",
+ "Rainbow",
+ ],
+ path: "Sources/Container-Compose"
+ ),
+
+ // Executable target
+ .executableTarget(
+ name: "Container-Compose",
+ dependencies: [
+ "ContainerComposeCore"
+ ],
+ path: "Sources/ContainerComposeApp"
+ ),
+
+ // Test Helper
+ .target(name: "TestHelpers", path: "Tests/TestHelpers"),
+
+ // Tests
+ .testTarget(
+ name: "Container-Compose-StaticTests",
+ dependencies: [
+ "ContainerComposeCore",
+ "TestHelpers"
+ ]
+ ),
+
+ .testTarget(
+ name: "Container-Compose-DynamicTests",
+ dependencies: [
+ "ContainerComposeCore",
+ "TestHelpers"
+ ]
+ ),
]
)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..480fe1a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,87 @@
+# 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.
+
+> **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.
+- **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.
+- **Volume and network mapping:** Map data and networking as specified in Compose files to Apple Container equivalents.
+- **Extensible:** Designed for future extension and customization.
+
+## Getting Started
+
+### Prerequisites
+
+- A Mac running macOS with Apple Container support (macOS Sonoma or later recommended)
+- Git
+- [Xcode command line tools](https://developer.apple.com/xcode/resources/) (for building, if building from source)
+
+### Installation
+
+You can install Container-Compose via **Homebrew** (recommended):
+
+```sh
+brew update
+brew install container-compose
+````
+
+Or, build it from source:
+
+1. **Clone the repository:**
+
+ ```sh
+ git clone https://github.com/Mcrich23/Container-Compose.git
+ cd Container-Compose
+ ```
+
+2. **Build the executable:**
+
+ > *Note: Ensure you have Swift installed (or the required toolchain).*
+
+ ```sh
+ make build
+ ```
+
+3. **(Optional)**: Install globally
+
+ ```sh
+ make install
+ ```
+
+### Usage
+
+After installation, simply run:
+
+```sh
+container-compose up
+```
+
+You may need to provide a path to your `docker-compose.yml` and `.env` file as arguments.
+
+## 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.
+
+## License
+
+This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
+
+## Support
+
+If you encounter issues or have questions, please open an [Issue](https://github.com/Mcrich23/Container-Compose/issues).
+
+---
+
+Happy Coding! 🚀
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..f244327
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,14 @@
+# Security Policy
+
+## Supported Versions
+
+Use this section to tell people about which versions of your project are
+currently being supported with security updates.
+
+| Version | Supported |
+| ------- | ------------------ |
+| 0.5.x | :white_check_mark: |
+
+## Reporting a Vulnerability
+
+To report a vulnerability, please open a report in the [Security Tab](https://github.com/Mcrich23/Container-Compose/security)
diff --git a/Sources/Container-Compose/Application.swift b/Sources/Container-Compose/Application.swift
index 04f1b7f..a5aa532 100644
--- a/Sources/Container-Compose/Application.swift
+++ b/Sources/Container-Compose/Application.swift
@@ -1,831 +1,37 @@
+//===----------------------------------------------------------------------===//
+// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved.
//
-// File.swift
-// Container-Compose
+// 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
//
-// Created by Morris Richman on 6/18/25.
+// 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.
+//===----------------------------------------------------------------------===//
import Foundation
-import Yams
import ArgumentParser
-enum Action: String, ExpressibleByArgument, Codable {
- init?(argument: String) {
- self.init(rawValue: argument)
- }
-
- case up, down
-}
-
-@main
-struct Application: AsyncParsableCommand {
- static let configuration: CommandConfiguration = .init(
- commandName: "container-compose",
- abstract: "A tool to use manage Docker Compose files with Apple Container"
- )
-
- @Argument(help: "Directs what container-compose should do")
- var action: Action
-
- @Flag(name: [.customShort("d"), .customLong("detach")], help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down")
- var detatch: Bool = false
-
- @Flag(name: [.customShort("b"), .customLong("build")])
- var rebuild: Bool = false
-
- @Option(
- name: [.customLong("cwd"), .customShort("w"), .customLong("workdir")],
- help: "Current working directory for the container")
- public var cwd: String = FileManager.default.currentDirectoryPath
-
- var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml
- var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file
-//
- private var fileManager: FileManager { FileManager.default }
- private var projectName: String?
- private var environmentVariables: [String : String] = [:]
- private var containerIps: [String : String] = [:]
-
- mutating func run() async throws {
- // Read docker-compose.yml content
- guard let yamlData = fileManager.contents(atPath: dockerComposePath) else {
- throw YamlError.dockerfileNotFound(dockerComposePath)
- }
-
- // Decode the YAML file into the DockerCompose struct
- let dockerComposeString = String(data: yamlData, encoding: .utf8)!
- let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString)
-
- // Load environment variables from .env file
- environmentVariables = loadEnvFile(path: envFilePath)
-
- // Handle 'version' field
- if let version = dockerCompose.version {
- print("Info: Docker 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("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 = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name
- print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)")
- }
-
- switch action {
- case .up:
- try await up(dockerCompose: dockerCompose)
- case .down:
- try await down()
- }
- }
-
- func down() async throws {
- try await stopOldStuff(remove: false)
- }
-
- mutating func up(dockerCompose: DockerCompose) async throws {
-
- try await stopOldStuff(remove: true)
-
- // Process top-level networks
- // This creates named networks defined in the docker-compose.yml
- if let networks = dockerCompose.networks {
- print("\n--- Processing Networks ---")
- for (networkName, networkConfig) in networks {
- try await setupNetwork(name: networkName, config: networkConfig)
- }
- print("--- Networks Processed ---\n")
- }
-
- // Process top-level volumes
- // This creates named volumes defined in the docker-compose.yml
- if let volumes = dockerCompose.volumes {
- print("\n--- Processing Volumes ---")
- for (volumeName, volumeConfig) in volumes {
- await createVolumeHardLink(name: volumeName, config: volumeConfig)
- }
- print("--- Volumes Processed ---\n")
- }
-
- // Process each service defined in the docker-compose.yml
- print("\n--- Processing Services ---")
-
- var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) })
- services = try topoSortConfiguredServices(services)
-
- print(services.map(\.serviceName))
- for (serviceName, service) in services {
- try await configService(service, serviceName: serviceName, from: dockerCompose)
- }
-
- if !detatch {
- await waitForever()
- }
- }
-
- func waitForever() async -> Never {
- for await _ in AsyncStream(unfolding: { }) {
- // This will never run
- }
- fatalError("unreachable")
- }
-
- func getIPForRunningService(_ serviceName: String) async throws -> String? {
- guard let projectName else { return nil }
-
- let containerName = "\(projectName)-\(serviceName)"
-
- // Run the container list command
- let containerCommandOutput = try await runCommand("container", args: ["list", "-a"])
- let allLines = containerCommandOutput.stdout.components(separatedBy: .newlines)
-
- // Find the line matching the full container name
- guard let matchingLine = allLines.first(where: { $0.contains(containerName) }) else {
- return nil
- }
-
- // Extract IP using regex
- let pattern = #"\b(?:\d{1,3}\.){3}\d{1,3}\b"#
- let regex = try NSRegularExpression(pattern: pattern)
-
- let range = NSRange(matchingLine.startIndex.. [String] {
- let result = try await runCommand("container", args: ["list", "-a"])
- let lines = result.stdout.split(separator: "\n")
-
- return lines.compactMap { line in
- let trimmed = line.trimmingCharacters(in: .whitespaces)
- let components = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
- guard let name = components.first else { return nil }
- return name.hasPrefix(prefix) ? String(name) : nil
- }
- }
-
- // MARK: Compose Top Level Functions
-
- mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws {
- let ip = try await getIPForRunningService(serviceName)
- self.containerIps[serviceName] = ip
- for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName {
- self.environmentVariables[key] = ip ?? value
- }
- }
-
- /// Returns the services in topological order based on `depends_on` relationships.
- func topoSortConfiguredServices(
- _ services: [(serviceName: String, service: Service)]
- ) throws -> [(serviceName: String, service: Service)] {
-
- var visited = Set()
- var visiting = Set()
- var sorted: [(String, Service)] = []
-
- func visit(_ name: String) throws {
- guard let serviceTuple = services.first(where: { $0.serviceName == name }) else { return }
-
- if visiting.contains(name) {
- throw NSError(domain: "ComposeError", code: 1, userInfo: [
- NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'"
- ])
- }
- guard !visited.contains(name) else { return }
-
- visiting.insert(name)
- for depName in serviceTuple.service.depends_on ?? [] {
- try visit(depName)
- }
- visiting.remove(name)
- visited.insert(name)
- sorted.append(serviceTuple)
- }
-
- for (serviceName, _) in services {
- if !visited.contains(serviceName) {
- try visit(serviceName)
- }
- }
-
- return sorted
- }
- 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
-
- let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)")
- let volumePath = volumeUrl.path(percentEncoded: false)
-
- 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)
- }
-
- func setupNetwork(name networkName: String, config networkConfig: Network) async throws {
- let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name
-
- if let externalNetwork = networkConfig.external, externalNetwork.isExternal {
- print("Info: Network '\(networkName)' is declared as external.")
- print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.")
- } else {
- var networkCreateArgs: [String] = ["network", "create"]
-
- // Add driver and driver options
- if let driver = networkConfig.driver {
- networkCreateArgs.append("--driver")
- networkCreateArgs.append(driver)
- }
- if let driverOpts = networkConfig.driver_opts {
- for (optKey, optValue) in driverOpts {
- networkCreateArgs.append("--opt")
- networkCreateArgs.append("\(optKey)=\(optValue)")
- }
- }
- // Add various network flags
- if networkConfig.attachable == true { networkCreateArgs.append("--attachable") }
- if networkConfig.enable_ipv6 == true { networkCreateArgs.append("--ipv6") }
- if networkConfig.isInternal == true { networkCreateArgs.append("--internal") } // CORRECTED: Use isInternal
-
- // Add labels
- if let labels = networkConfig.labels {
- for (labelKey, labelValue) in labels {
- networkCreateArgs.append("--label")
- networkCreateArgs.append("\(labelKey)=\(labelValue)")
- }
- }
-
- networkCreateArgs.append(actualNetworkName) // Add the network name
-
- print("Creating network: \(networkName) (Actual name: \(actualNetworkName))")
- print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))")
- let _ = try await runCommand("container", args: networkCreateArgs)
- #warning("Network creation output not used")
- print("Network '\(networkName)' created or already exists.")
- }
- }
-
- // MARK: Compose Service Level Functions
- mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws {
- guard let projectName else { throw ComposeError.invalidProjectName }
-
- var imageToRun: String
-
- // Handle 'build' configuration
- if let buildConfig = service.build {
- imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName)
- } else if let img = service.image {
- // Use specified image if no build config
- imageToRun = resolveVariable(img, with: environmentVariables)
- } else {
- // Should not happen due to Service init validation, but as a fallback
- throw ComposeError.imageNotFound(serviceName)
- }
-
- // Handle 'deploy' configuration (note that this tool doesn't fully support it)
- if service.deploy != nil {
- print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.")
- print("However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands.")
- print("The service will be run as a single container based on other configurations.")
- }
-
- var runCommandArgs: [String] = []
-
- // Add detach flag if specified on the CLI
- if detatch {
- runCommandArgs.append("-d")
- }
-
- // Determine container name
- let containerName: String
- if let explicitContainerName = service.container_name {
- containerName = explicitContainerName
- print("Info: Using explicit container_name: \(containerName)")
- } else {
- // Default container name based on project and service name
- containerName = "\(projectName)-\(serviceName)"
- }
- runCommandArgs.append("--name")
- runCommandArgs.append(containerName)
-
- // REMOVED: Restart policy is not supported by `container run`
- // if let restart = service.restart {
- // runCommandArgs.append("--restart")
- // runCommandArgs.append(restart)
- // }
-
- // Add user
- if let user = service.user {
- runCommandArgs.append("--user")
- runCommandArgs.append(user)
- }
-
- // Add volume mounts
- if let volumes = service.volumes {
- for volume in volumes {
- let args = try await configVolume(volume)
- runCommandArgs.append(contentsOf: args)
- }
- }
-
- // Combine environment variables from .env files and service environment
- var combinedEnv: [String: String] = environmentVariables
-
- if let envFiles = service.env_file {
- for envFile in envFiles {
- let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)")
- combinedEnv.merge(additionalEnvVars) { (current, _) in current }
- }
- }
-
- if let serviceEnv = service.environment {
- combinedEnv.merge(serviceEnv) { (old, new) in
- if !new.contains("${") {
- return new
- } else {
- return old
- }
- } // Service env overrides .env files
- }
-
- // Fill in variables
- combinedEnv = combinedEnv.mapValues({ value in
- guard value.contains("${") else { return value }
-
- let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast())
- return combinedEnv[variableName] ?? value
- })
-
- // Fill in IPs
- combinedEnv = combinedEnv.mapValues({ value in
- containerIps[value] ?? value
- })
-
- // MARK: Spinning Spot
- // Add environment variables to run command
- for (key, value) in combinedEnv {
- runCommandArgs.append("-e")
- runCommandArgs.append("\(key)=\(value)")
- }
-
- // REMOVED: Port mappings (-p) are not supported by `container run`
- // if let ports = service.ports {
- // for port in ports {
- // let resolvedPort = resolveVariable(port, with: envVarsFromFile)
- // runCommandArgs.append("-p")
- // runCommandArgs.append(resolvedPort)
- // }
- // }
-
- // Connect to specified networks
- if let serviceNetworks = service.networks {
- for network in serviceNetworks {
- let resolvedNetwork = resolveVariable(network, with: environmentVariables)
- // Use the explicit network name from top-level definition if available, otherwise resolved name
- let networkToConnect = dockerCompose.networks?[network]?.name ?? resolvedNetwork
- runCommandArgs.append("--network")
- runCommandArgs.append(networkToConnect)
- }
- print("Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml.")
- print("Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level.")
- } else {
- print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.")
- }
-
- // Add hostname
- if let hostname = service.hostname {
- let resolvedHostname = resolveVariable(hostname, with: environmentVariables)
- runCommandArgs.append("--hostname")
- runCommandArgs.append(resolvedHostname)
- }
-
- // Add working directory
- if let workingDir = service.working_dir {
- let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables)
- runCommandArgs.append("--workdir")
- runCommandArgs.append(resolvedWorkingDir)
- }
-
- // Add privileged flag
- if service.privileged == true {
- runCommandArgs.append("--privileged")
- }
-
- // Add read-only flag
- if service.read_only == true {
- runCommandArgs.append("--read-only")
- }
-
- // Handle service-level configs (note: still only parsing/logging, not attaching)
- if let serviceConfigs = service.configs {
- print("Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.")
- print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.")
- for serviceConfig in serviceConfigs {
- print(" - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))")
- }
- }
-//
- // Handle service-level secrets (note: still only parsing/logging, not attaching)
- if let serviceSecrets = service.secrets {
- print("Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.")
- print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.")
- for serviceSecret in serviceSecrets {
- print(" - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))")
- }
- }
-
- // Add interactive and TTY flags
- if service.stdin_open == true {
- runCommandArgs.append("-i") // --interactive
- }
- if service.tty == true {
- runCommandArgs.append("-t") // --tty
- }
-
- runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint
-
- // Add entrypoint or command
- if let entrypointParts = service.entrypoint {
- runCommandArgs.append("--entrypoint")
- runCommandArgs.append(contentsOf: entrypointParts)
- } else if let commandParts = service.command {
- runCommandArgs.append(contentsOf: commandParts)
- }
-
- Task { [self] in
-
- @Sendable
- func handleOutput(_ string: String) {
- print("\(serviceName): \(string)")
- }
-
- print("\nStarting service: \(serviceName)")
- print("Starting \(serviceName)")
- print("----------------------------------------\n")
- let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput)
- }
-
- do {
- try await waitUntilServiceIsRunning(serviceName)
- try await updateEnvironmentWithServiceIP(serviceName)
- } catch {
- print(error)
- }
- }
-
- /// Builds Docker Service
- ///
- /// - Parameters:
- /// - buildConfig: The configuration for the build
- /// - service: The service you would like to build
- /// - serviceName: The fallback name for the image
- ///
- /// - Returns: Image Name (`String`)
- func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String {
-
- var buildCommandArgs: [String] = ["build"]
-
- // Determine image tag for built image
- let imageToRun = service.image ?? "\(serviceName):latest"
-
- let imagesList = try await runCommand("container", args: ["images", "list"]).stdout
- if !rebuild, imagesList.contains(serviceName) {
- return imageToRun
- }
-
- do {
- try await runCommand("container", args: ["images", "rm", imageToRun])
- } catch {
- }
-
- buildCommandArgs.append("--tag")
- buildCommandArgs.append(imageToRun)
-
- // Resolve build context path
- let resolvedContext = resolveVariable(buildConfig.context, with: environmentVariables)
- buildCommandArgs.append(resolvedContext)
-
- // Add Dockerfile path if specified
- if let dockerfile = buildConfig.dockerfile {
- let resolvedDockerfile = resolveVariable(dockerfile, with: environmentVariables)
- buildCommandArgs.append("--file")
- buildCommandArgs.append(resolvedDockerfile)
- }
-
- // Add build arguments
- if let args = buildConfig.args {
- for (key, value) in args {
- let resolvedValue = resolveVariable(value, with: environmentVariables)
- buildCommandArgs.append("--build-arg")
- buildCommandArgs.append("\(key)=\(resolvedValue)")
- }
- }
-
- print("\n----------------------------------------")
- print("Building image for service: \(serviceName) (Tag: \(imageToRun))")
- print("Executing container build: container \(buildCommandArgs.joined(separator: " "))")
- try await streamCommand("container", args: buildCommandArgs, onStdout: { print($0) }, onStderr: { print($0) })
- print("Image build for \(serviceName) completed.")
- print("----------------------------------------")
-
- return imageToRun
- }
-
- func configVolume(_ volume: String) async throws -> [String] {
- let resolvedVolume = resolveVariable(volume, with: environmentVariables)
-
- var runCommandArgs: [String] = []
-
- // Parse the volume string: destination[:mode]
- let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init)
-
- guard components.count >= 2 else {
- print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.")
- return []
- }
-
- let source = components[0]
- let destination = components[1]
-
- // 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)
-
- 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
- } 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.")
- }
- } else {
- // Host path does not exist, assume it's meant to be a directory and try to create it.
- 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
- } 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
- }
-
- return runCommandArgs
- }
-}
-
-// MARK: CommandLine Functions
-extension Application {
-
- /// A structure representing the result of a command-line process execution.
- struct CommandResult {
- /// The standard output captured from the process.
- let stdout: String
-
- /// The standard error output captured from the process.
- let stderr: String
-
- /// The exit code returned by the process upon termination.
- let exitCode: Int32
- }
-
- /// Runs a command-line tool asynchronously and captures its output and exit code.
- ///
- /// This function uses async/await and `Process` to launch a command-line tool,
- /// returning a `CommandResult` containing the output, error, and exit code upon completion.
- ///
- /// - Parameters:
- /// - command: The full path to the executable to run (e.g., `/bin/ls`).
- /// - args: An array of arguments to pass to the command. Defaults to an empty array.
- /// - Returns: A `CommandResult` containing `stdout`, `stderr`, and `exitCode`.
- /// - Throws: An error if the process fails to launch.
- /// - Example:
- /// ```swift
- /// let result = try await runCommand("/bin/echo", args: ["Hello"])
- /// print(result.stdout) // "Hello\n"
- /// ```
- @discardableResult
- func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult {
- return try await withCheckedThrowingContinuation { continuation in
- let process = Process()
- let stdoutPipe = Pipe()
- let stderrPipe = Pipe()
-
- process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
- process.arguments = [command] + args
- process.currentDirectoryURL = URL(fileURLWithPath: cwd)
- process.standardOutput = stdoutPipe
- process.standardError = stderrPipe
-
- // Manually set PATH so it can find `container`
- process.environment = ProcessInfo.processInfo.environment.merging([
- "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
- ]) { _, new in new }
-
- do {
- try process.run()
- } catch {
- continuation.resume(throwing: error)
- return
- }
-
- process.terminationHandler = { proc in
- let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
- let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
-
- guard stderrData.isEmpty else {
- continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self)))
- return
- }
-
- let result = CommandResult(
- stdout: String(decoding: stdoutData, as: UTF8.self),
- stderr: String(decoding: stderrData, as: UTF8.self),
- exitCode: proc.terminationStatus
- )
-
- continuation.resume(returning: result)
- }
- }
- }
-
- /// Runs a command, streams stdout and stderr via closures, and completes when the process exits.
- ///
- /// - Parameters:
- /// - command: The name of the command to run (e.g., `"container"`).
- /// - args: Command-line arguments to pass to the command.
- /// - onStdout: Closure called with streamed stdout data.
- /// - onStderr: Closure called with streamed stderr data.
- /// - Returns: The process's exit code.
- /// - Throws: If the process fails to launch.
- @discardableResult
- func streamCommand(
- _ command: String,
- args: [String] = [],
- onStdout: @escaping (@Sendable (String) -> Void),
- onStderr: @escaping (@Sendable (String) -> Void)
- ) async throws -> Int32 {
- return try await withCheckedThrowingContinuation { continuation in
- let process = Process()
- let stdoutPipe = Pipe()
- let stderrPipe = Pipe()
-
- process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
- process.arguments = [command] + args
- process.currentDirectoryURL = URL(fileURLWithPath: cwd)
- 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 stdoutHandle = stdoutPipe.fileHandleForReading
- let stderrHandle = stderrPipe.fileHandleForReading
-
- stdoutHandle.readabilityHandler = { handle in
- let data = handle.availableData
- guard !data.isEmpty else { return }
- if let string = String(data: data, encoding: .utf8) {
- onStdout(string)
- }
- }
-
- stderrHandle.readabilityHandler = { handle in
- let data = handle.availableData
- guard !data.isEmpty else { return }
- if let string = String(data: data, encoding: .utf8) {
- onStderr(string)
- }
- }
-
- process.terminationHandler = { proc in
- stdoutHandle.readabilityHandler = nil
- stderrHandle.readabilityHandler = nil
- continuation.resume(returning: proc.terminationStatus)
- }
-
- do {
- try process.run()
- } catch {
- continuation.resume(throwing: error)
- }
- }
- }
-
- /// Launches a detached command-line process without waiting for its output or termination.
- ///
- /// This function is useful when you want to spawn a process that runs in the background
- /// independently of the current application. Output streams are redirected to null devices.
- ///
- /// - Parameters:
- /// - command: The full path to the executable to launch (e.g., `/usr/bin/open`).
- /// - args: An array of arguments to pass to the command. Defaults to an empty array.
- /// - Returns: The `Process` instance that was launched, in case you want to retain or manage it.
- /// - Throws: An error if the process fails to launch.
- /// - Example:
- /// ```swift
- /// try launchDetachedCommand("/usr/bin/open", args: ["/Applications/Calculator.app"])
- /// ```
- @discardableResult
- func launchDetachedCommand(_ command: String, args: [String] = []) throws -> Process {
- let process = Process()
- process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
- process.arguments = [command] + args
- process.currentDirectoryURL = URL(fileURLWithPath: cwd)
- process.standardOutput = FileHandle.nullDevice
- process.standardError = FileHandle.nullDevice
- process.standardInput = FileHandle.nullDevice
- // Manually set PATH so it can find `container`
- process.environment = ProcessInfo.processInfo.environment.merging([
- "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
- ]) { _, new in new }
-
- // Set this to true to run independently of the launching app
- process.qualityOfService = .background
-
- try process.run()
- return process
- }
+ public init() {}
}
diff --git a/Sources/Container-Compose/Codable Structs/Build.swift b/Sources/Container-Compose/Codable Structs/Build.swift
index 47301be..3147fc3 100644
--- a/Sources/Container-Compose/Codable Structs/Build.swift
+++ b/Sources/Container-Compose/Codable Structs/Build.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// Build.swift
// container-compose-app
@@ -7,27 +23,34 @@
/// Represents the `build` configuration for a service.
-struct Build: Codable, Hashable {
- let context: String // Path to the build context
- let dockerfile: String? // Optional path to the Dockerfile within the context
- let args: [String: String]? // Build arguments
-
- // Custom initializer to handle `build: .` (string) or `build: { context: . }` (object)
- init(from decoder: Decoder) throws {
+public struct Build: Codable, Hashable {
+ /// Path to the build context
+ public let context: String
+ /// Optional path to the Dockerfile within the context
+ 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()
if let contextString = try? container.decode(String.self) {
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/Config.swift b/Sources/Container-Compose/Codable Structs/Config.swift
index 9435579..451b94f 100644
--- a/Sources/Container-Compose/Codable Structs/Config.swift
+++ b/Sources/Container-Compose/Codable Structs/Config.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// Config.swift
// container-compose-app
@@ -7,18 +23,22 @@
/// Represents a top-level config definition (primarily for Swarm).
-struct Config: Codable {
- let file: String? // Path to the file containing the config content
- let external: ExternalConfig? // Indicates if the config is external (pre-existing)
- let name: String? // Explicit name for the config
- let labels: [String: String]? // Labels for the config
+public struct Config: Codable {
+ /// Path to the file containing the config content
+ public let file: String?
+ /// Indicates if the config is external (pre-existing)
+ public let external: ExternalConfig?
+ /// Explicit name for the config
+ public let name: String?
+ /// Labels for the config
+ public let labels: [String: String]?
enum CodingKeys: String, CodingKey {
case file, external, name, labels
}
/// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_cfg" }` (object).
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
file = try container.decodeIfPresent(String.self, forKey: .file)
name = try container.decodeIfPresent(String.self, forKey: .name)
@@ -32,4 +52,4 @@ struct Config: Codable {
external = nil
}
}
-}
\ No newline at end of file
+}
diff --git a/Sources/Container-Compose/Codable Structs/Deploy.swift b/Sources/Container-Compose/Codable Structs/Deploy.swift
index 19cd9df..d4bbba6 100644
--- a/Sources/Container-Compose/Codable Structs/Deploy.swift
+++ b/Sources/Container-Compose/Codable Structs/Deploy.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// Deploy.swift
// container-compose-app
@@ -7,9 +23,13 @@
/// Represents the `deploy` configuration for a service (primarily for Swarm orchestration).
-struct Deploy: Codable, Hashable {
- let mode: String? // Deployment mode (e.g., 'replicated', 'global')
- let replicas: Int? // Number of replicated service tasks
- let resources: DeployResources? // Resource constraints (limits, reservations)
- let restart_policy: DeployRestartPolicy? // Restart policy for tasks
+public struct Deploy: Codable, Hashable {
+ /// Deployment mode (e.g., 'replicated', 'global')
+ public let mode: String?
+ /// Number of replicated service tasks
+ public let replicas: Int?
+ /// Resource constraints (limits, reservations)
+ public let resources: DeployResources?
+ /// Restart policy for tasks
+ public let restart_policy: DeployRestartPolicy?
}
diff --git a/Sources/Container-Compose/Codable Structs/DeployResources.swift b/Sources/Container-Compose/Codable Structs/DeployResources.swift
index 54e8075..6c4bf4d 100644
--- a/Sources/Container-Compose/Codable Structs/DeployResources.swift
+++ b/Sources/Container-Compose/Codable Structs/DeployResources.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// DeployResources.swift
// container-compose-app
@@ -7,7 +23,9 @@
/// Resource constraints for deployment.
-struct DeployResources: Codable, Hashable {
- let limits: ResourceLimits? // Hard limits on resources
- let reservations: ResourceReservations? // Guarantees for resources
+public struct DeployResources: Codable, Hashable {
+ /// Hard limits on resources
+ public let limits: ResourceLimits?
+ /// Guarantees for resources
+ public let reservations: ResourceReservations?
}
diff --git a/Sources/Container-Compose/Codable Structs/DeployRestartPolicy.swift b/Sources/Container-Compose/Codable Structs/DeployRestartPolicy.swift
index 234cc53..bebe595 100644
--- a/Sources/Container-Compose/Codable Structs/DeployRestartPolicy.swift
+++ b/Sources/Container-Compose/Codable Structs/DeployRestartPolicy.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// DeployRestartPolicy.swift
// container-compose-app
@@ -7,9 +23,13 @@
/// Restart policy for deployed tasks.
-struct DeployRestartPolicy: Codable, Hashable {
- let condition: String? // Condition to restart on (e.g., 'on-failure', 'any')
- let delay: String? // Delay before attempting restart
- let max_attempts: Int? // Maximum number of restart attempts
- let window: String? // Window to evaluate restart policy
+public struct DeployRestartPolicy: Codable, Hashable {
+ /// Condition to restart on (e.g., 'on-failure', 'any')
+ public let condition: String?
+ /// Delay before attempting restart
+ public let delay: String?
+ /// Maximum number of restart attempts
+ public let max_attempts: Int?
+ /// Window to evaluate restart policy
+ public let window: String?
}
diff --git a/Sources/Container-Compose/Codable Structs/DeviceReservation.swift b/Sources/Container-Compose/Codable Structs/DeviceReservation.swift
index 1da9bf8..0e7f07f 100644
--- a/Sources/Container-Compose/Codable Structs/DeviceReservation.swift
+++ b/Sources/Container-Compose/Codable Structs/DeviceReservation.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// DeviceReservation.swift
// container-compose-app
@@ -7,9 +23,13 @@
/// Device reservations for GPUs or other devices.
-struct DeviceReservation: Codable, Hashable {
- let capabilities: [String]? // Device capabilities
- let driver: String? // Device driver
- let count: String? // Number of devices
- let device_ids: [String]? // Specific device IDs
+public struct DeviceReservation: Codable, Hashable {
+ /// Device capabilities
+ public let capabilities: [String]?
+ /// Device driver
+ public let driver: String?
+ /// Number of devices
+ public let count: String?
+ /// Specific device IDs
+ public let device_ids: [String]?
}
diff --git a/Sources/Container-Compose/Codable Structs/DockerCompose.swift b/Sources/Container-Compose/Codable Structs/DockerCompose.swift
index a406cbc..dad3c6e 100644
--- a/Sources/Container-Compose/Codable Structs/DockerCompose.swift
+++ b/Sources/Container-Compose/Codable Structs/DockerCompose.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// DockerCompose.swift
// container-compose-app
@@ -7,20 +23,27 @@
/// Represents the top-level structure of a docker-compose.yml file.
-struct DockerCompose: Codable {
- let version: String? // The Compose file format version (e.g., '3.8')
- let name: String? // Optional project name
- let services: [String: Service] // Dictionary of service definitions, keyed by service name
- let volumes: [String: Volume]? // Optional top-level volume definitions
- let networks: [String: Network]? // Optional top-level network definitions
- let configs: [String: Config]? // Optional top-level config definitions (primarily for Swarm)
- let secrets: [String: Secret]? // Optional top-level secret definitions (primarily for Swarm)
+public struct DockerCompose: Codable {
+ /// The Compose file format version (e.g., '3.8')
+ public let version: String?
+ /// Optional project name
+ public let name: String?
+ /// Dictionary of service definitions, keyed by service name
+ public let services: [String: Service?]
+ /// Optional top-level volume definitions
+ public let volumes: [String: Volume?]?
+ /// Optional top-level network definitions
+ public let networks: [String: Network?]?
+ /// Optional top-level config definitions (primarily for Swarm)
+ public let configs: [String: Config?]?
+ /// Optional top-level secret definitions (primarily for Swarm)
+ public let secrets: [String: Secret?]?
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
version = try container.decodeIfPresent(String.self, forKey: .version)
name = try container.decodeIfPresent(String.self, forKey: .name)
- services = try container.decode([String: Service].self, forKey: .services)
+ services = try container.decode([String: Service?].self, forKey: .services)
if let volumes = try container.decodeIfPresent([String: Optional].self, forKey: .volumes) {
let safeVolumes: [String : Volume] = volumes.mapValues { value in
@@ -30,8 +53,8 @@ struct DockerCompose: Codable {
} else {
self.volumes = nil
}
- networks = try container.decodeIfPresent([String: Network].self, forKey: .networks)
- configs = try container.decodeIfPresent([String: Config].self, forKey: .configs)
- secrets = try container.decodeIfPresent([String: Secret].self, forKey: .secrets)
+ networks = try container.decodeIfPresent([String: Network?].self, forKey: .networks)
+ configs = try container.decodeIfPresent([String: Config?].self, forKey: .configs)
+ secrets = try container.decodeIfPresent([String: Secret?].self, forKey: .secrets)
}
}
diff --git a/Sources/Container-Compose/Codable Structs/ExternalConfig.swift b/Sources/Container-Compose/Codable Structs/ExternalConfig.swift
index a661be6..60eb72c 100644
--- a/Sources/Container-Compose/Codable Structs/ExternalConfig.swift
+++ b/Sources/Container-Compose/Codable Structs/ExternalConfig.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// ExternalConfig.swift
// container-compose-app
@@ -7,7 +23,9 @@
/// Represents an external config reference.
-struct ExternalConfig: Codable {
- let isExternal: Bool // True if the config is external
- let name: String? // Optional name of the external config if different from key
-}
\ No newline at end of file
+public struct ExternalConfig: Codable {
+ /// True if the config is external
+ public let isExternal: Bool
+ /// Optional name of the external config if different from key
+ public let name: String?
+}
diff --git a/Sources/Container-Compose/Codable Structs/ExternalNetwork.swift b/Sources/Container-Compose/Codable Structs/ExternalNetwork.swift
index 2d74c27..3cc9d90 100644
--- a/Sources/Container-Compose/Codable Structs/ExternalNetwork.swift
+++ b/Sources/Container-Compose/Codable Structs/ExternalNetwork.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// ExternalNetwork.swift
// container-compose-app
@@ -7,7 +23,9 @@
/// Represents an external network reference.
-struct ExternalNetwork: Codable {
- let isExternal: Bool // True if the network is external
- let name: String? // Optional name of the external network if different from key
-}
\ No newline at end of file
+public struct ExternalNetwork: Codable {
+ /// True if the network is external
+ public let isExternal: Bool
+ // Optional name of the external network if different from key
+ public let name: String?
+}
diff --git a/Sources/Container-Compose/Codable Structs/ExternalSecret.swift b/Sources/Container-Compose/Codable Structs/ExternalSecret.swift
index b4aca8b..765f79e 100644
--- a/Sources/Container-Compose/Codable Structs/ExternalSecret.swift
+++ b/Sources/Container-Compose/Codable Structs/ExternalSecret.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// ExternalSecret.swift
// container-compose-app
@@ -7,7 +23,9 @@
/// Represents an external secret reference.
-struct ExternalSecret: Codable {
- let isExternal: Bool // True if the secret is external
- let name: String? // Optional name of the external secret if different from key
-}
\ No newline at end of file
+public struct ExternalSecret: Codable {
+ /// True if the secret is external
+ public let isExternal: Bool
+ /// Optional name of the external secret if different from key
+ public let name: String?
+}
diff --git a/Sources/Container-Compose/Codable Structs/ExternalVolume.swift b/Sources/Container-Compose/Codable Structs/ExternalVolume.swift
index 161e88c..91ae736 100644
--- a/Sources/Container-Compose/Codable Structs/ExternalVolume.swift
+++ b/Sources/Container-Compose/Codable Structs/ExternalVolume.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// ExternalVolume.swift
// container-compose-app
@@ -7,7 +23,9 @@
/// Represents an external volume reference.
-struct ExternalVolume: Codable {
- let isExternal: Bool // True if the volume is external
- let name: String? // Optional name of the external volume if different from key
-}
\ No newline at end of file
+public struct ExternalVolume: Codable {
+ /// True if the volume is external
+ public let isExternal: Bool
+ /// Optional name of the external volume if different from key
+ public let name: String?
+}
diff --git a/Sources/Container-Compose/Codable Structs/Healthcheck.swift b/Sources/Container-Compose/Codable Structs/Healthcheck.swift
index a5a64f6..c26e23a 100644
--- a/Sources/Container-Compose/Codable Structs/Healthcheck.swift
+++ b/Sources/Container-Compose/Codable Structs/Healthcheck.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// Healthcheck.swift
// container-compose-app
@@ -7,10 +23,29 @@
/// Healthcheck configuration for a service.
-struct Healthcheck: Codable, Hashable {
- let test: [String]? // Command to run to check health
- let start_period: String? // Grace period for the container to start
- let interval: String? // How often to run the check
- let retries: Int? // Number of consecutive failures to consider unhealthy
- let timeout: String? // Timeout for each check
+public struct Healthcheck: Codable, Hashable {
+ /// Command to run to check health
+ public let test: [String]?
+ /// Grace period for the container to start
+ public let start_period: String?
+ /// How often to run the check
+ public let interval: String?
+ /// Number of consecutive failures to consider unhealthy
+ public let retries: Int?
+ /// Timeout for each check
+ public let timeout: String?
+
+ public init(
+ test: [String]? = nil,
+ start_period: String? = nil,
+ interval: String? = nil,
+ retries: Int? = nil,
+ timeout: String? = nil
+ ) {
+ self.test = test
+ self.start_period = start_period
+ self.interval = interval
+ self.retries = retries
+ self.timeout = timeout
+ }
}
diff --git a/Sources/Container-Compose/Codable Structs/Network.swift b/Sources/Container-Compose/Codable Structs/Network.swift
index 58970c5..5fc3255 100644
--- a/Sources/Container-Compose/Codable Structs/Network.swift
+++ b/Sources/Container-Compose/Codable Structs/Network.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// Network.swift
// container-compose-app
@@ -7,23 +23,31 @@
/// Represents a top-level network definition.
-struct Network: Codable {
- let driver: String? // Network driver (e.g., 'bridge', 'overlay')
- let driver_opts: [String: String]? // Driver-specific options
- let attachable: Bool? // Allow standalone containers to attach to this network
- let enable_ipv6: Bool? // Enable IPv6 networking
- let isInternal: Bool? // RENAMED: from `internal` to `isInternal` to avoid keyword clash
- let labels: [String: String]? // Labels for the network
- let name: String? // Explicit name for the network
- let external: ExternalNetwork? // Indicates if the network is external (pre-existing)
+public struct Network: Codable {
+ /// Network driver (e.g., 'bridge', 'overlay')
+ public let driver: String?
+ /// Driver-specific options
+ public let driver_opts: [String: String]?
+ /// Allow standalone containers to attach to this network
+ public let attachable: Bool?
+ /// Enable IPv6 networking
+ public let enable_ipv6: Bool?
+ /// RENAMED: from `internal` to `isInternal` to avoid keyword clash
+ public let isInternal: Bool?
+ /// Labels for the network
+ public let labels: [String: String]?
+ /// Explicit name for the network
+ public let name: String?
+ /// Indicates if the network is external (pre-existing)
+ public let external: ExternalNetwork?
- // Updated CodingKeys to map 'internal' from YAML to 'isInternal' Swift property
+ /// Updated CodingKeys to map 'internal' from YAML to 'isInternal' Swift property
enum CodingKeys: String, CodingKey {
case driver, driver_opts, attachable, enable_ipv6, isInternal = "internal", labels, name, external
}
/// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_net" }` (object).
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
driver = try container.decodeIfPresent(String.self, forKey: .driver)
driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts)
@@ -41,4 +65,4 @@ struct Network: Codable {
external = nil
}
}
-}
\ No newline at end of file
+}
diff --git a/Sources/Container-Compose/Codable Structs/ResourceLimits.swift b/Sources/Container-Compose/Codable Structs/ResourceLimits.swift
index 4d78d66..8bd7b1a 100644
--- a/Sources/Container-Compose/Codable Structs/ResourceLimits.swift
+++ b/Sources/Container-Compose/Codable Structs/ResourceLimits.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// ResourceLimits.swift
// container-compose-app
@@ -7,7 +23,9 @@
/// CPU and memory limits.
-struct ResourceLimits: Codable, Hashable {
- let cpus: String? // CPU limit (e.g., "0.5")
- let memory: String? // Memory limit (e.g., "512M")
+public struct ResourceLimits: Codable, Hashable {
+ /// CPU limit (e.g., "0.5")
+ public let cpus: String?
+ /// Memory limit (e.g., "512M")
+ public let memory: String?
}
diff --git a/Sources/Container-Compose/Codable Structs/ResourceReservations.swift b/Sources/Container-Compose/Codable Structs/ResourceReservations.swift
index 77cf7be..a4c1997 100644
--- a/Sources/Container-Compose/Codable Structs/ResourceReservations.swift
+++ b/Sources/Container-Compose/Codable Structs/ResourceReservations.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// ResourceReservations.swift
// container-compose-app
@@ -8,8 +24,11 @@
/// **FIXED**: Renamed from `ResourceReservables` to `ResourceReservations` and made `Codable`.
/// CPU and memory reservations.
-struct ResourceReservations: Codable, Hashable { // Changed from ResourceReservables to ResourceReservations
- let cpus: String? // CPU reservation (e.g., "0.25")
- let memory: String? // Memory reservation (e.g., "256M")
- let devices: [DeviceReservation]? // Device reservations for GPUs or other devices
+public struct ResourceReservations: Codable, Hashable {
+ /// CPU reservation (e.g., "0.25")
+ public let cpus: String?
+ /// Memory reservation (e.g., "256M")
+ public let memory: String?
+ /// Device reservations for GPUs or other devices
+ public let devices: [DeviceReservation]?
}
diff --git a/Sources/Container-Compose/Codable Structs/Secret.swift b/Sources/Container-Compose/Codable Structs/Secret.swift
index 54042d1..569474c 100644
--- a/Sources/Container-Compose/Codable Structs/Secret.swift
+++ b/Sources/Container-Compose/Codable Structs/Secret.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// Secret.swift
// container-compose-app
@@ -7,19 +23,24 @@
/// Represents a top-level secret definition (primarily for Swarm).
-struct Secret: Codable {
- let file: String? // Path to the file containing the secret content
- let environment: String? // Environment variable to populate with the secret content
- let external: ExternalSecret? // Indicates if the secret is external (pre-existing)
- let name: String? // Explicit name for the secret
- let labels: [String: String]? // Labels for the secret
+public struct Secret: Codable {
+ /// Path to the file containing the secret content
+ public let file: String?
+ /// Environment variable to populate with the secret content
+ public let environment: String?
+ /// Indicates if the secret is external (pre-existing)
+ public let external: ExternalSecret?
+ /// Explicit name for the secret
+ public let name: String?
+ /// Labels for the secret
+ public let labels: [String: String]?
enum CodingKeys: String, CodingKey {
case file, environment, external, name, labels
}
/// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_sec" }` (object).
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
file = try container.decodeIfPresent(String.self, forKey: .file)
environment = try container.decodeIfPresent(String.self, forKey: .environment)
@@ -34,4 +55,4 @@ struct Secret: Codable {
external = nil
}
}
-}
\ No newline at end of file
+}
diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift
index 22e42cb..733d887 100644
--- a/Sources/Container-Compose/Codable Structs/Service.swift
+++ b/Sources/Container-Compose/Codable Structs/Service.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// Service.swift
// container-compose-app
@@ -5,42 +21,169 @@
// Created by Morris Richman on 6/17/25.
//
+import Foundation
+
/// Represents a single service definition within the `services` section.
-struct Service: Codable, Hashable {
- let image: String? // Docker image name
- let build: Build? // Build configuration if the service is built from a Dockerfile
- let deploy: Deploy? // Deployment configuration (primarily for Swarm)
- let restart: String? // Restart policy (e.g., 'unless-stopped', 'always')
- let healthcheck: Healthcheck? // Healthcheck configuration
- let volumes: [String]? // List of volume mounts (e.g., "hostPath:containerPath", "namedVolume:/path")
- let environment: [String: String]? // Environment variables to set in the container
- let env_file: [String]? // List of .env files to load environment variables from
- let ports: [String]? // Port mappings (e.g., "hostPort:containerPort")
- let command: [String]? // Command to execute in the container, overriding the image's default
- let depends_on: [String]? // Services this service depends on (for startup order)
- let user: String? // User or UID to run the container as
-
- let container_name: String? // Explicit name for the container instance
- let networks: [String]? // List of networks the service will connect to
- let hostname: String? // Container hostname
- let entrypoint: [String]? // Entrypoint to execute in the container, overriding the image's default
- let privileged: Bool? // Run container in privileged mode
- let read_only: Bool? // Mount container's root filesystem as read-only
- let working_dir: String? // Working directory inside the container
- let configs: [ServiceConfig]? // Service-specific config usage (primarily for Swarm)
- let secrets: [ServiceSecret]? // Service-specific secret usage (primarily for Swarm)
- let stdin_open: Bool? // Keep STDIN open (-i flag for `container run`)
- let tty: Bool? // Allocate a pseudo-TTY (-t flag for `container run`)
+public struct Service: Codable, Hashable {
+ /// Docker image name
+ public let image: String?
+
+ /// Build configuration if the service is built from a Dockerfile
+ public let build: Build?
+
+ /// Deployment configuration (primarily for Swarm)
+ public let deploy: Deploy?
+
+ /// Restart policy (e.g., 'unless-stopped', 'always')
+ public let restart: String?
+
+ /// Healthcheck configuration
+ public let healthcheck: Healthcheck?
+
+ /// List of volume mounts (e.g., "hostPath:containerPath", "namedVolume:/path")
+ public let volumes: [String]?
+
+ /// Environment variables to set in the container
+ public let environment: [String: String]?
+
+ /// List of .env files to load environment variables from
+ public let env_file: [String]?
+
+ /// Port mappings (e.g., "hostPort:containerPort")
+ public let ports: [String]?
+
+ /// Command to execute in the container, overriding the image's default
+ public let command: [String]?
+
+ /// Services this service depends on (for startup order)
+ public let depends_on: [String]?
+
+ /// User or UID to run the container as
+ public let user: String?
+
+ /// Explicit name for the container instance
+ public let container_name: String?
+
+ /// List of networks the service will connect to
+ public let networks: [String]?
+
+ /// Container hostname
+ public let hostname: String?
+
+ /// Entrypoint to execute in the container, overriding the image's default
+ public let entrypoint: [String]?
+
+ /// Run container in privileged mode
+ public let privileged: Bool?
+
+ /// Mount container's root filesystem as read-only
+ public let read_only: Bool?
+
+ /// Working directory inside the container
+ public let working_dir: String?
+
+ /// 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]?
+
+ /// Service-specific secret usage (primarily for Swarm)
+ public let secrets: [ServiceSecret]?
+
+ /// Keep STDIN open (-i flag for `container run`)
+ public let stdin_open: Bool?
+
+ /// 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
+ 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
+ public init(
+ image: String? = nil,
+ build: Build? = nil,
+ deploy: Deploy? = nil,
+ restart: String? = nil,
+ healthcheck: Healthcheck? = nil,
+ volumes: [String]? = nil,
+ environment: [String: String]? = nil,
+ env_file: [String]? = nil,
+ ports: [String]? = nil,
+ command: [String]? = nil,
+ depends_on: [String]? = nil,
+ user: String? = nil,
+ container_name: String? = nil,
+ networks: [String]? = nil,
+ hostname: String? = nil,
+ entrypoint: [String]? = nil,
+ privileged: Bool? = nil,
+ 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
+ self.build = build
+ self.deploy = deploy
+ self.restart = restart
+ self.healthcheck = healthcheck
+ self.volumes = volumes
+ self.environment = environment
+ self.env_file = env_file
+ self.ports = ports
+ self.command = command
+ self.depends_on = depends_on
+ self.user = user
+ self.container_name = container_name
+ self.networks = networks
+ self.hostname = hostname
+ self.entrypoint = entrypoint
+ self.privileged = privileged
+ 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
}
/// Custom initializer to handle decoding and basic validation.
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
image = try container.decodeIfPresent(String.self, forKey: .image)
build = try container.decodeIfPresent(Build.self, forKey: .build)
@@ -67,7 +210,11 @@ struct Service: Codable, Hashable {
command = nil
}
- depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on)
+ if let dependsOnString = try? container.decodeIfPresent(String.self, forKey: .depends_on) {
+ depends_on = [dependsOnString]
+ } else {
+ depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on)
+ }
user = try container.decodeIfPresent(String.self, forKey: .user)
container_name = try container.decodeIfPresent(String.self, forKey: .container_name)
@@ -90,5 +237,53 @@ struct Service: Codable, Hashable {
secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets)
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.
+ public static func topoSortConfiguredServices(
+ _ services: [(serviceName: String, service: Service)]
+ ) throws -> [(serviceName: String, service: Service)] {
+
+ var visited = Set()
+ var visiting = Set()
+ var sorted: [(String, Service)] = []
+
+ func visit(_ name: String, from service: String? = nil) throws {
+ guard var serviceTuple = services.first(where: { $0.serviceName == name }) else { return }
+ if let service {
+ serviceTuple.service.dependedBy.append(service)
+ }
+
+ if visiting.contains(name) {
+ throw NSError(domain: "ComposeError", code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'"
+ ])
+ }
+ guard !visited.contains(name) else { return }
+
+ visiting.insert(name)
+ for depName in serviceTuple.service.depends_on ?? [] {
+ try visit(depName, from: name)
+ }
+ visiting.remove(name)
+ visited.insert(name)
+ sorted.append(serviceTuple)
+ }
+
+ for (serviceName, _) in services {
+ if !visited.contains(serviceName) {
+ try visit(serviceName)
+ }
+ }
+
+ return sorted
}
}
diff --git a/Sources/Container-Compose/Codable Structs/ServiceConfig.swift b/Sources/Container-Compose/Codable Structs/ServiceConfig.swift
index daf7fbc..677e83d 100644
--- a/Sources/Container-Compose/Codable Structs/ServiceConfig.swift
+++ b/Sources/Container-Compose/Codable Structs/ServiceConfig.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// ServiceConfig.swift
// container-compose-app
@@ -7,15 +23,24 @@
/// Represents a service's usage of a config.
-struct ServiceConfig: Codable, Hashable {
- let source: String // Name of the config being used
- let target: String? // Path in the container where the config will be mounted
- let uid: String? // User ID for the mounted config file
- let gid: String? // Group ID for the mounted config file
- let mode: Int? // Permissions mode for the mounted config file
+public struct ServiceConfig: Codable, Hashable {
+ /// Name of the config being used
+ public let source: String
+
+ /// Path in the container where the config will be mounted
+ public let target: String?
+
+ /// User ID for the mounted config file
+ public let uid: String?
+
+ /// Group ID for the mounted config file
+ public let gid: String?
+
+ /// Permissions mode for the mounted config file
+ public let mode: Int?
/// Custom initializer to handle `config_name` (string) or `{ source: config_name, target: /path }` (object).
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let sourceName = try? container.decode(String.self) {
self.source = sourceName
diff --git a/Sources/Container-Compose/Codable Structs/ServiceSecret.swift b/Sources/Container-Compose/Codable Structs/ServiceSecret.swift
index 19e5daa..e1daa3b 100644
--- a/Sources/Container-Compose/Codable Structs/ServiceSecret.swift
+++ b/Sources/Container-Compose/Codable Structs/ServiceSecret.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// ServiceSecret.swift
// container-compose-app
@@ -7,15 +23,24 @@
/// Represents a service's usage of a secret.
-struct ServiceSecret: Codable, Hashable {
- let source: String // Name of the secret being used
- let target: String? // Path in the container where the secret will be mounted
- let uid: String? // User ID for the mounted secret file
- let gid: String? // Group ID for the mounted secret file
- let mode: Int? // Permissions mode for the mounted secret file
+public struct ServiceSecret: Codable, Hashable {
+ /// Name of the secret being used
+ public let source: String
+
+ /// Path in the container where the secret will be mounted
+ public let target: String?
+
+ /// User ID for the mounted secret file
+ public let uid: String?
+
+ /// Group ID for the mounted secret file
+ public let gid: String?
+
+ /// Permissions mode for the mounted secret file
+ public let mode: Int?
/// Custom initializer to handle `secret_name` (string) or `{ source: secret_name, target: /path }` (object).
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let sourceName = try? container.decode(String.self) {
self.source = sourceName
diff --git a/Sources/Container-Compose/Codable Structs/Volume.swift b/Sources/Container-Compose/Codable Structs/Volume.swift
index 702a329..7204b04 100644
--- a/Sources/Container-Compose/Codable Structs/Volume.swift
+++ b/Sources/Container-Compose/Codable Structs/Volume.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// Volume.swift
// container-compose-app
@@ -7,19 +23,28 @@
/// Represents a top-level volume definition.
-struct Volume: Codable {
- let driver: String? // Volume driver (e.g., 'local')
- let driver_opts: [String: String]? // Driver-specific options
- let name: String? // Explicit name for the volume
- let labels: [String: String]? // Labels for the volume
- let external: ExternalVolume? // Indicates if the volume is external (pre-existing)
+public struct Volume: Codable {
+ /// Volume driver (e.g., 'local')
+ public let driver: String?
+
+ /// Driver-specific options
+ public let driver_opts: [String: String]?
+
+ /// Explicit name for the volume
+ public let name: String?
+
+ /// Labels for the volume
+ public let labels: [String: String]?
+
+ /// Indicates if the volume is external (pre-existing)
+ public let external: ExternalVolume?
enum CodingKeys: String, CodingKey {
case driver, driver_opts, name, labels, external
}
/// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_vol" }` (object).
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
driver = try container.decodeIfPresent(String.self, forKey: .driver)
driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts)
@@ -35,7 +60,7 @@ struct Volume: Codable {
}
}
- init(driver: String? = nil, driver_opts: [String : String]? = nil, name: String? = nil, labels: [String : String]? = nil, external: ExternalVolume? = nil) {
+ public init(driver: String? = nil, driver_opts: [String : String]? = nil, name: String? = nil, labels: [String : String]? = nil, external: ExternalVolume? = nil) {
self.driver = driver
self.driver_opts = driver_opts
self.name = name
diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift
new file mode 100644
index 0000000..68210e5
--- /dev/null
+++ b/Sources/Container-Compose/Commands/ComposeDown.swift
@@ -0,0 +1,143 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+//
+// ComposeDown.swift
+// Container-Compose
+//
+// Created by Morris Richman on 6/19/25.
+//
+
+import ArgumentParser
+import ContainerCommands
+import ContainerAPIClient
+import Foundation
+import Yams
+
+public struct ComposeDown: AsyncParsableCommand {
+ public init() {}
+
+ public static let configuration: CommandConfiguration = .init(
+ commandName: "down",
+ abstract: "Stop containers with compose"
+ )
+
+ @Argument(help: "Specify the services to stop")
+ var services: [String] = []
+
+ @OptionGroup
+ var process: Flags.Process
+
+ private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath }
+
+ @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file")
+ var composeFilename: String = "compose.yml"
+ private var composePath: String { "\(cwd)/\(composeFilename)" } // Path to compose.yml
+
+ private var fileManager: FileManager { FileManager.default }
+ private var projectName: String?
+
+ public mutating func run() async throws {
+
+ // Check for supported filenames and extensions
+ let filenames = [
+ "compose.yml",
+ "compose.yaml",
+ "docker-compose.yml",
+ "docker-compose.yaml",
+ ]
+ for filename in filenames {
+ if fileManager.fileExists(atPath: "\(cwd)/\(filename)") {
+ composeFilename = filename
+ break
+ }
+ }
+
+ // Read docker-compose.yml content
+ guard let yamlData = fileManager.contents(atPath: composePath) else {
+ let path = URL(fileURLWithPath: composePath)
+ .deletingLastPathComponent()
+ .path
+ throw YamlError.composeFileNotFound(path)
+ }
+
+ // Decode the YAML file into the DockerCompose struct
+ let dockerComposeString = String(data: yamlData, encoding: .utf8)!
+ let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString)
+
+ // Determine project name for container naming
+ if let name = dockerCompose.name {
+ projectName = name
+ print("Info: Docker 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 ?? "")")
+ }
+
+ var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ serviceName, service in
+ guard let service else { return nil }
+ return (serviceName, service)
+ })
+ services = try Service.topoSortConfiguredServices(services)
+
+ // Filter for specified services
+ if !self.services.isEmpty {
+ services = services.filter({ serviceName, service in
+ self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) })
+ })
+ }
+
+ try await stopOldStuff(services, remove: false)
+ }
+
+ private func stopOldStuff(_ services: [(serviceName: String, service: Service)], remove: Bool) async throws {
+ guard let projectName else { return }
+
+ for (serviceName, service) in services {
+ // Respect explicit container_name, otherwise use default pattern
+ let containerName: String
+ if let explicitContainerName = service.container_name {
+ containerName = explicitContainerName
+ } else {
+ containerName = "\(projectName)-\(serviceName)"
+ }
+
+ print("Stopping container: \(containerName)")
+ guard let container = try? await ClientContainer.get(id: containerName) else {
+ print("Warning: Container '\(containerName)' not found, skipping.")
+ continue
+ }
+
+ do {
+ try await container.stop()
+ print("Successfully stopped container: \(containerName)")
+ } catch {
+ print("Error Stopping Container: \(error)")
+ }
+ if remove {
+ do {
+ try await container.delete()
+ print("Successfully removed container: \(containerName)")
+ } catch {
+ print("Error Removing Container: \(error)")
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift
new file mode 100644
index 0000000..f4150b0
--- /dev/null
+++ b/Sources/Container-Compose/Commands/ComposeUp.swift
@@ -0,0 +1,878 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+//
+// ComposeUp.swift
+// Container-Compose
+//
+// Created by Morris Richman on 6/19/25.
+//
+
+import ArgumentParser
+import ContainerCommands
+//import ContainerClient
+import ContainerAPIClient
+import ContainerizationExtras
+import Foundation
+@preconcurrency import Rainbow
+import Yams
+
+public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
+ public init() {}
+
+ public static let configuration: CommandConfiguration = .init(
+ commandName: "up",
+ abstract: "Start containers with compose"
+ )
+
+ @Argument(help: "Specify the services to start")
+ var services: [String] = []
+
+ @Flag(
+ name: [.customShort("d"), .customLong("detach")],
+ 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")
+ var composeFilename: String = "compose.yml"
+ private var composePath: String { "\(cwd)/\(composeFilename)" } // Path to compose.yml
+
+ @Flag(name: [.customShort("b"), .customLong("build")])
+ var rebuild: Bool = false
+
+ @Flag(name: .long, help: "Do not use cache")
+ var noCache: Bool = false
+
+ @OptionGroup
+ var process: Flags.Process
+
+ @OptionGroup
+ var logging: Flags.Logging
+
+ private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath }
+ var envFilePath: String { "\(cwd)/\(process.envFile.first ?? ".env")" } // Path to optional .env file
+
+ private var fileManager: FileManager { FileManager.default }
+ private var projectName: String?
+ private var environmentVariables: [String: String] = [:]
+ private var containerIps: [String: String] = [:]
+ private var containerConsoleColors: [String: NamedColor] = [:]
+
+ private static let availableContainerConsoleColors: Set = [
+ .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green,
+ ]
+
+ public mutating func run() async throws {
+ // Check for supported filenames and extensions
+ let filenames = [
+ "compose.yml",
+ "compose.yaml",
+ "docker-compose.yml",
+ "docker-compose.yaml",
+ ]
+ for filename in filenames {
+ if fileManager.fileExists(atPath: "\(cwd)/\(filename)") {
+ composeFilename = filename
+ break
+ }
+ }
+
+ // Read compose.yml content
+ guard let yamlData = fileManager.contents(atPath: composePath) else {
+ let path = URL(fileURLWithPath: composePath)
+ .deletingLastPathComponent()
+ .path
+ throw YamlError.composeFileNotFound(path)
+ }
+
+ // Decode the YAML file into the DockerCompose struct
+ let dockerComposeString = String(data: yamlData, encoding: .utf8)!
+ let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString)
+
+ // Load environment variables from .env file
+ environmentVariables = loadEnvFile(path: envFilePath)
+
+ // Handle 'version' field
+ if let version = dockerCompose.version {
+ print("Info: Docker 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(
+ "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 ?? "")")
+ }
+
+ // Get Services to use
+ var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ serviceName, service in
+ guard let service else { return nil }
+ return (serviceName, service)
+ })
+ services = try Service.topoSortConfiguredServices(services)
+
+ // Filter for specified services
+ if !self.services.isEmpty {
+ services = services.filter({ serviceName, service in
+ self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) })
+ })
+ }
+
+ // Stop Services
+ try await stopOldStuff(services.map({ $0.serviceName }), remove: true)
+
+ // Process top-level networks
+ // This creates named networks defined in the docker-compose.yml
+ if let networks = dockerCompose.networks {
+ print("\n--- Processing Networks ---")
+ for (networkName, networkConfig) in networks {
+ try await setupNetwork(name: networkName, config: networkConfig)
+ }
+ print("--- Networks Processed ---\n")
+ }
+
+ // Process top-level volumes
+ // This creates named volumes defined in the docker-compose.yml
+ if let volumes = dockerCompose.volumes {
+ print("\n--- Processing Volumes ---")
+ for (volumeName, volumeConfig) in volumes {
+ guard let volumeConfig else { continue }
+ await createVolumeHardLink(name: volumeName, config: volumeConfig)
+ }
+ print("--- Volumes Processed ---\n")
+ }
+
+ // Process each service defined in the docker-compose.yml
+ print("\n--- Processing Services ---")
+
+ print(services.map(\.serviceName))
+ for (serviceName, service) in services {
+ try await configService(service, serviceName: serviceName, from: dockerCompose)
+ }
+
+ if !detach {
+ await waitForever()
+ }
+ }
+
+ func waitForever() async -> Never {
+ for await _ in AsyncStream(unfolding: {}) {
+ // This will never run
+ }
+ fatalError("unreachable")
+ }
+
+ 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
+
+ return ip
+ }
+
+ /// Repeatedly checks `container list -a` until the given container is listed as `running`.
+ /// - Parameters:
+ /// - 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).
+ private func waitUntilContainerIsRunning(_ containerName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws {
+ let deadline = Date().addingTimeInterval(timeout)
+
+ while Date() < deadline {
+ 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))
+ }
+
+ throw NSError(
+ domain: "ContainerWait", code: 1,
+ userInfo: [
+ NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running."
+ ])
+ }
+
+ private func stopOldStuff(_ services: [String], remove: Bool) async throws {
+ guard let projectName else { return }
+ let containers = services.map { "\(projectName)-\($0)" }
+
+ for container in containers {
+ print("Stopping container: \(container)")
+ guard let container = try? await ClientContainer.get(id: container) else { continue }
+
+ do {
+ try await container.stop()
+ } catch {
+ print("Error Stopping Container: \(error)")
+ }
+ if remove {
+ do {
+ try await container.delete()
+ } catch {
+ print("Error Removing Container: \(error)")
+ }
+ }
+ }
+ }
+
+ // MARK: Compose Top Level Functions
+
+ 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
+ }
+ }
+
+ 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
+
+ let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)")
+ let volumePath = volumeUrl.path(percentEncoded: false)
+
+ 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)
+ }
+
+ private func setupNetwork(name networkName: String, config networkConfig: Network?) async throws {
+ let actualNetworkName = networkConfig?.name ?? networkName // Use explicit name or key as name
+
+ if let externalNetwork = networkConfig?.external, externalNetwork.isExternal {
+ print("Info: Network '\(networkName)' is declared as external.")
+ print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.")
+ } else {
+ var networkCreateArgs: [String] = ["network", "create"]
+
+ #warning("Docker Compose Network Options Not Supported")
+ // Add driver and driver options
+ if let driver = networkConfig?.driver, !driver.isEmpty {
+ // networkCreateArgs.append("--driver")
+ // networkCreateArgs.append(driver)
+ print("Network Driver Detected, But Not Supported")
+ }
+ if let driverOpts = networkConfig?.driver_opts, !driverOpts.isEmpty {
+ // for (optKey, optValue) in driverOpts {
+ // networkCreateArgs.append("--opt")
+ // networkCreateArgs.append("\(optKey)=\(optValue)")
+ // }
+ print("Network Options Detected, But Not Supported")
+ }
+ // Add various network flags
+ if networkConfig?.attachable == true {
+ // networkCreateArgs.append("--attachable")
+ print("Network Attachable Flag Detected, But Not Supported")
+ }
+ if networkConfig?.enable_ipv6 == true {
+ // networkCreateArgs.append("--ipv6")
+ print("Network IPv6 Flag Detected, But Not Supported")
+ }
+ if networkConfig?.isInternal == true {
+ // networkCreateArgs.append("--internal")
+ print("Network Internal Flag Detected, But Not Supported")
+ } // CORRECTED: Use isInternal
+
+ // Add labels
+ if let labels = networkConfig?.labels, !labels.isEmpty {
+ print("Network Labels Detected, But Not Supported")
+ // for (labelKey, labelValue) in labels {
+ // networkCreateArgs.append("--label")
+ // networkCreateArgs.append("\(labelKey)=\(labelValue)")
+ // }
+ }
+
+ print("Creating network: \(networkName) (Actual name: \(actualNetworkName))")
+ print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))")
+ guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else {
+ print("Network '\(networkName)' already exists")
+ return
+ }
+ let commands = [actualNetworkName]
+
+ let networkCreate = try Application.NetworkCreate.parse(commands + logging.passThroughCommands())
+
+ try await networkCreate.run()
+ print("Network '\(networkName)' created")
+ }
+ }
+
+ // MARK: Compose Service Level Functions
+ private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws {
+ guard let projectName else { throw ComposeError.invalidProjectName }
+
+ var imageToRun: String
+
+ var runCommandArgs: [String] = []
+
+ // Handle 'build' configuration
+ if let buildConfig = service.build {
+ imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName)
+ } else if let img = service.image {
+ // Use specified image if no build config
+ // Pull image if necessary
+ try await pullImage(img, platform: service.platform)
+ imageToRun = img
+ } else {
+ // Should not happen due to Service init validation, but as a fallback
+ throw ComposeError.imageNotFound(serviceName)
+ }
+
+ // Set Run Platform
+ if let platform = service.platform {
+ runCommandArgs.append(contentsOf: ["--platform", "\(platform)"])
+ }
+
+ // Handle 'deploy' configuration (note that this tool doesn't fully support it)
+ if service.deploy != nil {
+ print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.")
+ print(
+ "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands."
+ )
+ print("The service will be run as a single container based on other configurations.")
+ }
+
+ // Add detach flag if specified on the CLI
+ if detach {
+ runCommandArgs.append("-d")
+ }
+
+ // Determine container name
+ let containerName: String
+ if let explicitContainerName = service.container_name {
+ containerName = explicitContainerName
+ print("Info: Using explicit container_name: \(containerName)")
+ } else {
+ // Default container name based on project and service name
+ containerName = "\(projectName)-\(serviceName)"
+ }
+ runCommandArgs.append("--name")
+ runCommandArgs.append(containerName)
+
+ // REMOVED: Restart policy is not supported by `container run`
+ // if let restart = service.restart {
+ // runCommandArgs.append("--restart")
+ // runCommandArgs.append(restart)
+ // }
+
+ // Add user
+ if let user = service.user {
+ runCommandArgs.append("--user")
+ runCommandArgs.append(user)
+ }
+
+ // Add volume mounts
+ if let volumes = service.volumes {
+ for volume in volumes {
+ let args = try await configVolume(volume)
+ runCommandArgs.append(contentsOf: args)
+ }
+ }
+
+ // Combine environment variables from .env files and service environment
+ var combinedEnv: [String: String] = environmentVariables
+
+ if let envFiles = service.env_file {
+ for envFile in envFiles {
+ let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)")
+ combinedEnv.merge(additionalEnvVars) { (current, _) in current }
+ }
+ }
+
+ if let serviceEnv = service.environment {
+ combinedEnv.merge(serviceEnv) { (old, new) in
+ guard !new.contains("${") else {
+ return old
+ }
+ return new
+ } // Service env overrides .env files
+ }
+
+ // Fill in variables
+ combinedEnv = combinedEnv.mapValues({ value in
+ guard value.contains("${") else { return value }
+
+ let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast())
+ return combinedEnv[variableName] ?? value
+ })
+
+ // Fill in IPs
+ combinedEnv = combinedEnv.mapValues({ value in
+ containerIps[value] ?? value
+ })
+
+ // MARK: Spinning Spot
+ // Add environment variables to run command
+ for (key, value) in combinedEnv {
+ runCommandArgs.append("-e")
+ runCommandArgs.append("\(key)=\(value)")
+ }
+
+ if let ports = service.ports {
+ for port in ports {
+ let resolvedPort = resolveVariable(port, with: environmentVariables)
+ runCommandArgs.append("-p")
+ runCommandArgs.append("0.0.0.0:\(resolvedPort)")
+ }
+ }
+
+ // Connect to specified networks
+ if let serviceNetworks = service.networks {
+ for network in serviceNetworks {
+ let resolvedNetwork = resolveVariable(network, with: environmentVariables)
+ // Use the explicit network name from top-level definition if available, otherwise resolved name
+ let networkToConnect = dockerCompose.networks?[network]??.name ?? resolvedNetwork
+ runCommandArgs.append("--network")
+ runCommandArgs.append(networkToConnect)
+ }
+ print(
+ "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in \(composeFilename)."
+ )
+ print(
+ "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level."
+ )
+ } else {
+ print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.")
+ }
+
+ // Add hostname
+ if let hostname = service.hostname {
+ let resolvedHostname = resolveVariable(hostname, with: environmentVariables)
+ runCommandArgs.append("--hostname")
+ runCommandArgs.append(resolvedHostname)
+ }
+
+ // Add working directory
+ if let workingDir = service.working_dir {
+ let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables)
+ runCommandArgs.append("--workdir")
+ runCommandArgs.append(resolvedWorkingDir)
+ }
+
+ // Add privileged flag
+ if service.privileged == true {
+ runCommandArgs.append("--privileged")
+ }
+
+ // Add read-only flag
+ if service.read_only == true {
+ runCommandArgs.append("--read-only")
+ }
+
+ // Add resource limits
+ if let cpus = service.deploy?.resources?.limits?.cpus {
+ runCommandArgs.append(contentsOf: ["--cpus", cpus])
+ }
+ if let memory = service.deploy?.resources?.limits?.memory {
+ runCommandArgs.append(contentsOf: ["--memory", memory])
+ }
+
+ // Handle service-level configs (note: still only parsing/logging, not attaching)
+ if let serviceConfigs = service.configs {
+ print(
+ "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands."
+ )
+ print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.")
+ for serviceConfig in serviceConfigs {
+ print(
+ " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))"
+ )
+ }
+ }
+ //
+ // Handle service-level secrets (note: still only parsing/logging, not attaching)
+ if let serviceSecrets = service.secrets {
+ print(
+ "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands."
+ )
+ print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.")
+ for serviceSecret in serviceSecrets {
+ print(
+ " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))"
+ )
+ }
+ }
+
+ // Add interactive and TTY flags
+ if service.stdin_open == true {
+ runCommandArgs.append("-i") // --interactive
+ }
+ if service.tty == true {
+ runCommandArgs.append("-t") // --tty
+ }
+
+ // Configure DNS for container-to-container name resolution
+ if let dnsSearch = service.dns_search {
+ runCommandArgs.append("--dns-search")
+ runCommandArgs.append(dnsSearch)
+ }
+
+ // 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(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)
+ }
+
+ var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()!
+
+ if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) {
+ while containerConsoleColors.values.contains(serviceColor) {
+ serviceColor = Self.availableContainerConsoleColors.randomElement()!
+ }
+ }
+
+ 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) {
+ print("\(serviceName): \(output)".applyingColor(serviceColor))
+ }
+
+ print("\nStarting service: \(serviceName)")
+ print("Starting \(serviceName)")
+ print("----------------------------------------\n")
+ let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput)
+ }
+
+ do {
+ try await waitUntilContainerIsRunning(containerName)
+ try await updateEnvironmentWithServiceIP(serviceName, containerName: containerName)
+ } catch {
+ print(error)
+ }
+ }
+
+ private func pullImage(_ imageName: String, platform: String?) async throws {
+ let imageList = try await ClientImage.list()
+ guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else {
+ return
+ }
+
+ print("Pulling Image \(imageName)...")
+
+ var commands = [
+ imageName
+ ]
+
+ if let platform {
+ commands.append(contentsOf: ["--platform", platform])
+ }
+
+ let imagePull = try Application.ImagePull.parse(commands + logging.passThroughCommands())
+ try await imagePull.run()
+ }
+
+ /// Builds Docker Service
+ ///
+ /// - Parameters:
+ /// - buildConfig: The configuration for the build
+ /// - service: The service you would like to build
+ /// - serviceName: The fallback name for the image
+ ///
+ /// - Returns: Image Name (`String`)
+ private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String {
+ // Determine image tag for built image
+ let imageToRun = service.image ?? "\(serviceName):latest"
+ let imageList = try await ClientImage.list()
+ if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) {
+ return imageToRun
+ }
+
+ // Build command arguments
+ var commands = ["\(self.cwd)/\(buildConfig.context)"]
+
+ // Add build arguments
+ for (key, value) in buildConfig.args ?? [:] {
+ commands.append(contentsOf: ["--build-arg", "\(key)=\(resolveVariable(value, with: environmentVariables))"])
+ }
+
+ // 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")
+ }
+
+ // Add OS/Arch
+ let split = service.platform?.split(separator: "/")
+ let os = String(split?.first ?? "linux")
+ let arch = String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")
+ commands.append(contentsOf: ["--os", os])
+ commands.append(contentsOf: ["--arch", arch])
+
+ // Add image name
+ commands.append(contentsOf: ["--tag", imageToRun])
+
+ // Add CPU & Memory
+ let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2
+ let memoryLimit = service.deploy?.resources?.limits?.memory ?? "2048MB"
+ commands.append(contentsOf: ["--cpus", "\(cpuCount)"])
+ commands.append(contentsOf: ["--memory", memoryLimit])
+
+ 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.")
+ print("----------------------------------------")
+
+ return imageToRun
+ }
+
+ private func configVolume(_ volume: String) async throws -> [String] {
+ let resolvedVolume = resolveVariable(volume, with: environmentVariables)
+
+ var runCommandArgs: [String] = []
+
+ // Parse the volume string: destination[:mode]
+ let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init)
+
+ guard components.count >= 2 else {
+ print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.")
+ return []
+ }
+
+ let source = components[0]
+ let destination = components[1]
+
+ // 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)
+
+ 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
+ } 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.")
+ }
+ } else {
+ // Host path does not exist, assume it's meant to be a directory and try to create it.
+ 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
+ } 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)
+
+ 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):\(destination)") // Use original source for command argument
+ }
+
+ return runCommandArgs
+ }
+}
+
+// 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:
+ /// - command: The name of the command to run (e.g., `"container"`).
+ /// - args: Command-line arguments to pass to the command.
+ /// - onStdout: Closure called with streamed stdout data.
+ /// - onStderr: Closure called with streamed stderr data.
+ /// - Returns: The process's exit code.
+ /// - Throws: If the process fails to launch.
+ @discardableResult
+ func streamCommand(
+ _ command: String,
+ args: [String] = [],
+ onStdout: @escaping (@Sendable (String) -> Void),
+ onStderr: @escaping (@Sendable (String) -> Void)
+ ) async throws -> Int32 {
+ try await withCheckedThrowingContinuation { continuation in
+ let process = Process()
+ let stdoutPipe = Pipe()
+ let stderrPipe = Pipe()
+
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+ process.arguments = [command] + args
+ process.currentDirectoryURL = URL(fileURLWithPath: cwd)
+ 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 stdoutHandle = stdoutPipe.fileHandleForReading
+ let stderrHandle = stderrPipe.fileHandleForReading
+
+ stdoutHandle.readabilityHandler = { handle in
+ let data = handle.availableData
+ guard !data.isEmpty else { return }
+ if let string = String(data: data, encoding: .utf8) {
+ onStdout(string)
+ }
+ }
+
+ stderrHandle.readabilityHandler = { handle in
+ let data = handle.availableData
+ guard !data.isEmpty else { return }
+ if let string = String(data: data, encoding: .utf8) {
+ onStderr(string)
+ }
+ }
+
+ process.terminationHandler = { proc in
+ stdoutHandle.readabilityHandler = nil
+ stderrHandle.readabilityHandler = nil
+ continuation.resume(returning: proc.terminationStatus)
+ }
+
+ do {
+ try process.run()
+ } catch {
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+}
diff --git a/Sources/Container-Compose/Commands/Version.swift b/Sources/Container-Compose/Commands/Version.swift
new file mode 100644
index 0000000..508703d
--- /dev/null
+++ b/Sources/Container-Compose/Commands/Version.swift
@@ -0,0 +1,39 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+//
+// Version.swift
+// Container-Compose
+//
+// Created by Container Compose Contributors
+//
+
+import ArgumentParser
+import Foundation
+
+public struct Version: ParsableCommand {
+
+ public static let configuration: CommandConfiguration = .init(
+ commandName: "version",
+ abstract: "Display container-compose version and fork capabilities (dnsSearch, multi-stage build target, improved volume and entrypoint handling)"
+ )
+
+ public func run() {
+ print("\(Main.versionString)")
+ }
+
+ public init() {}
+}
diff --git a/Sources/Container-Compose/Errors.swift b/Sources/Container-Compose/Errors.swift
index 32ac5c8..b408ba6 100644
--- a/Sources/Container-Compose/Errors.swift
+++ b/Sources/Container-Compose/Errors.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// Errors.swift
// Container-Compose
@@ -5,24 +21,26 @@
// Created by Morris Richman on 6/18/25.
//
+import ContainerCommands
import Foundation
-enum YamlError: Error, LocalizedError {
- case dockerfileNotFound(String)
-
- var errorDescription: String? {
+//extension Application {
+public enum YamlError: Error, LocalizedError {
+ case composeFileNotFound(String)
+
+ public var errorDescription: String? {
switch self {
- case .dockerfileNotFound(let path):
- return "docker-compose.yml not found at \(path)"
+ case .composeFileNotFound(let path):
+ return "compose.yml not found at \(path)"
}
}
}
-enum ComposeError: Error, LocalizedError {
+public enum ComposeError: Error, LocalizedError {
case imageNotFound(String)
case invalidProjectName
-
- var errorDescription: String? {
+
+ public var errorDescription: String? {
switch self {
case .imageNotFound(let name):
return "Service \(name) must define either 'image' or 'build'."
@@ -32,17 +50,18 @@ enum ComposeError: Error, LocalizedError {
}
}
-enum TerminalError: Error, LocalizedError {
+public enum TerminalError: Error, LocalizedError {
case commandFailed(String)
-
- var errorDescription: String? {
- return "Command failed: \(self)"
+
+ public var errorDescription: String? {
+ "Command failed: \(self)"
}
}
/// An enum representing streaming output from either `stdout` or `stderr`.
-enum CommandOutput {
+public enum CommandOutput {
case stdout(String)
case stderr(String)
case exitCode(Int32)
}
+//}
diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift
index 50549fd..9a21a2b 100644
--- a/Sources/Container-Compose/Helper Functions.swift
+++ b/Sources/Container-Compose/Helper Functions.swift
@@ -1,3 +1,19 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
//
// Helper Functions.swift
// container-compose-app
@@ -7,11 +23,13 @@
import Foundation
import Yams
+import Rainbow
+import ContainerCommands
/// Loads environment variables from a .env file.
/// - Parameter path: The full path to the .env file.
/// - Returns: A dictionary of key-value pairs representing environment variables.
-func loadEnvFile(path: String) -> [String: String] {
+public func loadEnvFile(path: String) -> [String: String] {
var envVars: [String: String] = [:]
let fileURL = URL(fileURLWithPath: path)
do {
@@ -42,14 +60,14 @@ func loadEnvFile(path: String) -> [String: String] {
/// - value: The string possibly containing environment variable references.
/// - envVars: A dictionary of environment variables to use for resolution.
/// - Returns: The string with all recognized environment variables resolved.
-func resolveVariable(_ value: String, with envVars: [String: String]) -> String {
+public func resolveVariable(_ value: String, with envVars: [String: String]) -> String {
var resolvedValue = value
// Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error}
- let regex = try! NSRegularExpression(pattern: "\\$\\{([A-Z0-9_]+)(:?-(.*?))?(:\\?(.*?))?\\}", options: [])
+ let regex = try! NSRegularExpression(pattern: #"\$\{([A-Za-z0-9_]+)(:?-(.*?))?(:\?(.*?))?\}"#, options: [])
// Combine process environment with loaded .env file variables, prioritizing process environment
let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current }
-
+
// Loop to resolve all occurrences of variables in the string
while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex.. String
// Variable not found, and error-on-missing syntax used, print error and exit
let errorMessage = String(resolvedValue[errorMessageRange])
fputs("Error: Missing required environment variable '\(varName)': \(errorMessage)\n", stderr)
- exit(1)
+ Application.exit(withError: "Error: Missing required environment variable '\(varName)': \(errorMessage)\n")
} else {
// Variable not found and no default/error specified, leave as is and break loop to avoid infinite loop
break
@@ -74,3 +92,32 @@ func resolveVariable(_ value: String, with envVars: [String: String]) -> String
}
return resolvedValue
}
+
+/// Derives a project name from the current working directory. It replaces any '.' characters with
+/// '_' to ensure compatibility with container naming conventions.
+///
+/// - Parameter cwd: The current working directory path.
+/// - Returns: A sanitized project name suitable for container naming.
+public func deriveProjectName(cwd: String) -> String {
+ // We need to replace '.' with _ because it is not supported in the container name
+ let projectName = URL(fileURLWithPath: cwd).lastPathComponent.replacingOccurrences(of: ".", with: "_")
+ return projectName
+}
+
+extension String: @retroactive Error {}
+
+/// A structure representing the result of a command-line process execution.
+public struct CommandResult {
+ /// The standard output captured from the process.
+ public let stdout: String
+
+ /// The standard error output captured from the process.
+ public let stderr: String
+
+ /// The exit code returned by the process upon termination.
+ public let exitCode: Int32
+}
+
+extension NamedColor: @retroactive Codable {
+
+}
diff --git a/Sources/Container-Compose/old.swift b/Sources/Container-Compose/old.swift
deleted file mode 100644
index 469cb9c..0000000
--- a/Sources/Container-Compose/old.swift
+++ /dev/null
@@ -1,465 +0,0 @@
-import Foundation
-import Yams
-
-//// MARK: - Main Logic
-//let usageString = "Usage: container-compose up [-d]"
-//
-//// Process command line arguments
-//let arguments = CommandLine.arguments
-//guard arguments.count >= 2 else {
-// print(usageString)
-// exit(1)
-//}
-//
-//let subcommand = arguments[1] // Get the subcommand (e.g., "up")
-//let detachFlag = arguments.contains("-d") // Check for the -d (detach) flag
-//
-//// Currently, only the "up" subcommand is supported
-//guard subcommand == "up" else {
-// print("Error: Only 'up' subcommand is supported.")
-// exit(1)
-//}
-//
-//let fileManager = FileManager.default
-//let currentDirectory = "/Users/mcrich/Xcode/Assignment-Manager-API" //fileManager.currentDirectoryPath // Get current working directory
-//let dockerComposePath = "\(currentDirectory)/docker-compose.yml" // Path to docker-compose.yml
-//let envFilePath = "\(currentDirectory)/.env" // Path to optional .env file
-//
-//// Read docker-compose.yml content
-//guard let yamlData = fileManager.contents(atPath: dockerComposePath) else {
-// fputs("Error: docker-compose.yml not found at \(dockerComposePath)\n", stderr)
-// exit(1)
-//}
-//
-//do {
-// // Decode the YAML file into the DockerCompose struct
-// let dockerComposeString = String(data: yamlData, encoding: .utf8)!
-// let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString)
-//
-// // Load environment variables from .env file
-// let envVarsFromFile = loadEnvFile(path: envFilePath)
-//
-// // Handle 'version' field
-// if let version = dockerCompose.version {
-// print("Info: Docker 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
-// let projectName: String
-// if let name = dockerCompose.name {
-// projectName = name
-// print("Info: Docker 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 = URL(fileURLWithPath: currentDirectory).lastPathComponent // Default to directory name
-// print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "unknown")")
-// }
-//
-// // Process top-level networks
-// // This creates named networks defined in the docker-compose.yml
-// if let networks = dockerCompose.networks {
-// print("\n--- Processing Networks ---")
-// for (networkName, networkConfig) in networks {
-// let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name
-//
-// if let externalNetwork = networkConfig.external, externalNetwork.isExternal {
-// print("Info: Network '\(networkName)' is declared as external.")
-// print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.")
-// } else {
-// var networkCreateArgs: [String] = ["network", "create"]
-//
-// // Add driver and driver options
-// if let driver = networkConfig.driver {
-// networkCreateArgs.append("--driver")
-// networkCreateArgs.append(driver)
-// }
-// if let driverOpts = networkConfig.driver_opts {
-// for (optKey, optValue) in driverOpts {
-// networkCreateArgs.append("--opt")
-// networkCreateArgs.append("\(optKey)=\(optValue)")
-// }
-// }
-// // Add various network flags
-// if networkConfig.attachable == true { networkCreateArgs.append("--attachable") }
-// if networkConfig.enable_ipv6 == true { networkCreateArgs.append("--ipv6") }
-// if networkConfig.isInternal == true { networkCreateArgs.append("--internal") } // CORRECTED: Use isInternal
-//
-// // Add labels
-// if let labels = networkConfig.labels {
-// for (labelKey, labelValue) in labels {
-// networkCreateArgs.append("--label")
-// networkCreateArgs.append("\(labelKey)=\(labelValue)")
-// }
-// }
-//
-// networkCreateArgs.append(actualNetworkName) // Add the network name
-//
-// print("Creating network: \(networkName) (Actual name: \(actualNetworkName))")
-// print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))")
-// executeCommand(command: "container", arguments: networkCreateArgs, detach: false)
-// print("Network '\(networkName)' created or already exists.")
-// }
-// }
-// print("--- Networks Processed ---\n")
-// }
-//
-// // Process top-level volumes
-// // This creates named volumes defined in the docker-compose.yml
-// if let volumes = dockerCompose.volumes {
-// print("\n--- Processing Volumes ---")
-// for (volumeName, volumeConfig) in volumes {
-// let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name
-//
-//// 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.")
-//// } else {
-//// var volumeCreateArgs: [String] = ["volume", "create"]
-////
-//// // Add driver and driver options
-//// if let driver = volumeConfig.driver {
-//// volumeCreateArgs.append("--driver")
-//// volumeCreateArgs.append(driver)
-//// }
-//// if let driverOpts = volumeConfig.driver_opts {
-//// for (optKey, optValue) in driverOpts {
-//// volumeCreateArgs.append("--opt")
-//// volumeCreateArgs.append("\(optKey)=\(optValue)")
-//// }
-//// }
-//// // Add labels
-//// if let labels = volumeConfig.labels {
-//// for (labelKey, labelValue) in labels {
-//// volumeCreateArgs.append("--label")
-//// volumeCreateArgs.append("\(labelKey)=\(labelValue)")
-//// }
-//// }
-////
-//// volumeCreateArgs.append(actualVolumeName) // Add the volume name
-////
-//// print("Creating volume: \(volumeName) (Actual name: \(actualVolumeName))")
-//// print("Executing container volume create: container \(volumeCreateArgs.joined(separator: " "))")
-//// executeCommand(command: "container", arguments: volumeCreateArgs, detach: false)
-//// print("Volume '\(volumeName)' created or already exists.")
-//// }
-// let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(volumeName)")
-// let volumePath = volumeUrl.path(percentEncoded: false)
-//
-// print("Warning: Volume source '\(volumeName)' 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)
-// }
-// print("--- Volumes Processed ---\n")
-// }
-//
-// // Process top-level configs
-// // Note: Docker Compose 'configs' are primarily for Docker Swarm and are not directly managed by 'container run'.
-// // The tool parses them but does not create or attach them.
-// if let configs = dockerCompose.configs {
-// print("\n--- Processing Configs ---")
-// print("Note: Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.")
-// print("This tool will parse 'configs' definitions but will not create or attach them to containers.")
-// for (configName, configConfig) in configs {
-// let actualConfigName = configConfig.name ?? configName
-// if let externalConfig = configConfig.external, externalConfig.isExternal {
-// print("Info: Config '\(configName)' is declared as external (actual name: \(externalConfig.name ?? actualConfigName)). This tool will not attempt to create or manage it.")
-// } else if let file = configConfig.file {
-// let resolvedFile = resolveVariable(file, with: envVarsFromFile)
-// print("Info: Config '\(configName)' is defined from file '\(resolvedFile)'. This tool cannot automatically manage its distribution to individual containers outside of Swarm mode.")
-// } else {
-// print("Info: Config '\(configName)' (actual name: \(actualConfigName)) is defined. This tool cannot automatically manage its distribution to individual containers outside of Swarm mode.")
-// }
-// }
-// print("--- Configs Processed ---\n")
-// }
-//
-// // Process top-level secrets
-// // Note: Docker Compose 'secrets' are primarily for Docker Swarm and are not directly managed by 'container run'.
-// // The tool parses them but does not create or attach them.
-// if let secrets = dockerCompose.secrets {
-// print("\n--- Processing Secrets ---")
-// print("Note: Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.")
-// print("This tool will parse 'secrets' definitions but will not create or attach them to containers.")
-// for (secretName, secretConfig) in secrets {
-// let actualSecretName = secretConfig.name ?? secretName // Define actualSecretName here
-// if let externalSecret = secretConfig.external, externalSecret.isExternal {
-// print("Info: Secret '\(secretName)' is declared as external (actual name: \(externalSecret.name ?? actualSecretName)). This tool will not attempt to create or manage it.")
-// } else if let file = secretConfig.file {
-// let resolvedFile = resolveVariable(file, with: envVarsFromFile)
-// print("Info: Secret '\(secretName)' is defined from file '\(resolvedFile)'. This tool cannot automatically manage its distribution to individual containers outside of Swarm mode.")
-// } else {
-// print("Info: Secret '\(secretName)' (actual name: \(actualSecretName)) is defined. This tool cannot automatically manage its distribution to individual containers outside of Swarm mode.")
-// }
-// }
-// print("--- Secrets Processed ---\n")
-// }
-//
-//
-// // Process each service defined in the docker-compose.yml
-// print("\n--- Processing Services ---")
-// for (serviceName, service) in dockerCompose.services {
-// var imageToRun: String
-//
-// // Handle 'build' configuration
-// if let buildConfig = service.build {
-// var buildCommandArgs: [String] = ["build"]
-//
-// // Determine image tag for built image
-// imageToRun = service.image ?? "\(serviceName):latest"
-//
-// buildCommandArgs.append("--tag")
-// buildCommandArgs.append(imageToRun)
-//
-// // Resolve build context path
-// let resolvedContext = resolveVariable(buildConfig.context, with: envVarsFromFile)
-// buildCommandArgs.append(resolvedContext)
-//
-// // Add Dockerfile path if specified
-// if let dockerfile = buildConfig.dockerfile {
-// let resolvedDockerfile = resolveVariable(dockerfile, with: envVarsFromFile)
-// buildCommandArgs.append("--file")
-// buildCommandArgs.append(resolvedDockerfile)
-// }
-//
-// // Add build arguments
-// if let args = buildConfig.args {
-// for (key, value) in args {
-// let resolvedValue = resolveVariable(value, with: envVarsFromFile)
-// buildCommandArgs.append("--build-arg")
-// buildCommandArgs.append("\(key)=\(resolvedValue)")
-// }
-// }
-//
-// print("\n----------------------------------------")
-// print("Building image for service: \(serviceName) (Tag: \(imageToRun))")
-// print("Executing container build: container \(buildCommandArgs.joined(separator: " "))")
-// executeCommand(command: "container", arguments: buildCommandArgs, detach: false)
-// print("Image build for \(serviceName) completed.")
-// print("----------------------------------------")
-//
-// } else if let img = service.image {
-// // Use specified image if no build config
-// imageToRun = resolveVariable(img, with: envVarsFromFile)
-// } else {
-// // Should not happen due to Service init validation, but as a fallback
-// fputs("Error: Service \(serviceName) must define either 'image' or 'build'. Skipping.\n", stderr)
-// continue
-// }
-//
-// // Handle 'deploy' configuration (note that this tool doesn't fully support it)
-// if service.deploy != nil {
-// print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.")
-// print("However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands.")
-// print("The service will be run as a single container based on other configurations.")
-// }
-//
-// var runCommandArgs: [String] = []
-//
-// // Add detach flag if specified on the CLI
-// if detachFlag {
-// runCommandArgs.append("-d")
-// }
-//
-// // Determine container name
-// let containerName: String
-// if let explicitContainerName = service.container_name {
-// containerName = explicitContainerName
-// print("Info: Using explicit container_name: \(containerName)")
-// } else {
-// // Default container name based on project and service name
-// containerName = "\(projectName)-\(serviceName)"
-// }
-// runCommandArgs.append("--name")
-// runCommandArgs.append(containerName)
-//
-// // REMOVED: Restart policy is not supported by `container run`
-// // if let restart = service.restart {
-// // runCommandArgs.append("--restart")
-// // runCommandArgs.append(restart)
-// // }
-//
-// // Add user
-// if let user = service.user {
-// runCommandArgs.append("--user")
-// runCommandArgs.append(user)
-// }
-//
-// // Add volume mounts
-// if let volumes = service.volumes {
-// for volume in volumes {
-// let resolvedVolume = resolveVariable(volume, with: envVarsFromFile)
-//
-// // Parse the volume string: destination[:mode]
-// let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init)
-//
-// guard components.count >= 2 else {
-// print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.")
-// continue
-// }
-//
-// let source = components[0]
-// let destination = components[1]
-//
-// // 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 : (currentDirectory + "/" + 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
-// } 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.")
-// }
-// } else {
-// // Host path does not exist, assume it's meant to be a directory and try to create it.
-// 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
-// } catch {
-// print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.")
-// }
-// }
-// } else {
-// let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)")
-// let volumePath = volumeUrl.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("\(source):\(destination)") // Use original source for command argument
-// }
-// }
-// }
-//
-// // Combine environment variables from .env files and service environment
-// var combinedEnv: [String: String] = envVarsFromFile
-//
-// if let envFiles = service.env_file {
-// for envFile in envFiles {
-// let additionalEnvVars = loadEnvFile(path: "\(currentDirectory)/\(envFile)")
-// combinedEnv.merge(additionalEnvVars) { (current, _) in current }
-// }
-// }
-//
-// if let serviceEnv = service.environment {
-// combinedEnv.merge(serviceEnv) { (_, new) in new } // Service env overrides .env files
-// }
-//
-// // MARK: Spinning Spot
-// // Add environment variables to run command
-// print(combinedEnv)
-// for (key, value) in combinedEnv {
-// let resolvedValue = resolveVariable(value, with: combinedEnv)
-// print("Resolved value: \(key) | \(resolvedValue)")
-// runCommandArgs.append("-e")
-// runCommandArgs.append("\(key)=\(resolvedValue)")
-// }
-//
-// // REMOVED: Port mappings (-p) are not supported by `container run`
-// // if let ports = service.ports {
-// // for port in ports {
-// // let resolvedPort = resolveVariable(port, with: envVarsFromFile)
-// // runCommandArgs.append("-p")
-// // runCommandArgs.append(resolvedPort)
-// // }
-// // }
-//
-// // Connect to specified networks
-//// if let serviceNetworks = service.networks {
-//// for network in serviceNetworks {
-//// let resolvedNetwork = resolveVariable(network, with: envVarsFromFile)
-//// // Use the explicit network name from top-level definition if available, otherwise resolved name
-//// let networkToConnect = dockerCompose.networks?[network]?.name ?? resolvedNetwork
-//// runCommandArgs.append("--network")
-//// runCommandArgs.append(networkToConnect)
-//// }
-//// print("Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml.")
-//// print("Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level.")
-//// } else {
-//// print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.")
-//// }
-//
-// // Add hostname
-//// if let hostname = service.hostname {
-//// let resolvedHostname = resolveVariable(hostname, with: envVarsFromFile)
-//// runCommandArgs.append("--hostname")
-//// runCommandArgs.append(resolvedHostname)
-//// }
-////
-//// // Add working directory
-//// if let workingDir = service.working_dir {
-//// let resolvedWorkingDir = resolveVariable(workingDir, with: envVarsFromFile)
-//// runCommandArgs.append("--workdir")
-//// runCommandArgs.append(resolvedWorkingDir)
-//// }
-//
-// // Add privileged flag
-//// if service.privileged == true {
-//// runCommandArgs.append("--privileged")
-//// }
-////
-//// // Add read-only flag
-//// if service.read_only == true {
-//// runCommandArgs.append("--read-only")
-//// }
-////
-//// // Handle service-level configs (note: still only parsing/logging, not attaching)
-//// if let serviceConfigs = service.configs {
-//// print("Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.")
-//// print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.")
-//// for serviceConfig in serviceConfigs {
-//// print(" - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))")
-//// }
-//// }
-////
-//// // Handle service-level secrets (note: still only parsing/logging, not attaching)
-//// if let serviceSecrets = service.secrets {
-//// print("Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.")
-//// print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.")
-//// for serviceSecret in serviceSecrets {
-//// print(" - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))")
-//// }
-//// }
-////
-//// // Add interactive and TTY flags
-//// if service.stdin_open == true {
-//// runCommandArgs.append("-i") // --interactive
-//// }
-//// if service.tty == true {
-//// runCommandArgs.append("-t") // --tty
-//// }
-////
-//// runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint
-////
-//// // Add entrypoint or command
-//// if let entrypointParts = service.entrypoint {
-//// runCommandArgs.append("--entrypoint")
-//// runCommandArgs.append(contentsOf: entrypointParts)
-//// } else if let commandParts = service.command {
-//// runCommandArgs.append(contentsOf: commandParts)
-//// }
-////
-//// print("\nStarting service: \(serviceName)")
-//// print("Executing container run: container run \(runCommandArgs.joined(separator: " "))")
-//// executeCommand(command: "container", arguments: ["run"] + runCommandArgs, detach: detachFlag)
-// print("Service \(serviceName) command execution initiated.")
-// print("----------------------------------------\n")
-// }
-//
-//} catch {
-// fputs("Error parsing docker-compose.yml: \(error)\n", stderr)
-// exit(1)
-//}
diff --git a/Sources/ContainerComposeApp/application.swift b/Sources/ContainerComposeApp/application.swift
new file mode 100644
index 0000000..c6bdb74
--- /dev/null
+++ b/Sources/ContainerComposeApp/application.swift
@@ -0,0 +1,18 @@
+//
+// main.swift
+// Container-Compose
+//
+// Created by Morris Richman on 6/18/25.
+//
+
+import ContainerComposeCore
+import ArgumentParser
+
+@main
+struct Application: AsyncParsableCommand {
+ @Argument(parsing: .captureForPassthrough) var args: [String]
+
+ func run() async throws {
+ await Main.main(args)
+ }
+}
diff --git a/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift b/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift
new file mode 100644
index 0000000..d983bfc
--- /dev/null
+++ b/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift
@@ -0,0 +1,112 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+import ContainerAPIClient
+import ContainerCommands
+import Foundation
+import TestHelpers
+import Testing
+
+@testable import ContainerComposeCore
+
+@Suite("Compose Down Tests", .containerDependent, .serialized)
+struct ComposeDownTests {
+
+ @Test("What goes up must come down - two containers")
+ func testUpAndDownComplex() async throws {
+ let yaml = DockerComposeYamlFiles.dockerComposeYaml1
+ let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml)
+
+ var composeUp = try ComposeUp.parse([
+ "-d", "--cwd", project.base.path(percentEncoded: false),
+ ])
+ try await composeUp.run()
+
+ var containers = try await ClientContainer.list()
+ .filter({
+ $0.configuration.id.contains(project.name)
+ })
+
+ #expect(
+ containers.count == 2,
+ "Expected 2 containers for \(project.name), found \(containers.count)")
+
+ #expect(containers.filter({ $0.status == .running }).count == 2, "Expected 2 running containers for \(project.name), found \(containers.filter({ $0.status == .running }).count)")
+
+ var composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)])
+ try await composeDown.run()
+
+ containers = try await ClientContainer.list()
+ .filter({
+ $0.configuration.id.contains(project.name)
+ })
+
+ #expect(
+ containers.count == 2,
+ "Expected 2 containers for \(project.name), found \(containers.count)")
+
+ #expect(containers.filter({ $0.status == .stopped}).count == 2, "Expected 2 stopped containers for \(project.name), found \(containers.filter({ $0.status == .stopped }).count)")
+ }
+
+ @Test("What goes up must come down - container_name")
+ func testUpAndDownContainerName() async throws {
+ // Create a new temporary UUID to use as a container name, otherwise we might conflict with
+ // existing containers on the system
+ let containerName = UUID().uuidString
+
+ let yaml = DockerComposeYamlFiles.dockerComposeYaml9(containerName: containerName)
+ let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml)
+
+ var composeUp = try ComposeUp.parse([
+ "-d", "--cwd", project.base.path(percentEncoded: false),
+ ])
+ try await composeUp.run()
+
+ var containers = try await ClientContainer.list()
+ .filter({
+ $0.configuration.id.contains(containerName)
+ })
+
+ #expect(
+ containers.count == 1,
+ "Expected 1 container with the name \(containerName), found \(containers.count)")
+ #expect(
+ containers.filter({ $0.status == .running}).count == 1,
+ "Expected container \(containerName) to be running, found status: \(containers.map(\.status))"
+ )
+
+ var composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)])
+ try await composeDown.run()
+
+ containers = try await ClientContainer.list()
+ .filter({
+ $0.configuration.id.contains(containerName)
+ })
+
+ #expect(
+ containers.count == 1,
+ "Expected 1 container with the name \(containerName), found \(containers.count)")
+ #expect(
+ containers.filter({ $0.status == .stopped }).count == 1,
+ "Expected container \(containerName) to be stopped, found status: \(containers.map(\.status))"
+ )
+ }
+
+ enum Errors: Error {
+ case containerNotFound
+ }
+
+}
diff --git a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift
new file mode 100644
index 0000000..779b789
--- /dev/null
+++ b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift
@@ -0,0 +1,295 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+import Testing
+import Foundation
+import ContainerCommands
+import ContainerAPIClient
+import TestHelpers
+@testable import ContainerComposeCore
+
+@Suite("Compose Up Tests - Real-World Compose Files", .containerDependent, .serialized)
+struct ComposeUpTests {
+
+ @Test("Test WordPress with MySQL compose file")
+ func testWordPressCompose() async throws {
+ let yaml = DockerComposeYamlFiles.dockerComposeYaml1
+
+ let tempLocation = URL.temporaryDirectory.appending(path: "Container-Compose_Tests_\(UUID().uuidString)/docker-compose.yaml")
+ try? FileManager.default.createDirectory(at: tempLocation.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try yaml.write(to: tempLocation, atomically: false, encoding: .utf8)
+ let folderName = tempLocation.deletingLastPathComponent().lastPathComponent
+
+ var composeUp = try ComposeUp.parse(["-d", "--cwd", tempLocation.deletingLastPathComponent().path(percentEncoded: false)])
+ try await composeUp.run()
+
+ // Get these containers
+ let containers = try await ClientContainer.list()
+ .filter({
+ $0.configuration.id.contains(tempLocation.deletingLastPathComponent().lastPathComponent)
+ })
+
+ // Assert correct wordpress container information
+ guard let wordpressContainer = containers.first(where: { $0.configuration.id == "\(folderName)-wordpress" }),
+ let dbContainer = containers.first(where: { $0.configuration.id == "\(folderName)-db" })
+ else {
+ throw Errors.containerNotFound
+ }
+
+ // Check Ports
+ #expect(wordpressContainer.configuration.publishedPorts.map({ "\($0.hostAddress):\($0.hostPort):\($0.containerPort)" }) == ["0.0.0.0:8080:80"])
+
+ // Check Image
+ #expect(wordpressContainer.configuration.image.reference == "docker.io/library/wordpress:latest")
+
+ // Check Environment
+ let wpEnv = parseEnvToDict(wordpressContainer.configuration.initProcess.environment)
+ #expect(wpEnv["WORDPRESS_DB_HOST"] == dbContainer.networks.first!.ipv4Gateway.description)
+ #expect(wpEnv["WORDPRESS_DB_USER"] == "wordpress")
+ #expect(wpEnv["WORDPRESS_DB_PASSWORD"] == "wordpress")
+ #expect(wpEnv["WORDPRESS_DB_NAME"] == "wordpress")
+
+ // Check Volume
+ #expect(wordpressContainer.configuration.mounts.map(\.destination) == ["/var/www/"])
+
+ // Assert correct db container information
+
+ // Check Image
+ #expect(dbContainer.configuration.image.reference == "docker.io/library/mysql:8.0")
+
+ // Check Environment
+ let dbEnv = parseEnvToDict(dbContainer.configuration.initProcess.environment)
+ #expect(dbEnv["MYSQL_ROOT_PASSWORD"] == "rootpassword")
+ #expect(dbEnv["MYSQL_DATABASE"] == "wordpress")
+ #expect(dbEnv["MYSQL_USER"] == "wordpress")
+ #expect(dbEnv["MYSQL_PASSWORD"] == "wordpress")
+
+ // Check Volume
+ #expect(dbContainer.configuration.mounts.map(\.destination) == ["/var/lib/"])
+ print("")
+ }
+
+ // TODO: Reenable
+// @Test("Test three-tier web application with multiple networks")
+// func testThreeTierWebAppWithNetworks() async throws {
+// let yaml = DockerComposeYamlFiles.dockerComposeYaml2
+//
+// let tempLocation = URL.temporaryDirectory.appending(path: "Container-Compose_Tests_\(UUID().uuidString)/docker-compose.yaml")
+// try? FileManager.default.createDirectory(at: tempLocation.deletingLastPathComponent(), withIntermediateDirectories: true)
+// try yaml.write(to: tempLocation, atomically: false, encoding: .utf8)
+// let folderName = tempLocation.deletingLastPathComponent().lastPathComponent
+//
+// var composeUp = try ComposeUp.parse(["-d", "--cwd", tempLocation.deletingLastPathComponent().path(percentEncoded: false)])
+// try await composeUp.run()
+//
+// // Get the containers created by this compose file
+// let containers = try await ClientContainer.list()
+// .filter({
+// $0.configuration.id.contains(folderName)
+// })
+//
+// guard let nginxContainer = containers.first(where: { $0.configuration.id == "\(folderName)-nginx" }),
+// let appContainer = containers.first(where: { $0.configuration.id == "\(folderName)-app" }),
+// let dbContainer = containers.first(where: { $0.configuration.id == "\(folderName)-db" }),
+// let redisContainer = containers.first(where: { $0.configuration.id == "\(folderName)-redis" })
+// else {
+// throw Errors.containerNotFound
+// }
+//
+// // --- NGINX Container ---
+// #expect(nginxContainer.configuration.image.reference == "docker.io/library/nginx:alpine")
+// #expect(nginxContainer.configuration.publishedPorts.map({ "\($0.hostAddress):\($0.hostPort):\($0.containerPort)" }) == ["0.0.0.0:80:80"])
+// #expect(nginxContainer.networks.map(\.hostname).contains("frontend"))
+//
+// // --- APP Container ---
+// #expect(appContainer.configuration.image.reference == "docker.io/library/node:18-alpine")
+//
+// let appEnv = parseEnvToDict(appContainer.configuration.initProcess.environment)
+// #expect(appEnv["NODE_ENV"] == "production")
+// #expect(appEnv["DATABASE_URL"] == "postgres://\(dbContainer.networks.first!.address.split(separator: "/")[0]):5432/myapp")
+//
+// #expect(appContainer.networks.map(\.hostname).sorted() == ["backend", "frontend"])
+//
+// // --- DB Container ---
+// #expect(dbContainer.configuration.image.reference == "docker.io/library/postgres:14-alpine")
+// let dbEnv = parseEnvToDict(dbContainer.configuration.initProcess.environment)
+// #expect(dbEnv["POSTGRES_DB"] == "myapp")
+// #expect(dbEnv["POSTGRES_USER"] == "user")
+// #expect(dbEnv["POSTGRES_PASSWORD"] == "password")
+//
+// // Verify volume mount
+// #expect(dbContainer.configuration.mounts.map(\.destination) == ["/var/lib/postgresql/"])
+// #expect(dbContainer.networks.map(\.hostname) == ["backend"])
+//
+// // --- Redis Container ---
+// #expect(redisContainer.configuration.image.reference == "docker.io/library/redis:alpine")
+// #expect(redisContainer.networks.map(\.hostname) == ["backend"])
+// }
+
+// @Test("Parse development environment with build")
+// func parseDevelopmentEnvironment() throws {
+// let yaml = DockerComposeYamlFiles.dockerComposeYaml4
+//
+// let decoder = YAMLDecoder()
+// let compose = try decoder.decode(DockerCompose.self, from: yaml)
+//
+// #expect(compose.services["app"]??.build != nil)
+// #expect(compose.services["app"]??.build?.context == ".")
+// #expect(compose.services["app"]??.volumes?.count == 2)
+// }
+
+// @Test("Parse compose with secrets and configs")
+// func parseComposeWithSecretsAndConfigs() throws {
+// let yaml = DockerComposeYamlFiles.dockerComposeYaml5
+//
+// let decoder = YAMLDecoder()
+// let compose = try decoder.decode(DockerCompose.self, from: yaml)
+//
+// #expect(compose.configs != nil)
+// #expect(compose.secrets != nil)
+// }
+
+// @Test("Parse compose with healthchecks and restart policies")
+// func parseComposeWithHealthchecksAndRestart() async throws {
+// let yaml = DockerComposeYamlFiles.dockerComposeYaml6
+//
+// let tempLocation = URL.temporaryDirectory.appending(path: "Container-Compose_Tests_\(UUID().uuidString)/docker-compose.yaml")
+// try? FileManager.default.createDirectory(at: tempLocation.deletingLastPathComponent(), withIntermediateDirectories: true)
+// try yaml.write(to: tempLocation, atomically: false, encoding: .utf8)
+// let folderName = tempLocation.deletingLastPathComponent().lastPathComponent
+//
+// var composeUp = try ComposeUp.parse(["-d", "--cwd", tempLocation.deletingLastPathComponent().path(percentEncoded: false)])
+// try await composeUp.run()
+//
+// // Get the containers created by this compose file
+// let containers = try await ClientContainer.list()
+// .filter({
+// $0.configuration.id.contains(folderName)
+// })
+// dump(containers)
+// }
+
+ @Test("Test compose with complex dependency chain")
+ func TestComplexDependencyChain() async throws {
+ let yaml = DockerComposeYamlFiles.dockerComposeYaml8
+
+ let tempLocation = URL.temporaryDirectory.appending(path: "Container-Compose_Tests_\(UUID().uuidString)/docker-compose.yaml")
+ try? FileManager.default.createDirectory(at: tempLocation.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try yaml.write(to: tempLocation, atomically: false, encoding: .utf8)
+ let folderName = tempLocation.deletingLastPathComponent().lastPathComponent
+
+ var composeUp = try ComposeUp.parse(["-d", "--cwd", tempLocation.deletingLastPathComponent().path(percentEncoded: false)])
+ try await composeUp.run()
+
+ // Get the containers created by this compose file
+ let containers = try await ClientContainer.list()
+ .filter {
+ $0.configuration.id.contains(folderName)
+ }
+
+ guard let webContainer = containers.first(where: { $0.configuration.id == "\(folderName)-web" }),
+ let appContainer = containers.first(where: { $0.configuration.id == "\(folderName)-app" }),
+ let dbContainer = containers.first(where: { $0.configuration.id == "\(folderName)-db" })
+ else {
+ throw Errors.containerNotFound
+ }
+
+ // --- WEB Container ---
+ #expect(webContainer.configuration.image.reference == "docker.io/library/nginx:alpine")
+ #expect(webContainer.configuration.publishedPorts.map { "\($0.hostAddress):\($0.hostPort):\($0.containerPort)" } == ["0.0.0.0:8082:80"])
+
+ // --- APP Container ---
+ #expect(appContainer.configuration.image.reference == "docker.io/library/python:3.12-alpine")
+ let appEnv = parseEnvToDict(appContainer.configuration.initProcess.environment)
+ #expect(appEnv["DATABASE_URL"] == "postgres://postgres:postgres@db:5432/appdb")
+ #expect(appContainer.configuration.initProcess.executable == "python -m http.server 8000")
+ #expect(appContainer.configuration.platform.architecture == "arm64")
+ #expect(appContainer.configuration.platform.os == "linux")
+
+ // --- DB Container ---
+ #expect(dbContainer.configuration.image.reference == "docker.io/library/postgres:14")
+ let dbEnv = parseEnvToDict(dbContainer.configuration.initProcess.environment)
+ #expect(dbEnv["POSTGRES_DB"] == "appdb")
+ #expect(dbEnv["POSTGRES_USER"] == "postgres")
+ #expect(dbEnv["POSTGRES_PASSWORD"] == "postgres")
+
+ // --- Dependency Verification ---
+ // The dependency chain should reflect: web → app → db
+ // i.e., app depends on db, web depends on app
+ // We can verify indirectly by container states and environment linkage.
+ // App isn't set to run long term
+ #expect(webContainer.status == .running)
+ #expect(dbContainer.status == .running)
+ }
+
+ @Test("Test container created with non-default CPU and memory limits")
+ func testCpuAndMemoryLimits() async throws {
+ let yaml = """
+ version: "3.8"
+ services:
+ app:
+ image: nginx:alpine
+ deploy:
+ resources:
+ limits:
+ cpus: "1"
+ memory: "512MB"
+ """
+
+ let tempLocation = URL.temporaryDirectory.appending(path: "Container-Compose_Tests_\(UUID().uuidString)/docker-compose.yaml")
+ try? FileManager.default.createDirectory(at: tempLocation.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try yaml.write(to: tempLocation, atomically: false, encoding: .utf8)
+ let folderName = tempLocation.deletingLastPathComponent().lastPathComponent
+
+ var composeUp = try ComposeUp.parse(["-d", "--cwd", tempLocation.deletingLastPathComponent().path(percentEncoded: false)])
+ try await composeUp.run()
+
+ let containers = try await ClientContainer.list()
+ .filter { $0.configuration.id.contains(folderName) }
+
+ guard let appContainer = containers.first(where: { $0.configuration.id == "\(folderName)-app" }) else {
+ throw Errors.containerNotFound
+ }
+
+ #expect(appContainer.configuration.resources.cpus == 1)
+ #expect(appContainer.configuration.resources.memoryInBytes == 512.mib())
+ }
+
+ enum Errors: Error {
+ case containerNotFound
+ }
+
+ private func parseEnvToDict(_ envArray: [String]) -> [String: String] {
+ let array = envArray.map({ (String($0.split(separator: "=")[0]), String($0.split(separator: "=")[1])) })
+ let dict = Dictionary(uniqueKeysWithValues: array)
+
+ return dict
+ }
+}
+
+struct ContainerDependentTrait: TestScoping, TestTrait, SuiteTrait {
+ func provideScope(for test: Test, testCase: Test.Case?, performing function: () async throws -> Void) async throws {
+ // Start Server
+ try await Application.SystemStart.parse(["--enable-kernel-install"]).run()
+
+ // Run Test
+ try await function()
+ }
+}
+
+extension Trait where Self == ContainerDependentTrait {
+ static var containerDependent: ContainerDependentTrait { .init() }
+}
diff --git a/Tests/Container-Compose-StaticTests/BuildConfigurationTests.swift b/Tests/Container-Compose-StaticTests/BuildConfigurationTests.swift
new file mode 100644
index 0000000..c40b2e0
--- /dev/null
+++ b/Tests/Container-Compose-StaticTests/BuildConfigurationTests.swift
@@ -0,0 +1,130 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+import Testing
+import Foundation
+@testable import Yams
+@testable import ContainerComposeCore
+
+@Suite("Build Configuration Tests")
+struct BuildConfigurationTests {
+
+ @Test("Parse build with context only")
+ func parseBuildWithContextOnly() throws {
+ let yaml = """
+ context: .
+ """
+
+ let decoder = YAMLDecoder()
+ let build = try decoder.decode(Build.self, from: yaml)
+
+ #expect(build.context == ".")
+ #expect(build.dockerfile == nil)
+ }
+
+ @Test("Parse build with context and dockerfile")
+ func parseBuildWithContextAndDockerfile() throws {
+ let yaml = """
+ context: ./app
+ dockerfile: Dockerfile.prod
+ """
+
+ let decoder = YAMLDecoder()
+ let build = try decoder.decode(Build.self, from: yaml)
+
+ #expect(build.context == "./app")
+ #expect(build.dockerfile == "Dockerfile.prod")
+ }
+
+ @Test("Parse build with build args")
+ func parseBuildWithBuildArgs() throws {
+ let yaml = """
+ context: .
+ args:
+ NODE_VERSION: "18"
+ ENV: "production"
+ """
+
+ let decoder = YAMLDecoder()
+ let build = try decoder.decode(Build.self, from: yaml)
+
+ #expect(build.context == ".")
+ #expect(build.args?["NODE_VERSION"] == "18")
+ #expect(build.args?["ENV"] == "production")
+ }
+
+
+ @Test("Service with build configuration")
+ func serviceWithBuildConfiguration() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.build != nil)
+ #expect(compose.services["app"]??.build?.context == ".")
+ #expect(compose.services["app"]??.build?.dockerfile == "Dockerfile")
+ }
+
+ @Test("Service with both image and build")
+ func serviceWithImageAndBuild() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: myapp:latest
+ build:
+ context: .
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.image == "myapp:latest")
+ #expect(compose.services["app"]??.build?.context == ".")
+ }
+
+ @Test("Relative context path resolution")
+ func relativeContextPathResolution() {
+ let context = "./app"
+ let cwd = "/home/user/project"
+
+ let fullPath: String
+ if context.starts(with: "/") || context.starts(with: "~") {
+ fullPath = context
+ } else {
+ fullPath = cwd + "/" + context
+ }
+
+ #expect(fullPath == "/home/user/project/./app")
+ }
+
+ @Test("Absolute context path")
+ func absoluteContextPath() {
+ let context = "/absolute/path/to/build"
+
+ #expect(context.starts(with: "/") == true)
+ }
+}
+
+// Test helper structs
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
new file mode 100644
index 0000000..ec5de69
--- /dev/null
+++ b/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift
@@ -0,0 +1,543 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+import Testing
+import Foundation
+import TestHelpers
+@testable import Yams
+@testable import ContainerComposeCore
+
+@Suite("DockerCompose YAML Parsing Tests")
+struct DockerComposeParsingTests {
+ // MARK: File Snippets
+ @Test("Parse basic docker-compose.yml with single service")
+ func parseBasicCompose() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ web:
+ image: nginx:latest
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.version == "3.8")
+ #expect(compose.services.count == 1)
+ #expect(compose.services["web"]??.image == "nginx:latest")
+ }
+
+ @Test("Parse compose file with project name")
+ func parseComposeWithProjectName() throws {
+ let yaml = """
+ name: my-project
+ services:
+ app:
+ image: alpine:latest
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.name == "my-project")
+ #expect(compose.services["app"]??.image == "alpine:latest")
+ }
+
+ @Test("Parse compose with multiple services")
+ func parseMultipleServices() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ web:
+ image: nginx:latest
+ db:
+ image: postgres:14
+ redis:
+ image: redis:alpine
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services.count == 3)
+ #expect(compose.services["web"]??.image == "nginx:latest")
+ #expect(compose.services["db"]??.image == "postgres:14")
+ #expect(compose.services["redis"]??.image == "redis:alpine")
+ }
+
+ @Test("Parse compose with volumes")
+ func parseComposeWithVolumes() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ db:
+ image: postgres:14
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ volumes:
+ db-data:
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.volumes != nil)
+ #expect(compose.volumes?["db-data"] != nil)
+ #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 = """
+ version: '3.8'
+ services:
+ web:
+ image: nginx:latest
+ networks:
+ - frontend
+ networks:
+ frontend:
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.networks != nil)
+ #expect(compose.networks?["frontend"] != nil)
+ #expect(compose.services["web"]??.networks?.contains("frontend") == true)
+ }
+
+ @Test("Parse compose with environment variables")
+ func parseComposeWithEnvironment() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ environment:
+ DATABASE_URL: postgres://localhost/mydb
+ DEBUG: "true"
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.environment != nil)
+ #expect(compose.services["app"]??.environment?["DATABASE_URL"] == "postgres://localhost/mydb")
+ #expect(compose.services["app"]??.environment?["DEBUG"] == "true")
+ }
+
+ @Test("Parse compose with ports")
+ func parseComposeWithPorts() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ web:
+ image: nginx:latest
+ ports:
+ - "8080:80"
+ - "443:443"
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["web"]??.ports?.count == 2)
+ #expect(compose.services["web"]??.ports?.contains("8080:80") == true)
+ #expect(compose.services["web"]??.ports?.contains("443:443") == true)
+ }
+
+ @Test("Parse compose with depends_on")
+ func parseComposeWithDependencies() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ web:
+ image: nginx:latest
+ depends_on:
+ - db
+ db:
+ image: postgres:14
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["web"]??.depends_on?.contains("db") == true)
+ }
+
+ @Test("Parse compose with build context")
+ func parseComposeWithBuild() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.build != nil)
+ #expect(compose.services["app"]??.build?.context == ".")
+ #expect(compose.services["app"]??.build?.dockerfile == "Dockerfile")
+ }
+
+ @Test("Parse compose with command as array")
+ func parseComposeWithCommandArray() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ command: ["sh", "-c", "echo hello"]
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.command?.count == 3)
+ #expect(compose.services["app"]??.command?.first == "sh")
+ }
+
+ @Test("Parse compose with command as string")
+ func parseComposeWithCommandString() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ command: "echo hello"
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.command?.count == 1)
+ #expect(compose.services["app"]??.command?.first == "echo hello")
+ }
+
+ @Test("Parse compose with restart policy")
+ func parseComposeWithRestartPolicy() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ restart: always
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.restart == "always")
+ }
+
+ @Test("Parse compose with container name")
+ func parseComposeWithContainerName() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ container_name: my-custom-name
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.container_name == "my-custom-name")
+ }
+
+ @Test("Parse compose with working directory")
+ func parseComposeWithWorkingDir() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ working_dir: /app
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.working_dir == "/app")
+ }
+
+ @Test("Parse compose with user")
+ func parseComposeWithUser() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ user: "1000:1000"
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.user == "1000:1000")
+ }
+
+ @Test("Parse compose with privileged mode")
+ func parseComposeWithPrivileged() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ privileged: true
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.privileged == true)
+ }
+
+ @Test("Parse compose with read-only filesystem")
+ func parseComposeWithReadOnly() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ read_only: true
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.read_only == true)
+ }
+
+ @Test("Parse compose with stdin_open and tty")
+ func parseComposeWithInteractiveFlags() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ stdin_open: true
+ tty: true
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.stdin_open == true)
+ #expect(compose.services["app"]??.tty == true)
+ }
+
+ @Test("Parse compose with hostname")
+ func parseComposeWithHostname() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ hostname: my-host
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.hostname == "my-host")
+ }
+
+ @Test("Parse compose with platform")
+ func parseComposeWithPlatform() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ platform: linux/amd64
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.platform == "linux/amd64")
+ }
+
+ @Test("Parse deploy resources limits (cpus and memory)")
+ func parseComposeWithDeployResources() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: alpine:latest
+ deploy:
+ resources:
+ limits:
+ cpus: "0.5"
+ memory: "512M"
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.deploy?.resources?.limits?.cpus == "0.5")
+ #expect(compose.services["app"]??.deploy?.resources?.limits?.memory == "512M")
+ }
+
+ @Test("Service must have image or build - should fail without either")
+ func serviceRequiresImageOrBuild() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ restart: always
+ """
+
+ let decoder = YAMLDecoder()
+ #expect(throws: Error.self) {
+ try decoder.decode(DockerCompose.self, from: yaml)
+ }
+ }
+
+ // MARK: Full Files
+ @Test("Parse WordPress with MySQL compose file")
+ func parseWordPressCompose() throws {
+ let yaml = DockerComposeYamlFiles.dockerComposeYaml1
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services.count == 2)
+ #expect(compose.services["wordpress"] != nil)
+ #expect(compose.services["db"] != nil)
+ #expect(compose.volumes?.count == 2)
+ #expect(compose.services["wordpress"]??.depends_on?.contains("db") == true)
+ }
+
+ @Test("Parse three-tier web application")
+ func parseThreeTierApp() throws {
+ let yaml = DockerComposeYamlFiles.dockerComposeYaml2
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.name == "webapp")
+ #expect(compose.services.count == 4)
+ #expect(compose.networks?.count == 2)
+ #expect(compose.volumes?.count == 1)
+ }
+
+ @Test("Parse microservices architecture")
+ func parseMicroservicesCompose() throws {
+ let yaml = DockerComposeYamlFiles.dockerComposeYaml3
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services.count == 5)
+ #expect(compose.services["api-gateway"]??.depends_on?.count == 3)
+ }
+
+ @Test("Parse development environment with build")
+ func parseDevelopmentEnvironment() throws {
+ let yaml = DockerComposeYamlFiles.dockerComposeYaml4
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.build != nil)
+ #expect(compose.services["app"]??.build?.context == ".")
+ #expect(compose.services["app"]??.volumes?.count == 2)
+ }
+
+ @Test("Parse compose with secrets and configs")
+ func parseComposeWithSecretsAndConfigs() throws {
+ let yaml = DockerComposeYamlFiles.dockerComposeYaml5
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.configs != nil)
+ #expect(compose.secrets != nil)
+ }
+
+ @Test("Parse compose with healthchecks and restart policies")
+ func parseComposeWithHealthchecksAndRestart() throws {
+ let yaml = DockerComposeYamlFiles.dockerComposeYaml6
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["web"]??.restart == "unless-stopped")
+ #expect(compose.services["web"]??.healthcheck != nil)
+ #expect(compose.services["db"]??.restart == "always")
+ }
+
+ @Test("Parse compose with complex dependency chain")
+ func parseComplexDependencyChain() throws {
+ let yaml = DockerComposeYamlFiles.dockerComposeYaml7
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services.count == 4)
+
+ // Test dependency resolution
+ let services: [(String, Service)] = compose.services.compactMap({ serviceName, service in
+ guard let service else { return nil }
+ return (serviceName, service)
+ })
+ let sorted = try Service.topoSortConfiguredServices(services)
+
+ // db and cache should come before api
+ let dbIndex = sorted.firstIndex(where: { $0.serviceName == "db" })!
+ let cacheIndex = sorted.firstIndex(where: { $0.serviceName == "cache" })!
+ let apiIndex = sorted.firstIndex(where: { $0.serviceName == "api" })!
+ let frontendIndex = sorted.firstIndex(where: { $0.serviceName == "frontend" })!
+
+ #expect(dbIndex < apiIndex)
+ #expect(cacheIndex < apiIndex)
+ #expect(apiIndex < frontendIndex)
+ }
+}
diff --git a/Tests/Container-Compose-StaticTests/EnvFileLoadingTests.swift b/Tests/Container-Compose-StaticTests/EnvFileLoadingTests.swift
new file mode 100644
index 0000000..4252485
--- /dev/null
+++ b/Tests/Container-Compose-StaticTests/EnvFileLoadingTests.swift
@@ -0,0 +1,182 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+import Testing
+import Foundation
+@testable import ContainerComposeCore
+
+@Suite("Environment File Loading Tests")
+struct EnvFileLoadingTests {
+
+ @Test("Load simple key-value pairs from .env file")
+ func loadSimpleEnvFile() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ let envFile = tempDir.appendingPathComponent("test-\(UUID().uuidString).env")
+
+ let content = """
+ DATABASE_URL=postgres://localhost/mydb
+ PORT=8080
+ DEBUG=true
+ """
+
+ try content.write(to: envFile, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: envFile) }
+
+ let envVars = loadEnvFile(path: envFile.path)
+
+ #expect(envVars["DATABASE_URL"] == "postgres://localhost/mydb")
+ #expect(envVars["PORT"] == "8080")
+ #expect(envVars["DEBUG"] == "true")
+ #expect(envVars.count == 3)
+ }
+
+ @Test("Ignore comments in .env file")
+ func ignoreComments() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ let envFile = tempDir.appendingPathComponent("test-\(UUID().uuidString).env")
+
+ let content = """
+ # This is a comment
+ DATABASE_URL=postgres://localhost/mydb
+ # Another comment
+ PORT=8080
+ """
+
+ try content.write(to: envFile, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: envFile) }
+
+ let envVars = loadEnvFile(path: envFile.path)
+
+ #expect(envVars["DATABASE_URL"] == "postgres://localhost/mydb")
+ #expect(envVars["PORT"] == "8080")
+ #expect(envVars.count == 2)
+ }
+
+ @Test("Ignore empty lines in .env file")
+ func ignoreEmptyLines() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ let envFile = tempDir.appendingPathComponent("test-\(UUID().uuidString).env")
+
+ let content = """
+ DATABASE_URL=postgres://localhost/mydb
+
+ PORT=8080
+
+ DEBUG=true
+ """
+
+ try content.write(to: envFile, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: envFile) }
+
+ let envVars = loadEnvFile(path: envFile.path)
+
+ #expect(envVars.count == 3)
+ }
+
+ @Test("Handle values with equals signs")
+ func handleValuesWithEquals() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ let envFile = tempDir.appendingPathComponent("test-\(UUID().uuidString).env")
+
+ let content = """
+ CONNECTION_STRING=Server=localhost;Database=mydb;User=admin
+ """
+
+ try content.write(to: envFile, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: envFile) }
+
+ let envVars = loadEnvFile(path: envFile.path)
+
+ #expect(envVars["CONNECTION_STRING"] == "Server=localhost;Database=mydb;User=admin")
+ }
+
+ @Test("Handle empty values")
+ func handleEmptyValues() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ let envFile = tempDir.appendingPathComponent("test-\(UUID().uuidString).env")
+
+ let content = """
+ EMPTY_VAR=
+ NORMAL_VAR=value
+ """
+
+ try content.write(to: envFile, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: envFile) }
+
+ let envVars = loadEnvFile(path: envFile.path)
+
+ #expect(envVars["EMPTY_VAR"] == "")
+ #expect(envVars["NORMAL_VAR"] == "value")
+ }
+
+ @Test("Handle values with spaces")
+ func handleValuesWithSpaces() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ let envFile = tempDir.appendingPathComponent("test-\(UUID().uuidString).env")
+
+ let content = """
+ MESSAGE=Hello World
+ PATH_WITH_SPACES=/path/to/some directory
+ """
+
+ try content.write(to: envFile, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: envFile) }
+
+ let envVars = loadEnvFile(path: envFile.path)
+
+ #expect(envVars["MESSAGE"] == "Hello World")
+ #expect(envVars["PATH_WITH_SPACES"] == "/path/to/some directory")
+ }
+
+ @Test("Return empty dict for non-existent file")
+ func returnEmptyDictForNonExistentFile() {
+ let nonExistentPath = "/tmp/non-existent-\(UUID().uuidString).env"
+ let envVars = loadEnvFile(path: nonExistentPath)
+
+ #expect(envVars.isEmpty)
+ }
+
+ @Test("Handle mixed content")
+ func handleMixedContent() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ let envFile = tempDir.appendingPathComponent("test-\(UUID().uuidString).env")
+
+ let content = """
+ # Application Configuration
+ APP_NAME=MyApp
+
+ # Database Settings
+ DATABASE_URL=postgres://localhost/mydb
+ DB_POOL_SIZE=10
+
+ # Empty value
+ OPTIONAL_VAR=
+
+ # Comment at end
+ """
+
+ try content.write(to: envFile, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: envFile) }
+
+ let envVars = loadEnvFile(path: envFile.path)
+
+ #expect(envVars["APP_NAME"] == "MyApp")
+ #expect(envVars["DATABASE_URL"] == "postgres://localhost/mydb")
+ #expect(envVars["DB_POOL_SIZE"] == "10")
+ #expect(envVars["OPTIONAL_VAR"] == "")
+ #expect(envVars.count == 4)
+ }
+}
diff --git a/Tests/Container-Compose-StaticTests/EnvironmentVariableTests.swift b/Tests/Container-Compose-StaticTests/EnvironmentVariableTests.swift
new file mode 100644
index 0000000..6b014e9
--- /dev/null
+++ b/Tests/Container-Compose-StaticTests/EnvironmentVariableTests.swift
@@ -0,0 +1,145 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+import Testing
+import Foundation
+@testable import ContainerComposeCore
+
+@Suite("Environment Variable Resolution Tests")
+struct EnvironmentVariableTests {
+
+ @Test("Resolve simple variable")
+ func resolveSimpleVariable() {
+ let envVars = ["DATABASE_URL": "postgres://localhost/mydb"]
+ let input = "${DATABASE_URL}"
+ let result = resolveVariable(input, with: envVars)
+
+ #expect(result == "postgres://localhost/mydb")
+ }
+
+ @Test("Resolve variable with default value when variable exists")
+ func resolveVariableWithDefaultWhenExists() {
+ let envVars = ["PORT": "8080"]
+ let input = "${PORT:-3000}"
+ let result = resolveVariable(input, with: envVars)
+
+ #expect(result == "8080")
+ }
+
+ @Test("Use default value when variable does not exist")
+ func useDefaultWhenVariableDoesNotExist() {
+ let envVars: [String: String] = [:]
+ let input = "${PORT:-3000}"
+ let result = resolveVariable(input, with: envVars)
+
+ #expect(result == "3000")
+ }
+
+ @Test("Resolve multiple variables in string")
+ func resolveMultipleVariables() {
+ let envVars = [
+ "HOST": "localhost",
+ "PORT": "5432",
+ "DATABASE": "mydb"
+ ]
+ let input = "postgres://${HOST}:${PORT}/${DATABASE}"
+ let result = resolveVariable(input, with: envVars)
+
+ #expect(result == "postgres://localhost:5432/mydb")
+ }
+
+ @Test("Leave unresolved variable when no default provided")
+ func leaveUnresolvedVariable() {
+ let envVars: [String: String] = [:]
+ let input = "${UNDEFINED_VAR}"
+ let result = resolveVariable(input, with: envVars)
+
+ // Should leave as-is when variable not found and no default
+ #expect(result == "${UNDEFINED_VAR}")
+ }
+
+ @Test("Resolve with empty default value")
+ func resolveWithEmptyDefault() {
+ let envVars: [String: String] = [:]
+ let input = "${OPTIONAL_VAR:-}"
+ let result = resolveVariable(input, with: envVars)
+
+ #expect(result == "")
+ }
+
+ @Test("Resolve complex string with mixed content")
+ func resolveComplexString() {
+ let envVars = ["VERSION": "1.2.3"]
+ let input = "MyApp version ${VERSION} (build 42)"
+ let result = resolveVariable(input, with: envVars)
+
+ #expect(result == "MyApp version 1.2.3 (build 42)")
+ }
+
+ @Test("Variable names are case-sensitive")
+ func caseSensitiveVariableNames() {
+ let envVars = ["myvar": "lowercase", "MYVAR": "uppercase"]
+ let input1 = "${myvar}"
+ let input2 = "${MYVAR}"
+
+ let result1 = resolveVariable(input1, with: envVars)
+ let result2 = resolveVariable(input2, with: envVars)
+
+ #expect(result1 == "lowercase")
+ #expect(result2 == "uppercase")
+ }
+
+ @Test("Resolve variables with underscores and numbers")
+ func resolveVariablesWithUnderscoresAndNumbers() {
+ let envVars = ["VAR_NAME_123": "value123"]
+ let input = "${VAR_NAME_123}"
+ let result = resolveVariable(input, with: envVars)
+
+ #expect(result == "value123")
+ }
+
+ @Test("Process environment takes precedence over provided envVars")
+ func processEnvironmentTakesPrecedence() {
+ // This test assumes PATH exists in process environment
+ let envVars = ["PATH": "custom-path"]
+ let input = "${PATH}"
+ let result = resolveVariable(input, with: envVars)
+
+ // Should use process environment, not custom value
+ #expect(result != "custom-path")
+ #expect(result.isEmpty == false)
+ }
+
+ @Test("Resolve variable that is part of larger text")
+ func resolveVariableInLargerText() {
+ let envVars = ["API_KEY": "secret123"]
+ let input = "Authorization: Bearer ${API_KEY}"
+ let result = resolveVariable(input, with: envVars)
+
+ #expect(result == "Authorization: Bearer secret123")
+ }
+
+ @Test("No variables to resolve returns original string")
+ func noVariablesToResolve() {
+ let envVars = ["KEY": "value"]
+ let input = "This is a plain string"
+ let result = resolveVariable(input, with: envVars)
+
+ #expect(result == "This is a plain string")
+ }
+}
+
+// Test helper function that mimics the actual implementation
diff --git a/Tests/Container-Compose-StaticTests/HealthcheckConfigurationTests.swift b/Tests/Container-Compose-StaticTests/HealthcheckConfigurationTests.swift
new file mode 100644
index 0000000..72ecf8b
--- /dev/null
+++ b/Tests/Container-Compose-StaticTests/HealthcheckConfigurationTests.swift
@@ -0,0 +1,156 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+import Testing
+import Foundation
+@testable import Yams
+@testable import ContainerComposeCore
+
+@Suite("Healthcheck Configuration Tests")
+struct HealthcheckConfigurationTests {
+
+ @Test("Parse healthcheck with test command")
+ func parseHealthcheckWithTest() throws {
+ let yaml = """
+ test: ["CMD", "curl", "-f", "http://localhost"]
+ """
+
+ let decoder = YAMLDecoder()
+ let healthcheck = try decoder.decode(Healthcheck.self, from: yaml)
+
+ #expect(healthcheck.test?.count == 4)
+ #expect(healthcheck.test?.first == "CMD")
+ }
+
+ @Test("Parse healthcheck with interval")
+ func parseHealthcheckWithInterval() throws {
+ let yaml = """
+ test: ["CMD", "curl", "-f", "http://localhost"]
+ interval: 30s
+ """
+
+ let decoder = YAMLDecoder()
+ let healthcheck = try decoder.decode(Healthcheck.self, from: yaml)
+
+ #expect(healthcheck.interval == "30s")
+ }
+
+ @Test("Parse healthcheck with timeout")
+ func parseHealthcheckWithTimeout() throws {
+ let yaml = """
+ test: ["CMD", "curl", "-f", "http://localhost"]
+ timeout: 10s
+ """
+
+ let decoder = YAMLDecoder()
+ let healthcheck = try decoder.decode(Healthcheck.self, from: yaml)
+
+ #expect(healthcheck.timeout == "10s")
+ }
+
+ @Test("Parse healthcheck with retries")
+ func parseHealthcheckWithRetries() throws {
+ let yaml = """
+ test: ["CMD", "curl", "-f", "http://localhost"]
+ retries: 3
+ """
+
+ let decoder = YAMLDecoder()
+ let healthcheck = try decoder.decode(Healthcheck.self, from: yaml)
+
+ #expect(healthcheck.retries == 3)
+ }
+
+ @Test("Parse healthcheck with start_period")
+ func parseHealthcheckWithStartPeriod() throws {
+ let yaml = """
+ test: ["CMD", "curl", "-f", "http://localhost"]
+ start_period: 40s
+ """
+
+ let decoder = YAMLDecoder()
+ let healthcheck = try decoder.decode(Healthcheck.self, from: yaml)
+
+ #expect(healthcheck.start_period == "40s")
+ }
+
+ @Test("Parse complete healthcheck configuration")
+ func parseCompleteHealthcheck() throws {
+ let yaml = """
+ test: ["CMD", "curl", "-f", "http://localhost"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+ """
+
+ let decoder = YAMLDecoder()
+ let healthcheck = try decoder.decode(Healthcheck.self, from: yaml)
+
+ #expect(healthcheck.test != nil)
+ #expect(healthcheck.interval == "30s")
+ #expect(healthcheck.timeout == "10s")
+ #expect(healthcheck.retries == 3)
+ #expect(healthcheck.start_period == "40s")
+ }
+
+ @Test("Parse healthcheck with CMD-SHELL")
+ func parseHealthcheckWithCmdShell() throws {
+ let yaml = """
+ test: ["CMD-SHELL", "curl -f http://localhost || exit 1"]
+ """
+
+ let decoder = YAMLDecoder()
+ let healthcheck = try decoder.decode(Healthcheck.self, from: yaml)
+
+ #expect(healthcheck.test?.first == "CMD-SHELL")
+ }
+
+ @Test("Disable healthcheck")
+ func disableHealthcheck() throws {
+ let yaml = """
+ test: ["NONE"]
+ """
+
+ let decoder = YAMLDecoder()
+ let healthcheck = try decoder.decode(Healthcheck.self, from: yaml)
+
+ #expect(healthcheck.test?.first == "NONE")
+ }
+
+ @Test("Service with healthcheck")
+ func serviceWithHealthcheck() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ web:
+ image: nginx:latest
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["web"]??.healthcheck != nil)
+ #expect(compose.services["web"]??.healthcheck?.interval == "30s")
+ }
+}
+
+// Test helper structs
diff --git a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift
new file mode 100644
index 0000000..158c126
--- /dev/null
+++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift
@@ -0,0 +1,35 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+import Testing
+import Foundation
+@testable import ContainerComposeCore
+
+@Suite("Helper Functions Tests")
+struct HelperFunctionsTests {
+
+ @Test("Derive project name from current working directory - contains dot")
+ func testDeriveProjectName() throws {
+ var cwd = "/Users/user/Projects/My.Project"
+ var projectName = deriveProjectName(cwd: cwd)
+ #expect(projectName == "My_Project")
+
+ cwd = ".devcontainers"
+ projectName = deriveProjectName(cwd: cwd)
+ #expect(projectName == "_devcontainers")
+ }
+
+}
\ No newline at end of file
diff --git a/Tests/Container-Compose-StaticTests/NetworkConfigurationTests.swift b/Tests/Container-Compose-StaticTests/NetworkConfigurationTests.swift
new file mode 100644
index 0000000..f368f8a
--- /dev/null
+++ b/Tests/Container-Compose-StaticTests/NetworkConfigurationTests.swift
@@ -0,0 +1,190 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+import Testing
+import Foundation
+@testable import Yams
+@testable import ContainerComposeCore
+
+@Suite("Network Configuration Tests")
+struct NetworkConfigurationTests {
+
+ @Test("Parse service with single network")
+ func parseServiceWithSingleNetwork() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ web:
+ image: nginx:latest
+ networks:
+ - frontend
+ networks:
+ frontend:
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["web"]??.networks?.count == 1)
+ #expect(compose.services["web"]??.networks?.contains("frontend") == true)
+ #expect(compose.networks != nil)
+ }
+
+ @Test("Parse service with multiple networks")
+ func parseServiceWithMultipleNetworks() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ app:
+ image: myapp:latest
+ networks:
+ - frontend
+ - backend
+ networks:
+ frontend:
+ backend:
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["app"]??.networks?.count == 2)
+ #expect(compose.services["app"]??.networks?.contains("frontend") == true)
+ #expect(compose.services["app"]??.networks?.contains("backend") == true)
+ }
+
+ @Test("Parse network with driver")
+ func parseNetworkWithDriver() throws {
+ let yaml = """
+ driver: bridge
+ """
+
+ let decoder = YAMLDecoder()
+ let network = try decoder.decode(Network.self, from: yaml)
+
+ #expect(network.driver == "bridge")
+ }
+
+ @Test("Parse network with driver_opts")
+ func parseNetworkWithDriverOpts() throws {
+ let yaml = """
+ driver: bridge
+ driver_opts:
+ com.docker.network.bridge.name: br-custom
+ """
+
+ let decoder = YAMLDecoder()
+ let network = try decoder.decode(Network.self, from: yaml)
+
+ #expect(network.driver_opts != nil)
+ #expect(network.driver_opts?["com.docker.network.bridge.name"] == "br-custom")
+ }
+
+ @Test("Parse network with external flag")
+ func parseNetworkWithExternal() throws {
+ let yaml = """
+ external: true
+ """
+
+ let decoder = YAMLDecoder()
+ let network = try decoder.decode(Network.self, from: yaml)
+
+ #expect(network.external != nil)
+ #expect(network.external?.isExternal == true)
+ }
+
+ @Test("Parse network with labels")
+ func parseNetworkWithLabels() throws {
+ let yaml = """
+ driver: bridge
+ labels:
+ com.example.description: "Frontend Network"
+ com.example.version: "1.0"
+ """
+
+ let decoder = YAMLDecoder()
+ let network = try decoder.decode(Network.self, from: yaml)
+
+ #expect(network.labels?["com.example.description"] == "Frontend Network")
+ #expect(network.labels?["com.example.version"] == "1.0")
+ }
+
+ @Test("Multiple networks in compose")
+ func multipleNetworksInCompose() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ web:
+ image: nginx:latest
+ networks:
+ - frontend
+ api:
+ image: api:latest
+ networks:
+ - frontend
+ - backend
+ db:
+ image: postgres:14
+ networks:
+ - backend
+ networks:
+ frontend:
+ backend:
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.networks?.count == 2)
+ #expect(compose.networks?["frontend"] != nil)
+ #expect(compose.networks?["backend"] != nil)
+ #expect(compose.services["api"]??.networks?.count == 2)
+ }
+
+ @Test("Service without explicit networks uses default")
+ func serviceWithoutExplicitNetworks() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ web:
+ image: nginx:latest
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ // Service should exist without networks specified
+ #expect(compose.services["web"] != nil)
+ #expect(compose.services["web"]??.networks == nil)
+ }
+
+ @Test("Empty networks definition")
+ func emptyNetworksDefinition() throws {
+ let yaml = """
+ version: '3.8'
+ services:
+ web:
+ image: nginx:latest
+ networks:
+ """
+
+ let decoder = YAMLDecoder()
+ let compose = try decoder.decode(DockerCompose.self, from: yaml)
+
+ #expect(compose.services["web"] != nil)
+ }
+}
+
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/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift b/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift
new file mode 100644
index 0000000..7988161
--- /dev/null
+++ b/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift
@@ -0,0 +1,135 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+import Testing
+import Foundation
+@testable import ContainerComposeCore
+
+@Suite("Service Dependency Resolution Tests")
+struct ServiceDependencyTests {
+
+ @Test("Simple dependency chain - web depends on db")
+ func simpleDependencyChain() throws {
+ let web = Service(image: "nginx", depends_on: ["db"])
+ let db = Service(image: "postgres", depends_on: nil)
+
+ let services: [(String, Service)] = [("web", web), ("db", db)]
+ let sorted = try Service.topoSortConfiguredServices(services)
+
+ // db should come before web
+ #expect(sorted.count == 2)
+ #expect(sorted[0].serviceName == "db")
+ #expect(sorted[1].serviceName == "web")
+ }
+
+ @Test("Multiple dependencies - app depends on db and redis")
+ func multipleDependencies() throws {
+ let app = Service(image: "myapp", depends_on: ["db", "redis"])
+ let db = Service(image: "postgres", depends_on: nil)
+ let redis = Service(image: "redis", depends_on: nil)
+
+ let services: [(String, Service)] = [("app", app), ("db", db), ("redis", redis)]
+ let sorted = try Service.topoSortConfiguredServices(services)
+
+ #expect(sorted.count == 3)
+ // app should be last
+ #expect(sorted[2].serviceName == "app")
+ // db and redis should come before app
+ let firstTwo = Set([sorted[0].serviceName, sorted[1].serviceName])
+ #expect(firstTwo.contains("db"))
+ #expect(firstTwo.contains("redis"))
+ }
+
+ @Test("Complex dependency chain - web -> app -> db")
+ func complexDependencyChain() throws {
+ let web = Service(image: "nginx", depends_on: ["app"])
+ let app = Service(image: "myapp", depends_on: ["db"])
+ let db = Service(image: "postgres", depends_on: nil)
+
+ let services: [(String, Service)] = [("web", web), ("app", app), ("db", db)]
+ let sorted = try Service.topoSortConfiguredServices(services)
+
+ #expect(sorted.count == 3)
+ #expect(sorted[0].serviceName == "db")
+ #expect(sorted[1].serviceName == "app")
+ #expect(sorted[2].serviceName == "web")
+ }
+
+ @Test("No dependencies - services should maintain order")
+ func noDependencies() throws {
+ let web = Service(image: "nginx", depends_on: nil)
+ let app = Service(image: "myapp", depends_on: nil)
+ let db = Service(image: "postgres", depends_on: nil)
+
+ let services: [(String, Service)] = [("web", web), ("app", app), ("db", db)]
+ let sorted = try Service.topoSortConfiguredServices(services)
+
+ #expect(sorted.count == 3)
+ }
+
+ @Test("Cyclic dependency should throw error")
+ func cyclicDependency() throws {
+ let web = Service(image: "nginx", depends_on: ["app"])
+ let app = Service(image: "myapp", depends_on: ["web"])
+
+ let services: [(String, Service)] = [("web", web), ("app", app)]
+
+ #expect(throws: Error.self) {
+ try Service.topoSortConfiguredServices(services)
+ }
+ }
+
+ @Test("Diamond dependency - web and api both depend on db")
+ func diamondDependency() throws {
+ let web = Service(image: "nginx", depends_on: ["db"])
+ let api = Service(image: "api", depends_on: ["db"])
+ let db = Service(image: "postgres", depends_on: nil)
+
+ let services: [(String, Service)] = [("web", web), ("api", api), ("db", db)]
+ let sorted = try Service.topoSortConfiguredServices(services)
+
+ #expect(sorted.count == 3)
+ // db should be first
+ #expect(sorted[0].serviceName == "db")
+ // web and api can be in any order after db
+ let lastTwo = Set([sorted[1].serviceName, sorted[2].serviceName])
+ #expect(lastTwo.contains("web"))
+ #expect(lastTwo.contains("api"))
+ }
+
+ @Test("Single service with no dependencies")
+ func singleService() throws {
+ let web = Service(image: "nginx", depends_on: nil)
+
+ let services: [(String, Service)] = [("web", web)]
+ let sorted = try Service.topoSortConfiguredServices(services)
+
+ #expect(sorted.count == 1)
+ #expect(sorted[0].serviceName == "web")
+ }
+
+ @Test("Service depends on non-existent service - should not crash")
+ func dependsOnNonExistentService() throws {
+ let web = Service(image: "nginx", depends_on: ["nonexistent"])
+
+ let services: [(String, Service)] = [("web", web)]
+ let sorted = try Service.topoSortConfiguredServices(services)
+
+ // Should complete without crashing
+ #expect(sorted.count == 1)
+ }
+}
+
diff --git a/Tests/TestHelpers/DockerComposeYamlFiles.swift b/Tests/TestHelpers/DockerComposeYamlFiles.swift
new file mode 100644
index 0000000..f1539b7
--- /dev/null
+++ b/Tests/TestHelpers/DockerComposeYamlFiles.swift
@@ -0,0 +1,289 @@
+//===----------------------------------------------------------------------===//
+// 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.
+//===----------------------------------------------------------------------===//
+
+import Foundation
+
+public struct DockerComposeYamlFiles {
+ public static let dockerComposeYaml1 = """
+ version: '3.8'
+
+ services:
+ wordpress:
+ image: wordpress:latest
+ ports:
+ - "8080:80"
+ environment:
+ WORDPRESS_DB_HOST: db
+ WORDPRESS_DB_USER: wordpress
+ WORDPRESS_DB_PASSWORD: wordpress
+ WORDPRESS_DB_NAME: wordpress
+ depends_on:
+ - db
+ volumes:
+ - wordpress_data:/var/www/html
+
+ db:
+ image: mysql:8.0
+ environment:
+ MYSQL_DATABASE: wordpress
+ MYSQL_USER: wordpress
+ MYSQL_PASSWORD: wordpress
+ MYSQL_ROOT_PASSWORD: rootpassword
+ volumes:
+ - db_data:/var/lib/mysql
+
+ volumes:
+ wordpress_data:
+ db_data:
+ """
+
+ public static let dockerComposeYaml2 = """
+ version: '3.8'
+ name: webapp
+
+ services:
+ nginx:
+ image: nginx:alpine
+ ports:
+ - "80:80"
+ depends_on:
+ - app
+ networks:
+ - frontend
+
+ app:
+ image: node:18-alpine
+ working_dir: /app
+ environment:
+ NODE_ENV: production
+ DATABASE_URL: postgres://db:5432/myapp
+ depends_on:
+ - db
+ - redis
+ networks:
+ - frontend
+ - backend
+
+ db:
+ image: postgres:14-alpine
+ environment:
+ POSTGRES_DB: myapp
+ POSTGRES_USER: user
+ POSTGRES_PASSWORD: password
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ networks:
+ - backend
+
+ redis:
+ image: redis:alpine
+ networks:
+ - backend
+
+ volumes:
+ db-data:
+
+ networks:
+ frontend:
+ backend:
+ """
+
+ public static let dockerComposeYaml3 = """
+ version: '3.8'
+
+ services:
+ api-gateway:
+ image: traefik:v2.10
+ ports:
+ - "81:80"
+ - "8081:8080"
+ depends_on:
+ - auth-service
+ - user-service
+ - order-service
+
+ auth-service:
+ image: auth:latest
+ environment:
+ JWT_SECRET: secret123
+ DATABASE_URL: postgres://db:5432/auth
+
+ user-service:
+ image: user:latest
+ environment:
+ DATABASE_URL: postgres://db:5432/users
+
+ order-service:
+ image: order:latest
+ environment:
+ DATABASE_URL: postgres://db:5432/orders
+
+ db:
+ image: postgres:14
+ environment:
+ POSTGRES_PASSWORD: postgres
+ """
+
+ public static let dockerComposeYaml4 = """
+ version: '3.8'
+
+ services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile.dev
+ volumes:
+ - ./app:/app
+ - /app/node_modules
+ environment:
+ NODE_ENV: development
+ ports:
+ - "3000:3000"
+ command: npm run dev
+ """
+
+ public static let dockerComposeYaml5 = """
+ version: '3.8'
+
+ services:
+ app:
+ image: myapp:latest
+ configs:
+ - source: app_config
+ target: /etc/app/config.yml
+ secrets:
+ - db_password
+
+ configs:
+ app_config:
+ external: true
+
+ secrets:
+ db_password:
+ external: true
+ """
+
+ public static let dockerComposeYaml6 = """
+ version: '3.8'
+
+ services:
+ web:
+ image: nginx:latest
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+
+ db:
+ image: postgres:14
+ restart: always
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ """
+
+ public static let dockerComposeYaml7 = """
+ version: '3.8'
+
+ services:
+ frontend:
+ image: frontend:latest
+ depends_on:
+ - api
+
+ api:
+ image: api:latest
+ depends_on:
+ - cache
+ - db
+
+ cache:
+ image: redis:alpine
+
+ db:
+ image: postgres:14
+ """
+
+ public static let dockerComposeYaml8 = """
+ version: '3.8'
+
+ services:
+ web:
+ image: nginx:alpine
+ ports:
+ - "8082:80"
+ depends_on:
+ - app
+
+ app:
+ image: python:3.12-alpine
+ depends_on:
+ - db
+ command: python -m http.server 8000
+ environment:
+ DATABASE_URL: postgres://postgres:postgres@db:5432/appdb
+
+ db:
+ image: postgres:14
+ environment:
+ POSTGRES_DB: appdb
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ """
+
+ public static func dockerComposeYaml9(containerName: String) -> String {
+ return """
+ version: '3.8'
+ services:
+ web:
+ image: nginx:alpine
+ container_name: \(containerName)
+ """
+ }
+
+ /// Represents a temporary Docker Compose project copied to a temporary location for testing.
+ public struct TemporaryProject {
+ /// The URL of the temporary docker-compose.yaml file.
+ public let url: URL
+
+ /// The base directory containing the temporary docker-compose.yaml 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
+ /// TemporaryProject.
+ /// - Parameter yaml: The Docker Compose YAML content to copy.
+ /// - Returns: A TemporaryProject containing the URL and project name.
+ public static func copyYamlToTemporaryLocation(yaml: String) throws -> TemporaryProject {
+ let tempLocation = URL.temporaryDirectory.appending(
+ path: "Container-Compose_Tests_\(UUID().uuidString)/docker-compose.yaml")
+ let tempBase = tempLocation.deletingLastPathComponent()
+ try? FileManager.default.createDirectory(at: tempBase, withIntermediateDirectories: true)
+ try yaml.write(to: tempLocation, atomically: false, encoding: .utf8)
+ let projectName = tempBase.lastPathComponent
+
+ return TemporaryProject(url: tempLocation, base: tempBase, name: projectName)
+ }
+
+}
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.
]