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.