diff --git a/.dockerignore b/.dockerignore
index e660fd93d31..c1323a91826 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1 +1,14 @@
-bin/
+*.egg-info
+.coverage
+.git
+.github
+.tox
+build
+binaries
+coverage-html
+docs/_site
+*venv
+.tox
+**/__pycache__
+*.pyc
+Jenkinsfile
diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index 9c91b79966e..00000000000
--- a/.gitattributes
+++ /dev/null
@@ -1,2 +0,0 @@
-core.autocrlf false
-*.golden text eol=lf
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index b569a0c893b..85ab9015f3b 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,2 +1,6 @@
-# global rules
-* @docker/compose-maintainers @docker/compose-reviewers
+# GitHub code owners
+# See https://help.github.com/articles/about-codeowners/
+#
+# KEEP THIS FILE SORTED. Order is important. Last match takes precedence.
+
+* @aiordache @ndeloof @rumpl @ulyssessouza
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000000..441cd45194f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,76 @@
+---
+name: Bug report
+about: Report a bug encountered while using docker-compose
+title: ''
+labels: kind/bug
+assignees: ''
+
+---
+
+
+
+
+
+## Description of the issue
+
+## Context information (for bug reports)
+
+- [ ] Using Compose V2 `docker compose ...`
+- [ ] Using Compose V1 `docker-compose ...`
+
+**Output of `docker(-)compose version`**
+```
+(paste here)
+```
+
+**Output of `docker version`**
+```
+(paste here)
+```
+
+**Output of `docker-compose config`**
+(Make sure to add the relevant `-f` and other flags)
+```
+(paste here)
+```
+
+
+## Steps to reproduce the issue
+
+1.
+2.
+3.
+
+### Observed result
+
+### Expected result
+
+### Stacktrace / full error message
+
+```
+(paste here)
+```
+
+## Additional information
+
+OS version / distribution, `docker-compose` install method, etc.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
deleted file mode 100644
index 37b546967dd..00000000000
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ /dev/null
@@ -1,55 +0,0 @@
-name: 🐞 Bug
-description: File a bug/issue
-title: "[BUG]
"
-labels: ['status/0-triage', 'kind/bug']
-body:
- - type: textarea
- attributes:
- label: Description
- description: |
- Briefly describe the problem you are having.
-
- Include both the current behavior (what you are seeing) as well as what you expected to happen.
- validations:
- required: true
- - type: markdown
- attributes:
- value: |
- [Docker Swarm](https://www.mirantis.com/software/swarm/) uses a distinct compose file parser and
- as such doesn't support some of the recent features of Docker Compose. Please contact Mirantis
- if you need assistance with compose file support in Docker Swarm.
- - type: textarea
- attributes:
- label: Steps To Reproduce
- description: Steps to reproduce the behavior.
- placeholder: |
- 1. In this environment...
- 2. With this config...
- 3. Run '...'
- 4. See error...
- validations:
- required: false
- - type: textarea
- attributes:
- label: Compose Version
- description: |
- Paste output of `docker compose version` and `docker-compose version`.
- render: Text
- validations:
- required: false
- - type: textarea
- attributes:
- label: Docker Environment
- description: Paste output of `docker info`.
- render: Text
- validations:
- required: false
- - type: textarea
- attributes:
- label: Anything else?
- description: |
- Links? References? Anything that will give us more context about the issue you are encountering!
-
- Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
- validations:
- required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
deleted file mode 100644
index cc4b65bf24d..00000000000
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-blank_issues_enabled: true
-contact_links:
- - name: Docker Community Slack
- url: https://dockr.ly/slack
- about: 'Use the #docker-compose channel'
- - name: Docker Support Forums
- url: https://forums.docker.com/c/open-source-projects/compose/15
- about: 'Use the "Open Source Projects > Compose" category'
- - name: 'Ask on Stack Overflow'
- url: https://stackoverflow.com/questions/tagged/docker-compose
- about: 'Use the [docker-compose] tag when creating new questions'
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000000..2e010287366
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,45 @@
+---
+name: Feature request
+about: Suggest an idea to improve Compose
+title: ''
+labels: kind/feature
+assignees: ''
+
+---
+
+
+
+
+
+/!\ If your request is about evolving the compose file format, please report on the [Compose Specification](https://github.com/compose-spec/compose-spec)
+
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
deleted file mode 100644
index 677a1684fc0..00000000000
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-name: Feature request
-description: Missing functionality? Come tell us about it!
-labels:
- - kind/feature
- - status/0-triage
-body:
- - type: textarea
- id: description
- attributes:
- label: Description
- description: What is the feature you want to see?
- validations:
- required: true
diff --git a/.github/ISSUE_TEMPLATE/question-about-using-compose.md b/.github/ISSUE_TEMPLATE/question-about-using-compose.md
new file mode 100644
index 00000000000..a8555bf1e9b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/question-about-using-compose.md
@@ -0,0 +1,22 @@
+---
+name: Question about using Compose
+about: This is not the appropriate channel
+title: ''
+labels: kind/question
+assignees: ''
+
+---
+
+
+
+Please post on our forums: https://forums.docker.com for questions about using `docker-compose`.
+
+Posts that are not a bug report or a feature/enhancement request will not be addressed on this issue tracker.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
deleted file mode 100644
index 00e87ff8eaa..00000000000
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ /dev/null
@@ -1,6 +0,0 @@
-**What I did**
-
-**Related issue**
-
-
-**(not mandatory) A picture of a cute animal, if possible in relation to what you did**
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
deleted file mode 100644
index 68d983654a4..00000000000
--- a/.github/SECURITY.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# Security Policy
-
-The maintainers of Docker Compose take security seriously. If you discover
-a security issue, please bring it to their attention right away!
-
-## Reporting a Vulnerability
-
-Please **DO NOT** file a public issue, instead send your report privately
-to [security@docker.com](mailto:security@docker.com).
-
-Reporter(s) can expect a response within 72 hours, acknowledging the issue was
-received.
-
-## Review Process
-
-After receiving the report, an initial triage and technical analysis is
-performed to confirm the report and determine its scope. We may request
-additional information in this stage of the process.
-
-Once a reviewer has confirmed the relevance of the report, a draft security
-advisory will be created on GitHub. The draft advisory will be used to discuss
-the issue with maintainers, the reporter(s), and where applicable, other
-affected parties under embargo.
-
-If the vulnerability is accepted, a timeline for developing a patch, public
-disclosure, and patch release will be determined. If there is an embargo period
-on public disclosure before the patch release, the reporter(s) are expected to
-participate in the discussion of the timeline and abide by agreed upon dates
-for public disclosure.
-
-## Accreditation
-
-Security reports are greatly appreciated and we will publicly thank you,
-although we will keep your name confidential if you request it. We also like to
-send gifts - if you're into swag, make sure to let us know. We do not currently
-offer a paid security bounty program at this time.
-
-## Supported Versions
-
-This project does not provide long-term supported versions, and only the current
-release and `main` branch are actively maintained. Docker Compose v1, and the
-corresponding [v1 branch](https://github.com/docker/compose/tree/v1) reached
-EOL and are no longer supported.
-
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index b1434ab66b4..6eed7957469 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,38 +1,28 @@
----
version: 2
updates:
- - package-ecosystem: gomod
- directory: /
- cooldown:
- default-days: 7
- ignore:
- - dependency-name: github.com/docker/buildx
- update-types:
- - version-update:semver-minor
- - dependency-name: github.com/moby/buildkit
- update-types:
- - version-update:semver-minor
- - dependency-name: github.com/docker/cli
- update-types:
- - version-update:semver-major
- - dependency-name: github.com/docker/docker
- update-types:
- - version-update:semver-major
- - dependency-name: github.com/containerd/containerd
- update-types:
- - version-update:semver-major
- - version-update:semver-minor
- - dependency-name: go.opentelemetry.io/*
- schedule:
- interval: daily
- - package-ecosystem: github-actions
- open-pull-requests-limit: 10
- directory: "/"
- schedule:
- interval: daily
- cooldown:
- default-days: 7
- groups:
- docker-actions:
- patterns:
- - "docker/*"
+- package-ecosystem: pip
+ directory: "/"
+ schedule:
+ interval: weekly
+ time: "14:00"
+ timezone: America/Los_Angeles
+ open-pull-requests-limit: 10
+ ignore:
+ - dependency-name: python-dotenv
+ versions:
+ - 0.15.0
+ - 0.16.0
+ - dependency-name: urllib3
+ versions:
+ - 1.26.2
+ - 1.26.3
+ - dependency-name: coverage
+ versions:
+ - 5.3.1
+ - "5.4"
+ - dependency-name: packaging
+ versions:
+ - "20.8"
+ - dependency-name: cached-property
+ versions:
+ - 1.5.2
diff --git a/.github/stale.yml b/.github/stale.yml
index c14cb12918a..6de76aef987 100644
--- a/.github/stale.yml
+++ b/.github/stale.yml
@@ -1,7 +1,7 @@
# Configuration for probot-stale - https://github.com/probot/stale
# Number of days of inactivity before an Issue or Pull Request becomes stale
-daysUntilStale: 90
+daysUntilStale: 180
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
@@ -12,7 +12,7 @@ onlyLabels: []
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
exemptLabels:
- - "kind/feature"
+ - kind/feature
# Set to true to ignore issues in a project (defaults to false)
exemptProjects: false
@@ -56,4 +56,4 @@ only: issues
# issues:
# exemptLabels:
-# - confirmed
\ No newline at end of file
+# - confirmed
diff --git a/.github/workflows/artifacts.yml b/.github/workflows/artifacts.yml
new file mode 100644
index 00000000000..c0fe29ffc6c
--- /dev/null
+++ b/.github/workflows/artifacts.yml
@@ -0,0 +1,58 @@
+name: Publish Artifacts
+on:
+ issue_comment:
+ types: [created]
+jobs:
+ publish-artifacts:
+ if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/generate-artifacts')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Go 1.16
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.16
+ id: go
+
+ - name: Checkout code into the Go module directory
+ uses: actions/checkout@v2
+
+ - uses: actions/cache@v2
+ with:
+ path: ~/go/pkg/mod
+ key: go-${{ hashFiles('**/go.sum') }}
+
+ - name: Build cross platform compose-plugin binaries
+ run: make -f builder.Makefile cross
+
+ - name: Upload macos-amd64 binary
+ uses: actions/upload-artifact@v2
+ with:
+ name: docker-compose-darwin-amd64
+ path: ${{ github.workspace }}/bin/docker-compose-darwin-amd64
+
+ - name: Upload macos-arm64 binary
+ uses: actions/upload-artifact@v2
+ with:
+ name: docker-compose-darwin-arm64
+ path: ${{ github.workspace }}/bin/docker-compose-darwin-arm64
+
+ - name: Upload linux-amd64 binary
+ uses: actions/upload-artifact@v2
+ with:
+ name: docker-compose-linux-amd64
+ path: ${{ github.workspace }}/bin/docker-compose-linux-amd64
+
+ - name: Upload windows-amd64 binary
+ uses: actions/upload-artifact@v2
+ with:
+ name: docker-compose-windows-amd64.exe
+ path: ${{ github.workspace }}/bin/docker-compose-windows-amd64.exe
+
+ - name: Update comment
+ uses: peter-evans/create-or-update-comment@v1
+ with:
+ comment-id: ${{ github.event.comment.id }}
+ body: |
+ This PR can be tested using [binaries](https://github.com/docker/compose-cli/actions/runs/${{ github.run_id }}).
+ reactions: eyes
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0637850993c..6dbeae9a7be 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,351 +1,104 @@
-name: ci
-
-permissions:
- contents: read # to fetch code (actions/checkout)
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+name: Continuous integration
on:
push:
branches:
- - 'main'
- tags:
- - 'v*'
+ - v2
pull_request:
- workflow_dispatch:
- inputs:
- debug_enabled:
- description: 'To run with tmate enter "debug_enabled"'
- required: false
- default: "false"
+ branches:
+ - v2
jobs:
- validate:
+ lint:
+ name: Lint
runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- target:
- - lint
- - validate-go-mod
- - validate-headers
- - validate-docs
+ env:
+ GO111MODULE: "on"
steps:
- -
- name: Checkout
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- -
- name: Run
- run: |
- make ${{ matrix.target }}
+ - name: Set up Go 1.16
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.16
+ id: go
+
+ - name: Checkout code into the Go module directory
+ uses: actions/checkout@v2
- binary:
- uses: docker/github-builder/.github/workflows/bake.yml@27ade872c1e2296e62ef15ab3b10d37665e57cf7 # v1.15.0
- permissions:
- contents: read # same as global permission
- id-token: write # for signing attestation(s) with GitHub OIDC Token
- with:
- runner: amd64
- artifact-name: compose
- artifact-upload: true
- cache: true
- cache-scope: binary
- target: release
- output: local
- sbom: true
- sign: ${{ github.event_name != 'pull_request' }}
+ - name: Validate go-mod is up-to-date and license headers
+ run: make validate
- binary-finalize:
+ - name: Run golangci-lint
+ env:
+ BUILD_TAGS: e2e
+ run: |
+ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sudo sh -s -- -b /usr/bin/ v1.39.0
+ make -f builder.Makefile lint
+
+ # only on main branch, costs too much for the gain on every PR
+ validate-cross-build:
+ name: Validate cross build
runs-on: ubuntu-latest
- needs:
- - binary
+ if: github.ref == 'refs/heads/main'
+ env:
+ GO111MODULE: "on"
steps:
- -
- name: Download artifacts
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - name: Set up Go 1.16
+ uses: actions/setup-go@v2
with:
- path: /tmp/compose-output
- name: ${{ needs.binary.outputs.artifact-name }}
- -
- name: Rename provenance and sbom
- run: |
- for pdir in /tmp/compose-output/*/; do
- (
- cd "$pdir"
- binname=$(find . -name 'docker-compose-*')
- filename=$(basename "${binname%.exe}")
- mv "provenance.json" "${filename}.provenance.json"
- mv "sbom-binary.spdx.json" "${filename}.sbom.json"
- find . -name 'sbom*.json' -exec rm {} \;
- if [ -f "provenance.sigstore.json" ]; then
- mv "provenance.sigstore.json" "${filename}.sigstore.json"
- fi
- )
- done
- mkdir -p "./bin/release"
- mv /tmp/compose-output/**/* "./bin/release/"
- -
- name: Create checksum file
- working-directory: ./bin/release
- run: |
- find . -type f -print0 | sort -z | xargs -r0 shasum -a 256 -b | sed 's# \*\./# *#' > $RUNNER_TEMP/checksums.txt
- shasum -a 256 -U -c $RUNNER_TEMP/checksums.txt
- mv $RUNNER_TEMP/checksums.txt .
- cat checksums.txt | while read sum file; do
- if [[ "${file#\*}" == docker-compose-* && "${file#\*}" != *.provenance.json && "${file#\*}" != *.sbom.json && "${file#\*}" != *.sigstore.json ]]; then
- echo "$sum $file" > ${file#\*}.sha256
- fi
- done
- -
- name: List artifacts
- run: |
- tree -nh ./bin/release
- -
- name: Check artifacts
- run: |
- find bin/release -type f -exec file -e ascii -- {} +
- -
- name: Upload artifacts
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ go-version: 1.16
+ id: go
+
+ - name: Checkout code into the Go module directory
+ uses: actions/checkout@v2
+
+ - uses: actions/cache@v2
with:
- name: release
- path: ./bin/release/*
- if-no-files-found: error
+ path: ~/go/pkg/mod
+ key: go-${{ hashFiles('**/go.sum') }}
- bin-image-test:
- if: github.event_name == 'pull_request'
- uses: docker/github-builder/.github/workflows/bake.yml@27ade872c1e2296e62ef15ab3b10d37665e57cf7 # v1.15.0
- with:
- runner: amd64
- target: image-cross
- cache: true
- cache-scope: bin-image-test
- output: image
- push: false
- sbom: true
- set-meta-labels: true
- meta-images: |
- compose-bin
- meta-tags: |
- type=ref,event=pr
- meta-bake-target: meta-helper
+ # Ensure we don't discover cross platform build issues at release time.
+ # Time used to build linux here is gained back in the build for local E2E step
+ - name: Build packages
+ run: make -f builder.Makefile cross
- test:
+ build:
+ name: Build
runs-on: ubuntu-latest
+ env:
+ GO111MODULE: "on"
steps:
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- -
- name: Test
- uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
- with:
- targets: test
- set: |
- *.cache-from=type=gha,scope=test
- *.cache-to=type=gha,scope=test
- -
- name: Gather coverage data
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ - name: Set up Go 1.16
+ uses: actions/setup-go@v2
with:
- name: coverage-data-unit
- path: bin/coverage/unit/
- if-no-files-found: error
- -
- name: Unit Test Summary
- uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # v2.6
- with:
- paths: bin/coverage/unit/report.xml
- if: always()
-
- e2e:
- runs-on: ubuntu-latest
- name: e2e (${{ matrix.mode }}, ${{ matrix.channel }})
- strategy:
- fail-fast: false
- matrix:
- include:
- # current stable
- - mode: plugin
- engine: 29
- channel: stable
- - mode: standalone
- engine: 29
- channel: stable
+ go-version: 1.16
+ id: go
- # old stable (latest major - 1)
- - mode: plugin
- engine: 28
- channel: oldstable
- - mode: standalone
- engine: 28
- channel: oldstable
- steps:
- - name: Prepare
+ - name: Set up gosum
run: |
- mode=${{ matrix.mode }}
- engine=${{ matrix.engine }}
- echo "MODE_ENGINE_PAIR=${mode}-${engine}" >> $GITHUB_ENV
-
- - name: Checkout
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ go get -u gotest.tools/gotestsum
- - name: Install Docker ${{ matrix.engine }}
+ - name: Setup docker CLI
run: |
- sudo systemctl stop docker.service
- sudo apt-get purge docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-ce-rootless-extras docker-buildx-plugin
- sudo apt-get install curl
- curl -fsSL https://test.docker.com -o get-docker.sh
- sudo sh ./get-docker.sh --version ${{ matrix.engine }}
-
- - name: Check Docker Version
- run: docker --version
+ curl https://download.docker.com/linux/static/stable/x86_64/docker-20.10.3.tgz | tar xz
+ sudo cp ./docker/docker /usr/bin/ && rm -rf docker && docker version
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
+ - name: Checkout code into the Go module directory
+ uses: actions/checkout@v2
- - name: Set up Docker Model
- run: |
- sudo apt-get install docker-model-plugin
- docker model version
-
- - name: Set up Go
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
+ - uses: actions/cache@v2
with:
- go-version-file: '.go-version'
- check-latest: true
- cache: true
+ path: ~/go/pkg/mod
+ key: go-${{ hashFiles('**/go.sum') }}
- - name: Build example provider
- run: make example-provider
+ - name: Test
+ env:
+ BUILD_TAGS: kube
+ run: make -f builder.Makefile test
- - name: Build
- uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
- with:
- source: .
- targets: binary-with-coverage
- set: |
- *.cache-from=type=gha,scope=binary-linux-amd64
- *.cache-from=type=gha,scope=binary-e2e-${{ matrix.mode }}
- *.cache-to=type=gha,scope=binary-e2e-${{ matrix.mode }},mode=max
+ - name: Build for local E2E
env:
BUILD_TAGS: e2e
+ run: make -f builder.Makefile compose-plugin
- - name: Setup tmate session
- if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled }}
- uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3.24
- with:
- limit-access-to-actor: true
- github-token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Test plugin mode
- if: ${{ matrix.mode == 'plugin' }}
- run: |
- rm -rf ./bin/coverage/e2e
- mkdir -p ./bin/coverage/e2e
- make e2e-compose GOCOVERDIR=bin/coverage/e2e TEST_FLAGS="-v"
-
- - name: Gather coverage data
- if: ${{ matrix.mode == 'plugin' }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: coverage-data-e2e-${{ env.MODE_ENGINE_PAIR }}
- path: bin/coverage/e2e/
- if-no-files-found: error
-
- - name: Test standalone mode
- if: ${{ matrix.mode == 'standalone' }}
- run: |
- rm -f /usr/local/bin/docker-compose
- cp bin/build/docker-compose /usr/local/bin
- make e2e-compose-standalone
-
- - name: e2e Test Summary
- uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # v2.6
- with:
- paths: /tmp/report/report.xml
- if: always()
-
- coverage:
- runs-on: ubuntu-latest
- needs:
- - test
- - e2e
- steps:
- # codecov won't process the report without the source code available
- - name: Checkout
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - name: Set up Go
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
- with:
- go-version-file: '.go-version'
- check-latest: true
- - name: Download unit test coverage
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: coverage-data-unit
- path: coverage/unit
- merge-multiple: true
- - name: Download E2E test coverage
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- pattern: coverage-data-e2e-*
- path: coverage/e2e
- merge-multiple: true
- - name: Merge coverage reports
- run: |
- go tool covdata textfmt -i=./coverage/unit,./coverage/e2e -o ./coverage.txt
- - name: Store coverage report in GitHub Actions
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: go-covdata-txt
- path: ./coverage.txt
- if-no-files-found: error
- - name: Upload coverage to Codecov
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
- with:
- files: ./coverage.txt
-
- release:
- if: startsWith(github.ref, 'refs/tags/v')
- permissions:
- contents: write # to create a release (softprops/action-gh-release)
- runs-on: ubuntu-latest
- needs:
- - binary-finalize
- steps:
- -
- name: Checkout
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- -
- name: Download artifacts
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- path: ./bin/release
- name: release
- -
- # inspect what was actually downloaded from the artifact store —
- # this is what gets attached to the release
- name: List artifacts
- run: |
- tree -nh ./bin/release
- -
- name: Check artifacts
- run: |
- find bin/release -type f -exec file -e ascii -- {} +
- -
- name: GitHub Release
- uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
- with:
- files: ./bin/release/*
- generate_release_notes: true
- draft: true
- # never replace assets already uploaded to an existing release
- # (softprops updates an existing release for the tag instead of failing)
- overwrite_files: false
- token: ${{ secrets.GITHUB_TOKEN }}
+ - name: E2E Test
+ run: make e2e-compose
diff --git a/.github/workflows/docs-upstream.yml b/.github/workflows/docs-upstream.yml
deleted file mode 100644
index ff9ec029abe..00000000000
--- a/.github/workflows/docs-upstream.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-# this workflow runs the remote validate bake target from docker/docs
-# to check if yaml reference docs used in this repo are valid
-name: docs-upstream
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-on:
- push:
- branches:
- - 'main'
- - 'v[0-9]*'
- paths:
- - '.github/workflows/docs-upstream.yml'
- - 'docs/**'
- pull_request:
- paths:
- - '.github/workflows/docs-upstream.yml'
- - 'docs/**'
-
-jobs:
- docs-yaml:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- -
- name: Upload reference YAML docs
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: docs-yaml
- path: docs/reference
- retention-days: 1
-
- validate:
- uses: docker/docs/.github/workflows/validate-upstream.yml@main # zizmor: ignore[unpinned-uses] needs to validate against latest docs changes
- needs:
- - docs-yaml
- with:
- module-name: docker/compose
diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml
deleted file mode 100644
index 3f963932994..00000000000
--- a/.github/workflows/merge.yml
+++ /dev/null
@@ -1,81 +0,0 @@
-name: merge
-
-permissions:
- contents: read # to fetch code (actions/checkout)
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-on:
- push:
- branches:
- - 'main'
- tags:
- - 'v*'
-
-env:
- REPO_SLUG: "docker/compose-bin"
-
-jobs:
- bin-image-prepare:
- runs-on: ubuntu-24.04
- outputs:
- repo-slug: ${{ env.REPO_SLUG }}
- steps:
- # FIXME: can't use env object in reusable workflow inputs: https://github.com/orgs/community/discussions/26671
- - run: echo "Exposing env vars for reusable workflow"
-
- bin-image:
- uses: docker/github-builder/.github/workflows/bake.yml@27ade872c1e2296e62ef15ab3b10d37665e57cf7 # v1.15.0
- needs:
- - bin-image-prepare
- permissions:
- contents: read # same as global permission
- id-token: write # for signing attestation(s) with GitHub OIDC Token
- with:
- runner: amd64
- target: image-cross
- cache: true
- cache-scope: bin-image
- output: image
- push: true # this workflow only triggers on push (main and tags)
- sbom: true
- set-meta-labels: true
- meta-images: |
- ${{ needs.bin-image-prepare.outputs.repo-slug }}
- meta-tags: |
- type=ref,event=tag
- type=edge
- meta-bake-target: meta-helper
- registry-identities: |
- - type: dockerhub
- username: docker
- connection_id: a295c8b7-54ab-4507-ac88-5c43003c73a5
-
- module-image:
- uses: docker/github-builder/.github/workflows/bake.yml@27ade872c1e2296e62ef15ab3b10d37665e57cf7 # v1.15.0
- permissions:
- contents: read # same as global permission
- id-token: write # for signing attestation(s) with GitHub OIDC Token
- with:
- runner: amd64
- target: image-module-cross
- cache: true
- cache-scope: module-image
- output: image
- push: ${{ startsWith(github.ref, 'refs/tags/v') }}
- sbom: true
- set-meta-labels: true
- meta-images: |
- docker/compose-desktop-module
- meta-tags: |
- type=ref,event=branch
- type=ref,event=tag
- meta-bake-target: meta-helper
- registry-identities: |
- - type: dockerhub
- username: docker
- connection_id: a295c8b7-54ab-4507-ac88-5c43003c73a5
-
-
\ No newline at end of file
diff --git a/.github/workflows/pr-closed.yml b/.github/workflows/pr-closed.yml
new file mode 100644
index 00000000000..ab13ae12692
--- /dev/null
+++ b/.github/workflows/pr-closed.yml
@@ -0,0 +1,11 @@
+name: PR cleanup
+on:
+ pull_request:
+ types: [closed]
+jobs:
+ delete_pr_artifacts:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: stefanluptak/delete-old-pr-artifacts@v1
+ with:
+ workflow_filename: ci.yaml
\ No newline at end of file
diff --git a/.github/workflows/pr-review-trigger.yml b/.github/workflows/pr-review-trigger.yml
deleted file mode 100644
index a650abffeab..00000000000
--- a/.github/workflows/pr-review-trigger.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-name: PR Review - Trigger
-
-permissions: {}
-
-# Deduplicate simultaneous pull_request events for the same fork PR.
-# When reviewers are requested at the same time, GitHub fires multiple
-# review_requested events. Without this group each event triggers a
-# separate review via workflow_run, producing duplicate reviews.
-concurrency:
- group: pr-review-trigger-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-on:
- pull_request:
- types: [ready_for_review, opened, review_requested]
- pull_request_review_comment:
- types: [created]
-
-jobs:
- save-context:
- # Only run on fork PRs; skip GitHub App bot accounts (Dependabot, Renovate, etc.) early.
- if: github.event.pull_request.head.repo.fork && github.event.sender.type != 'Bot'
- runs-on: ubuntu-latest
- steps:
- - name: Save event context
- env:
- PR_NUMBER: ${{ github.event.pull_request.number }}
- PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- COMMENT_JSON: ${{ toJSON(github.event.comment) }}
- run: |
- mkdir -p context
- printf '%s' "${{ github.event_name }}" > context/event_name.txt
- printf '%s' "$PR_NUMBER" > context/pr_number.txt
- printf '%s' "$PR_HEAD_SHA" > context/pr_head_sha.txt
- if [ "${{ github.event_name }}" = "pull_request_review_comment" ]; then
- printf '%s' "$COMMENT_JSON" > context/comment.json
- fi
-
- - name: Upload context
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: pr-review-context
- path: context/
- retention-days: 1
diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml
deleted file mode 100644
index 8c1259c1cab..00000000000
--- a/.github/workflows/pr-review.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: PR Review
-
-permissions:
- contents: read # Required at top-level to give `issue_comment` events access to the secrets below.
-
-# workflow_run is used deliberately here: the unprivileged "PR Review - Trigger"
-# workflow runs on the fork PR and this privileged one only consumes its
-# artifacts, never checks out or executes fork code.
-on: # zizmor: ignore[dangerous-triggers]
- issue_comment:
- types: [ created ]
- workflow_run:
- workflows: [ "PR Review - Trigger" ]
- types: [ completed ]
-
-jobs:
- review:
- if: |
- github.event_name == 'issue_comment' ||
- github.event.workflow_run.conclusion == 'success'
- uses: docker/docker-agent-action/.github/workflows/review-pr.yml@baf90543d81f5de59751dfd10e6cf45e21a5a982 # v2.0.3
- # Scoped to the job so other jobs in this workflow aren't over-permissioned
- permissions:
- contents: read # Read repository files and PR diffs
- pull-requests: write # Post review comments
- issues: write # Create security incident issues if secrets detected
- checks: write # (Optional) Show review progress as a check run
- id-token: write # Required for OIDC authentication to AWS Secrets Manager
- actions: read # Download artifacts from trigger workflow
- with:
- trigger-run-id: ${{ github.event_name == 'workflow_run' && format('{0}',
- github.event.workflow_run.id) || '' }}
diff --git a/.github/workflows/rebase.yml b/.github/workflows/rebase.yml
new file mode 100644
index 00000000000..db5203798e1
--- /dev/null
+++ b/.github/workflows/rebase.yml
@@ -0,0 +1,19 @@
+name: Automatic Rebase
+on:
+ issue_comment:
+ types: [created]
+jobs:
+ rebase:
+ name: Rebase
+ if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout the latest code
+ uses: actions/checkout@v2
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0 # otherwise, you will fail to push refs to dest repo
+ - name: Automatic Rebase
+ uses: cirrus-actions/rebase@1.4
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 00000000000..7624447da1e
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,49 @@
+name: Releaser
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Release Tag'
+ required: true
+ dry-run:
+ description: 'Dry run'
+ required: false
+ default: 'true'
+
+jobs:
+ upload-release:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Go 1.16
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.16
+ id: go
+
+ - name: Setup docker CLI
+ run: |
+ curl https://download.docker.com/linux/static/stable/x86_64/docker-20.10.3.tgz | tar xz
+ sudo cp ./docker/docker /usr/bin/ && rm -rf docker && docker version
+
+ - name: Checkout code into the Go module directory
+ uses: actions/checkout@v2
+
+ - uses: actions/cache@v2
+ with:
+ path: ~/go/pkg/mod
+ key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+ restore-keys: |
+ ${{ runner.os }}-go-
+
+ - name: Build
+ run: make -f builder.Makefile cross-compose-plugin
+
+ - name: License
+ run: cp packaging/* bin/
+
+ - uses: ncipollo/release-action@v1
+ with:
+ artifacts: "bin/*"
+ prerelease: true
+ token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml
deleted file mode 100644
index c92855ed08c..00000000000
--- a/.github/workflows/scorecards.yml
+++ /dev/null
@@ -1,66 +0,0 @@
-name: Scorecards supply-chain security
-
-permissions: {}
-
-on:
- # Only the default branch is supported.
- branch_protection_rule:
- schedule:
- - cron: '44 9 * * 4'
- push:
- branches: [ "main" ]
-
-jobs:
- analysis:
- name: Scorecards analysis
- runs-on: ubuntu-latest
- permissions:
- # Needed to upload the results to code-scanning dashboard.
- security-events: write
- # Used to receive a badge.
- id-token: write
- # read permissions to all the other objects
- actions: read
- attestations: read
- checks: read
- contents: read
- deployments: read
- issues: read
- discussions: read
- packages: read
- pages: read
- pull-requests: read
- statuses: read
-
- steps:
- - name: "Checkout code"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: "Run analysis"
- uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
- with:
- results_file: results.sarif
- results_format: sarif
-
- # Publish the results for public repositories to enable scorecard badges. For more details, see
- # https://github.com/ossf/scorecard-action#publishing-results.
- # For private repositories, `publish_results` will automatically be set to `false`, regardless
- # of the value entered here.
- publish_results: true
-
- # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
- # format to the repository Actions tab.
- - name: "Upload artifact"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: SARIF file
- path: results.sarif
- retention-days: 5
-
- # Upload the results to GitHub's code scanning dashboard.
- - name: "Upload to code-scanning"
- uses: github/codeql-action/upload-sarif@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
- with:
- sarif_file: results.sarif
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
deleted file mode 100644
index 336272f4a79..00000000000
--- a/.github/workflows/stale.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-name: 'Close stale issues'
-
-permissions:
- contents: read
-
-on:
- schedule:
- - cron: '0 0 * * 0,3' # at midnight UTC every Sunday and Wednesday
-
-jobs:
- stale:
- runs-on: ubuntu-latest
- permissions:
- issues: write
- pull-requests: write
- steps:
- - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
- with:
- repo-token: ${{ secrets.GITHUB_TOKEN }}
- stale-issue-message: >
- This issue has been automatically marked as stale because it has not had
- recent activity. It will be closed if no further activity occurs. Thank you
- for your contributions.
- days-before-issue-stale: 150 # marks stale after 5 months
- days-before-issue-close: 30 # closes 1 month after being marked with no action
- stale-issue-label: "stale"
- exempt-issue-labels: "kind/feature,kind/enhancement"
diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml
deleted file mode 100644
index 708c8a70d09..00000000000
--- a/.github/workflows/zizmor.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-name: zizmor
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-on:
- workflow_dispatch:
- push:
- branches:
- - 'main'
- tags:
- - 'v*'
- pull_request:
-
-jobs:
- zizmor:
- uses: crazy-max/.github/.github/workflows/zizmor.yml@46267a6e61cd56aac2fc79943df180152f4c89d6 # v1.10.1
- permissions:
- contents: read
- security-events: write
- with:
- min-severity: medium
- min-confidence: medium
- persona: pedantic
diff --git a/.gitignore b/.gitignore
index f01544f105f..79888274847 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,18 @@
-bin/
-/.vscode/
-coverage.out
-covdatafiles/
+*.egg-info
+*.pyc
+*.swo
+*.swp
+.cache
+.coverage*
.DS_Store
-pkg/e2e/*.tar
+.idea
+
+/.tox
+/binaries
+/build
+/compose/GITSHA
+/coverage-html
+/dist
+/docs/_site
+/README.rst
+/*venv
diff --git a/.go-version b/.go-version
deleted file mode 100644
index 8fe00a57fe1..00000000000
--- a/.go-version
+++ /dev/null
@@ -1 +0,0 @@
-1.26.5
diff --git a/.golangci.yml b/.golangci.yml
deleted file mode 100644
index dda1822b1c6..00000000000
--- a/.golangci.yml
+++ /dev/null
@@ -1,116 +0,0 @@
-version: "2"
-run:
- concurrency: 2
-linters:
- default: none
- enable:
- - copyloopvar
- - depguard
- - errcheck
- - errorlint
- - forbidigo
- - gocritic
- - gocyclo
- - gomodguard
- - govet
- - ineffassign
- - lll
- - misspell
- - nakedret
- - nolintlint
- - revive
- - staticcheck
- - testifylint
- - unconvert
- - unparam
- - unused
- settings:
- depguard:
- rules:
- all:
- deny:
- - pkg: io/ioutil
- desc: io/ioutil package has been deprecated
- - pkg: github.com/docker/docker/errdefs
- desc: use github.com/containerd/errdefs instead.
- - pkg: golang.org/x/exp/maps
- desc: use stdlib maps package
- - pkg: golang.org/x/exp/slices
- desc: use stdlib slices package
- - pkg: gopkg.in/yaml.v2
- desc: compose-go uses yaml.v3
- - pkg: github.com/stretchr/testify/assert
- desc: Use "gotest.tools/v3/assert" instead
- - pkg: github.com/stretchr/testify/require
- desc: Use "gotest.tools/v3/assert" instead
- - pkg: github.com/stretchr/testify/suite
- desc: Do not use
- forbidigo:
- analyze-types: true
- forbid:
- - pattern: 'context\.Background'
- pkg: '^context$'
- msg: "in tests, use t.Context() instead of context.Background()"
- - pattern: 'context\.TODO'
- pkg: '^context$'
- msg: "in tests, use t.Context() instead of context.TODO()"
- gocritic:
- disabled-checks:
- - paramTypeCombine
- - unnamedResult
- - whyNoLint
- enabled-tags:
- - diagnostic
- - opinionated
- - style
- gocyclo:
- min-complexity: 16
- gomodguard:
- blocked:
- modules:
- - github.com/pkg/errors:
- recommendations:
- - errors
- - fmt
- versions:
- - github.com/distribution/distribution:
- reason: use distribution/reference
- - gotest.tools:
- version: < 3.0.0
- reason: deprecated, pre-modules version
- lll:
- line-length: 200
- revive:
- rules:
- - name: package-comments
- disabled: true
- exclusions:
- generated: lax
- paths:
- - third_party$
- - builtin$
- - examples$
- rules:
- - path-except: '_test\.go'
- linters:
- - forbidigo
-issues:
- max-issues-per-linter: 0
- max-same-issues: 0
-formatters:
- enable:
- - gci
- - gofumpt
- exclusions:
- generated: lax
- paths:
- - third_party$
- - builtin$
- - examples$
- settings:
- gci:
- sections:
- - standard
- - default
- - localmodule
- custom-order: true # make the section order the same as the order of "sections".
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 00000000000..2b3780b25b5
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,30 @@
+exclude: .github/
+repos:
+ - repo: git://github.com/pre-commit/pre-commit-hooks
+ sha: 'v0.9.1'
+ hooks:
+ - id: check-added-large-files
+ - id: check-docstring-first
+ - id: check-merge-conflict
+ - id: check-yaml
+ - id: check-json
+ - id: debug-statements
+ - id: end-of-file-fixer
+ - id: flake8
+ - id: name-tests-test
+ exclude: 'tests/(integration/testcases\.py|helpers\.py)'
+ - id: requirements-txt-fixer
+ - id: trailing-whitespace
+ - repo: git://github.com/asottile/reorder_python_imports
+ sha: v1.3.4
+ hooks:
+ - id: reorder-python-imports
+ language_version: 'python3.7'
+ args:
+ - --py3-plus
+ - repo: https://github.com/asottile/pyupgrade
+ rev: v2.1.0
+ hooks:
+ - id: pyupgrade
+ args:
+ - --py3-plus
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index a0f73f68254..00000000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,49 +0,0 @@
-# Project: Docker Compose
-
-## Build & Test
-
-- Build: `make build`
-- Test all: `make test`
-- Test unit: `go test ./pkg/...`
-- Test single: `go test ./pkg/compose/ -run TestFunctionName`
-- E2E tests: `go test -tags e2e ./pkg/e2e/ -run TestName`
-
-## Lint
-
-- Linter: golangci-lint v2 (config in `.golangci.yml`)
-- Run: `golangci-lint run --build-tags "e2e" ./...`
-- **After modifying any Go code, ALWAYS run the linter and fix all reported issues before considering the task complete.**
-- Lint is also run via Docker: `docker buildx bake lint` (uses version pinned in `Dockerfile`)
-
-## Code Style
-
-- Formatting is enforced by golangci-lint (gofumpt + gci)
-- Import order: stdlib, third-party, local module (enforced by gci)
-- Max line length: 200 chars
-- Max cyclomatic complexity: 16
-- No `io/ioutil`, `github.com/pkg/errors`, `gopkg.in/yaml.v2`, `golang.org/x/exp/maps`, `golang.org/x/exp/slices`
-- Use `github.com/containerd/errdefs` instead of `github.com/docker/docker/errdefs`
-- In tests: use `t.Context()` instead of `context.Background()` or `context.TODO()`
-- Prefer `fmt.Fprintf` over `WriteString(fmt.Sprintf(...))`
-
-## Git
-
-- **All commits MUST be signed off (DCO)**. Always pass `--signoff` (`-s`) to `git commit` and `git commit --amend`.
-
-## Issue and PR Guidelines
-
-- Never create an issue or pull request without the user's explicit instruction.
-- If a user asks you to create a pull request on their behalf, add a file named
- `AI_AGENT_DISCLOSURE.md` to the change containing the text below followed by
- the current date (`YYYY-MM-DD`). If the file is already present in the
- repository, update its date to the current date so the disclosure is always
- part of the change:
-
- > *"This contribution was prepared by an AI agent acting on a human's behalf.
- > The human submitter may not have independently reviewed or tested the change."*
-
-- If a user asks you to create an issue on their behalf, prepend the following
- line to the issue body:
-
- > *"This issue was filed by an AI agent on a human's behalf. The human
- > submitter may not have independently verified the report."*
diff --git a/BUILDING.md b/BUILDING.md
deleted file mode 100644
index e9861f08140..00000000000
--- a/BUILDING.md
+++ /dev/null
@@ -1,95 +0,0 @@
-
-### Prerequisites
-
-* Windows:
- * [Docker Desktop](https://docs.docker.com/desktop/setup/install/windows-install/)
- * make
- * go (see [go.mod](go.mod) for minimum version)
-* macOS:
- * [Docker Desktop](https://docs.docker.com/desktop/setup/install/mac-install/)
- * make
- * go (see [go.mod](go.mod) for minimum version)
-* Linux:
- * [Docker 20.10 or later](https://docs.docker.com/engine/install/)
- * make
- * go (see [go.mod](go.mod) for minimum version)
-
-### Building the CLI
-
-Once you have the prerequisites installed, you can build the CLI using:
-
-```console
-make
-```
-
-This will output a `docker-compose` CLI plugin for your host machine in
-`./bin/build`.
-
-You can statically cross compile the CLI for Windows, macOS, and Linux using the
-`cross` target.
-
-### Unit tests
-
-To run all of the unit tests, run:
-
-```console
-make test
-```
-
-If you need to update a golden file simply do `go test ./... -test.update-golden`.
-
-### End-to-end tests
-To run e2e tests, the Compose CLI binary needs to be built. All the commands to run e2e tests propose a version
-with the prefix `build-and-e2e` to first build the CLI before executing tests.
-
-Note that this requires a local Docker Engine to be running.
-
-#### Whole end-to-end tests suite
-
-To execute both CLI and standalone e2e tests, run :
-
-```console
-make e2e
-```
-
-Or if you need to build the CLI, run:
-```console
-make build-and-e2e
-```
-
-#### Plugin end-to-end tests suite
-
-To execute CLI plugin e2e tests, run :
-
-```console
-make e2e-compose
-```
-
-Or if you need to build the CLI, run:
-```console
-make build-and-e2e-compose
-```
-
-#### Standalone end-to-end tests suite
-
-To execute the standalone CLI e2e tests, run :
-
-```console
-make e2e-compose-standalone
-```
-
-Or if you need to build the CLI, run:
-
-```console
-make build-and-e2e-compose-standalone
-```
-
-## Releases
-
-To create a new release:
-* Check that the CI is green on the main branch for the commit you want to release
-* Run the release GitHub Actions workflow with a tag of form vx.y.z following existing tags.
-
-This will automatically create a new tag, release and make binaries for
-Windows, macOS, and Linux available for download on the
-[releases page](https://github.com/docker/compose/releases).
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000000..75bc7c7493b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,2712 @@
+Change log
+==========
+
+1.29.2 (2021-05-10)
+-------------------
+
+[List of PRs / issues for this release](https://github.com/docker/compose/milestone/59?closed=1)
+
+### Miscellaneous
+
+- Remove prompt to use `docker compose` in the `up` command
+
+- Bump `py` to `1.10.0` in `requirements-indirect.txt`
+
+1.29.1 (2021-04-13)
+-------------------
+
+[List of PRs / issues for this release](https://github.com/docker/compose/milestone/58?closed=1)
+
+### Bugs
+
+- Fix for invalid handler warning on Windows builds
+
+- Fix config hash to trigger container recreation on IPC mode updates
+
+- Fix conversion map for `placement.max_replicas_per_node`
+
+- Remove extra scan suggestion on build
+
+1.29.0 (2021-04-06)
+-------------------
+
+[List of PRs / issues for this release](https://github.com/docker/compose/milestone/56?closed=1)
+
+### Features
+
+- Add profile filter to `docker-compose config`
+
+- Add a `depends_on` condition to wait for successful service completion
+
+### Miscellaneous
+
+- Add image scan message on build
+
+- Update warning message for `--no-ansi` to mention `--ansi never` as alternative
+
+- Bump docker-py to 5.0.0
+
+- Bump PyYAML to 5.4.1
+
+- Bump python-dotenv to 0.17.0
+
+1.28.6 (2021-03-23)
+-------------------
+
+[List of PRs / issues for this release](https://github.com/docker/compose/milestone/57?closed=1)
+
+### Bugs
+
+- Make `--env-file` relative to the current working directory and error out for invalid paths. Environment file paths set with `--env-file` are relative to the current working directory while the default `.env` file is located in the project directory which by default is the base directory of the Compose file.
+
+- Fix missing service property `storage_opt` by updating the compose schema
+
+- Fix build `extra_hosts` list format
+
+- Remove extra error message on `exec`
+
+### Miscellaneous
+
+- Add `compose.yml` and `compose.yaml` to default filename list
+
+1.28.5 (2021-02-25)
+-------------------
+
+[List of PRs / issues for this release](https://github.com/docker/compose/milestone/55?closed=1)
+
+### Bugs
+
+- Fix OpenSSL version mismatch error when shelling out to the ssh client (via bump to docker-py 4.4.4 which contains the fix)
+
+- Add missing build flags to the native builder: `platform`, `isolation` and `extra_hosts`
+
+- Remove info message on native build
+
+- Avoid fetching logs when service logging driver is set to 'none'
+
+1.28.4 (2021-02-18)
+-------------------
+
+[List of PRs / issues for this release](https://github.com/docker/compose/milestone/54?closed=1)
+
+### Bugs
+
+- Fix SSH port parsing by bumping docker-py to 4.4.3
+
+### Miscellaneous
+
+- Bump Python to 3.7.10
+
+1.28.3 (2021-02-17)
+-------------------
+
+[List of PRs / issues for this release](https://github.com/docker/compose/milestone/53?closed=1)
+
+### Bugs
+
+- Fix SSH hostname parsing when it contains leading s/h, and remove the quiet option that was hiding the error (via docker-py bump to 4.4.2)
+
+- Fix key error for '--no-log-prefix' option
+
+- Fix incorrect CLI environment variable name for service profiles: `COMPOSE_PROFILES` instead of `COMPOSE_PROFILE`
+
+- Fix fish completion
+
+### Miscellaneous
+
+- Bump cryptography to 3.3.2
+
+- Remove log driver filter
+
+1.28.2 (2021-01-26)
+-------------------
+
+### Miscellaneous
+
+- CI setup update
+
+1.28.1 (2021-01-25)
+-------------------
+
+### Bugs
+
+- Revert to Python 3.7 bump for Linux static builds
+
+- Add bash completion for `docker-compose logs|up --no-log-prefix`
+
+1.28.0 (2021-01-20)
+-------------------
+
+### Features
+
+- Support for Nvidia GPUs via device requests
+
+- Support for service profiles
+
+- Change the SSH connection approach to the Docker CLI's via shellout to the local SSH client (old behaviour enabled by setting `COMPOSE_PARAMIKO_SSH` environment variable)
+
+- Add flag to disable log prefix
+
+- Add flag for ansi output control
+
+### Bugs
+
+- Make `parallel_pull=True` by default
+
+- Bring back warning for configs in non-swarm mode
+
+- Take `--file` in account when defining `project_dir`
+
+- On `compose up`, attach only to services we read logs from
+
+### Miscellaneous
+
+- Make COMPOSE_DOCKER_CLI_BUILD=1 the default
+
+- Add usage metrics
+
+- Sync schema with COMPOSE specification
+
+- Improve failure report for missing mandatory environment variables
+
+- Bump attrs to 20.3.0
+
+- Bump more_itertools to 8.6.0
+
+- Bump cryptograhy to 3.2.1
+
+- Bump cffi to 1.14.4
+
+- Bump virtualenv to 20.2.2
+
+- Bump bcrypt to 3.2.0
+
+- Bump gitpython to 3.1.11
+
+- Bump docker-py to 4.4.1
+
+- Bump Python to 3.9
+
+- Linux: bump Debian base image from stretch to buster (required for Python 3.9)
+
+- macOS: OpenSSL 1.1.1g to 1.1.1h, Python 3.7.7 to 3.9.0
+
+- Bump pyinstaller 4.1
+
+- Loosen restriction on base images to latest minor
+
+- Updates of READMEs
+
+
+1.27.4 (2020-09-24)
+-------------------
+
+### Bugs
+
+- Remove path checks for bind mounts
+
+- Fix port rendering to output long form syntax for non-v1
+
+- Add protocol to the docker socket address
+
+1.27.3 (2020-09-16)
+-------------------
+
+### Bugs
+
+- Merge `max_replicas_per_node` on `docker-compose config`
+
+- Fix `depends_on` serialization on `docker-compose config`
+
+- Fix scaling when some containers are not running on `docker-compose up`
+
+- Enable relative paths for `driver_opts.device` for `local` driver
+
+- Allow strings for `cpus` fields
+
+1.27.2 (2020-09-10)
+-------------------
+
+### Bugs
+
+- Fix bug on `docker-compose run` container attach
+
+1.27.1 (2020-09-10)
+-------------------
+
+### Bugs
+
+- Fix `docker-compose run` when `service.scale` is specified
+
+- Allow `driver` property for external networks as temporary workaround for swarm network propagation issue
+
+- Pin new internal schema version to `3.9` as the default
+
+- Preserve the version when configured in the compose file
+
+1.27.0 (2020-09-07)
+-------------------
+
+### Features
+
+- Merge 2.x and 3.x compose formats and align with COMPOSE_SPEC schema
+
+- Implement service mode for ipc
+
+- Pass `COMPOSE_PROJECT_NAME` environment variable in container mode
+
+- Make run behave in the same way as up
+
+- Use `docker build` on `docker-compose run` when `COMPOSE_DOCKER_CLI_BUILD` environment variable is set
+
+- Use docker-py default API version for engine queries (`auto`)
+
+- Parse `network_mode` on build
+
+### Bugs
+
+- Ignore build context path validation when building is not required
+
+- Fix float to bytes conversion via docker-py bump to 4.3.1
+
+- Fix scale bug when deploy section is set
+
+- Fix `docker-py` bump in `setup.py`
+
+- Fix experimental build failure detection
+
+- Fix context propagation to docker cli
+
+### Miscellaneous
+
+- Drop support for Python 2.7
+
+- Bump `docker-py` to 4.3.1
+
+- Bump `tox` to 3.19.0
+
+- Bump `virtualenv` to 20.0.30
+
+- Add script for docs syncronization
+
+1.26.2 (2020-07-02)
+-------------------
+
+### Bugs
+
+- Enforce `docker-py` 4.2.2 as minimum version when installing with pip
+
+1.26.1 (2020-06-30)
+-------------------
+
+### Features
+
+- Bump `docker-py` from 4.2.1 to 4.2.2
+
+### Bugs
+
+- Enforce `docker-py` 4.2.1 as minimum version when installing with pip
+
+- Fix context load for non-docker endpoints
+
+1.26.0 (2020-06-03)
+-------------------
+
+### Features
+
+- Add `docker context` support
+
+- Add missing test dependency `ddt` to `setup.py`
+
+- Add `--attach-dependencies` to command `up` for attaching to dependencies
+
+- Allow compatibility option with `COMPOSE_COMPATIBILITY` environment variable
+
+- Bump `Pytest` to 5.3.4 and add refactor compatibility with new version
+
+- Bump `OpenSSL` from 1.1.1f to 1.1.1g
+
+- Bump `docker-py` from 4.2.0 to 4.2.1
+
+### Bugs
+
+- Properly escape values coming from env_files
+
+- Sync compose-schemas with upstream (docker/cli)
+
+- Remove `None` entries on exec command
+
+- Add `python-dotenv` to delegate `.env` file processing
+
+- Don't adjust output on terminal width when piped into another command
+
+- Show an error message when `version` attribute is malformed
+
+- Fix HTTPS connection when DOCKER_HOST is remote
+
+1.25.5 (2020-02-04)
+-------------------
+
+### Features
+
+- Bump OpenSSL from 1.1.1d to 1.1.1f
+
+- Add 3.8 compose version
+
+1.25.4 (2020-01-23)
+-------------------
+
+### Bugfixes
+
+- Fix CI script to enforce the minimal MacOS version to 10.11
+
+- Fix docker-compose exec for keys with no value
+
+1.25.3 (2020-01-23)
+-------------------
+
+### Bugfixes
+
+- Fix CI script to enforce the compilation with Python3
+
+- Fix binary's sha256 in the release page
+
+1.25.2 (2020-01-20)
+-------------------
+
+### Features
+
+- Allow compatibility option with `COMPOSE_COMPATIBILITY` environment variable
+
+- Bump PyInstaller from 3.5 to 3.6
+
+- Bump pysocks from 1.6.7 to 1.7.1
+
+- Bump websocket-client from 0.32.0 to 0.57.0
+
+- Bump urllib3 from 1.24.2 to 1.25.7
+
+- Bump jsonschema from 3.0.1 to 3.2.0
+
+- Bump PyYAML from 4.2b1 to 5.3
+
+- Bump certifi from 2017.4.17 to 2019.11.28
+
+- Bump coverage from 4.5.4 to 5.0.3
+
+- Bump paramiko from 2.6.0 to 2.7.1
+
+- Bump cached-property from 1.3.0 to 1.5.1
+
+- Bump minor Linux and MacOSX dependencies
+
+### Bugfixes
+
+- Validate version format on formats 2+
+
+- Assume infinite terminal width when not running in a terminal
+
+1.25.1 (2020-01-06)
+-------------------
+
+### Features
+
+- Bump `pytest-cov` 2.8.1
+
+- Bump `flake8` 3.7.9
+
+- Bump `coverage` 4.5.4
+
+### Bugfixes
+
+- Decode APIError explanation to unicode before usage on start and create of a container
+
+- Reports when images that cannot be pulled and must be built
+
+- Discard label `com.docker.compose.filepaths` having None as value. Typically, when coming from stdin
+
+- Added OSX binary as a directory to solve slow start up time caused by MacOS Catalina binary scan
+
+- Passed in HOME env-var in container mode (running with `script/run/run.sh`)
+
+- Reverted behavior of "only pull images that we can't build" and replace by a warning informing the image we can't pull and must be built
+
+
+1.25.0 (2019-11-18)
+-------------------
+
+### Features
+
+- Set no-colors to true if CLICOLOR env variable is set to 0
+
+- Add working dir, config files and env file in service labels
+
+- Add dependencies for ARM build
+
+- Add BuildKit support, use `DOCKER_BUILDKIT=1` and `COMPOSE_DOCKER_CLI_BUILD=1`
+
+- Bump paramiko to 2.6.0
+
+- Add working dir, config files and env file in service labels
+
+- Add tag `docker-compose:latest`
+
+- Add `docker-compose:-alpine` image/tag
+
+- Add `docker-compose:-debian` image/tag
+
+- Bumped `docker-py` 4.1.0
+
+- Supports `requests` up to 2.22.0 version
+
+- Drops empty tag on `build:cache_from`
+
+- `Dockerfile` now generates `libmusl` binaries for alpine
+
+- Only pull images that can't be built
+
+- Attribute `scale` can now accept `0` as a value
+
+- Added `--quiet` build flag
+
+- Added `--no-interpolate` to `docker-compose config`
+
+- Bump OpenSSL for macOS build (`1.1.0j` to `1.1.1c`)
+
+- Added `--no-rm` to `build` command
+
+- Added support for `credential_spec`
+
+- Resolve digests without pulling image
+
+- Upgrade `pyyaml` to `4.2b1`
+
+- Lowered severity to `warning` if `down` tries to remove nonexisting image
+
+- Use improved API fields for project events when possible
+
+- Update `setup.py` for modern `pypi/setuptools` and remove `pandoc` dependencies
+
+- Removed `Dockerfile.armhf` which is no longer needed
+
+### Bugfixes
+
+- Make container service color deterministic, remove red from chosen colors
+
+- Fix non ascii chars error. Python2 only
+
+- Format image size as decimal to be align with Docker CLI
+
+- Use Python Posix support to get tty size
+
+- Fix same file 'extends' optimization
+
+- Use python POSIX support to get tty size
+
+- Format image size as decimal to be align with Docker CLI
+
+- Fixed stdin_open
+
+- Fixed `--remove-orphans` when used with `up --no-start`
+
+- Fixed `docker-compose ps --all`
+
+- Fixed `depends_on` dependency recreation behavior
+
+- Fixed bash completion for `build --memory`
+
+- Fixed misleading warning concerning env vars when performing an `exec` command
+
+- Fixed failure check in parallel_execute_watch
+
+- Fixed race condition after pulling image
+
+- Fixed error on duplicate mount points
+
+- Fixed merge on networks section
+
+- Always connect Compose container to `stdin`
+
+- Fixed the presentation of failed services on 'docker-compose start' when containers are not available
+
+1.24.1 (2019-06-24)
+-------------------
+
+### Bugfixes
+
+- Fixed acceptance tests
+
+1.24.0 (2019-03-28)
+-------------------
+
+### Features
+
+- Added support for connecting to the Docker Engine using the `ssh` protocol.
+
+- Added a `--all` flag to `docker-compose ps` to include stopped one-off containers
+ in the command's output.
+
+- Add bash completion for `ps --all|-a`
+
+- Support for credential_spec
+
+- Add `--parallel` to `docker build`'s options in `bash` and `zsh` completion
+
+### Bugfixes
+
+- Fixed a bug where some valid credential helpers weren't properly handled by Compose
+ when attempting to pull images from private registries.
+
+- Fixed an issue where the output of `docker-compose start` before containers were created
+ was misleading
+
+- To match the Docker CLI behavior and to avoid confusing issues, Compose will no longer
+ accept whitespace in variable names sourced from environment files.
+
+- Compose will now report a configuration error if a service attempts to declare
+ duplicate mount points in the volumes section.
+
+- Fixed an issue with the containerized version of Compose that prevented users from
+ writing to stdin during interactive sessions started by `run` or `exec`.
+
+- One-off containers started by `run` no longer adopt the restart policy of the service,
+ and are instead set to never restart.
+
+- Fixed an issue that caused some container events to not appear in the output of
+ the `docker-compose events` command.
+
+- Missing images will no longer stop the execution of `docker-compose down` commands
+ (a warning will be displayed instead).
+
+- Force `virtualenv` version for macOS CI
+
+- Fix merging of compose files when network has `None` config
+
+- Fix `CTRL+C` issues by enabling `bootloader_ignore_signals` in `pyinstaller`
+
+- Bump `docker-py` version to `3.7.2` to fix SSH and proxy config issues
+
+- Fix release script and some typos on release documentation
+
+1.23.2 (2018-11-28)
+-------------------
+
+### Bugfixes
+
+- Reverted a 1.23.0 change that appended random strings to container names
+ created by `docker-compose up`, causing addressability issues.
+ Note: Containers created by `docker-compose run` will continue to use
+ randomly generated names to avoid collisions during parallel runs.
+
+- Fixed an issue where some `dockerfile` paths would fail unexpectedly when
+ attempting to build on Windows.
+
+- Fixed a bug where build context URLs would fail to build on Windows.
+
+- Fixed a bug that caused `run` and `exec` commands to fail for some otherwise
+ accepted values of the `--host` parameter.
+
+- Fixed an issue where overrides for the `storage_opt` and `isolation` keys in
+ service definitions weren't properly applied.
+
+- Fixed a bug where some invalid Compose files would raise an uncaught
+ exception during validation.
+
+1.23.1 (2018-11-01)
+-------------------
+
+### Bugfixes
+
+- Fixed a bug where working with containers created with a previous (< 1.23.0)
+ version of Compose would cause unexpected crashes
+
+- Fixed an issue where the behavior of the `--project-directory` flag would
+ vary depending on which subcommand was being used.
+
+1.23.0 (2018-10-30)
+-------------------
+
+### Important note
+
+The default naming scheme for containers created by Compose in this version
+has changed from `__` to
+`___`, where `` is a randomly-generated
+hexadecimal string. Please make sure to update scripts relying on the old
+naming scheme accordingly before upgrading.
+
+### Features
+
+- Logs for containers restarting after a crash will now appear in the output
+ of the `up` and `logs` commands.
+
+- Added `--hash` option to the `docker-compose config` command, allowing users
+ to print a hash string for each service's configuration to facilitate rolling
+ updates.
+
+- Added `--parallel` flag to the `docker-compose build` command, allowing
+ Compose to build up to 5 images simultaneously.
+
+- Output for the `pull` command now reports status / progress even when pulling
+ multiple images in parallel.
+
+- For images with multiple names, Compose will now attempt to match the one
+ present in the service configuration in the output of the `images` command.
+
+### Bugfixes
+
+- Parallel `run` commands for the same service will no longer fail due to name
+ collisions.
+
+- Fixed an issue where paths longer than 260 characters on Windows clients would
+ cause `docker-compose build` to fail.
+
+- Fixed a bug where attempting to mount `/var/run/docker.sock` with
+ Docker Desktop for Windows would result in failure.
+
+- The `--project-directory` option is now used by Compose to determine where to
+ look for the `.env` file.
+
+- `docker-compose build` no longer fails when attempting to pull an image with
+ credentials provided by the gcloud credential helper.
+
+- Fixed the `--exit-code-from` option in `docker-compose up` to always report
+ the actual exit code even when the watched container isn't the cause of the
+ exit.
+
+- Fixed an issue that would prevent recreating a service in some cases where
+ a volume would be mapped to the same mountpoint as a volume declared inside
+ the image's Dockerfile.
+
+- Fixed a bug that caused hash configuration with multiple networks to be
+ inconsistent, causing some services to be unnecessarily restarted.
+
+- Fixed a bug that would cause failures with variable substitution for services
+ with a name containing one or more dot characters
+
+- Fixed a pipe handling issue when using the containerized version of Compose.
+
+- Fixed a bug causing `external: false` entries in the Compose file to be
+ printed as `external: true` in the output of `docker-compose config`
+
+- Fixed a bug where issuing a `docker-compose pull` command on services
+ without a defined image key would cause Compose to crash
+
+- Volumes and binds are now mounted in the order they're declared in the
+ service definition
+
+### Miscellaneous
+
+- The `zsh` completion script has been updated with new options, and no
+ longer suggests container names where service names are expected.
+
+1.22.0 (2018-07-17)
+-------------------
+
+### Features
+
+#### Compose format version 3.7
+
+- Introduced version 3.7 of the `docker-compose.yml` specification.
+ This version requires Docker Engine 18.06.0 or above.
+
+- Added support for `rollback_config` in the deploy configuration
+
+- Added support for the `init` parameter in service configurations
+
+- Added support for extension fields in service, network, volume, secret,
+ and config configurations
+
+#### Compose format version 2.4
+
+- Added support for extension fields in service, network,
+ and volume configurations
+
+### Bugfixes
+
+- Fixed a bug that prevented deployment with some Compose files when
+ `DOCKER_DEFAULT_PLATFORM` was set
+
+- Compose will no longer try to create containers or volumes with
+ invalid starting characters
+
+- Fixed several bugs that prevented Compose commands from working properly
+ with containers created with an older version of Compose
+
+- Fixed an issue with the output of `docker-compose config` with the
+ `--compatibility-mode` flag enabled when the source file contains
+ attachable networks
+
+- Fixed a bug that prevented the `gcloud` credential store from working
+ properly when used with the Compose binary on UNIX
+
+- Fixed a bug that caused connection errors when trying to operate
+ over a non-HTTPS TCP connection on Windows
+
+- Fixed a bug that caused builds to fail on Windows if the Dockerfile
+ was located in a subdirectory of the build context
+
+- Fixed an issue that prevented proper parsing of UTF-8 BOM encoded
+ Compose files on Windows
+
+- Fixed an issue with handling of the double-wildcard (`**`) pattern in `.dockerignore` files when using `docker-compose build`
+
+- Fixed a bug that caused auth values in legacy `.dockercfg` files to be ignored
+- `docker-compose build` will no longer attempt to create image names starting with an invalid character
+
+1.21.2 (2018-05-03)
+-------------------
+
+### Bugfixes
+
+- Fixed a bug where the ip_range attribute in IPAM configs was prevented
+ from passing validation
+
+1.21.1 (2018-04-27)
+-------------------
+
+### Bugfixes
+
+- In 1.21.0, we introduced a change to how project names are sanitized for
+ internal use in resource names. This caused issues when manipulating an
+ existing, deployed application whose name had changed as a result.
+ This release properly detects resources using "legacy" naming conventions.
+
+- Fixed an issue where specifying an in-context Dockerfile using an absolute
+ path would fail despite being valid.
+
+- Fixed a bug where IPAM option changes were incorrectly detected, preventing
+ redeployments.
+
+- Validation of v2 files now properly checks the structure of IPAM configs.
+
+- Improved support for credentials stores on Windows to include binaries using
+ extensions other than `.exe`. The list of valid extensions is determined by
+ the contents of the `PATHEXT` environment variable.
+
+- Fixed a bug where Compose would generate invalid binds containing duplicate
+ elements with some v3.2 files, triggering errors at the Engine level during
+ deployment.
+
+1.21.0 (2018-04-10)
+-------------------
+
+### New features
+
+#### Compose file version 2.4
+
+- Introduced version 2.4 of the `docker-compose.yml` specification.
+ This version requires Docker Engine 17.12.0 or above.
+
+- Added support for the `platform` parameter in service definitions.
+ If supplied, the parameter is also used when performing build for the
+ service.
+
+#### Compose file version 2.2 and up
+
+- Added support for the `cpu_rt_period` and `cpu_rt_runtime` parameters
+ in service definitions (2.x only).
+
+#### Compose file version 2.1 and up
+
+- Added support for the `cpu_period` parameter in service definitions
+ (2.x only).
+
+- Added support for the `isolation` parameter in service build configurations.
+ Additionally, the `isolation` parameter is used for builds as well if no
+ `build.isolation` parameter is defined. (2.x only)
+
+#### All formats
+
+- Added support for the `--workdir` flag in `docker-compose exec`.
+
+- Added support for the `--compress` flag in `docker-compose build`.
+
+- `docker-compose pull` is now performed in parallel by default. You can
+ opt out using the `--no-parallel` flag. The `--parallel` flag is now
+ deprecated and will be removed in a future version.
+
+- Dashes and underscores in project names are no longer stripped out.
+
+- `docker-compose build` now supports the use of Dockerfile from outside
+ the build context.
+
+### Bugfixes
+
+- Compose now checks that the volume's configuration matches the remote
+ volume, and errors out if a mismatch is detected.
+
+- Fixed a bug that caused Compose to raise unexpected errors when attempting
+ to create several one-off containers in parallel.
+
+- Fixed a bug with argument parsing when using `docker-machine config` to
+ generate TLS flags for `exec` and `run` commands.
+
+- Fixed a bug where variable substitution with an empty default value
+ (e.g. `${VAR:-}`) would print an incorrect warning.
+
+- Improved resilience when encoding of the Compose file doesn't match the
+ system's. Users are encouraged to use UTF-8 when possible.
+
+- Fixed a bug where external overlay networks in Swarm would be incorrectly
+ recognized as inexistent by Compose, interrupting otherwise valid
+ operations.
+
+1.20.1 (2018-03-21)
+-------------------
+
+### Bugfixes
+
+- Fixed an issue where `docker-compose build` would error out if the
+ build context contained directory symlinks
+
+1.20.0 (2018-03-20)
+-------------------
+
+### New features
+
+#### Compose file version 3.6
+
+- Introduced version 3.6 of the `docker-compose.yml` specification.
+ This version requires Docker Engine 18.02.0 or above.
+
+- Added support for the `tmpfs.size` property in volume mappings
+
+#### Compose file version 3.2 and up
+
+- The `--build-arg` option can now be used without specifying a service
+ in `docker-compose build`
+
+#### Compose file version 2.3
+
+- Added support for `device_cgroup_rules` in service definitions
+
+- Added support for the `tmpfs.size` property in long-form volume mappings
+
+- The `--build-arg` option can now be used without specifying a service
+ in `docker-compose build`
+
+#### All formats
+
+- Added a `--log-level` option to the top-level `docker-compose` command.
+ Accepted values are `debug`, `info`, `warning`, `error`, `critical`.
+ Default log level is `info`
+
+- `docker-compose run` now allows users to unset the container's entrypoint
+
+- Proxy configuration found in the `~/.docker/config.json` file now populates
+ environment and build args for containers created by Compose
+
+- Added the `--use-aliases` flag to `docker-compose run`, indicating that
+ network aliases declared in the service's config should be used for the
+ running container
+
+- Added the `--include-deps` flag to `docker-compose pull`
+
+- `docker-compose run` now kills and removes the running container upon
+ receiving `SIGHUP`
+
+- `docker-compose ps` now shows the containers' health status if available
+
+- Added the long-form `--detach` option to the `exec`, `run` and `up`
+ commands
+
+### Bugfixes
+
+- Fixed `.dockerignore` handling, notably with regard to absolute paths
+ and last-line precedence rules
+
+- Fixed an issue where Compose would make costly DNS lookups when connecting
+ to the Engine when using Docker For Mac
+
+- Fixed a bug introduced in 1.19.0 which caused the default certificate path
+ to not be honored by Compose
+
+- Fixed a bug where Compose would incorrectly check whether a symlink's
+ destination was accessible when part of a build context
+
+- Fixed a bug where `.dockerignore` files containing lines of whitespace
+ caused Compose to error out on Windows
+
+- Fixed a bug where `--tls*` and `--host` options wouldn't be properly honored
+ for interactive `run` and `exec` commands
+
+- A `seccomp:` entry in the `security_opt` config now correctly
+ sends the contents of the file to the engine
+
+- ANSI output for `up` and `down` operations should no longer affect the wrong
+ lines
+
+- Improved support for non-unicode locales
+
+- Fixed a crash occurring on Windows when the user's home directory name
+ contained non-ASCII characters
+
+- Fixed a bug occurring during builds caused by files with a negative `mtime`
+ values in the build context
+
+- Fixed an encoding bug when streaming build progress
+
+1.19.0 (2018-02-07)
+-------------------
+
+### Breaking changes
+
+- On UNIX platforms, interactive `run` and `exec` commands now require
+ the `docker` CLI to be installed on the client by default. To revert
+ to the previous behavior, users may set the `COMPOSE_INTERACTIVE_NO_CLI`
+ environment variable.
+
+### New features
+
+#### Compose file version 3.x
+
+- The output of the `config` command should now merge `deploy` options from
+ several Compose files in a more accurate manner
+
+#### Compose file version 2.3
+
+- Added support for the `runtime` option in service definitions
+
+#### Compose file version 2.1 and up
+
+- Added support for the `${VAR:?err}` and `${VAR?err}` variable interpolation
+ syntax to indicate mandatory variables
+
+#### Compose file version 2.x
+
+- Added `priority` key to service network mappings, allowing the user to
+ define in which order the specified service will connect to each network
+
+#### All formats
+
+- Added `--renew-anon-volumes` (shorthand `-V`) to the `up` command,
+ preventing Compose from recovering volume data from previous containers for
+ anonymous volumes
+
+- Added limit for number of simultaneous parallel operations, which should
+ prevent accidental resource exhaustion of the server. Default is 64 and
+ can be configured using the `COMPOSE_PARALLEL_LIMIT` environment variable
+
+- Added `--always-recreate-deps` flag to the `up` command to force recreating
+ dependent services along with the dependency owner
+
+- Added `COMPOSE_IGNORE_ORPHANS` environment variable to forgo orphan
+ container detection and suppress warnings
+
+- Added `COMPOSE_FORCE_WINDOWS_HOST` environment variable to force Compose
+ to parse volume definitions as if the Docker host was a Windows system,
+ even if Compose itself is currently running on UNIX
+
+- Bash completion should now be able to better differentiate between running,
+ stopped and paused services
+
+### Bugfixes
+
+- Fixed a bug that would cause the `build` command to report a connection
+ error when the build context contained unreadable files or FIFO objects.
+ These file types will now be handled appropriately
+
+- Fixed various issues around interactive `run`/`exec` sessions.
+
+- Fixed a bug where setting TLS options with environment and CLI flags
+ simultaneously would result in part of the configuration being ignored
+
+- Fixed a bug where the DOCKER_TLS_VERIFY environment variable was being
+ ignored by Compose
+
+- Fixed a bug where the `-d` and `--timeout` flags in `up` were erroneously
+ marked as incompatible
+
+- Fixed a bug where the recreation of a service would break if the image
+ associated with the previous container had been removed
+
+- Fixed a bug where updating a mount's target would break Compose when
+ trying to recreate the associated service
+
+- Fixed a bug where `tmpfs` volumes declared using the extended syntax in
+ Compose files using version 3.2 would be erroneously created as anonymous
+ volumes instead
+
+- Fixed a bug where type conversion errors would print a stacktrace instead
+ of exiting gracefully
+
+- Fixed some errors related to unicode handling
+
+- Dependent services no longer get recreated along with the dependency owner
+ if their configuration hasn't changed
+
+- Added better validation of `labels` fields in Compose files. Label values
+ containing scalar types (number, boolean) now get automatically converted
+ to strings
+
+1.18.0 (2017-12-15)
+-------------------
+
+### New features
+
+#### Compose file version 3.5
+
+- Introduced version 3.5 of the `docker-compose.yml` specification.
+ This version requires Docker Engine 17.06.0 or above
+
+- Added support for the `shm_size` parameter in build configurations
+
+- Added support for the `isolation` parameter in service definitions
+
+- Added support for custom names for network, secret and config definitions
+
+#### Compose file version 2.3
+
+- Added support for `extra_hosts` in build configuration
+
+- Added support for the [long syntax](https://docs.docker.com/compose/compose-file/#long-syntax-3) for volume entries, as previously introduced in the 3.2 format.
+ Note that using this syntax will create [mounts](https://docs.docker.com/engine/admin/volumes/bind-mounts/) instead of volumes.
+
+#### Compose file version 2.1 and up
+
+- Added support for the `oom_kill_disable` parameter in service definitions
+ (2.x only)
+
+- Added support for custom names for network definitions (2.x only)
+
+
+#### All formats
+
+- Values interpolated from the environment will now be converted to the
+ proper type when used in non-string fields.
+
+- Added support for `--label` in `docker-compose run`
+
+- Added support for `--timeout` in `docker-compose down`
+
+- Added support for `--memory` in `docker-compose build`
+
+- Setting `stop_grace_period` in service definitions now also sets the
+ container's `stop_timeout`
+
+### Bugfixes
+
+- Fixed an issue where Compose was still handling service hostname according
+ to legacy engine behavior, causing hostnames containing dots to be cut up
+
+- Fixed a bug where the `X-Y:Z` syntax for ports was considered invalid
+ by Compose
+
+- Fixed an issue with CLI logging causing duplicate messages and inelegant
+ output to occur
+
+- Fixed an issue that caused `stop_grace_period` to be ignored when using
+ multiple Compose files
+
+- Fixed a bug that caused `docker-compose images` to crash when using
+ untagged images
+
+- Fixed a bug where the valid `${VAR:-}` syntax would cause Compose to
+ error out
+
+- Fixed a bug where `env_file` entries using an UTF-8 BOM were being read
+ incorrectly
+
+- Fixed a bug where missing secret files would generate an empty directory
+ in their place
+
+- Fixed character encoding issues in the CLI's error handlers
+
+- Added validation for the `test` field in healthchecks
+
+- Added validation for the `subnet` field in IPAM configurations
+
+- Added validation for `volumes` properties when using the long syntax in
+ service definitions
+
+- The CLI now explicit prevents using `-d` and `--timeout` together
+ in `docker-compose up`
+
+1.17.1 (2017-11-08)
+------------------
+
+### Bugfixes
+
+- Fixed a bug that would prevent creating new containers when using
+ container labels in the list format as part of the service's definition.
+
+1.17.0 (2017-11-02)
+-------------------
+
+### New features
+
+#### Compose file version 3.4
+
+- Introduced version 3.4 of the `docker-compose.yml` specification.
+ This version requires to be used with Docker Engine 17.06.0 or above.
+
+- Added support for `cache_from`, `network` and `target` options in build
+ configurations
+
+- Added support for the `order` parameter in the `update_config` section
+
+- Added support for setting a custom name in volume definitions using
+ the `name` parameter
+
+#### Compose file version 2.3
+
+- Added support for `shm_size` option in build configuration
+
+#### Compose file version 2.x
+
+- Added support for extension fields (`x-*`). Also available for v3.4 files
+
+#### All formats
+
+- Added new `--no-start` to the `up` command, allowing users to create all
+ resources (networks, volumes, containers) without starting services.
+ The `create` command is deprecated in favor of this new option
+
+### Bugfixes
+
+- Fixed a bug where `extra_hosts` values would be overridden by extension
+ files instead of merging together
+
+- Fixed a bug where the validation for v3.2 files would prevent using the
+ `consistency` field in service volume definitions
+
+- Fixed a bug that would cause a crash when configuration fields expecting
+ unique items would contain duplicates
+
+- Fixed a bug where mount overrides with a different mode would create a
+ duplicate entry instead of overriding the original entry
+
+- Fixed a bug where build labels declared as a list wouldn't be properly
+ parsed
+
+- Fixed a bug where the output of `docker-compose config` would be invalid
+ for some versions if the file contained custom-named external volumes
+
+- Improved error handling when issuing a build command on Windows using an
+ unsupported file version
+
+- Fixed an issue where networks with identical names would sometimes be
+ created when running `up` commands concurrently.
+
+1.16.1 (2017-09-01)
+-------------------
+
+### Bugfixes
+
+- Fixed bug that prevented using `extra_hosts` in several configuration files.
+
+1.16.0 (2017-08-31)
+-------------------
+
+### New features
+
+#### Compose file version 2.3
+
+- Introduced version 2.3 of the `docker-compose.yml` specification.
+ This version requires to be used with Docker Engine 17.06.0 or above.
+
+- Added support for the `target` parameter in build configurations
+
+- Added support for the `start_period` parameter in healthcheck
+ configurations
+
+#### Compose file version 2.x
+
+- Added support for the `blkio_config` parameter in service definitions
+
+- Added support for setting a custom name in volume definitions using
+ the `name` parameter (not available for version 2.0)
+
+#### All formats
+
+- Added new CLI flag `--no-ansi` to suppress ANSI control characters in
+ output
+
+### Bugfixes
+
+- Fixed a bug where nested `extends` instructions weren't resolved
+ properly, causing "file not found" errors
+
+- Fixed several issues with `.dockerignore` parsing
+
+- Fixed issues where logs of TTY-enabled services were being printed
+ incorrectly and causing `MemoryError` exceptions
+
+- Fixed a bug where printing application logs would sometimes be interrupted
+ by a `UnicodeEncodeError` exception on Python 3
+
+- The `$` character in the output of `docker-compose config` is now
+ properly escaped
+
+- Fixed a bug where running `docker-compose top` would sometimes fail
+ with an uncaught exception
+
+- Fixed a bug where `docker-compose pull` with the `--parallel` flag
+ would return a `0` exit code when failing
+
+- Fixed an issue where keys in `deploy.resources` were not being validated
+
+- Fixed an issue where the `logging` options in the output of
+ `docker-compose config` would be set to `null`, an invalid value
+
+- Fixed the output of the `docker-compose images` command when an image
+ would come from a private repository using an explicit port number
+
+- Fixed the output of `docker-compose config` when a port definition used
+ `0` as the value for the published port
+
+1.15.0 (2017-07-26)
+-------------------
+
+### New features
+
+#### Compose file version 2.2
+
+- Added support for the `network` parameter in build configurations.
+
+#### Compose file version 2.1 and up
+
+- The `pid` option in a service's definition now supports a `service:`
+ value.
+
+- Added support for the `storage_opt` parameter in in service definitions.
+ This option is not available for the v3 format
+
+#### All formats
+
+- Added `--quiet` flag to `docker-compose pull`, suppressing progress output
+
+- Some improvements to CLI output
+
+### Bugfixes
+
+- Volumes specified through the `--volume` flag of `docker-compose run` now
+ complement volumes declared in the service's definition instead of replacing
+ them
+
+- Fixed a bug where using multiple Compose files would unset the scale value
+ defined inside the Compose file.
+
+- Fixed an issue where the `credHelpers` entries in the `config.json` file
+ were not being honored by Compose
+
+- Fixed a bug where using multiple Compose files with port declarations
+ would cause failures in Python 3 environments
+
+- Fixed a bug where some proxy-related options present in the user's
+ environment would prevent Compose from running
+
+- Fixed an issue where the output of `docker-compose config` would be invalid
+ if the original file used `Y` or `N` values
+
+- Fixed an issue preventing `up` operations on a previously created stack on
+ Windows Engine.
+
+1.14.0 (2017-06-19)
+-------------------
+
+### New features
+
+#### Compose file version 3.3
+
+- Introduced version 3.3 of the `docker-compose.yml` specification.
+ This version requires to be used with Docker Engine 17.06.0 or above.
+ Note: the `credential_spec` and `configs` keys only apply to Swarm services
+ and will be ignored by Compose
+
+#### Compose file version 2.2
+
+- Added the following parameters in service definitions: `cpu_count`,
+ `cpu_percent`, `cpus`
+
+#### Compose file version 2.1
+
+- Added support for build labels. This feature is also available in the
+ 2.2 and 3.3 formats.
+
+#### All formats
+
+- Added shorthand `-u` for `--user` flag in `docker-compose exec`
+
+- Differences in labels between the Compose file and remote network
+ will now print a warning instead of preventing redeployment.
+
+### Bugfixes
+
+- Fixed a bug where service's dependencies were being rescaled to their
+ default scale when running a `docker-compose run` command
+
+- Fixed a bug where `docker-compose rm` with the `--stop` flag was not
+ behaving properly when provided with a list of services to remove
+
+- Fixed a bug where `cache_from` in the build section would be ignored when
+ using more than one Compose file.
+
+- Fixed a bug that prevented binding the same port to different IPs when
+ using more than one Compose file.
+
+- Fixed a bug where override files would not be picked up by Compose if they
+ had the `.yaml` extension
+
+- Fixed a bug on Windows Engine where networks would be incorrectly flagged
+ for recreation
+
+- Fixed a bug where services declaring ports would cause crashes on some
+ versions of Python 3
+
+- Fixed a bug where the output of `docker-compose config` would sometimes
+ contain invalid port definitions
+
+1.13.0 (2017-05-02)
+-------------------
+
+### Breaking changes
+
+- `docker-compose up` now resets a service's scaling to its default value.
+ You can use the newly introduced `--scale` option to specify a custom
+ scale value
+
+### New features
+
+#### Compose file version 2.2
+
+- Introduced version 2.2 of the `docker-compose.yml` specification. This
+ version requires to be used with Docker Engine 1.13.0 or above
+
+- Added support for `init` in service definitions.
+
+- Added support for `scale` in service definitions. The configuration's value
+ can be overridden using the `--scale` flag in `docker-compose up`.
+ Please note that the `scale` command is disabled for this file format
+
+#### Compose file version 2.x
+
+- Added support for `options` in the `ipam` section of network definitions
+
+### Bugfixes
+
+- Fixed a bug where paths provided to compose via the `-f` option were not
+ being resolved properly
+
+- Fixed a bug where the `ext_ip::target_port` notation in the ports section
+ was incorrectly marked as invalid
+
+- Fixed an issue where the `exec` command would sometimes not return control
+ to the terminal when using the `-d` flag
+
+- Fixed a bug where secrets were missing from the output of the `config`
+ command for v3.2 files
+
+- Fixed an issue where `docker-compose` would hang if no internet connection
+ was available
+
+- Fixed an issue where paths containing unicode characters passed via the `-f`
+ flag were causing Compose to crash
+
+- Fixed an issue where the output of `docker-compose config` would be invalid
+ if the Compose file contained external secrets
+
+- Fixed a bug where using `--exit-code-from` with `up` would fail if Compose
+ was installed in a Python 3 environment
+
+- Fixed a bug where recreating containers using a combination of `tmpfs` and
+ `volumes` would result in an invalid config state
+
+
+1.12.0 (2017-04-04)
+-------------------
+
+### New features
+
+#### Compose file version 3.2
+
+- Introduced version 3.2 of the `docker-compose.yml` specification
+
+- Added support for `cache_from` in the `build` section of services
+
+- Added support for the new expanded ports syntax in service definitions
+
+- Added support for the new expanded volumes syntax in service definitions
+
+#### Compose file version 2.1
+
+- Added support for `pids_limit` in service definitions
+
+#### Compose file version 2.0 and up
+
+- Added `--volumes` option to `docker-compose config` that lists named
+ volumes declared for that project
+
+- Added support for `mem_reservation` in service definitions (2.x only)
+
+- Added support for `dns_opt` in service definitions (2.x only)
+
+#### All formats
+
+- Added a new `docker-compose images` command that lists images used by
+ the current project's containers
+
+- Added a `--stop` (shorthand `-s`) option to `docker-compose rm` that stops
+ the running containers before removing them
+
+- Added a `--resolve-image-digests` option to `docker-compose config` that
+ pins the image version for each service to a permanent digest
+
+- Added a `--exit-code-from SERVICE` option to `docker-compose up`. When
+ used, `docker-compose` will exit on any container's exit with the code
+ corresponding to the specified service's exit code
+
+- Added a `--parallel` option to `docker-compose pull` that enables images
+ for multiple services to be pulled simultaneously
+
+- Added a `--build-arg` option to `docker-compose build`
+
+- Added a `--volume ` (shorthand `-v`) option to
+ `docker-compose run` to declare runtime volumes to be mounted
+
+- Added a `--project-directory PATH` option to `docker-compose` that will
+ affect path resolution for the project
+
+- When using `--abort-on-container-exit` in `docker-compose up`, the exit
+ code for the container that caused the abort will be the exit code of
+ the `docker-compose up` command
+
+- Users can now configure which path separator character they want to use
+ to separate the `COMPOSE_FILE` environment value using the
+ `COMPOSE_PATH_SEPARATOR` environment variable
+
+- Added support for port range to single port in port mappings
+ (e.g. `8000-8010:80`)
+
+### Bugfixes
+
+- `docker-compose run --rm` now removes anonymous volumes after execution,
+ matching the behavior of `docker run --rm`.
+
+- Fixed a bug where override files containing port lists would cause a
+ TypeError to be raised
+
+- Fixed a bug where the `deploy` key would be missing from the output of
+ `docker-compose config`
+
+- Fixed a bug where scaling services up or down would sometimes re-use
+ obsolete containers
+
+- Fixed a bug where the output of `docker-compose config` would be invalid
+ if the project declared anonymous volumes
+
+- Variable interpolation now properly occurs in the `secrets` section of
+ the Compose file
+
+- The `secrets` section now properly appears in the output of
+ `docker-compose config`
+
+- Fixed a bug where changes to some networks properties would not be
+ detected against previously created networks
+
+- Fixed a bug where `docker-compose` would crash when trying to write into
+ a closed pipe
+
+- Fixed an issue where Compose would not pick up on the value of
+ COMPOSE_TLS_VERSION when used in combination with command-line TLS flags
+
+1.11.2 (2017-02-17)
+-------------------
+
+### Bugfixes
+
+- Fixed a bug that was preventing secrets configuration from being
+ loaded properly
+
+- Fixed a bug where the `docker-compose config` command would fail
+ if the config file contained secrets definitions
+
+- Fixed an issue where Compose on some linux distributions would
+ pick up and load an outdated version of the requests library
+
+- Fixed an issue where socket-type files inside a build folder
+ would cause `docker-compose` to crash when trying to build that
+ service
+
+- Fixed an issue where recursive wildcard patterns `**` were not being
+ recognized in `.dockerignore` files.
+
+1.11.1 (2017-02-09)
+-------------------
+
+### Bugfixes
+
+- Fixed a bug where the 3.1 file format was not being recognized as valid
+ by the Compose parser
+
+1.11.0 (2017-02-08)
+-------------------
+
+### New Features
+
+#### Compose file version 3.1
+
+- Introduced version 3.1 of the `docker-compose.yml` specification. This
+ version requires Docker Engine 1.13.0 or above. It introduces support
+ for secrets. See the documentation for more information
+
+#### Compose file version 2.0 and up
+
+- Introduced the `docker-compose top` command that displays processes running
+ for the different services managed by Compose.
+
+### Bugfixes
+
+- Fixed a bug where extending a service defining a healthcheck dictionary
+ would cause `docker-compose` to error out.
+
+- Fixed an issue where the `pid` entry in a service definition was being
+ ignored when using multiple Compose files.
+
+1.10.1 (2017-02-01)
+------------------
+
+### Bugfixes
+
+- Fixed an issue where presence of older versions of the docker-py
+ package would cause unexpected crashes while running Compose
+
+- Fixed an issue where healthcheck dependencies would be lost when
+ using multiple compose files for a project
+
+- Fixed a few issues that made the output of the `config` command
+ invalid
+
+- Fixed an issue where adding volume labels to v3 Compose files would
+ result in an error
+
+- Fixed an issue on Windows where build context paths containing unicode
+ characters were being improperly encoded
+
+- Fixed a bug where Compose would occasionally crash while streaming logs
+ when containers would stop or restart
+
+1.10.0 (2017-01-18)
+-------------------
+
+### New Features
+
+#### Compose file version 3.0
+
+- Introduced version 3.0 of the `docker-compose.yml` specification. This
+ version requires to be used with Docker Engine 1.13 or above and is
+ specifically designed to work with the `docker stack` commands.
+
+#### Compose file version 2.1 and up
+
+- Healthcheck configuration can now be done in the service definition using
+ the `healthcheck` parameter
+
+- Containers dependencies can now be set up to wait on positive healthchecks
+ when declared using `depends_on`. See the documentation for the updated
+ syntax.
+ **Note:** This feature will not be ported to version 3 Compose files.
+
+- Added support for the `sysctls` parameter in service definitions
+
+- Added support for the `userns_mode` parameter in service definitions
+
+- Compose now adds identifying labels to networks and volumes it creates
+
+#### Compose file version 2.0 and up
+
+- Added support for the `stop_grace_period` option in service definitions.
+
+### Bugfixes
+
+- Colored output now works properly on Windows.
+
+- Fixed a bug where docker-compose run would fail to set up link aliases
+ in interactive mode on Windows.
+
+- Networks created by Compose are now always made attachable
+ (Compose files v2.1 and up).
+
+- Fixed a bug where falsy values of `COMPOSE_CONVERT_WINDOWS_PATHS`
+ (`0`, `false`, empty value) were being interpreted as true.
+
+- Fixed a bug where forward slashes in some .dockerignore patterns weren't
+ being parsed correctly on Windows
+
+
+1.9.0 (2016-11-16)
+-----------------
+
+**Breaking changes**
+
+- When using Compose with Docker Toolbox/Machine on Windows, volume paths are
+ no longer converted from `C:\Users` to `/c/Users`-style by default. To
+ re-enable this conversion so that your volumes keep working, set the
+ environment variable `COMPOSE_CONVERT_WINDOWS_PATHS=1`. Users of
+ Docker for Windows are not affected and do not need to set the variable.
+
+New Features
+
+- Interactive mode for `docker-compose run` and `docker-compose exec` is
+ now supported on Windows platforms. Please note that the `docker` binary
+ is required to be present on the system for this feature to work.
+
+- Introduced version 2.1 of the `docker-compose.yml` specification. This
+ version requires to be used with Docker Engine 1.12 or above.
+ - Added support for setting volume labels and network labels in
+ `docker-compose.yml`.
+ - Added support for the `isolation` parameter in service definitions.
+ - Added support for link-local IPs in the service networks definitions.
+ - Added support for shell-style inline defaults in variable interpolation.
+ The supported forms are `${FOO-default}` (fall back if FOO is unset) and
+ `${FOO:-default}` (fall back if FOO is unset or empty).
+
+- Added support for the `group_add` and `oom_score_adj` parameters in
+ service definitions.
+
+- Added support for the `internal` and `enable_ipv6` parameters in network
+ definitions.
+
+- Compose now defaults to using the `npipe` protocol on Windows.
+
+- Overriding a `logging` configuration will now properly merge the `options`
+ mappings if the `driver` values do not conflict.
+
+Bug Fixes
+
+- Fixed several bugs related to `npipe` protocol support on Windows.
+
+- Fixed an issue with Windows paths being incorrectly converted when
+ using Docker on Windows Server.
+
+- Fixed a bug where an empty `restart` value would sometimes result in an
+ exception being raised.
+
+- Fixed an issue where service logs containing unicode characters would
+ sometimes cause an error to occur.
+
+- Fixed a bug where unicode values in environment variables would sometimes
+ raise a unicode exception when retrieved.
+
+- Fixed an issue where Compose would incorrectly detect a configuration
+ mismatch for overlay networks.
+
+
+1.8.1 (2016-09-22)
+-----------------
+
+Bug Fixes
+
+- Fixed a bug where users using a credentials store were not able
+ to access their private images.
+
+- Fixed a bug where users using identity tokens to authenticate
+ were not able to access their private images.
+
+- Fixed a bug where an `HttpHeaders` entry in the docker configuration
+ file would cause Compose to crash when trying to build an image.
+
+- Fixed a few bugs related to the handling of Windows paths in volume
+ binding declarations.
+
+- Fixed a bug where Compose would sometimes crash while trying to
+ read a streaming response from the engine.
+
+- Fixed an issue where Compose would crash when encountering an API error
+ while streaming container logs.
+
+- Fixed an issue where Compose would erroneously try to output logs from
+ drivers not handled by the Engine's API.
+
+- Fixed a bug where options from the `docker-machine config` command would
+ not be properly interpreted by Compose.
+
+- Fixed a bug where the connection to the Docker Engine would
+ sometimes fail when running a large number of services simultaneously.
+
+- Fixed an issue where Compose would sometimes print a misleading
+ suggestion message when running the `bundle` command.
+
+- Fixed a bug where connection errors would not be handled properly by
+ Compose during the project initialization phase.
+
+- Fixed a bug where a misleading error would appear when encountering
+ a connection timeout.
+
+
+1.8.0 (2016-06-14)
+-----------------
+
+**Breaking Changes**
+
+- As announced in 1.7.0, `docker-compose rm` now removes containers
+ created by `docker-compose run` by default.
+
+- Setting `entrypoint` on a service now empties out any default
+ command that was set on the image (i.e. any `CMD` instruction in the
+ Dockerfile used to build it). This makes it consistent with
+ the `--entrypoint` flag to `docker run`.
+
+New Features
+
+- Added `docker-compose bundle`, a command that builds a bundle file
+ to be consumed by the new *Docker Stack* commands in Docker 1.12.
+
+- Added `docker-compose push`, a command that pushes service images
+ to a registry.
+
+- Compose now supports specifying a custom TLS version for
+ interaction with the Docker Engine using the `COMPOSE_TLS_VERSION`
+ environment variable.
+
+Bug Fixes
+
+- Fixed a bug where Compose would erroneously try to read `.env`
+ at the project's root when it is a directory.
+
+- `docker-compose run -e VAR` now passes `VAR` through from the shell
+ to the container, as with `docker run -e VAR`.
+
+- Improved config merging when multiple compose files are involved
+ for several service sub-keys.
+
+- Fixed a bug where volume mappings containing Windows drives would
+ sometimes be parsed incorrectly.
+
+- Fixed a bug in Windows environment where volume mappings of the
+ host's root directory would be parsed incorrectly.
+
+- Fixed a bug where `docker-compose config` would output an invalid
+ Compose file if external networks were specified.
+
+- Fixed an issue where unset buildargs would be assigned a string
+ containing `'None'` instead of the expected empty value.
+
+- Fixed a bug where yes/no prompts on Windows would not show before
+ receiving input.
+
+- Fixed a bug where trying to `docker-compose exec` on Windows
+ without the `-d` option would exit with a stacktrace. This will
+ still fail for the time being, but should do so gracefully.
+
+- Fixed a bug where errors during `docker-compose up` would show
+ an unrelated stacktrace at the end of the process.
+
+- `docker-compose create` and `docker-compose start` show more
+ descriptive error messages when something goes wrong.
+
+
+1.7.1 (2016-05-04)
+-----------------
+
+Bug Fixes
+
+- Fixed a bug where the output of `docker-compose config` for v1 files
+ would be an invalid configuration file.
+
+- Fixed a bug where `docker-compose config` would not check the validity
+ of links.
+
+- Fixed an issue where `docker-compose help` would not output a list of
+ available commands and generic options as expected.
+
+- Fixed an issue where filtering by service when using `docker-compose logs`
+ would not apply for newly created services.
+
+- Fixed a bug where unchanged services would sometimes be recreated in
+ in the up phase when using Compose with Python 3.
+
+- Fixed an issue where API errors encountered during the up phase would
+ not be recognized as a failure state by Compose.
+
+- Fixed a bug where Compose would raise a NameError because of an undefined
+ exception name on non-Windows platforms.
+
+- Fixed a bug where the wrong version of `docker-py` would sometimes be
+ installed alongside Compose.
+
+- Fixed a bug where the host value output by `docker-machine config default`
+ would not be recognized as valid options by the `docker-compose`
+ command line.
+
+- Fixed an issue where Compose would sometimes exit unexpectedly while
+ reading events broadcasted by a Swarm cluster.
+
+- Corrected a statement in the docs about the location of the `.env` file,
+ which is indeed read from the current directory, instead of in the same
+ location as the Compose file.
+
+
+1.7.0 (2016-04-13)
+------------------
+
+**Breaking Changes**
+
+- `docker-compose logs` no longer follows log output by default. It now
+ matches the behaviour of `docker logs` and exits after the current logs
+ are printed. Use `-f` to get the old default behaviour.
+
+- Booleans are no longer allows as values for mappings in the Compose file
+ (for keys `environment`, `labels` and `extra_hosts`). Previously this
+ was a warning. Boolean values should be quoted so they become string values.
+
+New Features
+
+- Compose now looks for a `.env` file in the directory where it's run and
+ reads any environment variables defined inside, if they're not already
+ set in the shell environment. This lets you easily set defaults for
+ variables used in the Compose file, or for any of the `COMPOSE_*` or
+ `DOCKER_*` variables.
+
+- Added a `--remove-orphans` flag to both `docker-compose up` and
+ `docker-compose down` to remove containers for services that were removed
+ from the Compose file.
+
+- Added a `--all` flag to `docker-compose rm` to include containers created
+ by `docker-compose run`. This will become the default behavior in the next
+ version of Compose.
+
+- Added support for all the same TLS configuration flags used by the `docker`
+ client: `--tls`, `--tlscert`, `--tlskey`, etc.
+
+- Compose files now support the `tmpfs` and `shm_size` options.
+
+- Added the `--workdir` flag to `docker-compose run`
+
+- `docker-compose logs` now shows logs for new containers that are created
+ after it starts.
+
+- The `COMPOSE_FILE` environment variable can now contain multiple files,
+ separated by the host system's standard path separator (`:` on Mac/Linux,
+ `;` on Windows).
+
+- You can now specify a static IP address when connecting a service to a
+ network with the `ipv4_address` and `ipv6_address` options.
+
+- Added `--follow`, `--timestamp`, and `--tail` flags to the
+ `docker-compose logs` command.
+
+- `docker-compose up`, and `docker-compose start` will now start containers
+ in parallel where possible.
+
+- `docker-compose stop` now stops containers in reverse dependency order
+ instead of all at once.
+
+- Added the `--build` flag to `docker-compose up` to force it to build a new
+ image. It now shows a warning if an image is automatically built when the
+ flag is not used.
+
+- Added the `docker-compose exec` command for executing a process in a running
+ container.
+
+
+Bug Fixes
+
+- `docker-compose down` now removes containers created by
+ `docker-compose run`.
+
+- A more appropriate error is shown when a timeout is hit during `up` when
+ using a tty.
+
+- Fixed a bug in `docker-compose down` where it would abort if some resources
+ had already been removed.
+
+- Fixed a bug where changes to network aliases would not trigger a service
+ to be recreated.
+
+- Fix a bug where a log message was printed about creating a new volume
+ when it already existed.
+
+- Fixed a bug where interrupting `up` would not always shut down containers.
+
+- Fixed a bug where `log_opt` and `log_driver` were not properly carried over
+ when extending services in the v1 Compose file format.
+
+- Fixed a bug where empty values for build args would cause file validation
+ to fail.
+
+1.6.2 (2016-02-23)
+------------------
+
+- Fixed a bug where connecting to a TLS-enabled Docker Engine would fail with
+ a certificate verification error.
+
+1.6.1 (2016-02-23)
+------------------
+
+Bug Fixes
+
+- Fixed a bug where recreating a container multiple times would cause the
+ new container to be started without the previous volumes.
+
+- Fixed a bug where Compose would set the value of unset environment variables
+ to an empty string, instead of a key without a value.
+
+- Provide a better error message when Compose requires a more recent version
+ of the Docker API.
+
+- Add a missing config field `network.aliases` which allows setting a network
+ scoped alias for a service.
+
+- Fixed a bug where `run` would not start services listed in `depends_on`.
+
+- Fixed a bug where `networks` and `network_mode` where not merged when using
+ extends or multiple Compose files.
+
+- Fixed a bug with service aliases where the short container id alias was
+ only contained 10 characters, instead of the 12 characters used in previous
+ versions.
+
+- Added a missing log message when creating a new named volume.
+
+- Fixed a bug where `build.args` was not merged when using `extends` or
+ multiple Compose files.
+
+- Fixed some bugs with config validation when null values or incorrect types
+ were used instead of a mapping.
+
+- Fixed a bug where a `build` section without a `context` would show a stack
+ trace instead of a helpful validation message.
+
+- Improved compatibility with swarm by only setting a container affinity to
+ the previous instance of a services' container when the service uses an
+ anonymous container volume. Previously the affinity was always set on all
+ containers.
+
+- Fixed the validation of some `driver_opts` would cause an error if a number
+ was used instead of a string.
+
+- Some improvements to the `run.sh` script used by the Compose container install
+ option.
+
+- Fixed a bug with `up --abort-on-container-exit` where Compose would exit,
+ but would not stop other containers.
+
+- Corrected the warning message that is printed when a boolean value is used
+ as a value in a mapping.
+
+
+1.6.0 (2016-01-15)
+------------------
+
+Major Features:
+
+- Compose 1.6 introduces a new format for `docker-compose.yml` which lets
+ you define networks and volumes in the Compose file as well as services. It
+ also makes a few changes to the structure of some configuration options.
+
+ You don't have to use it - your existing Compose files will run on Compose
+ 1.6 exactly as they do today.
+
+ Check the upgrade guide for full details:
+ https://docs.docker.com/compose/compose-file#upgrading
+
+- Support for networking has exited experimental status and is the recommended
+ way to enable communication between containers.
+
+ If you use the new file format, your app will use networking. If you aren't
+ ready yet, just leave your Compose file as it is and it'll continue to work
+ just the same.
+
+ By default, you don't have to configure any networks. In fact, using
+ networking with Compose involves even less configuration than using links.
+ Consult the networking guide for how to use it:
+ https://docs.docker.com/compose/networking
+
+ The experimental flags `--x-networking` and `--x-network-driver`, introduced
+ in Compose 1.5, have been removed.
+
+- You can now pass arguments to a build if you're using the new file format:
+
+ build:
+ context: .
+ args:
+ buildno: 1
+
+- You can now specify both a `build` and an `image` key if you're using the
+ new file format. `docker-compose build` will build the image and tag it with
+ the name you've specified, while `docker-compose pull` will attempt to pull
+ it.
+
+- There's a new `events` command for monitoring container events from
+ the application, much like `docker events`. This is a good primitive for
+ building tools on top of Compose for performing actions when particular
+ things happen, such as containers starting and stopping.
+
+- There's a new `depends_on` option for specifying dependencies between
+ services. This enforces the order of startup, and ensures that when you run
+ `docker-compose up SERVICE` on a service with dependencies, those are started
+ as well.
+
+New Features:
+
+- Added a new command `config` which validates and prints the Compose
+ configuration after interpolating variables, resolving relative paths, and
+ merging multiple files and `extends`.
+
+- Added a new command `create` for creating containers without starting them.
+
+- Added a new command `down` to stop and remove all the resources created by
+ `up` in a single command.
+
+- Added support for the `cpu_quota` configuration option.
+
+- Added support for the `stop_signal` configuration option.
+
+- Commands `start`, `restart`, `pause`, and `unpause` now exit with an
+ error status code if no containers were modified.
+
+- Added a new `--abort-on-container-exit` flag to `up` which causes `up` to
+ stop all container and exit once the first container exits.
+
+- Removed support for `FIG_FILE`, `FIG_PROJECT_NAME`, and no longer reads
+ `fig.yml` as a default Compose file location.
+
+- Removed the `migrate-to-labels` command.
+
+- Removed the `--allow-insecure-ssl` flag.
+
+
+Bug Fixes:
+
+- Fixed a validation bug that prevented the use of a range of ports in
+ the `expose` field.
+
+- Fixed a validation bug that prevented the use of arrays in the `entrypoint`
+ field if they contained duplicate entries.
+
+- Fixed a bug that caused `ulimits` to be ignored when used with `extends`.
+
+- Fixed a bug that prevented ipv6 addresses in `extra_hosts`.
+
+- Fixed a bug that caused `extends` to be ignored when included from
+ multiple Compose files.
+
+- Fixed an incorrect warning when a container volume was defined in
+ the Compose file.
+
+- Fixed a bug that prevented the force shutdown behaviour of `up` and
+ `logs`.
+
+- Fixed a bug that caused `None` to be printed as the network driver name
+ when the default network driver was used.
+
+- Fixed a bug where using the string form of `dns` or `dns_search` would
+ cause an error.
+
+- Fixed a bug where a container would be reported as "Up" when it was
+ in the restarting state.
+
+- Fixed a confusing error message when DOCKER_CERT_PATH was not set properly.
+
+- Fixed a bug where attaching to a container would fail if it was using a
+ non-standard logging driver (or none at all).
+
+
+1.5.2 (2015-12-03)
+------------------
+
+- Fixed a bug which broke the use of `environment` and `env_file` with
+ `extends`, and caused environment keys without values to have a `None`
+ value, instead of a value from the host environment.
+
+- Fixed a regression in 1.5.1 that caused a warning about volumes to be
+ raised incorrectly when containers were recreated.
+
+- Fixed a bug which prevented building a `Dockerfile` that used `ADD `
+
+- Fixed a bug with `docker-compose restart` which prevented it from
+ starting stopped containers.
+
+- Fixed handling of SIGTERM and SIGINT to properly stop containers
+
+- Add support for using a url as the value of `build`
+
+- Improved the validation of the `expose` option
+
+
+1.5.1 (2015-11-12)
+------------------
+
+- Add the `--force-rm` option to `build`.
+
+- Add the `ulimit` option for services in the Compose file.
+
+- Fixed a bug where `up` would error with "service needs to be built" if
+ a service changed from using `image` to using `build`.
+
+- Fixed a bug that would cause incorrect output of parallel operations
+ on some terminals.
+
+- Fixed a bug that prevented a container from being recreated when the
+ mode of a `volumes_from` was changed.
+
+- Fixed a regression in 1.5.0 where non-utf-8 unicode characters would cause
+ `up` or `logs` to crash.
+
+- Fixed a regression in 1.5.0 where Compose would use a success exit status
+ code when a command fails due to an HTTP timeout communicating with the
+ docker daemon.
+
+- Fixed a regression in 1.5.0 where `name` was being accepted as a valid
+ service option which would override the actual name of the service.
+
+- When using `--x-networking` Compose no longer sets the hostname to the
+ container name.
+
+- When using `--x-networking` Compose will only create the default network
+ if at least one container is using the network.
+
+- When printings logs during `up` or `logs`, flush the output buffer after
+ each line to prevent buffering issues from hiding logs.
+
+- Recreate a container if one of its dependencies is being created.
+ Previously a container was only recreated if it's dependencies already
+ existed, but were being recreated as well.
+
+- Add a warning when a `volume` in the Compose file is being ignored
+ and masked by a container volume from a previous container.
+
+- Improve the output of `pull` when run without a tty.
+
+- When using multiple Compose files, validate each before attempting to merge
+ them together. Previously invalid files would result in not helpful errors.
+
+- Allow dashes in keys in the `environment` service option.
+
+- Improve validation error messages by including the filename as part of the
+ error message.
+
+
+1.5.0 (2015-11-03)
+------------------
+
+**Breaking changes:**
+
+With the introduction of variable substitution support in the Compose file, any
+Compose file that uses an environment variable (`$VAR` or `${VAR}`) in the `command:`
+or `entrypoint:` field will break.
+
+Previously these values were interpolated inside the container, with a value
+from the container environment. In Compose 1.5.0, the values will be
+interpolated on the host, with a value from the host environment.
+
+To migrate a Compose file to 1.5.0, escape the variables with an extra `$`
+(ex: `$$VAR` or `$${VAR}`). See
+https://github.com/docker/compose/blob/8cc8e61/docs/compose-file.md#variable-substitution
+
+Major features:
+
+- Compose is now available for Windows.
+
+- Environment variables can be used in the Compose file. See
+ https://github.com/docker/compose/blob/8cc8e61/docs/compose-file.md#variable-substitution
+
+- Multiple compose files can be specified, allowing you to override
+ settings in the default Compose file. See
+ https://github.com/docker/compose/blob/8cc8e61/docs/reference/docker-compose.md
+ for more details.
+
+- Compose now produces better error messages when a file contains
+ invalid configuration.
+
+- `up` now waits for all services to exit before shutting down,
+ rather than shutting down as soon as one container exits.
+
+- Experimental support for the new docker networking system can be
+ enabled with the `--x-networking` flag. Read more here:
+ https://github.com/docker/docker/blob/8fee1c20/docs/userguide/dockernetworks.md
+
+New features:
+
+- You can now optionally pass a mode to `volumes_from`, e.g.
+ `volumes_from: ["servicename:ro"]`.
+
+- Since Docker now lets you create volumes with names, you can refer to those
+ volumes by name in `docker-compose.yml`. For example,
+ `volumes: ["mydatavolume:/data"]` will mount the volume named
+ `mydatavolume` at the path `/data` inside the container.
+
+ If the first component of an entry in `volumes` starts with a `.`, `/` or
+ `~`, it is treated as a path and expansion of relative paths is performed as
+ necessary. Otherwise, it is treated as a volume name and passed straight
+ through to Docker.
+
+ Read more on named volumes and volume drivers here:
+ https://github.com/docker/docker/blob/244d9c33/docs/userguide/dockervolumes.md
+
+- `docker-compose build --pull` instructs Compose to pull the base image for
+ each Dockerfile before building.
+
+- `docker-compose pull --ignore-pull-failures` instructs Compose to continue
+ if it fails to pull a single service's image, rather than aborting.
+
+- You can now specify an IPC namespace in `docker-compose.yml` with the `ipc`
+ option.
+
+- Containers created by `docker-compose run` can now be named with the
+ `--name` flag.
+
+- If you install Compose with pip or use it as a library, it now works with
+ Python 3.
+
+- `image` now supports image digests (in addition to ids and tags), e.g.
+ `image: "busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d"`
+
+- `ports` now supports ranges of ports, e.g.
+
+ ports:
+ - "3000-3005"
+ - "9000-9001:8000-8001"
+
+- `docker-compose run` now supports a `-p|--publish` parameter, much like
+ `docker run -p`, for publishing specific ports to the host.
+
+- `docker-compose pause` and `docker-compose unpause` have been implemented,
+ analogous to `docker pause` and `docker unpause`.
+
+- When using `extends` to copy configuration from another service in the same
+ Compose file, you can omit the `file` option.
+
+- Compose can be installed and run as a Docker image. This is an experimental
+ feature.
+
+Bug fixes:
+
+- All values for the `log_driver` option which are supported by the Docker
+ daemon are now supported by Compose.
+
+- `docker-compose build` can now be run successfully against a Swarm cluster.
+
+
+1.4.2 (2015-09-22)
+------------------
+
+- Fixed a regression in the 1.4.1 release that would cause `docker-compose up`
+ without the `-d` option to exit immediately.
+
+1.4.1 (2015-09-10)
+------------------
+
+The following bugs have been fixed:
+
+- Some configuration changes (notably changes to `links`, `volumes_from`, and
+ `net`) were not properly triggering a container recreate as part of
+ `docker-compose up`.
+- `docker-compose up ` was showing logs for all services instead of
+ just the specified services.
+- Containers with custom container names were showing up in logs as
+ `service_number` instead of their custom container name.
+- When scaling a service sometimes containers would be recreated even when
+ the configuration had not changed.
+
+
+1.4.0 (2015-08-04)
+------------------
+
+- By default, `docker-compose up` now only recreates containers for services whose configuration has changed since they were created. This should result in a dramatic speed-up for many applications.
+
+ The experimental `--x-smart-recreate` flag which introduced this feature in Compose 1.3.0 has been removed, and a `--force-recreate` flag has been added for when you want to recreate everything.
+
+- Several of Compose's commands - `scale`, `stop`, `kill` and `rm` - now perform actions on multiple containers in parallel, rather than in sequence, which will run much faster on larger applications.
+
+- You can now specify a custom name for a service's container with `container_name`. Because Docker container names must be unique, this means you can't scale the service beyond one container.
+
+- You no longer have to specify a `file` option when using `extends` - it will default to the current file.
+
+- Service names can now contain dots, dashes and underscores.
+
+- Compose can now read YAML configuration from standard input, rather than from a file, by specifying `-` as the filename. This makes it easier to generate configuration dynamically:
+
+ $ echo 'redis: {"image": "redis"}' | docker-compose --file - up
+
+- There's a new `docker-compose version` command which prints extended information about Compose's bundled dependencies.
+
+- `docker-compose.yml` now supports `log_opt` as well as `log_driver`, allowing you to pass extra configuration to a service's logging driver.
+
+- `docker-compose.yml` now supports `memswap_limit`, similar to `docker run --memory-swap`.
+
+- When mounting volumes with the `volumes` option, you can now pass in any mode supported by the daemon, not just `:ro` or `:rw`. For example, SELinux users can pass `:z` or `:Z`.
+
+- You can now specify a custom volume driver with the `volume_driver` option in `docker-compose.yml`, much like `docker run --volume-driver`.
+
+- A bug has been fixed where Compose would fail to pull images from private registries serving plain (unsecured) HTTP. The `--allow-insecure-ssl` flag, which was previously used to work around this issue, has been deprecated and now has no effect.
+
+- A bug has been fixed where `docker-compose build` would fail if the build depended on a private Hub image or an image from a private registry.
+
+- A bug has been fixed where Compose would crash if there were containers which the Docker daemon had not finished removing.
+
+- Two bugs have been fixed where Compose would sometimes fail with a "Duplicate bind mount" error, or fail to attach volumes to a container, if there was a volume path specified in `docker-compose.yml` with a trailing slash.
+
+Thanks @mnowster, @dnephin, @ekristen, @funkyfuture, @jeffk and @lukemarsden!
+
+1.3.3 (2015-07-15)
+------------------
+
+Two regressions have been fixed:
+
+- When stopping containers gracefully, Compose was setting the timeout to 0, effectively forcing a SIGKILL every time.
+- Compose would sometimes crash depending on the formatting of container data returned from the Docker API.
+
+1.3.2 (2015-07-14)
+------------------
+
+The following bugs have been fixed:
+
+- When there were one-off containers created by running `docker-compose run` on an older version of Compose, `docker-compose run` would fail with a name collision. Compose now shows an error if you have leftover containers of this type lying around, and tells you how to remove them.
+- Compose was not reading Docker authentication config files created in the new location, `~/docker/config.json`, and authentication against private registries would therefore fail.
+- When a container had a pseudo-TTY attached, its output in `docker-compose up` would be truncated.
+- `docker-compose up --x-smart-recreate` would sometimes fail when an image tag was updated.
+- `docker-compose up` would sometimes create two containers with the same numeric suffix.
+- `docker-compose rm` and `docker-compose ps` would sometimes list services that aren't part of the current project (though no containers were erroneously removed).
+- Some `docker-compose` commands would not show an error if invalid service names were passed in.
+
+Thanks @dano, @josephpage, @kevinsimper, @lieryan, @phemmer, @soulrebel and @sschepens!
+
+1.3.1 (2015-06-21)
+------------------
+
+The following bugs have been fixed:
+
+- `docker-compose build` would always attempt to pull the base image before building.
+- `docker-compose help migrate-to-labels` failed with an error.
+- If no network mode was specified, Compose would set it to "bridge", rather than allowing the Docker daemon to use its configured default network mode.
+
+1.3.0 (2015-06-18)
+------------------
+
+Firstly, two important notes:
+
+- **This release contains breaking changes, and you will need to either remove or migrate your existing containers before running your app** - see the [upgrading section of the install docs](https://github.com/docker/compose/blob/1.3.0rc1/docs/install.md#upgrading) for details.
+
+- Compose now requires Docker 1.6.0 or later.
+
+We've done a lot of work in this release to remove hacks and make Compose more stable:
+
+- Compose now uses container labels, rather than names, to keep track of containers. This makes Compose both faster and easier to integrate with your own tools.
+
+- Compose no longer uses "intermediate containers" when recreating containers for a service. This makes `docker-compose up` less complex and more resilient to failure.
+
+There are some new features:
+
+- `docker-compose up` has an **experimental** new behaviour: it will only recreate containers for services whose configuration has changed in `docker-compose.yml`. This will eventually become the default, but for now you can take it for a spin:
+
+ $ docker-compose up --x-smart-recreate
+
+- When invoked in a subdirectory of a project, `docker-compose` will now climb up through parent directories until it finds a `docker-compose.yml`.
+
+Several new configuration keys have been added to `docker-compose.yml`:
+
+- `dockerfile`, like `docker build --file`, lets you specify an alternate Dockerfile to use with `build`.
+- `labels`, like `docker run --labels`, lets you add custom metadata to containers.
+- `extra_hosts`, like `docker run --add-host`, lets you add entries to a container's `/etc/hosts` file.
+- `pid: host`, like `docker run --pid=host`, lets you reuse the same PID namespace as the host machine.
+- `cpuset`, like `docker run --cpuset-cpus`, lets you specify which CPUs to allow execution in.
+- `read_only`, like `docker run --read-only`, lets you mount a container's filesystem as read-only.
+- `security_opt`, like `docker run --security-opt`, lets you specify [security options](https://docs.docker.com/engine/reference/run/#security-configuration).
+- `log_driver`, like `docker run --log-driver`, lets you specify a [log driver](https://docs.docker.com/engine/reference/run/#logging-drivers-log-driver).
+
+Many bugs have been fixed, including the following:
+
+- The output of `docker-compose run` was sometimes truncated, especially when running under Jenkins.
+- A service's volumes would sometimes not update after volume configuration was changed in `docker-compose.yml`.
+- Authenticating against third-party registries would sometimes fail.
+- `docker-compose run --rm` would fail to remove the container if the service had a `restart` policy in place.
+- `docker-compose scale` would refuse to scale a service beyond 1 container if it exposed a specific port number on the host.
+- Compose would refuse to create multiple volume entries with the same host path.
+
+Thanks @ahromis, @albers, @aleksandr-vin, @antoineco, @ccverak, @chernjie, @dnephin, @edmorley, @fordhurley, @josephpage, @KyleJamesWalker, @lsowen, @mchasal, @noironetworks, @sdake, @sdurrheimer, @sherter, @stephenlawrence, @thaJeztah, @thieman, @turtlemonvh, @twhiteman, @vdemeester, @xuxinkun and @zwily!
+
+1.2.0 (2015-04-16)
+------------------
+
+- `docker-compose.yml` now supports an `extends` option, which enables a service to inherit configuration from another service in another configuration file. This is really good for sharing common configuration between apps, or for configuring the same app for different environments. Here's the [documentation](https://github.com/docker/compose/blob/master/docs/yml.md#extends).
+
+- When using Compose with a Swarm cluster, containers that depend on one another will be co-scheduled on the same node. This means that most Compose apps will now work out of the box, as long as they don't use `build`.
+
+- Repeated invocations of `docker-compose up` when using Compose with a Swarm cluster now work reliably.
+
+- Directories passed to `build`, filenames passed to `env_file` and volume host paths passed to `volumes` are now treated as relative to the *directory of the configuration file*, not the directory that `docker-compose` is being run in. In the majority of cases, those are the same, but if you use the `-f|--file` argument to specify a configuration file in another directory, **this is a breaking change**.
+
+- A service can now share another service's network namespace with `net: container:`.
+
+- `volumes_from` and `net: container:` entries are taken into account when resolving dependencies, so `docker-compose up ` will correctly start all dependencies of ``.
+
+- `docker-compose run` now accepts a `--user` argument to specify a user to run the command as, just like `docker run`.
+
+- The `up`, `stop` and `restart` commands now accept a `--timeout` (or `-t`) argument to specify how long to wait when attempting to gracefully stop containers, just like `docker stop`.
+
+- `docker-compose rm` now accepts `-f` as a shorthand for `--force`, just like `docker rm`.
+
+Thanks, @abesto, @albers, @alunduil, @dnephin, @funkyfuture, @gilclark, @IanVS, @KingsleyKelly, @knutwalker, @thaJeztah and @vmalloc!
+
+1.1.0 (2015-02-25)
+------------------
+
+Fig has been renamed to Docker Compose, or just Compose for short. This has several implications for you:
+
+- The command you type is now `docker-compose`, not `fig`.
+- You should rename your fig.yml to docker-compose.yml.
+- If you’re installing via PyPI, the package is now `docker-compose`, so install it with `pip install docker-compose`.
+
+Besides that, there’s a lot of new stuff in this release:
+
+- We’ve made a few small changes to ensure that Compose will work with Swarm, Docker’s new clustering tool (https://github.com/docker/swarm). Eventually you'll be able to point Compose at a Swarm cluster instead of a standalone Docker host and it’ll run your containers on the cluster with no extra work from you. As Swarm is still developing, integration is rough and lots of Compose features don't work yet.
+
+- `docker-compose run` now has a `--service-ports` flag for exposing ports on the given service. This is useful for e.g. running your webapp with an interactive debugger.
+
+- You can now link to containers outside your app with the `external_links` option in docker-compose.yml.
+
+- You can now prevent `docker-compose up` from automatically building images with the `--no-build` option. This will make fewer API calls and run faster.
+
+- If you don’t specify a tag when using the `image` key, Compose will default to the `latest` tag, rather than pulling all tags.
+
+- `docker-compose kill` now supports the `-s` flag, allowing you to specify the exact signal you want to send to a service’s containers.
+
+- docker-compose.yml now has an `env_file` key, analogous to `docker run --env-file`, letting you specify multiple environment variables in a separate file. This is great if you have a lot of them, or if you want to keep sensitive information out of version control.
+
+- docker-compose.yml now supports the `dns_search`, `cap_add`, `cap_drop`, `cpu_shares` and `restart` options, analogous to `docker run`’s `--dns-search`, `--cap-add`, `--cap-drop`, `--cpu-shares` and `--restart` options.
+
+- Compose now ships with Bash tab completion - see the installation and usage docs at https://github.com/docker/compose/blob/1.1.0/docs/completion.md
+
+- A number of bugs have been fixed - see the milestone for details: https://github.com/docker/compose/issues?q=milestone%3A1.1.0+
+
+Thanks @dnephin, @squebe, @jbalonso, @raulcd, @benlangfield, @albers, @ggtools, @bersace, @dtenenba, @petercv, @drewkett, @TFenby, @paulRbr, @Aigeruth and @salehe!
+
+1.0.1 (2014-11-04)
+------------------
+
+ - Added an `--allow-insecure-ssl` option to allow `fig up`, `fig run` and `fig pull` to pull from insecure registries.
+ - Fixed `fig run` not showing output in Jenkins.
+ - Fixed a bug where Fig couldn't build Dockerfiles with ADD statements pointing at URLs.
+
+1.0.0 (2014-10-16)
+------------------
+
+The highlights:
+
+ - [Fig has joined Docker.](https://www.orchardup.com/blog/orchard-is-joining-docker) Fig will continue to be maintained, but we'll also be incorporating the best bits of Fig into Docker itself.
+
+ This means the GitHub repository has moved to [https://github.com/docker/fig](https://github.com/docker/fig) and our IRC channel is now #docker-fig on Freenode.
+
+ - Fig can be used with the [official Docker OS X installer](https://docs.docker.com/installation/mac/). Boot2Docker will mount the home directory from your host machine so volumes work as expected.
+
+ - Fig supports Docker 1.3.
+
+ - It is now possible to connect to the Docker daemon using TLS by using the `DOCKER_CERT_PATH` and `DOCKER_TLS_VERIFY` environment variables.
+
+ - There is a new `fig port` command which outputs the host port binding of a service, in a similar way to `docker port`.
+
+ - There is a new `fig pull` command which pulls the latest images for a service.
+
+ - There is a new `fig restart` command which restarts a service's containers.
+
+ - Fig creates multiple containers in service by appending a number to the service name (e.g. `db_1`, `db_2`, etc). As a convenience, Fig will now give the first container an alias of the service name (e.g. `db`).
+
+ This link alias is also a valid hostname and added to `/etc/hosts` so you can connect to linked services using their hostname. For example, instead of resolving the environment variables `DB_PORT_5432_TCP_ADDR` and `DB_PORT_5432_TCP_PORT`, you could just use the hostname `db` and port `5432` directly.
+
+ - Volume definitions now support `ro` mode, expanding `~` and expanding environment variables.
+
+ - `.dockerignore` is supported when building.
+
+ - The project name can be set with the `FIG_PROJECT_NAME` environment variable.
+
+ - The `--env` and `--entrypoint` options have been added to `fig run`.
+
+ - The Fig binary for Linux is now linked against an older version of glibc so it works on CentOS 6 and Debian Wheezy.
+
+Other things:
+
+ - `fig ps` now works on Jenkins and makes fewer API calls to the Docker daemon.
+ - `--verbose` displays more useful debugging output.
+ - When starting a service where `volumes_from` points to a service without any containers running, that service will now be started.
+ - Lots of docs improvements. Notably, environment variables are documented and official repositories are used throughout.
+
+Thanks @dnephin, @d11wtq, @marksteve, @rubbish, @jbalonso, @timfreund, @alunduil, @mieciu, @shuron, @moss, @suzaku and @chmouel! Whew.
+
+0.5.2 (2014-07-28)
+------------------
+
+ - Added a `--no-cache` option to `fig build`, which bypasses the cache just like `docker build --no-cache`.
+ - Fixed the `dns:` fig.yml option, which was causing fig to error out.
+ - Fixed a bug where fig couldn't start under Python 2.6.
+ - Fixed a log-streaming bug that occasionally caused fig to exit.
+
+Thanks @dnephin and @marksteve!
+
+
+0.5.1 (2014-07-11)
+------------------
+
+ - If a service has a command defined, `fig run [service]` with no further arguments will run it.
+ - The project name now defaults to the directory containing fig.yml, not the current working directory (if they're different)
+ - `volumes_from` now works properly with containers as well as services
+ - Fixed a race condition when recreating containers in `fig up`
+
+Thanks @ryanbrainard and @d11wtq!
+
+
+0.5.0 (2014-07-11)
+------------------
+
+ - Fig now starts links when you run `fig run` or `fig up`.
+
+ For example, if you have a `web` service which depends on a `db` service, `fig run web ...` will start the `db` service.
+
+ - Environment variables can now be resolved from the environment that Fig is running in. Just specify it as a blank variable in your `fig.yml` and, if set, it'll be resolved:
+ ```
+ environment:
+ RACK_ENV: development
+ SESSION_SECRET:
+ ```
+
+ - `volumes_from` is now supported in `fig.yml`. All of the volumes from the specified services and containers will be mounted:
+
+ ```
+ volumes_from:
+ - service_name
+ - container_name
+ ```
+
+ - A host address can now be specified in `ports`:
+
+ ```
+ ports:
+ - "0.0.0.0:8000:8000"
+ - "127.0.0.1:8001:8001"
+ ```
+
+ - The `net` and `workdir` options are now supported in `fig.yml`.
+ - The `hostname` option now works in the same way as the Docker CLI, splitting out into a `domainname` option.
+ - TTY behaviour is far more robust, and resizes are supported correctly.
+ - Load YAML files safely.
+
+Thanks to @d11wtq, @ryanbrainard, @rail44, @j0hnsmith, @binarin, @Elemecca, @mozz100 and @marksteve for their help with this release!
+
+
+0.4.2 (2014-06-18)
+------------------
+
+ - Fix various encoding errors when using `fig run`, `fig up` and `fig build`.
+
+0.4.1 (2014-05-08)
+------------------
+
+ - Add support for Docker 0.11.0. (Thanks @marksteve!)
+ - Make project name configurable. (Thanks @jefmathiot!)
+ - Return correct exit code from `fig run`.
+
+0.4.0 (2014-04-29)
+------------------
+
+ - Support Docker 0.9 and 0.10
+ - Display progress bars correctly when pulling images (no more ski slopes)
+ - `fig up` now stops all services when any container exits
+ - Added support for the `privileged` config option in fig.yml (thanks @kvz!)
+ - Shortened and aligned log prefixes in `fig up` output
+ - Only containers started with `fig run` link back to their own service
+ - Handle UTF-8 correctly when streaming `fig build/run/up` output (thanks @mauvm and @shanejonas!)
+ - Error message improvements
+
+0.3.2 (2014-03-05)
+------------------
+
+ - Added an `--rm` option to `fig run`. (Thanks @marksteve!)
+ - Added an `expose` option to `fig.yml`.
+
+0.3.1 (2014-03-04)
+------------------
+
+ - Added contribution instructions. (Thanks @kvz!)
+ - Fixed `fig rm` throwing an error.
+ - Fixed a bug in `fig ps` on Docker 0.8.1 when there is a container with no command.
+
+0.3.0 (2014-03-03)
+------------------
+
+ - We now ship binaries for OS X and Linux. No more having to install with Pip!
+ - Add `-f` flag to specify alternate `fig.yml` files
+ - Add support for custom link names
+ - Fix a bug where recreating would sometimes hang
+ - Update docker-py to support Docker 0.8.0.
+ - Various documentation improvements
+ - Various error message improvements
+
+Thanks @marksteve, @Gazler and @teozkr!
+
+0.2.2 (2014-02-17)
+------------------
+
+ - Resolve dependencies using Cormen/Tarjan topological sort
+ - Fix `fig up` not printing log output
+ - Stop containers in reverse order to starting
+ - Fix scale command not binding ports
+
+Thanks to @barnybug and @dustinlacewell for their work on this release.
+
+0.2.1 (2014-02-04)
+------------------
+
+ - General improvements to error reporting (#77, #79)
+
+0.2.0 (2014-01-31)
+------------------
+
+ - Link services to themselves so run commands can access the running service. (#67)
+ - Much better documentation.
+ - Make service dependency resolution more reliable. (#48)
+ - Load Fig configurations with a `.yaml` extension. (#58)
+
+Big thanks to @cameronmaske, @mrchrisadams and @damianmoore for their help with this release.
+
+0.1.4 (2014-01-27)
+------------------
+
+ - Add a link alias without the project name. This makes the environment variables a little shorter: `REDIS_1_PORT_6379_TCP_ADDR`. (#54)
+
+0.1.3 (2014-01-23)
+------------------
+
+ - Fix ports sometimes being configured incorrectly. (#46)
+ - Fix log output sometimes not displaying. (#47)
+
+0.1.2 (2014-01-22)
+------------------
+
+ - Add `-T` option to `fig run` to disable pseudo-TTY. (#34)
+ - Fix `fig up` requiring the ubuntu image to be pulled to recreate containers. (#33) Thanks @cameronmaske!
+ - Improve reliability, fix arrow keys and fix a race condition in `fig run`. (#34, #39, #40)
+
+0.1.1 (2014-01-17)
+------------------
+
+ - Fix bug where ports were not exposed correctly (#29). Thanks @dustinlacewell!
+
+0.1.0 (2014-01-16)
+------------------
+
+ - Containers are recreated on each `fig up`, ensuring config is up-to-date with `fig.yml` (#2)
+ - Add `fig scale` command (#9)
+ - Use `DOCKER_HOST` environment variable to find Docker daemon, for consistency with the official Docker client (was previously `DOCKER_URL`) (#19)
+ - Truncate long commands in `fig ps` (#18)
+ - Fill out CLI help banners for commands (#15, #16)
+ - Show a friendlier error when `fig.yml` is missing (#4)
+ - Fix bug with `fig build` logging (#3)
+ - Fix bug where builds would time out if a step took a long time without generating output (#6)
+ - Fix bug where streaming container output over the Unix socket raised an error (#7)
+
+Big thanks to @tomstuart, @EnTeQuAk, @schickling, @aronasorman and @GeoffreyPlitt.
+
+0.0.2 (2014-01-02)
+------------------
+
+ - Improve documentation
+ - Try to connect to Docker on `tcp://localdocker:4243` and a UNIX socket in addition to `localhost`.
+ - Improve `fig up` behaviour
+ - Add confirmation prompt to `fig rm`
+ - Add `fig build` command
+
+0.0.1 (2013-12-20)
+------------------
+
+Initial release.
diff --git a/CHANGES.md b/CHANGES.md
new file mode 120000
index 00000000000..83b694704ba
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1 @@
+CHANGELOG.md
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 120000
index 47dc3e3d863..00000000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-AGENTS.md
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9b80b8abc45..5bf7cb1318c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,339 +1,76 @@
-# Contributing to Docker
+# Contributing to Compose
-Want to hack on Docker? Awesome! We have a contributor's guide that explains
-[setting up a Docker development environment and the contribution
-process](https://docs.docker.com/contribute/).
+Compose is a part of the Docker project, and follows the same rules and
+principles. Take a read of [Docker's contributing guidelines](https://github.com/docker/docker/blob/master/CONTRIBUTING.md)
+to get an overview.
-This page contains information about reporting issues as well as some tips and
-guidelines useful to experienced open source contributors. Finally, make sure
-you read our [community guidelines](#docker-community-guidelines) before you
-start participating.
+## TL;DR
-## Topics
+Pull requests will need:
-- [Contributing to Docker](#contributing-to-docker)
- - [Topics](#topics)
- - [Reporting security issues](#reporting-security-issues)
- - [Reporting other issues](#reporting-other-issues)
- - [Quick contribution tips and guidelines](#quick-contribution-tips-and-guidelines)
- - [Pull requests are always welcome](#pull-requests-are-always-welcome)
- - [Talking to other Docker users and contributors](#talking-to-other-docker-users-and-contributors)
- - [Conventions](#conventions)
- - [Merge approval](#merge-approval)
- - [Sign your work](#sign-your-work)
- - [How can I become a maintainer?](#how-can-i-become-a-maintainer)
- - [Docker community guidelines](#docker-community-guidelines)
- - [Coding Style](#coding-style)
+ - Tests
+ - Documentation
+ - [To be signed off](https://github.com/docker/docker/blob/master/CONTRIBUTING.md#sign-your-work)
+ - A logical series of [well written commits](https://github.com/alphagov/styleguides/blob/master/git.md)
-## Reporting security issues
+## Development environment
-The Docker maintainers take security seriously. If you discover a security
-issue, please bring it to their attention right away!
+If you're looking contribute to Compose
+but you're new to the project or maybe even to Python, here are the steps
+that should get you started.
-Please **DO NOT** file a public issue, instead, send your report privately to
-[security@docker.com](mailto:security@docker.com).
+1. Fork [https://github.com/docker/compose](https://github.com/docker/compose)
+ to your username.
+2. Clone your forked repository locally `git clone git@github.com:yourusername/compose.git`.
+3. You must [configure a remote](https://help.github.com/articles/configuring-a-remote-for-a-fork/) for your fork so that you can [sync changes you make](https://help.github.com/articles/syncing-a-fork/) with the original repository.
+4. Enter the local directory `cd compose`.
+5. Set up a development environment by running `python setup.py develop`. This
+ will install the dependencies and set up a symlink from your `docker-compose`
+ executable to the checkout of the repository. When you now run
+ `docker-compose` from anywhere on your machine, it will run your development
+ version of Compose.
-Security reports are greatly appreciated and we will publicly thank you for them.
-We also like to send gifts—if you're into Docker swag, make sure to let
-us know. We currently do not offer a paid security bounty program but are not
-ruling it out in the future.
+## Install pre-commit hooks
+This step is optional, but recommended. Pre-commit hooks will run style checks
+and in some cases fix style issues for you, when you commit code.
-## Reporting other issues
+Install the git pre-commit hooks using [tox](https://tox.readthedocs.io) by
+running `tox -e pre-commit` or by following the
+[pre-commit install guide](http://pre-commit.com/#install).
-A great way to contribute to the project is to send a detailed report when you
-encounter an issue. We always appreciate a well-written, thorough bug report,
-and will thank you for it!
+To run the style checks at any time run `tox -e pre-commit`.
-Check that [our issue database](https://github.com/docker/compose/labels/Docker%20Compose%20V2)
-doesn't already include that problem or suggestion before submitting an issue.
-If you find a match, you can use the "subscribe" button to get notified of
-updates. Do *not* leave random "+1" or "I have this too" comments, as they
-only clutter the discussion, and don't help to resolve it. However, if you
-have ways to reproduce the issue or have additional information that may help
-resolve the issue, please leave a comment.
+## Submitting a pull request
-When reporting issues, always include:
+See Docker's [basic contribution workflow](https://docs.docker.com/v17.06/opensource/code/#code-contribution-workflow) for a guide on how to submit a pull request for code.
-* The output of `docker version`.
-* The output of `docker context show`.
-* The output of `docker info`.
+## Documentation changes
-Also, include the steps required to reproduce the problem if possible and
-applicable. This information will help us review and fix your issue faster.
-When sending lengthy log files, consider posting them as a gist
-(https://gist.github.com).
-Don't forget to remove sensitive data from your log files before posting (you
-can replace those parts with "REDACTED").
+Issues and pull requests to update the documentation should be submitted to the [docs repo](https://github.com/docker/docker.github.io). You can learn more about contributing to the documentation [here](https://docs.docker.com/opensource/#how-to-contribute-to-the-docs).
-_Note:_
-Maintainers might request additional information to diagnose an issue,
-if initial reporter doesn't answer within a reasonable delay (a few weeks),
-issue will be closed.
+## Running the test suite
-## Quick contribution tips and guidelines
+Use the test script to run linting checks and then the full test suite against
+different Python interpreters:
-This section gives the experienced contributor some tips and guidelines.
+ $ script/test/default
-### Pull requests are always welcome
+Tests are run against a Docker daemon inside a container, so that we can test
+against multiple Docker versions. By default they'll run against only the latest
+Docker version - set the `DOCKER_VERSIONS` environment variable to "all" to run
+against all supported versions:
-Not sure if that typo is worth a pull request? Found a bug and know how to fix
-it? Do it! We will appreciate it. Any significant change, like adding a backend,
-should be documented as
-[a GitHub issue](https://github.com/docker/compose/issues)
-before anybody starts working on it.
+ $ DOCKER_VERSIONS=all script/test/default
-We are always thrilled to receive pull requests. We do our best to process them
-quickly. If your pull request is not accepted on the first try,
-don't get discouraged!
+Arguments to `script/test/default` are passed through to the `tox` executable, so
+you can specify a test directory, file, module, class or method:
-### Talking to other Docker users and contributors
+ $ script/test/default tests/unit
+ $ script/test/default tests/unit/cli_test.py
+ $ script/test/default tests/unit/config/config_test.py::ConfigTest
+ $ script/test/default tests/unit/config/config_test.py::ConfigTest::test_load
-
-
-
-
-
Community Slack
-
- The Docker Community has a dedicated Slack chat to discuss features and issues. You can sign-up with this link.
-
-
-
-
Forums
-
- A public forum for users to discuss questions and explore current design patterns and
- best practices about Docker and related projects in the Docker Ecosystem. To participate,
- just log in with your Docker Hub account on https://forums.docker.com.
-
-
-
-
Twitter
-
- You can follow Docker's Twitter feed
- to get updates on our products. You can also tweet us questions or just
- share blogs or stories.
-
-
-
-
Stack Overflow
-
- Stack Overflow has over 17000 Docker questions listed. We regularly
- monitor Docker questions
- and so do many other knowledgeable Docker users.
-
-
-
+## Finding things to work on
-
-### Conventions
-
-Fork the repository and make changes on your fork in a feature branch:
-
-- If it's a bug fix branch, name it XXXX-something where XXXX is the number of
- the issue.
-- If it's a feature branch, create an enhancement issue to announce
- your intentions, and name it XXXX-something where XXXX is the number of the
- issue.
-
-Submit unit tests for your changes. Go has a great test framework built in; use
-it! Take a look at existing tests for inspiration. Also, end-to-end tests are
-available. Run the full test suite, both unit tests and e2e tests on your
-branch before submitting a pull request. See [BUILDING.md](BUILDING.md) for
-instructions to build and run tests.
-
-Write clean code. Universally formatted code promotes ease of writing, reading,
-and maintenance. Always run `gofmt -s -w file.go` on each changed file before
-committing your changes. Most editors have plug-ins that do this automatically.
-
-Pull request descriptions should be as clear as possible and include a reference
-to all the issues that they address.
-
-Commit messages must start with a capitalized and short summary (max. 50 chars)
-written in the imperative, followed by an optional, more detailed explanatory
-text which is separated from the summary by an empty line.
-
-Code review comments may be added to your pull request. Discuss, then make the
-suggested modifications and push additional commits to your feature branch. Post
-a comment after pushing. New commits show up in the pull request automatically,
-but the reviewers are notified only when you comment.
-
-Pull requests must be cleanly rebased on top of the base branch without multiple branches
-mixed into the PR.
-
-**Git tip**: If your PR no longer merges cleanly, use `rebase master` in your
-feature branch to update your pull request rather than `merge master`.
-
-Before you make a pull request, squash your commits into logical units of work
-using `git rebase -i` and `git push -f`. A logical unit of work is a consistent
-set of patches that should be reviewed together: for example, upgrading the
-version of a vendored dependency and taking advantage of its now available new
-feature constitute two separate units of work. Implementing a new function and
-calling it in another file constitute a single logical unit of work. The very
-high majority of submissions should have a single commit, so if in doubt: squash
-down to one.
-
-After every commit, make sure the test suite passes. Include documentation
-changes in the same pull request so that a revert would remove all traces of
-the feature or fix.
-
-Include an issue reference like `Closes #XXXX` or `Fixes #XXXX` in the pull
-request description that closes an issue. Including references automatically
-closes the issue on a merge.
-
-Please do not add yourself to the `AUTHORS` file, as it is regenerated regularly
-from the Git history.
-
-Please see the [Coding Style](#coding-style) for further guidelines.
-
-### Merge approval
-
-Docker maintainers use LGTM (Looks Good To Me) in comments on the code review to
-indicate acceptance.
-
-A change requires at least 2 LGTMs from the maintainers of each
-component affected.
-
-For more details, see the [MAINTAINERS](MAINTAINERS) page.
-
-### Sign your work
-
-The sign-off is a simple line at the end of the explanation for the patch. Your
-signature certifies that you wrote the patch or otherwise have the right to pass
-it on as an open-source patch. The rules are pretty simple: if you can certify
-the below (from [developercertificate.org](https://developercertificate.org/)):
-
-```
-Developer Certificate of Origin
-Version 1.1
-
-Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
-660 York Street, Suite 102,
-San Francisco, CA 94110 USA
-
-Everyone is permitted to copy and distribute verbatim copies of this
-license document, but changing it is not allowed.
-
-Developer's Certificate of Origin 1.1
-
-By making a contribution to this project, I certify that:
-
-(a) The contribution was created in whole or in part by me and I
- have the right to submit it under the open source license
- indicated in the file; or
-
-(b) The contribution is based upon previous work that, to the best
- of my knowledge, is covered under an appropriate open source
- license and I have the right under that license to submit that
- work with modifications, whether created in whole or in part
- by me, under the same open source license (unless I am
- permitted to submit under a different license), as indicated
- in the file; or
-
-(c) The contribution was provided directly to me by some other
- person who certified (a), (b) or (c) and I have not modified
- it.
-
-(d) I understand and agree that this project and the contribution
- are public and that a record of the contribution (including all
- personal information I submit with it, including my sign-off) is
- maintained indefinitely and may be redistributed consistent with
- this project or the open source license(s) involved.
-```
-
-Then you just add a line to every git commit message:
-
- Signed-off-by: Joe Smith
-
-Use your real name (sorry, no pseudonyms or anonymous contributions.)
-
-If you set your `user.name` and `user.email` git configs, you can sign your
-commit automatically with `git commit -s`.
-
-### How can I become a maintainer?
-
-The procedures for adding new maintainers are explained in the global
-[MAINTAINERS](https://github.com/docker/opensource/blob/main/MAINTAINERS)
-file in the
-[https://github.com/docker/opensource/](https://github.com/docker/opensource/)
-repository.
-
-Don't forget: being a maintainer is a time investment. Make sure you
-will have time to make yourself available. You don't have to be a
-maintainer to make a difference on the project!
-
-## Docker community guidelines
-
-We want to keep the Docker community awesome, growing and collaborative. We need
-your help to keep it that way. To help with this we've come up with some general
-guidelines for the community as a whole:
-
-* Be nice: Be courteous, respectful and polite to fellow community members:
- no regional, racial, gender or other abuse will be tolerated. We like
- nice people way better than mean ones!
-
-* Encourage diversity and participation: Make everyone in our community feel
- welcome, regardless of their background and the extent of their
- contributions, and do everything possible to encourage participation in
- our community.
-
-* Keep it legal: Basically, don't get us in trouble. Share only content that
- you own, do not share private or sensitive information, and don't break
- the law.
-
-* Stay on topic: Make sure that you are posting to the correct channel and
- avoid off-topic discussions. Remember when you update an issue or respond
- to an email you are potentially sending it to a large number of people. Please
- consider this before you update. Also, remember that nobody likes spam.
-
-* Don't send emails to the maintainers: There's no need to send emails to the
- maintainers to ask them to investigate an issue or to take a look at a
- pull request. Instead of sending an email, GitHub mentions should be
- used to ping maintainers to review a pull request, a proposal or an
- issue.
-
-## Coding Style
-
-Unless explicitly stated, we follow all coding guidelines from the Go
-community. While some of these standards may seem arbitrary, they somehow seem
-to result in a solid, consistent codebase.
-
-It is possible that the code base does not currently comply with these
-guidelines. We are not looking for a massive PR that fixes this, since that
-goes against the spirit of the guidelines. All new contributors should make their
-best effort to clean up and make the code base better than they left it.
-Obviously, apply your best judgement. Remember, the goal here is to make the
-code base easier for humans to navigate and understand. Always keep that in
-mind when nudging others to comply.
-
-The rules:
-
-1. All code should be formatted with `gofmt -s`.
-2. All code should pass the default levels of
- [`golint`](https://github.com/golang/lint).
-3. All code should follow the guidelines covered in [Effective
- Go](https://go.dev/doc/effective_go) and [Go Code Review
- Comments](https://go.dev/wiki/CodeReviewComments).
-4. Include code comments. Tell us the why, the history and the context.
-5. Document _all_ declarations and methods, even private ones. Declare
- expectations, caveats and anything else that may be important. If a type
- gets exported, having the comments already there will ensure it's ready.
-6. Variable name length should be proportional to its context and no longer.
- `noCommaALongVariableNameLikeThisIsNotMoreClearWhenASimpleCommentWouldDo`.
- In practice, short methods will have short variable names and globals will
- have longer names.
-7. No underscores in package names. If you need a compound name, step back,
- and re-examine why you need a compound name. If you still think you need a
- compound name, lose the underscore.
-8. No utils or helpers packages. If a function is not general enough to
- warrant its own package, it has not been written generally enough to be a
- part of a util package. Just leave it unexported and well-documented.
-9. All tests should run with `go test` and outside tooling should not be
- required. No, we don't need another unit testing framework. Assertion
- packages are acceptable if they provide _real_ incremental value.
-10. Even though we call these "rules" above, they are actually just
- guidelines. Since you've read all the rules, you now know that.
-
-If you are having trouble getting into the mood of idiomatic Go, we recommend
-reading through [Effective Go](https://go.dev/doc/effective_go). The
-[Go Blog](https://go.dev/blog/) is also a great resource. Drinking the
-kool-aid is a lot easier than going thirsty.
+[Issues marked with the `exp/beginner` label](https://github.com/docker/compose/issues?q=is%3Aopen+is%3Aissue+label%3Aexp%2Fbeginner) are a good starting point for people looking to make their first contribution to the project.
diff --git a/Dockerfile b/Dockerfile
index 432b2b9a83a..d934a8878c7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,211 +1,100 @@
-# syntax=docker/dockerfile:1
+ARG DOCKER_VERSION=19.03
+ARG PYTHON_VERSION=3.7.10
+ARG BUILD_ALPINE_VERSION=3.12
+ARG BUILD_CENTOS_VERSION=7
+ARG BUILD_DEBIAN_VERSION=slim-stretch
-# Copyright 2020 Docker Compose CLI authors
+ARG RUNTIME_ALPINE_VERSION=3.12
+ARG RUNTIME_CENTOS_VERSION=7
+ARG RUNTIME_DEBIAN_VERSION=stretch-slim
-# 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
+ARG DISTRO=alpine
-# http://www.apache.org/licenses/LICENSE-2.0
+FROM docker:${DOCKER_VERSION} AS docker-cli
-# 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.
-
-ARG GO_VERSION=1.26.5
-ARG XX_VERSION=1.9.0
-ARG GOLANGCI_LINT_VERSION=v2.11.3
-ARG ADDLICENSE_VERSION=v1.0.0
-
-ARG BUILD_TAGS="e2e"
-ARG DOCS_FORMATS="md,yaml"
-ARG LICENSE_FILES=".*\(Dockerfile\|Makefile\|\.go\|\.hcl\|\.sh\)"
-
-# xx is a helper for cross-compilation
-FROM --platform=${BUILDPLATFORM} tonistiigi/xx:${XX_VERSION} AS xx
-
-# osxcross contains the MacOSX cross toolchain for xx
-FROM crazymax/osxcross:15.5-alpine AS osxcross
-
-FROM golangci/golangci-lint:${GOLANGCI_LINT_VERSION}-alpine AS golangci-lint
-FROM ghcr.io/google/addlicense:${ADDLICENSE_VERSION} AS addlicense
-
-FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine3.23 AS base
-COPY --from=xx / /
+FROM python:${PYTHON_VERSION}-alpine${BUILD_ALPINE_VERSION} AS build-alpine
RUN apk add --no-cache \
- clang \
- docker \
- file \
- findutils \
- git \
- make \
- protoc \
- protobuf-dev
-WORKDIR /src
-ENV CGO_ENABLED=0
-
-FROM base AS build-base
-COPY go.* .
-RUN --mount=type=cache,target=/go/pkg/mod \
- --mount=type=cache,target=/root/.cache/go-build \
- go mod download
-
-FROM build-base AS vendored
-RUN --mount=type=bind,target=.,rw \
- --mount=type=cache,target=/go/pkg/mod \
- go mod tidy && mkdir /out && cp go.mod go.sum /out
-
-FROM scratch AS vendor-update
-COPY --from=vendored /out /
-
-FROM vendored AS vendor-validate
-RUN --mount=type=bind,target=.,rw <&2 'ERROR: Vendor result differs. Please vendor your package with "make go-mod-tidy"'
- echo "$diff"
- exit 1
- fi
-EOT
-
-FROM build-base AS build
-ARG BUILD_TAGS
-ARG BUILD_FLAGS
-ARG TARGETPLATFORM
-ARG MACOSX_VERSION_MIN=11.0
-RUN --mount=type=bind,target=. \
- --mount=type=cache,target=/root/.cache \
- --mount=type=cache,target=/go/pkg/mod \
- --mount=type=bind,from=osxcross,src=/osxsdk,target=/xx-sdk \
- xx-go --wrap && \
- if [ "$(xx-info os)" == "darwin" ]; then export CGO_ENABLED=1; export BUILD_TAGS=fsnotify,$BUILD_TAGS; fi && \
- make build GO_BUILDTAGS="$BUILD_TAGS" DESTDIR=/out && \
- xx-verify --static /out/docker-compose
-
-FROM build-base AS lint
-ARG BUILD_TAGS
-ENV GOLANGCI_LINT_CACHE=/cache/golangci-lint
-RUN --mount=type=bind,target=. \
- --mount=type=cache,target=/root/.cache \
- --mount=type=cache,target=/go/pkg/mod \
- --mount=type=cache,target=/cache/golangci-lint \
- --mount=from=golangci-lint,source=/usr/bin/golangci-lint,target=/usr/bin/golangci-lint \
- golangci-lint cache status && \
- golangci-lint run --build-tags "$BUILD_TAGS" ./...
-
-FROM build-base AS test
-ARG CGO_ENABLED=0
-ARG BUILD_TAGS
-RUN --mount=type=bind,target=. \
- --mount=type=cache,target=/root/.cache \
- --mount=type=cache,target=/go/pkg/mod \
- rm -rf /tmp/coverage && \
- mkdir -p /tmp/coverage && \
- rm -rf /tmp/report && \
- mkdir -p /tmp/report && \
- go run gotest.tools/gotestsum@latest --format testname --junitfile "/tmp/report/report.xml" -- -tags "$BUILD_TAGS" -v -cover -covermode=atomic $(go list $(TAGS) ./... | grep -vE 'e2e') -args -test.gocoverdir="/tmp/coverage" && \
- go tool covdata percent -i=/tmp/coverage
-
-FROM scratch AS test-coverage
-COPY --from=test --link /tmp/coverage /
-COPY --from=test --link /tmp/report /
-
-FROM base AS license-set
-ARG LICENSE_FILES
-RUN --mount=type=bind,target=.,rw \
- --mount=from=addlicense,source=/app/addlicense,target=/usr/bin/addlicense \
- find . -regex "${LICENSE_FILES}" | xargs addlicense -c 'Docker Compose CLI' -l apache && \
- mkdir /out && \
- find . -regex "${LICENSE_FILES}" | cpio -pdm /out
-
-FROM scratch AS license-update
-COPY --from=set /out /
-
-FROM base AS license-validate
-ARG LICENSE_FILES
-RUN --mount=type=bind,target=. \
- --mount=from=addlicense,source=/app/addlicense,target=/usr/bin/addlicense \
- find . -regex "${LICENSE_FILES}" | xargs addlicense -check -c 'Docker Compose CLI' -l apache -ignore validate -ignore testdata -ignore resolvepath -v
-
-FROM base AS docsgen
-WORKDIR /src
-RUN --mount=target=. \
- --mount=target=/root/.cache,type=cache \
- --mount=type=cache,target=/go/pkg/mod \
- go build -o /out/docsgen ./docs/yaml/main/generate.go
-
-FROM --platform=${BUILDPLATFORM} alpine AS docs-build
-RUN apk add --no-cache rsync git
-WORKDIR /src
-COPY --from=docsgen /out/docsgen /usr/bin
-ARG DOCS_FORMATS
-RUN --mount=target=/context \
- --mount=target=.,type=tmpfs <&2 'ERROR: Docs result differs. Please update with "make docs"'
- git status --porcelain -- docs/reference
- exit 1
- fi
-EOT
-
-FROM scratch AS binary-unix
-COPY --link --from=build /out/docker-compose /
-FROM binary-unix AS binary-darwin
-FROM binary-unix AS binary-linux
-FROM scratch AS binary-windows
-COPY --link --from=build /out/docker-compose /docker-compose.exe
-FROM binary-$TARGETOS AS binary
-# enable scanning for this stage
-ARG BUILDKIT_SBOM_SCAN_STAGE=true
-
-FROM --platform=$BUILDPLATFORM alpine AS releaser
-WORKDIR /work
-ARG TARGETOS
+ bash \
+ build-base \
+ ca-certificates \
+ curl \
+ gcc \
+ git \
+ libc-dev \
+ libffi-dev \
+ libgcc \
+ make \
+ musl-dev \
+ openssl \
+ openssl-dev \
+ zlib-dev
+ENV BUILD_BOOTLOADER=1
+
+FROM python:${PYTHON_VERSION}-${BUILD_DEBIAN_VERSION} AS build-debian
+RUN apt-get update && apt-get install --no-install-recommends -y \
+ curl \
+ gcc \
+ git \
+ libc-dev \
+ libffi-dev \
+ libgcc-6-dev \
+ libssl-dev \
+ make \
+ openssl \
+ zlib1g-dev
+
+FROM centos:${BUILD_CENTOS_VERSION} AS build-centos
+RUN yum install -y \
+ gcc \
+ git \
+ libffi-devel \
+ make \
+ openssl \
+ openssl-devel
+WORKDIR /tmp/python3/
+ARG PYTHON_VERSION
+RUN curl -L https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz | tar xzf - \
+ && cd Python-${PYTHON_VERSION} \
+ && ./configure --enable-optimizations --enable-shared --prefix=/usr LDFLAGS="-Wl,-rpath /usr/lib" \
+ && make altinstall
+RUN alternatives --install /usr/bin/python python /usr/bin/python2.7 50
+RUN alternatives --install /usr/bin/python python /usr/bin/python$(echo "${PYTHON_VERSION%.*}") 60
+RUN curl https://bootstrap.pypa.io/get-pip.py | python -
+
+FROM build-${DISTRO} AS build
+ENTRYPOINT ["sh", "/usr/local/bin/docker-compose-entrypoint.sh"]
+WORKDIR /code/
+COPY docker-compose-entrypoint.sh /usr/local/bin/
+COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
+RUN pip install \
+ virtualenv==20.4.0 \
+ tox==3.21.2
+COPY requirements-dev.txt .
+COPY requirements-indirect.txt .
+COPY requirements.txt .
+RUN pip install -r requirements.txt -r requirements-indirect.txt -r requirements-dev.txt
+COPY .pre-commit-config.yaml .
+COPY tox.ini .
+COPY setup.py .
+COPY README.md .
+COPY compose compose/
+RUN tox -e py37 --notest
+COPY . .
+ARG GIT_COMMIT=unknown
+ENV DOCKER_COMPOSE_GITSHA=$GIT_COMMIT
+RUN script/build/linux-entrypoint
+
+FROM scratch AS bin
ARG TARGETARCH
-ARG TARGETVARIANT
-RUN --mount=from=binary \
- mkdir -p /out && \
- # TODO: should just use standard arch
- TARGETARCH=$([ "$TARGETARCH" = "amd64" ] && echo "x86_64" || echo "$TARGETARCH"); \
- TARGETARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "$TARGETARCH"); \
- cp docker-compose* "/out/docker-compose-${TARGETOS}-${TARGETARCH}${TARGETVARIANT}$(ls docker-compose* | sed -e 's/^docker-compose//')"
-
-FROM scratch AS release
-COPY --from=releaser /out/ /
-
-FROM --platform=$BUILDPLATFORM alpine AS module-releaser
-WORKDIR /work
-ARG TARGETOS
-RUN --mount=from=binary \
- mkdir -p /cli-plugins/compose/$TARGETOS && \
- cp docker-compose* "/cli-plugins/compose/$TARGETOS/docker-compose$(ls docker-compose* | sed -e 's/^docker-compose//')"
-
-FROM scratch AS module
ARG TARGETOS
-COPY --from=module-releaser /cli-plugins/compose/$TARGETOS /cli-plugins/compose/$TARGETOS
-COPY ./desktop-module/module-metadata.json /
-COPY LICENSE /
+COPY --from=build /usr/local/bin/docker-compose /docker-compose-${TARGETOS}-${TARGETARCH}
+
+FROM alpine:${RUNTIME_ALPINE_VERSION} AS runtime-alpine
+FROM debian:${RUNTIME_DEBIAN_VERSION} AS runtime-debian
+FROM centos:${RUNTIME_CENTOS_VERSION} AS runtime-centos
+FROM runtime-${DISTRO} AS runtime
+COPY docker-compose-entrypoint.sh /usr/local/bin/
+ENTRYPOINT ["sh", "/usr/local/bin/docker-compose-entrypoint.sh"]
+COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
+COPY --from=build /usr/local/bin/docker-compose /usr/local/bin/docker-compose
diff --git a/INSTALL.md b/INSTALL.md
new file mode 100644
index 00000000000..502d50bf820
--- /dev/null
+++ b/INSTALL.md
@@ -0,0 +1,136 @@
+# Install Docker Compose
+
+This page contains information on how to install Docker Compose. You can run Compose on macOS, Windows, and 64-bit Linux.
+
+> ⚠️ The installation instructions on this page will help you to install Compose v1 which is a deprecated version. We recommend that you use the [latest version of Docker Compose](https://docs.docker.com/compose/install/).
+
+## Prerequisites
+
+Docker Compose relies on Docker Engine for any meaningful work, so make sure you
+have Docker Engine installed either locally or remote, depending on your setup.
+
+
+- Install
+ [Docker Engine](https://docs.docker.com/engine/install/#server)
+ for your OS and then come back here for
+ instructions on installing the Python version of Compose.
+
+- To run Compose as a non-root user, see [Manage Docker as a non-root user](https://docs.docker.com/engine/install/linux-postinstall/).
+
+## Install Compose
+
+
+Follow the instructions below to install Compose using the `pip`
+Python package manager or to install Compose as a container.
+
+> Install a different version
+>
+> The instructions below outline installation of the current stable release
+> (**v1.29.2**) of Compose. To install a different version of
+> Compose, replace the given release number with the one that you want. For instructions to install Compose 2.x.x on Linux, see [Install Compose 2.x.x on Linux](https://docs.docker.com/compose/install/#install-compose-on-linux-systems).
+>
+> Compose releases are also listed and available for direct download on the
+> [Compose repository release page on GitHub](https://github.com/docker/compose/releases).
+> To install a **pre-release** of Compose, refer to the [install pre-release builds](#install-pre-release-builds)
+> section.
+
+- [Install using pip](#install-using-pip)
+- [Install as a container](#install-as-a-container)
+
+#### Install using pip
+
+> For `alpine`, the following dependency packages are needed:
+> `py-pip`, `python3-dev`, `libffi-dev`, `openssl-dev`, `gcc`, `libc-dev`, `rust`, `cargo`, and `make`.
+{: .important}
+
+You can install Compose from
+[pypi](https://pypi.python.org/pypi/docker-compose) using `pip`. If you install
+using `pip`, we recommend that you use a
+[virtualenv](https://virtualenv.pypa.io/en/latest/) because many operating
+systems have python system packages that conflict with docker-compose
+dependencies. See the [virtualenv
+tutorial](https://docs.python-guide.org/dev/virtualenvs/) to get
+started.
+
+```console
+$ pip3 install docker-compose
+```
+
+If you are not using virtualenv,
+
+```console
+$ sudo pip install docker-compose
+```
+
+> pip version 6.0 or greater is required.
+
+#### Install as a container
+
+You can also run Compose inside a container, from a small bash script wrapper. To
+install Compose as a container run this command:
+
+```console
+$ sudo curl -L --fail https://github.com/docker/compose/releases/download/1.29.2/run.sh -o /usr/local/bin/docker-compose
+$ sudo chmod +x /usr/local/bin/docker-compose
+```
+
+
+### Install pre-release builds
+
+If you're interested in trying out a pre-release build, you can download release
+candidates from the [Compose repository release page on GitHub](https://github.com/docker/compose/releases).
+Follow the instructions from the link, which involves running the `curl` command
+in your terminal to download the binaries.
+
+Pre-releases built from the "master" branch are also available for download at
+[https://dl.bintray.com/docker-compose/master/](https://dl.bintray.com/docker-compose/master/).
+
+> Pre-release builds allow you to try out new features before they are released,
+> but may be less stable.
+
+----
+
+## Upgrading
+
+If you're upgrading from Compose 1.2 or earlier, remove or
+migrate your existing containers after upgrading Compose. This is because, as of
+version 1.3, Compose uses Docker labels to keep track of containers, and your
+containers need to be recreated to add the labels.
+
+If Compose detects containers that were created without labels, it refuses
+to run, so that you don't end up with two sets of them. If you want to keep using
+your existing containers (for example, because they have data volumes you want
+to preserve), you can use Compose 1.5.x to migrate them with the following
+command:
+
+```console
+$ docker-compose migrate-to-labels
+```
+
+Alternatively, if you're not worried about keeping them, you can remove them.
+Compose just creates new ones.
+
+```console
+$ docker container rm -f -v myapp_web_1 myapp_db_1 ...
+```
+
+## Uninstall
+
+To uninstall Docker Compose if you installed using `curl`:
+
+```console
+$ sudo rm /usr/local/bin/docker-compose
+```
+
+To uninstall Docker Compose if you installed using `pip`:
+
+```console
+$ pip uninstall docker-compose
+```
+
+> Got a "Permission denied" error?
+>
+> If you get a "Permission denied" error using either of the above
+> methods, you probably do not have the proper permissions to remove
+> `docker-compose`. To force the removal, prepend `sudo` to either of the above
+> commands and run again.
diff --git a/Jenkinsfile b/Jenkinsfile
new file mode 100644
index 00000000000..454f9bb852d
--- /dev/null
+++ b/Jenkinsfile
@@ -0,0 +1,117 @@
+#!groovy
+
+def dockerVersions = ['19.03.13']
+def baseImages = ['alpine', 'debian']
+def pythonVersions = ['py37']
+
+pipeline {
+ agent none
+
+ options {
+ skipDefaultCheckout(true)
+ buildDiscarder(logRotator(daysToKeepStr: '30'))
+ timeout(time: 2, unit: 'HOURS')
+ timestamps()
+ }
+ environment {
+ DOCKER_BUILDKIT="1"
+ }
+
+ stages {
+ stage('Build test images') {
+ // TODO use declarative 1.5.0 `matrix` once available on CI
+ parallel {
+ stage('alpine') {
+ agent {
+ label 'ubuntu-2004 && amd64 && !zfs && cgroup1'
+ }
+ steps {
+ buildImage('alpine')
+ }
+ }
+ stage('debian') {
+ agent {
+ label 'ubuntu-2004 && amd64 && !zfs && cgroup1'
+ }
+ steps {
+ buildImage('debian')
+ }
+ }
+ }
+ }
+ stage('Test') {
+ steps {
+ // TODO use declarative 1.5.0 `matrix` once available on CI
+ script {
+ def testMatrix = [:]
+ baseImages.each { baseImage ->
+ dockerVersions.each { dockerVersion ->
+ pythonVersions.each { pythonVersion ->
+ testMatrix["${baseImage}_${dockerVersion}_${pythonVersion}"] = runTests(dockerVersion, pythonVersion, baseImage)
+ }
+ }
+ }
+
+ parallel testMatrix
+ }
+ }
+ }
+ }
+}
+
+
+def buildImage(baseImage) {
+ def scmvar = checkout(scm)
+ def imageName = "dockerpinata/compose:${baseImage}-${scmvar.GIT_COMMIT}"
+ image = docker.image(imageName)
+
+ withDockerRegistry(credentialsId:'dockerbuildbot-index.docker.io') {
+ try {
+ image.pull()
+ } catch (Exception exc) {
+ ansiColor('xterm') {
+ sh """docker build -t ${imageName} \\
+ --target build \\
+ --build-arg DISTRO="${baseImage}" \\
+ --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT}" \\
+ .\\
+ """
+ sh "docker push ${imageName}"
+ }
+ echo "${imageName}"
+ return imageName
+ }
+ }
+}
+
+def runTests(dockerVersion, pythonVersion, baseImage) {
+ return {
+ stage("python=${pythonVersion} docker=${dockerVersion} ${baseImage}") {
+ node("ubuntu-2004 && amd64 && !zfs && cgroup1") {
+ def scmvar = checkout(scm)
+ def imageName = "dockerpinata/compose:${baseImage}-${scmvar.GIT_COMMIT}"
+ def storageDriver = sh(script: "docker info -f \'{{.Driver}}\'", returnStdout: true).trim()
+ echo "Using local system's storage driver: ${storageDriver}"
+ withDockerRegistry(credentialsId:'dockerbuildbot-index.docker.io') {
+ sh """docker run \\
+ -t \\
+ --rm \\
+ --privileged \\
+ --volume="\$(pwd)/.git:/code/.git" \\
+ --volume="/var/run/docker.sock:/var/run/docker.sock" \\
+ --volume="\${DOCKER_CONFIG}/config.json:/root/.docker/config.json" \\
+ -e "DOCKER_TLS_CERTDIR=" \\
+ -e "TAG=${imageName}" \\
+ -e "STORAGE_DRIVER=${storageDriver}" \\
+ -e "DOCKER_VERSIONS=${dockerVersion}" \\
+ -e "BUILD_NUMBER=${env.BUILD_NUMBER}" \\
+ -e "PY_TEST_VERSIONS=${pythonVersion}" \\
+ --entrypoint="script/test/ci" \\
+ ${imageName} \\
+ --verbose
+ """
+ }
+ }
+ }
+ }
+}
diff --git a/LICENSE b/LICENSE
index 7a4a3ea2424..27448585ad4 100644
--- a/LICENSE
+++ b/LICENSE
@@ -176,18 +176,7 @@
END OF TERMS AND CONDITIONS
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
+ Copyright 2014 Docker, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -199,4 +188,4 @@
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.
\ No newline at end of file
+ limitations under the License.
diff --git a/MAINTAINERS b/MAINTAINERS
new file mode 100644
index 00000000000..7e178147e84
--- /dev/null
+++ b/MAINTAINERS
@@ -0,0 +1,105 @@
+# Compose maintainers file
+#
+# This file describes who runs the docker/compose project and how.
+# This is a living document - if you see something out of date or missing, speak up!
+#
+# It is structured to be consumable by both humans and programs.
+# To extract its contents programmatically, use any TOML-compliant parser.
+#
+# This file is compiled into the MAINTAINERS file in docker/opensource.
+#
+[Org]
+ [Org."Core maintainers"]
+ people = [
+ "aiordache",
+ "ndeloof",
+ "rumpl",
+ "ulyssessouza",
+ ]
+ [Org.Alumni]
+ people = [
+ # Aanand Prasad is one of the two creators of the fig project
+ # which later went on to become docker-compose, and a longtime
+ # maintainer responsible for several keystone features
+ "aanand",
+ # Ben Firshman is also one of the fig creators and contributed
+ # heavily to the project's design and UX as well as the
+ # day-to-day maintenance
+ "bfirsh",
+ # Mazz Mosley made significant contributions to the project
+ # in 2015 with solid bugfixes and improved error handling
+ # among them
+ "mnowster",
+ # Daniel Nephin is one of the longest-running maintainers on
+ # the Compose project, and has contributed several major features
+ # including muti-file support, variable interpolation, secrets
+ # emulation and many more
+ "dnephin",
+
+ "shin-",
+ "mefyl",
+ "mnottale",
+ ]
+
+[people]
+
+# A reference list of all people associated with the project.
+# All other sections should refer to people by their canonical key
+# in the people section.
+
+ # ADD YOURSELF HERE IN ALPHABETICAL ORDER
+
+ [people.aanand]
+ Name = "Aanand Prasad"
+ Email = "aanand.prasad@gmail.com"
+ GitHub = "aanand"
+
+ [people.aiordache]
+ Name = "Anca Iordache"
+ Email = "anca.iordache@docker.com"
+ GitHub = "aiordache"
+
+ [people.bfirsh]
+ Name = "Ben Firshman"
+ Email = "ben@firshman.co.uk"
+ GitHub = "bfirsh"
+
+ [people.dnephin]
+ Name = "Daniel Nephin"
+ Email = "dnephin@gmail.com"
+ GitHub = "dnephin"
+
+ [people.mefyl]
+ Name = "Quentin Hocquet"
+ Email = "quentin.hocquet@docker.com"
+ GitHub = "mefyl"
+
+ [people.mnottale]
+ Name = "Matthieu Nottale"
+ Email = "matthieu.nottale@docker.com"
+ GitHub = "mnottale"
+
+ [people.mnowster]
+ Name = "Mazz Mosley"
+ Email = "mazz@houseofmnowster.com"
+ GitHub = "mnowster"
+
+ [people.ndeloof]
+ Name = "Nicolas De Loof"
+ Email = "nicolas.deloof@gmail.com"
+ GitHub = "ndeloof"
+
+ [people.rumpl]
+ Name = "Djordje Lukic"
+ Email = "djordje.lukic@docker.com"
+ GitHub = "rumpl"
+
+ [people.shin-]
+ Name = "Joffrey F"
+ Email = "f.joffrey@gmail.com"
+ GitHub = "shin-"
+
+ [people.ulyssessouza]
+ Name = "Ulysses Domiciano Souza"
+ Email = "ulysses.souza@docker.com"
+ GitHub = "ulyssessouza"
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 00000000000..313b4e00814
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,15 @@
+include Dockerfile
+include LICENSE
+include requirements-indirect.txt
+include requirements.txt
+include requirements-dev.txt
+include tox.ini
+include *.md
+include README.md
+include compose/config/*.json
+include compose/GITSHA
+recursive-include contrib/completion *
+recursive-include tests *
+global-exclude *.pyc
+global-exclude *.pyo
+global-exclude *.un~
diff --git a/Makefile b/Makefile
index 3924a1c2e9c..0a7a5c366b4 100644
--- a/Makefile
+++ b/Makefile
@@ -1,162 +1,57 @@
-# Copyright 2020 Docker Compose CLI authors
+TAG = "docker-compose:alpine-$(shell git rev-parse --short HEAD)"
+GIT_VOLUME = "--volume=$(shell pwd)/.git:/code/.git"
-# 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
+DOCKERFILE ?="Dockerfile"
+DOCKER_BUILD_TARGET ?="build"
-# http://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.
-
-PKG := github.com/docker/compose/v5
-VERSION ?= $(shell git describe --match 'v[0-9]*' --dirty='.m' --always --tags)
-
-GO_LDFLAGS ?= -w -X ${PKG}/internal.Version=${VERSION}
-GO_BUILDTAGS ?= e2e
-DRIVE_PREFIX?=
-ifeq ($(OS),Windows_NT)
- DETECTED_OS = Windows
- DRIVE_PREFIX=C:
-else
- DETECTED_OS = $(shell uname -s)
-endif
-
-ifeq ($(DETECTED_OS),Windows)
- BINARY_EXT=.exe
-endif
-
-BUILD_FLAGS?=
-TEST_FLAGS?=
-E2E_TEST?=
-ifneq ($(E2E_TEST),)
- TEST_FLAGS:=$(TEST_FLAGS) -run '$(E2E_TEST)'
+UNAME_S := $(shell uname -s)
+ifeq ($(UNAME_S),Linux)
+ BUILD_SCRIPT = linux
endif
-
-EXCLUDE_E2E_TESTS?=
-ifneq ($(EXCLUDE_E2E_TESTS),)
- TEST_FLAGS:=$(TEST_FLAGS) -skip '$(EXCLUDE_E2E_TESTS)'
+ifeq ($(UNAME_S),Darwin)
+ BUILD_SCRIPT = osx
endif
-BUILDX_CMD ?= docker buildx
-
-# DESTDIR overrides the output path for binaries and other artifacts
-# this is used by docker/docker-ce-packaging for the apt/rpm builds,
-# so it's important that the resulting binary ends up EXACTLY at the
-# path $DESTDIR/docker-compose when specified.
-#
-# See https://github.com/docker/docker-ce-packaging/blob/e43fbd37e48fde49d907b9195f23b13537521b94/rpm/SPECS/docker-compose-plugin.spec#L47
-#
-# By default, all artifacts go to subdirectories under ./bin/ in the
-# repo root, e.g. ./bin/build, ./bin/coverage, ./bin/release.
-DESTDIR ?=
-
-all: build
-
-.PHONY: build ## Build the compose cli-plugin
-build:
- GO111MODULE=on go build $(BUILD_FLAGS) -trimpath -tags "$(GO_BUILDTAGS)" -ldflags "$(GO_LDFLAGS)" -o "$(or $(DESTDIR),./bin/build)/docker-compose$(BINARY_EXT)" ./cmd
-
-.PHONY: binary
-binary:
- BUILD_TAGS="$(GO_BUILDTAGS)" $(BUILDX_CMD) bake binary
-
-.PHONY: binary-with-coverage
-binary-with-coverage:
- BUILD_TAGS="$(GO_BUILDTAGS)" $(BUILDX_CMD) bake binary-with-coverage
-
-.PHONY: install
-install: binary
- mkdir -p ~/.docker/cli-plugins
- install $(or $(DESTDIR),./bin/build)/docker-compose ~/.docker/cli-plugins/docker-compose
+COMPOSE_SPEC_SCHEMA_PATH = "compose/config/compose_spec.json"
+COMPOSE_SPEC_RAW_URL = "https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json"
-.PHONY: e2e-compose
-e2e-compose: example-provider ## Run end to end local tests in plugin mode. Set E2E_TEST=TestName to run a single test
- go run gotest.tools/gotestsum@latest --format testname --junitfile "/tmp/report/report.xml" -- -v $(TEST_FLAGS) -count=1 ./pkg/e2e
+all: cli
-.PHONY: e2e-compose-standalone
-e2e-compose-standalone: ## Run End to end local tests in standalone mode. Set E2E_TEST=TestName to run a single test
- go run gotest.tools/gotestsum@latest --format testname --junitfile "/tmp/report/report.xml" -- $(TEST_FLAGS) -v -count=1 -parallel=1 --tags=standalone ./pkg/e2e
+cli: download-compose-spec ## Compile the cli
+ ./script/build/$(BUILD_SCRIPT)
-.PHONY: build-and-e2e-compose
-build-and-e2e-compose: build e2e-compose ## Compile the compose cli-plugin and run end to end local tests in plugin mode. Set E2E_TEST=TestName to run a single test
+download-compose-spec: ## Download the compose-spec schema from it's repo
+ curl -so $(COMPOSE_SPEC_SCHEMA_PATH) $(COMPOSE_SPEC_RAW_URL)
-.PHONY: build-and-e2e-compose-standalone
-build-and-e2e-compose-standalone: build e2e-compose-standalone ## Compile the compose cli-plugin and run End to end local tests in standalone mode. Set E2E_TEST=TestName to run a single test
-
-.PHONY: example-provider
-example-provider: ## build example provider for e2e tests
- go build -o bin/build/example-provider docs/examples/provider.go
-
-.PHONY: mocks
-mocks:
- mockgen --version >/dev/null 2>&1 || go install go.uber.org/mock/mockgen@v0.4.0
- mockgen -destination pkg/mocks/mock_docker_cli.go -package mocks github.com/docker/cli/cli/command Cli
- mockgen -destination pkg/mocks/mock_docker_api.go -package mocks github.com/moby/moby/client APIClient
- mockgen -destination pkg/mocks/mock_docker_compose_api.go -package mocks -source=./pkg/api/api.go Service
-
-.PHONY: e2e
-e2e: e2e-compose e2e-compose-standalone ## Run end to end local tests in both modes. Set E2E_TEST=TestName to run a single test
-
-.PHONY: build-and-e2e
-build-and-e2e: build e2e-compose e2e-compose-standalone ## Compile the compose cli-plugin and run end to end local tests in both modes. Set E2E_TEST=TestName to run a single test
-
-.PHONY: cross
-cross: ## Compile the CLI for linux, darwin and windows
- $(BUILDX_CMD) bake binary-cross
-
-.PHONY: test
-test: ## Run unit tests
- $(BUILDX_CMD) bake test
-
-.PHONY: cache-clear
cache-clear: ## Clear the builder cache
- $(BUILDX_CMD) prune --force --filter type=exec.cachemount --filter=unused-for=24h
+ @docker builder prune --force --filter type=exec.cachemount --filter=unused-for=24h
-.PHONY: lint
-lint: ## run linter(s)
- $(BUILDX_CMD) bake lint
+base-image: ## Builds base image
+ docker build -f $(DOCKERFILE) -t $(TAG) --target $(DOCKER_BUILD_TARGET) .
-.PHONY: fmt
-fmt:
- gofumpt --version >/dev/null 2>&1 || go install mvdan.cc/gofumpt@latest
- gofumpt -w .
+lint: base-image ## Run linter
+ docker run --rm \
+ --tty \
+ $(GIT_VOLUME) \
+ $(TAG) \
+ tox -e pre-commit
-.PHONY: docs
-docs: ## generate documentation
- $(eval $@_TMP_OUT := $(shell mktemp -d -t compose-output.XXXXXXXXXX))
- $(BUILDX_CMD) bake --set "*.output=type=local,dest=$($@_TMP_OUT)" docs-update
- rm -rf ./docs/internal
- cp -R "$(DRIVE_PREFIX)$($@_TMP_OUT)"/out/* ./docs/
- rm -rf "$(DRIVE_PREFIX)$($@_TMP_OUT)"/*
+test-unit: base-image ## Run tests
+ docker run --rm \
+ --tty \
+ $(GIT_VOLUME) \
+ $(TAG) \
+ pytest -v tests/unit/
-.PHONY: validate-docs
-validate-docs: ## validate the doc does not change
- $(BUILDX_CMD) bake docs-validate
+test: ## Run all tests
+ ./script/test/default
-.PHONY: check-dependencies
-check-dependencies: ## check dependency updates
- go list -u -m -f '{{if not .Indirect}}{{if .Update}}{{.}}{{end}}{{end}}' all
-
-.PHONY: validate-headers
-validate-headers: ## Check license header for all files
- $(BUILDX_CMD) bake license-validate
-
-.PHONY: go-mod-tidy
-go-mod-tidy: ## Run go mod tidy in a container and output resulting go.mod and go.sum
- $(BUILDX_CMD) bake vendor-update
-
-.PHONY: validate-go-mod
-validate-go-mod: ## Validate go.mod and go.sum are up-to-date
- $(BUILDX_CMD) bake vendor-validate
-
-validate: validate-go-mod validate-headers validate-docs ## Validate sources
-
-pre-commit: validate check-dependencies lint build test e2e-compose
+pre-commit: lint test-unit cli
help: ## Show help
@echo Please specify a build target. The choices are:
@grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
+
+FORCE:
+
+.PHONY: all cli download-compose-spec cache-clear base-image lint test-unit test pre-commit help
diff --git a/NOTICE b/NOTICE
deleted file mode 100644
index dffae91878a..00000000000
--- a/NOTICE
+++ /dev/null
@@ -1,4 +0,0 @@
-Docker Compose V2
-Copyright 2020 Docker Compose authors
-
-This product includes software developed at Docker, Inc. (https://www.docker.com).
diff --git a/README.md b/README.md
index 3481dfc7656..110d5be76ca 100644
--- a/README.md
+++ b/README.md
@@ -1,66 +1,74 @@
-# Table of Contents
-- [Docker Compose](#docker-compose)
-- [Where to get Docker Compose](#where-to-get-docker-compose)
- + [Windows and macOS](#windows-and-macos)
- + [Linux](#linux)
-- [Quick Start](#quick-start)
-- [Contributing](#contributing)
-- [Legacy](#legacy)
-
-# Docker Compose
-
-[](https://github.com/docker/compose/releases/latest)
-[](https://pkg.go.dev/github.com/docker/compose/v5)
-[](https://github.com/docker/compose/actions?query=workflow%3Aci)
-[](https://codecov.io/gh/docker/compose)
-[](https://api.securityscorecards.dev/projects/github.com/docker/compose)
+Docker Compose
+==============
+[](https://ci-next.docker.com/public/job/compose/job/master/)
+

-Docker Compose is a tool for running multi-container applications on Docker
-defined using the [Compose file format](https://compose-spec.io).
-A Compose file is used to define how one or more containers that make up
-your application are configured.
-Once you have a Compose file, you can create and start your application with a
-single command: `docker compose up`.
+# :warning: *Compose V1 is DEPRECATED* :warning:
+Since [Compose V2 is now GA](https://www.docker.com/blog/announcing-compose-v2-general-availability/), Compose V1 is officially **End of Life**. This means that:
+- Active development and new features will only be added to the V2 codebase
+- Only security-related issues will be considered for V1
+
+Check out the [V2 branch here](https://github.com/docker/compose/tree/v2/)!!
+
+---------------------------------------------
+
+** Compose V2 is **Generally Available**! :star_struck: **
+---------------------------------------------
-> **Note**: About Docker Swarm
-> Docker Swarm used to rely on the legacy compose file format but did not adopt the compose specification
-> so is missing some of the recent enhancements in the compose syntax. After
-> [acquisition by Mirantis](https://www.mirantis.com/software/swarm/) swarm isn't maintained by Docker Inc, and
-> as such some Docker Compose features aren't accessible to swarm users.
+Check it out [here](https://github.com/docker/compose/tree/v2/)!
-# Where to get Docker Compose
+Read more on the [GA announcement here](https://www.docker.com/blog/announcing-compose-v2-general-availability/)
-### Windows and macOS
-Docker Compose is included in
-[Docker Desktop](https://www.docker.com/products/docker-desktop/)
-for Windows and macOS.
+---------------------------------------------
-### Linux
+V1 vs V2 transition :hourglass_flowing_sand:
+--------------------------------------------
-You can download Docker Compose binaries from the
-[release page](https://github.com/docker/compose/releases) on this repository.
+"Generally Available" will mean:
+- New features and bug fixes will only be considered in the V2 codebase
+- Users on Mac/Windows will be defaulted into Docker Compose V2, but can still opt out through the UI and the CLI. This means when running `docker-compose` you will actually be running `docker compose`
+- Our current goal is for users on Linux to receive Compose v2 with the latest version of the docker CLI, but is pending some technical discussion. Users will be able to use [compose switch](https://github.com/docker/compose-switch) to enable redirection of `docker-compose` to `docker compose`
+- Docker Compose V1 will continue to be maintained regarding security issues
+- [v2 branch](https://github.com/docker/compose/tree/v2) will become the default one at that time
-Rename the relevant binary for your OS to `docker-compose` and copy it to `$HOME/.docker/cli-plugins`
+:lock_with_ink_pen: Depending on the feedback we receive from the community of GA and the adoption on Linux, we will come up with a plan to deprecate v1, but as of right now there is no concrete timeline as we want the transition to be as smooth as possible for all users. It is important to note that we have no plans of removing any aliasing of `docker-compose` to `docker compose`. We want to make it as easy as possible to switch and not break any ones scripts. We will follow up with a blog post in the next few months with more information of an exact timeline of V1 being marked as deprecated and end of support for security issues. We’d love to hear your feedback! You can provide it [here](https://github.com/docker/roadmap/issues/257).
-Or copy it into one of these folders to install it system-wide:
+About
+-----
-* `/usr/local/lib/docker/cli-plugins` OR `/usr/local/libexec/docker/cli-plugins`
-* `/usr/lib/docker/cli-plugins` OR `/usr/libexec/docker/cli-plugins`
+Docker Compose is a tool for running multi-container applications on Docker
+defined using the [Compose file format](https://compose-spec.io).
+A Compose file is used to define how the one or more containers that make up
+your application are configured.
+Once you have a Compose file, you can create and start your application with a
+single command: `docker-compose up`.
-(might require making the downloaded file executable with `chmod +x`)
+Compose files can be used to deploy applications locally, or to the cloud on
+[Amazon ECS](https://aws.amazon.com/ecs) or
+[Microsoft ACI](https://azure.microsoft.com/services/container-instances/) using
+the Docker CLI. You can read more about how to do this:
+- [Compose for Amazon ECS](https://docs.docker.com/engine/context/ecs-integration/)
+- [Compose for Microsoft ACI](https://docs.docker.com/engine/context/aci-integration/)
+Where to get Docker Compose
+----------------------------
+
+All the instructions to install the Python version of Docker Compose, aka `v1`,
+are described in the [installation guide](./INSTALL.md).
+
+> ⚠️ This version is a deprecated version of Compose. We recommend that you use the [latest version of Docker Compose](https://docs.docker.com/compose/install/).
Quick Start
-----------
-Using Docker Compose is a three-step process:
+Using Docker Compose is basically a three-step process:
1. Define your app's environment with a `Dockerfile` so it can be
reproduced anywhere.
-2. Define the services that make up your app in `compose.yaml` so
+2. Define the services that make up your app in `docker-compose.yml` so
they can be run together in an isolated environment.
-3. Lastly, run `docker compose up` and Compose will start and run your entire
+3. Lastly, run `docker-compose up` and Compose will start and run your entire
app.
A Compose file looks like this:
@@ -77,16 +85,22 @@ services:
image: redis
```
+You can find examples of Compose applications in our
+[Awesome Compose repository](https://github.com/docker/awesome-compose).
+
+For more information about the Compose format, see the
+[Compose file reference](https://docs.docker.com/compose/compose-file/).
+
Contributing
------------
Want to help develop Docker Compose? Check out our
-[contributing documentation](CONTRIBUTING.md).
+[contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md).
If you find an issue, please report it on the
[issue tracker](https://github.com/docker/compose/issues/new/choose).
-Legacy
--------------
+Releasing
+---------
-The Python version of Compose is available under the `v1` [branch](https://github.com/docker/compose/tree/v1).
+Releases are built by maintainers, following an outline of the [release process](https://github.com/docker/compose/blob/master/project/RELEASE-PROCESS.md).
diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile
new file mode 100644
index 00000000000..4779c25a3e3
--- /dev/null
+++ b/Release.Jenkinsfile
@@ -0,0 +1,312 @@
+#!groovy
+
+def dockerVersions = ['19.03.13', '18.09.9']
+def baseImages = ['alpine', 'debian']
+def pythonVersions = ['py37']
+
+pipeline {
+ agent none
+
+ options {
+ skipDefaultCheckout(true)
+ buildDiscarder(logRotator(daysToKeepStr: '30'))
+ timeout(time: 2, unit: 'HOURS')
+ timestamps()
+ }
+ environment {
+ DOCKER_BUILDKIT="1"
+ }
+
+ stages {
+ stage('Build test images') {
+ // TODO use declarative 1.5.0 `matrix` once available on CI
+ parallel {
+ stage('alpine') {
+ agent {
+ label 'linux && docker && ubuntu-2004 && amd64 && cgroup1'
+ }
+ steps {
+ buildImage('alpine')
+ }
+ }
+ stage('debian') {
+ agent {
+ label 'linux && docker && ubuntu-2004 && amd64 && cgroup1'
+ }
+ steps {
+ buildImage('debian')
+ }
+ }
+ }
+ }
+ stage('Test') {
+ agent {
+ label 'linux && docker && ubuntu-2004 && amd64 && cgroup1'
+ }
+ steps {
+ // TODO use declarative 1.5.0 `matrix` once available on CI
+ script {
+ def testMatrix = [:]
+ baseImages.each { baseImage ->
+ dockerVersions.each { dockerVersion ->
+ pythonVersions.each { pythonVersion ->
+ testMatrix["${baseImage}_${dockerVersion}_${pythonVersion}"] = runTests(dockerVersion, pythonVersion, baseImage)
+ }
+ }
+ }
+
+ parallel testMatrix
+ }
+ }
+ }
+ stage('Generate Changelog') {
+ agent {
+ label 'linux && docker && ubuntu-2004 && amd64 && cgroup1'
+ }
+ steps {
+ checkout scm
+ withCredentials([string(credentialsId: 'github-compose-release-test-token', variable: 'GITHUB_TOKEN')]) {
+ sh "./script/release/generate_changelog.sh"
+ }
+ archiveArtifacts artifacts: 'CHANGELOG.md'
+ stash( name: "changelog", includes: 'CHANGELOG.md' )
+ }
+ }
+ stage('Package') {
+ parallel {
+ stage('macosx binary') {
+ agent {
+ label 'mac-python'
+ }
+ environment {
+ DEPLOYMENT_TARGET="10.11"
+ }
+ steps {
+ checkout scm
+ sh './script/setup/osx'
+ sh 'tox -e py39 -- tests/unit'
+ sh './script/build/osx'
+ dir ('dist') {
+ checksum('docker-compose-Darwin-x86_64')
+ checksum('docker-compose-Darwin-x86_64.tgz')
+ }
+ archiveArtifacts artifacts: 'dist/*', fingerprint: true
+ dir("dist") {
+ stash name: "bin-darwin"
+ }
+ }
+ }
+ stage('linux binary') {
+ agent {
+ label 'linux && docker && ubuntu-2004 && amd64 && cgroup1'
+ }
+ steps {
+ checkout scm
+ sh ' ./script/build/linux'
+ dir ('dist') {
+ checksum('docker-compose-Linux-x86_64')
+ }
+ archiveArtifacts artifacts: 'dist/*', fingerprint: true
+ dir("dist") {
+ stash name: "bin-linux"
+ }
+ }
+ }
+ stage('windows binary') {
+ agent {
+ label 'windows-python'
+ }
+ environment {
+ PATH = "C:\\Python39;C:\\Python39\\Scripts;$PATH"
+ }
+ steps {
+ checkout scm
+ bat 'tox.exe -e py39 -- tests/unit'
+ powershell '.\\script\\build\\windows.ps1'
+ dir ('dist') {
+ checksum('docker-compose-Windows-x86_64.exe')
+ }
+ archiveArtifacts artifacts: 'dist/*', fingerprint: true
+ dir("dist") {
+ stash name: "bin-win"
+ }
+ }
+ }
+ stage('alpine image') {
+ agent {
+ label 'linux && docker && ubuntu-2004 && amd64 && cgroup1'
+ }
+ steps {
+ buildRuntimeImage('alpine')
+ }
+ }
+ stage('debian image') {
+ agent {
+ label 'linux && docker && ubuntu-2004 && amd64 && cgroup1'
+ }
+ steps {
+ buildRuntimeImage('debian')
+ }
+ }
+ }
+ }
+ stage('Release') {
+ when {
+ buildingTag()
+ }
+ parallel {
+ stage('Pushing images') {
+ agent {
+ label 'linux && docker && ubuntu-2004 && amd64 && cgroup1'
+ }
+ steps {
+ pushRuntimeImage('alpine')
+ pushRuntimeImage('debian')
+ }
+ }
+ stage('Creating Github Release') {
+ agent {
+ label 'linux && docker && ubuntu-2004 && amd64 && cgroup1'
+ }
+ environment {
+ GITHUB_TOKEN = credentials('github-release-token')
+ }
+ steps {
+ checkout scm
+ sh 'mkdir -p dist'
+ dir("dist") {
+ unstash "bin-darwin"
+ unstash "bin-linux"
+ unstash "bin-win"
+ unstash "changelog"
+ sh("""
+ curl -SfL https://github.com/github/hub/releases/download/v2.13.0/hub-linux-amd64-2.13.0.tgz | tar xzv --wildcards 'hub-*/bin/hub' --strip=2
+ ./hub release create --draft --prerelease=${env.TAG_NAME !=~ /v[0-9\.]+/} \\
+ -a docker-compose-Darwin-x86_64 \\
+ -a docker-compose-Darwin-x86_64.sha256 \\
+ -a docker-compose-Darwin-x86_64.tgz \\
+ -a docker-compose-Darwin-x86_64.tgz.sha256 \\
+ -a docker-compose-Linux-x86_64 \\
+ -a docker-compose-Linux-x86_64.sha256 \\
+ -a docker-compose-Windows-x86_64.exe \\
+ -a docker-compose-Windows-x86_64.exe.sha256 \\
+ -a ../script/run/run.sh \\
+ -F CHANGELOG.md \${TAG_NAME}
+ """)
+ }
+ }
+ }
+ stage('Publishing Python packages') {
+ agent {
+ label 'linux && docker && ubuntu-2004 && amd64 && cgroup1'
+ }
+ environment {
+ PYPIRC = credentials('pypirc-docker-dsg-cibot')
+ }
+ steps {
+ checkout scm
+ sh """
+ rm -rf build/ dist/
+ pip3 install wheel
+ python3 setup.py sdist bdist_wheel
+ pip3 install twine
+ ~/.local/bin/twine upload --config-file ${PYPIRC} ./dist/docker-compose-*.tar.gz ./dist/docker_compose-*-py2.py3-none-any.whl
+ """
+ }
+ }
+ }
+ }
+ }
+}
+
+
+def buildImage(baseImage) {
+ def scmvar = checkout(scm)
+ def imageName = "dockerpinata/compose:${baseImage}-${scmvar.GIT_COMMIT}"
+ image = docker.image(imageName)
+
+ withDockerRegistry(credentialsId:'dockerbuildbot-index.docker.io') {
+ try {
+ image.pull()
+ } catch (Exception exc) {
+ ansiColor('xterm') {
+ sh """docker build -t ${imageName} \\
+ --target build \\
+ --build-arg DISTRO="${baseImage}" \\
+ --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT}" \\
+ .\\
+ """
+ sh "docker push ${imageName}"
+ }
+ echo "${imageName}"
+ return imageName
+ }
+ }
+}
+
+def runTests(dockerVersion, pythonVersion, baseImage) {
+ return {
+ stage("python=${pythonVersion} docker=${dockerVersion} ${baseImage}") {
+ node("linux && docker && ubuntu-2004 && amd64 && cgroup1") {
+ def scmvar = checkout(scm)
+ def imageName = "dockerpinata/compose:${baseImage}-${scmvar.GIT_COMMIT}"
+ def storageDriver = sh(script: "docker info -f \'{{.Driver}}\'", returnStdout: true).trim()
+ echo "Using local system's storage driver: ${storageDriver}"
+ withDockerRegistry(credentialsId:'dockerbuildbot-index.docker.io') {
+ sh """docker run \\
+ -t \\
+ --rm \\
+ --privileged \\
+ --volume="\$(pwd)/.git:/code/.git" \\
+ --volume="/var/run/docker.sock:/var/run/docker.sock" \\
+ --volume="\${DOCKER_CONFIG}/config.json:/root/.docker/config.json" \\
+ -e "DOCKER_TLS_CERTDIR=" \\
+ -e "TAG=${imageName}" \\
+ -e "STORAGE_DRIVER=${storageDriver}" \\
+ -e "DOCKER_VERSIONS=${dockerVersion}" \\
+ -e "BUILD_NUMBER=${env.BUILD_NUMBER}" \\
+ -e "PY_TEST_VERSIONS=${pythonVersion}" \\
+ --entrypoint="script/test/ci" \\
+ ${imageName} \\
+ --verbose
+ """
+ }
+ }
+ }
+ }
+}
+
+def buildRuntimeImage(baseImage) {
+ scmvar = checkout scm
+ def imageName = "docker/compose:${baseImage}-${env.BRANCH_NAME}"
+ ansiColor('xterm') {
+ sh """docker build -t ${imageName} \\
+ --build-arg DISTRO="${baseImage}" \\
+ --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT.take(7)}" \\
+ .
+ """
+ }
+ sh "mkdir -p dist"
+ sh "docker save ${imageName} -o dist/docker-compose-${baseImage}.tar"
+ stash name: "compose-${baseImage}", includes: "dist/docker-compose-${baseImage}.tar"
+}
+
+def pushRuntimeImage(baseImage) {
+ unstash "compose-${baseImage}"
+ sh "docker load -i dist/docker-compose-${baseImage}.tar"
+ withDockerRegistry(credentialsId: 'dockerhub-dockerdsgcibot') {
+ sh "docker push docker/compose:${baseImage}-${env.TAG_NAME}"
+ if (baseImage == "alpine" && env.TAG_NAME != null) {
+ sh "docker tag docker/compose:alpine-${env.TAG_NAME} docker/compose:${env.TAG_NAME}"
+ sh "docker push docker/compose:${env.TAG_NAME}"
+ }
+ }
+}
+
+def checksum(filepath) {
+ if (isUnix()) {
+ sh "openssl sha256 -r -out ${filepath}.sha256 ${filepath}"
+ } else {
+ powershell "(Get-FileHash -Path ${filepath} -Algorithm SHA256 | % hash).ToLower() + ' *${filepath}' | Out-File -encoding ascii ${filepath}.sha256"
+ }
+}
diff --git a/SWARM.md b/SWARM.md
new file mode 100644
index 00000000000..c6f378a9a34
--- /dev/null
+++ b/SWARM.md
@@ -0,0 +1 @@
+This file has moved to: https://docs.docker.com/compose/swarm/
diff --git a/bin/docker-compose b/bin/docker-compose
new file mode 100755
index 00000000000..5976e1d4aa5
--- /dev/null
+++ b/bin/docker-compose
@@ -0,0 +1,3 @@
+#!/usr/bin/env python
+from compose.cli.main import main
+main()
diff --git a/cmd/cmdtrace/cmd_span.go b/cmd/cmdtrace/cmd_span.go
deleted file mode 100644
index cce3e5db3f3..00000000000
--- a/cmd/cmdtrace/cmd_span.go
+++ /dev/null
@@ -1,148 +0,0 @@
-/*
- Copyright 2023 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package cmdtrace
-
-import (
- "context"
- "errors"
- "fmt"
- "sort"
- "strings"
- "time"
-
- dockercli "github.com/docker/cli/cli"
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
- flag "github.com/spf13/pflag"
- "go.opentelemetry.io/otel"
- "go.opentelemetry.io/otel/attribute"
- "go.opentelemetry.io/otel/codes"
- "go.opentelemetry.io/otel/trace"
-
- commands "github.com/docker/compose/v5/cmd/compose"
- "github.com/docker/compose/v5/internal/tracing"
-)
-
-// Setup should be called as part of the command's PersistentPreRunE
-// as soon as possible after initializing the dockerCli.
-//
-// It initializes the tracer for the CLI using both auto-detection
-// from the Docker context metadata as well as standard OTEL_ env
-// vars, creates a root span for the command, and wraps the actual
-// command invocation to ensure the span is properly finalized and
-// exported before exit.
-func Setup(cmd *cobra.Command, dockerCli command.Cli, args []string) error {
- tracingShutdown, err := tracing.InitTracing(dockerCli)
- if err != nil {
- return fmt.Errorf("initializing tracing: %w", err)
- }
-
- ctx := cmd.Context()
- ctx, cmdSpan := otel.Tracer("").Start(
- ctx,
- "cli/"+strings.Join(commandName(cmd), "-"),
- )
- cmdSpan.SetAttributes(
- attribute.StringSlice("cli.flags", getFlags(cmd.Flags())),
- attribute.Bool("cli.isatty", dockerCli.In().IsTerminal()),
- )
-
- cmd.SetContext(ctx)
- wrapRunE(cmd, cmdSpan, tracingShutdown)
- return nil
-}
-
-// wrapRunE injects a wrapper function around the command's actual RunE (or Run)
-// method. This is necessary to capture the command result for reporting as well
-// as flushing any spans before exit.
-//
-// Unfortunately, PersistentPostRun(E) can't be used for this purpose because it
-// only runs if RunE does _not_ return an error, but this should run unconditionally.
-func wrapRunE(c *cobra.Command, cmdSpan trace.Span, tracingShutdown tracing.ShutdownFunc) {
- origRunE := c.RunE
- if origRunE == nil {
- origRun := c.Run
- //nolint:unparam // wrapper function for RunE, always returns nil by design
- origRunE = func(cmd *cobra.Command, args []string) error {
- origRun(cmd, args)
- return nil
- }
- c.Run = nil
- }
-
- c.RunE = func(cmd *cobra.Command, args []string) error {
- cmdErr := origRunE(cmd, args)
- if cmdSpan != nil {
- if cmdErr != nil && !errors.Is(cmdErr, context.Canceled) {
- // default exit code is 1 if a more descriptive error
- // wasn't returned
- exitCode := 1
- var statusErr dockercli.StatusError
- if errors.As(cmdErr, &statusErr) {
- exitCode = statusErr.StatusCode
- }
- cmdSpan.SetStatus(codes.Error, "CLI command returned error")
- cmdSpan.RecordError(cmdErr, trace.WithAttributes(
- attribute.Int("exit_code", exitCode),
- ))
-
- } else {
- cmdSpan.SetStatus(codes.Ok, "")
- }
- cmdSpan.End()
- }
- if tracingShutdown != nil {
- // use background for root context because the cmd's context might have
- // been canceled already
- ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
- defer cancel()
- // TODO(milas): add an env var to enable logging from the
- // OTel components for debugging purposes
- _ = tracingShutdown(ctx)
- }
- return cmdErr
- }
-}
-
-// commandName returns the path components for a given command,
-// in reverse alphabetical order for consistent usage metrics.
-//
-// The root Compose command and anything before (i.e. "docker")
-// are not included.
-//
-// For example:
-// - docker compose alpha watch -> [watch, alpha]
-// - docker-compose up -> [up]
-func commandName(cmd *cobra.Command) []string {
- var name []string
- for c := cmd; c != nil; c = c.Parent() {
- if c.Name() == commands.PluginName {
- break
- }
- name = append(name, c.Name())
- }
- sort.Sort(sort.Reverse(sort.StringSlice(name)))
- return name
-}
-
-func getFlags(fs *flag.FlagSet) []string {
- var result []string
- fs.Visit(func(flag *flag.Flag) {
- result = append(result, flag.Name)
- })
- return result
-}
diff --git a/cmd/cmdtrace/cmd_span_test.go b/cmd/cmdtrace/cmd_span_test.go
deleted file mode 100644
index 27becd3c363..00000000000
--- a/cmd/cmdtrace/cmd_span_test.go
+++ /dev/null
@@ -1,113 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package cmdtrace
-
-import (
- "reflect"
- "testing"
-
- "github.com/spf13/cobra"
- flag "github.com/spf13/pflag"
-
- commands "github.com/docker/compose/v5/cmd/compose"
-)
-
-func TestGetFlags(t *testing.T) {
- // Initialize flagSet with flags
- fs := flag.NewFlagSet("up", flag.ContinueOnError)
- var (
- detach string
- timeout string
- )
- fs.StringVar(&detach, "detach", "d", "")
- fs.StringVar(&timeout, "timeout", "t", "")
- _ = fs.Set("detach", "detach")
- _ = fs.Set("timeout", "timeout")
-
- tests := []struct {
- name string
- input *flag.FlagSet
- expected []string
- }{
- {
- name: "NoFlags",
- input: flag.NewFlagSet("NoFlags", flag.ContinueOnError),
- expected: nil,
- },
- {
- name: "Flags",
- input: fs,
- expected: []string{"detach", "timeout"},
- },
- }
-
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- result := getFlags(test.input)
- if !reflect.DeepEqual(result, test.expected) {
- t.Errorf("Expected %v, but got %v", test.expected, result)
- }
- })
- }
-}
-
-func TestCommandName(t *testing.T) {
- tests := []struct {
- name string
- setupCmd func() *cobra.Command
- want []string
- }{
- {
- name: "docker compose alpha watch -> [watch, alpha]",
- setupCmd: func() *cobra.Command {
- dockerCmd := &cobra.Command{Use: "docker"}
- composeCmd := &cobra.Command{Use: commands.PluginName}
- alphaCmd := &cobra.Command{Use: "alpha"}
- watchCmd := &cobra.Command{Use: "watch"}
-
- dockerCmd.AddCommand(composeCmd)
- composeCmd.AddCommand(alphaCmd)
- alphaCmd.AddCommand(watchCmd)
-
- return watchCmd
- },
- want: []string{"watch", "alpha"},
- },
- {
- name: "docker-compose up -> [up]",
- setupCmd: func() *cobra.Command {
- dockerComposeCmd := &cobra.Command{Use: commands.PluginName}
- upCmd := &cobra.Command{Use: "up"}
-
- dockerComposeCmd.AddCommand(upCmd)
-
- return upCmd
- },
- want: []string{"up"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cmd := tt.setupCmd()
- got := commandName(cmd)
- if !reflect.DeepEqual(got, tt.want) {
- t.Errorf("commandName() = %v, want %v", got, tt.want)
- }
- })
- }
-}
diff --git a/cmd/compatibility/convert.go b/cmd/compatibility/convert.go
deleted file mode 100644
index 39d63114c88..00000000000
--- a/cmd/compatibility/convert.go
+++ /dev/null
@@ -1,108 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compatibility
-
-import (
- "fmt"
- "os"
- "slices"
- "strings"
-
- "github.com/docker/compose/v5/cmd/compose"
-)
-
-func getCompletionCommands() []string {
- return []string{
- "__complete",
- "__completeNoDesc",
- }
-}
-
-func getBoolFlags() []string {
- return []string{
- "--debug", "-D",
- "--verbose",
- "--tls",
- "--tlsverify",
- }
-}
-
-func getStringFlags() []string {
- return []string{
- "--tlscacert",
- "--tlscert",
- "--tlskey",
- "--host", "-H",
- "--context",
- "--log-level",
- }
-}
-
-// Convert transforms standalone docker-compose args into CLI plugin compliant ones
-func Convert(args []string) []string {
- var rootFlags []string
- command := []string{compose.PluginName}
- l := len(args)
-ARGS:
- for i := 0; i < l; i++ {
- arg := args[i]
- if slices.Contains(getCompletionCommands(), arg) {
- command = append([]string{arg}, command...)
- continue
- }
- if arg != "" && arg[0] != '-' {
- command = append(command, args[i:]...)
- break
- }
-
- switch arg {
- case "--verbose":
- arg = "--debug"
- case "-h":
- // docker cli has deprecated -h to avoid ambiguity with -H, while docker-compose still support it
- arg = "--help"
- case "--version", "-v":
- // redirect --version pseudo-command to actual command
- arg = "version"
- }
-
- if slices.Contains(getBoolFlags(), arg) {
- rootFlags = append(rootFlags, arg)
- continue
- }
- for _, flag := range getStringFlags() {
- if arg == flag {
- i++
- if i >= l {
- fmt.Fprintf(os.Stderr, "flag needs an argument: '%s'\n", arg)
- os.Exit(1)
- }
- rootFlags = append(rootFlags, arg, args[i])
- continue ARGS
- }
- if strings.HasPrefix(arg, flag) {
- _, val, found := strings.Cut(arg, "=")
- if found {
- rootFlags = append(rootFlags, flag, val)
- continue ARGS
- }
- }
- }
- command = append(command, arg)
- }
- return append(rootFlags, command...)
-}
diff --git a/cmd/compatibility/convert_test.go b/cmd/compatibility/convert_test.go
deleted file mode 100644
index ae01665e92a..00000000000
--- a/cmd/compatibility/convert_test.go
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compatibility
-
-import (
- "errors"
- "os"
- "os/exec"
- "testing"
-
- "gotest.tools/v3/assert"
-)
-
-func Test_convert(t *testing.T) {
- tests := []struct {
- name string
- args []string
- want []string
- wantErr bool
- }{
- {
- name: "compose only",
- args: []string{"up"},
- want: []string{"compose", "up"},
- },
- {
- name: "with context",
- args: []string{"--context", "foo", "-f", "compose.yaml", "up"},
- want: []string{"--context", "foo", "compose", "-f", "compose.yaml", "up"},
- },
- {
- name: "with context arg",
- args: []string{"--context=foo", "-f", "compose.yaml", "up"},
- want: []string{"--context", "foo", "compose", "-f", "compose.yaml", "up"},
- },
- {
- name: "with host",
- args: []string{"--host", "tcp://1.2.3.4", "up"},
- want: []string{"--host", "tcp://1.2.3.4", "compose", "up"},
- },
- {
- name: "compose --verbose",
- args: []string{"--verbose"},
- want: []string{"--debug", "compose"},
- },
- {
- name: "compose --version",
- args: []string{"--version"},
- want: []string{"compose", "version"},
- },
- {
- name: "compose -v",
- args: []string{"-v"},
- want: []string{"compose", "version"},
- },
- {
- name: "help",
- args: []string{"-h"},
- want: []string{"compose", "--help"},
- },
- {
- name: "issues/1962",
- args: []string{"psql", "-h", "postgres"},
- want: []string{"compose", "psql", "-h", "postgres"}, // -h should not be converted to --help
- },
- {
- name: "issues/8648",
- args: []string{"exec", "mongo", "mongo", "--host", "mongo"},
- want: []string{"compose", "exec", "mongo", "mongo", "--host", "mongo"}, // --host is passed to exec
- },
- {
- name: "issues/12",
- args: []string{"--log-level", "INFO", "up"},
- want: []string{"--log-level", "INFO", "compose", "up"},
- },
- {
- name: "empty string argument",
- args: []string{"--project-directory", "", "ps"},
- want: []string{"compose", "--project-directory", "", "ps"},
- },
- {
- name: "compose as project name",
- args: []string{"--project-name", "compose", "down", "--remove-orphans"},
- want: []string{"compose", "--project-name", "compose", "down", "--remove-orphans"},
- },
- {
- name: "completion command",
- args: []string{"__complete", "up"},
- want: []string{"__complete", "compose", "up"},
- },
- {
- name: "string flag without argument",
- args: []string{"--log-level"},
- wantErr: true,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if tt.wantErr {
- if os.Getenv("BE_CRASHER") == "1" {
- Convert(tt.args)
- return
- }
- cmd := exec.Command(os.Args[0], "-test.run=^"+t.Name()+"$")
- cmd.Env = append(os.Environ(), "BE_CRASHER=1")
- err := cmd.Run()
- var e *exec.ExitError
- if errors.As(err, &e) && !e.Success() {
- return
- }
- t.Fatalf("process ran with err %v, want exit status 1", err)
- } else {
- got := Convert(tt.args)
- assert.DeepEqual(t, tt.want, got)
- }
- })
- }
-}
diff --git a/cmd/compose/alpha.go b/cmd/compose/alpha.go
deleted file mode 100644
index 8acc969ca30..00000000000
--- a/cmd/compose/alpha.go
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
- 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
- http://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.
-*/
-
-package compose
-
-import (
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-)
-
-// alphaCommand groups all experimental subcommands
-func alphaCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- cmd := &cobra.Command{
- Short: "Experimental commands",
- Use: "alpha [COMMAND]",
- Hidden: true,
- Annotations: map[string]string{
- "experimentalCLI": "true",
- },
- }
- cmd.AddCommand(
- vizCommand(p, dockerCli, backendOptions),
- publishCommand(p, dockerCli, backendOptions),
- generateCommand(p, dockerCli, backendOptions),
- )
- return cmd
-}
diff --git a/cmd/compose/attach.go b/cmd/compose/attach.go
deleted file mode 100644
index 20f23fca20f..00000000000
--- a/cmd/compose/attach.go
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type attachOpts struct {
- *composeOptions
-
- service string
- index int
-
- detachKeys string
- noStdin bool
- proxy bool
-}
-
-func attachCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := attachOpts{
- composeOptions: &composeOptions{
- ProjectOptions: p,
- },
- }
- runCmd := &cobra.Command{
- Use: "attach [OPTIONS] SERVICE",
- Short: "Attach local standard input, output, and error streams to a service's running container",
- Args: cobra.MinimumNArgs(1),
- PreRunE: Adapt(func(ctx context.Context, args []string) error {
- opts.service = args[0]
- return nil
- }),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runAttach(ctx, dockerCli, backendOptions, opts)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
-
- runCmd.Flags().IntVar(&opts.index, "index", 0, "index of the container if service has multiple replicas.")
- runCmd.Flags().StringVarP(&opts.detachKeys, "detach-keys", "", "", "Override the key sequence for detaching from a container.")
-
- runCmd.Flags().BoolVar(&opts.noStdin, "no-stdin", false, "Do not attach STDIN")
- runCmd.Flags().BoolVar(&opts.proxy, "sig-proxy", true, "Proxy all received signals to the process")
- return runCmd
-}
-
-func runAttach(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts attachOpts) error {
- projectName, err := opts.toProjectName(ctx, dockerCli)
- if err != nil {
- return err
- }
-
- attachOpts := api.AttachOptions{
- Service: opts.service,
- Index: opts.index,
- DetachKeys: opts.detachKeys,
- NoStdin: opts.noStdin,
- Proxy: opts.proxy,
- }
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- return backend.Attach(ctx, projectName, attachOpts)
-}
diff --git a/cmd/compose/backend.go b/cmd/compose/backend.go
deleted file mode 100644
index cc7d07ac778..00000000000
--- a/cmd/compose/backend.go
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "time"
-
- "github.com/docker/cli/cli/command"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-// withBackend creates a compose backend and passes it to fn.
-func withBackend(dockerCli command.Cli, opts *BackendOptions, fn func(api.Compose) error) error {
- backend, err := compose.NewComposeService(dockerCli, opts.Options...)
- if err != nil {
- return err
- }
- return fn(backend)
-}
-
-// optionalTimeout converts an integer timeout (in seconds) into a *time.Duration.
-// If changed is false, nil is returned (no timeout was explicitly set).
-func optionalTimeout(t int, changed bool) *time.Duration {
- if !changed {
- return nil
- }
- d := time.Duration(t) * time.Second
- return &d
-}
diff --git a/cmd/compose/bridge.go b/cmd/compose/bridge.go
deleted file mode 100644
index 1e006506b5f..00000000000
--- a/cmd/compose/bridge.go
+++ /dev/null
@@ -1,155 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "io"
-
- "github.com/distribution/reference"
- "github.com/docker/cli/cli/command"
- "github.com/docker/go-units"
- "github.com/moby/moby/api/types/image"
- "github.com/moby/moby/client/pkg/stringid"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/pkg/bridge"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-func bridgeCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
- cmd := &cobra.Command{
- Use: "bridge CMD [OPTIONS]",
- Short: "Convert compose files into another model",
- TraverseChildren: true,
- }
- cmd.AddCommand(
- convertCommand(p, dockerCli),
- transformersCommand(dockerCli),
- )
- return cmd
-}
-
-func convertCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
- convertOpts := bridge.ConvertOptions{}
- cmd := &cobra.Command{
- Use: "convert",
- Short: "Convert compose files to Kubernetes manifests, Helm charts, or another model",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runConvert(ctx, dockerCli, p, convertOpts)
- }),
- }
- flags := cmd.Flags()
- flags.StringVarP(&convertOpts.Output, "output", "o", "out", "The output directory for the Kubernetes resources")
- flags.StringArrayVarP(&convertOpts.Transformations, "transformation", "t", nil, "Transformation to apply to compose model (default: docker/compose-bridge-kubernetes)")
- flags.StringVar(&convertOpts.Templates, "templates", "", "Directory containing transformation templates")
- return cmd
-}
-
-func runConvert(ctx context.Context, dockerCli command.Cli, p *ProjectOptions, opts bridge.ConvertOptions) error {
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return err
- }
-
- project, _, err := p.ToProject(ctx, dockerCli, backend, nil)
- if err != nil {
- return err
- }
- return bridge.Convert(ctx, dockerCli, project, opts)
-}
-
-func transformersCommand(dockerCli command.Cli) *cobra.Command {
- cmd := &cobra.Command{
- Use: "transformations CMD [OPTIONS]",
- Short: "Manage transformation images",
- }
- cmd.AddCommand(
- listTransformersCommand(dockerCli),
- createTransformerCommand(dockerCli),
- )
- return cmd
-}
-
-func listTransformersCommand(dockerCli command.Cli) *cobra.Command {
- options := lsOptions{}
- cmd := &cobra.Command{
- Use: "list",
- Aliases: []string{"ls"},
- Short: "List available transformations",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- transformers, err := bridge.ListTransformers(ctx, dockerCli)
- if err != nil {
- return err
- }
- return displayTransformer(dockerCli, transformers, options)
- }),
- }
- cmd.Flags().StringVar(&options.Format, "format", "table", "Format the output. Values: [table | json]")
- cmd.Flags().BoolVarP(&options.Quiet, "quiet", "q", false, "Only display transformer names")
- return cmd
-}
-
-func displayTransformer(dockerCli command.Cli, transformers []image.Summary, options lsOptions) error {
- if options.Quiet {
- for _, t := range transformers {
- if len(t.RepoTags) > 0 {
- _, _ = fmt.Fprintln(dockerCli.Out(), t.RepoTags[0])
- } else {
- _, _ = fmt.Fprintln(dockerCli.Out(), t.ID)
- }
- }
- return nil
- }
- return formatter.Print(transformers, options.Format, dockerCli.Out(),
- func(w io.Writer) {
- for _, img := range transformers {
- id := stringid.TruncateID(img.ID)
- size := units.HumanSizeWithPrecision(float64(img.Size), 3)
- repo, tag := "", ""
- if len(img.RepoTags) > 0 {
- ref, err := reference.ParseDockerRef(img.RepoTags[0])
- if err == nil {
- // ParseDockerRef will reject a local image ID
- repo = reference.FamiliarName(ref)
- if tagged, ok := ref.(reference.Tagged); ok {
- tag = tagged.Tag()
- }
- }
- }
-
- _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", id, repo, tag, size)
- }
- },
- "IMAGE ID", "REPO", "TAGS", "SIZE")
-}
-
-func createTransformerCommand(dockerCli command.Cli) *cobra.Command {
- var opts bridge.CreateTransformerOptions
- cmd := &cobra.Command{
- Use: "create [OPTION] PATH",
- Short: "Create a new transformation",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- opts.Dest = args[0]
- return bridge.CreateTransformer(ctx, dockerCli, opts)
- }),
- }
- cmd.Flags().StringVarP(&opts.From, "from", "f", "", "Existing transformation to copy (default: docker/compose-bridge-kubernetes)")
- return cmd
-}
diff --git a/cmd/compose/build.go b/cmd/compose/build.go
deleted file mode 100644
index 996cf4d9ef0..00000000000
--- a/cmd/compose/build.go
+++ /dev/null
@@ -1,178 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
-
- "github.com/compose-spec/compose-go/v2/cli"
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/command"
- cliopts "github.com/docker/cli/opts"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/cmd/display"
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type buildOptions struct {
- *ProjectOptions
- quiet bool
- pull bool
- push bool
- args []string
- noCache bool
- memory cliopts.MemBytes
- ssh string
- builder string
- deps bool
- print bool
- check bool
- sbom string
- provenance string
-}
-
-func (opts buildOptions) toAPIBuildOptions(services []string) (api.BuildOptions, error) {
- var SSHKeys []types.SSHKey
- if opts.ssh != "" {
- id, path, found := strings.Cut(opts.ssh, "=")
- if !found && id != "default" {
- return api.BuildOptions{}, fmt.Errorf("invalid ssh key %q", opts.ssh)
- }
- SSHKeys = append(SSHKeys, types.SSHKey{
- ID: id,
- Path: path,
- })
- }
- builderName := opts.builder
- if builderName == "" {
- builderName = os.Getenv("BUILDX_BUILDER")
- }
-
- uiMode := display.Mode
- if uiMode == display.ModeJSON {
- uiMode = "rawjson"
- }
-
- return api.BuildOptions{
- Pull: opts.pull,
- Push: opts.push,
- Progress: uiMode,
- Args: types.NewMappingWithEquals(opts.args),
- NoCache: opts.noCache,
- Quiet: opts.quiet,
- Services: services,
- Deps: opts.deps,
- Memory: int64(opts.memory),
- Print: opts.print,
- Check: opts.check,
- SSHs: SSHKeys,
- Builder: builderName,
- SBOM: opts.sbom,
- Provenance: opts.provenance,
- }, nil
-}
-
-func buildCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := buildOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "build [OPTIONS] [SERVICE...]",
- Short: "Build or rebuild services",
- PreRunE: Adapt(func(ctx context.Context, args []string) error {
- if opts.quiet {
- display.Mode = display.ModeQuiet
- devnull, err := os.Open(os.DevNull)
- if err != nil {
- return err
- }
- os.Stdout = devnull
- }
- return nil
- }),
- RunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
- if cmd.Flags().Changed("ssh") && opts.ssh == "" {
- opts.ssh = "default"
- }
- if cmd.Flags().Changed("progress") && opts.ssh == "" {
- fmt.Fprint(os.Stderr, "--progress is a global compose flag, better use `docker compose --progress xx build ...\n")
- }
- return runBuild(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := cmd.Flags()
- flags.BoolVar(&opts.push, "push", false, "Push service images")
- flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress the build output")
- flags.BoolVar(&opts.pull, "pull", false, "Always attempt to pull a newer version of the image")
- flags.StringArrayVar(&opts.args, "build-arg", []string{}, "Set build-time variables for services")
- flags.StringVar(&opts.ssh, "ssh", "", "Set SSH authentications used when building service images. (use 'default' for using your default SSH Agent)")
- flags.StringVar(&opts.builder, "builder", "", "Set builder to use")
- flags.BoolVar(&opts.deps, "with-dependencies", false, "Also build dependencies (transitively)")
- flags.StringVar(&opts.provenance, "provenance", "", `Add a provenance attestation`)
- flags.StringVar(&opts.sbom, "sbom", "", `Add a SBOM attestation`)
-
- flags.Bool("parallel", true, "Build images in parallel. DEPRECATED")
- flags.MarkHidden("parallel") //nolint:errcheck
- flags.Bool("compress", true, "Compress the build context using gzip. DEPRECATED")
- flags.MarkHidden("compress") //nolint:errcheck
- flags.Bool("force-rm", true, "Always remove intermediate containers. DEPRECATED")
- flags.MarkHidden("force-rm") //nolint:errcheck
- flags.BoolVar(&opts.noCache, "no-cache", false, "Do not use cache when building the image")
- flags.Bool("no-rm", false, "Do not remove intermediate containers after a successful build. DEPRECATED")
- flags.MarkHidden("no-rm") //nolint:errcheck
- flags.VarP(&opts.memory, "memory", "m", "Set memory limit for the build container. Not supported by BuildKit.")
- flags.StringVar(&p.Progress, "progress", "", fmt.Sprintf(`Set type of ui output (%s)`, strings.Join(printerModes, ", ")))
- flags.MarkHidden("progress") //nolint:errcheck
- flags.BoolVar(&opts.print, "print", false, "Print equivalent bake file")
- flags.BoolVar(&opts.check, "check", false, "Check build configuration")
-
- return cmd
-}
-
-func runBuild(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts buildOptions, services []string) error {
- if opts.print {
- backendOptions.Add(compose.WithEventProcessor(display.Quiet()))
- }
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
-
- opts.All = true // do not drop resources as build may involve some dependencies by additional_contexts
- project, _, err := opts.ToProject(ctx, dockerCli, backend, nil, cli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
-
- if err := applyPlatforms(project, false); err != nil {
- return err
- }
-
- apiBuildOptions, err := opts.toAPIBuildOptions(services)
- if err != nil {
- return err
- }
- apiBuildOptions.Attestations = true
-
- return backend.Build(ctx, project, apiBuildOptions)
-}
diff --git a/cmd/compose/commit.go b/cmd/compose/commit.go
deleted file mode 100644
index 730deb2e561..00000000000
--- a/cmd/compose/commit.go
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
-
- "github.com/docker/cli/cli/command"
- "github.com/docker/cli/opts"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type commitOptions struct {
- *ProjectOptions
-
- service string
- reference string
-
- pause bool
- comment string
- author string
- changes opts.ListOpts
-
- index int
-}
-
-func commitCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- options := commitOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "commit [OPTIONS] SERVICE [REPOSITORY[:TAG]]",
- Short: "Create a new image from a service container's changes",
- Args: cobra.RangeArgs(1, 2),
- PreRunE: Adapt(func(ctx context.Context, args []string) error {
- options.service = args[0]
- if len(args) > 1 {
- options.reference = args[1]
- }
-
- return nil
- }),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runCommit(ctx, dockerCli, backendOptions, options)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
-
- flags := cmd.Flags()
- flags.IntVar(&options.index, "index", 0, "index of the container if service has multiple replicas.")
-
- flags.BoolVarP(&options.pause, "pause", "p", true, "Pause container during commit")
- flags.StringVarP(&options.comment, "message", "m", "", "Commit message")
- flags.StringVarP(&options.author, "author", "a", "", `Author (e.g., "John Hannibal Smith ")`)
- options.changes = opts.NewListOpts(nil)
- flags.VarP(&options.changes, "change", "c", "Apply Dockerfile instruction to the created image")
-
- return cmd
-}
-
-func runCommit(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, options commitOptions) error {
- projectName, err := options.toProjectName(ctx, dockerCli)
- if err != nil {
- return err
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- return backend.Commit(ctx, projectName, api.CommitOptions{
- Service: options.service,
- Reference: options.reference,
- Pause: options.pause,
- Comment: options.comment,
- Author: options.author,
- Changes: options.changes,
- Index: options.index,
- })
-}
diff --git a/cmd/compose/completion.go b/cmd/compose/completion.go
deleted file mode 100644
index 7a472937c55..00000000000
--- a/cmd/compose/completion.go
+++ /dev/null
@@ -1,124 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "sort"
- "strings"
-
- "github.com/compose-spec/compose-go/v2/cli"
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-// validArgsFn defines a completion func to be returned to fetch completion options
-type validArgsFn func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective)
-
-func noCompletion() validArgsFn {
- return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- return []string{}, cobra.ShellCompDirectiveNoSpace
- }
-}
-
-func completeServiceNames(dockerCli command.Cli, p *ProjectOptions) validArgsFn {
- return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- p.Offline = true
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
-
- // only service names are needed, so skip environment resolution: a missing
- // env_file must not prevent completion
- project, _, err := p.ToProject(cmd.Context(), dockerCli, backend, nil, cli.WithoutEnvironmentResolution)
- if err != nil {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
- var values []string
- serviceNames := append(project.ServiceNames(), project.DisabledServiceNames()...)
- for _, s := range serviceNames {
- if toComplete == "" || strings.HasPrefix(s, toComplete) {
- values = append(values, s)
- }
- }
- return values, cobra.ShellCompDirectiveNoFileComp
- }
-}
-
-func completeProjectNames(dockerCli command.Cli, backendOptions *BackendOptions) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return nil, cobra.ShellCompDirectiveError
- }
-
- list, err := backend.List(cmd.Context(), api.ListOptions{
- All: true,
- })
- if err != nil {
- return nil, cobra.ShellCompDirectiveError
- }
- var values []string
- for _, stack := range list {
- if strings.HasPrefix(stack.Name, toComplete) {
- values = append(values, stack.Name)
- }
- }
- return values, cobra.ShellCompDirectiveNoFileComp
- }
-}
-
-func completeProfileNames(dockerCli command.Cli, p *ProjectOptions) validArgsFn {
- return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- p.Offline = true
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
-
- // only profile names are needed, so skip environment resolution: a missing
- // env_file must not prevent completion
- project, _, err := p.ToProject(cmd.Context(), dockerCli, backend, nil, cli.WithoutEnvironmentResolution)
- if err != nil {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
-
- allProfileNames := project.AllServices().GetProfiles()
- sort.Strings(allProfileNames)
-
- var values []string
- for _, profileName := range allProfileNames {
- if strings.HasPrefix(profileName, toComplete) {
- values = append(values, profileName)
- }
- }
- return values, cobra.ShellCompDirectiveNoFileComp
- }
-}
-
-func completeScaleArgs(dockerCli command.Cli, p *ProjectOptions) cobra.CompletionFunc {
- return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- completions, directive := completeServiceNames(dockerCli, p)(cmd, args, toComplete)
- for i, completion := range completions {
- completions[i] = completion + "="
- }
- return completions, directive
- }
-}
diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go
deleted file mode 100644
index 1544c402e52..00000000000
--- a/cmd/compose/compose.go
+++ /dev/null
@@ -1,736 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "os"
- "os/signal"
- "path/filepath"
- "strconv"
- "strings"
- "syscall"
-
- "github.com/compose-spec/compose-go/v2/cli"
- "github.com/compose-spec/compose-go/v2/dotenv"
- "github.com/compose-spec/compose-go/v2/loader"
- composepaths "github.com/compose-spec/compose-go/v2/paths"
- "github.com/compose-spec/compose-go/v2/types"
- composegoutils "github.com/compose-spec/compose-go/v2/utils"
- dockercli "github.com/docker/cli/cli"
- "github.com/docker/cli/cli-plugins/metadata"
- "github.com/docker/cli/cli/command"
- "github.com/docker/cli/pkg/kvfile"
- "github.com/morikuni/aec"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
- "github.com/spf13/pflag"
-
- "github.com/docker/compose/v5/cmd/display"
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/internal/tracing"
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
- "github.com/docker/compose/v5/pkg/remote"
- "github.com/docker/compose/v5/pkg/utils"
-)
-
-const (
- // ComposeParallelLimit set the limit running concurrent operation on docker engine
- ComposeParallelLimit = "COMPOSE_PARALLEL_LIMIT"
- // ComposeProjectName define the project name to be used, instead of guessing from parent directory
- ComposeProjectName = "COMPOSE_PROJECT_NAME"
- // ComposeCompatibility try to mimic compose v1 as much as possible
- ComposeCompatibility = api.ComposeCompatibility
- // ComposeRemoveOrphans remove "orphaned" containers, i.e. containers tagged for current project but not declared as service
- ComposeRemoveOrphans = "COMPOSE_REMOVE_ORPHANS"
- // ComposeIgnoreOrphans ignore "orphaned" containers
- ComposeIgnoreOrphans = "COMPOSE_IGNORE_ORPHANS"
- // ComposeEnvFiles defines the env files to use if --env-file isn't used
- ComposeEnvFiles = "COMPOSE_ENV_FILES"
- // ComposeMenu defines if the navigation menu should be rendered. Can be also set via --menu
- ComposeMenu = "COMPOSE_MENU"
- // ComposeProgress defines type of progress output, if --progress isn't used
- ComposeProgress = "COMPOSE_PROGRESS"
-)
-
-// rawEnv load a dot env file using docker/cli key=value parser, without attempt to interpolate or evaluate values
-func rawEnv(r io.Reader, filename string, vars map[string]string, lookup func(key string) (string, bool)) error {
- lines, err := kvfile.ParseFromReader(r, lookup)
- if err != nil {
- return fmt.Errorf("failed to parse env_file %s: %w", filename, err)
- }
- for _, line := range lines {
- key, value, _ := strings.Cut(line, "=")
- vars[key] = value
- }
- return nil
-}
-
-var stdioToStdout bool
-
-func init() {
- // compose evaluates env file values for interpolation
- // `raw` format allows to load env_file with the same parser used by docker run --env-file
- dotenv.RegisterFormat("raw", rawEnv)
-
- if v, ok := os.LookupEnv("COMPOSE_STATUS_STDOUT"); ok {
- stdioToStdout, _ = strconv.ParseBool(v)
- }
-}
-
-// Command defines a compose CLI command as a func with args
-type Command func(context.Context, []string) error
-
-// CobraCommand defines a cobra command function
-type CobraCommand func(context.Context, *cobra.Command, []string) error
-
-// AdaptCmd adapt a CobraCommand func to cobra library
-func AdaptCmd(fn CobraCommand) func(cmd *cobra.Command, args []string) error {
- return func(cmd *cobra.Command, args []string) error {
- ctx, cancel := context.WithCancel(cmd.Context())
-
- s := make(chan os.Signal, 1)
- signal.Notify(s, syscall.SIGTERM, syscall.SIGINT)
- go func() {
- <-s
- cancel()
- signal.Stop(s)
- close(s)
- }()
-
- err := fn(ctx, cmd, args)
- if api.IsErrCanceled(err) || errors.Is(ctx.Err(), context.Canceled) {
- err = dockercli.StatusError{
- StatusCode: 130,
- }
- }
- if display.Mode == display.ModeJSON {
- err = makeJSONError(err)
- }
- return err
- }
-}
-
-// Adapt a Command func to cobra library
-func Adapt(fn Command) func(cmd *cobra.Command, args []string) error {
- return AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
- return fn(ctx, args)
- })
-}
-
-type ProjectOptions struct {
- ProjectName string
- Profiles []string
- ConfigPaths []string
- WorkDir string
- ProjectDir string
- EnvFiles []string
- Compatibility bool
- Progress string
- Offline bool
- All bool
- insecureRegistries []string
- remoteLoadersOverride []loader.ResourceLoader
-}
-
-// ProjectFunc does stuff within a types.Project
-type ProjectFunc func(ctx context.Context, project *types.Project) error
-
-// ProjectServicesFunc does stuff within a types.Project and a selection of services
-type ProjectServicesFunc func(ctx context.Context, project *types.Project, services []string) error
-
-// WithProject creates a cobra run command from a ProjectFunc based on configured project options and selected services
-func (o *ProjectOptions) WithProject(fn ProjectFunc, dockerCli command.Cli) func(cmd *cobra.Command, args []string) error {
- return o.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error {
- return fn(ctx, project)
- })
-}
-
-// WithServices creates a cobra run command from a ProjectFunc based on configured project options and selected services
-func (o *ProjectOptions) WithServices(dockerCli command.Cli, fn ProjectServicesFunc) func(cmd *cobra.Command, args []string) error {
- return Adapt(func(ctx context.Context, services []string) error {
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return err
- }
-
- project, metrics, err := o.ToProject(ctx, dockerCli, backend, services, cli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
-
- ctx = context.WithValue(ctx, tracing.MetricsKey{}, metrics)
-
- project, err = project.WithServicesEnvironmentResolved(true)
- if err != nil {
- return err
- }
-
- return fn(ctx, project, services)
- })
-}
-
-type jsonErrorData struct {
- Error bool `json:"error,omitempty"`
- Message string `json:"message,omitempty"`
-}
-
-func errorAsJSON(message string) string {
- errorMessage := &jsonErrorData{
- Error: true,
- Message: message,
- }
- marshal, err := json.Marshal(errorMessage)
- if err == nil {
- return string(marshal)
- } else {
- return message
- }
-}
-
-func makeJSONError(err error) error {
- if err == nil {
- return nil
- }
- var statusErr dockercli.StatusError
- if errors.As(err, &statusErr) {
- return dockercli.StatusError{
- StatusCode: statusErr.StatusCode,
- Status: errorAsJSON(statusErr.Status),
- }
- }
- return fmt.Errorf("%s", errorAsJSON(err.Error()))
-}
-
-func (o *ProjectOptions) addProjectFlags(f *pflag.FlagSet) {
- f.StringArrayVar(&o.Profiles, "profile", []string{}, "Specify a profile to enable")
- f.StringVarP(&o.ProjectName, "project-name", "p", "", "Project name")
- f.StringArrayVarP(&o.ConfigPaths, "file", "f", []string{}, "Compose configuration files")
- f.StringArrayVar(&o.insecureRegistries, "insecure-registry", []string{}, "Use insecure registry to pull Compose OCI artifacts. Doesn't apply to images")
- _ = f.MarkHidden("insecure-registry")
- f.StringArrayVar(&o.EnvFiles, "env-file", defaultStringArrayVar(ComposeEnvFiles), "Specify an alternate environment file")
- f.StringVar(&o.ProjectDir, "project-directory", "", "Specify an alternate working directory\n(default: the path of the, first specified, Compose file)")
- f.StringVar(&o.WorkDir, "workdir", "", "DEPRECATED! USE --project-directory INSTEAD.\nSpecify an alternate working directory\n(default: the path of the, first specified, Compose file)")
- f.BoolVar(&o.Compatibility, "compatibility", false, "Run compose in backward compatibility mode")
- f.StringVar(&o.Progress, "progress", os.Getenv(ComposeProgress), fmt.Sprintf(`Set type of progress output (%s)`, strings.Join(printerModes, ", ")))
- f.BoolVar(&o.All, "all-resources", false, "Include all resources, even those not used by services")
- _ = f.MarkHidden("workdir")
-}
-
-// get default value for a command line flag that is set by a coma-separated value in environment variable
-func defaultStringArrayVar(env string) []string {
- return strings.FieldsFunc(os.Getenv(env), func(c rune) bool {
- return c == ','
- })
-}
-
-func (o *ProjectOptions) projectOrName(ctx context.Context, dockerCli command.Cli, services ...string) (*types.Project, string, error) {
- name := o.ProjectName
- var project *types.Project
- if len(o.ConfigPaths) > 0 || o.ProjectName == "" {
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return nil, "", err
- }
-
- p, _, err := o.ToProject(ctx, dockerCli, backend, services, cli.WithDiscardEnvFile, cli.WithoutEnvironmentResolution)
- if err != nil {
- envProjectName := os.Getenv(ComposeProjectName)
- if envProjectName != "" {
- return nil, envProjectName, nil
- }
- return nil, "", err
- }
- project = p
- name = p.Name
- }
- return project, name, nil
-}
-
-func (o *ProjectOptions) toProjectName(ctx context.Context, dockerCli command.Cli) (string, error) {
- if o.ProjectName != "" {
- return o.ProjectName, nil
- }
-
- envProjectName := os.Getenv(ComposeProjectName)
- if envProjectName != "" {
- return envProjectName, nil
- }
-
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return "", err
- }
-
- project, _, err := o.ToProject(ctx, dockerCli, backend, nil, cli.WithDiscardEnvFile, cli.WithoutEnvironmentResolution)
- if err != nil {
- return "", err
- }
- return project.Name, nil
-}
-
-func (o *ProjectOptions) ToModel(ctx context.Context, dockerCli command.Cli, services []string, po ...cli.ProjectOptionsFn) (map[string]any, error) {
- remotes := o.remoteLoaders(dockerCli)
- for _, r := range remotes {
- po = append(po, cli.WithResourceLoader(r))
- }
-
- options, err := o.toProjectOptions(po...)
- if err != nil {
- return nil, err
- }
-
- if o.Compatibility || utils.StringToBool(options.Environment[ComposeCompatibility]) {
- api.Separator = "_"
- }
-
- return options.LoadModel(ctx)
-}
-
-// ToProject loads a Compose project using the LoadProject API.
-// Accepts optional cli.ProjectOptionsFn to control loader behavior.
-func (o *ProjectOptions) ToProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, services []string, po ...cli.ProjectOptionsFn) (*types.Project, tracing.Metrics, error) {
- var metrics tracing.Metrics
- remotes := o.remoteLoaders(dockerCli)
-
- // Setup metrics listener to collect project data
- metricsListener := func(event string, metadata map[string]any) {
- switch event {
- case "extends":
- metrics.CountExtends++
- case "include":
- paths := metadata["path"].(types.StringList)
- for _, path := range paths {
- var isRemote bool
- for _, r := range remotes {
- if r.Accept(path) {
- isRemote = true
- break
- }
- }
- if isRemote {
- metrics.CountIncludesRemote++
- } else {
- metrics.CountIncludesLocal++
- }
- }
- }
- }
-
- loadOpts := api.ProjectLoadOptions{
- ProjectName: o.ProjectName,
- ConfigPaths: o.ConfigPaths,
- WorkingDir: o.ProjectDir,
- EnvFiles: o.EnvFiles,
- Profiles: o.Profiles,
- Services: services,
- Offline: o.Offline,
- All: o.All,
- Compatibility: o.Compatibility,
- ProjectOptionsFns: po,
- LoadListeners: []api.LoadListener{metricsListener},
- OCI: o.ociOptions(),
- }
-
- project, err := backend.LoadProject(ctx, loadOpts)
- if err != nil {
- return nil, metrics, err
- }
-
- return project, metrics, nil
-}
-
-func (o *ProjectOptions) remoteLoaders(dockerCli command.Cli) []loader.ResourceLoader {
- if o.remoteLoadersOverride != nil {
- return o.remoteLoadersOverride
- }
- if o.Offline {
- return nil
- }
- git := remote.NewGitRemoteLoader(dockerCli, o.Offline)
- oci := remote.NewOCIRemoteLoader(dockerCli, o.Offline, o.ociOptions())
- return []loader.ResourceLoader{git, oci}
-}
-
-// ociOptions builds the OCI loader configuration from the project options.
-// Both the primary project load and the loaders returned by remoteLoaders
-// must use this so the --insecure-registry flag is honored on every path
-// that pulls an OCI compose artifact (e.g. the interpolation-variable
-// re-load that `up` runs via ToModel). See docker/compose#13824.
-func (o *ProjectOptions) ociOptions() api.OCIOptions {
- return api.OCIOptions{
- InsecureRegistries: o.insecureRegistries,
- }
-}
-
-func (o *ProjectOptions) toProjectOptions(po ...cli.ProjectOptionsFn) (*cli.ProjectOptions, error) {
- opts := []cli.ProjectOptionsFn{
- cli.WithWorkingDirectory(o.ProjectDir),
- // First apply os.Environment, always win
- cli.WithOsEnv,
- }
-
- if _, present := os.LookupEnv("PWD"); !present {
- if pwd, err := os.Getwd(); err != nil {
- return nil, err
- } else {
- opts = append(opts, cli.WithEnv([]string{"PWD=" + pwd}))
- }
- }
-
- opts = append(opts,
- // Load PWD/.env if present and no explicit --env-file has been set
- cli.WithEnvFiles(o.EnvFiles...),
- // read dot env file to populate project environment
- cli.WithDotEnv,
- // get compose file path set by COMPOSE_FILE
- cli.WithConfigFileEnv,
- // if none was selected, get default compose.yaml file from current dir or parent folder
- cli.WithDefaultConfigPath,
- // .. and then, a project directory != PWD maybe has been set so let's load .env file
- cli.WithEnvFiles(o.EnvFiles...), //nolint:gocritic // intentionally applying cli.WithEnvFiles twice.
- cli.WithDotEnv, //nolint:gocritic // intentionally applying cli.WithDotEnv twice.
- // eventually COMPOSE_PROFILES should have been set
- cli.WithDefaultProfiles(o.Profiles...),
- cli.WithName(o.ProjectName),
- )
-
- return cli.NewProjectOptions(o.ConfigPaths, append(po, opts...)...)
-}
-
-// PluginName is the name of the plugin
-const PluginName = "compose"
-
-// RunningAsStandalone detects when running as a standalone program
-func RunningAsStandalone() bool {
- return len(os.Args) < 2 || os.Args[1] != metadata.MetadataSubcommandName && os.Args[1] != metadata.HookSubcommandName && os.Args[1] != PluginName
-}
-
-type BackendOptions struct {
- Options []compose.Option
-}
-
-func (o *BackendOptions) Add(option compose.Option) {
- o.Options = append(o.Options, option)
-}
-
-// RootCommand returns the compose command with its child commands
-func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command { //nolint:gocyclo
- opts := ProjectOptions{}
- var (
- ansi string
- noAnsi bool
- verbose bool
- version bool
- parallel int
- dryRun bool
- )
- c := &cobra.Command{
- Short: "Docker Compose",
- Long: "Define and run multi-container applications with Docker",
- Use: PluginName,
- TraverseChildren: true,
- // By default (no Run/RunE in parent c) for typos in subcommands, cobra displays the help of parent c but exit(0) !
- RunE: func(cmd *cobra.Command, args []string) error {
- if len(args) == 0 {
- return cmd.Help()
- }
- if version {
- return versionCommand(dockerCli).Execute()
- }
- _ = cmd.Help()
- return dockercli.StatusError{
- StatusCode: 1,
- Status: fmt.Sprintf("unknown docker command: %q", "compose "+args[0]),
- }
- },
- PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
- parent := cmd.Root()
- if parent != nil {
- parentPrerun := parent.PersistentPreRunE
- if parentPrerun != nil {
- err := parentPrerun(cmd, args)
- if err != nil {
- return err
- }
- }
- }
-
- if verbose {
- logrus.SetLevel(logrus.TraceLevel)
- }
-
- err := setEnvWithDotEnv(opts, dockerCli)
- if err != nil {
- return err
- }
- if noAnsi {
- if ansi != "auto" {
- return errors.New(`cannot specify DEPRECATED "--no-ansi" and "--ansi". Please use only "--ansi"`)
- }
- ansi = "never"
- fmt.Fprint(os.Stderr, "option '--no-ansi' is DEPRECATED ! Please use '--ansi' instead.\n")
- }
- if v, ok := os.LookupEnv("COMPOSE_ANSI"); ok && !cmd.Flags().Changed("ansi") {
- ansi = v
- }
- formatter.SetANSIMode(dockerCli, ansi)
-
- if noColor, ok := os.LookupEnv("NO_COLOR"); ok && noColor != "" {
- display.NoColor()
- formatter.SetANSIMode(dockerCli, formatter.Never)
- }
-
- switch ansi {
- case "never":
- display.Mode = display.ModePlain
- case "always":
- display.Mode = display.ModeTTY
- }
-
- detached, _ := cmd.Flags().GetBool("detach")
- ep, err := selectEventProcessor(dockerCli, opts.Progress, ansi, detached)
- if err != nil {
- return err
- }
- backendOptions.Add(compose.WithEventProcessor(ep))
-
- // (4) options validation / normalization
- if opts.WorkDir != "" {
- if opts.ProjectDir != "" {
- return errors.New(`cannot specify DEPRECATED "--workdir" and "--project-directory". Please use only "--project-directory" instead`)
- }
- opts.ProjectDir = opts.WorkDir
- fmt.Fprint(os.Stderr, aec.Apply("option '--workdir' is DEPRECATED at root level! Please use '--project-directory' instead.\n", aec.RedF))
- }
- for i, file := range opts.EnvFiles {
- file = composepaths.ExpandUser(file)
- if !filepath.IsAbs(file) {
- file, err := filepath.Abs(file)
- if err != nil {
- return err
- }
- opts.EnvFiles[i] = file
- } else {
- opts.EnvFiles[i] = file
- }
- }
-
- composeCmd := cmd
- for composeCmd.Name() != PluginName {
- if !composeCmd.HasParent() {
- return fmt.Errorf("error parsing command line, expected %q", PluginName)
- }
- composeCmd = composeCmd.Parent()
- }
-
- if v, ok := os.LookupEnv(ComposeParallelLimit); ok && !composeCmd.Flags().Changed("parallel") {
- i, err := strconv.Atoi(v)
- if err != nil {
- return fmt.Errorf("%s must be an integer (found: %q)", ComposeParallelLimit, v)
- }
- parallel = i
- }
- if parallel > 0 {
- logrus.Debugf("Limiting max concurrency to %d jobs", parallel)
- backendOptions.Add(compose.WithMaxConcurrency(parallel))
- }
-
- // dry run detection
- if dryRun {
- backendOptions.Add(compose.WithDryRun)
- }
- return nil
- },
- }
-
- c.AddCommand(
- upCommand(&opts, dockerCli, backendOptions),
- downCommand(&opts, dockerCli, backendOptions),
- startCommand(&opts, dockerCli, backendOptions),
- restartCommand(&opts, dockerCli, backendOptions),
- stopCommand(&opts, dockerCli, backendOptions),
- psCommand(&opts, dockerCli, backendOptions),
- listCommand(dockerCli, backendOptions),
- logsCommand(&opts, dockerCli, backendOptions),
- configCommand(&opts, dockerCli),
- killCommand(&opts, dockerCli, backendOptions),
- runCommand(&opts, dockerCli, backendOptions),
- removeCommand(&opts, dockerCli, backendOptions),
- execCommand(&opts, dockerCli, backendOptions),
- attachCommand(&opts, dockerCli, backendOptions),
- exportCommand(&opts, dockerCli, backendOptions),
- commitCommand(&opts, dockerCli, backendOptions),
- pauseCommand(&opts, dockerCli, backendOptions),
- unpauseCommand(&opts, dockerCli, backendOptions),
- topCommand(&opts, dockerCli, backendOptions),
- eventsCommand(&opts, dockerCli, backendOptions),
- portCommand(&opts, dockerCli, backendOptions),
- imagesCommand(&opts, dockerCli, backendOptions),
- versionCommand(dockerCli),
- buildCommand(&opts, dockerCli, backendOptions),
- pushCommand(&opts, dockerCli, backendOptions),
- pullCommand(&opts, dockerCli, backendOptions),
- createCommand(&opts, dockerCli, backendOptions),
- copyCommand(&opts, dockerCli, backendOptions),
- waitCommand(&opts, dockerCli, backendOptions),
- scaleCommand(&opts, dockerCli, backendOptions),
- statsCommand(&opts, dockerCli),
- watchCommand(&opts, dockerCli, backendOptions),
- publishCommand(&opts, dockerCli, backendOptions),
- alphaCommand(&opts, dockerCli, backendOptions),
- bridgeCommand(&opts, dockerCli),
- volumesCommand(&opts, dockerCli, backendOptions),
- )
-
- c.Flags().SetInterspersed(false)
- opts.addProjectFlags(c.Flags())
- c.RegisterFlagCompletionFunc( //nolint:errcheck
- "project-name",
- completeProjectNames(dockerCli, backendOptions),
- )
- c.RegisterFlagCompletionFunc( //nolint:errcheck
- "project-directory",
- func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- return []string{}, cobra.ShellCompDirectiveFilterDirs
- },
- )
- c.RegisterFlagCompletionFunc( //nolint:errcheck
- "file",
- func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- return []string{"yaml", "yml"}, cobra.ShellCompDirectiveFilterFileExt
- },
- )
- c.RegisterFlagCompletionFunc( //nolint:errcheck
- "profile",
- completeProfileNames(dockerCli, &opts),
- )
- c.RegisterFlagCompletionFunc( //nolint:errcheck
- "progress",
- cobra.FixedCompletions(printerModes, cobra.ShellCompDirectiveNoFileComp),
- )
-
- c.Flags().StringVar(&ansi, "ansi", "auto", `Control when to print ANSI control characters ("never"|"always"|"auto")`)
- c.Flags().IntVar(¶llel, "parallel", -1, `Control max parallelism, -1 for unlimited`)
- c.Flags().BoolVarP(&version, "version", "v", false, "Show the Docker Compose version information")
- c.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Execute command in dry run mode")
- c.Flags().MarkHidden("version") //nolint:errcheck
- c.Flags().BoolVar(&noAnsi, "no-ansi", false, `Do not print ANSI control characters (DEPRECATED)`)
- c.Flags().MarkHidden("no-ansi") //nolint:errcheck
- c.Flags().BoolVar(&verbose, "verbose", false, "Show more output")
- c.Flags().MarkHidden("verbose") //nolint:errcheck
- return c
-}
-
-func stdinfo(dockerCli command.Cli) io.Writer {
- if stdioToStdout {
- return dockerCli.Out()
- }
- return dockerCli.Err()
-}
-
-// selectEventProcessor picks the EventProcessor for Compose progress rendering.
-//
-// In auto mode we probe Err() (not Out()) because the renderer writes to stderr;
-// probing stdout would force plain mode whenever stdout is redirected (e.g.
-// `docker compose up | tee log`) while stderr is still a terminal.
-func selectEventProcessor(dockerCli command.Cli, progress, ansi string, detached bool) (api.EventProcessor, error) {
- switch progress {
- case "", display.ModeAuto:
- switch {
- case ansi == "never":
- display.Mode = display.ModePlain
- return display.Plain(dockerCli.Err()), nil
- case dockerCli.Err().IsTerminal():
- return display.Full(dockerCli.Err(), stdinfo(dockerCli), detached), nil
- default:
- return display.Plain(dockerCli.Err()), nil
- }
- case display.ModeTTY:
- if ansi == "never" {
- return nil, fmt.Errorf("can't use --progress tty while ANSI support is disabled")
- }
- display.Mode = display.ModeTTY
- return display.Full(dockerCli.Err(), stdinfo(dockerCli), detached), nil
- case display.ModePlain:
- if ansi == "always" {
- return nil, fmt.Errorf("can't use --progress plain while ANSI support is forced")
- }
- display.Mode = display.ModePlain
- return display.Plain(dockerCli.Err()), nil
- case display.ModeQuiet, "none":
- display.Mode = display.ModeQuiet
- return display.Quiet(), nil
- case display.ModeJSON:
- display.Mode = display.ModeJSON
- logrus.SetFormatter(&logrus.JSONFormatter{})
- return display.JSON(dockerCli.Err()), nil
- default:
- return nil, fmt.Errorf("unsupported --progress value %q", progress)
- }
-}
-
-func setEnvWithDotEnv(opts ProjectOptions, dockerCli command.Cli) error {
- // Check if we're using a remote config (OCI or Git)
- // If so, skip env loading as remote loaders haven't been initialized yet
- // and trying to process the path would fail
- remoteLoaders := opts.remoteLoaders(dockerCli)
- for _, path := range opts.ConfigPaths {
- for _, loader := range remoteLoaders {
- if loader.Accept(path) {
- // Remote config - skip env loading for now
- // It will be loaded later when the project is fully initialized
- return nil
- }
- }
- }
-
- options, err := cli.NewProjectOptions(opts.ConfigPaths,
- cli.WithWorkingDirectory(opts.ProjectDir),
- cli.WithOsEnv,
- cli.WithEnvFiles(opts.EnvFiles...),
- cli.WithDotEnv,
- )
- if err != nil {
- return err
- }
- envFromFile, err := dotenv.GetEnvFromFile(composegoutils.GetAsEqualsMap(os.Environ()), options.EnvFiles)
- if err != nil {
- return err
- }
- for k, v := range envFromFile {
- if _, ok := os.LookupEnv(k); !ok && strings.HasPrefix(k, "COMPOSE_") {
- if err := os.Setenv(k, v); err != nil {
- return err
- }
- }
- }
- return nil
-}
-
-var printerModes = []string{
- display.ModeAuto,
- display.ModeTTY,
- display.ModePlain,
- display.ModeJSON,
- display.ModeQuiet,
-}
diff --git a/cmd/compose/compose_oci_test.go b/cmd/compose/compose_oci_test.go
deleted file mode 100644
index 7450f7633cf..00000000000
--- a/cmd/compose/compose_oci_test.go
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "testing"
-
- "go.uber.org/mock/gomock"
- "gotest.tools/v3/assert"
-
- "github.com/docker/compose/v5/pkg/mocks"
-)
-
-func TestSetEnvWithDotEnv_WithOCIArtifact(t *testing.T) {
- // Test that setEnvWithDotEnv doesn't fail when using OCI artifacts
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
- cli := mocks.NewMockCli(ctrl)
-
- opts := ProjectOptions{
- ConfigPaths: []string{"oci://docker.io/dockersamples/welcome-to-docker"},
- ProjectDir: "",
- EnvFiles: []string{},
- }
-
- err := setEnvWithDotEnv(opts, cli)
- assert.NilError(t, err, "setEnvWithDotEnv should not fail with OCI artifact path")
-}
-
-func TestSetEnvWithDotEnv_WithGitRemote(t *testing.T) {
- // Test that setEnvWithDotEnv doesn't fail when using Git remotes
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
- cli := mocks.NewMockCli(ctrl)
-
- opts := ProjectOptions{
- ConfigPaths: []string{"https://github.com/docker/compose.git"},
- ProjectDir: "",
- EnvFiles: []string{},
- }
-
- err := setEnvWithDotEnv(opts, cli)
- assert.NilError(t, err, "setEnvWithDotEnv should not fail with Git remote path")
-}
-
-func TestSetEnvWithDotEnv_WithLocalPath(t *testing.T) {
- // Test that setEnvWithDotEnv still works with local paths
- // This will fail if the file doesn't exist, but it should not panic
- // or produce invalid paths
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
- cli := mocks.NewMockCli(ctrl)
-
- opts := ProjectOptions{
- ConfigPaths: []string{"compose.yaml"},
- ProjectDir: "",
- EnvFiles: []string{},
- }
-
- // This may error if files don't exist, but should not panic
- _ = setEnvWithDotEnv(opts, cli)
-}
diff --git a/cmd/compose/compose_progress_test.go b/cmd/compose/compose_progress_test.go
deleted file mode 100644
index c94760ec66c..00000000000
--- a/cmd/compose/compose_progress_test.go
+++ /dev/null
@@ -1,209 +0,0 @@
-//go:build !windows
-
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "fmt"
- "os"
- "testing"
-
- "github.com/creack/pty"
- "github.com/docker/cli/cli/streams"
- "github.com/sirupsen/logrus"
- "go.uber.org/mock/gomock"
- "gotest.tools/v3/assert"
-
- "github.com/docker/compose/v5/cmd/display"
- "github.com/docker/compose/v5/pkg/mocks"
-)
-
-// saveGlobalState snapshots package-level state that selectEventProcessor
-// mutates (display.Mode and, in JSON mode, the logrus standard formatter)
-// and restores it on test cleanup.
-func saveGlobalState(t *testing.T) {
- t.Helper()
- originalMode := display.Mode
- originalFormatter := logrus.StandardLogger().Formatter
- t.Cleanup(func() {
- display.Mode = originalMode
- logrus.SetFormatter(originalFormatter)
- })
-}
-
-// newStream returns a *streams.Out whose IsTerminal() matches tty. When tty is
-// true it is backed by a pseudo-terminal slave; otherwise by an os.Pipe writer.
-func newStream(t *testing.T, tty bool) *streams.Out {
- t.Helper()
- if tty {
- ptmx, slave, err := pty.Open()
- assert.NilError(t, err)
- t.Cleanup(func() {
- _ = ptmx.Close()
- _ = slave.Close()
- })
- return streams.NewOut(slave)
- }
- r, w, err := os.Pipe()
- assert.NilError(t, err)
- t.Cleanup(func() {
- _ = r.Close()
- _ = w.Close()
- })
- return streams.NewOut(w)
-}
-
-func newMockCli(t *testing.T, out, errStream *streams.Out) *mocks.MockCli {
- t.Helper()
- cli := mocks.NewMockCli(gomock.NewController(t))
- cli.EXPECT().Out().Return(out).AnyTimes()
- cli.EXPECT().Err().Return(errStream).AnyTimes()
- return cli
-}
-
-// TestSelectEventProcessor_AutoMode covers the regression from docker/compose#13570:
-// auto mode must probe Err() (not Out()) so `docker compose up | tee log` still
-// renders the colorized UI on stderr.
-func TestSelectEventProcessor_AutoMode(t *testing.T) {
- tests := []struct {
- name string
- outIsTTY bool
- errIsTTY bool
- ansi string
- wantType string
- }{
- {
- name: "stderr TTY, stdout piped -> Full",
- errIsTTY: true,
- ansi: "auto",
- wantType: "*display.ttyWriter",
- },
- {
- name: "stderr piped, stdout TTY -> Plain (do not fall back to stdout)",
- outIsTTY: true,
- ansi: "auto",
- wantType: "*display.plainWriter",
- },
- {
- name: "both TTY -> Full",
- outIsTTY: true,
- errIsTTY: true,
- ansi: "auto",
- wantType: "*display.ttyWriter",
- },
- {
- name: "both piped -> Plain",
- ansi: "auto",
- wantType: "*display.plainWriter",
- },
- {
- name: "ansi never forces Plain even when stderr is TTY",
- outIsTTY: true,
- errIsTTY: true,
- ansi: "never",
- wantType: "*display.plainWriter",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- saveGlobalState(t)
- cli := newMockCli(t, newStream(t, tc.outIsTTY), newStream(t, tc.errIsTTY))
-
- ep, err := selectEventProcessor(cli, "", tc.ansi, false)
- assert.NilError(t, err)
- assert.Equal(t, fmt.Sprintf("%T", ep), tc.wantType)
- })
- }
-}
-
-func TestSelectEventProcessor_ExplicitMode(t *testing.T) {
- tests := []struct {
- name string
- progress string
- ansi string
- wantType string
- wantErrText string
- }{
- {
- name: "progress=tty forces Full regardless of streams",
- progress: display.ModeTTY,
- ansi: "auto",
- wantType: "*display.ttyWriter",
- },
- {
- name: "progress=tty with ansi=never is rejected",
- progress: display.ModeTTY,
- ansi: "never",
- wantErrText: "can't use --progress tty while ANSI support is disabled",
- },
- {
- name: "progress=plain forces Plain",
- progress: display.ModePlain,
- ansi: "auto",
- wantType: "*display.plainWriter",
- },
- {
- name: "progress=plain with ansi=always is rejected",
- progress: display.ModePlain,
- ansi: "always",
- wantErrText: "can't use --progress plain while ANSI support is forced",
- },
- {
- name: "progress=quiet returns Quiet",
- progress: display.ModeQuiet,
- ansi: "auto",
- wantType: "*display.quiet",
- },
- {
- name: `progress="none" aliases to Quiet`,
- progress: "none",
- ansi: "auto",
- wantType: "*display.quiet",
- },
- {
- name: "progress=json returns JSON",
- progress: display.ModeJSON,
- ansi: "auto",
- wantType: "*display.jsonWriter",
- },
- {
- name: "unknown progress value is rejected",
- progress: "bogus",
- ansi: "auto",
- wantErrText: `unsupported --progress value "bogus"`,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- saveGlobalState(t)
- // Explicit modes don't probe IsTerminal; pipes are fine for both.
- cli := newMockCli(t, newStream(t, false), newStream(t, false))
-
- ep, err := selectEventProcessor(cli, tc.progress, tc.ansi, false)
- if tc.wantErrText != "" {
- assert.ErrorContains(t, err, tc.wantErrText)
- assert.Assert(t, ep == nil)
- return
- }
- assert.NilError(t, err)
- assert.Equal(t, fmt.Sprintf("%T", ep), tc.wantType)
- })
- }
-}
diff --git a/cmd/compose/compose_test.go b/cmd/compose/compose_test.go
deleted file mode 100644
index 708929ff8cd..00000000000
--- a/cmd/compose/compose_test.go
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "testing"
-
- "github.com/compose-spec/compose-go/v2/types"
- "gotest.tools/v3/assert"
-)
-
-func TestFilterServices(t *testing.T) {
- p := &types.Project{
- Services: types.Services{
- "foo": {
- Name: "foo",
- Links: []string{"bar"},
- },
- "bar": {
- Name: "bar",
- DependsOn: map[string]types.ServiceDependency{
- "zot": {},
- },
- },
- "zot": {
- Name: "zot",
- },
- "qix": {
- Name: "qix",
- },
- },
- }
- p, err := p.WithSelectedServices([]string{"bar"})
- assert.NilError(t, err)
-
- assert.Equal(t, len(p.Services), 2)
- _, err = p.GetService("bar")
- assert.NilError(t, err)
- _, err = p.GetService("zot")
- assert.NilError(t, err)
-}
diff --git a/cmd/compose/config.go b/cmd/compose/config.go
deleted file mode 100644
index 331a0b0ea41..00000000000
--- a/cmd/compose/config.go
+++ /dev/null
@@ -1,704 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "os"
- "sort"
- "strings"
-
- "github.com/compose-spec/compose-go/v2/cli"
- "github.com/compose-spec/compose-go/v2/loader"
- "github.com/compose-spec/compose-go/v2/template"
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/command"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
- "go.yaml.in/yaml/v4"
-
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type configOptions struct {
- *ProjectOptions
- Format string
- Output string
- quiet bool
- resolveImageDigests bool
- noInterpolate bool
- noNormalize bool
- noResolvePath bool
- noResolveEnv bool
- services bool
- volumes bool
- networks bool
- models bool
- profiles bool
- images bool
- hash string
- noConsistency bool
- variables bool
- environment bool
- lockImageDigests bool
-}
-
-func (o *configOptions) ToProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, services []string, po ...cli.ProjectOptionsFn) (*types.Project, error) {
- po = append(po, o.toProjectOptionsFns()...)
- project, _, err := o.ProjectOptions.ToProject(ctx, dockerCli, backend, services, po...)
- return project, err
-}
-
-func (o *configOptions) ToModel(ctx context.Context, dockerCli command.Cli, services []string, po ...cli.ProjectOptionsFn) (map[string]any, error) {
- po = append(po, o.toProjectOptionsFns()...)
- return o.ProjectOptions.ToModel(ctx, dockerCli, services, po...)
-}
-
-// toProjectOptionsFns converts config options to cli.ProjectOptionsFn
-func (o *configOptions) toProjectOptionsFns() []cli.ProjectOptionsFn {
- fns := []cli.ProjectOptionsFn{
- cli.WithInterpolation(!o.noInterpolate),
- cli.WithResolvedPaths(!o.noResolvePath),
- cli.WithNormalization(!o.noNormalize),
- cli.WithConsistency(!o.noConsistency),
- cli.WithDefaultProfiles(o.Profiles...),
- cli.WithDiscardEnvFile,
- }
- if o.noResolveEnv {
- fns = append(fns, cli.WithoutEnvironmentResolution)
- }
- return fns
-}
-
-func configCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
- opts := configOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "config [OPTIONS] [SERVICE...]",
- Short: "Parse, resolve and render compose file in canonical format",
- PreRunE: Adapt(func(ctx context.Context, args []string) error {
- if opts.quiet {
- devnull, err := os.Open(os.DevNull)
- if err != nil {
- return err
- }
- os.Stdout = devnull
- }
- if p.Compatibility {
- opts.noNormalize = true
- }
- if opts.lockImageDigests {
- opts.resolveImageDigests = true
- }
- return nil
- }),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- if opts.services {
- return runServices(ctx, dockerCli, opts)
- }
- if opts.volumes {
- return runVolumes(ctx, dockerCli, opts)
- }
- if opts.networks {
- return runNetworks(ctx, dockerCli, opts)
- }
- if opts.models {
- return runModels(ctx, dockerCli, opts)
- }
- if opts.hash != "" {
- return runHash(ctx, dockerCli, opts)
- }
- if opts.profiles {
- return runProfiles(ctx, dockerCli, opts, args)
- }
- if opts.images {
- return runConfigImages(ctx, dockerCli, opts, args)
- }
- if opts.variables {
- return runVariables(ctx, dockerCli, opts, args)
- }
- if opts.environment {
- return runEnvironment(ctx, dockerCli, opts, args)
- }
-
- if opts.Format == "" {
- opts.Format = "yaml"
- }
- return runConfig(ctx, dockerCli, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := cmd.Flags()
- flags.StringVar(&opts.Format, "format", "", "Format the output. Values: [yaml | json]")
- flags.BoolVar(&opts.resolveImageDigests, "resolve-image-digests", false, "Pin image tags to digests")
- flags.BoolVar(&opts.lockImageDigests, "lock-image-digests", false, "Produces an override file with image digests")
- flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only validate the configuration, don't print anything")
- flags.BoolVar(&opts.noInterpolate, "no-interpolate", false, "Don't interpolate environment variables")
- flags.BoolVar(&opts.noNormalize, "no-normalize", false, "Don't normalize compose model")
- flags.BoolVar(&opts.noResolvePath, "no-path-resolution", false, "Don't resolve file paths")
- flags.BoolVar(&opts.noConsistency, "no-consistency", false, "Don't check model consistency - warning: may produce invalid Compose output")
- flags.BoolVar(&opts.noResolveEnv, "no-env-resolution", false, "Don't resolve service env files")
-
- flags.BoolVar(&opts.services, "services", false, "Print the service names, one per line.")
- flags.BoolVar(&opts.volumes, "volumes", false, "Print the volume names, one per line.")
- flags.BoolVar(&opts.networks, "networks", false, "Print the network names, one per line.")
- flags.BoolVar(&opts.models, "models", false, "Print the model names, one per line.")
- flags.BoolVar(&opts.profiles, "profiles", false, "Print the profile names, one per line.")
- flags.BoolVar(&opts.images, "images", false, "Print the image names, one per line.")
- flags.StringVar(&opts.hash, "hash", "", "Print the service config hash, one per line.")
- flags.BoolVar(&opts.variables, "variables", false, "Print model variables and default values.")
- flags.BoolVar(&opts.environment, "environment", false, "Print environment used for interpolation.")
- flags.StringVarP(&opts.Output, "output", "o", "", "Save to file (default to stdout)")
-
- return cmd
-}
-
-func runConfig(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) (err error) {
- var content []byte
- if opts.noInterpolate {
- content, err = runConfigNoInterpolate(ctx, dockerCli, opts, services)
- if err != nil {
- return err
- }
- } else {
- content, err = runConfigInterpolate(ctx, dockerCli, opts, services)
- if err != nil {
- return err
- }
- }
-
- if !opts.noInterpolate {
- content = escapeDollarSign(content)
- }
-
- if opts.quiet {
- return nil
- }
-
- if opts.Output != "" && len(content) > 0 {
- return os.WriteFile(opts.Output, content, 0o666)
- }
- _, err = fmt.Fprint(dockerCli.Out(), string(content))
- return err
-}
-
-func runConfigInterpolate(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) ([]byte, error) {
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return nil, err
- }
-
- project, err := opts.ToProject(ctx, dockerCli, backend, services)
- if err != nil {
- return nil, err
- }
-
- if opts.resolveImageDigests {
- project, err = project.WithImagesResolved(compose.ImageDigestResolver(ctx, dockerCli.ConfigFile(), dockerCli.Client()))
- if err != nil {
- return nil, err
- }
- }
-
- if !opts.noResolveEnv {
- project, err = project.WithServicesEnvironmentResolved(true)
- if err != nil {
- return nil, err
- }
- }
-
- if !opts.noConsistency {
- err := project.CheckContainerNameUnicity()
- if err != nil {
- return nil, err
- }
- }
-
- if opts.lockImageDigests {
- warnHooksNotLockable(project)
- project = imagesOnly(project)
- }
-
- var content []byte
- switch opts.Format {
- case "json":
- content, err = project.MarshalJSON()
- case "yaml":
- content, err = project.MarshalYAML()
- default:
- return nil, fmt.Errorf("unsupported format %q", opts.Format)
- }
- if err != nil {
- return nil, err
- }
- return content, nil
-}
-
-// imagesOnly return project with all attributes removed but service.images and `type: image` volumes
-func imagesOnly(project *types.Project) *types.Project {
- digests := types.Services{}
- for name, config := range project.Services {
- service := types.ServiceConfig{
- Image: config.Image,
- }
- for _, vol := range config.Volumes {
- if vol.Type == types.VolumeTypeImage {
- service.Volumes = append(service.Volumes, vol)
- }
- }
- digests[name] = service
- }
- project = &types.Project{Services: digests}
- return project
-}
-
-func runConfigNoInterpolate(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) ([]byte, error) {
- if len(services) > 0 {
- logrus.Warn("service filtering is not applied when --no-interpolate is set, the full model will be rendered")
- }
- // we can't use ToProject, so the model we render here is only partially resolved
- model, err := opts.ToModel(ctx, dockerCli, services)
- if err != nil {
- return nil, err
- }
-
- if opts.resolveImageDigests {
- err = resolveImageDigests(ctx, dockerCli, model)
- if err != nil {
- return nil, err
- }
- }
-
- if opts.lockImageDigests {
- warnModelHooksNotLockable(model)
- lockModel(model)
- }
-
- return formatModel(model, opts.Format)
-}
-
-// hook sequences are appended when compose files are merged, so a lock override
-// cannot pin a hook image without duplicating the hook: the lock file leaves
-// pre_start hooks out, and their images stay unpinned once merged
-const hooksNotLockableWarning = "service %q: pre_start hook images are not pinned in the override file produced by --lock-image-digests, use the full --resolve-image-digests output to pin them"
-
-func warnHooksNotLockable(project *types.Project) {
- for name, service := range project.Services {
- for _, hook := range service.PreStart {
- if hook.Image != "" {
- logrus.Warnf(hooksNotLockableWarning, name)
- break
- }
- }
- }
-}
-
-func warnModelHooksNotLockable(model map[string]any) {
- services, ok := model["services"].(map[string]any)
- if !ok {
- return
- }
- for name, s := range services {
- service, ok := s.(map[string]any)
- if !ok {
- continue
- }
- for _, hook := range preStartHooks(service) {
- if image, ok := hook["image"].(string); ok && image != "" {
- logrus.Warnf(hooksNotLockableWarning, name)
- break
- }
- }
- }
-}
-
-// lockModel removes from model all attributes but service images and `type: image` volumes
-func lockModel(model map[string]any) {
- for key, e := range model {
- if key != "services" {
- delete(model, key)
- continue
- }
- for _, s := range e.(map[string]any) {
- service := s.(map[string]any)
- for key := range service {
- switch key {
- case "image":
- case "volumes":
- if volumes := imageVolumes(service); len(volumes) > 0 {
- // write back as []any to keep the raw-model volumes type unchanged
- filtered := make([]any, len(volumes))
- for i, volume := range volumes {
- filtered[i] = volume
- }
- service["volumes"] = filtered
- } else {
- delete(service, "volumes")
- }
- default:
- delete(service, key)
- }
- }
- }
- }
-}
-
-func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[string]any) error {
- // create a pseudo-project so we can rely on WithImagesResolved to resolve images,
- // pre_start hook images and `type: image` volume sources, keyed by actual service
- // names so sources referencing another service are detected as such and kept unresolved
- p := &types.Project{
- Services: types.Services{},
- }
- services, ok := model["services"].(map[string]any)
- if !ok {
- // services is optional at the top level of the compose model
- return nil
- }
- for name, s := range services {
- service := s.(map[string]any)
- config := types.ServiceConfig{}
- if image, ok := service["image"].(string); ok {
- config.Image = image
- }
- for _, hook := range preStartHooks(service) {
- image, _ := hook["image"].(string)
- config.PreStart = append(config.PreStart, types.ServiceHook{Image: image})
- }
- for _, volume := range imageVolumes(service) {
- source, _ := volume["source"].(string)
- config.Volumes = append(config.Volumes, types.ServiceVolumeConfig{
- Type: types.VolumeTypeImage,
- Source: source,
- })
- }
- p.Services[name] = config
- }
-
- p, err := p.WithImagesResolved(compose.ImageDigestResolver(ctx, dockerCli.ConfigFile(), dockerCli.Client()))
- if err != nil {
- return err
- }
-
- // update model with image and volume-source references resolved with digest;
- // fields absent from the resolved pseudo-project (empty Image / Source) are left untouched
- for name, s := range services {
- service := s.(map[string]any)
- config := p.Services[name]
- if config.Image != "" {
- service["image"] = config.Image
- }
- for i, hook := range preStartHooks(service) {
- if image := config.PreStart[i].Image; image != "" {
- hook["image"] = image
- }
- }
- for i, volume := range imageVolumes(service) {
- if source := config.Volumes[i].Source; source != "" {
- volume["source"] = source
- }
- }
- }
- return nil
-}
-
-// preStartHooks returns the pre_start hook declarations of a service raw model
-func preStartHooks(service map[string]any) []map[string]any {
- hooks, ok := service["pre_start"].([]any)
- if !ok {
- return nil
- }
- var result []map[string]any
- for _, h := range hooks {
- if hook, ok := h.(map[string]any); ok {
- result = append(result, hook)
- }
- }
- return result
-}
-
-// imageVolumes returns the `type: image` volume declarations of a service raw model
-func imageVolumes(service map[string]any) []map[string]any {
- volumes, ok := service["volumes"].([]any)
- if !ok {
- return nil
- }
- var images []map[string]any
- for _, v := range volumes {
- volume, ok := v.(map[string]any)
- if ok && volume["type"] == types.VolumeTypeImage {
- images = append(images, volume)
- }
- }
- return images
-}
-
-func formatModel(model map[string]any, format string) (content []byte, err error) {
- switch format {
- case "json":
- return json.MarshalIndent(model, "", " ")
- case "yaml":
- buf := bytes.NewBuffer([]byte{})
- encoder := yaml.NewEncoder(buf)
- encoder.SetIndent(2)
- err = encoder.Encode(model)
- return buf.Bytes(), err
- default:
- return nil, fmt.Errorf("unsupported format %q", format)
- }
-}
-
-func runServices(ctx context.Context, dockerCli command.Cli, opts configOptions) error {
- if opts.noInterpolate {
- // we can't use ToProject, so the model we render here is only partially resolved
- data, err := opts.ToModel(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
-
- if _, ok := data["services"]; ok {
- for serviceName := range data["services"].(map[string]any) {
- _, _ = fmt.Fprintln(dockerCli.Out(), serviceName)
- }
- }
-
- return nil
- }
-
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return err
- }
-
- project, err := opts.ToProject(ctx, dockerCli, backend, nil, cli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
- err = project.ForEachService(project.ServiceNames(), func(serviceName string, _ *types.ServiceConfig) error {
- _, _ = fmt.Fprintln(dockerCli.Out(), serviceName)
- return nil
- })
-
- return err
-}
-
-func runVolumes(ctx context.Context, dockerCli command.Cli, opts configOptions) error {
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return err
- }
-
- project, err := opts.ToProject(ctx, dockerCli, backend, nil, cli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
- for n := range project.Volumes {
- _, _ = fmt.Fprintln(dockerCli.Out(), n)
- }
- return nil
-}
-
-func runNetworks(ctx context.Context, dockerCli command.Cli, opts configOptions) error {
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return err
- }
-
- project, err := opts.ToProject(ctx, dockerCli, backend, nil, cli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
- for n := range project.Networks {
- _, _ = fmt.Fprintln(dockerCli.Out(), n)
- }
- return nil
-}
-
-func runModels(ctx context.Context, dockerCli command.Cli, opts configOptions) error {
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return err
- }
-
- project, err := opts.ToProject(ctx, dockerCli, backend, nil, cli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
- for _, model := range project.Models {
- if model.Model != "" {
- _, _ = fmt.Fprintln(dockerCli.Out(), model.Model)
- }
- }
- return nil
-}
-
-func runHash(ctx context.Context, dockerCli command.Cli, opts configOptions) error {
- var services []string
- if opts.hash != "*" {
- services = append(services, strings.Split(opts.hash, ",")...)
- }
-
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return err
- }
-
- project, err := opts.ToProject(ctx, dockerCli, backend, nil, cli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
-
- // narrow the project to the services being hashed, so a broken env_file
- // or platforms on an unrelated service doesn't prevent hashing the requested ones
- project, err = project.WithSelectedServices(services, types.IgnoreDependencies)
- if err != nil {
- return err
- }
-
- if err := applyPlatforms(project, true); err != nil {
- return err
- }
-
- if !opts.noResolveEnv {
- // containers are created from a project with service environment
- // resolved (env_file merged into environment), so hash the same content
- project, err = project.WithServicesEnvironmentResolved(true)
- if err != nil {
- return err
- }
- }
-
- for _, name := range project.ServiceNames() {
- s, err := project.GetService(name)
- if err != nil {
- return err
- }
-
- hash, err := compose.ServiceHash(s)
- if err != nil {
- return err
- }
- _, _ = fmt.Fprintf(dockerCli.Out(), "%s %s\n", name, hash)
- }
- return nil
-}
-
-func runProfiles(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) error {
- set := map[string]struct{}{}
-
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return err
- }
-
- project, err := opts.ToProject(ctx, dockerCli, backend, services)
- if err != nil {
- return err
- }
- for _, s := range project.AllServices() {
- for _, p := range s.Profiles {
- set[p] = struct{}{}
- }
- }
- profiles := make([]string, 0, len(set))
- for p := range set {
- profiles = append(profiles, p)
- }
- sort.Strings(profiles)
- for _, p := range profiles {
- _, _ = fmt.Fprintln(dockerCli.Out(), p)
- }
- return nil
-}
-
-func runConfigImages(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) error {
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return err
- }
-
- project, err := opts.ToProject(ctx, dockerCli, backend, services)
- if err != nil {
- return err
- }
-
- for _, s := range project.Services {
- _, _ = fmt.Fprintln(dockerCli.Out(), api.GetImageNameOrDefault(s, project.Name))
- for _, img := range api.GetDependentImages(s, project.Name) {
- _, _ = fmt.Fprintln(dockerCli.Out(), img)
- }
- }
- return nil
-}
-
-func runVariables(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) error {
- if len(services) > 0 {
- logrus.Warn("service filtering is not applied when --variables is set, variables from the full model will be rendered")
- }
- opts.noInterpolate = true
- model, err := opts.ToModel(ctx, dockerCli, services, cli.WithoutEnvironmentResolution, cli.WithLoadOptions(loader.WithSkipValidation))
- if err != nil {
- return err
- }
-
- variables := template.ExtractVariables(model, template.DefaultPattern)
-
- if opts.Format == "yaml" {
- result, err := yaml.Marshal(variables)
- if err != nil {
- return err
- }
- fmt.Print(string(result))
- return nil
- }
-
- return formatter.Print(variables, opts.Format, dockerCli.Out(), func(w io.Writer) {
- for name, variable := range variables {
- _, _ = fmt.Fprintf(w, "%s\t%t\t%s\t%s\n", name, variable.Required, variable.DefaultValue, variable.PresenceValue)
- }
- }, "NAME", "REQUIRED", "DEFAULT VALUE", "ALTERNATE VALUE")
-}
-
-func runEnvironment(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) error {
- backend, err := compose.NewComposeService(dockerCli)
- if err != nil {
- return err
- }
-
- project, err := opts.ToProject(ctx, dockerCli, backend, services)
- if err != nil {
- return err
- }
-
- for _, v := range project.Environment.Values() {
- fmt.Println(v)
- }
- return nil
-}
-
-func escapeDollarSign(marshal []byte) []byte {
- dollar := []byte{'$'}
- escDollar := []byte{'$', '$'}
- return bytes.ReplaceAll(marshal, dollar, escDollar)
-}
diff --git a/cmd/compose/config_test.go b/cmd/compose/config_test.go
deleted file mode 100644
index a9a08f2cd77..00000000000
--- a/cmd/compose/config_test.go
+++ /dev/null
@@ -1,256 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "io"
- "os"
- "strings"
- "testing"
-
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/config/configfile"
- "github.com/moby/moby/api/types/registry"
- "github.com/moby/moby/client"
- ocispec "github.com/opencontainers/image-spec/specs-go/v1"
- "github.com/sirupsen/logrus"
- logrustest "github.com/sirupsen/logrus/hooks/test"
- "go.uber.org/mock/gomock"
- "gotest.tools/v3/assert"
-
- "github.com/docker/compose/v5/pkg/mocks"
-)
-
-const testDigest = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
-
-func TestResolveImageDigests(t *testing.T) {
- // distinct digests per resolved reference, so attaching a digest to the wrong image would fail
- const (
- serviceDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- hookDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
- volumeDigest = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
- )
- mockCtrl := gomock.NewController(t)
- defer mockCtrl.Finish()
- apiClient := mocks.NewMockAPIClient(mockCtrl)
- cli := mocks.NewMockCli(mockCtrl)
- cli.EXPECT().Client().Return(apiClient).AnyTimes()
- cli.EXPECT().ConfigFile().Return(configfile.New("")).AnyTimes()
-
- model := map[string]any{
- "services": map[string]any{
- "test": map[string]any{
- "image": "nginx:latest",
- "pre_start": []any{
- map[string]any{"command": "echo hello", "image": "hookimage:latest"},
- // hook running in the service container: no image to resolve
- map[string]any{"command": "echo hello"},
- },
- "volumes": []any{
- map[string]any{"type": "image", "source": "someimage:latest", "target": "/data"},
- // already digested: must NOT trigger any registry call and must be kept as-is
- map[string]any{"type": "image", "source": "docker.io/library/pinned@" + testDigest, "target": "/pinned"},
- // source referencing another service: locally built image, must be kept as-is
- map[string]any{"type": "image", "source": "builder", "target": "/built"},
- map[string]any{"type": "bind", "source": "/host", "target": "/bind"},
- "./data:/short",
- },
- },
- "builder": map[string]any{
- "image": "docker.io/library/pinned@" + testDigest,
- },
- // service without image: its image volumes must still be pinned, and
- // "someimage:latest" being also used by "test" must be resolved only once
- "data": map[string]any{
- "volumes": []any{
- map[string]any{"type": "image", "source": "someimage:latest", "target": "/data"},
- },
- },
- },
- }
-
- apiClient.EXPECT().DistributionInspect(gomock.Any(), "docker.io/library/nginx:latest", gomock.Any()).
- Return(client.DistributionInspectResult{
- DistributionInspect: registry.DistributionInspect{Descriptor: ocispec.Descriptor{Digest: serviceDigest}},
- }, nil)
- apiClient.EXPECT().DistributionInspect(gomock.Any(), "docker.io/library/someimage:latest", gomock.Any()).
- Return(client.DistributionInspectResult{
- DistributionInspect: registry.DistributionInspect{Descriptor: ocispec.Descriptor{Digest: volumeDigest}},
- }, nil)
- apiClient.EXPECT().DistributionInspect(gomock.Any(), "docker.io/library/hookimage:latest", gomock.Any()).
- Return(client.DistributionInspectResult{
- DistributionInspect: registry.DistributionInspect{Descriptor: ocispec.Descriptor{Digest: hookDigest}},
- }, nil)
-
- err := resolveImageDigests(t.Context(), cli, model)
- assert.NilError(t, err)
-
- services := model["services"].(map[string]any)
- service := services["test"].(map[string]any)
- assert.Equal(t, service["image"], "docker.io/library/nginx:latest@"+serviceDigest)
- hooks := service["pre_start"].([]any)
- assert.Equal(t, hooks[0].(map[string]any)["image"], "docker.io/library/hookimage:latest@"+hookDigest)
- _, hasImage := hooks[1].(map[string]any)["image"]
- assert.Assert(t, !hasImage)
- volumes := service["volumes"].([]any)
- assert.Equal(t, volumes[0].(map[string]any)["source"], "docker.io/library/someimage:latest@"+volumeDigest)
- assert.Equal(t, volumes[1].(map[string]any)["source"], "docker.io/library/pinned@"+testDigest)
- assert.Equal(t, volumes[2].(map[string]any)["source"], "builder")
- assert.Equal(t, volumes[3].(map[string]any)["source"], "/host")
- assert.Equal(t, volumes[4], "./data:/short")
- assert.Equal(t, services["builder"].(map[string]any)["image"], "docker.io/library/pinned@"+testDigest)
- dataVolumes := services["data"].(map[string]any)["volumes"].([]any)
- assert.Equal(t, dataVolumes[0].(map[string]any)["source"], "docker.io/library/someimage:latest@"+volumeDigest)
-}
-
-func TestResolveImageDigestsWithoutServices(t *testing.T) {
- mockCtrl := gomock.NewController(t)
- defer mockCtrl.Finish()
- cli := mocks.NewMockCli(mockCtrl)
- cli.EXPECT().Client().Return(mocks.NewMockAPIClient(mockCtrl)).AnyTimes()
- cli.EXPECT().ConfigFile().Return(configfile.New("")).AnyTimes()
-
- // top-level services is optional in the compose spec, and unlike the typed
- // project the raw model gets no empty services map from normalization
- model := map[string]any{
- "networks": map[string]any{"foo": nil},
- }
-
- err := resolveImageDigests(t.Context(), cli, model)
- assert.NilError(t, err)
-}
-
-func TestImagesOnly(t *testing.T) {
- project := &types.Project{
- Name: "test",
- Services: types.Services{
- "test": types.ServiceConfig{
- Name: "test",
- Image: "docker.io/library/nginx@" + testDigest,
- Command: types.ShellCommand{"echo", "hello"},
- // hooks can't be overridden element-wise on merge, so the lock must not carry them
- PreStart: []types.ServiceHook{{Image: "docker.io/library/hookimage@" + testDigest}},
- Volumes: []types.ServiceVolumeConfig{
- {Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"},
- {Type: types.VolumeTypeBind, Source: "/host", Target: "/bind"},
- },
- },
- },
- Networks: types.Networks{"default": types.NetworkConfig{}},
- }
-
- locked := imagesOnly(project)
-
- assert.DeepEqual(t, locked, &types.Project{
- Services: types.Services{
- "test": types.ServiceConfig{
- Image: "docker.io/library/nginx@" + testDigest,
- Volumes: []types.ServiceVolumeConfig{
- {Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"},
- },
- },
- },
- })
-}
-
-func TestWarnHooksNotLockable(t *testing.T) {
- hook := logrustest.NewGlobal()
- logrus.SetOutput(io.Discard)
- defer func() {
- logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks))
- logrus.SetOutput(os.Stderr)
- }()
-
- warnHooksNotLockable(&types.Project{
- Services: types.Services{
- "with-hook-image": types.ServiceConfig{PreStart: []types.ServiceHook{{Image: "alpine:latest"}}},
- "inline-hook": types.ServiceConfig{PreStart: []types.ServiceHook{{Command: types.ShellCommand{"echo"}}}},
- "without-hook": types.ServiceConfig{},
- },
- })
-
- assert.Equal(t, len(hook.Entries), 1)
- assert.Assert(t, strings.Contains(hook.Entries[0].Message, `service "with-hook-image"`))
-}
-
-func TestWarnModelHooksNotLockable(t *testing.T) {
- hook := logrustest.NewGlobal()
- logrus.SetOutput(io.Discard)
- defer func() {
- logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks))
- logrus.SetOutput(os.Stderr)
- }()
-
- warnModelHooksNotLockable(map[string]any{
- "services": map[string]any{
- "with-hook-image": map[string]any{
- "pre_start": []any{map[string]any{"image": "alpine:latest"}},
- },
- "inline-hook": map[string]any{
- "pre_start": []any{map[string]any{"command": "echo"}},
- },
- "without-hook": map[string]any{"image": "nginx"},
- },
- })
-
- assert.Equal(t, len(hook.Entries), 1)
- assert.Assert(t, strings.Contains(hook.Entries[0].Message, `service "with-hook-image"`))
-}
-
-func TestLockModel(t *testing.T) {
- model := map[string]any{
- "name": "test",
- "services": map[string]any{
- "a": map[string]any{
- "image": "docker.io/library/nginx@" + testDigest,
- "command": "echo hello",
- // hooks can't be overridden element-wise on merge, so the lock must not carry them
- "pre_start": []any{
- map[string]any{"image": "docker.io/library/hookimage@" + testDigest},
- },
- "volumes": []any{
- map[string]any{"type": "image", "source": "docker.io/library/someimage@" + testDigest, "target": "/data"},
- map[string]any{"type": "bind", "source": "/host", "target": "/bind"},
- },
- },
- "b": map[string]any{
- "image": "docker.io/library/alpine@" + testDigest,
- "volumes": []any{
- map[string]any{"type": "bind", "source": "/host", "target": "/bind"},
- },
- },
- },
- "networks": map[string]any{"default": nil},
- }
-
- lockModel(model)
-
- assert.DeepEqual(t, model, map[string]any{
- "services": map[string]any{
- "a": map[string]any{
- "image": "docker.io/library/nginx@" + testDigest,
- // volumes keeps its []any raw-model type after filtering
- "volumes": []any{
- map[string]any{"type": "image", "source": "docker.io/library/someimage@" + testDigest, "target": "/data"},
- },
- },
- "b": map[string]any{
- "image": "docker.io/library/alpine@" + testDigest,
- },
- },
- })
-}
diff --git a/cmd/compose/cp.go b/cmd/compose/cp.go
deleted file mode 100644
index 17f7411309d..00000000000
--- a/cmd/compose/cp.go
+++ /dev/null
@@ -1,95 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "errors"
-
- "github.com/docker/cli/cli"
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type copyOptions struct {
- *ProjectOptions
-
- source string
- destination string
- index int
- all bool
- followLink bool
- copyUIDGID bool
-}
-
-func copyCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := copyOptions{
- ProjectOptions: p,
- }
- copyCmd := &cobra.Command{
- Use: `cp [OPTIONS] SERVICE:SRC_PATH DEST_PATH|-
- docker compose cp [OPTIONS] SRC_PATH|- SERVICE:DEST_PATH`,
- Short: "Copy files/folders between a service container and the local filesystem",
- Args: cli.ExactArgs(2),
- PreRunE: Adapt(func(ctx context.Context, args []string) error {
- if args[0] == "" {
- return errors.New("source can not be empty")
- }
- if args[1] == "" {
- return errors.New("destination can not be empty")
- }
- return nil
- }),
- RunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
- opts.source = args[0]
- opts.destination = args[1]
- return runCopy(ctx, dockerCli, backendOptions, opts)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
-
- flags := copyCmd.Flags()
- flags.IntVar(&opts.index, "index", 0, "Index of the container if service has multiple replicas")
- flags.BoolVar(&opts.all, "all", false, "Include containers created by the run command")
- flags.BoolVarP(&opts.followLink, "follow-link", "L", false, "Always follow symbol link in SRC_PATH")
- flags.BoolVarP(&opts.copyUIDGID, "archive", "a", false, "Archive mode (copy all uid/gid information)")
-
- return copyCmd
-}
-
-func runCopy(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts copyOptions) error {
- name, err := opts.toProjectName(ctx, dockerCli)
- if err != nil {
- return err
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- return backend.Copy(ctx, name, api.CopyOptions{
- Source: opts.source,
- Destination: opts.destination,
- All: opts.all,
- Index: opts.index,
- FollowLink: opts.followLink,
- CopyUIDGID: opts.copyUIDGID,
- })
-}
diff --git a/cmd/compose/create.go b/cmd/compose/create.go
deleted file mode 100644
index 5f9f7908315..00000000000
--- a/cmd/compose/create.go
+++ /dev/null
@@ -1,223 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "slices"
- "strconv"
- "strings"
- "time"
-
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/command"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
- "github.com/spf13/pflag"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type createOptions struct {
- Build bool
- noBuild bool
- Pull string
- pullChanged bool
- removeOrphans bool
- ignoreOrphans bool
- forceRecreate bool
- noRecreate bool
- recreateDeps bool
- noInherit bool
- timeChanged bool
- timeout int
- quietPull bool
- scale []string
- AssumeYes bool
-}
-
-func createCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := createOptions{}
- buildOpts := buildOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "create [OPTIONS] [SERVICE...]",
- Short: "Creates containers for a service",
- PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
- opts.pullChanged = cmd.Flags().Changed("pull")
- if opts.Build && opts.noBuild {
- return fmt.Errorf("--build and --no-build are incompatible")
- }
- if opts.forceRecreate && opts.noRecreate {
- return fmt.Errorf("--force-recreate and --no-recreate are incompatible")
- }
- return nil
- }),
- RunE: p.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error {
- return runCreate(ctx, dockerCli, backendOptions, opts, buildOpts, project, services)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := cmd.Flags()
- flags.BoolVar(&opts.Build, "build", false, "Build images before starting containers")
- flags.BoolVar(&opts.noBuild, "no-build", false, "Don't build an image, even if it's policy")
- flags.StringVar(&opts.Pull, "pull", "policy", `Pull image before running ("always"|"missing"|"never"|"build")`)
- flags.BoolVar(&opts.quietPull, "quiet-pull", false, "Pull without printing progress information")
- flags.BoolVar(&opts.forceRecreate, "force-recreate", false, "Recreate containers even if their configuration and image haven't changed")
- flags.BoolVar(&opts.noRecreate, "no-recreate", false, "If containers already exist, don't recreate them. Incompatible with --force-recreate.")
- flags.BoolVar(&opts.removeOrphans, "remove-orphans", false, "Remove containers for services not defined in the Compose file")
- flags.StringArrayVar(&opts.scale, "scale", []string{}, "Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present.")
- flags.BoolVarP(&opts.AssumeYes, "yes", "y", false, `Assume "yes" as answer to all prompts and run non-interactively`)
- flags.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
- // assumeYes was introduced by mistake as `--y`
- if name == "y" {
- logrus.Warn("--y is deprecated, please use --yes instead")
- name = "yes"
- }
- return pflag.NormalizedName(name)
- })
- return cmd
-}
-
-func runCreate(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, createOpts createOptions, buildOpts buildOptions, project *types.Project, services []string) error {
- if err := createOpts.Apply(project); err != nil {
- return err
- }
-
- var build *api.BuildOptions
- if !createOpts.noBuild {
- bo, err := buildOpts.toAPIBuildOptions(services)
- if err != nil {
- return err
- }
- build = &bo
- }
-
- if createOpts.AssumeYes {
- backendOptions.Options = append(backendOptions.Options, compose.WithPrompt(compose.AlwaysOkPrompt()))
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- return backend.Create(ctx, project, api.CreateOptions{
- Build: build,
- Services: services,
- RemoveOrphans: createOpts.removeOrphans,
- IgnoreOrphans: createOpts.ignoreOrphans,
- Recreate: createOpts.recreateStrategy(),
- RecreateDependencies: createOpts.dependenciesRecreateStrategy(),
- Inherit: !createOpts.noInherit,
- Timeout: createOpts.GetTimeout(),
- QuietPull: createOpts.quietPull,
- })
-}
-
-func (opts createOptions) recreateStrategy() string {
- if opts.noRecreate {
- return api.RecreateNever
- }
- if opts.forceRecreate {
- return api.RecreateForce
- }
- if opts.noInherit {
- return api.RecreateForce
- }
- return api.RecreateDiverged
-}
-
-func (opts createOptions) dependenciesRecreateStrategy() string {
- if opts.noRecreate {
- return api.RecreateNever
- }
- if opts.recreateDeps {
- return api.RecreateForce
- }
- return api.RecreateDiverged
-}
-
-func (opts createOptions) GetTimeout() *time.Duration {
- if opts.timeChanged {
- t := time.Duration(opts.timeout) * time.Second
- return &t
- }
- return nil
-}
-
-func (opts createOptions) Apply(project *types.Project) error {
- if opts.pullChanged {
- if !opts.isPullPolicyValid() {
- return fmt.Errorf("invalid --pull option %q", opts.Pull)
- }
- for i, service := range project.Services {
- service.PullPolicy = opts.Pull
- project.Services[i] = service
- }
- }
- // N.B. opts.Build means "force build all", but images can still be built
- // when this is false
- // e.g. if a service has pull_policy: build or its local image is policy
- if opts.Build {
- for i, service := range project.Services {
- if service.Build == nil {
- continue
- }
- service.PullPolicy = types.PullPolicyBuild
- project.Services[i] = service
- }
- }
-
- if err := applyPlatforms(project, true); err != nil {
- return err
- }
-
- err := applyScaleOpts(project, opts.scale)
- if err != nil {
- return err
- }
- return nil
-}
-
-func applyScaleOpts(project *types.Project, opts []string) error {
- for _, scale := range opts {
- name, val, ok := strings.Cut(scale, "=")
- if !ok || val == "" {
- return fmt.Errorf("invalid --scale option %q. Should be SERVICE=NUM", scale)
- }
- replicas, err := strconv.Atoi(val)
- if err != nil {
- return err
- }
- err = setServiceScale(project, name, replicas)
- if err != nil {
- return err
- }
- }
- return nil
-}
-
-func (opts createOptions) isPullPolicyValid() bool {
- pullPolicies := []string{
- types.PullPolicyAlways, types.PullPolicyNever, types.PullPolicyBuild,
- types.PullPolicyMissing, types.PullPolicyIfNotPresent,
- }
- return slices.Contains(pullPolicies, opts.Pull)
-}
diff --git a/cmd/compose/down.go b/cmd/compose/down.go
deleted file mode 100644
index d74c8175292..00000000000
--- a/cmd/compose/down.go
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "os"
- "time"
-
- "github.com/docker/cli/cli/command"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
- "github.com/spf13/pflag"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
- "github.com/docker/compose/v5/pkg/utils"
-)
-
-type downOptions struct {
- *ProjectOptions
- removeOrphans bool
- timeChanged bool
- timeout int
- volumes bool
- images string
-}
-
-func downCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := downOptions{
- ProjectOptions: p,
- }
- downCmd := &cobra.Command{
- Use: "down [OPTIONS] [SERVICES]",
- Short: "Stop and remove containers, networks",
- PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
- opts.timeChanged = cmd.Flags().Changed("timeout")
- if opts.images != "" {
- if opts.images != "all" && opts.images != "local" {
- return fmt.Errorf("invalid value for --rmi: %q", opts.images)
- }
- }
- return nil
- }),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runDown(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := downCmd.Flags()
- removeOrphans := utils.StringToBool(os.Getenv(ComposeRemoveOrphans))
- flags.BoolVar(&opts.removeOrphans, "remove-orphans", removeOrphans, "Remove containers for services not defined in the Compose file")
- flags.IntVarP(&opts.timeout, "timeout", "t", 0, "Specify a shutdown timeout in seconds")
- flags.BoolVarP(&opts.volumes, "volumes", "v", false, `Remove named volumes declared in the "volumes" section of the Compose file and anonymous volumes attached to containers`)
- flags.StringVar(&opts.images, "rmi", "", `Remove images used by services. "local" remove only images that don't have a custom tag ("local"|"all")`)
- flags.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
- if name == "volume" {
- name = "volumes"
- logrus.Warn("--volume is deprecated, please use --volumes")
- }
- return pflag.NormalizedName(name)
- })
- return downCmd
-}
-
-func runDown(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts downOptions, services []string) error {
- project, name, err := opts.projectOrName(ctx, dockerCli, services...)
- if err != nil {
- return err
- }
-
- var timeout *time.Duration
- if opts.timeChanged {
- timeoutValue := time.Duration(opts.timeout) * time.Second
- timeout = &timeoutValue
- }
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- return backend.Down(ctx, name, api.DownOptions{
- RemoveOrphans: opts.removeOrphans,
- Project: project,
- Timeout: timeout,
- Images: opts.images,
- Volumes: opts.volumes,
- Services: services,
- })
-}
diff --git a/cmd/compose/events.go b/cmd/compose/events.go
deleted file mode 100644
index 7f8a4a77df4..00000000000
--- a/cmd/compose/events.go
+++ /dev/null
@@ -1,93 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "encoding/json"
- "fmt"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type eventsOpts struct {
- *composeOptions
- json bool
- since string
- until string
-}
-
-func eventsCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := eventsOpts{
- composeOptions: &composeOptions{
- ProjectOptions: p,
- },
- }
- cmd := &cobra.Command{
- Use: "events [OPTIONS] [SERVICE...]",
- Short: "Receive real time events from containers",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runEvents(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
-
- cmd.Flags().BoolVar(&opts.json, "json", false, "Output events as a stream of json objects")
- cmd.Flags().StringVar(&opts.since, "since", "", "Show all events created since timestamp")
- cmd.Flags().StringVar(&opts.until, "until", "", "Stream events until this timestamp")
- return cmd
-}
-
-func runEvents(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts eventsOpts, services []string) error {
- name, err := opts.toProjectName(ctx, dockerCli)
- if err != nil {
- return err
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- return backend.Events(ctx, name, api.EventsOptions{
- Services: services,
- Since: opts.since,
- Until: opts.until,
- Consumer: func(event api.Event) error {
- if opts.json {
- marshal, err := json.Marshal(map[string]any{
- "time": event.Timestamp,
- "type": "container",
- "service": event.Service,
- "id": event.Container,
- "action": event.Status,
- "attributes": event.Attributes,
- })
- if err != nil {
- return err
- }
- _, _ = fmt.Fprintln(dockerCli.Out(), string(marshal))
- } else {
- _, _ = fmt.Fprintln(dockerCli.Out(), event)
- }
- return nil
- },
- })
-}
diff --git a/cmd/compose/exec.go b/cmd/compose/exec.go
deleted file mode 100644
index f548730dc06..00000000000
--- a/cmd/compose/exec.go
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
-
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli"
- "github.com/docker/cli/cli/command"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
- "github.com/spf13/pflag"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type execOpts struct {
- *composeOptions
-
- service string
- command []string
- environment []string
- workingDir string
-
- noTty bool
- user string
- detach bool
- index int
- privileged bool
- interactive bool
-}
-
-func execCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := execOpts{
- composeOptions: &composeOptions{
- ProjectOptions: p,
- },
- }
- runCmd := &cobra.Command{
- Use: "exec [OPTIONS] SERVICE COMMAND [ARGS...]",
- Short: "Execute a command in a running container",
- Args: cobra.MinimumNArgs(2),
- PreRunE: Adapt(func(ctx context.Context, args []string) error {
- opts.service = args[0]
- opts.command = args[1:]
- return nil
- }),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- err := runExec(ctx, dockerCli, backendOptions, opts)
- if err != nil {
- logrus.Debugf("%v", err)
- var cliError cli.StatusError
- if ok := errors.As(err, &cliError); ok {
- os.Exit(err.(cli.StatusError).StatusCode) //nolint: errorlint
- }
- }
- return err
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
-
- runCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: Run command in the background")
- runCmd.Flags().StringArrayVarP(&opts.environment, "env", "e", []string{}, "Set environment variables")
- runCmd.Flags().IntVar(&opts.index, "index", 0, "Index of the container if service has multiple replicas")
- runCmd.Flags().BoolVarP(&opts.privileged, "privileged", "", false, "Give extended privileges to the process")
- runCmd.Flags().StringVarP(&opts.user, "user", "u", "", "Run the command as this user")
- runCmd.Flags().BoolVarP(&opts.noTty, "no-tty", "T", !dockerCli.Out().IsTerminal(), "Disable pseudo-TTY allocation. By default 'docker compose exec' allocates a TTY.")
- runCmd.Flags().StringVarP(&opts.workingDir, "workdir", "w", "", "Path to workdir directory for this command")
-
- runCmd.Flags().BoolVarP(&opts.interactive, "interactive", "i", true, "Keep STDIN open even if not attached")
- runCmd.Flags().MarkHidden("interactive") //nolint:errcheck
- runCmd.Flags().BoolP("tty", "t", true, "Allocate a pseudo-TTY")
- runCmd.Flags().MarkHidden("tty") //nolint:errcheck
-
- runCmd.Flags().SetInterspersed(false)
- runCmd.Flags().SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
- if name == "no-TTY" { // legacy
- name = "no-tty"
- }
- return pflag.NormalizedName(name)
- })
- return runCmd
-}
-
-func runExec(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts execOpts) error {
- projectName, err := opts.toProjectName(ctx, dockerCli)
- if err != nil {
- return err
- }
- projectOptions, err := opts.composeOptions.toProjectOptions() //nolint:staticcheck
- if err != nil {
- return err
- }
- lookupFn := func(k string) (string, bool) {
- v, ok := projectOptions.Environment[k]
- return v, ok
- }
- execOpts := api.RunOptions{
- Service: opts.service,
- Command: opts.command,
- Environment: compose.ToMobyEnv(types.NewMappingWithEquals(opts.environment).Resolve(lookupFn)),
- Tty: !opts.noTty,
- User: opts.user,
- Privileged: opts.privileged,
- Index: opts.index,
- Detach: opts.detach,
- WorkingDir: opts.workingDir,
- Interactive: opts.interactive,
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- exitCode, err := backend.Exec(ctx, projectName, execOpts)
- if exitCode != 0 {
- errMsg := fmt.Sprintf("exit status %d", exitCode)
- if err != nil && err.Error() != "" {
- errMsg = err.Error()
- }
- return cli.StatusError{StatusCode: exitCode, Status: errMsg}
- }
- return err
-}
diff --git a/cmd/compose/export.go b/cmd/compose/export.go
deleted file mode 100644
index 4c7eaf7ef5d..00000000000
--- a/cmd/compose/export.go
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type exportOptions struct {
- *ProjectOptions
-
- service string
- output string
- index int
-}
-
-func exportCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- options := exportOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "export [OPTIONS] SERVICE",
- Short: "Export a service container's filesystem as a tar archive",
- Args: cobra.MinimumNArgs(1),
- PreRunE: Adapt(func(ctx context.Context, args []string) error {
- options.service = args[0]
- return nil
- }),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runExport(ctx, dockerCli, backendOptions, options)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
-
- flags := cmd.Flags()
- flags.IntVar(&options.index, "index", 0, "index of the container if service has multiple replicas.")
- flags.StringVarP(&options.output, "output", "o", "", "Write to a file, instead of STDOUT")
-
- return cmd
-}
-
-func runExport(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, options exportOptions) error {
- projectName, err := options.toProjectName(ctx, dockerCli)
- if err != nil {
- return err
- }
-
- exportOptions := api.ExportOptions{
- Service: options.service,
- Index: options.index,
- Output: options.output,
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- return backend.Export(ctx, projectName, exportOptions)
-}
diff --git a/cmd/compose/generate.go b/cmd/compose/generate.go
deleted file mode 100644
index 3fe5b2389e5..00000000000
--- a/cmd/compose/generate.go
+++ /dev/null
@@ -1,91 +0,0 @@
-/*
- Copyright 2023 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "os"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type generateOptions struct {
- *ProjectOptions
- Format string
-}
-
-func generateCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := generateOptions{
- ProjectOptions: p,
- }
-
- cmd := &cobra.Command{
- Use: "generate [OPTIONS] [CONTAINERS...]",
- Short: "EXPERIMENTAL - Generate a Compose file from existing containers",
- PreRunE: Adapt(func(ctx context.Context, args []string) error {
- return nil
- }),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runGenerate(ctx, dockerCli, backendOptions, opts, args)
- }),
- }
-
- cmd.Flags().StringVar(&opts.ProjectName, "name", "", "Project name to set in the Compose file")
- cmd.Flags().StringVar(&opts.ProjectDir, "project-dir", "", "Directory to use for the project")
- cmd.Flags().StringVar(&opts.Format, "format", "yaml", "Format the output. Values: [yaml | json]")
- return cmd
-}
-
-func runGenerate(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts generateOptions, containers []string) error {
- _, _ = fmt.Fprintln(os.Stderr, "generate command is EXPERIMENTAL")
- if len(containers) == 0 {
- return fmt.Errorf("at least one container must be specified")
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- project, err := backend.Generate(ctx, api.GenerateOptions{
- Containers: containers,
- ProjectName: opts.ProjectName,
- })
- if err != nil {
- return err
- }
-
- var content []byte
- switch opts.Format {
- case "json":
- content, err = project.MarshalJSON()
- case "yaml":
- content, err = project.MarshalYAML()
- default:
- return fmt.Errorf("unsupported format %q", opts.Format)
- }
- if err != nil {
- return err
- }
- fmt.Println(string(content))
-
- return nil
-}
diff --git a/cmd/compose/hooks.go b/cmd/compose/hooks.go
deleted file mode 100644
index 1697c8686b1..00000000000
--- a/cmd/compose/hooks.go
+++ /dev/null
@@ -1,208 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "encoding/json"
- "io"
- "os"
- "time"
-
- "github.com/compose-spec/compose-go/v2/cli"
- "github.com/docker/cli/cli-plugins/hooks"
- "github.com/docker/cli/cli-plugins/metadata"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/internal/desktop"
-)
-
-func composeLogsHint(appID string) string {
- return "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + hintLink(desktop.BuildLogsURL(appID))
-}
-
-func dockerLogsHint(appID string) string {
- return "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + hintLink(desktop.BuildLogsURL(appID))
-}
-
-// hintLink returns a clickable OSC 8 terminal hyperlink when ANSI is allowed,
-// or the plain URL when ANSI output is suppressed via NO_COLOR or COMPOSE_ANSI.
-func hintLink(url string) string {
- if shouldDisableAnsi() {
- return url
- }
- return formatter.OSC8Link(url, url)
-}
-
-// shouldDisableAnsi checks whether ANSI escape sequences should be explicitly
-// suppressed via environment variables. The hook runs as a separate subprocess
-// where the normal PersistentPreRunE (which calls formatter.SetANSIMode) is
-// skipped, so we check NO_COLOR and COMPOSE_ANSI directly.
-//
-// TTY detection is intentionally omitted: the hook produces a JSON response
-// whose template is rendered by the parent Docker CLI process via
-// PrintNextSteps (which itself emits bold ANSI unconditionally). The hook
-// subprocess cannot reliably detect whether the parent's output is a terminal.
-func shouldDisableAnsi() bool {
- if noColor, ok := os.LookupEnv("NO_COLOR"); ok && noColor != "" {
- return true
- }
- if v, ok := os.LookupEnv("COMPOSE_ANSI"); ok && v == formatter.Never {
- return true
- }
- return false
-}
-
-type hookHint struct {
- template func(appID string) string
- checkFlags func(flags map[string]string) bool
- resolveProject bool
-}
-
-var hooksHints = map[string]hookHint{
- // "docker logs": the CLI hook payload doesn't carry the positional
- // container id, so the link is emitted unfiltered.
- "logs": {template: dockerLogsHint},
- "compose logs": {template: composeLogsHint, resolveProject: true},
- "compose up": {
- template: composeLogsHint,
- resolveProject: true,
- checkFlags: func(flags map[string]string) bool {
- return hasFlag(flags, "detach", "d")
- },
- },
-}
-
-// Test seams. Replace via t.Cleanup; not safe to mutate from t.Parallel().
-var (
- logsTabEnabled = func(ctx context.Context) bool {
- return desktop.IsFeatureActiveStandalone(ctx, desktop.FeatureLogsTab)
- }
- resolveAppID = defaultResolveAppID
-)
-
-const projectNameResolveTimeout = 250 * time.Millisecond
-
-// Root-command flags whose values change which project the loader would
-// resolve. The hook payload exposes flag names but not values, so when any
-// is set we skip the appId rather than emit a wrong filter. workdir is the
-// deprecated alias for --project-directory; env-file can set
-// COMPOSE_PROJECT_NAME via the .env file it points at.
-var projectScopingFlags = []string{
- "project-name", "p",
- "file", "f",
- "project-directory", "workdir",
- "env-file",
-}
-
-func defaultResolveAppID(ctx context.Context, flags map[string]string) string {
- workDir, err := os.Getwd()
- if err != nil {
- return ""
- }
- return resolveAppIDIn(ctx, flags, workDir)
-}
-
-// Split from defaultResolveAppID so tests can pass a t.TempDir() instead
-// of mutating process state via t.Chdir.
-func resolveAppIDIn(ctx context.Context, flags map[string]string, workDir string) string {
- if hasFlag(flags, projectScopingFlags...) {
- return ""
- }
- ctx, cancel := context.WithTimeout(ctx, projectNameResolveTimeout)
- defer cancel()
-
- opts, err := cli.NewProjectOptions(nil,
- cli.WithWorkingDirectory(workDir),
- cli.WithOsEnv,
- cli.WithDotEnv,
- cli.WithConfigFileEnv,
- cli.WithDefaultConfigPath,
- )
- if err != nil {
- return ""
- }
- project, err := opts.LoadProject(ctx)
- if err != nil {
- return ""
- }
- return project.Name
-}
-
-func hasFlag(flags map[string]string, names ...string) bool {
- for _, n := range names {
- if _, ok := flags[n]; ok {
- return true
- }
- }
- return false
-}
-
-// HooksCommand returns the hidden subcommand that the Docker CLI invokes
-// after command execution when the compose plugin has hooks configured.
-// Docker Desktop is responsible for registering which commands trigger hooks
-// in the docker CLI config; the handler gates all hints on the LogsTab
-// feature flag before emitting them.
-func HooksCommand() *cobra.Command {
- return &cobra.Command{
- Use: metadata.HookSubcommandName,
- Hidden: true,
- // Override PersistentPreRunE to prevent the parent's PersistentPreRunE
- // (plugin initialization) from running for hook invocations.
- PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
- RunE: func(cmd *cobra.Command, args []string) error {
- return handleHook(cmd.Context(), args, cmd.OutOrStdout())
- },
- }
-}
-
-func handleHook(ctx context.Context, args []string, w io.Writer) error {
- if len(args) == 0 {
- return nil
- }
-
- var hookData hooks.Request
- if err := json.Unmarshal([]byte(args[0]), &hookData); err != nil {
- return nil
- }
-
- hint, ok := hooksHints[hookData.RootCmd]
- if !ok {
- return nil
- }
-
- if hint.checkFlags != nil && !hint.checkFlags(hookData.Flags) {
- return nil
- }
-
- if !logsTabEnabled(ctx) {
- return nil
- }
-
- var appID string
- if hint.resolveProject {
- appID = resolveAppID(ctx, hookData.Flags)
- }
-
- enc := json.NewEncoder(w)
- enc.SetEscapeHTML(false)
- return enc.Encode(hooks.Response{
- Type: hooks.NextSteps,
- Template: hint.template(appID),
- })
-}
diff --git a/cmd/compose/hooks_test.go b/cmd/compose/hooks_test.go
deleted file mode 100644
index fc53c75edbb..00000000000
--- a/cmd/compose/hooks_test.go
+++ /dev/null
@@ -1,342 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/docker/cli/cli-plugins/hooks"
- "gotest.tools/v3/assert"
-
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/internal/desktop"
-)
-
-const testDeepLink = "docker-desktop://dashboard/logs"
-
-// TestMain stubs the Docker Desktop feature-flag check and the project
-// loader so handleHook tests don't make a live engine call or read a
-// compose file from the test runner's working directory. Individual tests
-// override either stub with t.Cleanup to restore.
-func TestMain(m *testing.M) {
- logsTabEnabled = func(context.Context) bool { return true }
- resolveAppID = func(context.Context, map[string]string) string { return "" }
- os.Exit(m.Run())
-}
-
-func TestHandleHook_NoArgs(t *testing.T) {
- var buf bytes.Buffer
- err := handleHook(t.Context(), nil, &buf)
- assert.NilError(t, err)
- assert.Equal(t, buf.String(), "")
-}
-
-func TestHandleHook_InvalidJSON(t *testing.T) {
- var buf bytes.Buffer
- err := handleHook(t.Context(), []string{"not json"}, &buf)
- assert.NilError(t, err)
- assert.Equal(t, buf.String(), "")
-}
-
-func TestHandleHook_UnknownCommand(t *testing.T) {
- data := marshalHookData(t, hooks.Request{
- RootCmd: "compose push",
- })
- var buf bytes.Buffer
- err := handleHook(t.Context(), []string{data}, &buf)
- assert.NilError(t, err)
- assert.Equal(t, buf.String(), "")
-}
-
-func TestHandleHook_LogsCommand(t *testing.T) {
- tests := []struct {
- rootCmd string
- wantHint func(appID string) string
- }{
- {rootCmd: "compose logs", wantHint: composeLogsHint},
- {rootCmd: "logs", wantHint: dockerLogsHint},
- }
- for _, tt := range tests {
- t.Run(tt.rootCmd, func(t *testing.T) {
- data := marshalHookData(t, hooks.Request{
- RootCmd: tt.rootCmd,
- })
- var buf bytes.Buffer
- err := handleHook(t.Context(), []string{data}, &buf)
- assert.NilError(t, err)
-
- msg := unmarshalResponse(t, buf.Bytes())
- assert.Equal(t, msg.Type, hooks.NextSteps)
- assert.Equal(t, msg.Template, tt.wantHint(""))
- })
- }
-}
-
-func TestHandleHook_ComposeUpDetached(t *testing.T) {
- tests := []struct {
- name string
- flags map[string]string
- wantHint bool
- }{
- {
- name: "with --detach flag",
- flags: map[string]string{"detach": ""},
- wantHint: true,
- },
- {
- name: "with -d flag",
- flags: map[string]string{"d": ""},
- wantHint: true,
- },
- {
- name: "without detach flag",
- flags: map[string]string{"build": ""},
- wantHint: false,
- },
- {
- name: "no flags",
- flags: map[string]string{},
- wantHint: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- data := marshalHookData(t, hooks.Request{
- RootCmd: "compose up",
- Flags: tt.flags,
- })
- var buf bytes.Buffer
- err := handleHook(t.Context(), []string{data}, &buf)
- assert.NilError(t, err)
-
- if tt.wantHint {
- msg := unmarshalResponse(t, buf.Bytes())
- assert.Equal(t, msg.Template, composeLogsHint(""))
- } else {
- assert.Equal(t, buf.String(), "")
- }
- })
- }
-}
-
-func TestHandleHook_HintContainsOSC8Link(t *testing.T) {
- // Ensure ANSI is not suppressed by the test runner environment
- t.Setenv("NO_COLOR", "")
- t.Setenv("COMPOSE_ANSI", "")
- data := marshalHookData(t, hooks.Request{
- RootCmd: "compose logs",
- })
- var buf bytes.Buffer
- err := handleHook(t.Context(), []string{data}, &buf)
- assert.NilError(t, err)
-
- msg := unmarshalResponse(t, buf.Bytes())
- // Verify the template contains the OSC 8 hyperlink sequence
- wantLink := formatter.OSC8Link(testDeepLink, testDeepLink)
- assert.Assert(t, len(wantLink) > len(testDeepLink), "OSC8Link should wrap the URL with escape sequences")
- assert.Assert(t, strings.Contains(msg.Template, wantLink), "hint should contain OSC 8 hyperlink")
-}
-
-func TestHandleHook_NoColorDisablesOsc8(t *testing.T) {
- t.Setenv("NO_COLOR", "1")
- data := marshalHookData(t, hooks.Request{
- RootCmd: "compose logs",
- })
- var buf bytes.Buffer
- err := handleHook(t.Context(), []string{data}, &buf)
- assert.NilError(t, err)
-
- msg := unmarshalResponse(t, buf.Bytes())
- // With NO_COLOR set, the hint should contain the plain URL without escape sequences
- assert.Assert(t, strings.Contains(msg.Template, testDeepLink), "hint should contain the deep link URL")
- assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences")
-}
-
-func TestHandleHook_AppIDEncodedInURL(t *testing.T) {
- prev := resolveAppID
- t.Cleanup(func() { resolveAppID = prev })
- resolveAppID = func(context.Context, map[string]string) string { return "myapp" }
-
- t.Setenv("NO_COLOR", "1") // emit a plain URL we can substring-match
- for _, rootCmd := range []string{"compose logs", "compose up"} {
- t.Run(rootCmd, func(t *testing.T) {
- data := marshalHookData(t, hooks.Request{
- RootCmd: rootCmd,
- Flags: map[string]string{"d": "true"},
- })
- var buf bytes.Buffer
- err := handleHook(t.Context(), []string{data}, &buf)
- assert.NilError(t, err)
-
- msg := unmarshalResponse(t, buf.Bytes())
- assert.Assert(t, strings.Contains(msg.Template, desktop.BuildLogsURL("myapp")),
- "hint should include the project-scoped URL, got %q", msg.Template)
- })
- }
-}
-
-func TestHandleHook_DockerLogsIgnoresAppID(t *testing.T) {
- // resolveAppID is not consulted for "logs" because that hint isn't
- // resolveProject; assert the URL stays paramless even if a stub
- // would otherwise return a value.
- prev := resolveAppID
- t.Cleanup(func() { resolveAppID = prev })
- resolveAppID = func(context.Context, map[string]string) string {
- t.Fatalf("resolveAppID should not be called for docker logs")
- return ""
- }
-
- t.Setenv("NO_COLOR", "1")
- data := marshalHookData(t, hooks.Request{RootCmd: "logs"})
- var buf bytes.Buffer
- err := handleHook(t.Context(), []string{data}, &buf)
- assert.NilError(t, err)
-
- msg := unmarshalResponse(t, buf.Bytes())
- assert.Assert(t, strings.Contains(msg.Template, testDeepLink),
- "docker logs hint should contain the bare deep link")
- assert.Assert(t, !strings.Contains(msg.Template, "?appId="),
- "docker logs hint must not encode an appId")
-}
-
-func TestHandleHook_FeatureFlagDisabledSuppressesHint(t *testing.T) {
- prev := logsTabEnabled
- t.Cleanup(func() { logsTabEnabled = prev })
- logsTabEnabled = func(context.Context) bool { return false }
-
- for _, rootCmd := range []string{"compose logs", "logs"} {
- t.Run(rootCmd, func(t *testing.T) {
- data := marshalHookData(t, hooks.Request{RootCmd: rootCmd})
- var buf bytes.Buffer
- err := handleHook(t.Context(), []string{data}, &buf)
- assert.NilError(t, err)
- assert.Equal(t, buf.String(), "")
- })
- }
-}
-
-func TestHandleHook_ComposeAnsiNeverDisablesOsc8(t *testing.T) {
- t.Setenv("COMPOSE_ANSI", "never")
- data := marshalHookData(t, hooks.Request{
- RootCmd: "compose logs",
- })
- var buf bytes.Buffer
- err := handleHook(t.Context(), []string{data}, &buf)
- assert.NilError(t, err)
-
- msg := unmarshalResponse(t, buf.Bytes())
- assert.Assert(t, strings.Contains(msg.Template, testDeepLink), "hint should contain the deep link URL")
- assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences")
-}
-
-func TestResolveAppID_ShortCircuitsOnFlag(t *testing.T) {
- tests := []struct {
- name string
- flags map[string]string
- }{
- {name: "long --project-name", flags: map[string]string{"project-name": ""}},
- {name: "short -p", flags: map[string]string{"p": ""}},
- {name: "long --file", flags: map[string]string{"file": ""}},
- {name: "short -f", flags: map[string]string{"f": ""}},
- {name: "long --project-directory", flags: map[string]string{"project-directory": ""}},
- {name: "deprecated --workdir alias", flags: map[string]string{"workdir": ""}},
- {name: "long --env-file", flags: map[string]string{"env-file": ""}},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Use a real tmpdir as workDir so the short-circuit path is
- // exercised independently of the loader's file discovery.
- got := resolveAppIDIn(t.Context(), tt.flags, t.TempDir())
- assert.Equal(t, got, "")
- })
- }
-}
-
-func TestResolveAppID_NameFromComposeFile(t *testing.T) {
- dir := t.TempDir()
- mustWrite(t, dir, "compose.yaml", "name: from-yaml\nservices:\n svc:\n image: nginx\n")
- unsetEnv(t, "COMPOSE_PROJECT_NAME")
- unsetEnv(t, "COMPOSE_FILE")
-
- got := resolveAppIDIn(t.Context(), nil, dir)
- assert.Equal(t, got, "from-yaml")
-}
-
-func TestResolveAppID_EnvVarOverridesYAML(t *testing.T) {
- dir := t.TempDir()
- mustWrite(t, dir, "compose.yaml", "name: from-yaml\nservices:\n svc:\n image: nginx\n")
- t.Setenv("COMPOSE_PROJECT_NAME", "from-env")
- unsetEnv(t, "COMPOSE_FILE")
-
- got := resolveAppIDIn(t.Context(), nil, dir)
- assert.Equal(t, got, "from-env")
-}
-
-func TestResolveAppID_NoComposeFileReturnsEmpty(t *testing.T) {
- unsetEnv(t, "COMPOSE_PROJECT_NAME")
- unsetEnv(t, "COMPOSE_FILE")
-
- got := resolveAppIDIn(t.Context(), nil, t.TempDir())
- assert.Equal(t, got, "")
-}
-
-// unsetEnv removes an env var for the lifetime of the test, restoring its
-// prior state on cleanup. t.Setenv("", "") is not equivalent to unset:
-// compose-go's WithConfigFileEnv treats empty as a meaningful override.
-func unsetEnv(t *testing.T, key string) {
- t.Helper()
- prev, had := os.LookupEnv(key)
- if err := os.Unsetenv(key); err != nil {
- t.Fatalf("unsetenv %s: %v", key, err)
- }
- t.Cleanup(func() {
- if !had {
- return
- }
- if err := os.Setenv(key, prev); err != nil {
- t.Errorf("restore env %s: %v", key, err)
- }
- })
-}
-
-func mustWrite(t *testing.T, dir, name, content string) {
- t.Helper()
- if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
- t.Fatalf("write %s: %v", name, err)
- }
-}
-
-func marshalHookData(t *testing.T, data hooks.Request) string {
- t.Helper()
- b, err := json.Marshal(data)
- assert.NilError(t, err)
- return string(b)
-}
-
-func unmarshalResponse(t *testing.T, data []byte) hooks.Response {
- t.Helper()
- var msg hooks.Response
- err := json.Unmarshal(data, &msg)
- assert.NilError(t, err)
- return msg
-}
diff --git a/cmd/compose/images.go b/cmd/compose/images.go
deleted file mode 100644
index ead066b17a6..00000000000
--- a/cmd/compose/images.go
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "io"
- "maps"
- "slices"
- "strings"
- "time"
-
- "github.com/containerd/platforms"
- "github.com/docker/cli/cli/command"
- "github.com/docker/go-units"
- "github.com/moby/moby/client/pkg/stringid"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type imageOptions struct {
- *ProjectOptions
- Quiet bool
- Format string
-}
-
-func imagesCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := imageOptions{
- ProjectOptions: p,
- }
- imgCmd := &cobra.Command{
- Use: "images [OPTIONS] [SERVICE...]",
- Short: "List images used by the created containers",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runImages(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- imgCmd.Flags().StringVar(&opts.Format, "format", "table", "Format the output. Values: [table | json]")
- imgCmd.Flags().BoolVarP(&opts.Quiet, "quiet", "q", false, "Only display IDs")
- return imgCmd
-}
-
-func runImages(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts imageOptions, services []string) error {
- projectName, err := opts.toProjectName(ctx, dockerCli)
- if err != nil {
- return err
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- images, err := backend.Images(ctx, projectName, api.ImagesOptions{
- Services: services,
- })
- if err != nil {
- return err
- }
-
- if opts.Quiet {
- ids := []string{}
- for _, img := range images {
- id := img.ID
- if i := strings.IndexRune(img.ID, ':'); i >= 0 {
- id = id[i+1:]
- }
- if !slices.Contains(ids, id) {
- ids = append(ids, id)
- }
- }
- for _, img := range ids {
- _, _ = fmt.Fprintln(dockerCli.Out(), img)
- }
- return nil
- }
- if opts.Format == "json" {
-
- type img struct {
- ID string `json:"ID"`
- ContainerName string `json:"ContainerName"`
- Repository string `json:"Repository"`
- Tag string `json:"Tag"`
- Platform string `json:"Platform"`
- Size int64 `json:"Size"`
- Created *time.Time `json:"Created,omitempty"`
- LastTagTime time.Time `json:"LastTagTime,omitzero"`
- }
- // Convert map to slice
- var imageList []img
- for ctr, i := range images {
- lastTagTime := i.LastTagTime
- imageList = append(imageList, img{
- ContainerName: ctr,
- ID: i.ID,
- Repository: i.Repository,
- Tag: i.Tag,
- Platform: platforms.Format(i.Platform),
- Size: i.Size,
- Created: i.Created,
- LastTagTime: lastTagTime,
- })
- }
- json, err := formatter.ToJSON(imageList, "", "")
- if err != nil {
- return err
- }
- _, err = fmt.Fprintln(dockerCli.Out(), json)
- return err
- }
-
- return formatter.Print(images, opts.Format, dockerCli.Out(),
- func(w io.Writer) {
- for _, container := range slices.Sorted(maps.Keys(images)) {
- img := images[container]
- id := stringid.TruncateID(img.ID)
- size := units.HumanSizeWithPrecision(float64(img.Size), 3)
- repo := img.Repository
- if repo == "" {
- repo = ""
- }
- tag := img.Tag
- if tag == "" {
- tag = ""
- }
- created := "N/A"
- if img.Created != nil {
- created = units.HumanDuration(time.Now().UTC().Sub(*img.Created)) + " ago"
- }
- _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
- container, repo, tag, platforms.Format(img.Platform), id, size, created)
- }
- },
- "CONTAINER", "REPOSITORY", "TAG", "PLATFORM", "IMAGE ID", "SIZE", "CREATED")
-}
diff --git a/cmd/compose/kill.go b/cmd/compose/kill.go
deleted file mode 100644
index 1ec83153bc9..00000000000
--- a/cmd/compose/kill.go
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/utils"
-)
-
-type killOptions struct {
- *ProjectOptions
- removeOrphans bool
- signal string
-}
-
-func killCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := killOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "kill [OPTIONS] [SERVICE...]",
- Short: "Force stop service containers",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runKill(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
-
- flags := cmd.Flags()
- removeOrphans := utils.StringToBool(os.Getenv(ComposeRemoveOrphans))
- flags.BoolVar(&opts.removeOrphans, "remove-orphans", removeOrphans, "Remove containers for services not defined in the Compose file")
- flags.StringVarP(&opts.signal, "signal", "s", "SIGKILL", "SIGNAL to send to the container")
-
- return cmd
-}
-
-func runKill(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts killOptions, services []string) error {
- project, name, err := opts.projectOrName(ctx, dockerCli, services...)
- if err != nil {
- return err
- }
-
- return withBackend(dockerCli, backendOptions, func(backend api.Compose) error {
- err := backend.Kill(ctx, name, api.KillOptions{
- RemoveOrphans: opts.removeOrphans,
- Project: project,
- Services: services,
- Signal: opts.signal,
- })
- if errors.Is(err, api.ErrNoResources) {
- _, _ = fmt.Fprintln(stdinfo(dockerCli), "No container to kill")
- return nil
- }
- return err
- })
-}
diff --git a/cmd/compose/list.go b/cmd/compose/list.go
deleted file mode 100644
index 8a7f875da2d..00000000000
--- a/cmd/compose/list.go
+++ /dev/null
@@ -1,145 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "regexp"
- "strings"
-
- "github.com/docker/cli/cli/command"
- "github.com/docker/cli/opts"
- "github.com/moby/moby/client"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type lsOptions struct {
- Format string
- Quiet bool
- All bool
- Filter opts.FilterOpt
-}
-
-func listCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- lsOpts := lsOptions{Filter: opts.NewFilterOpt()}
- lsCmd := &cobra.Command{
- Use: "ls [OPTIONS]",
- Short: "List running compose projects",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runList(ctx, dockerCli, backendOptions, lsOpts)
- }),
- Args: cobra.NoArgs,
- ValidArgsFunction: noCompletion(),
- }
- lsCmd.Flags().StringVar(&lsOpts.Format, "format", "table", "Format the output. Values: [table | json]")
- lsCmd.Flags().BoolVarP(&lsOpts.Quiet, "quiet", "q", false, "Only display project names")
- lsCmd.Flags().Var(&lsOpts.Filter, "filter", "Filter output based on conditions provided")
- lsCmd.Flags().BoolVarP(&lsOpts.All, "all", "a", false, "Show all stopped Compose projects")
-
- return lsCmd
-}
-
-var acceptedListFilters = map[string]bool{
- "name": true,
-}
-
-// match returns true if any of the values at key match the source string
-func match(filters client.Filters, field, source string) bool {
- if f, ok := filters[field]; ok && f[source] {
- return true
- }
-
- fieldValues := filters[field]
- for name2match := range fieldValues {
- isMatch, err := regexp.MatchString(name2match, source)
- if err != nil {
- continue
- }
- if isMatch {
- return true
- }
- }
- return false
-}
-
-func runList(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, lsOpts lsOptions) error {
- filters := lsOpts.Filter.Value()
-
- for filter := range filters {
- if _, ok := acceptedListFilters[filter]; !ok {
- return errors.New("invalid filter '" + filter + "'")
- }
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- stackList, err := backend.List(ctx, api.ListOptions{All: lsOpts.All})
- if err != nil {
- return err
- }
-
- if len(filters) > 0 {
- var filtered []api.Stack
- for _, s := range stackList {
- if match(filters, "name", s.Name) {
- filtered = append(filtered, s)
- }
- }
- stackList = filtered
- }
-
- if lsOpts.Quiet {
- for _, s := range stackList {
- _, _ = fmt.Fprintln(dockerCli.Out(), s.Name)
- }
- return nil
- }
-
- view := viewFromStackList(stackList)
- return formatter.Print(view, lsOpts.Format, dockerCli.Out(), func(w io.Writer) {
- for _, stack := range view {
- _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", stack.Name, stack.Status, stack.ConfigFiles)
- }
- }, "NAME", "STATUS", "CONFIG FILES")
-}
-
-type stackView struct {
- Name string
- Status string
- ConfigFiles string
-}
-
-func viewFromStackList(stackList []api.Stack) []stackView {
- retList := make([]stackView, len(stackList))
- for i, s := range stackList {
- retList[i] = stackView{
- Name: s.Name,
- Status: strings.TrimSpace(fmt.Sprintf("%s %s", s.Status, s.Reason)),
- ConfigFiles: s.ConfigFiles,
- }
- }
- return retList
-}
diff --git a/cmd/compose/logs.go b/cmd/compose/logs.go
deleted file mode 100644
index 8f1b2f11862..00000000000
--- a/cmd/compose/logs.go
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "errors"
-
- "github.com/docker/cli-docs-tool/annotation"
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type logsOptions struct {
- *ProjectOptions
- composeOptions
- follow bool
- index int
- tail string
- since string
- until string
- noColor bool
- noPrefix bool
- timestamps bool
-}
-
-func logsCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := logsOptions{
- ProjectOptions: p,
- }
- logsCmd := &cobra.Command{
- Use: "logs [OPTIONS] [SERVICE...]",
- Short: "View output from containers",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runLogs(ctx, dockerCli, backendOptions, opts, args)
- }),
- PreRunE: func(cmd *cobra.Command, args []string) error {
- if opts.index > 0 && len(args) != 1 {
- return errors.New("--index requires one service to be selected")
- }
- return nil
- },
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := logsCmd.Flags()
- flags.BoolVarP(&opts.follow, "follow", "f", false, "Follow log output")
- flags.SetAnnotation("follow", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/container/logs/#follow"}) //nolint:errcheck
- flags.IntVar(&opts.index, "index", 0, "index of the container if service has multiple replicas")
- flags.StringVar(&opts.since, "since", "", "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)")
- flags.SetAnnotation("since", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/container/logs/#since"}) //nolint:errcheck
- flags.StringVar(&opts.until, "until", "", "Show logs before a timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)")
- flags.SetAnnotation("until", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/container/logs/#until"}) //nolint:errcheck
- flags.BoolVar(&opts.noColor, "no-color", false, "Produce monochrome output")
- flags.BoolVar(&opts.noPrefix, "no-log-prefix", false, "Don't print prefix in logs")
- flags.BoolVarP(&opts.timestamps, "timestamps", "t", false, "Show timestamps")
- flags.SetAnnotation("timestamps", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/container/logs/#timestamps"}) //nolint:errcheck
- flags.StringVarP(&opts.tail, "tail", "n", "all", "Number of lines to show from the end of the logs for each container")
- flags.SetAnnotation("tail", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/container/logs/#tail"}) //nolint:errcheck
- return logsCmd
-}
-
-func runLogs(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts logsOptions, services []string) error {
- project, name, err := opts.projectOrName(ctx, dockerCli, services...)
- if err != nil {
- return err
- }
-
- // exclude services configured to ignore output (attach: false), until explicitly selected
- if project != nil && len(services) == 0 {
- for n, service := range project.Services {
- if service.Attach == nil || *service.Attach {
- services = append(services, n)
- }
- }
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- consumer := formatter.NewLogConsumer(ctx, dockerCli.Out(), dockerCli.Err(), !opts.noColor, !opts.noPrefix, false)
- return backend.Logs(ctx, name, consumer, api.LogOptions{
- Project: project,
- Services: services,
- Follow: opts.follow,
- Index: opts.index,
- Tail: opts.tail,
- Since: opts.since,
- Until: opts.until,
- Timestamps: opts.timestamps,
- })
-}
-
-var _ api.LogConsumer = &logConsumer{}
-
-type logConsumer struct {
- events api.EventProcessor
-}
-
-func (l logConsumer) Log(containerName, message string) {
- l.events.On(api.Resource{
- ID: containerName,
- Text: message,
- })
-}
-
-func (l logConsumer) Err(containerName, message string) {
- l.events.On(api.Resource{
- ID: containerName,
- Status: api.Error,
- Text: message,
- })
-}
-
-func (l logConsumer) Status(containerName, message string) {
- l.events.On(api.Resource{
- ID: containerName,
- Status: api.Error,
- Text: message,
- })
-}
diff --git a/cmd/compose/options.go b/cmd/compose/options.go
deleted file mode 100644
index df2848a6bf1..00000000000
--- a/cmd/compose/options.go
+++ /dev/null
@@ -1,293 +0,0 @@
-/*
- Copyright 2023 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "io"
- "os"
- "slices"
- "sort"
- "strings"
- "text/tabwriter"
-
- "github.com/compose-spec/compose-go/v2/cli"
- "github.com/compose-spec/compose-go/v2/loader"
- "github.com/compose-spec/compose-go/v2/template"
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/command"
-
- "github.com/docker/compose/v5/cmd/display"
- "github.com/docker/compose/v5/cmd/prompt"
- "github.com/docker/compose/v5/internal/tracing"
-)
-
-func applyPlatforms(project *types.Project, buildForSinglePlatform bool) error {
- defaultPlatform := project.Environment["DOCKER_DEFAULT_PLATFORM"]
- for name, service := range project.Services {
- if service.Build == nil {
- continue
- }
-
- // default platform only applies if the service doesn't specify
- if defaultPlatform != "" && service.Platform == "" {
- if len(service.Build.Platforms) > 0 && !slices.Contains(service.Build.Platforms, defaultPlatform) {
- return fmt.Errorf("service %q build.platforms does not support value set by DOCKER_DEFAULT_PLATFORM: %s", name, defaultPlatform)
- }
- service.Platform = defaultPlatform
- }
-
- if service.Platform != "" {
- if len(service.Build.Platforms) > 0 {
- if !slices.Contains(service.Build.Platforms, service.Platform) {
- return fmt.Errorf("service %q build configuration does not support platform: %s", name, service.Platform)
- }
- }
-
- if buildForSinglePlatform || len(service.Build.Platforms) == 0 {
- // if we're building for a single platform, we want to build for the platform we'll use to run the image
- // similarly, if no build platforms were explicitly specified, it makes sense to build for the platform
- // the image is designed for rather than allowing the builder to infer the platform
- service.Build.Platforms = []string{service.Platform}
- }
- }
-
- // services can specify that they should be built for multiple platforms, which can be used
- // with `docker compose build` to produce a multi-arch image
- // other cases, such as `up` and `run`, need a single architecture to actually run
- // if there is only a single platform present (which might have been inferred
- // from service.Platform above), it will be used, even if it requires emulation.
- // if there's more than one platform, then the list is cleared so that the builder
- // can decide.
- // TODO(milas): there's no validation that the platform the builder will pick is actually one
- // of the supported platforms from the build definition
- // e.g. `build.platforms: [linux/arm64, linux/amd64]` on a `linux/ppc64` machine would build
- // for `linux/ppc64` instead of returning an error that it's not a valid platform for the service.
- if buildForSinglePlatform && len(service.Build.Platforms) > 1 {
- // empty indicates that the builder gets to decide
- service.Build.Platforms = nil
- }
- project.Services[name] = service
- }
- return nil
-}
-
-// isRemoteConfig checks if the main compose file is from a remote source (OCI or Git)
-func isRemoteConfig(dockerCli command.Cli, options buildOptions) bool {
- if len(options.ConfigPaths) == 0 {
- return false
- }
- remoteLoaders := options.remoteLoaders(dockerCli)
- for _, loader := range remoteLoaders {
- if loader.Accept(options.ConfigPaths[0]) {
- return true
- }
- }
- return false
-}
-
-// checksForRemoteStack handles environment variable prompts for remote configurations
-func checksForRemoteStack(ctx context.Context, dockerCli command.Cli, project *types.Project, options buildOptions, assumeYes bool, cmdEnvs []string) error {
- if !isRemoteConfig(dockerCli, options) {
- return nil
- }
- if metrics, ok := ctx.Value(tracing.MetricsKey{}).(tracing.Metrics); ok && metrics.CountIncludesRemote > 0 {
- if err := confirmRemoteIncludes(dockerCli, options, assumeYes); err != nil {
- return err
- }
- }
- displayLocationRemoteStack(dockerCli, project, options)
- return promptForInterpolatedVariables(ctx, dockerCli, options.ProjectOptions, assumeYes, cmdEnvs)
-}
-
-// Prepare the values map and collect all variables info
-type varInfo struct {
- name string
- value string
- source string
- required bool
- defaultValue string
-}
-
-// promptForInterpolatedVariables displays all variables and their values at once,
-// then prompts for confirmation
-func promptForInterpolatedVariables(ctx context.Context, dockerCli command.Cli, projectOptions *ProjectOptions, assumeYes bool, cmdEnvs []string) error {
- if assumeYes {
- return nil
- }
-
- varsInfo, noVariables, err := extractInterpolationVariablesFromModel(ctx, dockerCli, projectOptions, cmdEnvs)
- if err != nil {
- return err
- }
-
- if noVariables {
- return nil
- }
-
- displayInterpolationVariables(dockerCli.Out(), varsInfo)
-
- // Prompt for confirmation
- userInput := prompt.NewPrompt(dockerCli.In(), dockerCli.Out())
- msg := "\nDo you want to proceed with these variables? [Y/n]: "
- confirmed, err := userInput.Confirm(msg, true)
- if err != nil {
- return err
- }
-
- if !confirmed {
- return fmt.Errorf("operation cancelled by user")
- }
-
- return nil
-}
-
-func extractInterpolationVariablesFromModel(ctx context.Context, dockerCli command.Cli, projectOptions *ProjectOptions, cmdEnvs []string) ([]varInfo, bool, error) {
- cmdEnvMap := extractEnvCLIDefined(cmdEnvs)
-
- // Create a model without interpolation to extract variables
- opts := configOptions{
- noInterpolate: true,
- ProjectOptions: projectOptions,
- }
-
- model, err := opts.ToModel(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution, cli.WithLoadOptions(loader.WithSkipValidation))
- if err != nil {
- return nil, false, err
- }
-
- // Extract variables that need interpolation
- variables := template.ExtractVariables(model, template.DefaultPattern)
- if len(variables) == 0 {
- return nil, true, nil
- }
-
- var varsInfo []varInfo
- proposedValues := make(map[string]string)
-
- for name, variable := range variables {
- info := varInfo{
- name: name,
- required: variable.Required,
- defaultValue: variable.DefaultValue,
- }
-
- // Determine value and source based on priority
- if value, exists := cmdEnvMap[name]; exists {
- info.value = value
- info.source = "command-line"
- proposedValues[name] = value
- } else if value, exists := os.LookupEnv(name); exists {
- info.value = value
- info.source = "environment"
- proposedValues[name] = value
- } else if variable.DefaultValue != "" {
- info.value = variable.DefaultValue
- info.source = "compose file"
- proposedValues[name] = variable.DefaultValue
- } else {
- info.value = ""
- info.source = "none"
- }
-
- varsInfo = append(varsInfo, info)
- }
- return varsInfo, false, nil
-}
-
-func extractEnvCLIDefined(cmdEnvs []string) map[string]string {
- // Parse command-line environment variables
- cmdEnvMap := make(map[string]string)
- for _, env := range cmdEnvs {
- key, val, ok := strings.Cut(env, "=")
- if ok {
- cmdEnvMap[key] = val
- }
- }
- return cmdEnvMap
-}
-
-func displayInterpolationVariables(writer io.Writer, varsInfo []varInfo) {
- // Display all variables in a table format
- _, _ = fmt.Fprintln(writer, "\nFound the following variables in configuration:")
-
- w := tabwriter.NewWriter(writer, 0, 0, 3, ' ', 0)
- _, _ = fmt.Fprintln(w, "VARIABLE\tVALUE\tSOURCE\tREQUIRED\tDEFAULT")
- sort.Slice(varsInfo, func(a, b int) bool {
- return varsInfo[a].name < varsInfo[b].name
- })
- for _, info := range varsInfo {
- required := "no"
- if info.required {
- required = "yes"
- }
- _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
- info.name,
- info.value,
- info.source,
- required,
- info.defaultValue,
- )
- }
- _ = w.Flush()
-}
-
-func displayLocationRemoteStack(dockerCli command.Cli, project *types.Project, options buildOptions) {
- mainComposeFile := options.ProjectOptions.ConfigPaths[0] //nolint:staticcheck
- if display.Mode != display.ModeQuiet && display.Mode != display.ModeJSON {
- _, _ = fmt.Fprintf(dockerCli.Out(), "Your compose stack %q is stored in %q\n", mainComposeFile, project.WorkingDir)
- }
-}
-
-func confirmRemoteIncludes(dockerCli command.Cli, options buildOptions, assumeYes bool) error {
- if assumeYes {
- return nil
- }
-
- var remoteIncludes []string
- remoteLoaders := options.ProjectOptions.remoteLoaders(dockerCli) //nolint:staticcheck
- for _, cf := range options.ProjectOptions.ConfigPaths { //nolint:staticcheck
- for _, loader := range remoteLoaders {
- if loader.Accept(cf) {
- remoteIncludes = append(remoteIncludes, cf)
- break
- }
- }
- }
-
- if len(remoteIncludes) == 0 {
- return nil
- }
-
- _, _ = fmt.Fprintln(dockerCli.Out(), "\nWarning: This Compose project includes files from remote sources:")
- for _, include := range remoteIncludes {
- _, _ = fmt.Fprintf(dockerCli.Out(), " - %s\n", include)
- }
- _, _ = fmt.Fprintln(dockerCli.Out(), "\nRemote includes could potentially be malicious. Make sure you trust the source.")
-
- msg := "Do you want to continue? [y/N]: "
- confirmed, err := prompt.NewPrompt(dockerCli.In(), dockerCli.Out()).Confirm(msg, false)
- if err != nil {
- return err
- }
- if !confirmed {
- return fmt.Errorf("operation cancelled by user")
- }
-
- return nil
-}
diff --git a/cmd/compose/options_test.go b/cmd/compose/options_test.go
deleted file mode 100644
index 13a33d7994c..00000000000
--- a/cmd/compose/options_test.go
+++ /dev/null
@@ -1,453 +0,0 @@
-/*
- Copyright 2023 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "bytes"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/streams"
- "go.uber.org/mock/gomock"
- "gotest.tools/v3/assert"
-
- "github.com/docker/compose/v5/pkg/mocks"
-)
-
-func TestApplyPlatforms_InferFromRuntime(t *testing.T) {
- makeProject := func() *types.Project {
- return &types.Project{
- Services: types.Services{
- "test": {
- Name: "test",
- Image: "foo",
- Build: &types.BuildConfig{
- Context: ".",
- Platforms: []string{
- "linux/amd64",
- "linux/arm64",
- "alice/32",
- },
- },
- Platform: "alice/32",
- },
- },
- }
- }
-
- t.Run("SinglePlatform", func(t *testing.T) {
- project := makeProject()
- assert.NilError(t, applyPlatforms(project, true))
- assert.DeepEqual(t, types.StringList{"alice/32"}, project.Services["test"].Build.Platforms)
- })
-
- t.Run("MultiPlatform", func(t *testing.T) {
- project := makeProject()
- assert.NilError(t, applyPlatforms(project, false))
- assert.DeepEqual(t, types.StringList{"linux/amd64", "linux/arm64", "alice/32"}, project.Services["test"].Build.Platforms)
- })
-}
-
-func TestApplyPlatforms_DockerDefaultPlatform(t *testing.T) {
- makeProject := func() *types.Project {
- return &types.Project{
- Environment: map[string]string{
- "DOCKER_DEFAULT_PLATFORM": "linux/amd64",
- },
- Services: types.Services{
- "test": {
- Name: "test",
- Image: "foo",
- Build: &types.BuildConfig{
- Context: ".",
- Platforms: []string{
- "linux/amd64",
- "linux/arm64",
- },
- },
- },
- },
- }
- }
-
- t.Run("SinglePlatform", func(t *testing.T) {
- project := makeProject()
- assert.NilError(t, applyPlatforms(project, true))
- assert.DeepEqual(t, types.StringList{"linux/amd64"}, project.Services["test"].Build.Platforms)
- })
-
- t.Run("MultiPlatform", func(t *testing.T) {
- project := makeProject()
- assert.NilError(t, applyPlatforms(project, false))
- assert.DeepEqual(t, types.StringList{"linux/amd64", "linux/arm64"}, project.Services["test"].Build.Platforms)
- })
-}
-
-func TestApplyPlatforms_UnsupportedPlatform(t *testing.T) {
- makeProject := func() *types.Project {
- return &types.Project{
- Environment: map[string]string{
- "DOCKER_DEFAULT_PLATFORM": "commodore/64",
- },
- Services: types.Services{
- "test": {
- Name: "test",
- Image: "foo",
- Build: &types.BuildConfig{
- Context: ".",
- Platforms: []string{
- "linux/amd64",
- "linux/arm64",
- },
- },
- },
- },
- }
- }
-
- t.Run("SinglePlatform", func(t *testing.T) {
- project := makeProject()
- assert.Error(t, applyPlatforms(project, true),
- `service "test" build.platforms does not support value set by DOCKER_DEFAULT_PLATFORM: commodore/64`)
- })
-
- t.Run("MultiPlatform", func(t *testing.T) {
- project := makeProject()
- assert.Error(t, applyPlatforms(project, false),
- `service "test" build.platforms does not support value set by DOCKER_DEFAULT_PLATFORM: commodore/64`)
- })
-}
-
-func TestIsRemoteConfig(t *testing.T) {
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
- cli := mocks.NewMockCli(ctrl)
-
- tests := []struct {
- name string
- configPaths []string
- want bool
- }{
- {
- name: "empty config paths",
- configPaths: []string{},
- want: false,
- },
- {
- name: "local file",
- configPaths: []string{"docker-compose.yaml"},
- want: false,
- },
- {
- name: "OCI reference",
- configPaths: []string{"oci://registry.example.com/stack:latest"},
- want: true,
- },
- {
- name: "GIT reference",
- configPaths: []string{"git://github.com/user/repo.git"},
- want: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- opts := buildOptions{
- ProjectOptions: &ProjectOptions{
- ConfigPaths: tt.configPaths,
- },
- }
- got := isRemoteConfig(cli, opts)
- assert.Equal(t, tt.want, got)
- })
- }
-}
-
-func TestDisplayLocationRemoteStack(t *testing.T) {
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
- cli := mocks.NewMockCli(ctrl)
-
- buf := new(bytes.Buffer)
- cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes()
-
- project := &types.Project{
- Name: "test-project",
- WorkingDir: "/tmp/test",
- }
-
- options := buildOptions{
- ProjectOptions: &ProjectOptions{
- ConfigPaths: []string{"oci://registry.example.com/stack:latest"},
- },
- }
-
- displayLocationRemoteStack(cli, project, options)
-
- output := buf.String()
- assert.Equal(t, output, fmt.Sprintf("Your compose stack %q is stored in %q\n", "oci://registry.example.com/stack:latest", "/tmp/test"))
-}
-
-func TestDisplayInterpolationVariables(t *testing.T) {
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
-
- tmpDir := t.TempDir()
-
- // Create a temporary compose file
- composeContent := `
-services:
- app:
- image: nginx
- environment:
- - TEST_VAR=${TEST_VAR:?required} # required with default
- - API_KEY=${API_KEY:?} # required without default
- - DEBUG=${DEBUG:-true} # optional with default
- - UNSET_VAR # optional without default
-`
- composePath := filepath.Join(tmpDir, "docker-compose.yml")
- assert.NilError(t, os.WriteFile(composePath, []byte(composeContent), 0o644))
-
- buf := new(bytes.Buffer)
- cli := mocks.NewMockCli(ctrl)
- cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes()
-
- // Create ProjectOptions with the temporary compose file
- projectOptions := &ProjectOptions{
- ConfigPaths: []string{composePath},
- }
-
- // Set up the context with necessary environment variables
- t.Setenv("TEST_VAR", "test-value")
- t.Setenv("API_KEY", "123456")
-
- // Extract variables from the model
- info, noVariables, err := extractInterpolationVariablesFromModel(t.Context(), cli, projectOptions, []string{})
- assert.NilError(t, err)
- assert.Assert(t, noVariables == false)
-
- // Display the variables
- displayInterpolationVariables(cli.Out(), info)
-
- // Expected output format with proper spacing
- expected := "\nFound the following variables in configuration:\n" +
- "VARIABLE VALUE SOURCE REQUIRED DEFAULT\n" +
- "API_KEY 123456 environment yes \n" +
- "DEBUG true compose file no true\n" +
- "TEST_VAR test-value environment yes \n"
-
- // Normalize spaces and newlines for comparison
- normalizeSpaces := func(s string) string {
- // Replace multiple spaces with a single space
- s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
- return s
- }
-
- actualOutput := buf.String()
-
- // Compare normalized strings
- assert.Equal(t,
- normalizeSpaces(expected),
- normalizeSpaces(actualOutput),
- "\nExpected:\n%s\nGot:\n%s", expected, actualOutput)
-}
-
-func TestExtractInterpolationVariablesFromModelAllowsTemplatedPortFields(t *testing.T) {
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
- cli := mocks.NewMockCli(ctrl)
-
- dir := t.TempDir()
- composePath := filepath.Join(dir, "compose.yaml")
- assert.NilError(t, os.WriteFile(composePath, []byte(`
-name: remote-defaults
-services:
- web:
- image: nginx
- ports:
- - host_ip: "${LXKNS_ADDRESS:-127.0.0.1}"
- published: "${LXKNS_PORT:-5010}"
- target: 80
- protocol: tcp
-`), 0o600))
-
- projectOptions := &ProjectOptions{
- ConfigPaths: []string{composePath},
- ProjectDir: dir,
- }
- info, noVariables, err := extractInterpolationVariablesFromModel(t.Context(), cli, projectOptions, []string{})
- assert.NilError(t, err)
- assert.Assert(t, noVariables == false)
-
- values := map[string]string{}
- for _, variable := range info {
- values[variable.name] = variable.defaultValue
- }
- assert.Equal(t, values["LXKNS_ADDRESS"], "127.0.0.1")
- assert.Equal(t, values["LXKNS_PORT"], "5010")
-}
-
-func TestRunVariablesAllowsTemplatedPortFields(t *testing.T) {
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
-
- dir := t.TempDir()
- composePath := filepath.Join(dir, "compose.yaml")
- assert.NilError(t, os.WriteFile(composePath, []byte(`
-name: remote-defaults
-services:
- web:
- image: nginx
- ports:
- - host_ip: "${LXKNS_ADDRESS:-127.0.0.1}"
- published: "${LXKNS_PORT:-5010}"
- target: 80
- protocol: tcp
-`), 0o600))
-
- buf := new(bytes.Buffer)
- cli := mocks.NewMockCli(ctrl)
- cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes()
-
- opts := configOptions{
- Format: "json",
- ProjectOptions: &ProjectOptions{
- ConfigPaths: []string{composePath},
- ProjectDir: dir,
- },
- }
- assert.NilError(t, runVariables(t.Context(), cli, opts, nil))
-
- output := buf.String()
- assert.Assert(t, strings.Contains(output, `"LXKNS_ADDRESS"`), output)
- assert.Assert(t, strings.Contains(output, `"LXKNS_PORT"`), output)
-}
-
-func TestConfirmRemoteIncludes(t *testing.T) {
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
- cli := mocks.NewMockCli(ctrl)
-
- tests := []struct {
- name string
- opts buildOptions
- assumeYes bool
- userInput string
- wantErr bool
- errMessage string
- wantPrompt bool
- wantOutput string
- }{
- {
- name: "no remote includes",
- opts: buildOptions{
- ProjectOptions: &ProjectOptions{
- ConfigPaths: []string{
- "docker-compose.yaml",
- "./local/path/compose.yaml",
- },
- },
- },
- assumeYes: false,
- wantErr: false,
- wantPrompt: false,
- },
- {
- name: "assume yes with remote includes",
- opts: buildOptions{
- ProjectOptions: &ProjectOptions{
- ConfigPaths: []string{
- "oci://registry.example.com/stack:latest",
- "git://github.com/user/repo.git",
- },
- },
- },
- assumeYes: true,
- wantErr: false,
- wantPrompt: false,
- },
- {
- name: "user confirms remote includes",
- opts: buildOptions{
- ProjectOptions: &ProjectOptions{
- ConfigPaths: []string{
- "oci://registry.example.com/stack:latest",
- "git://github.com/user/repo.git",
- },
- },
- },
- assumeYes: false,
- userInput: "y\n",
- wantErr: false,
- wantPrompt: true,
- wantOutput: "\nWarning: This Compose project includes files from remote sources:\n" +
- " - oci://registry.example.com/stack:latest\n" +
- " - git://github.com/user/repo.git\n" +
- "\nRemote includes could potentially be malicious. Make sure you trust the source.\n" +
- "Do you want to continue? [y/N]: ",
- },
- {
- name: "user rejects remote includes",
- opts: buildOptions{
- ProjectOptions: &ProjectOptions{
- ConfigPaths: []string{
- "oci://registry.example.com/stack:latest",
- },
- },
- },
- assumeYes: false,
- userInput: "n\n",
- wantErr: true,
- errMessage: "operation cancelled by user",
- wantPrompt: true,
- wantOutput: "\nWarning: This Compose project includes files from remote sources:\n" +
- " - oci://registry.example.com/stack:latest\n" +
- "\nRemote includes could potentially be malicious. Make sure you trust the source.\n" +
- "Do you want to continue? [y/N]: ",
- },
- }
-
- buf := new(bytes.Buffer)
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes()
-
- if tt.wantPrompt {
- inbuf := io.NopCloser(bytes.NewBufferString(tt.userInput))
- cli.EXPECT().In().Return(streams.NewIn(inbuf)).AnyTimes()
- }
-
- err := confirmRemoteIncludes(cli, tt.opts, tt.assumeYes)
-
- if tt.wantErr {
- assert.Error(t, err, tt.errMessage)
- } else {
- assert.NilError(t, err)
- }
-
- if tt.wantOutput != "" {
- assert.Equal(t, tt.wantOutput, buf.String())
- }
- buf.Reset()
- })
- }
-}
diff --git a/cmd/compose/pause.go b/cmd/compose/pause.go
deleted file mode 100644
index 8b8fb4a5faa..00000000000
--- a/cmd/compose/pause.go
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-type pauseOptions struct {
- *ProjectOptions
-}
-
-func pauseCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := pauseOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "pause [SERVICE...]",
- Short: "Pause services",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runPause(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- return cmd
-}
-
-func runPause(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts pauseOptions, services []string) error {
- project, name, err := opts.projectOrName(ctx, dockerCli, services...)
- if err != nil {
- return err
- }
- return withBackend(dockerCli, backendOptions, func(backend api.Compose) error {
- return backend.Pause(ctx, name, api.PauseOptions{
- Services: services,
- Project: project,
- })
- })
-}
-
-type unpauseOptions struct {
- *ProjectOptions
-}
-
-func unpauseCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := unpauseOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "unpause [SERVICE...]",
- Short: "Unpause services",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runUnPause(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- return cmd
-}
-
-func runUnPause(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts unpauseOptions, services []string) error {
- project, name, err := opts.projectOrName(ctx, dockerCli, services...)
- if err != nil {
- return err
- }
- return withBackend(dockerCli, backendOptions, func(backend api.Compose) error {
- return backend.UnPause(ctx, name, api.PauseOptions{
- Services: services,
- Project: project,
- })
- })
-}
diff --git a/cmd/compose/port.go b/cmd/compose/port.go
deleted file mode 100644
index 862e3b5d68d..00000000000
--- a/cmd/compose/port.go
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "strconv"
- "strings"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type portOptions struct {
- *ProjectOptions
- port uint16
- protocol string
- index int
-}
-
-func portCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := portOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "port [OPTIONS] SERVICE PRIVATE_PORT",
- Short: "Print the public port for a port binding",
- Args: cobra.MinimumNArgs(2),
- PreRunE: Adapt(func(ctx context.Context, args []string) error {
- port, err := strconv.ParseUint(args[1], 10, 16)
- if err != nil {
- return err
- }
- opts.port = uint16(port)
- opts.protocol = strings.ToLower(opts.protocol)
- return nil
- }),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runPort(ctx, dockerCli, backendOptions, opts, args[0])
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- cmd.Flags().StringVar(&opts.protocol, "protocol", "tcp", "tcp or udp")
- cmd.Flags().IntVar(&opts.index, "index", 0, "Index of the container if service has multiple replicas")
- return cmd
-}
-
-func runPort(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts portOptions, service string) error {
- projectName, err := opts.toProjectName(ctx, dockerCli)
- if err != nil {
- return err
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- ip, port, err := backend.Port(ctx, projectName, service, opts.port, api.PortOptions{
- Protocol: opts.protocol,
- Index: opts.index,
- })
- if err != nil {
- return err
- }
-
- _, _ = fmt.Fprintf(dockerCli.Out(), "%s:%d\n", ip, port)
- return nil
-}
diff --git a/cmd/compose/ps.go b/cmd/compose/ps.go
deleted file mode 100644
index 2528fccacfb..00000000000
--- a/cmd/compose/ps.go
+++ /dev/null
@@ -1,175 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "errors"
- "fmt"
- "slices"
- "sort"
- "strings"
-
- "github.com/docker/cli/cli/command"
- cliformatter "github.com/docker/cli/cli/command/formatter"
- cliflags "github.com/docker/cli/cli/flags"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type psOptions struct {
- *ProjectOptions
- Format string
- All bool
- Quiet bool
- Services bool
- Filter string
- Status []string
- noTrunc bool
- Orphans bool
-}
-
-func (p *psOptions) parseFilter() error {
- if p.Filter == "" {
- return nil
- }
- key, val, ok := strings.Cut(p.Filter, "=")
- if !ok {
- return errors.New("arguments to --filter should be in form KEY=VAL")
- }
- switch key {
- case "status":
- p.Status = append(p.Status, val)
- return nil
- case "source":
- return api.ErrNotImplemented
- default:
- return fmt.Errorf("unknown filter %s", key)
- }
-}
-
-func psCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := psOptions{
- ProjectOptions: p,
- }
- psCmd := &cobra.Command{
- Use: "ps [OPTIONS] [SERVICE...]",
- Short: "List containers",
- PreRunE: func(cmd *cobra.Command, args []string) error {
- return opts.parseFilter()
- },
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runPs(ctx, dockerCli, backendOptions, args, opts)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := psCmd.Flags()
- flags.StringVar(&opts.Format, "format", "table", cliflags.FormatHelp)
- flags.StringVar(&opts.Filter, "filter", "", "Filter services by a property (supported filters: status)")
- flags.StringArrayVar(&opts.Status, "status", []string{}, "Filter services by status. Values: [paused | restarting | removing | running | dead | created | exited]")
- flags.BoolVarP(&opts.Quiet, "quiet", "q", false, "Only display IDs")
- flags.BoolVar(&opts.Services, "services", false, "Display services")
- flags.BoolVar(&opts.Orphans, "orphans", true, "Include orphaned services (not declared by project)")
- flags.BoolVarP(&opts.All, "all", "a", false, "Show all stopped containers (including those created by the run command)")
- flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Don't truncate output")
- return psCmd
-}
-
-func runPs(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, services []string, opts psOptions) error { //nolint:gocyclo
- project, name, err := opts.projectOrName(ctx, dockerCli, services...)
- if err != nil {
- return err
- }
-
- if project != nil {
- names := project.ServiceNames()
- if len(services) > 0 {
- for _, service := range services {
- if !slices.Contains(names, service) {
- return fmt.Errorf("no such service: %s", service)
- }
- }
- } else if !opts.Orphans {
- // until user asks to list orphaned services, we only include those declared in project
- services = names
- }
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- containers, err := backend.Ps(ctx, name, api.PsOptions{
- Project: project,
- All: opts.All || len(opts.Status) != 0,
- Services: services,
- })
- if err != nil {
- return err
- }
-
- if len(opts.Status) != 0 {
- containers = filterByStatus(containers, opts.Status)
- }
-
- sort.Slice(containers, func(i, j int) bool {
- return containers[i].Name < containers[j].Name
- })
-
- if opts.Quiet {
- for _, c := range containers {
- _, _ = fmt.Fprintln(dockerCli.Out(), c.ID)
- }
- return nil
- }
-
- if opts.Services {
- services := []string{}
- for _, c := range containers {
- s := c.Service
- if !slices.Contains(services, s) {
- services = append(services, s)
- }
- }
- _, _ = fmt.Fprintln(dockerCli.Out(), strings.Join(services, "\n"))
- return nil
- }
-
- if opts.Format == "" {
- opts.Format = dockerCli.ConfigFile().PsFormat
- }
-
- containerCtx := cliformatter.Context{
- Output: dockerCli.Out(),
- Format: formatter.NewContainerFormat(opts.Format, opts.Quiet, false),
- Trunc: !opts.noTrunc,
- }
- return formatter.ContainerWrite(containerCtx, containers)
-}
-
-func filterByStatus(containers []api.ContainerSummary, statuses []string) []api.ContainerSummary {
- var filtered []api.ContainerSummary
- for _, c := range containers {
- if slices.Contains(statuses, string(c.State)) {
- filtered = append(filtered, c)
- }
- }
- return filtered
-}
diff --git a/cmd/compose/publish.go b/cmd/compose/publish.go
deleted file mode 100644
index 6dba282ebc4..00000000000
--- a/cmd/compose/publish.go
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "errors"
-
- "github.com/docker/cli/cli"
- "github.com/docker/cli/cli/command"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
- "github.com/spf13/pflag"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type publishOptions struct {
- *ProjectOptions
- resolveImageDigests bool
- ociVersion string
- withEnvironment bool
- assumeYes bool
- app bool
- insecureRegistry bool
-}
-
-func publishCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := publishOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "publish [OPTIONS] REPOSITORY[:TAG]",
- Short: "Publish compose application",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runPublish(ctx, dockerCli, backendOptions, opts, args[0])
- }),
- Args: cli.ExactArgs(1),
- }
- flags := cmd.Flags()
- flags.BoolVar(&opts.resolveImageDigests, "resolve-image-digests", false, "Pin image tags to digests")
- flags.StringVar(&opts.ociVersion, "oci-version", "", "OCI image/artifact specification version (automatically determined by default)")
- flags.BoolVar(&opts.withEnvironment, "with-env", false, "Include environment variables in the published OCI artifact")
- flags.BoolVarP(&opts.assumeYes, "yes", "y", false, `Assume "yes" as answer to all prompts`)
- flags.BoolVar(&opts.app, "app", false, "Published compose application (includes referenced images)")
- flags.BoolVar(&opts.insecureRegistry, "insecure-registry", false, "Use insecure registry")
- flags.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
- // assumeYes was introduced by mistake as `--y`
- if name == "y" {
- logrus.Warn("--y is deprecated, please use --yes instead")
- name = "yes"
- }
- return pflag.NormalizedName(name)
- })
- // Should **only** be used for testing purpose, we don't want to promote use of insecure registries
- _ = flags.MarkHidden("insecure-registry")
-
- return cmd
-}
-
-func runPublish(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts publishOptions, repository string) error {
- if opts.assumeYes {
- backendOptions.Options = append(backendOptions.Options, compose.WithPrompt(compose.AlwaysOkPrompt()))
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
-
- project, metrics, err := opts.ToProject(ctx, dockerCli, backend, nil)
- if err != nil {
- return err
- }
-
- if metrics.CountIncludesLocal > 0 {
- return errors.New("cannot publish compose file with local includes")
- }
-
- return backend.Publish(ctx, project, repository, api.PublishOptions{
- ResolveImageDigests: opts.resolveImageDigests || opts.app,
- Application: opts.app,
- OCIVersion: api.OCIVersion(opts.ociVersion),
- WithEnvironment: opts.withEnvironment,
- InsecureRegistry: opts.insecureRegistry,
- })
-}
diff --git a/cmd/compose/pull.go b/cmd/compose/pull.go
deleted file mode 100644
index 694731155f6..00000000000
--- a/cmd/compose/pull.go
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "os"
-
- "github.com/compose-spec/compose-go/v2/cli"
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/command"
- "github.com/morikuni/aec"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type pullOptions struct {
- *ProjectOptions
- composeOptions
- quiet bool
- parallel bool
- noParallel bool
- includeDeps bool
- ignorePullFailures bool
- noBuildable bool
- policy string
-}
-
-func pullCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := pullOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "pull [OPTIONS] [SERVICE...]",
- Short: "Pull service images",
- PreRunE: func(cmd *cobra.Command, args []string) error {
- if cmd.Flags().Changed("no-parallel") {
- fmt.Fprint(os.Stderr, aec.Apply("option '--no-parallel' is DEPRECATED and will be ignored.\n", aec.RedF))
- }
- if cmd.Flags().Changed("parallel") {
- fmt.Fprint(os.Stderr, aec.Apply("option '--parallel' is DEPRECATED and will be ignored.\n", aec.RedF))
- }
- return nil
- },
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runPull(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := cmd.Flags()
- flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Pull without printing progress information")
- cmd.Flags().BoolVar(&opts.includeDeps, "include-deps", false, "Also pull services declared as dependencies")
- cmd.Flags().BoolVar(&opts.parallel, "parallel", true, "DEPRECATED pull multiple images in parallel")
- flags.MarkHidden("parallel") //nolint:errcheck
- cmd.Flags().BoolVar(&opts.noParallel, "no-parallel", true, "DEPRECATED disable parallel pulling")
- flags.MarkHidden("no-parallel") //nolint:errcheck
- cmd.Flags().BoolVar(&opts.ignorePullFailures, "ignore-pull-failures", false, "Pull what it can and ignores images with pull failures")
- cmd.Flags().BoolVar(&opts.noBuildable, "ignore-buildable", false, "Ignore images that can be built")
- cmd.Flags().StringVar(&opts.policy, "policy", "", `Apply pull policy ("missing"|"always")`)
- return cmd
-}
-
-func (opts pullOptions) apply(project *types.Project, services []string) (*types.Project, error) {
- if !opts.includeDeps {
- var err error
- project, err = project.WithSelectedServices(services, types.IgnoreDependencies)
- if err != nil {
- return nil, err
- }
- }
-
- if opts.policy != "" {
- for i, service := range project.Services {
- if service.Image == "" {
- continue
- }
- service.PullPolicy = opts.policy
- project.Services[i] = service
- }
- }
- return project, nil
-}
-
-func runPull(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts pullOptions, services []string) error {
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
-
- project, _, err := opts.ToProject(ctx, dockerCli, backend, services, cli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
-
- project, err = opts.apply(project, services)
- if err != nil {
- return err
- }
-
- return backend.Pull(ctx, project, api.PullOptions{
- Quiet: opts.quiet,
- IgnoreFailures: opts.ignorePullFailures,
- IgnoreBuildable: opts.noBuildable,
- })
-}
diff --git a/cmd/compose/pullOptions_test.go b/cmd/compose/pullOptions_test.go
deleted file mode 100644
index 05dd868edf7..00000000000
--- a/cmd/compose/pullOptions_test.go
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- Copyright 2023 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "testing"
-
- "github.com/compose-spec/compose-go/v2/types"
- "gotest.tools/v3/assert"
-)
-
-func TestApplyPullOptions(t *testing.T) {
- project := &types.Project{
- Services: types.Services{
- "must-build": {
- Name: "must-build",
- // No image, local build only
- Build: &types.BuildConfig{
- Context: ".",
- },
- },
- "has-build": {
- Name: "has-build",
- Image: "registry.example.com/myservice",
- Build: &types.BuildConfig{
- Context: ".",
- },
- },
- "must-pull": {
- Name: "must-pull",
- Image: "registry.example.com/another-service",
- },
- },
- }
- project, err := pullOptions{
- policy: types.PullPolicyMissing,
- }.apply(project, nil)
- assert.NilError(t, err)
-
- assert.Equal(t, project.Services["must-build"].PullPolicy, "") // still default
- assert.Equal(t, project.Services["has-build"].PullPolicy, types.PullPolicyMissing)
- assert.Equal(t, project.Services["must-pull"].PullPolicy, types.PullPolicyMissing)
-}
diff --git a/cmd/compose/push.go b/cmd/compose/push.go
deleted file mode 100644
index 4dd23aedb6f..00000000000
--- a/cmd/compose/push.go
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
-
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type pushOptions struct {
- *ProjectOptions
- composeOptions
- IncludeDeps bool
- Ignorefailures bool
- Quiet bool
-}
-
-func pushCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := pushOptions{
- ProjectOptions: p,
- }
- pushCmd := &cobra.Command{
- Use: "push [OPTIONS] [SERVICE...]",
- Short: "Push service images",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runPush(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- pushCmd.Flags().BoolVar(&opts.Ignorefailures, "ignore-push-failures", false, "Push what it can and ignores images with push failures")
- pushCmd.Flags().BoolVar(&opts.IncludeDeps, "include-deps", false, "Also push images of services declared as dependencies")
- pushCmd.Flags().BoolVarP(&opts.Quiet, "quiet", "q", false, "Push without printing progress information")
-
- return pushCmd
-}
-
-func runPush(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts pushOptions, services []string) error {
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
-
- project, _, err := opts.ToProject(ctx, dockerCli, backend, services)
- if err != nil {
- return err
- }
-
- if !opts.IncludeDeps {
- project, err = project.WithSelectedServices(services, types.IgnoreDependencies)
- if err != nil {
- return err
- }
- }
-
- return backend.Push(ctx, project, api.PushOptions{
- IgnoreFailures: opts.Ignorefailures,
- Quiet: opts.Quiet,
- })
-}
diff --git a/cmd/compose/remove.go b/cmd/compose/remove.go
deleted file mode 100644
index d0765b20a53..00000000000
--- a/cmd/compose/remove.go
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type removeOptions struct {
- *ProjectOptions
- force bool
- stop bool
- volumes bool
-}
-
-func removeCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := removeOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "rm [OPTIONS] [SERVICE...]",
- Short: "Removes stopped service containers",
- Long: `Removes stopped service containers
-
-By default, anonymous volumes attached to containers will not be removed. You
-can override this with -v. To list all volumes, use "docker volume ls".
-
-Any data which is not in a volume will be lost.`,
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runRemove(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- f := cmd.Flags()
- f.BoolVarP(&opts.force, "force", "f", false, "Don't ask to confirm removal")
- f.BoolVarP(&opts.stop, "stop", "s", false, "Stop the containers, if required, before removing")
- f.BoolVarP(&opts.volumes, "volumes", "v", false, "Remove any anonymous volumes attached to containers")
- f.BoolP("all", "a", false, "Deprecated - no effect")
- f.MarkHidden("all") //nolint:errcheck
-
- return cmd
-}
-
-func runRemove(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts removeOptions, services []string) error {
- project, name, err := opts.projectOrName(ctx, dockerCli, services...)
- if err != nil {
- return err
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- err = backend.Remove(ctx, name, api.RemoveOptions{
- Services: services,
- Force: opts.force,
- Volumes: opts.volumes,
- Project: project,
- Stop: opts.stop,
- })
- if errors.Is(err, api.ErrNoResources) {
- _, _ = fmt.Fprintln(stdinfo(dockerCli), "No stopped containers")
- return nil
- }
- return err
-}
diff --git a/cmd/compose/restart.go b/cmd/compose/restart.go
deleted file mode 100644
index a9d97c50263..00000000000
--- a/cmd/compose/restart.go
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-type restartOptions struct {
- *ProjectOptions
- timeChanged bool
- timeout int
- noDeps bool
-}
-
-func restartCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := restartOptions{
- ProjectOptions: p,
- }
- restartCmd := &cobra.Command{
- Use: "restart [OPTIONS] [SERVICE...]",
- Short: "Restart service containers",
- PreRun: func(cmd *cobra.Command, args []string) {
- opts.timeChanged = cmd.Flags().Changed("timeout")
- },
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runRestart(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := restartCmd.Flags()
- flags.IntVarP(&opts.timeout, "timeout", "t", 0, "Specify a shutdown timeout in seconds")
- flags.BoolVar(&opts.noDeps, "no-deps", false, "Don't restart dependent services")
-
- return restartCmd
-}
-
-func runRestart(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts restartOptions, services []string) error {
- project, name, err := opts.projectOrName(ctx, dockerCli)
- if err != nil {
- return err
- }
-
- if project != nil && len(services) > 0 {
- project, err = project.WithServicesEnabled(services...)
- if err != nil {
- return err
- }
- }
-
- return withBackend(dockerCli, backendOptions, func(backend api.Compose) error {
- return backend.Restart(ctx, name, api.RestartOptions{
- Timeout: optionalTimeout(opts.timeout, opts.timeChanged),
- Services: services,
- Project: project,
- NoDeps: opts.noDeps,
- })
- })
-}
diff --git a/cmd/compose/run.go b/cmd/compose/run.go
deleted file mode 100644
index ecb3f9f1bb3..00000000000
--- a/cmd/compose/run.go
+++ /dev/null
@@ -1,353 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
-
- composecli "github.com/compose-spec/compose-go/v2/cli"
- "github.com/compose-spec/compose-go/v2/dotenv"
- "github.com/compose-spec/compose-go/v2/format"
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli"
- "github.com/docker/cli/cli/command"
- "github.com/docker/cli/opts"
- "github.com/mattn/go-shellwords"
- xprogress "github.com/moby/buildkit/util/progress/progressui"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
- "github.com/spf13/pflag"
-
- "github.com/docker/compose/v5/cmd/display"
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
- "github.com/docker/compose/v5/pkg/utils"
-)
-
-type runOptions struct {
- *composeOptions
- Service string
- Command []string
- environment []string
- envFiles []string
- Detach bool
- Remove bool
- noTty bool
- interactive bool
- user string
- workdir string
- entrypoint string
- entrypointCmd []string
- capAdd opts.ListOpts
- capDrop opts.ListOpts
- labels []string
- volumes []string
- publish []string
- useAliases bool
- servicePorts bool
- name string
- noDeps bool
- ignoreOrphans bool
- removeOrphans bool
- quiet bool
- quietPull bool
-}
-
-func (options runOptions) apply(project *types.Project) (*types.Project, error) {
- if options.noDeps {
- var err error
- project, err = project.WithSelectedServices([]string{options.Service}, types.IgnoreDependencies)
- if err != nil {
- return nil, err
- }
- }
-
- target, err := project.GetService(options.Service)
- if err != nil {
- return nil, err
- }
-
- target.Tty = !options.noTty
- target.StdinOpen = options.interactive
-
- // --service-ports and --publish are incompatible
- if !options.servicePorts {
- if len(target.Ports) > 0 {
- logrus.Debug("Running service without ports exposed as --service-ports=false")
- }
- target.Ports = []types.ServicePortConfig{}
- for _, p := range options.publish {
- config, err := types.ParsePortConfig(p)
- if err != nil {
- return nil, err
- }
- target.Ports = append(target.Ports, config...)
- }
- }
-
- for _, v := range options.volumes {
- volume, err := format.ParseVolume(v)
- if err != nil {
- return nil, err
- }
- target.Volumes = append(target.Volumes, volume)
- }
-
- for name := range project.Services {
- if name == options.Service {
- project.Services[name] = target
- break
- }
- }
- return project, nil
-}
-
-func (options runOptions) getEnvironment(resolve func(string) (string, bool)) (types.Mapping, error) {
- environment := types.NewMappingWithEquals(options.environment).Resolve(resolve).ToMapping()
- for _, file := range options.envFiles {
- f, err := os.Open(file)
- if err != nil {
- return nil, err
- }
- vars, err := dotenv.ParseWithLookup(f, func(k string) (string, bool) {
- value, ok := environment[k]
- return value, ok
- })
- if err != nil {
- return nil, nil
- }
- for k, v := range vars {
- if _, ok := environment[k]; !ok {
- environment[k] = v
- }
- }
- }
- return environment, nil
-}
-
-func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- options := runOptions{
- composeOptions: &composeOptions{
- ProjectOptions: p,
- },
- capAdd: opts.NewListOpts(nil),
- capDrop: opts.NewListOpts(nil),
- }
- createOpts := createOptions{}
- buildOpts := buildOptions{
- ProjectOptions: p,
- }
- // We remove the attribute from the option struct and use a dedicated var, to limit confusion and avoid anyone to use options.tty.
- // The tty flag is here for convenience and let user do "docker compose run -it" the same way as they use the "docker run" command.
- var ttyFlag bool
-
- cmd := &cobra.Command{
- Use: "run [OPTIONS] SERVICE [COMMAND] [ARGS...]",
- Short: "Run a one-off command on a service",
- Args: cobra.MinimumNArgs(1),
- PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
- options.Service = args[0]
- if len(args) > 1 {
- options.Command = args[1:]
- }
- if len(options.publish) > 0 && options.servicePorts {
- return fmt.Errorf("--service-ports and --publish are incompatible")
- }
- if cmd.Flags().Changed("entrypoint") {
- command, err := shellwords.Parse(options.entrypoint)
- if err != nil {
- return err
- }
- options.entrypointCmd = command
- }
- if cmd.Flags().Changed("tty") {
- if cmd.Flags().Changed("no-tty") {
- return fmt.Errorf("--tty and --no-tty can't be used together")
- } else {
- options.noTty = !ttyFlag
- }
- } else if !cmd.Flags().Changed("no-tty") && !cmd.Flags().Changed("interactive") && !dockerCli.In().IsTerminal() {
- // while `docker run` requires explicit `-it` flags, Compose enables interactive mode and TTY by default
- // but when compose is used from a script that has stdin piped from another command, we just can't
- // Here, we detect we run "by default" (user didn't passed explicit flags) and disable TTY allocation if
- // we don't have an actual terminal to attach to for interactive mode
- options.noTty = true
- }
-
- if options.quiet {
- display.Mode = display.ModeQuiet
- backendOptions.Add(compose.WithEventProcessor(display.Quiet()))
- }
- createOpts.pullChanged = cmd.Flags().Changed("pull")
- return nil
- }),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
-
- project, _, err := p.ToProject(ctx, dockerCli, backend, []string{options.Service}, composecli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
-
- project, err = project.WithServicesEnvironmentResolved(true)
- if err != nil {
- return err
- }
-
- if createOpts.quietPull {
- buildOpts.Progress = string(xprogress.QuietMode)
- }
-
- options.ignoreOrphans = utils.StringToBool(project.Environment[ComposeIgnoreOrphans])
- return runRun(ctx, backend, project, options, createOpts, buildOpts, dockerCli)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := cmd.Flags()
- flags.BoolVarP(&options.Detach, "detach", "d", false, "Run container in background and print container ID")
- flags.StringArrayVarP(&options.environment, "env", "e", []string{}, "Set environment variables")
- flags.StringArrayVar(&options.envFiles, "env-from-file", []string{}, "Set environment variables from file")
- flags.StringArrayVarP(&options.labels, "label", "l", []string{}, "Add or override a label")
- flags.BoolVar(&options.Remove, "rm", false, "Automatically remove the container when it exits")
- flags.BoolVarP(&options.noTty, "no-tty", "T", !dockerCli.Out().IsTerminal(), "Disable pseudo-TTY allocation (default: auto-detected)")
- flags.StringVar(&options.name, "name", "", "Assign a name to the container")
- flags.StringVarP(&options.user, "user", "u", "", "Run as specified username or uid")
- flags.StringVarP(&options.workdir, "workdir", "w", "", "Working directory inside the container")
- flags.StringVar(&options.entrypoint, "entrypoint", "", "Override the entrypoint of the image")
- flags.Var(&options.capAdd, "cap-add", "Add Linux capabilities")
- flags.Var(&options.capDrop, "cap-drop", "Drop Linux capabilities")
- flags.BoolVar(&options.noDeps, "no-deps", false, "Don't start linked services")
- flags.StringArrayVarP(&options.volumes, "volume", "v", []string{}, "Bind mount a volume")
- flags.StringArrayVarP(&options.publish, "publish", "p", []string{}, "Publish a container's port(s) to the host")
- flags.BoolVar(&options.useAliases, "use-aliases", false, "Use the service's network useAliases in the network(s) the container connects to")
- flags.BoolVarP(&options.servicePorts, "service-ports", "P", false, "Run command with all service's ports enabled and mapped to the host")
- flags.StringVar(&createOpts.Pull, "pull", "policy", `Pull image before running ("always"|"missing"|"never")`)
- flags.BoolVarP(&options.quiet, "quiet", "q", false, "Don't print anything to STDOUT")
- flags.BoolVar(&buildOpts.quiet, "quiet-build", false, "Suppress progress output from the build process")
- flags.BoolVar(&options.quietPull, "quiet-pull", false, "Pull without printing progress information")
- flags.BoolVar(&createOpts.Build, "build", false, "Build image before starting container")
- flags.BoolVar(&options.removeOrphans, "remove-orphans", false, "Remove containers for services not defined in the Compose file")
-
- cmd.Flags().BoolVarP(&options.interactive, "interactive", "i", true, "Keep STDIN open even if not attached")
- cmd.Flags().BoolVarP(&ttyFlag, "tty", "t", true, "Allocate a pseudo-TTY")
- cmd.Flags().MarkHidden("tty") //nolint:errcheck
-
- flags.SetNormalizeFunc(normalizeRunFlags)
- flags.SetInterspersed(false)
- return cmd
-}
-
-func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName {
- switch name {
- case "volumes":
- name = "volume"
- case "labels":
- name = "label"
- case "no-TTY":
- name = "no-tty"
- }
- return pflag.NormalizedName(name)
-}
-
-func runRun(ctx context.Context, backend api.Compose, project *types.Project, options runOptions, createOpts createOptions, buildOpts buildOptions, dockerCli command.Cli) error {
- project, err := options.apply(project)
- if err != nil {
- return err
- }
-
- err = createOpts.Apply(project)
- if err != nil {
- return err
- }
-
- if err := checksForRemoteStack(ctx, dockerCli, project, buildOpts, createOpts.AssumeYes, []string{}); err != nil {
- return err
- }
-
- labels := types.Labels{}
- for _, s := range options.labels {
- key, val, ok := strings.Cut(s, "=")
- if !ok {
- return fmt.Errorf("label must be set as KEY=VALUE")
- }
- labels[key] = val
- }
-
- var buildForRun *api.BuildOptions
- if !createOpts.noBuild {
- bo, err := buildOpts.toAPIBuildOptions(nil)
- if err != nil {
- return err
- }
- buildForRun = &bo
- }
-
- environment, err := options.getEnvironment(project.Environment.Resolve)
- if err != nil {
- return err
- }
-
- // start container and attach to container streams
- runOpts := api.RunOptions{
- CreateOptions: api.CreateOptions{
- Build: buildForRun,
- RemoveOrphans: options.removeOrphans,
- IgnoreOrphans: options.ignoreOrphans,
- QuietPull: options.quietPull,
- },
- Name: options.name,
- Service: options.Service,
- Command: options.Command,
- Detach: options.Detach,
- AutoRemove: options.Remove,
- Tty: !options.noTty,
- Interactive: options.interactive,
- WorkingDir: options.workdir,
- User: options.user,
- CapAdd: options.capAdd.GetSlice(),
- CapDrop: options.capDrop.GetSlice(),
- Environment: environment.Values(),
- Entrypoint: options.entrypointCmd,
- Labels: labels,
- UseNetworkAliases: options.useAliases,
- NoDeps: options.noDeps,
- Index: 0,
- }
-
- for name, service := range project.Services {
- if name == options.Service {
- service.StdinOpen = options.interactive
- project.Services[name] = service
- }
- }
-
- exitCode, err := backend.RunOneOffContainer(ctx, project, runOpts)
- if exitCode != 0 {
- errMsg := ""
- if err != nil {
- errMsg = err.Error()
- }
- return cli.StatusError{StatusCode: exitCode, Status: errMsg}
- }
- return err
-}
diff --git a/cmd/compose/scale.go b/cmd/compose/scale.go
deleted file mode 100644
index ac1e25469ba..00000000000
--- a/cmd/compose/scale.go
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "maps"
- "slices"
- "strconv"
- "strings"
-
- "github.com/compose-spec/compose-go/v2/cli"
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type scaleOptions struct {
- *ProjectOptions
- noDeps bool
-}
-
-func scaleCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := scaleOptions{
- ProjectOptions: p,
- }
- scaleCmd := &cobra.Command{
- Use: "scale [SERVICE=REPLICAS...]",
- Short: "Scale services ",
- Args: cobra.MinimumNArgs(1),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- serviceTuples, err := parseServicesReplicasArgs(args)
- if err != nil {
- return err
- }
- return runScale(ctx, dockerCli, backendOptions, opts, serviceTuples)
- }),
- ValidArgsFunction: completeScaleArgs(dockerCli, p),
- }
- flags := scaleCmd.Flags()
- flags.BoolVar(&opts.noDeps, "no-deps", false, "Don't start linked services")
-
- return scaleCmd
-}
-
-func runScale(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts scaleOptions, serviceReplicaTuples map[string]int) error {
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
-
- services := slices.Sorted(maps.Keys(serviceReplicaTuples))
- project, _, err := opts.ToProject(ctx, dockerCli, backend, services, cli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
-
- // resolve environment after the project has been reduced to selected services,
- // so env_file declared by unrelated services doesn't need to exist
- project, err = project.WithServicesEnvironmentResolved(true)
- if err != nil {
- return err
- }
-
- if opts.noDeps {
- if project, err = project.WithSelectedServices(services, types.IgnoreDependencies); err != nil {
- return err
- }
- }
-
- for key, value := range serviceReplicaTuples {
- service, err := project.GetService(key)
- if err != nil {
- return err
- }
- service.SetScale(value)
- project.Services[key] = service
- }
-
- return backend.Scale(ctx, project, api.ScaleOptions{Services: services})
-}
-
-func parseServicesReplicasArgs(args []string) (map[string]int, error) {
- serviceReplicaTuples := map[string]int{}
- for _, arg := range args {
- key, val, ok := strings.Cut(arg, "=")
- if !ok || key == "" || val == "" {
- return nil, fmt.Errorf("invalid scale specifier: %s", arg)
- }
- intValue, err := strconv.Atoi(val)
- if err != nil {
- return nil, fmt.Errorf("invalid scale specifier: can't parse replica value as int: %v", arg)
- }
- serviceReplicaTuples[key] = intValue
- }
- return serviceReplicaTuples, nil
-}
diff --git a/cmd/compose/start.go b/cmd/compose/start.go
deleted file mode 100644
index 062efb680d2..00000000000
--- a/cmd/compose/start.go
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "time"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-type startOptions struct {
- *ProjectOptions
- wait bool
- waitTimeout int
-}
-
-func startCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := startOptions{
- ProjectOptions: p,
- }
- startCmd := &cobra.Command{
- Use: "start [SERVICE...]",
- Short: "Start services",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runStart(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := startCmd.Flags()
- flags.BoolVar(&opts.wait, "wait", false, "Wait for services to be running|healthy. Implies detached mode.")
- flags.IntVar(&opts.waitTimeout, "wait-timeout", 0, "Maximum duration in seconds to wait for the project to be running|healthy")
-
- return startCmd
-}
-
-func runStart(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts startOptions, services []string) error {
- project, name, err := opts.projectOrName(ctx, dockerCli, services...)
- if err != nil {
- return err
- }
-
- var timeout time.Duration
- if opts.waitTimeout > 0 {
- timeout = time.Duration(opts.waitTimeout) * time.Second
- }
- return withBackend(dockerCli, backendOptions, func(backend api.Compose) error {
- return backend.Start(ctx, name, api.StartOptions{
- AttachTo: services,
- Project: project,
- Services: services,
- Wait: opts.wait,
- WaitTimeout: timeout,
- })
- })
-}
diff --git a/cmd/compose/stats.go b/cmd/compose/stats.go
deleted file mode 100644
index 887da083c2e..00000000000
--- a/cmd/compose/stats.go
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
-
- "github.com/docker/cli/cli/command"
- "github.com/docker/cli/cli/command/container"
- "github.com/moby/moby/client"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-type statsOptions struct {
- ProjectOptions *ProjectOptions
- all bool
- format string
- noStream bool
- noTrunc bool
-}
-
-func statsCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
- opts := statsOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "stats [OPTIONS] [SERVICE]",
- Short: "Display a live stream of container(s) resource usage statistics",
- Args: cobra.MaximumNArgs(1),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runStats(ctx, dockerCli, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := cmd.Flags()
- flags.BoolVarP(&opts.all, "all", "a", false, "Show all containers (default shows just running)")
- flags.StringVar(&opts.format, "format", "", `Format output using a custom template:
-'table': Print output in table format with column headers (default)
-'table TEMPLATE': Print output in table format using the given Go template
-'json': Print in JSON format
-'TEMPLATE': Print output using the given Go template.
-Refer to https://docs.docker.com/engine/cli/formatting/ for more information about formatting output with templates`)
- flags.BoolVar(&opts.noStream, "no-stream", false, "Disable streaming stats and only pull the first result")
- flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Do not truncate output")
- return cmd
-}
-
-func runStats(ctx context.Context, dockerCli command.Cli, opts statsOptions, service []string) error {
- name, err := opts.ProjectOptions.toProjectName(ctx, dockerCli)
- if err != nil {
- return err
- }
- f := client.Filters{}
- f.Add("label", fmt.Sprintf("%s=%s", api.ProjectLabel, name))
-
- if len(service) > 0 {
- f.Add("label", fmt.Sprintf("%s=%s", api.ServiceLabel, service[0]))
- }
- return container.RunStats(ctx, dockerCli, &container.StatsOptions{
- All: opts.all,
- NoStream: opts.noStream,
- NoTrunc: opts.noTrunc,
- Format: opts.format,
- Filters: f,
- })
-}
diff --git a/cmd/compose/stop.go b/cmd/compose/stop.go
deleted file mode 100644
index be5dec26fc2..00000000000
--- a/cmd/compose/stop.go
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-type stopOptions struct {
- *ProjectOptions
- timeChanged bool
- timeout int
-}
-
-func stopCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := stopOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "stop [OPTIONS] [SERVICE...]",
- Short: "Stop services",
- PreRun: func(cmd *cobra.Command, args []string) {
- opts.timeChanged = cmd.Flags().Changed("timeout")
- },
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runStop(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := cmd.Flags()
- flags.IntVarP(&opts.timeout, "timeout", "t", 0, "Specify a shutdown timeout in seconds")
-
- return cmd
-}
-
-func runStop(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts stopOptions, services []string) error {
- project, name, err := opts.projectOrName(ctx, dockerCli, services...)
- if err != nil {
- return err
- }
- return withBackend(dockerCli, backendOptions, func(backend api.Compose) error {
- return backend.Stop(ctx, name, api.StopOptions{
- Timeout: optionalTimeout(opts.timeout, opts.timeChanged),
- Services: services,
- Project: project,
- })
- })
-}
diff --git a/cmd/compose/top.go b/cmd/compose/top.go
deleted file mode 100644
index 0d7f969c841..00000000000
--- a/cmd/compose/top.go
+++ /dev/null
@@ -1,149 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "io"
- "sort"
- "strings"
- "text/tabwriter"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type topOptions struct {
- *ProjectOptions
-}
-
-func topCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := topOptions{
- ProjectOptions: p,
- }
- topCmd := &cobra.Command{
- Use: "top [SERVICES...]",
- Short: "Display the running processes",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runTop(ctx, dockerCli, backendOptions, opts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- return topCmd
-}
-
-type (
- topHeader map[string]int // maps a proc title to its output index
- topEntries map[string]string
-)
-
-func runTop(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts topOptions, services []string) error {
- projectName, err := opts.toProjectName(ctx, dockerCli)
- if err != nil {
- return err
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- containers, err := backend.Top(ctx, projectName, services)
- if err != nil {
- return err
- }
-
- sort.Slice(containers, func(i, j int) bool {
- return containers[i].Name < containers[j].Name
- })
-
- header, entries := collectTop(containers)
- return topPrint(dockerCli.Out(), header, entries)
-}
-
-func collectTop(containers []api.ContainerProcSummary) (topHeader, []topEntries) {
- // map column name to its header (should keep working if backend.Top returns
- // varying columns for different containers)
- header := topHeader{"SERVICE": 0, "#": 1}
-
- // assume one process per container and grow if needed
- entries := make([]topEntries, 0, len(containers))
-
- for _, container := range containers {
- for _, proc := range container.Processes {
- entry := topEntries{
- "SERVICE": container.Service,
- "#": container.Replica,
- }
- for i, title := range container.Titles {
- if _, exists := header[title]; !exists {
- header[title] = len(header)
- }
- entry[title] = proc[i]
- }
- entries = append(entries, entry)
- }
- }
-
- // ensure CMD is the right-most column
- if pos, ok := header["CMD"]; ok {
- maxPos := pos
- for h, i := range header {
- if i > maxPos {
- maxPos = i
- }
- if i > pos {
- header[h] = i - 1
- }
- }
- header["CMD"] = maxPos
- }
-
- return header, entries
-}
-
-func topPrint(out io.Writer, headers topHeader, rows []topEntries) error {
- if len(rows) == 0 {
- return nil
- }
-
- w := tabwriter.NewWriter(out, 4, 1, 2, ' ', 0)
-
- // write headers in the order we've encountered them
- h := make([]string, len(headers))
- for title, index := range headers {
- h[index] = title
- }
- _, _ = fmt.Fprintln(w, strings.Join(h, "\t"))
-
- for _, row := range rows {
- // write proc data in header order
- r := make([]string, len(headers))
- for title, index := range headers {
- if v, ok := row[title]; ok {
- r[index] = v
- } else {
- r[index] = "-"
- }
- }
- _, _ = fmt.Fprintln(w, strings.Join(r, "\t"))
- }
- return w.Flush()
-}
diff --git a/cmd/compose/top_test.go b/cmd/compose/top_test.go
deleted file mode 100644
index 94853ae06a4..00000000000
--- a/cmd/compose/top_test.go
+++ /dev/null
@@ -1,329 +0,0 @@
-/*
- Copyright 2024 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "bytes"
- "strings"
- "testing"
-
- "gotest.tools/v3/assert"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-var topTestCases = []struct {
- name string
- titles []string
- procs [][]string
-
- header topHeader
- entries []topEntries
- output string
-}{
- {
- name: "noprocs",
- titles: []string{"UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"},
- procs: [][]string{},
- header: topHeader{"SERVICE": 0, "#": 1},
- entries: []topEntries{},
- output: "",
- },
- {
- name: "simple",
- titles: []string{"UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"},
- procs: [][]string{{"root", "1", "1", "0", "12:00", "?", "00:00:01", "/entrypoint"}},
- header: topHeader{
- "SERVICE": 0,
- "#": 1,
- "UID": 2,
- "PID": 3,
- "PPID": 4,
- "C": 5,
- "STIME": 6,
- "TTY": 7,
- "TIME": 8,
- "CMD": 9,
- },
- entries: []topEntries{
- {
- "SERVICE": "simple",
- "#": "1",
- "UID": "root",
- "PID": "1",
- "PPID": "1",
- "C": "0",
- "STIME": "12:00",
- "TTY": "?",
- "TIME": "00:00:01",
- "CMD": "/entrypoint",
- },
- },
- output: trim(`
- SERVICE # UID PID PPID C STIME TTY TIME CMD
- simple 1 root 1 1 0 12:00 ? 00:00:01 /entrypoint
- `),
- },
- {
- name: "noppid",
- titles: []string{"UID", "PID", "C", "STIME", "TTY", "TIME", "CMD"},
- procs: [][]string{{"root", "1", "0", "12:00", "?", "00:00:02", "/entrypoint"}},
- header: topHeader{
- "SERVICE": 0,
- "#": 1,
- "UID": 2,
- "PID": 3,
- "C": 4,
- "STIME": 5,
- "TTY": 6,
- "TIME": 7,
- "CMD": 8,
- },
- entries: []topEntries{
- {
- "SERVICE": "noppid",
- "#": "1",
- "UID": "root",
- "PID": "1",
- "C": "0",
- "STIME": "12:00",
- "TTY": "?",
- "TIME": "00:00:02",
- "CMD": "/entrypoint",
- },
- },
- output: trim(`
- SERVICE # UID PID C STIME TTY TIME CMD
- noppid 1 root 1 0 12:00 ? 00:00:02 /entrypoint
- `),
- },
- {
- name: "extra-hdr",
- titles: []string{"UID", "GID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"},
- procs: [][]string{{"root", "1", "1", "1", "0", "12:00", "?", "00:00:03", "/entrypoint"}},
- header: topHeader{
- "SERVICE": 0,
- "#": 1,
- "UID": 2,
- "GID": 3,
- "PID": 4,
- "PPID": 5,
- "C": 6,
- "STIME": 7,
- "TTY": 8,
- "TIME": 9,
- "CMD": 10,
- },
- entries: []topEntries{
- {
- "SERVICE": "extra-hdr",
- "#": "1",
- "UID": "root",
- "GID": "1",
- "PID": "1",
- "PPID": "1",
- "C": "0",
- "STIME": "12:00",
- "TTY": "?",
- "TIME": "00:00:03",
- "CMD": "/entrypoint",
- },
- },
- output: trim(`
- SERVICE # UID GID PID PPID C STIME TTY TIME CMD
- extra-hdr 1 root 1 1 1 0 12:00 ? 00:00:03 /entrypoint
- `),
- },
- {
- name: "multiple",
- titles: []string{"UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"},
- procs: [][]string{
- {"root", "1", "1", "0", "12:00", "?", "00:00:04", "/entrypoint"},
- {"root", "123", "1", "0", "12:00", "?", "00:00:42", "sleep infinity"},
- },
- header: topHeader{
- "SERVICE": 0,
- "#": 1,
- "UID": 2,
- "PID": 3,
- "PPID": 4,
- "C": 5,
- "STIME": 6,
- "TTY": 7,
- "TIME": 8,
- "CMD": 9,
- },
- entries: []topEntries{
- {
- "SERVICE": "multiple",
- "#": "1",
- "UID": "root",
- "PID": "1",
- "PPID": "1",
- "C": "0",
- "STIME": "12:00",
- "TTY": "?",
- "TIME": "00:00:04",
- "CMD": "/entrypoint",
- },
- {
- "SERVICE": "multiple",
- "#": "1",
- "UID": "root",
- "PID": "123",
- "PPID": "1",
- "C": "0",
- "STIME": "12:00",
- "TTY": "?",
- "TIME": "00:00:42",
- "CMD": "sleep infinity",
- },
- },
- output: trim(`
- SERVICE # UID PID PPID C STIME TTY TIME CMD
- multiple 1 root 1 1 0 12:00 ? 00:00:04 /entrypoint
- multiple 1 root 123 1 0 12:00 ? 00:00:42 sleep infinity
- `),
- },
-}
-
-// TestRunTopCore only tests the core functionality of runTop: formatting
-// and printing of the output of (api.Compose).Top().
-func TestRunTopCore(t *testing.T) {
- t.Parallel()
-
- all := []api.ContainerProcSummary{}
-
- for _, tc := range topTestCases {
- summary := api.ContainerProcSummary{
- Name: "not used",
- Titles: tc.titles,
- Processes: tc.procs,
- Service: tc.name,
- Replica: "1",
- }
- all = append(all, summary)
-
- t.Run(tc.name, func(t *testing.T) {
- header, entries := collectTop([]api.ContainerProcSummary{summary})
- assert.DeepEqual(t, tc.header, header)
- assert.DeepEqual(t, tc.entries, entries)
-
- var buf bytes.Buffer
- err := topPrint(&buf, header, entries)
-
- assert.NilError(t, err)
- assert.Equal(t, tc.output, buf.String())
- })
- }
-
- t.Run("all", func(t *testing.T) {
- header, entries := collectTop(all)
- assert.DeepEqual(t, topHeader{
- "SERVICE": 0,
- "#": 1,
- "UID": 2,
- "PID": 3,
- "PPID": 4,
- "C": 5,
- "STIME": 6,
- "TTY": 7,
- "TIME": 8,
- "GID": 9,
- "CMD": 10,
- }, header)
- assert.DeepEqual(t, []topEntries{
- {
- "SERVICE": "simple",
- "#": "1",
- "UID": "root",
- "PID": "1",
- "PPID": "1",
- "C": "0",
- "STIME": "12:00",
- "TTY": "?",
- "TIME": "00:00:01",
- "CMD": "/entrypoint",
- }, {
- "SERVICE": "noppid",
- "#": "1",
- "UID": "root",
- "PID": "1",
- "C": "0",
- "STIME": "12:00",
- "TTY": "?",
- "TIME": "00:00:02",
- "CMD": "/entrypoint",
- }, {
- "SERVICE": "extra-hdr",
- "#": "1",
- "UID": "root",
- "GID": "1",
- "PID": "1",
- "PPID": "1",
- "C": "0",
- "STIME": "12:00",
- "TTY": "?",
- "TIME": "00:00:03",
- "CMD": "/entrypoint",
- }, {
- "SERVICE": "multiple",
- "#": "1",
- "UID": "root",
- "PID": "1",
- "PPID": "1",
- "C": "0",
- "STIME": "12:00",
- "TTY": "?",
- "TIME": "00:00:04",
- "CMD": "/entrypoint",
- }, {
- "SERVICE": "multiple",
- "#": "1",
- "UID": "root",
- "PID": "123",
- "PPID": "1",
- "C": "0",
- "STIME": "12:00",
- "TTY": "?",
- "TIME": "00:00:42",
- "CMD": "sleep infinity",
- },
- }, entries)
-
- var buf bytes.Buffer
- err := topPrint(&buf, header, entries)
- assert.NilError(t, err)
- assert.Equal(t, trim(`
- SERVICE # UID PID PPID C STIME TTY TIME GID CMD
- simple 1 root 1 1 0 12:00 ? 00:00:01 - /entrypoint
- noppid 1 root 1 - 0 12:00 ? 00:00:02 - /entrypoint
- extra-hdr 1 root 1 1 0 12:00 ? 00:00:03 1 /entrypoint
- multiple 1 root 1 1 0 12:00 ? 00:00:04 - /entrypoint
- multiple 1 root 123 1 0 12:00 ? 00:00:42 - sleep infinity
- `), buf.String())
- })
-}
-
-func trim(s string) string {
- var out bytes.Buffer
- for line := range strings.SplitSeq(strings.TrimSpace(s), "\n") {
- out.WriteString(strings.TrimSpace(line))
- out.WriteRune('\n')
- }
- return out.String()
-}
diff --git a/cmd/compose/up.go b/cmd/compose/up.go
deleted file mode 100644
index cda2678bbb2..00000000000
--- a/cmd/compose/up.go
+++ /dev/null
@@ -1,363 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "strings"
- "time"
-
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/command"
- xprogress "github.com/moby/buildkit/util/progress/progressui"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
- "github.com/spf13/pflag"
-
- "github.com/docker/compose/v5/cmd/display"
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
- "github.com/docker/compose/v5/pkg/utils"
-)
-
-// composeOptions hold options common to `up` and `run` to run compose project
-type composeOptions struct {
- *ProjectOptions
-}
-
-type upOptions struct {
- *composeOptions
- Detach bool
- noStart bool
- noDeps bool
- cascadeStop bool
- cascadeFail bool
- exitCodeFrom string
- noColor bool
- noPrefix bool
- attachDependencies bool
- attach []string
- noAttach []string
- timestamp bool
- wait bool
- waitTimeout int
- watch bool
- navigationMenu bool
- navigationMenuChanged bool
-}
-
-func (opts upOptions) apply(project *types.Project, services []string) (*types.Project, error) {
- if opts.noDeps {
- var err error
- project, err = project.WithSelectedServices(services, types.IgnoreDependencies)
- if err != nil {
- return nil, err
- }
- }
-
- if opts.exitCodeFrom != "" {
- _, err := project.GetService(opts.exitCodeFrom)
- if err != nil {
- return nil, err
- }
- }
-
- return project, nil
-}
-
-func (opts *upOptions) validateNavigationMenu(dockerCli command.Cli) {
- if !dockerCli.Out().IsTerminal() {
- opts.navigationMenu = false
- return
- }
- // If --menu flag was not set
- if !opts.navigationMenuChanged {
- if envVar, ok := os.LookupEnv(ComposeMenu); ok {
- opts.navigationMenu = utils.StringToBool(envVar)
- return
- }
- // ...and COMPOSE_MENU env var is not defined we want the default value to be true
- opts.navigationMenu = true
- }
-}
-
-func (opts upOptions) OnExit() api.Cascade {
- switch {
- case opts.cascadeStop:
- return api.CascadeStop
- case opts.cascadeFail:
- return api.CascadeFail
- default:
- return api.CascadeIgnore
- }
-}
-
-func upCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- up := upOptions{}
- create := createOptions{}
- build := buildOptions{ProjectOptions: p}
- upCmd := &cobra.Command{
- Use: "up [OPTIONS] [SERVICE...]",
- Short: "Create and start containers",
- PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
- create.pullChanged = cmd.Flags().Changed("pull")
- create.timeChanged = cmd.Flags().Changed("timeout")
- up.navigationMenuChanged = cmd.Flags().Changed("menu")
- if !cmd.Flags().Changed("remove-orphans") {
- create.removeOrphans = utils.StringToBool(os.Getenv(ComposeRemoveOrphans))
- }
- return validateFlags(&up, &create)
- }),
- RunE: p.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error {
- create.ignoreOrphans = utils.StringToBool(project.Environment[ComposeIgnoreOrphans])
- if create.ignoreOrphans && create.removeOrphans {
- return fmt.Errorf("cannot combine %s and --remove-orphans", ComposeIgnoreOrphans)
- }
- if len(up.attach) != 0 && up.attachDependencies {
- return errors.New("cannot combine --attach and --attach-dependencies")
- }
-
- up.validateNavigationMenu(dockerCli)
-
- if !p.All && len(project.Services) == 0 {
- return fmt.Errorf("no service selected")
- }
-
- return runUp(ctx, dockerCli, backendOptions, create, up, build, project, services)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
- flags := upCmd.Flags()
- flags.BoolVarP(&up.Detach, "detach", "d", false, "Detached mode: Run containers in the background")
- flags.BoolVar(&create.Build, "build", false, "Build images before starting containers")
- flags.BoolVar(&create.noBuild, "no-build", false, "Don't build an image, even if it's policy")
- flags.StringVar(&create.Pull, "pull", "policy", `Pull image before running ("always"|"missing"|"never")`)
- flags.BoolVar(&create.removeOrphans, "remove-orphans", false, "Remove containers for services not defined in the Compose file")
- flags.StringArrayVar(&create.scale, "scale", []string{}, "Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present.")
- flags.BoolVar(&up.noColor, "no-color", false, "Produce monochrome output")
- flags.BoolVar(&up.noPrefix, "no-log-prefix", false, "Don't print prefix in logs")
- flags.BoolVar(&create.forceRecreate, "force-recreate", false, "Recreate containers even if their configuration and image haven't changed")
- flags.BoolVar(&create.noRecreate, "no-recreate", false, "If containers already exist, don't recreate them. Incompatible with --force-recreate.")
- flags.BoolVar(&up.noStart, "no-start", false, "Don't start the services after creating them")
- flags.BoolVar(&up.cascadeStop, "abort-on-container-exit", false, "Stops all containers if any container was stopped. Incompatible with -d")
- flags.BoolVar(&up.cascadeFail, "abort-on-container-failure", false, "Stops all containers if any container exited with failure. Incompatible with -d")
- flags.StringVar(&up.exitCodeFrom, "exit-code-from", "", "Return the exit code of the selected service container. Implies --abort-on-container-exit")
- flags.IntVarP(&create.timeout, "timeout", "t", 0, "Use this timeout in seconds for container shutdown when attached or when containers are already running")
- flags.BoolVar(&up.timestamp, "timestamps", false, "Show timestamps")
- flags.BoolVar(&up.noDeps, "no-deps", false, "Don't start linked services")
- flags.BoolVar(&create.recreateDeps, "always-recreate-deps", false, "Recreate dependent containers. Incompatible with --no-recreate.")
- flags.BoolVarP(&create.noInherit, "renew-anon-volumes", "V", false, "Recreate anonymous volumes instead of retrieving data from the previous containers")
- flags.BoolVar(&create.quietPull, "quiet-pull", false, "Pull without printing progress information")
- flags.BoolVar(&build.quiet, "quiet-build", false, "Suppress the build output")
- flags.StringArrayVar(&up.attach, "attach", []string{}, "Restrict attaching to the specified services. Incompatible with --attach-dependencies.")
- flags.StringArrayVar(&up.noAttach, "no-attach", []string{}, "Do not attach (stream logs) to the specified services")
- flags.BoolVar(&up.attachDependencies, "attach-dependencies", false, "Automatically attach to log output of dependent services")
- flags.BoolVar(&up.wait, "wait", false, "Wait for services to be running|healthy. Implies detached mode.")
- flags.IntVar(&up.waitTimeout, "wait-timeout", 0, "Maximum duration in seconds to wait for the project to be running|healthy")
- flags.BoolVarP(&up.watch, "watch", "w", false, "Watch source code and rebuild/refresh containers when files are updated.")
- flags.BoolVar(&up.navigationMenu, "menu", false, "Enable interactive shortcuts when running attached. Incompatible with --detach. Can also be enable/disable by setting COMPOSE_MENU environment var.")
- flags.BoolVarP(&create.AssumeYes, "yes", "y", false, `Assume "yes" as answer to all prompts and run non-interactively`)
- flags.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
- // assumeYes was introduced by mistake as `--y`
- if name == "y" {
- logrus.Warn("--y is deprecated, please use --yes instead")
- name = "yes"
- }
- return pflag.NormalizedName(name)
- })
- return upCmd
-}
-
-//nolint:gocyclo
-func validateFlags(up *upOptions, create *createOptions) error {
- if up.waitTimeout < 0 {
- return fmt.Errorf("--wait-timeout must be a non-negative integer")
- }
- if up.exitCodeFrom != "" && !up.cascadeFail {
- up.cascadeStop = true
- }
- if up.cascadeStop && up.cascadeFail {
- return fmt.Errorf("--abort-on-container-failure cannot be combined with --abort-on-container-exit")
- }
- if up.wait {
- if up.attachDependencies || up.cascadeStop || len(up.attach) > 0 {
- return fmt.Errorf("--wait cannot be combined with --abort-on-container-exit, --attach or --attach-dependencies")
- }
- up.Detach = true
- }
- if create.Build && create.noBuild {
- return fmt.Errorf("--build and --no-build are incompatible")
- }
- if up.Detach && (up.attachDependencies || up.cascadeStop || up.cascadeFail || len(up.attach) > 0 || up.watch) {
- if up.wait {
- return fmt.Errorf("--wait cannot be combined with --abort-on-container-exit, --abort-on-container-failure, --attach, --attach-dependencies or --watch")
- } else {
- return fmt.Errorf("--detach cannot be combined with --abort-on-container-exit, --abort-on-container-failure, --attach, --attach-dependencies or --watch")
- }
- }
- if create.noInherit && create.noRecreate {
- return fmt.Errorf("--no-recreate and --renew-anon-volumes are incompatible")
- }
- if create.forceRecreate && create.noRecreate {
- return fmt.Errorf("--force-recreate and --no-recreate are incompatible")
- }
- if create.recreateDeps && create.noRecreate {
- return fmt.Errorf("--always-recreate-deps and --no-recreate are incompatible")
- }
- if create.noBuild && up.watch {
- return fmt.Errorf("--no-build and --watch are incompatible")
- }
- return nil
-}
-
-//nolint:gocyclo
-func runUp(
- ctx context.Context,
- dockerCli command.Cli,
- backendOptions *BackendOptions,
- createOptions createOptions,
- upOptions upOptions,
- buildOptions buildOptions,
- project *types.Project,
- services []string,
-) error {
- if err := checksForRemoteStack(ctx, dockerCli, project, buildOptions, createOptions.AssumeYes, []string{}); err != nil {
- return err
- }
-
- err := createOptions.Apply(project)
- if err != nil {
- return err
- }
-
- project, err = upOptions.apply(project, services)
- if err != nil {
- return err
- }
-
- var build *api.BuildOptions
- if !createOptions.noBuild {
- if createOptions.quietPull {
- buildOptions.Progress = string(xprogress.QuietMode)
- }
- // BuildOptions here is nested inside CreateOptions, so
- // no service list is passed, it will implicitly pick all
- // services being created, which includes any explicitly
- // specified via "services" arg here as well as deps
- bo, err := buildOptions.toAPIBuildOptions(nil)
- if err != nil {
- return err
- }
- bo.Services = project.ServiceNames()
- bo.Deps = !upOptions.noDeps
- build = &bo
- }
-
- create := api.CreateOptions{
- Build: build,
- Services: services,
- RemoveOrphans: createOptions.removeOrphans,
- IgnoreOrphans: createOptions.ignoreOrphans,
- Recreate: createOptions.recreateStrategy(),
- RecreateDependencies: createOptions.dependenciesRecreateStrategy(),
- Inherit: !createOptions.noInherit,
- Timeout: createOptions.GetTimeout(),
- QuietPull: createOptions.quietPull,
- }
-
- if createOptions.AssumeYes {
- backendOptions.Options = append(backendOptions.Options, compose.WithPrompt(compose.AlwaysOkPrompt()))
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
-
- if upOptions.noStart {
- return backend.Create(ctx, project, create)
- }
-
- var consumer api.LogConsumer
- var attach []string
- if !upOptions.Detach {
- consumer = formatter.NewLogConsumer(ctx, dockerCli.Out(), dockerCli.Err(), !upOptions.noColor, !upOptions.noPrefix, upOptions.timestamp)
-
- var attachSet utils.Set[string]
- if len(upOptions.attach) != 0 {
- // services are passed explicitly with --attach, verify they're valid and then use them as-is
- attachSet = utils.NewSet(upOptions.attach...)
- unexpectedSvcs := attachSet.Diff(utils.NewSet(project.ServiceNames()...))
- if len(unexpectedSvcs) != 0 {
- return fmt.Errorf("cannot attach to services not included in up: %s", strings.Join(unexpectedSvcs.Elements(), ", "))
- }
- } else {
- // mark services being launched (and potentially their deps) for attach
- // if they didn't opt-out via Compose YAML
- attachSet = utils.NewSet[string]()
- var dependencyOpt types.DependencyOption = types.IgnoreDependencies
- if upOptions.attachDependencies {
- dependencyOpt = types.IncludeDependencies
- }
- if err := project.ForEachService(services, func(serviceName string, s *types.ServiceConfig) error {
- if s.Attach == nil || *s.Attach {
- attachSet.Add(serviceName)
- }
- return nil
- }, dependencyOpt); err != nil {
- return err
- }
- }
- // filter out any services that have been explicitly marked for ignore with `--no-attach`
- attachSet.RemoveAll(upOptions.noAttach...)
- attach = attachSet.Elements()
- }
-
- var timeout time.Duration
- if upOptions.waitTimeout > 0 {
- timeout = time.Duration(upOptions.waitTimeout) * time.Second
- }
- return backend.Up(ctx, project, api.UpOptions{
- Create: create,
- Start: api.StartOptions{
- Project: project,
- Attach: consumer,
- AttachTo: attach,
- ExitCodeFrom: upOptions.exitCodeFrom,
- OnExit: upOptions.OnExit(),
- Wait: upOptions.wait,
- WaitTimeout: timeout,
- Watch: upOptions.watch,
- Services: services,
- NavigationMenu: upOptions.navigationMenu && display.Mode != "plain" && dockerCli.In().IsTerminal(),
- },
- })
-}
-
-func setServiceScale(project *types.Project, name string, replicas int) error {
- service, err := project.GetService(name)
- if err != nil {
- return err
- }
- service.SetScale(replicas)
- project.Services[name] = service
- return nil
-}
diff --git a/cmd/compose/up_test.go b/cmd/compose/up_test.go
deleted file mode 100644
index e6e7fd2224f..00000000000
--- a/cmd/compose/up_test.go
+++ /dev/null
@@ -1,181 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "bytes"
- "context"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/compose-spec/compose-go/v2/loader"
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/streams"
- "go.uber.org/mock/gomock"
- "gotest.tools/v3/assert"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/mocks"
-)
-
-type testRemoteLoader struct {
- localPath string
-}
-
-func (l testRemoteLoader) Accept(path string) bool {
- return strings.HasPrefix(path, "test://")
-}
-
-func (l testRemoteLoader) Load(context.Context, string) (string, error) {
- return l.localPath, nil
-}
-
-func (l testRemoteLoader) Dir(string) string {
- return filepath.Dir(l.localPath)
-}
-
-var _ loader.ResourceLoader = testRemoteLoader{}
-
-func TestApplyScaleOpt(t *testing.T) {
- p := types.Project{
- Services: types.Services{
- "foo": {
- Name: "foo",
- },
- "bar": {
- Name: "bar",
- Deploy: &types.DeployConfig{
- Mode: "test",
- },
- },
- },
- }
- err := applyScaleOpts(&p, []string{"foo=2", "bar=3"})
- assert.NilError(t, err)
- foo, err := p.GetService("foo")
- assert.NilError(t, err)
- assert.Equal(t, *foo.Scale, 2)
-
- bar, err := p.GetService("bar")
- assert.NilError(t, err)
- assert.Equal(t, *bar.Scale, 3)
- assert.Equal(t, *bar.Deploy.Replicas, 3)
-}
-
-func TestUpOptions_OnExit(t *testing.T) {
- tests := []struct {
- name string
- args upOptions
- want api.Cascade
- }{
- {
- name: "no cascade",
- args: upOptions{},
- want: api.CascadeIgnore,
- },
- {
- name: "cascade stop",
- args: upOptions{cascadeStop: true},
- want: api.CascadeStop,
- },
- {
- name: "cascade fail",
- args: upOptions{cascadeFail: true},
- want: api.CascadeFail,
- },
- {
- name: "both set - stop takes precedence",
- args: upOptions{
- cascadeStop: true,
- cascadeFail: true,
- },
- want: api.CascadeStop,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := tt.args.OnExit()
- assert.Equal(t, got, tt.want)
- })
- }
-}
-
-func TestRunUpAllowsTemplatedPortFieldsInRemoteStackPrompt(t *testing.T) {
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
-
- dir := t.TempDir()
- composePath := filepath.Join(dir, "compose.yaml")
- assert.NilError(t, os.WriteFile(composePath, []byte(`
-name: remote-defaults
-services:
- web:
- image: nginx
- ports:
- - host_ip: "${LXKNS_ADDRESS:-127.0.0.1}"
- published: "${LXKNS_PORT:-5010}"
- target: 80
- protocol: tcp
-`), 0o600))
-
- in := io.NopCloser(bytes.NewBufferString("n\n"))
- out := new(bytes.Buffer)
- errOut := new(bytes.Buffer)
- cli := mocks.NewMockCli(ctrl)
- cli.EXPECT().In().Return(streams.NewIn(in)).AnyTimes()
- cli.EXPECT().Out().Return(streams.NewOut(out)).AnyTimes()
- cli.EXPECT().Err().Return(streams.NewOut(errOut)).AnyTimes()
-
- projectOptions := &ProjectOptions{
- ConfigPaths: []string{"test://remote/compose.yaml"},
- ProjectDir: dir,
- remoteLoadersOverride: []loader.ResourceLoader{testRemoteLoader{localPath: composePath}},
- }
- project := &types.Project{
- Name: "remote-defaults",
- WorkingDir: dir,
- Services: types.Services{
- "web": {
- Name: "web",
- Image: "nginx",
- },
- },
- }
-
- err := runUp(
- t.Context(),
- cli,
- &BackendOptions{},
- createOptions{},
- upOptions{},
- buildOptions{ProjectOptions: projectOptions},
- project,
- nil,
- )
-
- assert.Error(t, err, "operation cancelled by user")
- output := out.String()
- assert.Assert(t, strings.Contains(output, `Your compose stack "test://remote/compose.yaml"`), output)
- assert.Assert(t, strings.Contains(output, "LXKNS_ADDRESS"), output)
- assert.Assert(t, strings.Contains(output, "LXKNS_PORT"), output)
- assert.Assert(t, !strings.Contains(fmt.Sprint(err), "invalid ip address"), fmt.Sprint(err))
-}
diff --git a/cmd/compose/version.go b/cmd/compose/version.go
deleted file mode 100644
index 4302c15697b..00000000000
--- a/cmd/compose/version.go
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "fmt"
- "strings"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/internal"
-)
-
-type versionOptions struct {
- format string
- short bool
-}
-
-func versionCommand(dockerCli command.Cli) *cobra.Command {
- opts := versionOptions{}
- cmd := &cobra.Command{
- Use: "version [OPTIONS]",
- Short: "Show the Docker Compose version information",
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, _ []string) error {
- runVersion(opts, dockerCli)
- return nil
- },
- PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
- // overwrite parent PersistentPreRunE to avoid trying to load
- // compose file on version command if COMPOSE_FILE is set
- return nil
- },
- }
- // define flags for backward compatibility with com.docker.cli
- flags := cmd.Flags()
- flags.StringVarP(&opts.format, "format", "f", "", "Format the output. Values: [pretty | json]. (Default: pretty)")
- flags.BoolVar(&opts.short, "short", false, "Shows only Compose's version number")
-
- return cmd
-}
-
-func runVersion(opts versionOptions, dockerCli command.Cli) {
- if opts.short {
- _, _ = fmt.Fprintln(dockerCli.Out(), strings.TrimPrefix(internal.Version, "v"))
- return
- }
- if opts.format == formatter.JSON {
- _, _ = fmt.Fprintf(dockerCli.Out(), "{\"version\":%q}\n", internal.Version)
- return
- }
- _, _ = fmt.Fprintln(dockerCli.Out(), "Docker Compose version", internal.Version)
-}
diff --git a/cmd/compose/version_test.go b/cmd/compose/version_test.go
deleted file mode 100644
index c9bf9b74eae..00000000000
--- a/cmd/compose/version_test.go
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- Copyright 2025 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "bytes"
- "testing"
-
- "github.com/docker/cli/cli/streams"
- "go.uber.org/mock/gomock"
- "gotest.tools/v3/assert"
-
- "github.com/docker/compose/v5/internal"
- "github.com/docker/compose/v5/pkg/mocks"
-)
-
-func TestVersionCommand(t *testing.T) {
- originalVersion := internal.Version
- defer func() {
- internal.Version = originalVersion
- }()
- internal.Version = "v9.9.9-test"
-
- tests := []struct {
- name string
- args []string
- want string
- }{
- {
- name: "default",
- args: []string{},
- want: "Docker Compose version v9.9.9-test\n",
- },
- {
- name: "short flag",
- args: []string{"--short"},
- want: "9.9.9-test\n",
- },
- {
- name: "json flag",
- args: []string{"--format", "json"},
- want: `{"version":"v9.9.9-test"}` + "\n",
- },
- }
-
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
-
- buf := new(bytes.Buffer)
- cli := mocks.NewMockCli(ctrl)
- cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes()
-
- cmd := versionCommand(cli)
- cmd.SetArgs(test.args)
- err := cmd.Execute()
- assert.NilError(t, err)
-
- assert.Equal(t, test.want, buf.String())
- })
- }
-}
diff --git a/cmd/compose/viz.go b/cmd/compose/viz.go
deleted file mode 100644
index 443d78c6261..00000000000
--- a/cmd/compose/viz.go
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- Copyright 2023 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
-
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type vizOptions struct {
- *ProjectOptions
- includeNetworks bool
- includePorts bool
- includeImageName bool
- indentationStr string
-}
-
-func vizCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := vizOptions{
- ProjectOptions: p,
- }
- var indentationSize int
- var useSpaces bool
-
- cmd := &cobra.Command{
- Use: "viz [OPTIONS]",
- Short: "EXPERIMENTAL - Generate a graphviz graph from your compose file",
- PreRunE: Adapt(func(ctx context.Context, args []string) error {
- var err error
- opts.indentationStr, err = preferredIndentationStr(indentationSize, useSpaces)
- return err
- }),
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runViz(ctx, dockerCli, backendOptions, &opts)
- }),
- }
-
- cmd.Flags().BoolVar(&opts.includePorts, "ports", false, "Include service's exposed ports in output graph")
- cmd.Flags().BoolVar(&opts.includeNetworks, "networks", false, "Include service's attached networks in output graph")
- cmd.Flags().BoolVar(&opts.includeImageName, "image", false, "Include service's image name in output graph")
- cmd.Flags().IntVar(&indentationSize, "indentation-size", 1, "Number of tabs or spaces to use for indentation")
- cmd.Flags().BoolVar(&useSpaces, "spaces", false, "If given, space character ' ' will be used to indent,\notherwise tab character '\\t' will be used")
- return cmd
-}
-
-func runViz(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts *vizOptions) error {
- _, _ = fmt.Fprintln(os.Stderr, "viz command is EXPERIMENTAL")
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
-
- project, _, err := opts.ToProject(ctx, dockerCli, backend, nil)
- if err != nil {
- return err
- }
-
- // build graph
- graphStr, _ := backend.Viz(ctx, project, api.VizOptions{
- IncludeNetworks: opts.includeNetworks,
- IncludePorts: opts.includePorts,
- IncludeImageName: opts.includeImageName,
- Indentation: opts.indentationStr,
- })
-
- fmt.Println(graphStr)
-
- return nil
-}
-
-// preferredIndentationStr returns a single string given the indentation preference
-func preferredIndentationStr(size int, useSpace bool) (string, error) {
- if size < 0 {
- return "", fmt.Errorf("invalid indentation size: %d", size)
- }
-
- indentationStr := "\t"
- if useSpace {
- indentationStr = " "
- }
- return strings.Repeat(indentationStr, size), nil
-}
diff --git a/cmd/compose/viz_test.go b/cmd/compose/viz_test.go
deleted file mode 100644
index e8621178103..00000000000
--- a/cmd/compose/viz_test.go
+++ /dev/null
@@ -1,93 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "testing"
-
- "gotest.tools/v3/assert"
-)
-
-func TestPreferredIndentationStr(t *testing.T) {
- type args struct {
- size int
- useSpace bool
- }
- tests := []struct {
- name string
- args args
- want string
- wantErr bool
- }{
- {
- name: "should return '\\t\\t'",
- args: args{
- size: 2,
- useSpace: false,
- },
- want: "\t\t",
- wantErr: false,
- },
- {
- name: "should return ' '",
- args: args{
- size: 4,
- useSpace: true,
- },
- want: " ",
- wantErr: false,
- },
- {
- name: "should return ''",
- args: args{
- size: 0,
- useSpace: false,
- },
- want: "",
- wantErr: false,
- },
- {
- name: "should return ''",
- args: args{
- size: 0,
- useSpace: true,
- },
- want: "",
- wantErr: false,
- },
- {
- name: "should throw error because indentation size < 0",
- args: args{
- size: -1,
- useSpace: false,
- },
- want: "",
- wantErr: true,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := preferredIndentationStr(tt.args.size, tt.args.useSpace)
- if tt.wantErr {
- assert.ErrorContains(t, err, "invalid indentation size", "preferredIndentationStr(%v,%v)", tt.args.size, tt.args.useSpace)
- } else {
- assert.NilError(t, err)
- assert.Equal(t, tt.want, got)
- }
- })
- }
-}
diff --git a/cmd/compose/volumes.go b/cmd/compose/volumes.go
deleted file mode 100644
index e0da4f82e3b..00000000000
--- a/cmd/compose/volumes.go
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
- "slices"
-
- "github.com/docker/cli/cli/command"
- "github.com/docker/cli/cli/command/formatter"
- "github.com/docker/cli/cli/flags"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type volumesOptions struct {
- *ProjectOptions
- Quiet bool
- Format string
-}
-
-func volumesCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- options := volumesOptions{
- ProjectOptions: p,
- }
-
- cmd := &cobra.Command{
- Use: "volumes [OPTIONS] [SERVICE...]",
- Short: "List volumes",
- RunE: Adapt(func(ctx context.Context, args []string) error {
- return runVol(ctx, dockerCli, backendOptions, args, options)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
-
- cmd.Flags().BoolVarP(&options.Quiet, "quiet", "q", false, "Only display volume names")
- cmd.Flags().StringVar(&options.Format, "format", "table", flags.FormatHelp)
-
- return cmd
-}
-
-func runVol(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, services []string, options volumesOptions) error {
- project, name, err := options.projectOrName(ctx, dockerCli, services...)
- if err != nil {
- return err
- }
-
- if project != nil {
- names := project.ServiceNames()
- for _, service := range services {
- if !slices.Contains(names, service) {
- return fmt.Errorf("no such service: %s", service)
- }
- }
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
- volumes, err := backend.Volumes(ctx, name, api.VolumesOptions{
- Services: services,
- })
- if err != nil {
- return err
- }
-
- if options.Quiet {
- for _, v := range volumes {
- _, _ = fmt.Fprintln(dockerCli.Out(), v.Name)
- }
- return nil
- }
-
- volumeCtx := formatter.Context{
- Output: dockerCli.Out(),
- Format: formatter.NewVolumeFormat(options.Format, options.Quiet),
- }
-
- return formatter.VolumeWrite(volumeCtx, volumes)
-}
diff --git a/cmd/compose/wait.go b/cmd/compose/wait.go
deleted file mode 100644
index 9d86fd314cf..00000000000
--- a/cmd/compose/wait.go
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- Copyright 2023 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "os"
-
- "github.com/docker/cli/cli"
- "github.com/docker/cli/cli/command"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type waitOptions struct {
- *ProjectOptions
-
- services []string
-
- downProject bool
-}
-
-func waitCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- opts := waitOptions{
- ProjectOptions: p,
- }
-
- var statusCode int64
- var err error
- cmd := &cobra.Command{
- Use: "wait SERVICE [SERVICE...] [OPTIONS]",
- Short: "Block until containers of all (or specified) services stop.",
- Args: cli.RequiresMinArgs(1),
- RunE: Adapt(func(ctx context.Context, services []string) error {
- opts.services = services
- statusCode, err = runWait(ctx, dockerCli, backendOptions, &opts)
- return err
- }),
- PostRun: func(cmd *cobra.Command, args []string) {
- os.Exit(int(statusCode))
- },
- }
-
- cmd.Flags().BoolVar(&opts.downProject, "down-project", false, "Drops project when the first container stops")
-
- return cmd
-}
-
-func runWait(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts *waitOptions) (int64, error) {
- _, name, err := opts.projectOrName(ctx, dockerCli)
- if err != nil {
- return 0, err
- }
-
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return 0, err
- }
- return backend.Wait(ctx, name, api.WaitOptions{
- Services: opts.services,
- DownProjectOnContainerExit: opts.downProject,
- })
-}
diff --git a/cmd/compose/watch.go b/cmd/compose/watch.go
deleted file mode 100644
index 039971ce23f..00000000000
--- a/cmd/compose/watch.go
+++ /dev/null
@@ -1,140 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package compose
-
-import (
- "context"
- "fmt"
-
- "github.com/compose-spec/compose-go/v2/cli"
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/docker/cli/cli/command"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/cmd/formatter"
- "github.com/docker/compose/v5/internal/locker"
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-type watchOptions struct {
- *ProjectOptions
- prune bool
- noUp bool
-}
-
-func watchCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
- watchOpts := watchOptions{
- ProjectOptions: p,
- }
- buildOpts := buildOptions{
- ProjectOptions: p,
- }
- cmd := &cobra.Command{
- Use: "watch [SERVICE...]",
- Short: "Watch build context for service and rebuild/refresh containers when files are updated",
- PreRunE: Adapt(func(ctx context.Context, args []string) error {
- return nil
- }),
- RunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
- if cmd.Parent().Name() == "alpha" {
- logrus.Warn("watch command is now available as a top level command")
- }
- return runWatch(ctx, dockerCli, backendOptions, watchOpts, buildOpts, args)
- }),
- ValidArgsFunction: completeServiceNames(dockerCli, p),
- }
-
- cmd.Flags().BoolVar(&buildOpts.quiet, "quiet", false, "hide build output")
- cmd.Flags().BoolVar(&watchOpts.prune, "prune", true, "Prune dangling images on rebuild")
- cmd.Flags().BoolVar(&watchOpts.noUp, "no-up", false, "Do not build & start services before watching")
- return cmd
-}
-
-func runWatch(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, watchOpts watchOptions, buildOpts buildOptions, services []string) error {
- backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
- if err != nil {
- return err
- }
-
- project, _, err := watchOpts.ToProject(ctx, dockerCli, backend, services, cli.WithoutEnvironmentResolution)
- if err != nil {
- return err
- }
-
- // resolve environment after the project has been reduced to selected services,
- // so env_file declared by unrelated services doesn't need to exist
- project, err = project.WithServicesEnvironmentResolved(true)
- if err != nil {
- return err
- }
-
- if err := applyPlatforms(project, true); err != nil {
- return err
- }
-
- build, err := buildOpts.toAPIBuildOptions(nil)
- if err != nil {
- return err
- }
-
- // validation done -- ensure we have the lockfile for this project before doing work
- l, err := locker.NewPidfile(project.Name)
- if err != nil {
- return fmt.Errorf("cannot take exclusive lock for project %q: %w", project.Name, err)
- }
- if err := l.Lock(); err != nil {
- return fmt.Errorf("cannot take exclusive lock for project %q: %w", project.Name, err)
- }
-
- if !watchOpts.noUp {
- for index, service := range project.Services {
- if service.Build != nil && service.Develop != nil {
- service.PullPolicy = types.PullPolicyBuild
- }
- project.Services[index] = service
- }
- upOpts := api.UpOptions{
- Create: api.CreateOptions{
- Build: &build,
- Services: services,
- RemoveOrphans: false,
- Recreate: api.RecreateDiverged,
- RecreateDependencies: api.RecreateNever,
- Inherit: true,
- QuietPull: buildOpts.quiet,
- },
- Start: api.StartOptions{
- Project: project,
- Attach: nil,
- Services: services,
- },
- }
- if err := backend.Up(ctx, project, upOpts); err != nil {
- return err
- }
- }
-
- consumer := formatter.NewLogConsumer(ctx, dockerCli.Out(), dockerCli.Err(), false, false, false)
- return backend.Watch(ctx, project, api.WatchOptions{
- Build: &build,
- LogTo: consumer,
- Prune: watchOpts.prune,
- Services: services,
- })
-}
diff --git a/cmd/display/colors.go b/cmd/display/colors.go
deleted file mode 100644
index a00b4ed6ad5..00000000000
--- a/cmd/display/colors.go
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package display
-
-import (
- "github.com/morikuni/aec"
-)
-
-type colorFunc func(string) string
-
-var (
- nocolor colorFunc = func(s string) string {
- return s
- }
-
- DoneColor colorFunc = aec.BlueF.Apply
- TimerColor colorFunc = aec.BlueF.Apply
- CountColor colorFunc = aec.YellowF.Apply
- WarningColor colorFunc = aec.YellowF.With(aec.Bold).Apply
- SuccessColor colorFunc = aec.GreenF.Apply
- ErrorColor colorFunc = aec.RedF.With(aec.Bold).Apply
- PrefixColor colorFunc = aec.CyanF.Apply
-)
-
-func NoColor() {
- DoneColor = nocolor
- TimerColor = nocolor
- CountColor = nocolor
- WarningColor = nocolor
- SuccessColor = nocolor
- ErrorColor = nocolor
- PrefixColor = nocolor
-}
diff --git a/cmd/display/dryrun.go b/cmd/display/dryrun.go
deleted file mode 100644
index 2ab542e5b05..00000000000
--- a/cmd/display/dryrun.go
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package display
-
-const (
- DRYRUN_PREFIX = " DRY-RUN MODE - "
-)
diff --git a/cmd/display/json.go b/cmd/display/json.go
deleted file mode 100644
index b8873596374..00000000000
--- a/cmd/display/json.go
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- Copyright 2024 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package display
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-func JSON(out io.Writer) api.EventProcessor {
- return &jsonWriter{
- out: out,
- }
-}
-
-type jsonWriter struct {
- out io.Writer
- dryRun bool
-}
-
-type jsonMessage struct {
- DryRun bool `json:"dry-run,omitempty"`
- Tail bool `json:"tail,omitempty"`
- ID string `json:"id,omitempty"`
- ParentID string `json:"parent_id,omitempty"`
- Status string `json:"status,omitempty"`
- Text string `json:"text,omitempty"`
- Details string `json:"details,omitempty"`
- Current int64 `json:"current,omitempty"`
- Total int64 `json:"total,omitempty"`
- Percent int `json:"percent,omitempty"`
-}
-
-func (p *jsonWriter) Start(ctx context.Context, operation string) {
-}
-
-func (p *jsonWriter) Event(e api.Resource) {
- message := &jsonMessage{
- DryRun: p.dryRun,
- Tail: false,
- ID: e.ID,
- Status: e.StatusText(),
- Text: e.Text,
- Details: e.Details,
- ParentID: e.ParentID,
- Current: e.Current,
- Total: e.Total,
- Percent: e.Percent,
- }
- marshal, err := json.Marshal(message)
- if err == nil {
- _, _ = fmt.Fprintln(p.out, string(marshal))
- }
-}
-
-func (p *jsonWriter) On(events ...api.Resource) {
- for _, e := range events {
- p.Event(e)
- }
-}
-
-func (p *jsonWriter) Done(_ string, _ bool) {
-}
diff --git a/cmd/display/json_test.go b/cmd/display/json_test.go
deleted file mode 100644
index 0f0dff23a61..00000000000
--- a/cmd/display/json_test.go
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- Copyright 2024 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package display
-
-import (
- "bytes"
- "encoding/json"
- "testing"
-
- "gotest.tools/v3/assert"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-func TestJsonWriter_Event(t *testing.T) {
- var out bytes.Buffer
- w := &jsonWriter{
- out: &out,
- dryRun: true,
- }
-
- event := api.Resource{
- ID: "service1",
- ParentID: "project",
- Status: api.Working,
- Text: api.StatusCreating,
- Current: 50,
- Total: 100,
- Percent: 50,
- }
- w.Event(event)
-
- var actual jsonMessage
- err := json.Unmarshal(out.Bytes(), &actual)
- assert.NilError(t, err)
-
- expected := jsonMessage{
- DryRun: true,
- ID: event.ID,
- ParentID: event.ParentID,
- Text: api.StatusCreating,
- Status: "Working",
- Current: event.Current,
- Total: event.Total,
- Percent: event.Percent,
- }
- assert.DeepEqual(t, expected, actual)
-}
diff --git a/cmd/display/mode.go b/cmd/display/mode.go
deleted file mode 100644
index d66777b472c..00000000000
--- a/cmd/display/mode.go
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- Copyright 2024 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package display
-
-// Mode define how progress should be rendered, either as ModePlain or ModeTTY
-var Mode = ModeAuto
-
-const (
- // ModeAuto detect console capabilities
- ModeAuto = "auto"
- // ModeTTY use terminal capability for advanced rendering
- ModeTTY = "tty"
- // ModePlain dump raw events to output
- ModePlain = "plain"
- // ModeQuiet don't display events
- ModeQuiet = "quiet"
- // ModeJSON outputs a machine-readable JSON stream
- ModeJSON = "json"
-)
diff --git a/cmd/display/plain.go b/cmd/display/plain.go
deleted file mode 100644
index 16f2816c011..00000000000
--- a/cmd/display/plain.go
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package display
-
-import (
- "context"
- "fmt"
- "io"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-func Plain(out io.Writer) api.EventProcessor {
- return &plainWriter{
- out: out,
- }
-}
-
-type plainWriter struct {
- out io.Writer
- dryRun bool
-}
-
-func (p *plainWriter) Start(ctx context.Context, operation string) {
-}
-
-func (p *plainWriter) Event(e api.Resource) {
- prefix := ""
- if p.dryRun {
- prefix = DRYRUN_PREFIX
- }
- _, _ = fmt.Fprintln(p.out, prefix, e.ID, e.Text, e.Details)
-}
-
-func (p *plainWriter) On(events ...api.Resource) {
- for _, e := range events {
- p.Event(e)
- }
-}
-
-func (p *plainWriter) Done(_ string, _ bool) {
-}
diff --git a/cmd/display/quiet.go b/cmd/display/quiet.go
deleted file mode 100644
index 8e1537d8061..00000000000
--- a/cmd/display/quiet.go
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package display
-
-import (
- "context"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-func Quiet() api.EventProcessor {
- return &quiet{}
-}
-
-type quiet struct{}
-
-func (q *quiet) Start(_ context.Context, _ string) {
-}
-
-func (q *quiet) Done(_ string, _ bool) {
-}
-
-func (q *quiet) On(_ ...api.Resource) {
-}
diff --git a/cmd/display/spinner.go b/cmd/display/spinner.go
deleted file mode 100644
index e476deae80f..00000000000
--- a/cmd/display/spinner.go
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package display
-
-import (
- "runtime"
- "time"
-)
-
-type Spinner struct {
- time time.Time
- index int
- chars []string
- stop bool
- done string
-}
-
-func NewSpinner() *Spinner {
- chars := []string{
- "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
- }
- done := "⠿"
-
- if runtime.GOOS == "windows" {
- chars = []string{"-"}
- done = "-"
- }
-
- return &Spinner{
- index: 0,
- time: time.Now(),
- chars: chars,
- done: done,
- }
-}
-
-func (s *Spinner) String() string {
- if s.stop {
- return s.done
- }
-
- d := time.Since(s.time)
- if d.Milliseconds() > 100 {
- s.index = (s.index + 1) % len(s.chars)
- }
-
- return s.chars[s.index]
-}
-
-func (s *Spinner) Stop() {
- s.stop = true
-}
-
-func (s *Spinner) Restart() {
- s.stop = false
-}
diff --git a/cmd/display/tty.go b/cmd/display/tty.go
deleted file mode 100644
index 60909692597..00000000000
--- a/cmd/display/tty.go
+++ /dev/null
@@ -1,696 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package display
-
-import (
- "context"
- "fmt"
- "io"
- "iter"
- "slices"
- "strings"
- "sync"
- "time"
- "unicode/utf8"
-
- "github.com/buger/goterm"
- "github.com/docker/go-units"
- "github.com/morikuni/aec"
-
- "github.com/docker/compose/v5/pkg/api"
- "github.com/docker/compose/v5/pkg/utils"
-)
-
-// Full creates an EventProcessor that render advanced UI within a terminal.
-// On Start, TUI lists task with a progress timer
-func Full(out io.Writer, info io.Writer, detached bool) api.EventProcessor {
- return &ttyWriter{
- out: out,
- info: info,
- tasks: map[string]*task{},
- done: make(chan bool),
- mtx: &sync.Mutex{},
- detached: detached,
- }
-}
-
-type ttyWriter struct {
- out io.Writer
- ids []string // tasks ids ordered as first event appeared
- tasks map[string]*task
- repeated bool
- numLines int
- done chan bool
- mtx *sync.Mutex
- dryRun bool // FIXME(ndeloof) (re)implement support for dry-run
- operation string
- ticker *time.Ticker
- suspended bool
- info io.Writer
- detached bool
-}
-
-type task struct {
- ID string
- parent string // the resource this task receives updates from - other parents will be ignored
- parents utils.Set[string] // all resources to depend on this task
- startTime time.Time
- endTime time.Time
- text string
- details string
- status api.EventStatus
- current int64
- percent int
- total int64
- spinner *Spinner
-}
-
-func newTask(e api.Resource) task {
- t := task{
- ID: e.ID,
- parents: utils.NewSet[string](),
- startTime: time.Now(),
- text: e.Text,
- details: e.Details,
- status: e.Status,
- current: e.Current,
- percent: e.Percent,
- total: e.Total,
- spinner: NewSpinner(),
- }
- if e.ParentID != "" {
- t.parent = e.ParentID
- t.parents.Add(e.ParentID)
- }
- if e.Status == api.Done || e.Status == api.Error {
- t.stop()
- }
- return t
-}
-
-// update adjusts task state based on last received event
-func (t *task) update(e api.Resource) {
- if e.ParentID != "" {
- t.parents.Add(e.ParentID)
- // we may receive same event from distinct parents (typically: images sharing layers)
- // to avoid status to flicker, only accept updates from our first declared parent
- if t.parent != e.ParentID {
- return
- }
- }
-
- // update task based on received event
- switch e.Status {
- case api.Done, api.Error, api.Warning:
- if t.status != e.Status {
- t.stop()
- }
- case api.Working:
- t.hasMore()
- }
- t.status = e.Status
- t.text = e.Text
- t.details = e.Details
- // progress can only go up
- if e.Total > t.total {
- t.total = e.Total
- }
- if e.Current > t.current {
- t.current = e.Current
- }
- if e.Percent > t.percent {
- t.percent = e.Percent
- }
-}
-
-func (t *task) stop() {
- t.endTime = time.Now()
- t.spinner.Stop()
-}
-
-func (t *task) hasMore() {
- t.spinner.Restart()
-}
-
-func (t *task) Completed() bool {
- switch t.status {
- case api.Done, api.Error, api.Warning:
- return true
- default:
- return false
- }
-}
-
-func (w *ttyWriter) Start(ctx context.Context, operation string) {
- w.ticker = time.NewTicker(100 * time.Millisecond)
- w.operation = operation
- go func() {
- for {
- select {
- case <-ctx.Done():
- // interrupted
- w.ticker.Stop()
- return
- case <-w.done:
- return
- case <-w.ticker.C:
- w.print()
- }
- }
- }()
-}
-
-func (w *ttyWriter) Done(operation string, success bool) {
- w.print()
- w.done <- true
- w.mtx.Lock()
- defer w.mtx.Unlock()
- if w.ticker != nil {
- w.ticker.Stop()
- }
- w.operation = ""
-}
-
-func (w *ttyWriter) On(events ...api.Resource) {
- w.mtx.Lock()
- defer w.mtx.Unlock()
- for _, e := range events {
- if e.ID == "Compose" {
- _, _ = fmt.Fprintln(w.info, ErrorColor(e.Details))
- continue
- }
-
- if w.operation != "start" && (e.Text == api.StatusStarted || e.Text == api.StatusStarting) && !w.detached {
- // skip those events to avoid mix with container logs
- continue
- }
- w.event(e)
- }
-}
-
-func (w *ttyWriter) event(e api.Resource) {
- // Suspend print while a build is in progress, to avoid collision with buildkit Display
- if w.ticker != nil {
- if e.Text == api.StatusBuilding {
- w.ticker.Stop()
- w.suspended = true
- } else if w.suspended {
- w.ticker.Reset(100 * time.Millisecond)
- w.suspended = false
- }
- }
-
- if last, ok := w.tasks[e.ID]; ok {
- last.update(e)
- } else {
- t := newTask(e)
- w.tasks[e.ID] = &t
- w.ids = append(w.ids, e.ID)
- }
- w.printEvent(e)
-}
-
-func (w *ttyWriter) printEvent(e api.Resource) {
- if w.operation != "" {
- // event will be displayed by progress UI on ticker's ticks
- return
- }
-
- var color colorFunc
- switch e.Status {
- case api.Working:
- color = SuccessColor
- case api.Done:
- color = SuccessColor
- case api.Warning:
- color = WarningColor
- case api.Error:
- color = ErrorColor
- }
- _, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, color(e.Text), e.Details)
-}
-
-func (w *ttyWriter) parentTasks() iter.Seq[*task] {
- return func(yield func(*task) bool) {
- for _, id := range w.ids { // iterate on ids to enforce a consistent order
- t := w.tasks[id]
- if len(t.parents) == 0 {
- yield(t)
- }
- }
- }
-}
-
-func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] {
- return func(yield func(*task) bool) {
- for _, id := range w.ids { // iterate on ids to enforce a consistent order
- t := w.tasks[id]
- if t.parents.Has(parent) {
- yield(t)
- }
- }
- }
-}
-
-// lineData holds pre-computed formatting for a task line
-type lineData struct {
- spinner string // rendered spinner with color
- prefix string // dry-run prefix if any
- taskID string // possibly abbreviated
- progress string // progress bar and (optionally) size info appended
- progressSizeBytes int // byte length of the trailing size suffix in progress, 0 if none
- status string // rendered status with color
- details string // possibly abbreviated
- timer string // rendered timer with color
- statusPad int // padding before status to align
- timerPad int // padding before timer to align
- statusColor colorFunc
-}
-
-func (w *ttyWriter) print() {
- terminalWidth := goterm.Width()
- terminalHeight := goterm.Height()
- if terminalWidth <= 0 {
- terminalWidth = 80
- }
- if terminalHeight <= 0 {
- terminalHeight = 24
- }
- w.printWithDimensions(terminalWidth, terminalHeight)
-}
-
-func (w *ttyWriter) printWithDimensions(terminalWidth, terminalHeight int) {
- w.mtx.Lock()
- defer w.mtx.Unlock()
- if len(w.tasks) == 0 {
- return
- }
-
- up := w.numLines + 1
- if !w.repeated {
- up--
- w.repeated = true
- }
- b := aec.NewBuilder(
- aec.Hide, // Hide the cursor while we are printing
- aec.Up(uint(up)),
- aec.Column(0),
- )
- _, _ = fmt.Fprint(w.out, b.ANSI)
- defer func() {
- _, _ = fmt.Fprint(w.out, aec.Show)
- }()
-
- firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks))
- _, _ = fmt.Fprintln(w.out, firstLine)
-
- // Collect parent tasks in original order
- allTasks := slices.Collect(w.parentTasks())
-
- // Available lines: terminal height - 2 (header line + potential "more" line)
- maxLines := max(terminalHeight-2, 1)
-
- showMore := len(allTasks) > maxLines
- tasksToShow := allTasks
- if showMore {
- tasksToShow = allTasks[:maxLines-1] // Reserve one line for "more" message
- }
-
- // collect line data and compute timerLen
- lines := make([]lineData, len(tasksToShow))
- var timerLen int
- for i, t := range tasksToShow {
- lines[i] = w.prepareLineData(t)
- if len(lines[i].timer) > timerLen {
- timerLen = len(lines[i].timer)
- }
- }
-
- // pad timers so they all have the same visible width
- for i := range lines {
- l := &lines[i]
- if l.timer == "" {
- continue
- }
- timerWidth := utf8.RuneCountInString(l.timer)
- if timerWidth < timerLen {
- // Left-pad so the timer's right edge stays aligned on the terminal.
- // This also prevents stale suffix characters from visually “sticking”
- // when a previously-rendered timer was wider (e.g. "10.6s" -> "0.0s").
- l.timer = strings.Repeat(" ", timerLen-timerWidth) + l.timer
- }
- }
-
- // shorten details/taskID to fit terminal width
- w.adjustLineWidth(lines, timerLen, terminalWidth)
-
- // compute padding
- w.applyPadding(lines, terminalWidth, timerLen)
-
- // Render lines
- numLines := 0
- for _, l := range lines {
- _, _ = fmt.Fprint(w.out, lineText(l))
- numLines++
- }
-
- if showMore {
- moreCount := len(allTasks) - len(tasksToShow)
- moreText := fmt.Sprintf(" ... %d more", moreCount)
- pad := max(terminalWidth-len(moreText), 0)
- _, _ = fmt.Fprintf(w.out, "%s%s\n", moreText, strings.Repeat(" ", pad))
- numLines++
- }
-
- // Clear any remaining lines from previous render
- for i := numLines; i < w.numLines; i++ {
- _, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
- numLines++
- }
- w.numLines = numLines
-}
-
-func (w *ttyWriter) applyPadding(lines []lineData, terminalWidth int, timerLen int) {
- var maxBeforeStatus int
- for i := range lines {
- l := &lines[i]
- // Width before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress
- beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
- if beforeStatus > maxBeforeStatus {
- maxBeforeStatus = beforeStatus
- }
- }
-
- for i, l := range lines {
- // Position before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress
- beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
- // statusPad aligns status; lineText adds 1 more space after statusPad
- l.statusPad = maxBeforeStatus - beforeStatus
-
- // Format: beforeStatus + statusPad + space(1) + status
- lineLen := beforeStatus + l.statusPad + 1 + utf8.RuneCountInString(l.status)
- if l.details != "" {
- lineLen += 1 + utf8.RuneCountInString(l.details)
- }
- l.timerPad = max(terminalWidth-lineLen-timerLen, 1)
- lines[i] = l
-
- }
-}
-
-func (w *ttyWriter) adjustLineWidth(lines []lineData, timerLen int, terminalWidth int) {
- const minIDLen = 10
- maxStatusLen := maxStatusLength(lines)
-
- // Iteratively truncate until all lines fit
- for range 100 { // safety limit
- maxBeforeStatus := maxBeforeStatusWidth(lines)
- overflow := computeOverflow(lines, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth)
-
- if overflow <= 0 {
- break
- }
-
- // Drop ancillary content (details, progress size info) before touching the taskID.
- if !truncateDetails(lines, overflow) && !truncateProgressSize(lines) && !truncateLongestTaskID(lines, overflow, minIDLen) {
- break // Can't truncate further
- }
- }
-}
-
-// maxStatusLength returns the maximum status text length across all lines.
-func maxStatusLength(lines []lineData) int {
- var maxLen int
- for i := range lines {
- if len(lines[i].status) > maxLen {
- maxLen = len(lines[i].status)
- }
- }
- return maxLen
-}
-
-// maxBeforeStatusWidth computes the maximum width before statusPad across all lines.
-// This is: space(1) + spinner(1) + prefix + space(1) + taskID + progress
-func maxBeforeStatusWidth(lines []lineData) int {
- var maxWidth int
- for i := range lines {
- l := &lines[i]
- width := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
- if width > maxWidth {
- maxWidth = width
- }
- }
- return maxWidth
-}
-
-// computeOverflow calculates how many characters the widest line exceeds the terminal width.
-// Returns 0 or negative if all lines fit.
-func computeOverflow(lines []lineData, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth int) int {
- var maxOverflow int
- for i := range lines {
- l := &lines[i]
- detailsLen := len(l.details)
- if detailsLen > 0 {
- detailsLen++ // space before details
- }
- // Line width: maxBeforeStatus + space(1) + status + details + minTimerPad(1) + timer
- lineWidth := maxBeforeStatus + 1 + maxStatusLen + detailsLen + 1 + timerLen
- overflow := lineWidth - terminalWidth
- if overflow > maxOverflow {
- maxOverflow = overflow
- }
- }
- return maxOverflow
-}
-
-// truncateProgressSize drops the trailing "X.XMB / Y.YMB" size info from the
-// line currently driving maxBeforeStatusWidth — only that line's shrink can
-// reduce overflow. Returns true if any line was modified.
-func truncateProgressSize(lines []lineData) bool {
- maxIdx := -1
- var maxWidth int
- for i := range lines {
- l := &lines[i]
- if l.progressSizeBytes == 0 {
- continue
- }
- w := lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
- if maxIdx < 0 || w > maxWidth {
- maxWidth = w
- maxIdx = i
- }
- }
- if maxIdx < 0 {
- return false
- }
- l := &lines[maxIdx]
- l.progress = l.progress[:len(l.progress)-l.progressSizeBytes]
- l.progressSizeBytes = 0
- return true
-}
-
-// truncateDetails tries to truncate the first line's details to reduce overflow.
-// Returns true if any truncation was performed.
-func truncateDetails(lines []lineData, overflow int) bool {
- for i := range lines {
- l := &lines[i]
- if len(l.details) > 3 {
- reduction := min(overflow, len(l.details)-3)
- l.details = l.details[:len(l.details)-reduction-3] + "..."
- return true
- } else if l.details != "" {
- l.details = ""
- return true
- }
- }
- return false
-}
-
-// truncateLongestTaskID truncates the longest taskID to reduce overflow.
-// Returns true if truncation was performed. Lengths and slicing are in runes
-// to avoid emitting invalid UTF-8 when taskID contains multi-byte chars.
-func truncateLongestTaskID(lines []lineData, overflow, minIDLen int) bool {
- longestIdx := -1
- longestLen := minIDLen
- for i := range lines {
- if utf8.RuneCountInString(lines[i].taskID) > longestLen {
- longestLen = utf8.RuneCountInString(lines[i].taskID)
- longestIdx = i
- }
- }
-
- if longestIdx < 0 {
- return false
- }
-
- l := &lines[longestIdx]
- reduction := overflow + 3 // account for "..."
- newLen := max(longestLen-reduction, minIDLen-3)
- runes := []rune(l.taskID)
- l.taskID = string(runes[:newLen]) + "..."
- return true
-}
-
-func (w *ttyWriter) prepareLineData(t *task) lineData {
- endTime := time.Now()
- if t.status != api.Working {
- endTime = t.startTime
- if (t.endTime != time.Time{}) {
- endTime = t.endTime
- }
- }
-
- prefix := ""
- if w.dryRun {
- prefix = PrefixColor(DRYRUN_PREFIX)
- }
-
- elapsed := endTime.Sub(t.startTime).Seconds()
-
- var (
- hideDetails bool
- total int64
- current int64
- completion []string
- )
-
- // only show the aggregated progress while the root operation is in-progress
- if t.status == api.Working {
- for child := range w.childrenTasks(t.ID) {
- if child.status == api.Working && child.total == 0 {
- hideDetails = true
- }
- total += child.total
- current += child.current
- r := len(percentChars) - 1
- p := min(child.percent, 100)
- completion = append(completion, percentChars[r*p/100])
- }
- }
-
- if total == 0 {
- hideDetails = true
- }
-
- var progress string
- var progressSizeBytes int
- if len(completion) > 0 {
- progress = " [" + SuccessColor(strings.Join(completion, "")) + "]"
- if !hideDetails {
- sizeInfo := fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
- progress += sizeInfo
- progressSizeBytes = len(sizeInfo)
- }
- }
-
- return lineData{
- spinner: spinner(t),
- prefix: prefix,
- taskID: t.ID,
- progress: progress,
- progressSizeBytes: progressSizeBytes,
- status: t.text,
- statusColor: colorFn(t.status),
- details: t.details,
- timer: fmt.Sprintf("%.1fs", elapsed),
- }
-}
-
-func lineText(l lineData) string {
- var sb strings.Builder
- sb.WriteString(" ")
- sb.WriteString(l.spinner)
- sb.WriteString(l.prefix)
- sb.WriteString(" ")
- sb.WriteString(l.taskID)
- sb.WriteString(l.progress)
- sb.WriteString(strings.Repeat(" ", l.statusPad))
- sb.WriteString(" ")
- sb.WriteString(l.statusColor(l.status))
- if l.details != "" {
- sb.WriteString(" ")
- sb.WriteString(l.details)
- }
- sb.WriteString(strings.Repeat(" ", l.timerPad))
- sb.WriteString(TimerColor(l.timer))
- sb.WriteString("\n")
- return sb.String()
-}
-
-var (
- spinnerDone = "✔"
- spinnerWarning = "!"
- spinnerError = "✘"
-)
-
-func spinner(t *task) string {
- switch t.status {
- case api.Done:
- return SuccessColor(spinnerDone)
- case api.Warning:
- return WarningColor(spinnerWarning)
- case api.Error:
- return ErrorColor(spinnerError)
- default:
- return CountColor(t.spinner.String())
- }
-}
-
-func colorFn(s api.EventStatus) colorFunc {
- switch s {
- case api.Done:
- return SuccessColor
- case api.Warning:
- return WarningColor
- case api.Error:
- return ErrorColor
- default:
- return nocolor
- }
-}
-
-func numDone(tasks map[string]*task) int {
- i := 0
- for _, t := range tasks {
- if t.status != api.Working {
- i++
- }
- }
- return i
-}
-
-// lenAnsi count of user-perceived characters in ANSI string.
-func lenAnsi(s string) int {
- length := 0
- ansiCode := false
- for _, r := range s {
- if r == '\x1b' {
- ansiCode = true
- continue
- }
- if ansiCode && r == 'm' {
- ansiCode = false
- continue
- }
- if !ansiCode {
- length++
- }
- }
- return length
-}
-
-var percentChars = strings.Split("⠀⡀⣀⣄⣤⣦⣶⣷⣿", "")
diff --git a/cmd/display/tty_test.go b/cmd/display/tty_test.go
deleted file mode 100644
index c6d6165c31b..00000000000
--- a/cmd/display/tty_test.go
+++ /dev/null
@@ -1,670 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package display
-
-import (
- "bytes"
- "context"
- "fmt"
- "strings"
- "sync"
- "testing"
- "time"
- "unicode/utf8"
-
- "gotest.tools/v3/assert"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-func newTestWriter() (*ttyWriter, *bytes.Buffer) {
- var buf bytes.Buffer
- w := &ttyWriter{
- out: &buf,
- info: &buf,
- tasks: map[string]*task{},
- done: make(chan bool),
- mtx: &sync.Mutex{},
- operation: "pull",
- }
- return w, &buf
-}
-
-func addTask(w *ttyWriter, id, text, details string, status api.EventStatus) {
- t := &task{
- ID: id,
- parents: make(map[string]struct{}),
- startTime: time.Now(),
- text: text,
- details: details,
- status: status,
- spinner: NewSpinner(),
- }
- w.tasks[id] = t
- w.ids = append(w.ids, id)
-}
-
-// extractLines parses the output buffer and returns lines without ANSI control sequences
-func extractLines(buf *bytes.Buffer) []string {
- content := buf.String()
- // Split by newline
- rawLines := strings.Split(content, "\n")
- var lines []string
- for _, line := range rawLines {
- // Skip empty lines and lines that are just ANSI codes
- if lenAnsi(line) > 0 {
- lines = append(lines, line)
- }
- }
- return lines
-}
-
-func TestPrintWithDimensions_LinesFitTerminalWidth(t *testing.T) {
- testCases := []struct {
- name string
- taskID string
- status string
- details string
- terminalWidth int
- }{
- {
- name: "short task fits wide terminal",
- taskID: "Image foo",
- status: "Pulling",
- details: "layer abc123",
- terminalWidth: 100,
- },
- {
- name: "long details truncated to fit",
- taskID: "Image foo",
- status: "Pulling",
- details: "downloading layer sha256:abc123def456789xyz0123456789abcdef",
- terminalWidth: 50,
- },
- {
- name: "long taskID truncated to fit",
- taskID: "very-long-image-name-that-exceeds-terminal-width",
- status: "Pulling",
- details: "",
- terminalWidth: 40,
- },
- {
- name: "both long taskID and details",
- taskID: "my-very-long-service-name-here",
- status: "Downloading",
- details: "layer sha256:abc123def456789xyz0123456789",
- terminalWidth: 50,
- },
- {
- name: "narrow terminal",
- taskID: "service-name",
- status: "Pulling",
- details: "some details",
- terminalWidth: 35,
- },
- }
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- w, buf := newTestWriter()
- addTask(w, tc.taskID, tc.status, tc.details, api.Working)
-
- w.printWithDimensions(tc.terminalWidth, 24)
-
- lines := extractLines(buf)
- for i, line := range lines {
- lineLen := lenAnsi(line)
- assert.Assert(t, lineLen <= tc.terminalWidth,
- "line %d has length %d which exceeds terminal width %d: %q",
- i, lineLen, tc.terminalWidth, line)
- }
- })
- }
-}
-
-func TestPrintWithDimensions_MultipleTasksFitTerminalWidth(t *testing.T) {
- w, buf := newTestWriter()
-
- // Add multiple tasks with varying lengths
- addTask(w, "Image nginx", "Pulling", "layer sha256:abc123", api.Working)
- addTask(w, "Image postgres-database", "Pulling", "downloading", api.Working)
- addTask(w, "Image redis", "Pulled", "", api.Done)
-
- terminalWidth := 60
- w.printWithDimensions(terminalWidth, 24)
-
- lines := extractLines(buf)
- for i, line := range lines {
- lineLen := lenAnsi(line)
- assert.Assert(t, lineLen <= terminalWidth,
- "line %d has length %d which exceeds terminal width %d: %q",
- i, lineLen, terminalWidth, line)
- }
-}
-
-func TestPrintWithDimensions_VeryNarrowTerminal(t *testing.T) {
- w, buf := newTestWriter()
- addTask(w, "Image nginx", "Pulling", "details", api.Working)
-
- terminalWidth := 30
- w.printWithDimensions(terminalWidth, 24)
-
- lines := extractLines(buf)
- for i, line := range lines {
- lineLen := lenAnsi(line)
- assert.Assert(t, lineLen <= terminalWidth,
- "line %d has length %d which exceeds terminal width %d: %q",
- i, lineLen, terminalWidth, line)
- }
-}
-
-func TestPrintWithDimensions_TaskWithProgress(t *testing.T) {
- w, buf := newTestWriter()
-
- // Create parent task
- parent := &task{
- ID: "Image nginx",
- parents: make(map[string]struct{}),
- startTime: time.Now(),
- text: "Pulling",
- status: api.Working,
- spinner: NewSpinner(),
- }
- w.tasks["Image nginx"] = parent
- w.ids = append(w.ids, "Image nginx")
-
- // Create child tasks to trigger progress display
- for i := range 3 {
- child := &task{
- ID: "layer" + string(rune('a'+i)),
- parents: map[string]struct{}{"Image nginx": {}},
- startTime: time.Now(),
- text: "Downloading",
- status: api.Working,
- total: 1000,
- current: 500,
- percent: 50,
- spinner: NewSpinner(),
- }
- w.tasks[child.ID] = child
- w.ids = append(w.ids, child.ID)
- }
-
- terminalWidth := 80
- w.printWithDimensions(terminalWidth, 24)
-
- lines := extractLines(buf)
- for i, line := range lines {
- lineLen := lenAnsi(line)
- assert.Assert(t, lineLen <= terminalWidth,
- "line %d has length %d which exceeds terminal width %d: %q",
- i, lineLen, terminalWidth, line)
- }
-}
-
-func TestAdjustLineWidth_DetailsCorrectlyTruncated(t *testing.T) {
- w := &ttyWriter{}
- lines := []lineData{
- {
- taskID: "Image foo",
- status: "Pulling",
- details: "downloading layer sha256:abc123def456789xyz",
- },
- }
-
- terminalWidth := 50
- timerLen := 5
- w.adjustLineWidth(lines, timerLen, terminalWidth)
-
- // Verify the line fits
- detailsLen := len(lines[0].details)
- if detailsLen > 0 {
- detailsLen++ // space before details
- }
- // widthWithoutDetails = 5 + prefix(0) + taskID(9) + progress(0) + status(7) + timer(5) = 26
- lineWidth := 5 + len(lines[0].taskID) + len(lines[0].status) + detailsLen + timerLen
-
- assert.Assert(t, lineWidth <= terminalWidth,
- "line width %d should not exceed terminal width %d (taskID=%q, details=%q)",
- lineWidth, terminalWidth, lines[0].taskID, lines[0].details)
-
- // Verify details were truncated (not removed entirely)
- assert.Assert(t, lines[0].details != "", "details should be truncated, not removed")
- assert.Assert(t, strings.HasSuffix(lines[0].details, "..."), "truncated details should end with ...")
-}
-
-func TestAdjustLineWidth_TaskIDCorrectlyTruncated(t *testing.T) {
- w := &ttyWriter{}
- lines := []lineData{
- {
- taskID: "very-long-image-name-that-exceeds-minimum-length",
- status: "Pulling",
- details: "",
- },
- }
-
- terminalWidth := 40
- timerLen := 5
- w.adjustLineWidth(lines, timerLen, terminalWidth)
-
- lineWidth := 5 + len(lines[0].taskID) + 7 + timerLen
-
- assert.Assert(t, lineWidth <= terminalWidth,
- "line width %d should not exceed terminal width %d (taskID=%q)",
- lineWidth, terminalWidth, lines[0].taskID)
-
- assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."), "truncated taskID should end with ...")
-}
-
-// TestAdjustLineWidth_MultiByteTaskIDFits guards against drift between
-// applyPadding (rune-based) and maxBeforeStatusWidth (formerly byte-based):
-// a byte-based measurement falsely flags overflow for multi-byte taskIDs.
-func TestAdjustLineWidth_MultiByteTaskIDFits(t *testing.T) {
- w := &ttyWriter{}
- taskID := "Image 测试测试" // 10 runes, 18 bytes
- lines := []lineData{{
- taskID: taskID,
- status: "Pulling",
- }}
-
- // terminalWidth=30 fits in runes (3+10+1+7+1+4 = 26) but not in bytes
- // (3+18+1+7+1+4 = 34), so a byte-based measurement would truncate.
- w.adjustLineWidth(lines, 4, 30)
-
- assert.Equal(t, taskID, lines[0].taskID,
- "taskID should not be modified when it fits terminal width in runes")
-}
-
-// TestTruncateLongestTaskID_PreservesValidUTF8 verifies that when truncation
-// of a multi-byte UTF-8 taskID is genuinely required, the resulting string
-// remains valid UTF-8. Byte-indexed slicing can land mid-rune and emit
-// replacement characters (�) into the rendered output.
-func TestTruncateLongestTaskID_PreservesValidUTF8(t *testing.T) {
- taskID := "Image 测试测试测试测试" // 14 runes, 30 bytes
- lines := []lineData{{taskID: taskID}}
-
- truncateLongestTaskID(lines, 8, 10)
-
- assert.Assert(t, utf8.ValidString(lines[0].taskID),
- "truncated taskID must remain valid UTF-8, got %q", lines[0].taskID)
- assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."),
- "truncated taskID should end with ..., got %q", lines[0].taskID)
-}
-
-// TestTruncateProgressSize_PicksWidestLine verifies that dropping the size
-// suffix targets the line currently driving maxBeforeStatusWidth (the only
-// line whose shrink can reduce overflow), preserving size info on narrower
-// lines that are not the bottleneck.
-func TestTruncateProgressSize_PicksWidestLine(t *testing.T) {
- narrowSuffix := " 5MB / 10MB"
- wideSuffix := " 50MB / 100MB"
- lines := []lineData{
- {
- taskID: "Image short",
- progress: " [⣿⣿]" + narrowSuffix,
- progressSizeBytes: len(narrowSuffix),
- },
- {
- taskID: "Image very-long-named-task",
- progress: " [⣿⣿⣿⣿⣿⣿⣿⣿]" + wideSuffix,
- progressSizeBytes: len(wideSuffix),
- },
- }
-
- truncateProgressSize(lines)
-
- assert.Equal(t, 0, lines[1].progressSizeBytes,
- "widest line should lose its size suffix first")
- assert.Equal(t, len(narrowSuffix), lines[0].progressSizeBytes,
- "narrower line should retain its size suffix")
-}
-
-func TestAdjustLineWidth_NoTruncationNeeded(t *testing.T) {
- w := &ttyWriter{}
- originalDetails := "short"
- originalTaskID := "Image foo"
- lines := []lineData{
- {
- taskID: originalTaskID,
- status: "Pulling",
- details: originalDetails,
- },
- }
-
- // Wide terminal, nothing should be truncated
- w.adjustLineWidth(lines, 5, 100)
-
- assert.Equal(t, originalTaskID, lines[0].taskID, "taskID should not be modified")
- assert.Equal(t, originalDetails, lines[0].details, "details should not be modified")
-}
-
-func TestAdjustLineWidth_DetailsRemovedWhenTooShort(t *testing.T) {
- w := &ttyWriter{}
- lines := []lineData{
- {
- taskID: "Image foo",
- status: "Pulling",
- details: "abc", // Very short, can't be meaningfully truncated
- },
- }
-
- // Terminal so narrow that even minimal details + "..." wouldn't help
- w.adjustLineWidth(lines, 5, 28)
-
- assert.Equal(t, "", lines[0].details, "details should be removed entirely when too short to truncate")
-}
-
-// stripAnsi removes ANSI escape codes from a string
-func stripAnsi(s string) string {
- var result strings.Builder
- inAnsi := false
- for _, r := range s {
- if r == '\x1b' {
- inAnsi = true
- continue
- }
- if inAnsi {
- // ANSI sequences end with a letter (m, h, l, G, etc.)
- if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
- inAnsi = false
- }
- continue
- }
- result.WriteRune(r)
- }
- return result.String()
-}
-
-func TestPrintWithDimensions_PulledAndPullingWithLongIDs(t *testing.T) {
- w, buf := newTestWriter()
-
- // Add a completed task with long ID
- completedTask := &task{
- ID: "Image docker.io/library/nginx-long-name",
- parents: make(map[string]struct{}),
- startTime: time.Now().Add(-2 * time.Second),
- endTime: time.Now(),
- text: "Pulled",
- status: api.Done,
- spinner: NewSpinner(),
- }
- completedTask.spinner.Stop()
- w.tasks[completedTask.ID] = completedTask
- w.ids = append(w.ids, completedTask.ID)
-
- // Add a pending task with long ID
- pendingTask := &task{
- ID: "Image docker.io/library/postgres-database",
- parents: make(map[string]struct{}),
- startTime: time.Now(),
- text: "Pulling",
- status: api.Working,
- spinner: NewSpinner(),
- }
- w.tasks[pendingTask.ID] = pendingTask
- w.ids = append(w.ids, pendingTask.ID)
-
- terminalWidth := 50
- w.printWithDimensions(terminalWidth, 24)
-
- // Strip all ANSI codes from output and split by newline
- stripped := stripAnsi(buf.String())
- lines := strings.Split(stripped, "\n")
-
- // Filter non-empty lines
- var nonEmptyLines []string
- for _, line := range lines {
- if strings.TrimSpace(line) != "" {
- nonEmptyLines = append(nonEmptyLines, line)
- }
- }
-
- // Expected output format (50 runes per task line)
- expected := `[+] pull 1/2
- ✔ Image docker.io/library/nginx-l... Pulled 2.0s
- ⠋ Image docker.io/library/postgre... Pulling 0.0s`
-
- expectedLines := strings.Split(expected, "\n")
-
- // Debug output
- t.Logf("Actual output:\n")
- for i, line := range nonEmptyLines {
- t.Logf(" line %d (%2d runes): %q", i, utf8.RuneCountInString(line), line)
- }
-
- // Verify number of lines
- assert.Equal(t, len(expectedLines), len(nonEmptyLines), "number of lines should match")
-
- // Verify each line matches expected
- for i, line := range nonEmptyLines {
- if i < len(expectedLines) {
- assert.Equal(t, expectedLines[i], line,
- "line %d should match expected", i)
- }
- }
-
- // Verify task lines fit within terminal width (strict - no tolerance)
- for i, line := range nonEmptyLines {
- if i > 0 { // Skip header line
- runeCount := utf8.RuneCountInString(line)
- assert.Assert(t, runeCount <= terminalWidth,
- "line %d has %d runes which exceeds terminal width %d: %q",
- i, runeCount, terminalWidth, line)
- }
- }
-}
-
-func TestPrintWithDimensions_TimerIsRightAligned(t *testing.T) {
- w, buf := newTestWriter()
-
- base := time.Unix(0, 0)
-
- // Long timer: "10.6s" (length 5)
- longTask := &task{
- ID: "task-long",
- parents: make(map[string]struct{}),
- startTime: base,
- endTime: base.Add(10*time.Second + 600*time.Millisecond),
- text: "Pulled",
- status: api.Done,
- spinner: NewSpinner(),
- }
- longTask.spinner.Stop()
- w.tasks[longTask.ID] = longTask
- w.ids = append(w.ids, longTask.ID)
-
- // Short timer: "0.0s" (length 4)
- shortTask := &task{
- ID: "task-short",
- parents: make(map[string]struct{}),
- startTime: base,
- endTime: base,
- text: "Pulled",
- status: api.Done,
- spinner: NewSpinner(),
- }
- shortTask.spinner.Stop()
- w.tasks[shortTask.ID] = shortTask
- w.ids = append(w.ids, shortTask.ID)
-
- terminalWidth := 80
- w.printWithDimensions(terminalWidth, 24)
-
- // Strip ANSI codes from output and split by newline
- stripped := stripAnsi(buf.String())
- lines := strings.Split(stripped, "\n")
-
- var nonEmptyLines []string
- for _, line := range lines {
- if strings.TrimSpace(line) != "" {
- nonEmptyLines = append(nonEmptyLines, line)
- }
- }
-
- // Find the line containing the shorter timer.
- var shortLine string
- for _, line := range nonEmptyLines {
- if strings.Contains(line, "0.0s") {
- shortLine = line
- break
- }
- }
- assert.Assert(t, shortLine != "", "expected to find a rendered line containing \"0.0s\"")
- assert.Assert(t, strings.HasSuffix(shortLine, "0.0s"),
- "short timer should be left-padded (no trailing spaces after the timer); got: %q",
- shortLine)
-}
-
-func TestLenAnsi(t *testing.T) {
- testCases := []struct {
- input string
- expected int
- }{
- {"hello", 5},
- {"\x1b[32mhello\x1b[0m", 5},
- {"\x1b[1;32mgreen\x1b[0m text", 10},
- {"", 0},
- {"\x1b[0m", 0},
- }
-
- for _, tc := range testCases {
- t.Run(tc.input, func(t *testing.T) {
- result := lenAnsi(tc.input)
- assert.Equal(t, tc.expected, result)
- })
- }
-}
-
-func TestDoneDeadlockFix(t *testing.T) {
- w, _ := newTestWriter()
- addTask(w, "test-task", "Working", "details", api.Working)
- ctx, cancel := context.WithCancel(t.Context())
- defer cancel()
-
- w.Start(ctx, "test")
- done := make(chan bool)
- go func() {
- w.Done("test", true)
- done <- true
- }()
-
- select {
- case <-done:
- case <-time.After(5 * time.Second):
- t.Fatal("Deadlock detected: Done() did not complete within 5 seconds")
- }
-}
-
-// TestAdjustLineWidth_WideProgressForcesSizeInfoDrop is the unit-level
-// regression test for docker/compose#13595. When progress contains the
-// " X.XMB / Y.YMB" size suffix and the bar makes beforeStatus large enough
-// to overflow terminalWidth, taskID truncation alone cannot make the line
-// fit: applyPadding's max(timerPad, 1) floor adds one char back, and the
-// "..."-padding minimum (10 chars) on taskID puts a lower bound on
-// beforeStatus. The size info portion of progress must therefore be
-// droppable when overflow can't be eliminated otherwise.
-func TestAdjustLineWidth_WideProgressForcesSizeInfoDrop(t *testing.T) {
- w := &ttyWriter{}
- // Mirror prepareLineData's layout: " [bar]" + " %7s / %-7s".
- sizeSuffix := " 50MB / 100MB "
- progress := " [" + strings.Repeat("⣿", 30) + "]" + sizeSuffix
- lines := []lineData{{
- taskID: "Image mariadb:11",
- progress: progress,
- progressSizeBytes: len(sizeSuffix),
- status: "Pulling",
- statusColor: nocolor,
- spinner: " ",
- timer: "5.4s",
- }}
-
- terminalWidth := 60
- timerLen := 4
- w.adjustLineWidth(lines, timerLen, terminalWidth)
- w.applyPadding(lines, terminalWidth, timerLen)
-
- rendered := strings.TrimRight(lineText(lines[0]), "\n")
- assert.Assert(t, lenAnsi(rendered) <= terminalWidth,
- "line length %d should not exceed terminal width %d: %q",
- lenAnsi(rendered), terminalWidth, rendered)
-}
-
-// addParentWithDownloadingChildren wires a parent task with N children whose
-// non-zero totals trigger the " X.XMB / Y.YMB" suffix in prepareLineData's
-// progress field. Used by the multi-render regression test below.
-func addParentWithDownloadingChildren(w *ttyWriter, parentID string, children int, totalBytes int64) {
- parent := &task{
- ID: parentID,
- parents: make(map[string]struct{}),
- startTime: time.Now(),
- text: "Pulling",
- status: api.Working,
- spinner: NewSpinner(),
- }
- w.tasks[parent.ID] = parent
- w.ids = append(w.ids, parent.ID)
- for i := range children {
- c := &task{
- ID: fmt.Sprintf("%s/layer%d", parentID, i),
- parents: map[string]struct{}{parent.ID: {}},
- startTime: time.Now(),
- text: "Downloading",
- status: api.Working,
- total: totalBytes / int64(children),
- current: totalBytes / int64(children) / 2,
- percent: 50,
- spinner: NewSpinner(),
- }
- w.tasks[c.ID] = c
- w.ids = append(w.ids, c.ID)
- }
-}
-
-// TestPrintWithDimensions_MultipleRendersFit verifies the cross-render aspect
-// of docker/compose#13595: even a single overflowing line desyncs the cursor
-// on the following tick because aec.Up(numLines) counts logical lines while
-// the terminal wraps visual lines. Use many concurrent parent tasks with
-// wide progress bars in a narrow terminal so adjustLineWidth's truncation
-// loop can't bring every line under terminalWidth without dropping size
-// info from progress.
-func TestPrintWithDimensions_MultipleRendersFit(t *testing.T) {
- w, buf := newTestWriter()
- // Two parents so the truncation loop must walk multiple lines; 30 children
- // per parent makes each progress bar wide enough that taskID truncation
- // alone can't bring the line under terminalWidth.
- for i := range 2 {
- addParentWithDownloadingChildren(w,
- "Image very-long-name-image-"+string(rune('a'+i))+":v1.2.3",
- 30, 100_000_000)
- }
-
- terminalWidth := 60
- for tick := range 10 {
- for _, t := range w.tasks {
- if t.status == api.Working && t.total > 0 {
- t.current = min(t.current+t.total/10, t.total)
- }
- }
- buf.Reset()
- w.printWithDimensions(terminalWidth, 24)
- for i, line := range extractLines(buf) {
- assert.Assert(t, lenAnsi(line) <= terminalWidth,
- "tick %d line %d has length %d > terminalWidth %d: %q",
- tick, i, lenAnsi(line), terminalWidth, line)
- }
- }
-}
diff --git a/cmd/formatter/ansi.go b/cmd/formatter/ansi.go
deleted file mode 100644
index 48ec433f68c..00000000000
--- a/cmd/formatter/ansi.go
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- Copyright 2024 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-import (
- "fmt"
-
- "github.com/acarl005/stripansi"
- "github.com/morikuni/aec"
-)
-
-var disableAnsi bool
-
-func saveCursor() {
- if disableAnsi {
- return
- }
- // see https://github.com/morikuni/aec/pull/5
- fmt.Print(aec.Save)
-}
-
-func restoreCursor() {
- if disableAnsi {
- return
- }
- // see https://github.com/morikuni/aec/pull/5
- fmt.Print(aec.Restore)
-}
-
-func showCursor() {
- if disableAnsi {
- return
- }
- fmt.Print(aec.Show)
-}
-
-func moveCursor(y, x int) {
- if disableAnsi {
- return
- }
- fmt.Print(aec.Position(uint(y), uint(x)))
-}
-
-func carriageReturn() {
- if disableAnsi {
- return
- }
- fmt.Print(aec.Column(0))
-}
-
-func clearLine() {
- if disableAnsi {
- return
- }
- // Does not move cursor from its current position
- fmt.Print(aec.EraseLine(aec.EraseModes.Tail))
-}
-
-func moveCursorUp(lines int) {
- if disableAnsi {
- return
- }
- // Does not add new lines
- fmt.Print(aec.Up(uint(lines)))
-}
-
-func moveCursorDown(lines int) {
- if disableAnsi {
- return
- }
- // Does not add new lines
- fmt.Print(aec.Down(uint(lines)))
-}
-
-func newLine() {
- fmt.Print("\012")
-}
-
-// lenAnsi returns the visible length of s after stripping ANSI escape codes.
-func lenAnsi(s string) int {
- return len(stripansi.Strip(s))
-}
-
-// OSC8Link wraps text in an OSC 8 terminal hyperlink escape sequence with
-// underline styling, making it clickable in supported terminal emulators.
-// When ANSI output is disabled, returns the plain text without escape sequences.
-func OSC8Link(url, text string) string {
- if disableAnsi {
- return text
- }
- return "\033]8;;" + url + "\033\\\033[4m" + text + "\033[24m\033]8;;\033\\"
-}
diff --git a/cmd/formatter/ansi_test.go b/cmd/formatter/ansi_test.go
deleted file mode 100644
index 7ef668b546a..00000000000
--- a/cmd/formatter/ansi_test.go
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- Copyright 2024 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-import (
- "testing"
-
- "gotest.tools/v3/assert"
-)
-
-func TestOSC8Link(t *testing.T) {
- disableAnsi = false
- t.Cleanup(func() { disableAnsi = false })
-
- got := OSC8Link("http://example.com", "click here")
- want := "\x1b]8;;http://example.com\x1b\\\x1b[4mclick here\x1b[24m\x1b]8;;\x1b\\"
- assert.Equal(t, got, want)
-}
-
-func TestOSC8Link_AnsiDisabled(t *testing.T) {
- disableAnsi = true
- t.Cleanup(func() { disableAnsi = false })
-
- got := OSC8Link("http://example.com", "click here")
- assert.Equal(t, got, "click here")
-}
-
-func TestOSC8Link_URLAsDisplayText(t *testing.T) {
- disableAnsi = false
- t.Cleanup(func() { disableAnsi = false })
-
- url := "docker-desktop://dashboard/logs"
- got := OSC8Link(url, url)
- want := "\x1b]8;;docker-desktop://dashboard/logs\x1b\\\x1b[4mdocker-desktop://dashboard/logs\x1b[24m\x1b]8;;\x1b\\"
- assert.Equal(t, got, want)
-}
diff --git a/cmd/formatter/colors.go b/cmd/formatter/colors.go
deleted file mode 100644
index ea0e1a26362..00000000000
--- a/cmd/formatter/colors.go
+++ /dev/null
@@ -1,148 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-import (
- "fmt"
- "strconv"
- "strings"
- "sync"
-
- "github.com/docker/cli/cli/command"
-)
-
-var names = []string{
- "grey",
- "red",
- "green",
- "yellow",
- "blue",
- "magenta",
- "cyan",
- "white",
-}
-
-const (
- BOLD = "1"
- FAINT = "2"
- ITALIC = "3"
- UNDERLINE = "4"
-)
-
-const (
- RESET = "0"
- CYAN = "36"
-)
-
-const (
- // Never use ANSI codes
- Never = "never"
-
- // Always use ANSI codes
- Always = "always"
-
- // Auto detect terminal is a tty and can use ANSI codes
- Auto = "auto"
-)
-
-// ansiColorOffset is the offset for basic foreground colors in ANSI escape codes.
-const ansiColorOffset = 30
-
-// SetANSIMode configure formatter for colored output on ANSI-compliant console
-func SetANSIMode(streams command.Streams, ansi string) {
- if !useAnsi(streams, ansi) {
- nextColor = func() colorFunc {
- return monochrome
- }
- disableAnsi = true
- }
-}
-
-func useAnsi(streams command.Streams, ansi string) bool {
- switch ansi {
- case Always:
- return true
- case Auto:
- return streams.Out().IsTerminal()
- }
- return false
-}
-
-// colorFunc use ANSI codes to render colored text on console
-type colorFunc func(s string) string
-
-var monochrome = func(s string) string {
- return s
-}
-
-func ansiColor(code, s string, formatOpts ...string) string {
- return fmt.Sprintf("%s%s%s", ansiColorCode(code, formatOpts...), s, ansiColorCode("0"))
-}
-
-// Everything about ansiColorCode color https://hyperskill.org/learn/step/18193
-func ansiColorCode(code string, formatOpts ...string) string {
- var sb strings.Builder
- sb.WriteString("\033[")
- for _, c := range formatOpts {
- sb.WriteString(c)
- sb.WriteString(";")
- }
- sb.WriteString(code)
- sb.WriteString("m")
- return sb.String()
-}
-
-func makeColorFunc(code string) colorFunc {
- return func(s string) string {
- return ansiColor(code, s)
- }
-}
-
-var (
- nextColor = rainbowColor
- rainbow []colorFunc
- currentIndex = 0
- mutex sync.Mutex
-)
-
-func rainbowColor() colorFunc {
- mutex.Lock()
- defer mutex.Unlock()
- result := rainbow[currentIndex]
- currentIndex = (currentIndex + 1) % len(rainbow)
- return result
-}
-
-func init() {
- colors := map[string]colorFunc{}
- for i, name := range names {
- colors[name] = makeColorFunc(strconv.Itoa(ansiColorOffset + i))
- colors["intense_"+name] = makeColorFunc(strconv.Itoa(ansiColorOffset+i) + ";1")
- }
- rainbow = []colorFunc{
- colors["cyan"],
- colors["yellow"],
- colors["green"],
- colors["magenta"],
- colors["blue"],
- colors["intense_cyan"],
- colors["intense_yellow"],
- colors["intense_green"],
- colors["intense_magenta"],
- colors["intense_blue"],
- }
-}
diff --git a/cmd/formatter/consts.go b/cmd/formatter/consts.go
deleted file mode 100644
index 0ae447c432f..00000000000
--- a/cmd/formatter/consts.go
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-const (
- // JSON Print in JSON format
- JSON = "json"
- // TemplateLegacyJSON the legacy json formatting value using go template
- TemplateLegacyJSON = "{{json.}}"
- // PRETTY is the constant for default formats on list commands
- //
- // Deprecated: use TABLE
- PRETTY = "pretty"
- // TABLE Print output in table format with column headers (default)
- TABLE = "table"
-)
diff --git a/cmd/formatter/container.go b/cmd/formatter/container.go
deleted file mode 100644
index 546def442bb..00000000000
--- a/cmd/formatter/container.go
+++ /dev/null
@@ -1,295 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-import (
- "fmt"
- "net/netip"
- "strconv"
- "strings"
- "time"
-
- "github.com/docker/cli/cli/command/formatter"
- "github.com/docker/go-units"
- "github.com/moby/moby/api/types/container"
- "github.com/moby/moby/client/pkg/stringid"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-const (
- defaultContainerTableFormat = "table {{.Name}}\t{{.Image}}\t{{.Command}}\t{{.Service}}\t{{.RunningFor}}\t{{.Status}}\t{{.Ports}}"
-
- nameHeader = "NAME"
- projectHeader = "PROJECT"
- serviceHeader = "SERVICE"
- commandHeader = "COMMAND"
- runningForHeader = "CREATED"
- mountsHeader = "MOUNTS"
- localVolumes = "LOCAL VOLUMES"
- networksHeader = "NETWORKS"
-)
-
-// NewContainerFormat returns a Format for rendering using a Context
-func NewContainerFormat(source string, quiet bool, size bool) formatter.Format {
- switch source {
- case formatter.TableFormatKey, "": // table formatting is the default if none is set.
- if quiet {
- return formatter.DefaultQuietFormat
- }
- format := defaultContainerTableFormat
- if size {
- format += `\t{{.Size}}`
- }
- return formatter.Format(format)
- case formatter.RawFormatKey:
- if quiet {
- return `container_id: {{.ID}}`
- }
- format := `container_id: {{.ID}}
-image: {{.Image}}
-command: {{.Command}}
-created_at: {{.CreatedAt}}
-state: {{- pad .State 1 0}}
-status: {{- pad .Status 1 0}}
-names: {{.Names}}
-labels: {{- pad .Labels 1 0}}
-ports: {{- pad .Ports 1 0}}
-`
- if size {
- format += `size: {{.Size}}\n`
- }
- return formatter.Format(format)
- default: // custom format
- if quiet {
- return formatter.DefaultQuietFormat
- }
- return formatter.Format(source)
- }
-}
-
-// ContainerWrite renders the context for a list of containers
-func ContainerWrite(ctx formatter.Context, containers []api.ContainerSummary) error {
- render := func(format func(subContext formatter.SubContext) error) error {
- for _, container := range containers {
- err := format(&ContainerContext{trunc: ctx.Trunc, c: container})
- if err != nil {
- return err
- }
- }
- return nil
- }
- return ctx.Write(NewContainerContext(), render)
-}
-
-// ContainerContext is a struct used for rendering a list of containers in a Go template.
-type ContainerContext struct {
- formatter.HeaderContext
- trunc bool
- c api.ContainerSummary
-
- // FieldsUsed is used in the pre-processing step to detect which fields are
- // used in the template. It's currently only used to detect use of the .Size
- // field which (if used) automatically sets the '--size' option when making
- // the API call.
- FieldsUsed map[string]any
-}
-
-// NewContainerContext creates a new context for rendering containers
-func NewContainerContext() *ContainerContext {
- containerCtx := ContainerContext{}
- containerCtx.Header = formatter.SubHeaderContext{
- "ID": formatter.ContainerIDHeader,
- "Name": nameHeader,
- "Project": projectHeader,
- "Service": serviceHeader,
- "Image": formatter.ImageHeader,
- "Command": commandHeader,
- "CreatedAt": formatter.CreatedAtHeader,
- "RunningFor": runningForHeader,
- "Ports": formatter.PortsHeader,
- "State": formatter.StateHeader,
- "Status": formatter.StatusHeader,
- "Size": formatter.SizeHeader,
- "Labels": formatter.LabelsHeader,
- }
- return &containerCtx
-}
-
-// MarshalJSON makes ContainerContext implement json.Marshaler
-func (c *ContainerContext) MarshalJSON() ([]byte, error) {
- return formatter.MarshalJSON(c)
-}
-
-// ID returns the container's ID as a string. Depending on the `--no-trunc`
-// option being set, the full or truncated ID is returned.
-func (c *ContainerContext) ID() string {
- if c.trunc {
- return stringid.TruncateID(c.c.ID)
- }
- return c.c.ID
-}
-
-func (c *ContainerContext) Name() string {
- return c.c.Name
-}
-
-// Names returns a comma-separated string of the container's names, with their
-// slash (/) prefix stripped. Additional names for the container (related to the
-// legacy `--link` feature) are omitted.
-func (c *ContainerContext) Names() string {
- names := formatter.StripNamePrefix(c.c.Names)
- if c.trunc {
- for _, name := range names {
- if len(strings.Split(name, "/")) == 1 {
- names = []string{name}
- break
- }
- }
- }
- return strings.Join(names, ",")
-}
-
-func (c *ContainerContext) Service() string {
- return c.c.Service
-}
-
-func (c *ContainerContext) Project() string {
- return c.c.Project
-}
-
-func (c *ContainerContext) Image() string {
- return c.c.Image
-}
-
-func (c *ContainerContext) Command() string {
- command := c.c.Command
- if c.trunc {
- command = formatter.Ellipsis(command, 20)
- }
- return strconv.Quote(command)
-}
-
-func (c *ContainerContext) CreatedAt() string {
- return time.Unix(c.c.Created, 0).String()
-}
-
-func (c *ContainerContext) RunningFor() string {
- createdAt := time.Unix(c.c.Created, 0)
- return units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
-}
-
-func (c *ContainerContext) ExitCode() int {
- return c.c.ExitCode
-}
-
-func (c *ContainerContext) State() string {
- return string(c.c.State)
-}
-
-func (c *ContainerContext) Status() string {
- return c.c.Status
-}
-
-func (c *ContainerContext) Health() string {
- return string(c.c.Health)
-}
-
-func (c *ContainerContext) Publishers() api.PortPublishers {
- return c.c.Publishers
-}
-
-func (c *ContainerContext) Ports() string {
- var ports []container.PortSummary
- for _, publisher := range c.c.Publishers {
- var pIP netip.Addr
- if publisher.URL != "" {
- if p, err := netip.ParseAddr(publisher.URL); err == nil {
- pIP = p
- }
- }
- ports = append(ports, container.PortSummary{
- IP: pIP,
- PrivatePort: uint16(publisher.TargetPort),
- PublicPort: uint16(publisher.PublishedPort),
- Type: publisher.Protocol,
- })
- }
- return formatter.DisplayablePorts(ports)
-}
-
-// Labels returns a comma-separated string of labels present on the container.
-func (c *ContainerContext) Labels() string {
- if c.c.Labels == nil {
- return ""
- }
-
- var joinLabels []string
- for k, v := range c.c.Labels {
- joinLabels = append(joinLabels, fmt.Sprintf("%s=%s", k, v))
- }
- return strings.Join(joinLabels, ",")
-}
-
-// Label returns the value of the label with the given name or an empty string
-// if the given label does not exist.
-func (c *ContainerContext) Label(name string) string {
- if c.c.Labels == nil {
- return ""
- }
- return c.c.Labels[name]
-}
-
-// Mounts returns a comma-separated string of mount names present on the container.
-// If the trunc option is set, names can be truncated (ellipsized).
-func (c *ContainerContext) Mounts() string {
- var mounts []string
- for _, name := range c.c.Mounts {
- if c.trunc {
- name = formatter.Ellipsis(name, 15)
- }
- mounts = append(mounts, name)
- }
- return strings.Join(mounts, ",")
-}
-
-// LocalVolumes returns the number of volumes using the "local" volume driver.
-func (c *ContainerContext) LocalVolumes() string {
- return fmt.Sprintf("%d", c.c.LocalVolumes)
-}
-
-// Networks returns a comma-separated string of networks that the container is
-// attached to.
-func (c *ContainerContext) Networks() string {
- return strings.Join(c.c.Networks, ",")
-}
-
-// Size returns the container's size and virtual size (e.g. "2B (virtual 21.5MB)")
-func (c *ContainerContext) Size() string {
- if c.FieldsUsed == nil {
- c.FieldsUsed = map[string]any{}
- }
- c.FieldsUsed["Size"] = struct{}{}
- srw := units.HumanSizeWithPrecision(float64(c.c.SizeRw), 3)
- sv := units.HumanSizeWithPrecision(float64(c.c.SizeRootFs), 3)
-
- sf := srw
- if c.c.SizeRootFs > 0 {
- sf = fmt.Sprintf("%s (virtual %s)", srw, sv)
- }
- return sf
-}
diff --git a/cmd/formatter/formatter.go b/cmd/formatter/formatter.go
deleted file mode 100644
index 6af92c478db..00000000000
--- a/cmd/formatter/formatter.go
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-import (
- "fmt"
- "io"
- "reflect"
- "strings"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-// Print prints formatted lists in different formats
-func Print(toJSON any, format string, outWriter io.Writer, writerFn func(w io.Writer), headers ...string) error {
- switch strings.ToLower(format) {
- case TABLE, PRETTY, "":
- return PrintPrettySection(outWriter, writerFn, headers...)
- case TemplateLegacyJSON:
- switch reflect.TypeOf(toJSON).Kind() {
- case reflect.Slice:
- s := reflect.ValueOf(toJSON)
- for i := 0; i < s.Len(); i++ {
- obj := s.Index(i).Interface()
- outJSON, err := ToJSON(obj, "", "")
- if err != nil {
- return err
- }
- _, _ = fmt.Fprint(outWriter, outJSON)
- }
- default:
- outJSON, err := ToStandardJSON(toJSON)
- if err != nil {
- return err
- }
- _, _ = fmt.Fprintln(outWriter, outJSON)
- }
- case JSON:
- switch reflect.TypeOf(toJSON).Kind() {
- case reflect.Slice:
- outJSON, err := ToJSON(toJSON, "", "")
- if err != nil {
- return err
- }
- _, _ = fmt.Fprint(outWriter, outJSON)
- default:
- outJSON, err := ToStandardJSON(toJSON)
- if err != nil {
- return err
- }
- _, _ = fmt.Fprintln(outWriter, outJSON)
- }
- default:
- return fmt.Errorf("format value %q could not be parsed: %w", format, api.ErrParsingFailed)
- }
- return nil
-}
diff --git a/cmd/formatter/formatter_test.go b/cmd/formatter/formatter_test.go
deleted file mode 100644
index 07b152559c1..00000000000
--- a/cmd/formatter/formatter_test.go
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-import (
- "bytes"
- "fmt"
- "io"
- "testing"
-
- "go.uber.org/goleak"
- "gotest.tools/v3/assert"
-)
-
-type testStruct struct {
- Name string
- Status string
-}
-
-// Print prints formatted lists in different formats
-func TestPrint(t *testing.T) {
- testList := []testStruct{
- {
- Name: "myName1",
- Status: "myStatus1",
- },
- {
- Name: "myName2",
- Status: "myStatus2",
- },
- }
-
- b := &bytes.Buffer{}
- assert.NilError(t, Print(testList, TABLE, b, func(w io.Writer) {
- for _, t := range testList {
- _, _ = fmt.Fprintf(w, "%s\t%s\n", t.Name, t.Status)
- }
- }, "NAME", "STATUS"))
- assert.Equal(t, b.String(), "NAME STATUS\nmyName1 myStatus1\nmyName2 myStatus2\n")
-
- b.Reset()
- assert.NilError(t, Print(testList, JSON, b, func(w io.Writer) {
- for _, t := range testList {
- _, _ = fmt.Fprintf(w, "%s\t%s\n", t.Name, t.Status)
- }
- }, "NAME", "STATUS"))
- assert.Equal(t, b.String(), `[{"Name":"myName1","Status":"myStatus1"},{"Name":"myName2","Status":"myStatus2"}]
-`)
-
- b.Reset()
- assert.NilError(t, Print(testList, TemplateLegacyJSON, b, func(w io.Writer) {
- for _, t := range testList {
- _, _ = fmt.Fprintf(w, "%s\t%s\n", t.Name, t.Status)
- }
- }, "NAME", "STATUS"))
- json := b.String()
- assert.Equal(t, json, `{"Name":"myName1","Status":"myStatus1"}
-{"Name":"myName2","Status":"myStatus2"}
-`)
-}
-
-func TestColorsGoroutinesLeak(t *testing.T) {
- goleak.VerifyNone(t)
-}
diff --git a/cmd/formatter/json.go b/cmd/formatter/json.go
deleted file mode 100644
index b09e721aa27..00000000000
--- a/cmd/formatter/json.go
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-import (
- "bytes"
- "encoding/json"
-)
-
-const standardIndentation = " "
-
-// ToStandardJSON return a string with the JSON representation of the interface{}
-func ToStandardJSON(i any) (string, error) {
- return ToJSON(i, "", standardIndentation)
-}
-
-// ToJSON return a string with the JSON representation of the interface{}
-func ToJSON(i any, prefix string, indentation string) (string, error) {
- buffer := &bytes.Buffer{}
- encoder := json.NewEncoder(buffer)
- encoder.SetEscapeHTML(false)
- encoder.SetIndent(prefix, indentation)
- err := encoder.Encode(i)
- return buffer.String(), err
-}
diff --git a/cmd/formatter/logs.go b/cmd/formatter/logs.go
deleted file mode 100644
index e0799a3fbde..00000000000
--- a/cmd/formatter/logs.go
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-import (
- "context"
- "fmt"
- "io"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "github.com/buger/goterm"
- "github.com/moby/moby/client/pkg/jsonmessage"
-
- "github.com/docker/compose/v5/pkg/api"
-)
-
-// LogConsumer consume logs from services and format them
-type logConsumer struct {
- ctx context.Context
- presenters sync.Map // map[string]*presenter
- width int
- stdout io.Writer
- stderr io.Writer
- color bool
- prefix bool
- timestamp bool
-}
-
-// NewLogConsumer creates a new LogConsumer
-func NewLogConsumer(ctx context.Context, stdout, stderr io.Writer, color, prefix, timestamp bool) api.LogConsumer {
- return &logConsumer{
- ctx: ctx,
- presenters: sync.Map{},
- width: 0,
- stdout: stdout,
- stderr: stderr,
- color: color,
- prefix: prefix,
- timestamp: timestamp,
- }
-}
-
-func (l *logConsumer) register(name string) *presenter {
- var p *presenter
- root, _, found := strings.Cut(name, " ")
- if found {
- parent := l.getPresenter(root)
- p = &presenter{
- colors: parent.colors,
- name: name,
- prefix: parent.prefix,
- }
- } else {
- cf := monochrome
- if l.color {
- switch name {
- case "":
- cf = monochrome
- case api.WatchLogger:
- cf = makeColorFunc("92")
- default:
- cf = nextColor()
- }
- }
- p = &presenter{
- colors: cf,
- name: name,
- }
- }
- l.presenters.Store(name, p)
- l.computeWidth()
- if l.prefix {
- l.presenters.Range(func(key, value any) bool {
- p := value.(*presenter)
- p.setPrefix(l.width)
- return true
- })
- }
- return p
-}
-
-func (l *logConsumer) getPresenter(container string) *presenter {
- p, ok := l.presenters.Load(container)
- if !ok { // should have been registered, but ¯\_(ツ)_/¯
- return l.register(container)
- }
- return p.(*presenter)
-}
-
-// Log formats a log message as received from name/container
-func (l *logConsumer) Log(container, message string) {
- l.write(l.stdout, container, message)
-}
-
-// Err formats a log message as received from name/container
-func (l *logConsumer) Err(container, message string) {
- l.write(l.stderr, container, message)
-}
-
-func (l *logConsumer) write(w io.Writer, container, message string) {
- if l.ctx.Err() != nil {
- return
- }
- p := l.getPresenter(container)
- timestamp := time.Now().Format(jsonmessage.RFC3339NanoFixed)
- for line := range strings.SplitSeq(message, "\n") {
- if l.timestamp {
- _, _ = fmt.Fprintf(w, "%s%s %s\n", p.prefix, timestamp, line)
- } else {
- _, _ = fmt.Fprintf(w, "%s%s\n", p.prefix, line)
- }
- }
-}
-
-func (l *logConsumer) Status(container, msg string) {
- p := l.getPresenter(container)
- s := p.colors(fmt.Sprintf("%s%s %s\n", goterm.RESET_LINE, container, msg))
- l.stdout.Write([]byte(s)) //nolint:errcheck
-}
-
-func (l *logConsumer) computeWidth() {
- width := 0
- l.presenters.Range(func(key, value any) bool {
- p := value.(*presenter)
- if len(p.name) > width {
- width = len(p.name)
- }
- return true
- })
- l.width = width + 1
-}
-
-type presenter struct {
- colors colorFunc
- name string
- prefix string
-}
-
-func (p *presenter) setPrefix(width int) {
- if p.name == api.WatchLogger {
- p.prefix = p.colors(strings.Repeat(" ", width) + " ⦿ ")
- return
- }
- p.prefix = p.colors(fmt.Sprintf("%-"+strconv.Itoa(width)+"s | ", p.name))
-}
-
-type logDecorator struct {
- decorated api.LogConsumer
- Before func()
- After func()
-}
-
-func (l logDecorator) Log(containerName, message string) {
- l.Before()
- l.decorated.Log(containerName, message)
- l.After()
-}
-
-func (l logDecorator) Err(containerName, message string) {
- l.Before()
- l.decorated.Err(containerName, message)
- l.After()
-}
-
-func (l logDecorator) Status(container, msg string) {
- l.Before()
- l.decorated.Status(container, msg)
- l.After()
-}
diff --git a/cmd/formatter/pretty.go b/cmd/formatter/pretty.go
deleted file mode 100644
index bb85dedef61..00000000000
--- a/cmd/formatter/pretty.go
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-import (
- "fmt"
- "io"
- "strings"
- "text/tabwriter"
-)
-
-// PrintPrettySection prints a tabbed section on the writer parameter
-func PrintPrettySection(out io.Writer, printer func(writer io.Writer), headers ...string) error {
- w := tabwriter.NewWriter(out, 20, 1, 3, ' ', 0)
- _, _ = fmt.Fprintln(w, strings.Join(headers, "\t"))
- printer(w)
- return w.Flush()
-}
diff --git a/cmd/formatter/shortcut.go b/cmd/formatter/shortcut.go
deleted file mode 100644
index ffa083e2f84..00000000000
--- a/cmd/formatter/shortcut.go
+++ /dev/null
@@ -1,392 +0,0 @@
-/*
- Copyright 2024 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-import (
- "context"
- "errors"
- "fmt"
- "math"
- "os"
- "strings"
- "syscall"
- "time"
-
- "github.com/buger/goterm"
- "github.com/compose-spec/compose-go/v2/types"
- "github.com/eiannone/keyboard"
- "github.com/skratchdot/open-golang/open"
-
- "github.com/docker/compose/v5/internal/desktop"
- "github.com/docker/compose/v5/internal/tracing"
- "github.com/docker/compose/v5/pkg/api"
-)
-
-const DISPLAY_ERROR_TIME = 10
-
-type KeyboardError struct {
- err error
- timeStart time.Time
-}
-
-func (ke *KeyboardError) shouldDisplay() bool {
- return ke.err != nil && int(time.Since(ke.timeStart).Seconds()) < DISPLAY_ERROR_TIME
-}
-
-func (ke *KeyboardError) printError(height int, info string) {
- if ke.shouldDisplay() {
- errMessage := ke.err.Error()
-
- moveCursor(height-1-extraLines(info)-extraLines(errMessage), 0)
- clearLine()
-
- fmt.Print(errMessage)
- }
-}
-
-func (ke *KeyboardError) addError(prefix string, err error) {
- ke.timeStart = time.Now()
-
- prefix = ansiColor(CYAN, fmt.Sprintf("%s →", prefix), BOLD)
- errorString := fmt.Sprintf("%s %s", prefix, err.Error())
-
- ke.err = errors.New(errorString)
-}
-
-func (ke *KeyboardError) error() string {
- return ke.err.Error()
-}
-
-type KeyboardWatch struct {
- Watching bool
- Watcher Feature
-}
-
-// Feature is an compose feature that can be started/stopped by a menu command
-type Feature interface {
- Start(context.Context) error
- Stop() error
-}
-
-type KEYBOARD_LOG_LEVEL int
-
-const (
- NONE KEYBOARD_LOG_LEVEL = 0
- INFO KEYBOARD_LOG_LEVEL = 1
- DEBUG KEYBOARD_LOG_LEVEL = 2
-)
-
-type LogKeyboard struct {
- kError KeyboardError
- Watch *KeyboardWatch
- Detach func()
- IsDockerDesktopActive bool
- IsLogsViewEnabled bool
- logLevel KEYBOARD_LOG_LEVEL
- signalChannel chan<- os.Signal
-}
-
-func NewKeyboardManager(isDockerDesktopActive, isLogsViewEnabled bool, sc chan<- os.Signal) *LogKeyboard {
- return &LogKeyboard{
- IsDockerDesktopActive: isDockerDesktopActive,
- IsLogsViewEnabled: isLogsViewEnabled,
- logLevel: INFO,
- signalChannel: sc,
- }
-}
-
-func (lk *LogKeyboard) Decorate(l api.LogConsumer) api.LogConsumer {
- return logDecorator{
- decorated: l,
- Before: lk.clearNavigationMenu,
- After: lk.PrintKeyboardInfo,
- }
-}
-
-func (lk *LogKeyboard) PrintKeyboardInfo() {
- if lk.logLevel == INFO {
- lk.printNavigationMenu()
- }
-}
-
-// Creates space to print error and menu string
-func (lk *LogKeyboard) createBuffer(lines int) {
- if lk.kError.shouldDisplay() {
- extraLines := extraLines(lk.kError.error()) + 1
- lines += extraLines
- }
-
- // get the string
- infoMessage := lk.navigationMenu()
- // calculate how many lines we need to display the menu info
- // might be needed a line break
- extraLines := extraLines(infoMessage) + 1
- lines += extraLines
-
- if lines > 0 {
- allocateSpace(lines)
- moveCursorUp(lines)
- }
-}
-
-func (lk *LogKeyboard) printNavigationMenu() {
- offset := 1
- lk.clearNavigationMenu()
- lk.createBuffer(offset)
-
- if lk.logLevel == INFO {
- height := goterm.Height()
- menu := lk.navigationMenu()
-
- carriageReturn()
- saveCursor()
-
- lk.kError.printError(height, menu)
-
- moveCursor(height-extraLines(menu), 0)
- clearLine()
- fmt.Print(menu)
-
- carriageReturn()
- restoreCursor()
- }
-}
-
-func (lk *LogKeyboard) navigationMenu() string {
- var items []string
- if lk.IsDockerDesktopActive {
- items = append(items, shortcutKeyColor("v")+navColor(" View in Docker Desktop"))
- }
-
- if lk.IsDockerDesktopActive {
- items = append(items, shortcutKeyColor("o")+navColor(" View Config"))
- }
-
- if lk.IsLogsViewEnabled {
- items = append(items, shortcutKeyColor("l")+navColor(" View Logs"))
- }
-
- isEnabled := " Enable"
- if lk.Watch != nil && lk.Watch.Watching {
- isEnabled = " Disable"
- }
- items = append(items, shortcutKeyColor("w")+navColor(isEnabled+" Watch"))
- items = append(items, shortcutKeyColor("d")+navColor(" Detach"))
-
- return strings.Join(items, " ")
-}
-
-func (lk *LogKeyboard) clearNavigationMenu() {
- height := goterm.Height()
- carriageReturn()
- saveCursor()
-
- // clearLine()
- for range height {
- moveCursorDown(1)
- clearLine()
- }
- restoreCursor()
-}
-
-func (lk *LogKeyboard) openDockerDesktop(ctx context.Context, project *types.Project) {
- if !lk.IsDockerDesktopActive {
- return
- }
- go func() {
- _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui", tracing.SpanOptions{},
- func(ctx context.Context) error {
- link := fmt.Sprintf("docker-desktop://dashboard/apps/%s", project.Name)
- err := open.Run(link)
- if err != nil {
- err = fmt.Errorf("could not open Docker Desktop")
- lk.keyboardError("View", err)
- }
- return err
- })()
- }()
-}
-
-func (lk *LogKeyboard) openDDComposeUI(ctx context.Context, project *types.Project) {
- if !lk.IsDockerDesktopActive {
- return
- }
- go func() {
- _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/composeview", tracing.SpanOptions{},
- func(ctx context.Context) error {
- link := fmt.Sprintf("docker-desktop://dashboard/docker-compose/%s", project.Name)
- err := open.Run(link)
- if err != nil {
- err = fmt.Errorf("could not open Docker Desktop Compose UI")
- lk.keyboardError("View Config", err)
- }
- return err
- })()
- }()
-}
-
-func (lk *LogKeyboard) openDDLogsView(ctx context.Context, project *types.Project) {
- if !lk.IsLogsViewEnabled {
- return
- }
- go func() {
- _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/logsview", tracing.SpanOptions{},
- func(ctx context.Context) error {
- link := desktop.BuildLogsURL(project.Name)
- err := open.Run(link)
- if err != nil {
- err = fmt.Errorf("could not open Docker Desktop Logs view: %w", err)
- lk.keyboardError("View Logs", err)
- }
- return err
- })()
- }()
-}
-
-func (lk *LogKeyboard) openDDWatchDocs(ctx context.Context, project *types.Project) {
- go func() {
- _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/watch", tracing.SpanOptions{},
- func(ctx context.Context) error {
- link := fmt.Sprintf("docker-desktop://dashboard/docker-compose/%s/watch", project.Name)
- err := open.Run(link)
- if err != nil {
- err = fmt.Errorf("could not open Docker Desktop Compose UI")
- lk.keyboardError("Watch Docs", err)
- }
- return err
- })()
- }()
-}
-
-func (lk *LogKeyboard) keyboardError(prefix string, err error) {
- lk.kError.addError(prefix, err)
-
- lk.printNavigationMenu()
- timer1 := time.NewTimer((DISPLAY_ERROR_TIME + 1) * time.Second)
- go func() {
- <-timer1.C
- lk.printNavigationMenu()
- }()
-}
-
-func (lk *LogKeyboard) ToggleWatch(ctx context.Context, options api.UpOptions) {
- if lk.Watch == nil {
- return
- }
- if lk.Watch.Watching {
- err := lk.Watch.Watcher.Stop()
- if err != nil {
- options.Start.Attach.Err(api.WatchLogger, err.Error())
- } else {
- lk.Watch.Watching = false
- }
- } else {
- go func() {
- _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
- func(ctx context.Context) error {
- err := lk.Watch.Watcher.Start(ctx)
- if err != nil {
- options.Start.Attach.Err(api.WatchLogger, err.Error())
- } else {
- lk.Watch.Watching = true
- }
- return err
- })()
- }()
- }
-}
-
-func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEvent, project *types.Project, options api.UpOptions) {
- switch kRune := event.Rune; kRune {
- case 'd':
- lk.clearNavigationMenu()
- lk.Detach()
- case 'v':
- lk.openDockerDesktop(ctx, project)
- case 'w':
- if lk.Watch == nil {
- // we try to open watch docs if DD is installed
- if lk.IsDockerDesktopActive {
- lk.openDDWatchDocs(ctx, project)
- }
- // either way we mark menu/watch as an error
- go func() {
- _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
- func(ctx context.Context) error {
- err := fmt.Errorf("watch is not yet configured. Learn more: %s", ansiColor(CYAN, "https://docs.docker.com/compose/file-watch/"))
- lk.keyboardError("Watch", err)
- return err
- })()
- }()
- }
- lk.ToggleWatch(ctx, options)
- case 'o':
- lk.openDDComposeUI(ctx, project)
- case 'l':
- lk.openDDLogsView(ctx, project)
- }
- switch key := event.Key; key {
- case keyboard.KeyCtrlC:
- _ = keyboard.Close()
- lk.clearNavigationMenu()
- showCursor()
-
- lk.logLevel = NONE
- // will notify main thread to kill and will handle gracefully
- lk.signalChannel <- syscall.SIGINT
- case keyboard.KeyCtrlZ:
- handleCtrlZ()
- case keyboard.KeyEnter:
- newLine()
- lk.printNavigationMenu()
- }
-}
-
-func (lk *LogKeyboard) EnableWatch(enabled bool, watcher Feature) {
- lk.Watch = &KeyboardWatch{
- Watching: enabled,
- Watcher: watcher,
- }
-}
-
-func (lk *LogKeyboard) EnableDetach(detach func()) {
- lk.Detach = detach
-}
-
-func allocateSpace(lines int) {
- for range lines {
- clearLine()
- newLine()
- carriageReturn()
- }
-}
-
-func extraLines(s string) int {
- return int(math.Floor(float64(lenAnsi(s)) / float64(goterm.Width())))
-}
-
-func shortcutKeyColor(key string) string {
- foreground := "38;2"
- black := "0;0;0"
- background := "48;2"
- white := "255;255;255"
- return ansiColor(foreground+";"+black+";"+background+";"+white, key, BOLD)
-}
-
-func navColor(key string) string {
- return ansiColor(FAINT, key)
-}
diff --git a/cmd/formatter/shortcut_unix.go b/cmd/formatter/shortcut_unix.go
deleted file mode 100644
index 0baa3a949cc..00000000000
--- a/cmd/formatter/shortcut_unix.go
+++ /dev/null
@@ -1,25 +0,0 @@
-//go:build !windows
-
-/*
- Copyright 2024 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-import "syscall"
-
-func handleCtrlZ() {
- _ = syscall.Kill(0, syscall.SIGSTOP)
-}
diff --git a/cmd/formatter/shortcut_windows.go b/cmd/formatter/shortcut_windows.go
deleted file mode 100644
index 1efa14cc2dd..00000000000
--- a/cmd/formatter/shortcut_windows.go
+++ /dev/null
@@ -1,25 +0,0 @@
-//go:build windows
-
-/*
- Copyright 2024 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package formatter
-
-// handleCtrlZ is a no-op on Windows as SIGSTOP is not supported
-func handleCtrlZ() {
- // Windows doesn't support SIGSTOP/SIGCONT signals
- // Ctrl+Z behavior is handled differently by the Windows terminal
-}
diff --git a/cmd/main.go b/cmd/main.go
deleted file mode 100644
index 7d3fab4f5ed..00000000000
--- a/cmd/main.go
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package main
-
-import (
- "os"
-
- dockercli "github.com/docker/cli/cli"
- "github.com/docker/cli/cli-plugins/metadata"
- "github.com/docker/cli/cli-plugins/plugin"
- "github.com/docker/cli/cli/command"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/cmd/cmdtrace"
- "github.com/docker/compose/v5/cmd/compatibility"
- commands "github.com/docker/compose/v5/cmd/compose"
- "github.com/docker/compose/v5/cmd/prompt"
- "github.com/docker/compose/v5/internal"
- "github.com/docker/compose/v5/pkg/compose"
-)
-
-func pluginMain() {
- plugin.Run(
- func(cli command.Cli) *cobra.Command {
- backendOptions := &commands.BackendOptions{
- Options: []compose.Option{
- compose.WithPrompt(prompt.NewPrompt(cli.In(), cli.Out()).Confirm),
- },
- }
-
- cmd := commands.RootCommand(cli, backendOptions)
- cmd.AddCommand(commands.HooksCommand())
- originalPreRunE := cmd.PersistentPreRunE
- cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
- // initialize the cli instance
- if err := plugin.PersistentPreRunE(cmd, args); err != nil {
- return err
- }
- if err := cmdtrace.Setup(cmd, cli, os.Args[1:]); err != nil {
- logrus.Debugf("failed to enable tracing: %v", err)
- }
-
- if originalPreRunE != nil {
- return originalPreRunE(cmd, args)
- }
- return nil
- }
-
- cmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
- return dockercli.StatusError{
- StatusCode: 1,
- Status: err.Error(),
- }
- })
- return cmd
- },
- metadata.Metadata{
- SchemaVersion: "0.1.0",
- Vendor: "Docker Inc.",
- Version: internal.Version,
- },
- command.WithUserAgent("compose/"+internal.Version),
- )
-}
-
-func main() {
- if plugin.RunningStandalone() {
- os.Args = append([]string{"docker"}, compatibility.Convert(os.Args[1:])...)
- }
- pluginMain()
-}
diff --git a/cmd/prompt/prompt.go b/cmd/prompt/prompt.go
deleted file mode 100644
index 87379f5eb61..00000000000
--- a/cmd/prompt/prompt.go
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
- Copyright 2020 Docker Compose CLI authors
-
- 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
-
- http://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.
-*/
-
-package prompt
-
-import (
- "fmt"
- "io"
-
- "github.com/AlecAivazis/survey/v2"
- "github.com/docker/cli/cli/streams"
-
- "github.com/docker/compose/v5/pkg/utils"
-)
-
-//go:generate mockgen -destination=./prompt_mock.go -self_package "github.com/docker/compose/v5/pkg/prompt" -package=prompt . UI
-
-// UI - prompt user input
-type UI interface {
- Confirm(message string, defaultValue bool) (bool, error)
-}
-
-func NewPrompt(stdin *streams.In, stdout *streams.Out) UI {
- if stdin.IsTerminal() {
- return User{stdin: streamsFileReader{stdin}, stdout: streamsFileWriter{stdout}}
- }
- return Pipe{stdin: stdin, stdout: stdout}
-}
-
-// User - in a terminal
-type User struct {
- stdout streamsFileWriter
- stdin streamsFileReader
-}
-
-// adapt streams.Out to terminal.FileWriter
-type streamsFileWriter struct {
- stream *streams.Out
-}
-
-func (s streamsFileWriter) Write(p []byte) (n int, err error) {
- return s.stream.Write(p)
-}
-
-func (s streamsFileWriter) Fd() uintptr {
- return s.stream.FD()
-}
-
-// adapt streams.In to terminal.FileReader
-type streamsFileReader struct {
- stream *streams.In
-}
-
-func (s streamsFileReader) Read(p []byte) (n int, err error) {
- return s.stream.Read(p)
-}
-
-func (s streamsFileReader) Fd() uintptr {
- return s.stream.FD()
-}
-
-// Confirm asks for yes or no input
-func (u User) Confirm(message string, defaultValue bool) (bool, error) {
- qs := &survey.Confirm{
- Message: message,
- Default: defaultValue,
- }
- var b bool
- err := survey.AskOne(qs, &b, func(options *survey.AskOptions) error {
- options.Stdio.In = u.stdin
- options.Stdio.Out = u.stdout
- return nil
- })
- return b, err
-}
-
-// Pipe - aggregates prompt methods
-type Pipe struct {
- stdout io.Writer
- stdin io.Reader
-}
-
-// Confirm asks for yes or no input
-func (u Pipe) Confirm(message string, defaultValue bool) (bool, error) {
- _, _ = fmt.Fprint(u.stdout, message)
- var answer string
- _, _ = fmt.Fscanln(u.stdin, &answer)
- return utils.StringToBool(answer), nil
-}
diff --git a/cmd/prompt/prompt_mock.go b/cmd/prompt/prompt_mock.go
deleted file mode 100644
index 83b0ff1189b..00000000000
--- a/cmd/prompt/prompt_mock.go
+++ /dev/null
@@ -1,94 +0,0 @@
-// Code generated by MockGen. DO NOT EDIT.
-// Source: github.com/docker/compose-cli/pkg/prompt (interfaces: UI)
-
-// Package prompt is a generated GoMock package.
-package prompt
-
-import (
- reflect "reflect"
-
- gomock "go.uber.org/mock/gomock"
-)
-
-// MockUI is a mock of UI interface
-type MockUI struct {
- ctrl *gomock.Controller
- recorder *MockUIMockRecorder
-}
-
-// MockUIMockRecorder is the mock recorder for MockUI
-type MockUIMockRecorder struct {
- mock *MockUI
-}
-
-// NewMockUI creates a new mock instance
-func NewMockUI(ctrl *gomock.Controller) *MockUI {
- mock := &MockUI{ctrl: ctrl}
- mock.recorder = &MockUIMockRecorder{mock}
- return mock
-}
-
-// EXPECT returns an object that allows the caller to indicate expected use
-func (m *MockUI) EXPECT() *MockUIMockRecorder {
- return m.recorder
-}
-
-// Confirm mocks base method
-func (m *MockUI) Confirm(arg0 string, arg1 bool) (bool, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "Confirm", arg0, arg1)
- ret0, _ := ret[0].(bool)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// Confirm indicates an expected call of Confirm
-func (mr *MockUIMockRecorder) Confirm(arg0, arg1 interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Confirm", reflect.TypeOf((*MockUI)(nil).Confirm), arg0, arg1)
-}
-
-// Input mocks base method
-func (m *MockUI) Input(arg0, arg1 string) (string, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "Input", arg0, arg1)
- ret0, _ := ret[0].(string)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// Input indicates an expected call of Input
-func (mr *MockUIMockRecorder) Input(arg0, arg1 interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Input", reflect.TypeOf((*MockUI)(nil).Input), arg0, arg1)
-}
-
-// Password mocks base method
-func (m *MockUI) Password(arg0 string) (string, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "Password", arg0)
- ret0, _ := ret[0].(string)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// Password indicates an expected call of Password
-func (mr *MockUIMockRecorder) Password(arg0 interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Password", reflect.TypeOf((*MockUI)(nil).Password), arg0)
-}
-
-// Select mocks base method
-func (m *MockUI) Select(arg0 string, arg1 []string) (int, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "Select", arg0, arg1)
- ret0, _ := ret[0].(int)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// Select indicates an expected call of Select
-func (mr *MockUIMockRecorder) Select(arg0, arg1 interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Select", reflect.TypeOf((*MockUI)(nil).Select), arg0, arg1)
-}
diff --git a/codecov.yml b/codecov.yml
deleted file mode 100644
index a66912f32e0..00000000000
--- a/codecov.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-coverage:
- status:
- project:
- default:
- informational: true
- target: auto
- threshold: 2%
- patch:
- default:
- informational: true
-
-comment:
- require_changes: true
-
-ignore:
- - "packaging"
- - "docs"
- - "bin"
- - "e2e"
- - "pkg/e2e"
- - "**/*_test.go"
diff --git a/compose/__init__.py b/compose/__init__.py
new file mode 100644
index 00000000000..002761a5e9b
--- /dev/null
+++ b/compose/__init__.py
@@ -0,0 +1 @@
+__version__ = '1.30.0dev'
diff --git a/compose/__main__.py b/compose/__main__.py
new file mode 100644
index 00000000000..199ba2ae9b4
--- /dev/null
+++ b/compose/__main__.py
@@ -0,0 +1,3 @@
+from compose.cli.main import main
+
+main()
diff --git a/pkg/e2e/fixtures/environment/env-priority/.env.empty b/compose/cli/__init__.py
similarity index 100%
rename from pkg/e2e/fixtures/environment/env-priority/.env.empty
rename to compose/cli/__init__.py
diff --git a/compose/cli/colors.py b/compose/cli/colors.py
new file mode 100644
index 00000000000..042403a9f7d
--- /dev/null
+++ b/compose/cli/colors.py
@@ -0,0 +1,64 @@
+import enum
+import os
+
+from ..const import IS_WINDOWS_PLATFORM
+
+NAMES = [
+ 'grey',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'magenta',
+ 'cyan',
+ 'white'
+]
+
+
+@enum.unique
+class AnsiMode(enum.Enum):
+ """Enumeration for when to output ANSI colors."""
+ NEVER = "never"
+ ALWAYS = "always"
+ AUTO = "auto"
+
+ def use_ansi_codes(self, stream):
+ if self is AnsiMode.ALWAYS:
+ return True
+ if self is AnsiMode.NEVER or os.environ.get('CLICOLOR') == '0':
+ return False
+ return stream.isatty()
+
+
+def get_pairs():
+ for i, name in enumerate(NAMES):
+ yield (name, str(30 + i))
+ yield ('intense_' + name, str(30 + i) + ';1')
+
+
+def ansi(code):
+ return '\033[{}m'.format(code)
+
+
+def ansi_color(code, s):
+ return '{}{}{}'.format(ansi(code), s, ansi(0))
+
+
+def make_color_fn(code):
+ return lambda s: ansi_color(code, s)
+
+
+if IS_WINDOWS_PLATFORM:
+ import colorama
+ colorama.init(strip=False)
+for (name, code) in get_pairs():
+ globals()[name] = make_color_fn(code)
+
+
+def rainbow():
+ cs = ['cyan', 'yellow', 'green', 'magenta', 'blue',
+ 'intense_cyan', 'intense_yellow', 'intense_green',
+ 'intense_magenta', 'intense_blue']
+
+ for c in cs:
+ yield globals()[c]
diff --git a/compose/cli/command.py b/compose/cli/command.py
new file mode 100644
index 00000000000..e8d399aa1d0
--- /dev/null
+++ b/compose/cli/command.py
@@ -0,0 +1,210 @@
+import logging
+import os
+import re
+
+from . import errors
+from .. import config
+from .. import parallel
+from ..config.environment import Environment
+from ..const import LABEL_CONFIG_FILES
+from ..const import LABEL_ENVIRONMENT_FILE
+from ..const import LABEL_WORKING_DIR
+from ..project import Project
+from .docker_client import get_client
+from .docker_client import load_context
+from .docker_client import make_context
+from .errors import UserError
+
+log = logging.getLogger(__name__)
+
+SILENT_COMMANDS = {
+ 'events',
+ 'exec',
+ 'kill',
+ 'logs',
+ 'pause',
+ 'ps',
+ 'restart',
+ 'rm',
+ 'start',
+ 'stop',
+ 'top',
+ 'unpause',
+}
+
+
+def project_from_options(project_dir, options, additional_options=None):
+ additional_options = additional_options or {}
+ override_dir = get_project_dir(options)
+ environment_file = options.get('--env-file')
+ environment = Environment.from_env_file(override_dir or project_dir, environment_file)
+ environment.silent = options.get('COMMAND', None) in SILENT_COMMANDS
+ set_parallel_limit(environment)
+
+ # get the context for the run
+ context = None
+ context_name = options.get('--context', None)
+ if context_name:
+ context = load_context(context_name)
+ if not context:
+ raise UserError("Context '{}' not found".format(context_name))
+
+ host = options.get('--host', None)
+ if host is not None:
+ if context:
+ raise UserError(
+ "-H, --host and -c, --context are mutually exclusive. Only one should be set.")
+ host = host.lstrip('=')
+ context = make_context(host, options, environment)
+
+ return get_project(
+ project_dir,
+ get_config_path_from_options(options, environment),
+ project_name=options.get('--project-name'),
+ verbose=options.get('--verbose'),
+ context=context,
+ environment=environment,
+ override_dir=override_dir,
+ interpolate=(not additional_options.get('--no-interpolate')),
+ environment_file=environment_file,
+ enabled_profiles=get_profiles_from_options(options, environment)
+ )
+
+
+def set_parallel_limit(environment):
+ parallel_limit = environment.get('COMPOSE_PARALLEL_LIMIT')
+ if parallel_limit:
+ try:
+ parallel_limit = int(parallel_limit)
+ except ValueError:
+ raise errors.UserError(
+ 'COMPOSE_PARALLEL_LIMIT must be an integer (found: "{}")'.format(
+ environment.get('COMPOSE_PARALLEL_LIMIT')
+ )
+ )
+ if parallel_limit <= 1:
+ raise errors.UserError('COMPOSE_PARALLEL_LIMIT can not be less than 2')
+ parallel.GlobalLimit.set_global_limit(parallel_limit)
+
+
+def get_project_dir(options):
+ override_dir = None
+ files = get_config_path_from_options(options, os.environ)
+ if files:
+ if files[0] == '-':
+ return '.'
+ override_dir = os.path.dirname(files[0])
+ return options.get('--project-directory') or override_dir
+
+
+def get_config_from_options(base_dir, options, additional_options=None):
+ additional_options = additional_options or {}
+ override_dir = get_project_dir(options)
+ environment_file = options.get('--env-file')
+ environment = Environment.from_env_file(override_dir or base_dir, environment_file)
+ config_path = get_config_path_from_options(options, environment)
+ return config.load(
+ config.find(base_dir, config_path, environment, override_dir),
+ not additional_options.get('--no-interpolate')
+ )
+
+
+def get_config_path_from_options(options, environment):
+ def unicode_paths(paths):
+ return [p.decode('utf-8') if isinstance(p, bytes) else p for p in paths]
+
+ file_option = options.get('--file')
+ if file_option:
+ return unicode_paths(file_option)
+
+ config_files = environment.get('COMPOSE_FILE')
+ if config_files:
+ pathsep = environment.get('COMPOSE_PATH_SEPARATOR', os.pathsep)
+ return unicode_paths(config_files.split(pathsep))
+ return None
+
+
+def get_profiles_from_options(options, environment):
+ profile_option = options.get('--profile')
+ if profile_option:
+ return profile_option
+
+ profiles = environment.get('COMPOSE_PROFILES')
+ if profiles:
+ return profiles.split(',')
+
+ return []
+
+
+def get_project(project_dir, config_path=None, project_name=None, verbose=False,
+ context=None, environment=None, override_dir=None,
+ interpolate=True, environment_file=None, enabled_profiles=None):
+ if not environment:
+ environment = Environment.from_env_file(project_dir)
+ config_details = config.find(project_dir, config_path, environment, override_dir)
+ project_name = get_project_name(
+ config_details.working_dir, project_name, environment
+ )
+ config_data = config.load(config_details, interpolate)
+
+ api_version = environment.get('COMPOSE_API_VERSION')
+
+ client = get_client(
+ verbose=verbose, version=api_version, context=context, environment=environment
+ )
+
+ with errors.handle_connection_errors(client):
+ return Project.from_config(
+ project_name,
+ config_data,
+ client,
+ environment.get('DOCKER_DEFAULT_PLATFORM'),
+ execution_context_labels(config_details, environment_file),
+ enabled_profiles,
+ )
+
+
+def execution_context_labels(config_details, environment_file):
+ extra_labels = [
+ '{}={}'.format(LABEL_WORKING_DIR, os.path.abspath(config_details.working_dir))
+ ]
+
+ if not use_config_from_stdin(config_details):
+ extra_labels.append('{}={}'.format(LABEL_CONFIG_FILES, config_files_label(config_details)))
+
+ if environment_file is not None:
+ extra_labels.append('{}={}'.format(
+ LABEL_ENVIRONMENT_FILE,
+ os.path.normpath(environment_file))
+ )
+ return extra_labels
+
+
+def use_config_from_stdin(config_details):
+ for c in config_details.config_files:
+ if not c.filename:
+ return True
+ return False
+
+
+def config_files_label(config_details):
+ return ",".join(
+ os.path.normpath(c.filename) for c in config_details.config_files
+ )
+
+
+def get_project_name(working_dir, project_name=None, environment=None):
+ def normalize_name(name):
+ return re.sub(r'[^-_a-z0-9]', '', name.lower())
+
+ if not environment:
+ environment = Environment.from_env_file(working_dir)
+ project_name = project_name or environment.get('COMPOSE_PROJECT_NAME')
+ if project_name:
+ return normalize_name(project_name)
+
+ project = os.path.basename(os.path.abspath(working_dir))
+ if project:
+ return normalize_name(project)
+
+ return 'default'
diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py
new file mode 100644
index 00000000000..e4a0fea61b4
--- /dev/null
+++ b/compose/cli/docker_client.py
@@ -0,0 +1,173 @@
+import logging
+import os.path
+import ssl
+
+from docker import APIClient
+from docker import Context
+from docker import ContextAPI
+from docker import TLSConfig
+from docker.errors import TLSParameterError
+from docker.utils import kwargs_from_env
+from docker.utils.config import home_dir
+
+from . import verbose_proxy
+from ..config.environment import Environment
+from ..const import HTTP_TIMEOUT
+from ..utils import unquote_path
+from .errors import UserError
+from .utils import generate_user_agent
+from .utils import get_version_info
+
+log = logging.getLogger(__name__)
+
+
+def default_cert_path():
+ return os.path.join(home_dir(), '.docker')
+
+
+def make_context(host, options, environment):
+ tls = tls_config_from_options(options, environment)
+ ctx = Context("compose", host=host, tls=tls.verify if tls else False)
+ if tls:
+ ctx.set_endpoint("docker", host, tls, skip_tls_verify=not tls.verify)
+ return ctx
+
+
+def load_context(name=None):
+ return ContextAPI.get_context(name)
+
+
+def get_client(environment, verbose=False, version=None, context=None):
+ client = docker_client(
+ version=version, context=context,
+ environment=environment, tls_version=get_tls_version(environment)
+ )
+ if verbose:
+ version_info = client.version().items()
+ log.info(get_version_info('full'))
+ log.info("Docker base_url: %s", client.base_url)
+ log.info("Docker version: %s",
+ ", ".join("%s=%s" % item for item in version_info))
+ return verbose_proxy.VerboseProxy('docker', client)
+ return client
+
+
+def get_tls_version(environment):
+ compose_tls_version = environment.get('COMPOSE_TLS_VERSION', None)
+ if not compose_tls_version:
+ return None
+
+ tls_attr_name = "PROTOCOL_{}".format(compose_tls_version)
+ if not hasattr(ssl, tls_attr_name):
+ log.warning(
+ 'The "{}" protocol is unavailable. You may need to update your '
+ 'version of Python or OpenSSL. Falling back to TLSv1 (default).'
+ .format(compose_tls_version)
+ )
+ return None
+
+ return getattr(ssl, tls_attr_name)
+
+
+def tls_config_from_options(options, environment=None):
+ environment = environment or Environment()
+ cert_path = environment.get('DOCKER_CERT_PATH') or None
+
+ tls = options.get('--tls', False)
+ ca_cert = unquote_path(options.get('--tlscacert'))
+ cert = unquote_path(options.get('--tlscert'))
+ key = unquote_path(options.get('--tlskey'))
+ # verify is a special case - with docopt `--tlsverify` = False means it
+ # wasn't used, so we set it if either the environment or the flag is True
+ # see https://github.com/docker/compose/issues/5632
+ verify = options.get('--tlsverify') or environment.get_boolean('DOCKER_TLS_VERIFY')
+
+ skip_hostname_check = options.get('--skip-hostname-check', False)
+ if cert_path is not None and not any((ca_cert, cert, key)):
+ # FIXME: Modify TLSConfig to take a cert_path argument and do this internally
+ cert = os.path.join(cert_path, 'cert.pem')
+ key = os.path.join(cert_path, 'key.pem')
+ ca_cert = os.path.join(cert_path, 'ca.pem')
+
+ if verify and not any((ca_cert, cert, key)):
+ # Default location for cert files is ~/.docker
+ ca_cert = os.path.join(default_cert_path(), 'ca.pem')
+ cert = os.path.join(default_cert_path(), 'cert.pem')
+ key = os.path.join(default_cert_path(), 'key.pem')
+
+ tls_version = get_tls_version(environment)
+
+ advanced_opts = any([ca_cert, cert, key, verify, tls_version])
+
+ if tls is True and not advanced_opts:
+ return True
+ elif advanced_opts: # --tls is a noop
+ client_cert = None
+ if cert or key:
+ client_cert = (cert, key)
+
+ return TLSConfig(
+ client_cert=client_cert, verify=verify, ca_cert=ca_cert,
+ assert_hostname=False if skip_hostname_check else None,
+ ssl_version=tls_version
+ )
+
+ return None
+
+
+def docker_client(environment, version=None, context=None, tls_version=None):
+ """
+ Returns a docker-py client configured using environment variables
+ according to the same logic as the official Docker client.
+ """
+ try:
+ kwargs = kwargs_from_env(environment=environment, ssl_version=tls_version)
+ except TLSParameterError:
+ raise UserError(
+ "TLS configuration is invalid - make sure your DOCKER_TLS_VERIFY "
+ "and DOCKER_CERT_PATH are set correctly.\n"
+ "You might need to run `eval \"$(docker-machine env default)\"`")
+
+ if not context:
+ # check env for DOCKER_HOST and certs path
+ host = kwargs.get("base_url", None)
+ tls = kwargs.get("tls", None)
+ verify = False if not tls else tls.verify
+ if host:
+ context = Context("compose", host=host, tls=verify)
+ else:
+ context = ContextAPI.get_current_context()
+ if tls:
+ context.set_endpoint("docker", host=host, tls_cfg=tls, skip_tls_verify=not verify)
+
+ if not context.is_docker_host():
+ raise UserError(
+ "The platform targeted with the current context is not supported.\n"
+ "Make sure the context in use targets a Docker Engine.\n")
+
+ kwargs['base_url'] = context.Host
+ if context.TLSConfig:
+ kwargs['tls'] = context.TLSConfig
+
+ if version:
+ kwargs['version'] = version
+
+ timeout = environment.get('COMPOSE_HTTP_TIMEOUT')
+ if timeout:
+ kwargs['timeout'] = int(timeout)
+ else:
+ kwargs['timeout'] = HTTP_TIMEOUT
+
+ kwargs['user_agent'] = generate_user_agent()
+
+ # Workaround for
+ # https://pyinstaller.readthedocs.io/en/v3.3.1/runtime-information.html#ld-library-path-libpath-considerations
+ if 'LD_LIBRARY_PATH_ORIG' in environment:
+ kwargs['credstore_env'] = {
+ 'LD_LIBRARY_PATH': environment.get('LD_LIBRARY_PATH_ORIG'),
+ }
+ use_paramiko_ssh = int(environment.get('COMPOSE_PARAMIKO_SSH', 0))
+ client = APIClient(use_ssh_client=not use_paramiko_ssh, **kwargs)
+ client._original_base_url = kwargs.get('base_url')
+
+ return client
diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py
new file mode 100644
index 00000000000..e56b37835bc
--- /dev/null
+++ b/compose/cli/docopt_command.py
@@ -0,0 +1,62 @@
+from inspect import getdoc
+
+from docopt import docopt
+from docopt import DocoptExit
+
+
+def docopt_full_help(docstring, *args, **kwargs):
+ try:
+ return docopt(docstring, *args, **kwargs)
+ except DocoptExit:
+ raise SystemExit(docstring)
+
+
+class DocoptDispatcher:
+
+ def __init__(self, command_class, options):
+ self.command_class = command_class
+ self.options = options
+
+ @classmethod
+ def get_command_and_options(cls, doc_entity, argv, options):
+ command_help = getdoc(doc_entity)
+ opt = docopt_full_help(command_help, argv, **options)
+ command = opt['COMMAND']
+ return command_help, opt, command
+
+ def parse(self, argv):
+ command_help, options, command = DocoptDispatcher.get_command_and_options(
+ self.command_class, argv, self.options)
+
+ if command is None:
+ raise SystemExit(command_help)
+
+ handler = get_handler(self.command_class, command)
+ docstring = getdoc(handler)
+
+ if docstring is None:
+ raise NoSuchCommand(command, self)
+
+ command_options = docopt_full_help(docstring, options['ARGS'], options_first=True)
+ return options, handler, command_options
+
+
+def get_handler(command_class, command):
+ command = command.replace('-', '_')
+ # we certainly want to have "exec" command, since that's what docker client has
+ # but in python exec is a keyword
+ if command == "exec":
+ command = "exec_command"
+
+ if not hasattr(command_class, command):
+ raise NoSuchCommand(command, command_class)
+
+ return getattr(command_class, command)
+
+
+class NoSuchCommand(Exception):
+ def __init__(self, command, supercommand):
+ super().__init__("No such command: %s" % command)
+
+ self.command = command
+ self.supercommand = supercommand
diff --git a/compose/cli/errors.py b/compose/cli/errors.py
new file mode 100644
index 00000000000..a807c7d1c78
--- /dev/null
+++ b/compose/cli/errors.py
@@ -0,0 +1,165 @@
+import contextlib
+import logging
+import socket
+from distutils.spawn import find_executable
+from textwrap import dedent
+
+from docker.errors import APIError
+from requests.exceptions import ConnectionError as RequestsConnectionError
+from requests.exceptions import ReadTimeout
+from requests.exceptions import SSLError
+from requests.packages.urllib3.exceptions import ReadTimeoutError
+
+from ..const import API_VERSION_TO_ENGINE_VERSION
+from .utils import binarystr_to_unicode
+from .utils import is_docker_for_mac_installed
+from .utils import is_mac
+from .utils import is_ubuntu
+from .utils import is_windows
+
+
+log = logging.getLogger(__name__)
+
+
+class UserError(Exception):
+
+ def __init__(self, msg):
+ self.msg = dedent(msg).strip()
+
+ def __str__(self):
+ return self.msg
+
+
+class ConnectionError(Exception):
+ pass
+
+
+@contextlib.contextmanager
+def handle_connection_errors(client):
+ try:
+ yield
+ except SSLError as e:
+ log.error('SSL error: %s' % e)
+ raise ConnectionError()
+ except RequestsConnectionError as e:
+ if e.args and isinstance(e.args[0], ReadTimeoutError):
+ log_timeout_error(client.timeout)
+ raise ConnectionError()
+ exit_with_error(get_conn_error_message(client.base_url))
+ except APIError as e:
+ log_api_error(e, client.api_version)
+ raise ConnectionError()
+ except (ReadTimeout, socket.timeout):
+ log_timeout_error(client.timeout)
+ raise ConnectionError()
+ except Exception as e:
+ if is_windows():
+ import pywintypes
+ if isinstance(e, pywintypes.error):
+ log_windows_pipe_error(e)
+ raise ConnectionError()
+ raise
+
+
+def log_windows_pipe_error(exc):
+ if exc.winerror == 2:
+ log.error("Couldn't connect to Docker daemon. You might need to start Docker for Windows.")
+ elif exc.winerror == 232: # https://github.com/docker/compose/issues/5005
+ log.error(
+ "The current Compose file version is not compatible with your engine version. "
+ "Please upgrade your Compose file to a more recent version, or set "
+ "a COMPOSE_API_VERSION in your environment."
+ )
+ else:
+ log.error(
+ "Windows named pipe error: {} (code: {})".format(
+ binarystr_to_unicode(exc.strerror), exc.winerror
+ )
+ )
+
+
+def log_timeout_error(timeout):
+ log.error(
+ "An HTTP request took too long to complete. Retry with --verbose to "
+ "obtain debug information.\n"
+ "If you encounter this issue regularly because of slow network "
+ "conditions, consider setting COMPOSE_HTTP_TIMEOUT to a higher "
+ "value (current value: %s)." % timeout)
+
+
+def log_api_error(e, client_version):
+ explanation = binarystr_to_unicode(e.explanation)
+
+ if 'client is newer than server' not in explanation:
+ log.error(explanation)
+ return
+
+ version = API_VERSION_TO_ENGINE_VERSION.get(client_version)
+ if not version:
+ # They've set a custom API version
+ log.error(explanation)
+ return
+
+ log.error(
+ "The Docker Engine version is less than the minimum required by "
+ "Compose. Your current project requires a Docker Engine of "
+ "version {version} or greater.".format(version=version)
+ )
+
+
+def exit_with_error(msg):
+ log.error(dedent(msg).strip())
+ raise ConnectionError()
+
+
+def get_conn_error_message(url):
+ try:
+ if find_executable('docker') is None:
+ return docker_not_found_msg("Couldn't connect to Docker daemon.")
+ if is_docker_for_mac_installed():
+ return conn_error_docker_for_mac
+ if find_executable('docker-machine') is not None:
+ return conn_error_docker_machine
+ except UnicodeDecodeError:
+ # https://github.com/docker/compose/issues/5442
+ # Ignore the error and print the generic message instead.
+ pass
+ return conn_error_generic.format(url=url)
+
+
+def docker_not_found_msg(problem):
+ return "{} You might need to install Docker:\n\n{}".format(
+ problem, docker_install_url())
+
+
+def docker_install_url():
+ if is_mac():
+ return docker_install_url_mac
+ elif is_ubuntu():
+ return docker_install_url_ubuntu
+ elif is_windows():
+ return docker_install_url_windows
+ else:
+ return docker_install_url_generic
+
+
+docker_install_url_mac = "https://docs.docker.com/engine/installation/mac/"
+docker_install_url_ubuntu = "https://docs.docker.com/engine/installation/ubuntulinux/"
+docker_install_url_windows = "https://docs.docker.com/engine/installation/windows/"
+docker_install_url_generic = "https://docs.docker.com/engine/installation/"
+
+
+conn_error_docker_machine = """
+ Couldn't connect to Docker daemon - you might need to run `docker-machine start default`.
+"""
+
+conn_error_docker_for_mac = """
+ Couldn't connect to Docker daemon. You might need to start Docker for Mac.
+"""
+
+
+conn_error_generic = """
+ Couldn't connect to Docker daemon at {url} - is it running?
+
+ If it's at a non-standard location, specify the URL with the DOCKER_HOST environment variable.
+"""
diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py
new file mode 100644
index 00000000000..ff81ee65163
--- /dev/null
+++ b/compose/cli/formatter.py
@@ -0,0 +1,54 @@
+import logging
+from shutil import get_terminal_size
+
+import texttable
+
+from compose.cli import colors
+
+
+def get_tty_width():
+ try:
+ # get_terminal_size can't determine the size if compose is piped
+ # to another command. But in such case it doesn't make sense to
+ # try format the output by terminal size as this output is consumed
+ # by another command. So let's pretend we have a huge terminal so
+ # output is single-lined
+ width, _ = get_terminal_size(fallback=(999, 0))
+ return int(width)
+ except OSError:
+ return 0
+
+
+class Formatter:
+ """Format tabular data for printing."""
+
+ @staticmethod
+ def table(headers, rows):
+ table = texttable.Texttable(max_width=get_tty_width())
+ table.set_cols_dtype(['t' for h in headers])
+ table.add_rows([headers] + rows)
+ table.set_deco(table.HEADER)
+ table.set_chars(['-', '|', '+', '-'])
+
+ return table.draw()
+
+
+class ConsoleWarningFormatter(logging.Formatter):
+ """A logging.Formatter which prints WARNING and ERROR messages with
+ a prefix of the log level colored appropriate for the log level.
+ """
+
+ def get_level_message(self, record):
+ separator = ': '
+ if record.levelno >= logging.ERROR:
+ return colors.red(record.levelname) + separator
+ if record.levelno >= logging.WARNING:
+ return colors.yellow(record.levelname) + separator
+
+ return ''
+
+ def format(self, record):
+ if isinstance(record.msg, bytes):
+ record.msg = record.msg.decode('utf-8')
+ message = super().format(record)
+ return '{}{}'.format(self.get_level_message(record), message)
diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py
new file mode 100644
index 00000000000..e56dc2fa37c
--- /dev/null
+++ b/compose/cli/log_printer.py
@@ -0,0 +1,255 @@
+import _thread as thread
+import sys
+from collections import namedtuple
+from itertools import cycle
+from operator import attrgetter
+from queue import Empty
+from queue import Queue
+from threading import Thread
+
+from docker.errors import APIError
+
+from . import colors
+from compose.cli.signals import ShutdownException
+from compose.utils import split_buffer
+
+
+class LogPresenter:
+
+ def __init__(self, prefix_width, color_func, keep_prefix=True):
+ self.prefix_width = prefix_width
+ self.color_func = color_func
+ self.keep_prefix = keep_prefix
+
+ def present(self, container, line):
+ to_log = '{line}'.format(line=line)
+
+ if self.keep_prefix:
+ prefix = container.name_without_project.ljust(self.prefix_width)
+ to_log = '{prefix} '.format(prefix=self.color_func(prefix + ' |')) + to_log
+
+ return to_log
+
+
+def build_log_presenters(service_names, monochrome, keep_prefix=True):
+ """Return an iterable of functions.
+
+ Each function can be used to format the logs output of a container.
+ """
+ prefix_width = max_name_width(service_names)
+
+ def no_color(text):
+ return text
+
+ for color_func in cycle([no_color] if monochrome else colors.rainbow()):
+ yield LogPresenter(prefix_width, color_func, keep_prefix)
+
+
+def max_name_width(service_names, max_index_width=3):
+ """Calculate the maximum width of container names so we can make the log
+ prefixes line up like so:
+
+ db_1 | Listening
+ web_1 | Listening
+ """
+ return max(len(name) for name in service_names) + max_index_width
+
+
+class LogPrinter:
+ """Print logs from many containers to a single output stream."""
+
+ def __init__(self,
+ containers,
+ presenters,
+ event_stream,
+ output=sys.stdout,
+ cascade_stop=False,
+ log_args=None):
+ self.containers = containers
+ self.presenters = presenters
+ self.event_stream = event_stream
+ self.output = output
+ self.cascade_stop = cascade_stop
+ self.log_args = log_args or {}
+
+ def run(self):
+ if not self.containers:
+ return
+
+ queue = Queue()
+ thread_args = queue, self.log_args
+ thread_map = build_thread_map(self.containers, self.presenters, thread_args)
+ start_producer_thread((
+ thread_map,
+ self.event_stream,
+ self.presenters,
+ thread_args))
+
+ for line in consume_queue(queue, self.cascade_stop):
+ remove_stopped_threads(thread_map)
+
+ if self.cascade_stop:
+ matching_container = [cont.name for cont in self.containers if cont.name == line]
+ if line in matching_container:
+ # Returning the name of the container that started the
+ # the cascade_stop so we can return the correct exit code
+ return line
+
+ if not line:
+ if not thread_map:
+ # There are no running containers left to tail, so exit
+ return
+ # We got an empty line because of a timeout, but there are still
+ # active containers to tail, so continue
+ continue
+
+ self.write(line)
+
+ def write(self, line):
+ try:
+ self.output.write(line)
+ except UnicodeEncodeError:
+ # This may happen if the user's locale settings don't support UTF-8
+ # and UTF-8 characters are present in the log line. The following
+ # will output a "degraded" log with unsupported characters
+ # replaced by `?`
+ self.output.write(line.encode('ascii', 'replace').decode())
+ self.output.flush()
+
+
+def remove_stopped_threads(thread_map):
+ for container_id, tailer_thread in list(thread_map.items()):
+ if not tailer_thread.is_alive():
+ thread_map.pop(container_id, None)
+
+
+def build_thread(container, presenter, queue, log_args):
+ tailer = Thread(
+ target=tail_container_logs,
+ args=(container, presenter, queue, log_args))
+ tailer.daemon = True
+ tailer.start()
+ return tailer
+
+
+def build_thread_map(initial_containers, presenters, thread_args):
+ return {
+ container.id: build_thread(container, next(presenters), *thread_args)
+ # Container order is unspecified, so they are sorted by name in order to make
+ # container:presenter (log color) assignment deterministic when given a list of containers
+ # with the same names.
+ for container in sorted(initial_containers, key=attrgetter('name'))
+ }
+
+
+class QueueItem(namedtuple('_QueueItem', 'item is_stop exc')):
+
+ @classmethod
+ def new(cls, item):
+ return cls(item, None, None)
+
+ @classmethod
+ def exception(cls, exc):
+ return cls(None, None, exc)
+
+ @classmethod
+ def stop(cls, item=None):
+ return cls(item, True, None)
+
+
+def tail_container_logs(container, presenter, queue, log_args):
+ try:
+ for item in build_log_generator(container, log_args):
+ queue.put(QueueItem.new(presenter.present(container, item)))
+ except Exception as e:
+ queue.put(QueueItem.exception(e))
+ return
+ if log_args.get('follow'):
+ queue.put(QueueItem.new(presenter.color_func(wait_on_exit(container))))
+ queue.put(QueueItem.stop(container.name))
+
+
+def build_log_generator(container, log_args):
+ # if the container doesn't have a log_stream we need to attach to container
+ # before log printer starts running
+ if container.log_stream is None:
+ stream = container.logs(stdout=True, stderr=True, stream=True, **log_args)
+ else:
+ stream = container.log_stream
+
+ return split_buffer(stream)
+
+
+def wait_on_exit(container):
+ try:
+ exit_code = container.wait()
+ return "{} exited with code {}\n".format(container.name, exit_code)
+ except APIError as e:
+ return "Unexpected API error for {} (HTTP code {})\nResponse body:\n{}\n".format(
+ container.name, e.response.status_code,
+ e.response.text or '[empty]'
+ )
+
+
+def start_producer_thread(thread_args):
+ producer = Thread(target=watch_events, args=thread_args)
+ producer.daemon = True
+ producer.start()
+
+
+def watch_events(thread_map, event_stream, presenters, thread_args):
+ crashed_containers = set()
+ for event in event_stream:
+ if event['action'] == 'stop':
+ thread_map.pop(event['id'], None)
+
+ if event['action'] == 'die':
+ thread_map.pop(event['id'], None)
+ crashed_containers.add(event['id'])
+
+ if event['action'] != 'start':
+ continue
+
+ if event['id'] in thread_map:
+ if thread_map[event['id']].is_alive():
+ continue
+ # Container was stopped and started, we need a new thread
+ thread_map.pop(event['id'], None)
+
+ # Container crashed so we should reattach to it
+ if event['id'] in crashed_containers:
+ container = event['container']
+ if not container.is_restarting:
+ try:
+ container.attach_log_stream()
+ except APIError:
+ # Just ignore errors when reattaching to already crashed containers
+ pass
+ crashed_containers.remove(event['id'])
+
+ thread_map[event['id']] = build_thread(
+ event['container'],
+ next(presenters),
+ *thread_args
+ )
+
+
+def consume_queue(queue, cascade_stop):
+ """Consume the queue by reading lines off of it and yielding them."""
+ while True:
+ try:
+ item = queue.get(timeout=0.1)
+ except Empty:
+ yield None
+ continue
+ # See https://github.com/docker/compose/issues/189
+ except thread.error:
+ raise ShutdownException()
+
+ if item.exc:
+ raise item.exc
+
+ if item.is_stop and not cascade_stop:
+ continue
+
+ yield item.item
diff --git a/compose/cli/main.py b/compose/cli/main.py
new file mode 100644
index 00000000000..25a2de88301
--- /dev/null
+++ b/compose/cli/main.py
@@ -0,0 +1,1695 @@
+import contextlib
+import functools
+import json
+import logging
+import pipes
+import re
+import subprocess
+import sys
+from distutils.spawn import find_executable
+from inspect import getdoc
+from operator import attrgetter
+
+import docker.errors
+import docker.utils
+
+from . import errors
+from . import signals
+from .. import __version__
+from ..config import ConfigurationError
+from ..config import parse_environment
+from ..config import parse_labels
+from ..config import resolve_build_args
+from ..config.environment import Environment
+from ..config.serialize import serialize_config
+from ..config.types import VolumeSpec
+from ..const import IS_LINUX_PLATFORM
+from ..const import IS_WINDOWS_PLATFORM
+from ..errors import StreamParseError
+from ..metrics.decorator import metrics
+from ..parallel import ParallelStreamWriter
+from ..progress_stream import StreamOutputError
+from ..project import get_image_digests
+from ..project import MissingDigests
+from ..project import NoSuchService
+from ..project import OneOffFilter
+from ..project import ProjectError
+from ..service import BuildAction
+from ..service import BuildError
+from ..service import ConvergenceStrategy
+from ..service import ImageType
+from ..service import NeedsBuildError
+from ..service import OperationFailedError
+from ..utils import filter_attached_for_up
+from .colors import AnsiMode
+from .command import get_config_from_options
+from .command import get_project_dir
+from .command import project_from_options
+from .docopt_command import DocoptDispatcher
+from .docopt_command import get_handler
+from .docopt_command import NoSuchCommand
+from .errors import UserError
+from .formatter import ConsoleWarningFormatter
+from .formatter import Formatter
+from .log_printer import build_log_presenters
+from .log_printer import LogPrinter
+from .utils import get_version_info
+from .utils import human_readable_file_size
+from .utils import yesno
+from compose.metrics.client import MetricsCommand
+from compose.metrics.client import Status
+
+
+if not IS_WINDOWS_PLATFORM:
+ from dockerpty.pty import PseudoTerminal, RunOperation, ExecOperation
+
+log = logging.getLogger(__name__)
+
+
+def main(): # noqa: C901
+ signals.ignore_sigpipe()
+ command = None
+ try:
+ _, opts, command = DocoptDispatcher.get_command_and_options(
+ TopLevelCommand,
+ get_filtered_args(sys.argv[1:]),
+ {'options_first': True, 'version': get_version_info('compose')})
+ except Exception:
+ pass
+ try:
+ command_func = dispatch()
+ command_func()
+ if not IS_LINUX_PLATFORM and command == 'help':
+ print("\nDocker Compose is now in the Docker CLI, try `docker compose` help")
+ except (KeyboardInterrupt, signals.ShutdownException):
+ exit_with_metrics(command, "Aborting.", status=Status.CANCELED)
+ except (UserError, NoSuchService, ConfigurationError,
+ ProjectError, OperationFailedError) as e:
+ exit_with_metrics(command, e.msg, status=Status.FAILURE)
+ except BuildError as e:
+ reason = ""
+ if e.reason:
+ reason = " : " + e.reason
+ exit_with_metrics(command,
+ "Service '{}' failed to build{}".format(e.service.name, reason),
+ status=Status.FAILURE)
+ except StreamOutputError as e:
+ exit_with_metrics(command, e, status=Status.FAILURE)
+ except NeedsBuildError as e:
+ exit_with_metrics(command,
+ "Service '{}' needs to be built, but --no-build was passed.".format(
+ e.service.name), status=Status.FAILURE)
+ except NoSuchCommand as e:
+ commands = "\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))
+ if not IS_LINUX_PLATFORM:
+ commands += "\n\nDocker Compose is now in the Docker CLI, try `docker compose`"
+ exit_with_metrics("", log_msg="No such command: {}\n\n{}".format(
+ e.command, commands), status=Status.FAILURE)
+ except (errors.ConnectionError, StreamParseError):
+ exit_with_metrics(command, status=Status.FAILURE)
+ except SystemExit as e:
+ status = Status.SUCCESS
+ if len(sys.argv) > 1 and '--help' not in sys.argv:
+ status = Status.FAILURE
+
+ if command and len(sys.argv) >= 3 and sys.argv[2] == '--help':
+ command = '--help ' + command
+
+ if not command and len(sys.argv) >= 2 and sys.argv[1] == '--help':
+ command = '--help'
+
+ msg = e.args[0] if len(e.args) else ""
+ code = 0
+ if isinstance(e.code, int):
+ code = e.code
+
+ if not IS_LINUX_PLATFORM and not command:
+ msg += "\n\nDocker Compose is now in the Docker CLI, try `docker compose`"
+
+ exit_with_metrics(command, log_msg=msg, status=status,
+ exit_code=code)
+
+
+def get_filtered_args(args):
+ if args[0] in ('-h', '--help'):
+ return []
+ if args[0] == '--version':
+ return ['version']
+
+
+def exit_with_metrics(command, log_msg=None, status=Status.SUCCESS, exit_code=1):
+ if log_msg and command != 'exec':
+ if not exit_code:
+ log.info(log_msg)
+ else:
+ log.error(log_msg)
+
+ MetricsCommand(command, status=status).send_metrics()
+ sys.exit(exit_code)
+
+
+def dispatch():
+ console_stream = sys.stderr
+ console_handler = logging.StreamHandler(console_stream)
+ setup_logging(console_handler)
+ dispatcher = DocoptDispatcher(
+ TopLevelCommand,
+ {'options_first': True, 'version': get_version_info('compose')})
+
+ options, handler, command_options = dispatcher.parse(sys.argv[1:])
+
+ ansi_mode = AnsiMode.AUTO
+ try:
+ if options.get("--ansi"):
+ ansi_mode = AnsiMode(options.get("--ansi"))
+ except ValueError:
+ raise UserError(
+ 'Invalid value for --ansi: {}. Expected one of {}.'.format(
+ options.get("--ansi"),
+ ', '.join(m.value for m in AnsiMode)
+ )
+ )
+ if options.get("--no-ansi"):
+ if options.get("--ansi"):
+ raise UserError("--no-ansi and --ansi cannot be combined.")
+ log.warning('--no-ansi option is deprecated and will be removed in future versions. '
+ 'Use `--ansi never` instead.')
+ ansi_mode = AnsiMode.NEVER
+
+ setup_console_handler(console_handler,
+ options.get('--verbose'),
+ ansi_mode.use_ansi_codes(console_handler.stream),
+ options.get("--log-level"))
+ setup_parallel_logger(ansi_mode)
+ if ansi_mode is AnsiMode.NEVER:
+ command_options['--no-color'] = True
+ return functools.partial(perform_command, options, handler, command_options)
+
+
+def perform_command(options, handler, command_options):
+ if options['COMMAND'] in ('help', 'version'):
+ # Skip looking up the compose file.
+ handler(command_options)
+ return
+
+ if options['COMMAND'] == 'config':
+ command = TopLevelCommand(None, options=options)
+ handler(command, command_options)
+ return
+
+ project = project_from_options('.', options)
+ command = TopLevelCommand(project, options=options)
+ with errors.handle_connection_errors(project.client):
+ handler(command, command_options)
+
+
+def setup_logging(console_handler):
+ root_logger = logging.getLogger()
+ root_logger.addHandler(console_handler)
+ root_logger.setLevel(logging.DEBUG)
+
+ # Disable requests and docker-py logging
+ logging.getLogger("urllib3").propagate = False
+ logging.getLogger("requests").propagate = False
+ logging.getLogger("docker").propagate = False
+
+
+def setup_parallel_logger(ansi_mode):
+ ParallelStreamWriter.set_default_ansi_mode(ansi_mode)
+
+
+def setup_console_handler(handler, verbose, use_console_formatter=True, level=None):
+ if use_console_formatter:
+ format_class = ConsoleWarningFormatter
+ else:
+ format_class = logging.Formatter
+
+ if verbose:
+ handler.setFormatter(format_class('%(name)s.%(funcName)s: %(message)s'))
+ loglevel = logging.DEBUG
+ else:
+ handler.setFormatter(format_class())
+ loglevel = logging.INFO
+
+ if level is not None:
+ levels = {
+ 'DEBUG': logging.DEBUG,
+ 'INFO': logging.INFO,
+ 'WARNING': logging.WARNING,
+ 'ERROR': logging.ERROR,
+ 'CRITICAL': logging.CRITICAL,
+ }
+ loglevel = levels.get(level.upper())
+ if loglevel is None:
+ raise UserError(
+ 'Invalid value for --log-level. Expected one of DEBUG, INFO, WARNING, ERROR, CRITICAL.'
+ )
+
+ handler.setLevel(loglevel)
+
+
+# stolen from docopt master
+def parse_doc_section(name, source):
+ pattern = re.compile('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)',
+ re.IGNORECASE | re.MULTILINE)
+ return [s.strip() for s in pattern.findall(source)]
+
+
+class TopLevelCommand:
+ """Define and run multi-container applications with Docker.
+
+ Usage:
+ docker-compose [-f ...] [--profile ...] [options] [--] [COMMAND] [ARGS...]
+ docker-compose -h|--help
+
+ Options:
+ -f, --file FILE Specify an alternate compose file
+ (default: docker-compose.yml)
+ -p, --project-name NAME Specify an alternate project name
+ (default: directory name)
+ --profile NAME Specify a profile to enable
+ -c, --context NAME Specify a context name
+ --verbose Show more output
+ --log-level LEVEL Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
+ --ansi (never|always|auto) Control when to print ANSI control characters
+ --no-ansi Do not print ANSI control characters (DEPRECATED)
+ -v, --version Print version and exit
+ -H, --host HOST Daemon socket to connect to
+
+ --tls Use TLS; implied by --tlsverify
+ --tlscacert CA_PATH Trust certs signed only by this CA
+ --tlscert CLIENT_CERT_PATH Path to TLS certificate file
+ --tlskey TLS_KEY_PATH Path to TLS key file
+ --tlsverify Use TLS and verify the remote
+ --skip-hostname-check Don't check the daemon's hostname against the
+ name specified in the client certificate
+ --project-directory PATH Specify an alternate working directory
+ (default: the path of the Compose file)
+ --compatibility If set, Compose will attempt to convert keys
+ in v3 files to their non-Swarm equivalent (DEPRECATED)
+ --env-file PATH Specify an alternate environment file
+
+ Commands:
+ build Build or rebuild services
+ config Validate and view the Compose file
+ create Create services
+ down Stop and remove resources
+ events Receive real time events from containers
+ exec Execute a command in a running container
+ help Get help on a command
+ images List images
+ kill Kill containers
+ logs View output from containers
+ pause Pause services
+ port Print the public port for a port binding
+ ps List containers
+ pull Pull service images
+ push Push service images
+ restart Restart services
+ rm Remove stopped containers
+ run Run a one-off command
+ scale Set number of containers for a service
+ start Start services
+ stop Stop services
+ top Display the running processes
+ unpause Unpause services
+ up Create and start containers
+ version Show version information and quit
+ """
+
+ def __init__(self, project, options=None):
+ self.project = project
+ self.toplevel_options = options or {}
+
+ @property
+ def project_dir(self):
+ return get_project_dir(self.toplevel_options)
+
+ @property
+ def toplevel_environment(self):
+ environment_file = self.toplevel_options.get('--env-file')
+ return Environment.from_env_file(self.project_dir, environment_file)
+
+ @metrics()
+ def build(self, options):
+ """
+ Build or rebuild services.
+
+ Services are built once and then tagged as `project_service`,
+ e.g. `composetest_db`. If you change a service's `Dockerfile` or the
+ contents of its build directory, you can run `docker-compose build` to rebuild it.
+
+ Usage: build [options] [--build-arg key=val...] [--] [SERVICE...]
+
+ Options:
+ --build-arg key=val Set build-time variables for services.
+ --compress Compress the build context using gzip.
+ --force-rm Always remove intermediate containers.
+ -m, --memory MEM Set memory limit for the build container.
+ --no-cache Do not use cache when building the image.
+ --no-rm Do not remove intermediate containers after a successful build.
+ --parallel Build images in parallel.
+ --progress string Set type of progress output (auto, plain, tty).
+ --pull Always attempt to pull a newer version of the image.
+ -q, --quiet Don't print anything to STDOUT
+ """
+ service_names = options['SERVICE']
+ build_args = options.get('--build-arg', None)
+ if build_args:
+ if not service_names and docker.utils.version_lt(self.project.client.api_version, '1.25'):
+ raise UserError(
+ '--build-arg is only supported when services are specified for API version < 1.25.'
+ ' Please use a Compose file version > 2.2 or specify which services to build.'
+ )
+ build_args = resolve_build_args(build_args, self.toplevel_environment)
+
+ native_builder = self.toplevel_environment.get_boolean('COMPOSE_DOCKER_CLI_BUILD', True)
+
+ self.project.build(
+ service_names=options['SERVICE'],
+ no_cache=bool(options.get('--no-cache', False)),
+ pull=bool(options.get('--pull', False)),
+ force_rm=bool(options.get('--force-rm', False)),
+ memory=options.get('--memory'),
+ rm=not bool(options.get('--no-rm', False)),
+ build_args=build_args,
+ gzip=options.get('--compress', False),
+ parallel_build=options.get('--parallel', False),
+ silent=options.get('--quiet', False),
+ cli=native_builder,
+ progress=options.get('--progress'),
+ )
+
+ @metrics()
+ def config(self, options):
+ """
+ Validate and view the Compose file.
+
+ Usage: config [options]
+
+ Options:
+ --resolve-image-digests Pin image tags to digests.
+ --no-interpolate Don't interpolate environment variables.
+ -q, --quiet Only validate the configuration, don't print
+ anything.
+ --profiles Print the profile names, one per line.
+ --services Print the service names, one per line.
+ --volumes Print the volume names, one per line.
+ --hash="*" Print the service config hash, one per line.
+ Set "service1,service2" for a list of specified services
+ or use the wildcard symbol to display all services.
+ """
+
+ additional_options = {'--no-interpolate': options.get('--no-interpolate')}
+ compose_config = get_config_from_options('.', self.toplevel_options, additional_options)
+ image_digests = None
+
+ if options['--resolve-image-digests']:
+ self.project = project_from_options('.', self.toplevel_options, additional_options)
+ with errors.handle_connection_errors(self.project.client):
+ image_digests = image_digests_for_project(self.project)
+
+ if options['--quiet']:
+ return
+
+ if options['--profiles']:
+ profiles = set()
+ for service in compose_config.services:
+ if 'profiles' in service:
+ for profile in service['profiles']:
+ profiles.add(profile)
+ print('\n'.join(sorted(profiles)))
+ return
+
+ if options['--services']:
+ print('\n'.join(service['name'] for service in compose_config.services))
+ return
+
+ if options['--volumes']:
+ print('\n'.join(volume for volume in compose_config.volumes))
+ return
+
+ if options['--hash'] is not None:
+ h = options['--hash']
+ self.project = project_from_options('.', self.toplevel_options, additional_options)
+ services = [svc for svc in options['--hash'].split(',')] if h != '*' else None
+ with errors.handle_connection_errors(self.project.client):
+ for service in self.project.get_services(services):
+ print('{} {}'.format(service.name, service.config_hash))
+ return
+
+ print(serialize_config(compose_config, image_digests, not options['--no-interpolate']))
+
+ @metrics()
+ def create(self, options):
+ """
+ Creates containers for a service.
+ This command is deprecated. Use the `up` command with `--no-start` instead.
+
+ Usage: create [options] [SERVICE...]
+
+ Options:
+ --force-recreate Recreate containers even if their configuration and
+ image haven't changed. Incompatible with --no-recreate.
+ --no-recreate If containers already exist, don't recreate them.
+ Incompatible with --force-recreate.
+ --no-build Don't build an image, even if it's missing.
+ --build Build images before creating containers.
+ """
+ service_names = options['SERVICE']
+
+ log.warning(
+ 'The create command is deprecated. '
+ 'Use the up command with the --no-start flag instead.'
+ )
+
+ self.project.create(
+ service_names=service_names,
+ strategy=convergence_strategy_from_opts(options),
+ do_build=build_action_from_opts(options),
+ )
+
+ @metrics()
+ def down(self, options):
+ """
+ Stops containers and removes containers, networks, volumes, and images
+ created by `up`.
+
+ By default, the only things removed are:
+
+ - Containers for services defined in the Compose file
+ - Networks defined in the `networks` section of the Compose file
+ - The default network, if one is used
+
+ Networks and volumes defined as `external` are never removed.
+
+ Usage: down [options]
+
+ Options:
+ --rmi type Remove images. Type must be one of:
+ 'all': Remove all images used by any service.
+ 'local': Remove only images that don't have a
+ custom tag set by the `image` field.
+ -v, --volumes Remove named volumes declared in the `volumes`
+ section of the Compose file and anonymous volumes
+ attached to containers.
+ --remove-orphans Remove containers for services not defined in the
+ Compose file
+ -t, --timeout TIMEOUT Specify a shutdown timeout in seconds.
+ (default: 10)
+ """
+ ignore_orphans = self.toplevel_environment.get_boolean('COMPOSE_IGNORE_ORPHANS')
+
+ if ignore_orphans and options['--remove-orphans']:
+ raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.")
+
+ image_type = image_type_from_opt('--rmi', options['--rmi'])
+ timeout = timeout_from_opts(options)
+ self.project.down(
+ image_type,
+ options['--volumes'],
+ options['--remove-orphans'],
+ timeout=timeout,
+ ignore_orphans=ignore_orphans)
+
+ def events(self, options):
+ """
+ Receive real time events from containers.
+
+ Usage: events [options] [--] [SERVICE...]
+
+ Options:
+ --json Output events as a stream of json objects
+ """
+
+ def format_event(event):
+ attributes = ["%s=%s" % item for item in event['attributes'].items()]
+ return ("{time} {type} {action} {id} ({attrs})").format(
+ attrs=", ".join(sorted(attributes)),
+ **event)
+
+ def json_format_event(event):
+ event['time'] = event['time'].isoformat()
+ event.pop('container')
+ return json.dumps(event)
+
+ for event in self.project.events():
+ formatter = json_format_event if options['--json'] else format_event
+ print(formatter(event))
+ sys.stdout.flush()
+
+ @metrics("exec")
+ def exec_command(self, options):
+ """
+ Execute a command in a running container
+
+ Usage: exec [options] [-e KEY=VAL...] [--] SERVICE COMMAND [ARGS...]
+
+ Options:
+ -d, --detach Detached mode: Run command in the background.
+ --privileged Give extended privileges to the process.
+ -u, --user USER Run the command as this user.
+ -T Disable pseudo-tty allocation. By default `docker-compose exec`
+ allocates a TTY.
+ --index=index index of the container if there are multiple
+ instances of a service [default: 1]
+ -e, --env KEY=VAL Set environment variables (can be used multiple times,
+ not supported in API < 1.25)
+ -w, --workdir DIR Path to workdir directory for this command.
+ """
+ use_cli = not self.toplevel_environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI')
+ index = int(options.get('--index'))
+ service = self.project.get_service(options['SERVICE'])
+ detach = options.get('--detach')
+
+ if options['--env'] and docker.utils.version_lt(self.project.client.api_version, '1.25'):
+ raise UserError("Setting environment for exec is not supported in API < 1.25 (%s)"
+ % self.project.client.api_version)
+
+ if options['--workdir'] and docker.utils.version_lt(self.project.client.api_version, '1.35'):
+ raise UserError("Setting workdir for exec is not supported in API < 1.35 (%s)"
+ % self.project.client.api_version)
+
+ try:
+ container = service.get_container(number=index)
+ except ValueError as e:
+ raise UserError(str(e))
+ command = [options['COMMAND']] + options['ARGS']
+ tty = not options["-T"]
+
+ if IS_WINDOWS_PLATFORM or use_cli and not detach:
+ sys.exit(call_docker(
+ build_exec_command(options, container.id, command),
+ self.toplevel_options, self.toplevel_environment)
+ )
+
+ create_exec_options = {
+ "privileged": options["--privileged"],
+ "user": options["--user"],
+ "tty": tty,
+ "stdin": True,
+ "workdir": options["--workdir"],
+ }
+
+ if docker.utils.version_gte(self.project.client.api_version, '1.25'):
+ create_exec_options["environment"] = options["--env"]
+
+ exec_id = container.create_exec(command, **create_exec_options)
+
+ if detach:
+ container.start_exec(exec_id, tty=tty, stream=True)
+ return
+
+ signals.set_signal_handler_to_shutdown()
+ try:
+ operation = ExecOperation(
+ self.project.client,
+ exec_id,
+ interactive=tty,
+ )
+ pty = PseudoTerminal(self.project.client, operation)
+ pty.start()
+ except signals.ShutdownException:
+ log.info("received shutdown exception: closing")
+ exit_code = self.project.client.exec_inspect(exec_id).get("ExitCode")
+ sys.exit(exit_code)
+
+ @classmethod
+ @metrics()
+ def help(cls, options):
+ """
+ Get help on a command.
+
+ Usage: help [COMMAND]
+ """
+ if options['COMMAND']:
+ subject = get_handler(cls, options['COMMAND'])
+ else:
+ subject = cls
+
+ print(getdoc(subject))
+
+ @metrics()
+ def images(self, options):
+ """
+ List images used by the created containers.
+ Usage: images [options] [--] [SERVICE...]
+
+ Options:
+ -q, --quiet Only display IDs
+ """
+ containers = sorted(
+ self.project.containers(service_names=options['SERVICE'], stopped=True) +
+ self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only),
+ key=attrgetter('name'))
+
+ if options['--quiet']:
+ for image in {c.image for c in containers}:
+ print(image.split(':')[1])
+ return
+
+ def add_default_tag(img_name):
+ if ':' not in img_name.split('/')[-1]:
+ return '{}:latest'.format(img_name)
+ return img_name
+
+ headers = [
+ 'Container',
+ 'Repository',
+ 'Tag',
+ 'Image Id',
+ 'Size'
+ ]
+ rows = []
+ for container in containers:
+ image_config = container.image_config
+ service = self.project.get_service(container.service)
+ index = 0
+ img_name = add_default_tag(service.image_name)
+ if img_name in image_config['RepoTags']:
+ index = image_config['RepoTags'].index(img_name)
+ repo_tags = (
+ image_config['RepoTags'][index].rsplit(':', 1) if image_config['RepoTags']
+ else ('', '')
+ )
+
+ image_id = image_config['Id'].split(':')[1][:12]
+ size = human_readable_file_size(image_config['Size'])
+ rows.append([
+ container.name,
+ repo_tags[0],
+ repo_tags[1],
+ image_id,
+ size
+ ])
+ print(Formatter.table(headers, rows))
+
+ @metrics()
+ def kill(self, options):
+ """
+ Force stop service containers.
+
+ Usage: kill [options] [--] [SERVICE...]
+
+ Options:
+ -s SIGNAL SIGNAL to send to the container.
+ Default signal is SIGKILL.
+ """
+ signal = options.get('-s', 'SIGKILL')
+
+ self.project.kill(service_names=options['SERVICE'], signal=signal)
+
+ @metrics()
+ def logs(self, options):
+ """
+ View output from containers.
+
+ Usage: logs [options] [--] [SERVICE...]
+
+ Options:
+ --no-color Produce monochrome output.
+ -f, --follow Follow log output.
+ -t, --timestamps Show timestamps.
+ --tail="all" Number of lines to show from the end of the logs
+ for each container.
+ --no-log-prefix Don't print prefix in logs.
+ """
+ containers = self.project.containers(service_names=options['SERVICE'], stopped=True)
+
+ tail = options['--tail']
+ if tail is not None:
+ if tail.isdigit():
+ tail = int(tail)
+ elif tail != 'all':
+ raise UserError("tail flag must be all or a number")
+ log_args = {
+ 'follow': options['--follow'],
+ 'tail': tail,
+ 'timestamps': options['--timestamps']
+ }
+ print("Attaching to", list_containers(containers))
+ log_printer_from_project(
+ self.project,
+ containers,
+ options['--no-color'],
+ log_args,
+ event_stream=self.project.events(service_names=options['SERVICE']),
+ keep_prefix=not options['--no-log-prefix']).run()
+
+ @metrics()
+ def pause(self, options):
+ """
+ Pause services.
+
+ Usage: pause [SERVICE...]
+ """
+ containers = self.project.pause(service_names=options['SERVICE'])
+ exit_if(not containers, 'No containers to pause', 1)
+
+ @metrics()
+ def port(self, options):
+ """
+ Print the public port for a port binding.
+
+ Usage: port [options] [--] SERVICE PRIVATE_PORT
+
+ Options:
+ --protocol=proto tcp or udp [default: tcp]
+ --index=index index of the container if there are multiple
+ instances of a service [default: 1]
+ """
+ index = int(options.get('--index'))
+ service = self.project.get_service(options['SERVICE'])
+ try:
+ container = service.get_container(number=index)
+ except ValueError as e:
+ raise UserError(str(e))
+ print(container.get_local_port(
+ options['PRIVATE_PORT'],
+ protocol=options.get('--protocol') or 'tcp') or '')
+
+ @metrics()
+ def ps(self, options):
+ """
+ List containers.
+
+ Usage: ps [options] [--] [SERVICE...]
+
+ Options:
+ -q, --quiet Only display IDs
+ --services Display services
+ --filter KEY=VAL Filter services by a property. KEY is either:
+ 1. `source` with values `image`, or `build`;
+ 2. `status` with values `running`, `stopped`, `paused`, or `restarted`.
+ -a, --all Show all stopped containers (including those created by the run command)
+ """
+ if options['--quiet'] and options['--services']:
+ raise UserError('--quiet and --services cannot be combined')
+
+ if options['--services']:
+ filt = build_filter(options.get('--filter'))
+ services = self.project.services
+ if filt:
+ services = filter_services(filt, services, self.project)
+ print('\n'.join(service.name for service in services))
+ return
+
+ if options['--all']:
+ containers = sorted(self.project.containers(service_names=options['SERVICE'],
+ one_off=OneOffFilter.include, stopped=True),
+ key=attrgetter('name'))
+ else:
+ containers = sorted(
+ self.project.containers(service_names=options['SERVICE'], stopped=True) +
+ self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only),
+ key=attrgetter('name'))
+
+ if options['--quiet']:
+ for container in containers:
+ print(container.id)
+ else:
+ headers = [
+ 'Name',
+ 'Command',
+ 'State',
+ 'Ports',
+ ]
+ rows = []
+ for container in containers:
+ command = container.human_readable_command
+ if len(command) > 30:
+ command = '%s ...' % command[:26]
+ rows.append([
+ container.name,
+ command,
+ container.human_readable_state,
+ container.human_readable_ports,
+ ])
+ print(Formatter.table(headers, rows))
+
+ @metrics()
+ def pull(self, options):
+ """
+ Pulls images for services defined in a Compose file, but does not start the containers.
+
+ Usage: pull [options] [--] [SERVICE...]
+
+ Options:
+ --ignore-pull-failures Pull what it can and ignores images with pull failures.
+ --parallel Deprecated, pull multiple images in parallel (enabled by default).
+ --no-parallel Disable parallel pulling.
+ -q, --quiet Pull without printing progress information
+ --include-deps Also pull services declared as dependencies
+ """
+ if options.get('--parallel'):
+ log.warning('--parallel option is deprecated and will be removed in future versions.')
+ self.project.pull(
+ service_names=options['SERVICE'],
+ ignore_pull_failures=options.get('--ignore-pull-failures'),
+ parallel_pull=not options.get('--no-parallel'),
+ silent=options.get('--quiet'),
+ include_deps=options.get('--include-deps'),
+ )
+
+ @metrics()
+ def push(self, options):
+ """
+ Pushes images for services.
+
+ Usage: push [options] [--] [SERVICE...]
+
+ Options:
+ --ignore-push-failures Push what it can and ignores images with push failures.
+ """
+ self.project.push(
+ service_names=options['SERVICE'],
+ ignore_push_failures=options.get('--ignore-push-failures')
+ )
+
+ @metrics()
+ def rm(self, options):
+ """
+ Removes stopped service containers.
+
+ By default, anonymous volumes attached to containers will not be removed. You
+ can override this with `-v`. To list all volumes, use `docker volume ls`.
+
+ Any data which is not in a volume will be lost.
+
+ Usage: rm [options] [--] [SERVICE...]
+
+ Options:
+ -f, --force Don't ask to confirm removal
+ -s, --stop Stop the containers, if required, before removing
+ -v Remove any anonymous volumes attached to containers
+ -a, --all Deprecated - no effect.
+ """
+ if options.get('--all'):
+ log.warning(
+ '--all flag is obsolete. This is now the default behavior '
+ 'of `docker-compose rm`'
+ )
+ one_off = OneOffFilter.include
+
+ if options.get('--stop'):
+ self.project.stop(service_names=options['SERVICE'], one_off=one_off)
+
+ all_containers = self.project.containers(
+ service_names=options['SERVICE'], stopped=True, one_off=one_off
+ )
+ stopped_containers = [c for c in all_containers if not c.is_running]
+
+ if len(stopped_containers) > 0:
+ print("Going to remove", list_containers(stopped_containers))
+ if options.get('--force') \
+ or yesno("Are you sure? [yN] ", default=False):
+ self.project.remove_stopped(
+ service_names=options['SERVICE'],
+ v=options.get('-v', False),
+ one_off=one_off
+ )
+ else:
+ print("No stopped containers")
+
+ @metrics()
+ def run(self, options):
+ """
+ Run a one-off command on a service.
+
+ For example:
+
+ $ docker-compose run web python manage.py shell
+
+ By default, linked services will be started, unless they are already
+ running. If you do not want to start linked services, use
+ `docker-compose run --no-deps SERVICE COMMAND [ARGS...]`.
+
+ Usage:
+ run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] [-l KEY=VALUE...] [--]
+ SERVICE [COMMAND] [ARGS...]
+
+ Options:
+ -d, --detach Detached mode: Run container in the background, print
+ new container name.
+ --name NAME Assign a name to the container
+ --entrypoint CMD Override the entrypoint of the image.
+ -e KEY=VAL Set an environment variable (can be used multiple times)
+ -l, --label KEY=VAL Add or override a label (can be used multiple times)
+ -u, --user="" Run as specified username or uid
+ --no-deps Don't start linked services.
+ --rm Remove container after run. Ignored in detached mode.
+ -p, --publish=[] Publish a container's port(s) to the host
+ --service-ports Run command with the service's ports enabled and mapped
+ to the host.
+ --use-aliases Use the service's network aliases in the network(s) the
+ container connects to.
+ -v, --volume=[] Bind mount a volume (default [])
+ -T Disable pseudo-tty allocation. By default `docker-compose run`
+ allocates a TTY.
+ -w, --workdir="" Working directory inside the container
+ """
+ service = self.project.get_service(options['SERVICE'])
+ detach = options.get('--detach')
+
+ if options['--publish'] and options['--service-ports']:
+ raise UserError(
+ 'Service port mapping and manual port mapping '
+ 'can not be used together'
+ )
+
+ if options['COMMAND'] is not None:
+ command = [options['COMMAND']] + options['ARGS']
+ elif options['--entrypoint'] is not None:
+ command = []
+ else:
+ command = service.options.get('command')
+
+ options['stdin_open'] = service.options.get('stdin_open', True)
+
+ container_options = build_one_off_container_options(options, detach, command)
+ run_one_off_container(
+ container_options, self.project, service, options,
+ self.toplevel_options, self.toplevel_environment
+ )
+
+ @metrics()
+ def scale(self, options):
+ """
+ Set number of containers to run for a service.
+
+ Numbers are specified in the form `service=num` as arguments.
+ For example:
+
+ $ docker-compose scale web=2 worker=3
+
+ This command is deprecated. Use the up command with the `--scale` flag
+ instead.
+
+ Usage: scale [options] [SERVICE=NUM...]
+
+ Options:
+ -t, --timeout TIMEOUT Specify a shutdown timeout in seconds.
+ (default: 10)
+ """
+ timeout = timeout_from_opts(options)
+
+ log.warning(
+ 'The scale command is deprecated. '
+ 'Use the up command with the --scale flag instead.'
+ )
+
+ for service_name, num in parse_scale_args(options['SERVICE=NUM']).items():
+ self.project.get_service(service_name).scale(num, timeout=timeout)
+
+ @metrics()
+ def start(self, options):
+ """
+ Start existing containers.
+
+ Usage: start [SERVICE...]
+ """
+ containers = self.project.start(service_names=options['SERVICE'])
+ exit_if(not containers, 'No containers to start', 1)
+
+ @metrics()
+ def stop(self, options):
+ """
+ Stop running containers without removing them.
+
+ They can be started again with `docker-compose start`.
+
+ Usage: stop [options] [--] [SERVICE...]
+
+ Options:
+ -t, --timeout TIMEOUT Specify a shutdown timeout in seconds.
+ (default: 10)
+ """
+ timeout = timeout_from_opts(options)
+ self.project.stop(service_names=options['SERVICE'], timeout=timeout)
+
+ @metrics()
+ def restart(self, options):
+ """
+ Restart running containers.
+
+ Usage: restart [options] [--] [SERVICE...]
+
+ Options:
+ -t, --timeout TIMEOUT Specify a shutdown timeout in seconds.
+ (default: 10)
+ """
+ timeout = timeout_from_opts(options)
+ containers = self.project.restart(service_names=options['SERVICE'], timeout=timeout)
+ exit_if(not containers, 'No containers to restart', 1)
+
+ @metrics()
+ def top(self, options):
+ """
+ Display the running processes
+
+ Usage: top [SERVICE...]
+
+ """
+ containers = sorted(
+ self.project.containers(service_names=options['SERVICE'], stopped=False) +
+ self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only),
+ key=attrgetter('name')
+ )
+
+ for idx, container in enumerate(containers):
+ if idx > 0:
+ print()
+
+ top_data = self.project.client.top(container.name)
+ headers = top_data.get("Titles")
+ rows = []
+
+ for process in top_data.get("Processes", []):
+ rows.append(process)
+
+ print(container.name)
+ print(Formatter.table(headers, rows))
+
+ @metrics()
+ def unpause(self, options):
+ """
+ Unpause services.
+
+ Usage: unpause [SERVICE...]
+ """
+ containers = self.project.unpause(service_names=options['SERVICE'])
+ exit_if(not containers, 'No containers to unpause', 1)
+
+ @metrics()
+ def up(self, options):
+ """
+ Builds, (re)creates, starts, and attaches to containers for a service.
+
+ Unless they are already running, this command also starts any linked services.
+
+ The `docker-compose up` command aggregates the output of each container. When
+ the command exits, all containers are stopped. Running `docker-compose up -d`
+ starts the containers in the background and leaves them running.
+
+ If there are existing containers for a service, and the service's configuration
+ or image was changed after the container's creation, `docker-compose up` picks
+ up the changes by stopping and recreating the containers (preserving mounted
+ volumes). To prevent Compose from picking up changes, use the `--no-recreate`
+ flag.
+
+ If you want to force Compose to stop and recreate all containers, use the
+ `--force-recreate` flag.
+
+ Usage: up [options] [--scale SERVICE=NUM...] [--] [SERVICE...]
+
+ Options:
+ -d, --detach Detached mode: Run containers in the background,
+ print new container names. Incompatible with
+ --abort-on-container-exit.
+ --no-color Produce monochrome output.
+ --quiet-pull Pull without printing progress information
+ --no-deps Don't start linked services.
+ --force-recreate Recreate containers even if their configuration
+ and image haven't changed.
+ --always-recreate-deps Recreate dependent containers.
+ Incompatible with --no-recreate.
+ --no-recreate If containers already exist, don't recreate
+ them. Incompatible with --force-recreate and -V.
+ --no-build Don't build an image, even if it's missing.
+ --no-start Don't start the services after creating them.
+ --build Build images before starting containers.
+ --abort-on-container-exit Stops all containers if any container was
+ stopped. Incompatible with -d.
+ --attach-dependencies Attach to dependent containers.
+ -t, --timeout TIMEOUT Use this timeout in seconds for container
+ shutdown when attached or when containers are
+ already running. (default: 10)
+ -V, --renew-anon-volumes Recreate anonymous volumes instead of retrieving
+ data from the previous containers.
+ --remove-orphans Remove containers for services not defined
+ in the Compose file.
+ --exit-code-from SERVICE Return the exit code of the selected service
+ container. Implies --abort-on-container-exit.
+ --scale SERVICE=NUM Scale SERVICE to NUM instances. Overrides the
+ `scale` setting in the Compose file if present.
+ --no-log-prefix Don't print prefix in logs.
+ """
+ start_deps = not options['--no-deps']
+ always_recreate_deps = options['--always-recreate-deps']
+ exit_value_from = exitval_from_opts(options, self.project)
+ cascade_stop = options['--abort-on-container-exit']
+ service_names = options['SERVICE']
+ timeout = timeout_from_opts(options)
+ remove_orphans = options['--remove-orphans']
+ detached = options.get('--detach')
+ no_start = options.get('--no-start')
+ attach_dependencies = options.get('--attach-dependencies')
+ keep_prefix = not options.get('--no-log-prefix')
+
+ if detached and (cascade_stop or exit_value_from or attach_dependencies):
+ raise UserError(
+ "-d cannot be combined with --abort-on-container-exit or --attach-dependencies.")
+
+ ignore_orphans = self.toplevel_environment.get_boolean('COMPOSE_IGNORE_ORPHANS')
+
+ if ignore_orphans and remove_orphans:
+ raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.")
+
+ opts = ['--detach', '--abort-on-container-exit', '--exit-code-from', '--attach-dependencies']
+ for excluded in [x for x in opts if options.get(x) and no_start]:
+ raise UserError('--no-start and {} cannot be combined.'.format(excluded))
+
+ native_builder = self.toplevel_environment.get_boolean('COMPOSE_DOCKER_CLI_BUILD', True)
+
+ with up_shutdown_context(self.project, service_names, timeout, detached):
+ warn_for_swarm_mode(self.project.client)
+
+ def up(rebuild):
+ return self.project.up(
+ service_names=service_names,
+ start_deps=start_deps,
+ strategy=convergence_strategy_from_opts(options),
+ do_build=build_action_from_opts(options),
+ timeout=timeout,
+ detached=detached,
+ remove_orphans=remove_orphans,
+ ignore_orphans=ignore_orphans,
+ scale_override=parse_scale_args(options['--scale']),
+ start=not no_start,
+ always_recreate_deps=always_recreate_deps,
+ reset_container_image=rebuild,
+ renew_anonymous_volumes=options.get('--renew-anon-volumes'),
+ silent=options.get('--quiet-pull'),
+ cli=native_builder,
+ attach_dependencies=attach_dependencies,
+ )
+
+ try:
+ to_attach = up(False)
+ except docker.errors.ImageNotFound as e:
+ log.error(
+ "The image for the service you're trying to recreate has been removed. "
+ "If you continue, volume data could be lost. Consider backing up your data "
+ "before continuing.\n"
+ )
+ res = yesno("Continue with the new image? [yN]", False)
+ if res is None or not res:
+ raise e
+
+ to_attach = up(True)
+
+ if detached or no_start:
+ return
+
+ attached_containers = filter_attached_containers(
+ to_attach,
+ service_names,
+ attach_dependencies)
+
+ log_printer = log_printer_from_project(
+ self.project,
+ attached_containers,
+ options['--no-color'],
+ {'follow': True},
+ cascade_stop,
+ event_stream=self.project.events(service_names=service_names),
+ keep_prefix=keep_prefix)
+ print("Attaching to", list_containers(log_printer.containers))
+ cascade_starter = log_printer.run()
+
+ if cascade_stop:
+ print("Aborting on container exit...")
+ all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True)
+ exit_code = compute_exit_code(
+ exit_value_from, attached_containers, cascade_starter, all_containers
+ )
+
+ self.project.stop(service_names=service_names, timeout=timeout)
+ if exit_value_from:
+ exit_code = compute_service_exit_code(exit_value_from, attached_containers)
+
+ sys.exit(exit_code)
+
+ @classmethod
+ @metrics()
+ def version(cls, options):
+ """
+ Show version information and quit.
+
+ Usage: version [--short]
+
+ Options:
+ --short Shows only Compose's version number.
+ """
+ if options['--short']:
+ print(__version__)
+ else:
+ print(get_version_info('full'))
+
+
+def compute_service_exit_code(exit_value_from, attached_containers):
+ candidates = list(filter(
+ lambda c: c.service == exit_value_from,
+ attached_containers))
+ if not candidates:
+ log.error(
+ 'No containers matching the spec "{}" '
+ 'were run.'.format(exit_value_from)
+ )
+ return 2
+ if len(candidates) > 1:
+ exit_values = filter(
+ lambda e: e != 0,
+ [c.inspect()['State']['ExitCode'] for c in candidates]
+ )
+
+ return exit_values[0]
+ return candidates[0].inspect()['State']['ExitCode']
+
+
+def compute_exit_code(exit_value_from, attached_containers, cascade_starter, all_containers):
+ exit_code = 0
+ for e in all_containers:
+ if (not e.is_running and cascade_starter == e.name):
+ if not e.exit_code == 0:
+ exit_code = e.exit_code
+ break
+
+ return exit_code
+
+
+def convergence_strategy_from_opts(options):
+ no_recreate = options['--no-recreate']
+ force_recreate = options['--force-recreate']
+ renew_anonymous_volumes = options.get('--renew-anon-volumes')
+ if force_recreate and no_recreate:
+ raise UserError("--force-recreate and --no-recreate cannot be combined.")
+
+ if no_recreate and renew_anonymous_volumes:
+ raise UserError('--no-recreate and --renew-anon-volumes cannot be combined.')
+
+ if force_recreate or renew_anonymous_volumes:
+ return ConvergenceStrategy.always
+
+ if no_recreate:
+ return ConvergenceStrategy.never
+
+ return ConvergenceStrategy.changed
+
+
+def timeout_from_opts(options):
+ timeout = options.get('--timeout')
+ return None if timeout is None else int(timeout)
+
+
+def image_digests_for_project(project):
+ try:
+ return get_image_digests(project)
+
+ except MissingDigests as e:
+ def list_images(images):
+ return "\n".join(" {}".format(name) for name in sorted(images))
+
+ paras = ["Some images are missing digests."]
+
+ if e.needs_push:
+ command_hint = (
+ "Use `docker push {}` to push them. "
+ .format(" ".join(sorted(e.needs_push)))
+ )
+ paras += [
+ "The following images can be pushed:",
+ list_images(e.needs_push),
+ command_hint,
+ ]
+
+ if e.needs_pull:
+ command_hint = (
+ "Use `docker pull {}` to pull them. "
+ .format(" ".join(sorted(e.needs_pull)))
+ )
+
+ paras += [
+ "The following images need to be pulled:",
+ list_images(e.needs_pull),
+ command_hint,
+ ]
+
+ raise UserError("\n\n".join(paras))
+
+
+def exitval_from_opts(options, project):
+ exit_value_from = options.get('--exit-code-from')
+ if exit_value_from:
+ if not options.get('--abort-on-container-exit'):
+ log.warning('using --exit-code-from implies --abort-on-container-exit')
+ options['--abort-on-container-exit'] = True
+ if exit_value_from not in [s.name for s in project.get_services()]:
+ log.error('No service named "%s" was found in your compose file.',
+ exit_value_from)
+ sys.exit(2)
+ return exit_value_from
+
+
+def image_type_from_opt(flag, value):
+ if not value:
+ return ImageType.none
+ try:
+ return ImageType[value]
+ except KeyError:
+ raise UserError("%s flag must be one of: all, local" % flag)
+
+
+def build_action_from_opts(options):
+ if options['--build'] and options['--no-build']:
+ raise UserError("--build and --no-build can not be combined.")
+
+ if options['--build']:
+ return BuildAction.force
+
+ if options['--no-build']:
+ return BuildAction.skip
+
+ return BuildAction.none
+
+
+def build_one_off_container_options(options, detach, command):
+ container_options = {
+ 'command': command,
+ 'tty': not (detach or options['-T'] or not sys.stdin.isatty()),
+ 'stdin_open': options.get('stdin_open'),
+ 'detach': detach,
+ }
+
+ if options['-e']:
+ container_options['environment'] = Environment.from_command_line(
+ parse_environment(options['-e'])
+ )
+
+ if options['--label']:
+ container_options['labels'] = parse_labels(options['--label'])
+
+ if options.get('--entrypoint') is not None:
+ container_options['entrypoint'] = (
+ [""] if options['--entrypoint'] == '' else options['--entrypoint']
+ )
+
+ # Ensure that run command remains one-off (issue #6302)
+ container_options['restart'] = None
+
+ if options['--user']:
+ container_options['user'] = options.get('--user')
+
+ if not options['--service-ports']:
+ container_options['ports'] = []
+
+ if options['--publish']:
+ container_options['ports'] = options.get('--publish')
+
+ if options['--name']:
+ container_options['name'] = options['--name']
+
+ if options['--workdir']:
+ container_options['working_dir'] = options['--workdir']
+
+ if options['--volume']:
+ volumes = [VolumeSpec.parse(i) for i in options['--volume']]
+ container_options['volumes'] = volumes
+
+ return container_options
+
+
+def run_one_off_container(container_options, project, service, options, toplevel_options,
+ toplevel_environment):
+ native_builder = toplevel_environment.get_boolean('COMPOSE_DOCKER_CLI_BUILD')
+ detach = options.get('--detach')
+ use_network_aliases = options.get('--use-aliases')
+ service.scale_num = 1
+ containers = project.up(
+ service_names=[service.name],
+ start_deps=not options['--no-deps'],
+ strategy=ConvergenceStrategy.never,
+ detached=True,
+ rescale=False,
+ cli=native_builder,
+ one_off=True,
+ override_options=container_options,
+ )
+ try:
+ container = next(c for c in containers if c.service == service.name)
+ except StopIteration:
+ raise OperationFailedError('Could not bring up the requested service')
+
+ if detach:
+ service.start_container(container, use_network_aliases)
+ print(container.name)
+ return
+
+ def remove_container():
+ if options['--rm']:
+ project.client.remove_container(container.id, force=True, v=True)
+
+ use_cli = not toplevel_environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI')
+
+ signals.set_signal_handler_to_shutdown()
+ signals.set_signal_handler_to_hang_up()
+ try:
+ try:
+ if IS_WINDOWS_PLATFORM or use_cli:
+ service.connect_container_to_networks(container, use_network_aliases)
+ exit_code = call_docker(
+ get_docker_start_call(container_options, container.id),
+ toplevel_options, toplevel_environment
+ )
+ else:
+ operation = RunOperation(
+ project.client,
+ container.id,
+ interactive=not options['-T'],
+ logs=False,
+ )
+ pty = PseudoTerminal(project.client, operation)
+ sockets = pty.sockets()
+ service.start_container(container, use_network_aliases)
+ pty.start(sockets)
+ exit_code = container.wait()
+ except (signals.ShutdownException):
+ project.client.stop(container.id)
+ exit_code = 1
+ except (signals.ShutdownException, signals.HangUpException):
+ project.client.kill(container.id)
+ remove_container()
+ sys.exit(2)
+
+ remove_container()
+ sys.exit(exit_code)
+
+
+def get_docker_start_call(container_options, container_id):
+ docker_call = ["start"]
+ if not container_options.get('detach'):
+ docker_call.append("--attach")
+ if container_options.get('stdin_open'):
+ docker_call.append("--interactive")
+ docker_call.append(container_id)
+ return docker_call
+
+
+def log_printer_from_project(
+ project,
+ containers,
+ monochrome,
+ log_args,
+ cascade_stop=False,
+ event_stream=None,
+ keep_prefix=True,
+):
+ return LogPrinter(
+ [c for c in containers if c.log_driver not in (None, 'none')],
+ build_log_presenters(project.service_names, monochrome, keep_prefix),
+ event_stream or project.events(),
+ cascade_stop=cascade_stop,
+ log_args=log_args)
+
+
+def filter_attached_containers(containers, service_names, attach_dependencies=False):
+ return filter_attached_for_up(
+ containers,
+ service_names,
+ attach_dependencies,
+ lambda container: container.service)
+
+
+@contextlib.contextmanager
+def up_shutdown_context(project, service_names, timeout, detached):
+ if detached:
+ yield
+ return
+
+ signals.set_signal_handler_to_shutdown()
+ try:
+ try:
+ yield
+ except signals.ShutdownException:
+ print("Gracefully stopping... (press Ctrl+C again to force)")
+ project.stop(service_names=service_names, timeout=timeout)
+ except signals.ShutdownException:
+ project.kill(service_names=service_names)
+ sys.exit(2)
+
+
+def list_containers(containers):
+ return ", ".join(c.name for c in containers)
+
+
+def exit_if(condition, message, exit_code):
+ if condition:
+ log.error(message)
+ raise SystemExit(exit_code)
+
+
+def call_docker(args, dockeropts, environment):
+ executable_path = find_executable('docker')
+ if not executable_path:
+ raise UserError(errors.docker_not_found_msg("Couldn't find `docker` binary."))
+
+ tls = dockeropts.get('--tls', False)
+ ca_cert = dockeropts.get('--tlscacert')
+ cert = dockeropts.get('--tlscert')
+ key = dockeropts.get('--tlskey')
+ verify = dockeropts.get('--tlsverify')
+ host = dockeropts.get('--host')
+ context = dockeropts.get('--context')
+ tls_options = []
+ if tls:
+ tls_options.append('--tls')
+ if ca_cert:
+ tls_options.extend(['--tlscacert', ca_cert])
+ if cert:
+ tls_options.extend(['--tlscert', cert])
+ if key:
+ tls_options.extend(['--tlskey', key])
+ if verify:
+ tls_options.append('--tlsverify')
+ if host:
+ tls_options.extend(
+ ['--host', re.sub(r'^https?://', 'tcp://', host.lstrip('='))]
+ )
+ if context:
+ tls_options.extend(
+ ['--context', context]
+ )
+
+ args = [executable_path] + tls_options + args
+ log.debug(" ".join(map(pipes.quote, args)))
+
+ filtered_env = {k: v for k, v in environment.items() if v is not None}
+
+ return subprocess.call(args, env=filtered_env)
+
+
+def parse_scale_args(options):
+ res = {}
+ for s in options:
+ if '=' not in s:
+ raise UserError('Arguments to scale should be in the form service=num')
+ service_name, num = s.split('=', 1)
+ try:
+ num = int(num)
+ except ValueError:
+ raise UserError(
+ 'Number of containers for service "%s" is not a number' % service_name
+ )
+ res[service_name] = num
+ return res
+
+
+def build_exec_command(options, container_id, command):
+ args = ["exec"]
+
+ if options["--detach"]:
+ args += ["--detach"]
+ else:
+ args += ["--interactive"]
+
+ if not options["-T"]:
+ args += ["--tty"]
+
+ if options["--privileged"]:
+ args += ["--privileged"]
+
+ if options["--user"]:
+ args += ["--user", options["--user"]]
+
+ if options["--env"]:
+ for env_variable in options["--env"]:
+ args += ["--env", env_variable]
+
+ if options["--workdir"]:
+ args += ["--workdir", options["--workdir"]]
+
+ args += [container_id]
+ args += command
+ return args
+
+
+def has_container_with_state(containers, state):
+ states = {
+ 'running': lambda c: c.is_running,
+ 'stopped': lambda c: not c.is_running,
+ 'paused': lambda c: c.is_paused,
+ 'restarting': lambda c: c.is_restarting,
+ }
+ for container in containers:
+ if state not in states:
+ raise UserError("Invalid state: %s" % state)
+ if states[state](container):
+ return True
+
+
+def filter_services(filt, services, project):
+ def should_include(service):
+ for f in filt:
+ if f == 'status':
+ state = filt[f]
+ containers = project.containers([service.name], stopped=True)
+ if not has_container_with_state(containers, state):
+ return False
+ elif f == 'source':
+ source = filt[f]
+ if source == 'image' or source == 'build':
+ if source not in service.options:
+ return False
+ else:
+ raise UserError("Invalid value for source filter: %s" % source)
+ else:
+ raise UserError("Invalid filter: %s" % f)
+ return True
+
+ return filter(should_include, services)
+
+
+def build_filter(arg):
+ filt = {}
+ if arg is not None:
+ if '=' not in arg:
+ raise UserError("Arguments to --filter should be in form KEY=VAL")
+ key, val = arg.split('=', 1)
+ filt[key] = val
+ return filt
+
+
+def warn_for_swarm_mode(client):
+ info = client.info()
+ if info.get('Swarm', {}).get('LocalNodeState') == 'active':
+ if info.get('ServerVersion', '').startswith('ucp'):
+ # UCP does multi-node scheduling with traditional Compose files.
+ return
+
+ log.warning(
+ "The Docker Engine you're using is running in swarm mode.\n\n"
+ "Compose does not use swarm mode to deploy services to multiple nodes in a swarm. "
+ "All containers will be scheduled on the current node.\n\n"
+ "To deploy your application across the swarm, "
+ "use `docker stack deploy`.\n"
+ )
diff --git a/compose/cli/signals.py b/compose/cli/signals.py
new file mode 100644
index 00000000000..0244e70189a
--- /dev/null
+++ b/compose/cli/signals.py
@@ -0,0 +1,41 @@
+import signal
+
+from ..const import IS_WINDOWS_PLATFORM
+
+
+class ShutdownException(Exception):
+ pass
+
+
+class HangUpException(Exception):
+ pass
+
+
+def shutdown(signal, frame):
+ raise ShutdownException()
+
+
+def set_signal_handler(handler):
+ signal.signal(signal.SIGINT, handler)
+ signal.signal(signal.SIGTERM, handler)
+
+
+def set_signal_handler_to_shutdown():
+ set_signal_handler(shutdown)
+
+
+def hang_up(signal, frame):
+ raise HangUpException()
+
+
+def set_signal_handler_to_hang_up():
+ # on Windows a ValueError will be raised if trying to set signal handler for SIGHUP
+ if not IS_WINDOWS_PLATFORM:
+ signal.signal(signal.SIGHUP, hang_up)
+
+
+def ignore_sigpipe():
+ # Restore default behavior for SIGPIPE instead of raising
+ # an exception when encountered.
+ if not IS_WINDOWS_PLATFORM:
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
diff --git a/compose/cli/utils.py b/compose/cli/utils.py
new file mode 100644
index 00000000000..6a4615a9660
--- /dev/null
+++ b/compose/cli/utils.py
@@ -0,0 +1,144 @@
+import math
+import os
+import platform
+import ssl
+import subprocess
+import sys
+
+import distro
+import docker
+
+import compose
+from ..const import IS_WINDOWS_PLATFORM
+
+
+def yesno(prompt, default=None):
+ """
+ Prompt the user for a yes or no.
+
+ Can optionally specify a default value, which will only be
+ used if they enter a blank line.
+
+ Unrecognised input (anything other than "y", "n", "yes",
+ "no" or "") will return None.
+ """
+ answer = input(prompt).strip().lower()
+
+ if answer == "y" or answer == "yes":
+ return True
+ elif answer == "n" or answer == "no":
+ return False
+ elif answer == "":
+ return default
+ else:
+ return None
+
+
+def input(prompt):
+ """
+ Version of input (raw_input in Python 2) which forces a flush of sys.stdout
+ to avoid problems where the prompt fails to appear due to line buffering
+ """
+ sys.stdout.write(prompt)
+ sys.stdout.flush()
+ return sys.stdin.readline().rstrip('\n')
+
+
+def call_silently(*args, **kwargs):
+ """
+ Like subprocess.call(), but redirects stdout and stderr to /dev/null.
+ """
+ with open(os.devnull, 'w') as shutup:
+ try:
+ return subprocess.call(*args, stdout=shutup, stderr=shutup, **kwargs)
+ except OSError:
+ # On Windows, subprocess.call() can still raise exceptions. Normalize
+ # to POSIXy behaviour by returning a nonzero exit code.
+ return 1
+
+
+def is_mac():
+ return platform.system() == 'Darwin'
+
+
+def is_ubuntu():
+ return platform.system() == 'Linux' and distro.linux_distribution()[0] == 'Ubuntu'
+
+
+def is_windows():
+ return IS_WINDOWS_PLATFORM
+
+
+def get_version_info(scope):
+ versioninfo = 'docker-compose version {}, build {}'.format(
+ compose.__version__,
+ get_build_version())
+
+ if scope == 'compose':
+ return versioninfo
+ if scope == 'full':
+ return (
+ "{}\n"
+ "docker-py version: {}\n"
+ "{} version: {}\n"
+ "OpenSSL version: {}"
+ ).format(
+ versioninfo,
+ docker.version,
+ platform.python_implementation(),
+ platform.python_version(),
+ ssl.OPENSSL_VERSION)
+
+ raise ValueError("{} is not a valid version scope".format(scope))
+
+
+def get_build_version():
+ filename = os.path.join(os.path.dirname(compose.__file__), 'GITSHA')
+ if not os.path.exists(filename):
+ return 'unknown'
+
+ with open(filename) as fh:
+ return fh.read().strip()
+
+
+def is_docker_for_mac_installed():
+ return is_mac() and os.path.isdir('/Applications/Docker.app')
+
+
+def generate_user_agent():
+ parts = [
+ "docker-compose/{}".format(compose.__version__),
+ "docker-py/{}".format(docker.__version__),
+ ]
+ try:
+ p_system = platform.system()
+ p_release = platform.release()
+ except OSError:
+ pass
+ else:
+ parts.append("{}/{}".format(p_system, p_release))
+ return " ".join(parts)
+
+
+def human_readable_file_size(size):
+ suffixes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', ]
+ order = int(math.log(size, 1000)) if size else 0
+ if order >= len(suffixes):
+ order = len(suffixes) - 1
+
+ return '{:.4g} {}'.format(
+ size / pow(10, order * 3),
+ suffixes[order]
+ )
+
+
+def binarystr_to_unicode(s):
+ if not isinstance(s, bytes):
+ return s
+
+ if IS_WINDOWS_PLATFORM:
+ try:
+ return s.decode('windows-1250')
+ except UnicodeDecodeError:
+ pass
+ return s.decode('utf-8', 'replace')
diff --git a/compose/cli/verbose_proxy.py b/compose/cli/verbose_proxy.py
new file mode 100644
index 00000000000..c9340c4e0d2
--- /dev/null
+++ b/compose/cli/verbose_proxy.py
@@ -0,0 +1,55 @@
+import functools
+import logging
+import pprint
+from itertools import chain
+
+
+def format_call(args, kwargs):
+ args = (repr(a) for a in args)
+ kwargs = ("{!s}={!r}".format(*item) for item in kwargs.items())
+ return "({})".format(", ".join(chain(args, kwargs)))
+
+
+def format_return(result, max_lines):
+ if isinstance(result, (list, tuple, set)):
+ return "({} with {} items)".format(type(result).__name__, len(result))
+
+ if result:
+ lines = pprint.pformat(result).split('\n')
+ extra = '\n...' if len(lines) > max_lines else ''
+ return '\n'.join(lines[:max_lines]) + extra
+
+ return result
+
+
+class VerboseProxy:
+ """Proxy all function calls to another class and log method name, arguments
+ and return values for each call.
+ """
+
+ def __init__(self, obj_name, obj, log_name=None, max_lines=10):
+ self.obj_name = obj_name
+ self.obj = obj
+ self.max_lines = max_lines
+ self.log = logging.getLogger(log_name or __name__)
+
+ def __getattr__(self, name):
+ attr = getattr(self.obj, name)
+
+ if not callable(attr):
+ return attr
+
+ return functools.partial(self.proxy_callable, name)
+
+ def proxy_callable(self, call_name, *args, **kwargs):
+ self.log.info("%s %s <- %s",
+ self.obj_name,
+ call_name,
+ format_call(args, kwargs))
+
+ result = getattr(self.obj, call_name)(*args, **kwargs)
+ self.log.info("%s %s -> %s",
+ self.obj_name,
+ call_name,
+ format_return(result, self.max_lines))
+ return result
diff --git a/compose/config/__init__.py b/compose/config/__init__.py
new file mode 100644
index 00000000000..855b2401d94
--- /dev/null
+++ b/compose/config/__init__.py
@@ -0,0 +1,12 @@
+# flake8: noqa
+from . import environment
+from .config import ConfigurationError
+from .config import DOCKER_CONFIG_KEYS
+from .config import find
+from .config import is_url
+from .config import load
+from .config import merge_environment
+from .config import merge_labels
+from .config import parse_environment
+from .config import parse_labels
+from .config import resolve_build_args
diff --git a/compose/config/compose_spec.json b/compose/config/compose_spec.json
new file mode 100644
index 00000000000..86e3de1eed5
--- /dev/null
+++ b/compose/config/compose_spec.json
@@ -0,0 +1,812 @@
+{
+ "$schema": "http://json-schema.org/draft/2019-09/schema#",
+ "id": "compose_spec.json",
+ "type": "object",
+ "title": "Compose Specification",
+ "description": "The Compose file is a YAML file defining a multi-containers based application.",
+
+ "properties": {
+ "version": {
+ "type": "string",
+ "description": "Version of the Compose specification used. Tools not implementing required version MUST reject the configuration file."
+ },
+
+ "services": {
+ "id": "#/properties/services",
+ "type": "object",
+ "patternProperties": {
+ "^[a-zA-Z0-9._-]+$": {
+ "$ref": "#/definitions/service"
+ }
+ },
+ "additionalProperties": false
+ },
+
+ "networks": {
+ "id": "#/properties/networks",
+ "type": "object",
+ "patternProperties": {
+ "^[a-zA-Z0-9._-]+$": {
+ "$ref": "#/definitions/network"
+ }
+ }
+ },
+
+ "volumes": {
+ "id": "#/properties/volumes",
+ "type": "object",
+ "patternProperties": {
+ "^[a-zA-Z0-9._-]+$": {
+ "$ref": "#/definitions/volume"
+ }
+ },
+ "additionalProperties": false
+ },
+
+ "secrets": {
+ "id": "#/properties/secrets",
+ "type": "object",
+ "patternProperties": {
+ "^[a-zA-Z0-9._-]+$": {
+ "$ref": "#/definitions/secret"
+ }
+ },
+ "additionalProperties": false
+ },
+
+ "configs": {
+ "id": "#/properties/configs",
+ "type": "object",
+ "patternProperties": {
+ "^[a-zA-Z0-9._-]+$": {
+ "$ref": "#/definitions/config"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+
+ "patternProperties": {"^x-": {}},
+ "additionalProperties": false,
+
+ "definitions": {
+
+ "service": {
+ "id": "#/definitions/service",
+ "type": "object",
+
+ "properties": {
+ "deploy": {"$ref": "#/definitions/deployment"},
+ "build": {
+ "oneOf": [
+ {"type": "string"},
+ {
+ "type": "object",
+ "properties": {
+ "context": {"type": "string"},
+ "dockerfile": {"type": "string"},
+ "args": {"$ref": "#/definitions/list_or_dict"},
+ "labels": {"$ref": "#/definitions/list_or_dict"},
+ "cache_from": {"type": "array", "items": {"type": "string"}},
+ "network": {"type": "string"},
+ "target": {"type": "string"},
+ "shm_size": {"type": ["integer", "string"]},
+ "extra_hosts": {"$ref": "#/definitions/list_or_dict"},
+ "isolation": {"type": "string"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ ]
+ },
+ "blkio_config": {
+ "type": "object",
+ "properties": {
+ "device_read_bps": {
+ "type": "array",
+ "items": {"$ref": "#/definitions/blkio_limit"}
+ },
+ "device_read_iops": {
+ "type": "array",
+ "items": {"$ref": "#/definitions/blkio_limit"}
+ },
+ "device_write_bps": {
+ "type": "array",
+ "items": {"$ref": "#/definitions/blkio_limit"}
+ },
+ "device_write_iops": {
+ "type": "array",
+ "items": {"$ref": "#/definitions/blkio_limit"}
+ },
+ "weight": {"type": "integer"},
+ "weight_device": {
+ "type": "array",
+ "items": {"$ref": "#/definitions/blkio_weight"}
+ }
+ },
+ "additionalProperties": false
+ },
+ "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "cgroup_parent": {"type": "string"},
+ "command": {
+ "oneOf": [
+ {"type": "string"},
+ {"type": "array", "items": {"type": "string"}}
+ ]
+ },
+ "configs": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {"type": "string"},
+ {
+ "type": "object",
+ "properties": {
+ "source": {"type": "string"},
+ "target": {"type": "string"},
+ "uid": {"type": "string"},
+ "gid": {"type": "string"},
+ "mode": {"type": "number"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ ]
+ }
+ },
+ "container_name": {"type": "string"},
+ "cpu_count": {"type": "integer", "minimum": 0},
+ "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100},
+ "cpu_shares": {"type": ["number", "string"]},
+ "cpu_quota": {"type": ["number", "string"]},
+ "cpu_period": {"type": ["number", "string"]},
+ "cpu_rt_period": {"type": ["number", "string"]},
+ "cpu_rt_runtime": {"type": ["number", "string"]},
+ "cpus": {"type": ["number", "string"]},
+ "cpuset": {"type": "string"},
+ "credential_spec": {
+ "type": "object",
+ "properties": {
+ "config": {"type": "string"},
+ "file": {"type": "string"},
+ "registry": {"type": "string"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "depends_on": {
+ "oneOf": [
+ {"$ref": "#/definitions/list_of_strings"},
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "patternProperties": {
+ "^[a-zA-Z0-9._-]+$": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "condition": {
+ "type": "string",
+ "enum": ["service_started", "service_healthy", "service_completed_successfully"]
+ }
+ },
+ "required": ["condition"]
+ }
+ }
+ }
+ ]
+ },
+ "device_cgroup_rules": {"$ref": "#/definitions/list_of_strings"},
+ "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "dns": {"$ref": "#/definitions/string_or_list"},
+ "dns_opt": {"type": "array","items": {"type": "string"}, "uniqueItems": true},
+ "dns_search": {"$ref": "#/definitions/string_or_list"},
+ "domainname": {"type": "string"},
+ "entrypoint": {
+ "oneOf": [
+ {"type": "string"},
+ {"type": "array", "items": {"type": "string"}}
+ ]
+ },
+ "env_file": {"$ref": "#/definitions/string_or_list"},
+ "environment": {"$ref": "#/definitions/list_or_dict"},
+
+ "expose": {
+ "type": "array",
+ "items": {
+ "type": ["string", "number"],
+ "format": "expose"
+ },
+ "uniqueItems": true
+ },
+ "extends": {
+ "oneOf": [
+ {"type": "string"},
+ {
+ "type": "object",
+
+ "properties": {
+ "service": {"type": "string"},
+ "file": {"type": "string"}
+ },
+ "required": ["service"],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "extra_hosts": {"$ref": "#/definitions/list_or_dict"},
+ "group_add": {
+ "type": "array",
+ "items": {
+ "type": ["string", "number"]
+ },
+ "uniqueItems": true
+ },
+ "healthcheck": {"$ref": "#/definitions/healthcheck"},
+ "hostname": {"type": "string"},
+ "image": {"type": "string"},
+ "init": {"type": "boolean"},
+ "ipc": {"type": "string"},
+ "isolation": {"type": "string"},
+ "labels": {"$ref": "#/definitions/list_or_dict"},
+ "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "logging": {
+ "type": "object",
+
+ "properties": {
+ "driver": {"type": "string"},
+ "options": {
+ "type": "object",
+ "patternProperties": {
+ "^.+$": {"type": ["string", "number", "null"]}
+ }
+ }
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "mac_address": {"type": "string"},
+ "mem_limit": {"type": ["number", "string"]},
+ "mem_reservation": {"type": ["string", "integer"]},
+ "mem_swappiness": {"type": "integer"},
+ "memswap_limit": {"type": ["number", "string"]},
+ "network_mode": {"type": "string"},
+ "networks": {
+ "oneOf": [
+ {"$ref": "#/definitions/list_of_strings"},
+ {
+ "type": "object",
+ "patternProperties": {
+ "^[a-zA-Z0-9._-]+$": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "aliases": {"$ref": "#/definitions/list_of_strings"},
+ "ipv4_address": {"type": "string"},
+ "ipv6_address": {"type": "string"},
+ "link_local_ips": {"$ref": "#/definitions/list_of_strings"},
+ "priority": {"type": "number"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ {"type": "null"}
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ ]
+ },
+ "oom_kill_disable": {"type": "boolean"},
+ "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000},
+ "pid": {"type": ["string", "null"]},
+ "pids_limit": {"type": ["number", "string"]},
+ "platform": {"type": "string"},
+ "ports": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {"type": "number", "format": "ports"},
+ {"type": "string", "format": "ports"},
+ {
+ "type": "object",
+ "properties": {
+ "mode": {"type": "string"},
+ "target": {"type": "integer"},
+ "published": {"type": "integer"},
+ "protocol": {"type": "string"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ ]
+ },
+ "uniqueItems": true
+ },
+ "privileged": {"type": "boolean"},
+ "profiles": {"$ref": "#/definitions/list_of_strings"},
+ "pull_policy": {"type": "string", "enum": [
+ "always", "never", "if_not_present", "build"
+ ]},
+ "read_only": {"type": "boolean"},
+ "restart": {"type": "string"},
+ "runtime": {
+ "type": "string"
+ },
+ "scale": {
+ "type": "integer"
+ },
+ "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "shm_size": {"type": ["number", "string"]},
+ "secrets": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {"type": "string"},
+ {
+ "type": "object",
+ "properties": {
+ "source": {"type": "string"},
+ "target": {"type": "string"},
+ "uid": {"type": "string"},
+ "gid": {"type": "string"},
+ "mode": {"type": "number"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ ]
+ }
+ },
+ "sysctls": {"$ref": "#/definitions/list_or_dict"},
+ "stdin_open": {"type": "boolean"},
+ "stop_grace_period": {"type": "string", "format": "duration"},
+ "stop_signal": {"type": "string"},
+ "storage_opt": {"type": "object"},
+ "tmpfs": {"$ref": "#/definitions/string_or_list"},
+ "tty": {"type": "boolean"},
+ "ulimits": {
+ "type": "object",
+ "patternProperties": {
+ "^[a-z]+$": {
+ "oneOf": [
+ {"type": "integer"},
+ {
+ "type": "object",
+ "properties": {
+ "hard": {"type": "integer"},
+ "soft": {"type": "integer"}
+ },
+ "required": ["soft", "hard"],
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ ]
+ }
+ }
+ },
+ "user": {"type": "string"},
+ "userns_mode": {"type": "string"},
+ "volumes": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {"type": "string"},
+ {
+ "type": "object",
+ "required": ["type"],
+ "properties": {
+ "type": {"type": "string"},
+ "source": {"type": "string"},
+ "target": {"type": "string"},
+ "read_only": {"type": "boolean"},
+ "consistency": {"type": "string"},
+ "bind": {
+ "type": "object",
+ "properties": {
+ "propagation": {"type": "string"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "volume": {
+ "type": "object",
+ "properties": {
+ "nocopy": {"type": "boolean"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "tmpfs": {
+ "type": "object",
+ "properties": {
+ "size": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ ]
+ },
+ "uniqueItems": true
+ },
+ "volumes_from": {
+ "type": "array",
+ "items": {"type": "string"},
+ "uniqueItems": true
+ },
+ "working_dir": {"type": "string"}
+ },
+ "patternProperties": {"^x-": {}},
+ "additionalProperties": false
+ },
+
+ "healthcheck": {
+ "id": "#/definitions/healthcheck",
+ "type": "object",
+ "properties": {
+ "disable": {"type": "boolean"},
+ "interval": {"type": "string", "format": "duration"},
+ "retries": {"type": "number"},
+ "test": {
+ "oneOf": [
+ {"type": "string"},
+ {"type": "array", "items": {"type": "string"}}
+ ]
+ },
+ "timeout": {"type": "string", "format": "duration"},
+ "start_period": {"type": "string", "format": "duration"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "deployment": {
+ "id": "#/definitions/deployment",
+ "type": ["object", "null"],
+ "properties": {
+ "mode": {"type": "string"},
+ "endpoint_mode": {"type": "string"},
+ "replicas": {"type": "integer"},
+ "labels": {"$ref": "#/definitions/list_or_dict"},
+ "rollback_config": {
+ "type": "object",
+ "properties": {
+ "parallelism": {"type": "integer"},
+ "delay": {"type": "string", "format": "duration"},
+ "failure_action": {"type": "string"},
+ "monitor": {"type": "string", "format": "duration"},
+ "max_failure_ratio": {"type": "number"},
+ "order": {"type": "string", "enum": [
+ "start-first", "stop-first"
+ ]}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "update_config": {
+ "type": "object",
+ "properties": {
+ "parallelism": {"type": "integer"},
+ "delay": {"type": "string", "format": "duration"},
+ "failure_action": {"type": "string"},
+ "monitor": {"type": "string", "format": "duration"},
+ "max_failure_ratio": {"type": "number"},
+ "order": {"type": "string", "enum": [
+ "start-first", "stop-first"
+ ]}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "resources": {
+ "type": "object",
+ "properties": {
+ "limits": {
+ "type": "object",
+ "properties": {
+ "cpus": {"type": ["number", "string"]},
+ "memory": {"type": "string"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "reservations": {
+ "type": "object",
+ "properties": {
+ "cpus": {"type": ["number", "string"]},
+ "memory": {"type": "string"},
+ "generic_resources": {"$ref": "#/definitions/generic_resources"},
+ "devices": {"$ref": "#/definitions/devices"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "restart_policy": {
+ "type": "object",
+ "properties": {
+ "condition": {"type": "string"},
+ "delay": {"type": "string", "format": "duration"},
+ "max_attempts": {"type": "integer"},
+ "window": {"type": "string", "format": "duration"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "placement": {
+ "type": "object",
+ "properties": {
+ "constraints": {"type": "array", "items": {"type": "string"}},
+ "preferences": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "spread": {"type": "string"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ },
+ "max_replicas_per_node": {"type": "integer"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+
+ "generic_resources": {
+ "id": "#/definitions/generic_resources",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "discrete_resource_spec": {
+ "type": "object",
+ "properties": {
+ "kind": {"type": "string"},
+ "value": {"type": "number"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ },
+
+ "devices": {
+ "id": "#/definitions/devices",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "capabilities": {"$ref": "#/definitions/list_of_strings"},
+ "count": {"type": ["string", "integer"]},
+ "device_ids": {"$ref": "#/definitions/list_of_strings"},
+ "driver":{"type": "string"},
+ "options":{"$ref": "#/definitions/list_or_dict"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ },
+
+ "network": {
+ "id": "#/definitions/network",
+ "type": ["object", "null"],
+ "properties": {
+ "name": {"type": "string"},
+ "driver": {"type": "string"},
+ "driver_opts": {
+ "type": "object",
+ "patternProperties": {
+ "^.+$": {"type": ["string", "number"]}
+ }
+ },
+ "ipam": {
+ "type": "object",
+ "properties": {
+ "driver": {"type": "string"},
+ "config": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "subnet": {"type": "string", "format": "subnet_ip_address"},
+ "ip_range": {"type": "string"},
+ "gateway": {"type": "string"},
+ "aux_addresses": {
+ "type": "object",
+ "additionalProperties": false,
+ "patternProperties": {"^.+$": {"type": "string"}}
+ }
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ }
+ },
+ "options": {
+ "type": "object",
+ "additionalProperties": false,
+ "patternProperties": {"^.+$": {"type": "string"}}
+ }
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "external": {
+ "type": ["boolean", "object"],
+ "properties": {
+ "name": {
+ "deprecated": true,
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "internal": {"type": "boolean"},
+ "enable_ipv6": {"type": "boolean"},
+ "attachable": {"type": "boolean"},
+ "labels": {"$ref": "#/definitions/list_or_dict"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+
+ "volume": {
+ "id": "#/definitions/volume",
+ "type": ["object", "null"],
+ "properties": {
+ "name": {"type": "string"},
+ "driver": {"type": "string"},
+ "driver_opts": {
+ "type": "object",
+ "patternProperties": {
+ "^.+$": {"type": ["string", "number"]}
+ }
+ },
+ "external": {
+ "type": ["boolean", "object"],
+ "properties": {
+ "name": {
+ "deprecated": true,
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+ "labels": {"$ref": "#/definitions/list_or_dict"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+
+ "secret": {
+ "id": "#/definitions/secret",
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "file": {"type": "string"},
+ "external": {
+ "type": ["boolean", "object"],
+ "properties": {
+ "name": {"type": "string"}
+ }
+ },
+ "labels": {"$ref": "#/definitions/list_or_dict"},
+ "driver": {"type": "string"},
+ "driver_opts": {
+ "type": "object",
+ "patternProperties": {
+ "^.+$": {"type": ["string", "number"]}
+ }
+ },
+ "template_driver": {"type": "string"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+
+ "config": {
+ "id": "#/definitions/config",
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "file": {"type": "string"},
+ "external": {
+ "type": ["boolean", "object"],
+ "properties": {
+ "name": {
+ "deprecated": true,
+ "type": "string"
+ }
+ }
+ },
+ "labels": {"$ref": "#/definitions/list_or_dict"},
+ "template_driver": {"type": "string"}
+ },
+ "additionalProperties": false,
+ "patternProperties": {"^x-": {}}
+ },
+
+ "string_or_list": {
+ "oneOf": [
+ {"type": "string"},
+ {"$ref": "#/definitions/list_of_strings"}
+ ]
+ },
+
+ "list_of_strings": {
+ "type": "array",
+ "items": {"type": "string"},
+ "uniqueItems": true
+ },
+
+ "list_or_dict": {
+ "oneOf": [
+ {
+ "type": "object",
+ "patternProperties": {
+ ".+": {
+ "type": ["string", "number", "null"]
+ }
+ },
+ "additionalProperties": false
+ },
+ {"type": "array", "items": {"type": "string"}, "uniqueItems": true}
+ ]
+ },
+
+ "blkio_limit": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string"},
+ "rate": {"type": ["integer", "string"]}
+ },
+ "additionalProperties": false
+ },
+ "blkio_weight": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string"},
+ "weight": {"type": "integer"}
+ },
+ "additionalProperties": false
+ },
+
+ "constraints": {
+ "service": {
+ "id": "#/definitions/constraints/service",
+ "anyOf": [
+ {"required": ["build"]},
+ {"required": ["image"]}
+ ],
+ "properties": {
+ "build": {
+ "required": ["context"]
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/compose/config/config.py b/compose/config/config.py
new file mode 100644
index 00000000000..c2f5e95a1fb
--- /dev/null
+++ b/compose/config/config.py
@@ -0,0 +1,1497 @@
+import functools
+import logging
+import os
+import re
+import string
+import sys
+from collections import namedtuple
+from itertools import chain
+from operator import attrgetter
+from operator import itemgetter
+
+import yaml
+
+try:
+ from functools import cached_property
+except ImportError:
+ from cached_property import cached_property
+
+from . import types
+from ..const import COMPOSE_SPEC as VERSION
+from ..const import COMPOSEFILE_V1 as V1
+from ..utils import build_string_dict
+from ..utils import json_hash
+from ..utils import parse_bytes
+from ..utils import parse_nanoseconds_int
+from ..utils import splitdrive
+from ..version import ComposeVersion
+from .environment import env_vars_from_file
+from .environment import Environment
+from .environment import split_env
+from .errors import CircularReference
+from .errors import ComposeFileNotFound
+from .errors import ConfigurationError
+from .errors import DuplicateOverrideFileFound
+from .errors import VERSION_EXPLANATION
+from .interpolation import interpolate_environment_variables
+from .sort_services import get_container_name_from_network_mode
+from .sort_services import get_service_name_from_network_mode
+from .sort_services import sort_service_dicts
+from .types import MountSpec
+from .types import parse_extra_hosts
+from .types import parse_restart_spec
+from .types import SecurityOpt
+from .types import ServiceLink
+from .types import ServicePort
+from .types import VolumeFromSpec
+from .types import VolumeSpec
+from .validation import match_named_volumes
+from .validation import validate_against_config_schema
+from .validation import validate_config_section
+from .validation import validate_cpu
+from .validation import validate_credential_spec
+from .validation import validate_depends_on
+from .validation import validate_extends_file_path
+from .validation import validate_healthcheck
+from .validation import validate_ipc_mode
+from .validation import validate_links
+from .validation import validate_network_mode
+from .validation import validate_pid_mode
+from .validation import validate_service_constraints
+from .validation import validate_top_level_object
+from .validation import validate_ulimits
+
+
+DOCKER_CONFIG_KEYS = [
+ 'cap_add',
+ 'cap_drop',
+ 'cgroup_parent',
+ 'command',
+ 'cpu_count',
+ 'cpu_percent',
+ 'cpu_period',
+ 'cpu_quota',
+ 'cpu_rt_period',
+ 'cpu_rt_runtime',
+ 'cpu_shares',
+ 'cpus',
+ 'cpuset',
+ 'detach',
+ 'device_cgroup_rules',
+ 'devices',
+ 'dns',
+ 'dns_search',
+ 'dns_opt',
+ 'domainname',
+ 'entrypoint',
+ 'env_file',
+ 'environment',
+ 'extra_hosts',
+ 'group_add',
+ 'hostname',
+ 'healthcheck',
+ 'image',
+ 'ipc',
+ 'isolation',
+ 'labels',
+ 'links',
+ 'mac_address',
+ 'mem_limit',
+ 'mem_reservation',
+ 'memswap_limit',
+ 'mem_swappiness',
+ 'net',
+ 'oom_score_adj',
+ 'oom_kill_disable',
+ 'pid',
+ 'ports',
+ 'privileged',
+ 'read_only',
+ 'restart',
+ 'runtime',
+ 'secrets',
+ 'security_opt',
+ 'shm_size',
+ 'pids_limit',
+ 'stdin_open',
+ 'stop_signal',
+ 'sysctls',
+ 'tty',
+ 'user',
+ 'userns_mode',
+ 'volume_driver',
+ 'volumes',
+ 'volumes_from',
+ 'working_dir',
+]
+
+ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [
+ 'blkio_config',
+ 'build',
+ 'container_name',
+ 'credential_spec',
+ 'dockerfile',
+ 'init',
+ 'log_driver',
+ 'log_opt',
+ 'logging',
+ 'network_mode',
+ 'platform',
+ 'profiles',
+ 'scale',
+ 'stop_grace_period',
+]
+
+DOCKER_VALID_URL_PREFIXES = (
+ 'http://',
+ 'https://',
+ 'git://',
+ 'github.com/',
+ 'git@',
+)
+
+SUPPORTED_FILENAMES = [
+ 'docker-compose.yml',
+ 'docker-compose.yaml',
+ 'compose.yml',
+ 'compose.yaml',
+]
+
+DEFAULT_OVERRIDE_FILENAMES = ('docker-compose.override.yml',
+ 'docker-compose.override.yaml',
+ 'compose.override.yml',
+ 'compose.override.yaml')
+
+
+log = logging.getLogger(__name__)
+
+
+class ConfigDetails(namedtuple('_ConfigDetails', 'working_dir config_files environment')):
+ """
+ :param working_dir: the directory to use for relative paths in the config
+ :type working_dir: string
+ :param config_files: list of configuration files to load
+ :type config_files: list of :class:`ConfigFile`
+ :param environment: computed environment values for this project
+ :type environment: :class:`environment.Environment`
+ """
+ def __new__(cls, working_dir, config_files, environment=None):
+ if environment is None:
+ environment = Environment.from_env_file(working_dir)
+ return super().__new__(
+ cls, working_dir, config_files, environment
+ )
+
+
+class ConfigFile(namedtuple('_ConfigFile', 'filename config')):
+ """
+ :param filename: filename of the config file
+ :type filename: string
+ :param config: contents of the config file
+ :type config: :class:`dict`
+ """
+
+ @classmethod
+ def from_filename(cls, filename):
+ return cls(filename, load_yaml(filename))
+
+ @cached_property
+ def config_version(self):
+ version = self.config.get('version', None)
+ if isinstance(version, dict):
+ return V1
+ return ComposeVersion(version) if version else self.version
+
+ @cached_property
+ def version(self):
+ version = self.config.get('version', None)
+ if not version:
+ # no version is specified in the config file
+ services = self.config.get('services', None)
+ networks = self.config.get('networks', None)
+ volumes = self.config.get('volumes', None)
+ if services or networks or volumes:
+ # validate V2/V3 structure
+ for section in ['services', 'networks', 'volumes']:
+ validate_config_section(
+ self.filename, self.config.get(section, {}), section)
+ return VERSION
+
+ # validate V1 structure
+ validate_config_section(
+ self.filename, self.config, 'services')
+ return V1
+
+ if isinstance(version, dict):
+ log.warning('Unexpected type for "version" key in "{}". Assuming '
+ '"version" is the name of a service, and defaulting to '
+ 'Compose file version {}.'.format(self.filename, V1))
+ return V1
+
+ if not isinstance(version, str):
+ raise ConfigurationError(
+ 'Version in "{}" is invalid - it should be a string.'
+ .format(self.filename))
+
+ if isinstance(version, str):
+ version_pattern = re.compile(r"^[1-3]+(\.\d+)?$")
+ if not version_pattern.match(version):
+ raise ConfigurationError(
+ 'Version "{}" in "{}" is invalid.'
+ .format(version, self.filename))
+
+ if version.startswith("1"):
+ raise ConfigurationError(
+ 'Version in "{}" is invalid. {}'
+ .format(self.filename, VERSION_EXPLANATION)
+ )
+
+ return VERSION
+
+ def get_service(self, name):
+ return self.get_service_dicts()[name]
+
+ def get_service_dicts(self):
+ if self.version == V1:
+ return self.config
+ return self.config.get('services', {})
+
+ def get_volumes(self):
+ return {} if self.version == V1 else self.config.get('volumes', {})
+
+ def get_networks(self):
+ return {} if self.version == V1 else self.config.get('networks', {})
+
+ def get_secrets(self):
+ return {} if self.version == V1 else self.config.get('secrets', {})
+
+ def get_configs(self):
+ return {} if self.version == V1 else self.config.get('configs', {})
+
+
+class Config(namedtuple('_Config', 'config_version version services volumes networks secrets configs')):
+ """
+ :param config_version: configuration file version
+ :type config_version: int
+ :param version: configuration version
+ :type version: int
+ :param services: List of service description dictionaries
+ :type services: :class:`list`
+ :param volumes: Dictionary mapping volume names to description dictionaries
+ :type volumes: :class:`dict`
+ :param networks: Dictionary mapping network names to description dictionaries
+ :type networks: :class:`dict`
+ :param secrets: Dictionary mapping secret names to description dictionaries
+ :type secrets: :class:`dict`
+ :param configs: Dictionary mapping config names to description dictionaries
+ :type configs: :class:`dict`
+ """
+
+
+class ServiceConfig(namedtuple('_ServiceConfig', 'working_dir filename name config')):
+
+ @classmethod
+ def with_abs_paths(cls, working_dir, filename, name, config):
+ if not working_dir:
+ raise ValueError("No working_dir for ServiceConfig.")
+
+ return cls(
+ os.path.abspath(working_dir),
+ os.path.abspath(filename) if filename else filename,
+ name,
+ config)
+
+
+def find(base_dir, filenames, environment, override_dir=None):
+ if filenames == ['-']:
+ return ConfigDetails(
+ os.path.abspath(override_dir) if override_dir else os.getcwd(),
+ [ConfigFile(None, yaml.safe_load(sys.stdin))],
+ environment
+ )
+
+ if filenames:
+ filenames = [os.path.join(base_dir, f) for f in filenames]
+ else:
+ # search for compose files in the base dir and its parents
+ filenames = get_default_config_files(base_dir)
+ if not filenames and not override_dir:
+ # none found in base_dir and no override_dir defined
+ raise ComposeFileNotFound(SUPPORTED_FILENAMES)
+ if not filenames:
+ # search for compose files in the project directory and its parents
+ filenames = get_default_config_files(override_dir)
+ if not filenames:
+ raise ComposeFileNotFound(SUPPORTED_FILENAMES)
+
+ log.debug("Using configuration files: {}".format(",".join(filenames)))
+ return ConfigDetails(
+ override_dir if override_dir else os.path.dirname(filenames[0]),
+ [ConfigFile.from_filename(f) for f in filenames],
+ environment
+ )
+
+
+def validate_config_version(config_files):
+ main_file = config_files[0]
+ validate_top_level_object(main_file)
+
+ for next_file in config_files[1:]:
+ validate_top_level_object(next_file)
+
+ if main_file.version != next_file.version:
+ raise ConfigurationError(
+ "Version mismatch: file {} specifies version {} but "
+ "extension file {} uses version {}".format(
+ main_file.filename,
+ main_file.version,
+ next_file.filename,
+ next_file.version))
+
+
+def get_default_config_files(base_dir):
+ (candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, base_dir)
+
+ if not candidates:
+ return None
+
+ winner = candidates[0]
+
+ if len(candidates) > 1:
+ log.warning("Found multiple config files with supported names: %s", ", ".join(candidates))
+ log.warning("Using %s\n", winner)
+
+ return [os.path.join(path, winner)] + get_default_override_file(path)
+
+
+def get_default_override_file(path):
+ override_files_in_path = [os.path.join(path, override_filename) for override_filename
+ in DEFAULT_OVERRIDE_FILENAMES
+ if os.path.exists(os.path.join(path, override_filename))]
+ if len(override_files_in_path) > 1:
+ raise DuplicateOverrideFileFound(override_files_in_path)
+ return override_files_in_path
+
+
+def find_candidates_in_parent_dirs(filenames, path):
+ """
+ Given a directory path to start, looks for filenames in the
+ directory, and then each parent directory successively,
+ until found.
+
+ Returns tuple (candidates, path).
+ """
+ candidates = [filename for filename in filenames
+ if os.path.exists(os.path.join(path, filename))]
+
+ if not candidates:
+ parent_dir = os.path.join(path, '..')
+ if os.path.abspath(parent_dir) != os.path.abspath(path):
+ return find_candidates_in_parent_dirs(filenames, parent_dir)
+
+ return (candidates, path)
+
+
+def check_swarm_only_config(service_dicts):
+ warning_template = (
+ "Some services ({services}) use the '{key}' key, which will be ignored. "
+ "Compose does not support '{key}' configuration - use "
+ "`docker stack deploy` to deploy to a swarm."
+ )
+ key = 'configs'
+ services = [s for s in service_dicts if s.get(key)]
+ if services:
+ log.warning(
+ warning_template.format(
+ services=", ".join(sorted(s['name'] for s in services)),
+ key=key
+ )
+ )
+
+
+def load(config_details, interpolate=True):
+ """Load the configuration from a working directory and a list of
+ configuration files. Files are loaded in order, and merged on top
+ of each other to create the final configuration.
+
+ Return a fully interpolated, extended and validated configuration.
+ """
+
+ # validate against latest version and if fails do it against v1 schema
+ validate_config_version(config_details.config_files)
+
+ processed_files = [
+ process_config_file(config_file, config_details.environment, interpolate=interpolate)
+ for config_file in config_details.config_files
+ ]
+ config_details = config_details._replace(config_files=processed_files)
+
+ main_file = config_details.config_files[0]
+ volumes = load_mapping(
+ config_details.config_files, 'get_volumes', 'Volume'
+ )
+ networks = load_mapping(
+ config_details.config_files, 'get_networks', 'Network'
+ )
+ secrets = load_mapping(
+ config_details.config_files, 'get_secrets', 'Secret', config_details.working_dir
+ )
+ configs = load_mapping(
+ config_details.config_files, 'get_configs', 'Config', config_details.working_dir
+ )
+ service_dicts = load_services(config_details, main_file, interpolate=interpolate)
+
+ if main_file.version != V1:
+ for service_dict in service_dicts:
+ match_named_volumes(service_dict, volumes)
+
+ check_swarm_only_config(service_dicts)
+
+ return Config(main_file.config_version, main_file.version,
+ service_dicts, volumes, networks, secrets, configs)
+
+
+def load_mapping(config_files, get_func, entity_type, working_dir=None):
+ mapping = {}
+
+ for config_file in config_files:
+ for name, config in getattr(config_file, get_func)().items():
+ mapping[name] = config or {}
+ if not config:
+ continue
+
+ external = config.get('external')
+ if external:
+ validate_external(entity_type, name, config, config_file.version)
+ if isinstance(external, dict):
+ config['name'] = external.get('name')
+ elif not config.get('name'):
+ config['name'] = name
+
+ if 'labels' in config:
+ config['labels'] = parse_labels(config['labels'])
+
+ if 'file' in config:
+ config['file'] = expand_path(working_dir, config['file'])
+
+ if 'driver_opts' in config:
+ config['driver_opts'] = build_string_dict(
+ config['driver_opts']
+ )
+ device = format_device_option(entity_type, config)
+ if device:
+ config['driver_opts']['device'] = device
+ return mapping
+
+
+def format_device_option(entity_type, config):
+ if entity_type != 'Volume':
+ return
+ # default driver is 'local'
+ driver = config.get('driver', 'local')
+ if driver != 'local':
+ return
+ o = config['driver_opts'].get('o')
+ device = config['driver_opts'].get('device')
+ if o and o == 'bind' and device:
+ fullpath = os.path.abspath(os.path.expanduser(device))
+ return fullpath
+
+
+def validate_external(entity_type, name, config, version):
+ for k in config.keys():
+ if entity_type == 'Network' and k == 'driver':
+ continue
+ if k not in ['external', 'name']:
+ raise ConfigurationError(
+ "{} {} declared as external but specifies additional attributes "
+ "({}).".format(
+ entity_type, name, ', '.join(k for k in config if k != 'external')))
+
+
+def load_services(config_details, config_file, interpolate=True):
+ def build_service(service_name, service_dict, service_names):
+ service_config = ServiceConfig.with_abs_paths(
+ config_details.working_dir,
+ config_file.filename,
+ service_name,
+ service_dict)
+ resolver = ServiceExtendsResolver(
+ service_config, config_file, environment=config_details.environment
+ )
+ service_dict = process_service(resolver.run())
+
+ service_config = service_config._replace(config=service_dict)
+ validate_service(service_config, service_names, config_file)
+ service_dict = finalize_service(
+ service_config,
+ service_names,
+ config_file.version,
+ config_details.environment,
+ interpolate
+ )
+ return service_dict
+
+ def build_services(service_config):
+ service_names = service_config.keys()
+ return sort_service_dicts([
+ build_service(name, service_dict, service_names)
+ for name, service_dict in service_config.items()
+ ])
+
+ def merge_services(base, override):
+ all_service_names = set(base) | set(override)
+ return {
+ name: merge_service_dicts_from_files(
+ base.get(name, {}),
+ override.get(name, {}),
+ config_file.version)
+ for name in all_service_names
+ }
+
+ service_configs = [
+ file.get_service_dicts() for file in config_details.config_files
+ ]
+
+ service_config = functools.reduce(merge_services, service_configs)
+
+ return build_services(service_config)
+
+
+def interpolate_config_section(config_file, config, section, environment):
+ return interpolate_environment_variables(
+ config_file.version,
+ config,
+ section,
+ environment
+ )
+
+
+def process_config_section(config_file, config, section, environment, interpolate):
+ validate_config_section(config_file.filename, config, section)
+ if interpolate:
+ return interpolate_environment_variables(
+ config_file.version,
+ config,
+ section,
+ environment)
+ else:
+ return config
+
+
+def process_config_file(config_file, environment, service_name=None, interpolate=True):
+ services = process_config_section(
+ config_file,
+ config_file.get_service_dicts(),
+ 'service',
+ environment,
+ interpolate,
+ )
+
+ if config_file.version > V1:
+ processed_config = dict(config_file.config)
+ processed_config['services'] = services
+ processed_config['volumes'] = process_config_section(
+ config_file,
+ config_file.get_volumes(),
+ 'volume',
+ environment,
+ interpolate,
+ )
+ processed_config['networks'] = process_config_section(
+ config_file,
+ config_file.get_networks(),
+ 'network',
+ environment,
+ interpolate,
+ )
+ processed_config['secrets'] = process_config_section(
+ config_file,
+ config_file.get_secrets(),
+ 'secret',
+ environment,
+ interpolate,
+ )
+ processed_config['configs'] = process_config_section(
+ config_file,
+ config_file.get_configs(),
+ 'config',
+ environment,
+ interpolate,
+ )
+ else:
+ processed_config = services
+
+ config_file = config_file._replace(config=processed_config)
+ validate_against_config_schema(config_file, config_file.version)
+
+ if service_name and service_name not in services:
+ raise ConfigurationError(
+ "Cannot extend service '{}' in {}: Service not found".format(
+ service_name, config_file.filename))
+
+ return config_file
+
+
+class ServiceExtendsResolver:
+ def __init__(self, service_config, config_file, environment, already_seen=None):
+ self.service_config = service_config
+ self.working_dir = service_config.working_dir
+ self.already_seen = already_seen or []
+ self.config_file = config_file
+ self.environment = environment
+
+ @property
+ def signature(self):
+ return self.service_config.filename, self.service_config.name
+
+ def detect_cycle(self):
+ if self.signature in self.already_seen:
+ raise CircularReference(self.already_seen + [self.signature])
+
+ def run(self):
+ self.detect_cycle()
+
+ if 'extends' in self.service_config.config:
+ service_dict = self.resolve_extends(*self.validate_and_construct_extends())
+ return self.service_config._replace(config=service_dict)
+
+ return self.service_config
+
+ def validate_and_construct_extends(self):
+ extends = self.service_config.config['extends']
+ if not isinstance(extends, dict):
+ extends = {'service': extends}
+
+ config_path = self.get_extended_config_path(extends)
+ service_name = extends['service']
+
+ if config_path == os.path.abspath(self.config_file.filename):
+ try:
+ service_config = self.config_file.get_service(service_name)
+ except KeyError:
+ raise ConfigurationError(
+ "Cannot extend service '{}' in {}: Service not found".format(
+ service_name, config_path)
+ )
+ else:
+ extends_file = ConfigFile.from_filename(config_path)
+ validate_config_version([self.config_file, extends_file])
+ extended_file = process_config_file(
+ extends_file, self.environment, service_name=service_name
+ )
+ service_config = extended_file.get_service(service_name)
+
+ return config_path, service_config, service_name
+
+ def resolve_extends(self, extended_config_path, service_dict, service_name):
+ resolver = ServiceExtendsResolver(
+ ServiceConfig.with_abs_paths(
+ os.path.dirname(extended_config_path),
+ extended_config_path,
+ service_name,
+ service_dict),
+ self.config_file,
+ already_seen=self.already_seen + [self.signature],
+ environment=self.environment
+ )
+
+ service_config = resolver.run()
+ other_service_dict = process_service(service_config)
+ validate_extended_service_dict(
+ other_service_dict,
+ extended_config_path,
+ service_name)
+
+ return merge_service_dicts(
+ other_service_dict,
+ self.service_config.config,
+ self.config_file.version)
+
+ def get_extended_config_path(self, extends_options):
+ """Service we are extending either has a value for 'file' set, which we
+ need to obtain a full path too or we are extending from a service
+ defined in our own file.
+ """
+ filename = self.service_config.filename
+ validate_extends_file_path(
+ self.service_config.name,
+ extends_options,
+ filename)
+ if 'file' in extends_options:
+ return expand_path(self.working_dir, extends_options['file'])
+ return filename
+
+
+def resolve_environment(service_dict, environment=None, interpolate=True):
+ """Unpack any environment variables from an env_file, if set.
+ Interpolate environment values if set.
+ """
+ env = {}
+ for env_file in service_dict.get('env_file', []):
+ env.update(env_vars_from_file(env_file, interpolate))
+
+ env.update(parse_environment(service_dict.get('environment')))
+ return dict(resolve_env_var(k, v, environment) for k, v in env.items())
+
+
+def resolve_build_args(buildargs, environment):
+ args = parse_build_arguments(buildargs)
+ return dict(resolve_env_var(k, v, environment) for k, v in args.items())
+
+
+def validate_extended_service_dict(service_dict, filename, service):
+ error_prefix = "Cannot extend service '{}' in {}:".format(service, filename)
+
+ if 'links' in service_dict:
+ raise ConfigurationError(
+ "%s services with 'links' cannot be extended" % error_prefix)
+
+ if 'volumes_from' in service_dict:
+ raise ConfigurationError(
+ "%s services with 'volumes_from' cannot be extended" % error_prefix)
+
+ if 'net' in service_dict:
+ if get_container_name_from_network_mode(service_dict['net']):
+ raise ConfigurationError(
+ "%s services with 'net: container' cannot be extended" % error_prefix)
+
+ if 'network_mode' in service_dict:
+ if get_service_name_from_network_mode(service_dict['network_mode']):
+ raise ConfigurationError(
+ "%s services with 'network_mode: service' cannot be extended" % error_prefix)
+
+ if 'depends_on' in service_dict:
+ raise ConfigurationError(
+ "%s services with 'depends_on' cannot be extended" % error_prefix)
+
+
+def validate_service(service_config, service_names, config_file):
+ def build_image():
+ args = sys.argv[1:]
+ if 'pull' in args:
+ return False
+
+ if '--no-build' in args:
+ return False
+
+ return True
+
+ service_dict, service_name = service_config.config, service_config.name
+ validate_service_constraints(service_dict, service_name, config_file)
+
+ if build_image():
+ # We only care about valid paths when actually building images
+ validate_paths(service_dict)
+
+ validate_cpu(service_config)
+ validate_ulimits(service_config)
+ validate_ipc_mode(service_config, service_names)
+ validate_network_mode(service_config, service_names)
+ validate_pid_mode(service_config, service_names)
+ validate_depends_on(service_config, service_names)
+ validate_links(service_config, service_names)
+ validate_healthcheck(service_config)
+ validate_credential_spec(service_config)
+
+ if not service_dict.get('image') and has_uppercase(service_name):
+ raise ConfigurationError(
+ "Service '{name}' contains uppercase characters which are not valid "
+ "as part of an image name. Either use a lowercase service name or "
+ "use the `image` field to set a custom name for the service image."
+ .format(name=service_name))
+
+
+def process_service(service_config):
+ working_dir = service_config.working_dir
+ service_dict = dict(service_config.config)
+
+ if 'env_file' in service_dict:
+ service_dict['env_file'] = [
+ expand_path(working_dir, path)
+ for path in to_list(service_dict['env_file'])
+ ]
+
+ if 'build' in service_dict:
+ process_build_section(service_dict, working_dir)
+
+ if 'volumes' in service_dict and service_dict.get('volume_driver') is None:
+ service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict)
+
+ if 'sysctls' in service_dict:
+ service_dict['sysctls'] = build_string_dict(parse_sysctls(service_dict['sysctls']))
+
+ if 'labels' in service_dict:
+ service_dict['labels'] = parse_labels(service_dict['labels'])
+
+ service_dict = process_depends_on(service_dict)
+
+ for field in ['dns', 'dns_search', 'tmpfs']:
+ if field in service_dict:
+ service_dict[field] = to_list(service_dict[field])
+
+ service_dict = process_security_opt(process_blkio_config(process_ports(
+ process_healthcheck(service_dict)
+ )))
+
+ return service_dict
+
+
+def process_build_section(service_dict, working_dir):
+ if isinstance(service_dict['build'], str):
+ service_dict['build'] = resolve_build_path(working_dir, service_dict['build'])
+ elif isinstance(service_dict['build'], dict):
+ if 'context' in service_dict['build']:
+ path = service_dict['build']['context']
+ service_dict['build']['context'] = resolve_build_path(working_dir, path)
+ if 'labels' in service_dict['build']:
+ service_dict['build']['labels'] = parse_labels(service_dict['build']['labels'])
+
+
+def process_ports(service_dict):
+ if 'ports' not in service_dict:
+ return service_dict
+
+ ports = []
+ for port_definition in service_dict['ports']:
+ if isinstance(port_definition, ServicePort):
+ ports.append(port_definition)
+ else:
+ ports.extend(ServicePort.parse(port_definition))
+ service_dict['ports'] = ports
+ return service_dict
+
+
+def process_depends_on(service_dict):
+ if 'depends_on' in service_dict and not isinstance(service_dict['depends_on'], dict):
+ service_dict['depends_on'] = {
+ svc: {'condition': 'service_started'} for svc in service_dict['depends_on']
+ }
+ return service_dict
+
+
+def process_blkio_config(service_dict):
+ if not service_dict.get('blkio_config'):
+ return service_dict
+
+ for field in ['device_read_bps', 'device_write_bps']:
+ if field in service_dict['blkio_config']:
+ for v in service_dict['blkio_config'].get(field, []):
+ rate = v.get('rate', 0)
+ v['rate'] = parse_bytes(rate)
+ if v['rate'] is None:
+ raise ConfigurationError('Invalid format for bytes value: "{}"'.format(rate))
+
+ for field in ['device_read_iops', 'device_write_iops']:
+ if field in service_dict['blkio_config']:
+ for v in service_dict['blkio_config'].get(field, []):
+ try:
+ v['rate'] = int(v.get('rate', 0))
+ except ValueError:
+ raise ConfigurationError(
+ 'Invalid IOPS value: "{}". Must be a positive integer.'.format(v.get('rate'))
+ )
+
+ return service_dict
+
+
+def process_healthcheck(service_dict):
+ if 'healthcheck' not in service_dict:
+ return service_dict
+
+ hc = service_dict['healthcheck']
+
+ if 'disable' in hc:
+ del hc['disable']
+ hc['test'] = ['NONE']
+
+ for field in ['interval', 'timeout', 'start_period']:
+ if field not in hc or isinstance(hc[field], int):
+ continue
+ hc[field] = parse_nanoseconds_int(hc[field])
+
+ return service_dict
+
+
+def finalize_service_volumes(service_dict, environment):
+ if 'volumes' in service_dict:
+ finalized_volumes = []
+ normalize = environment.get_boolean('COMPOSE_CONVERT_WINDOWS_PATHS')
+ win_host = environment.get_boolean('COMPOSE_FORCE_WINDOWS_HOST')
+ for v in service_dict['volumes']:
+ if isinstance(v, dict):
+ finalized_volumes.append(MountSpec.parse(v, normalize, win_host))
+ else:
+ finalized_volumes.append(VolumeSpec.parse(v, normalize, win_host))
+
+ duplicate_mounts = []
+ mounts = [v.as_volume_spec() if isinstance(v, MountSpec) else v for v in finalized_volumes]
+ for mount in mounts:
+ if list(map(attrgetter('internal'), mounts)).count(mount.internal) > 1:
+ duplicate_mounts.append(mount.repr())
+
+ if duplicate_mounts:
+ raise ConfigurationError("Duplicate mount points: [%s]" % (
+ ', '.join(duplicate_mounts)))
+
+ service_dict['volumes'] = finalized_volumes
+
+ return service_dict
+
+
+def finalize_service(service_config, service_names, version, environment,
+ interpolate=True):
+ service_dict = dict(service_config.config)
+
+ if 'environment' in service_dict or 'env_file' in service_dict:
+ service_dict['environment'] = resolve_environment(service_dict, environment, interpolate)
+ service_dict.pop('env_file', None)
+
+ if 'volumes_from' in service_dict:
+ service_dict['volumes_from'] = [
+ VolumeFromSpec.parse(vf, service_names, version)
+ for vf in service_dict['volumes_from']
+ ]
+
+ service_dict = finalize_service_volumes(service_dict, environment)
+
+ if 'net' in service_dict:
+ network_mode = service_dict.pop('net')
+ container_name = get_container_name_from_network_mode(network_mode)
+ if container_name and container_name in service_names:
+ service_dict['network_mode'] = 'service:{}'.format(container_name)
+ else:
+ service_dict['network_mode'] = network_mode
+
+ if 'networks' in service_dict:
+ service_dict['networks'] = parse_networks(service_dict['networks'])
+
+ if 'restart' in service_dict:
+ service_dict['restart'] = parse_restart_spec(service_dict['restart'])
+
+ if 'secrets' in service_dict:
+ service_dict['secrets'] = [
+ types.ServiceSecret.parse(s) for s in service_dict['secrets']
+ ]
+
+ if 'configs' in service_dict:
+ service_dict['configs'] = [
+ types.ServiceConfig.parse(c) for c in service_dict['configs']
+ ]
+
+ normalize_build(service_dict, service_config.working_dir, environment)
+
+ service_dict['name'] = service_config.name
+ return normalize_v1_service_format(service_dict)
+
+
+def normalize_v1_service_format(service_dict):
+ if 'log_driver' in service_dict or 'log_opt' in service_dict:
+ if 'logging' not in service_dict:
+ service_dict['logging'] = {}
+ if 'log_driver' in service_dict:
+ service_dict['logging']['driver'] = service_dict['log_driver']
+ del service_dict['log_driver']
+ if 'log_opt' in service_dict:
+ service_dict['logging']['options'] = service_dict['log_opt']
+ del service_dict['log_opt']
+
+ if 'dockerfile' in service_dict:
+ service_dict['build'] = service_dict.get('build', {})
+ service_dict['build'].update({
+ 'dockerfile': service_dict.pop('dockerfile')
+ })
+
+ return service_dict
+
+
+def merge_service_dicts_from_files(base, override, version):
+ """When merging services from multiple files we need to merge the `extends`
+ field. This is not handled by `merge_service_dicts()` which is used to
+ perform the `extends`.
+ """
+ new_service = merge_service_dicts(base, override, version)
+ if 'extends' in override:
+ new_service['extends'] = override['extends']
+ elif 'extends' in base:
+ new_service['extends'] = base['extends']
+ return new_service
+
+
+class MergeDict(dict):
+ """A dict-like object responsible for merging two dicts into one."""
+
+ def __init__(self, base, override):
+ self.base = base
+ self.override = override
+
+ def needs_merge(self, field):
+ return field in self.base or field in self.override
+
+ def merge_field(self, field, merge_func, default=None):
+ if not self.needs_merge(field):
+ return
+
+ self[field] = merge_func(
+ self.base.get(field, default),
+ self.override.get(field, default))
+
+ def merge_mapping(self, field, parse_func=None):
+ if not self.needs_merge(field):
+ return
+
+ if parse_func is None:
+ def parse_func(m):
+ return m or {}
+
+ self[field] = parse_func(self.base.get(field))
+ self[field].update(parse_func(self.override.get(field)))
+
+ def merge_sequence(self, field, parse_func):
+ def parse_sequence_func(seq):
+ return to_mapping((parse_func(item) for item in seq), 'merge_field')
+
+ if not self.needs_merge(field):
+ return
+
+ merged = parse_sequence_func(self.base.get(field, []))
+ merged.update(parse_sequence_func(self.override.get(field, [])))
+ self[field] = [item.repr() for item in sorted(merged.values())]
+
+ def merge_scalar(self, field):
+ if self.needs_merge(field):
+ self[field] = self.override.get(field, self.base.get(field))
+
+
+def merge_service_dicts(base, override, version):
+ md = MergeDict(base, override)
+
+ md.merge_mapping('environment', parse_environment)
+ md.merge_mapping('labels', parse_labels)
+ md.merge_mapping('ulimits', parse_flat_dict)
+ md.merge_mapping('sysctls', parse_sysctls)
+ md.merge_mapping('depends_on', parse_depends_on)
+ md.merge_mapping('storage_opt', parse_flat_dict)
+ md.merge_sequence('links', ServiceLink.parse)
+ md.merge_sequence('secrets', types.ServiceSecret.parse)
+ md.merge_sequence('configs', types.ServiceConfig.parse)
+ md.merge_sequence('security_opt', types.SecurityOpt.parse)
+ md.merge_mapping('extra_hosts', parse_extra_hosts)
+
+ md.merge_field('networks', merge_networks, default={})
+ for field in ['volumes', 'devices']:
+ md.merge_field(field, merge_path_mappings)
+
+ for field in [
+ 'cap_add', 'cap_drop', 'expose', 'external_links',
+ 'volumes_from', 'device_cgroup_rules', 'profiles',
+ ]:
+ md.merge_field(field, merge_unique_items_lists, default=[])
+
+ for field in ['dns', 'dns_search', 'env_file', 'tmpfs']:
+ md.merge_field(field, merge_list_or_string)
+
+ md.merge_field('logging', merge_logging, default={})
+ merge_ports(md, base, override)
+ md.merge_field('blkio_config', merge_blkio_config, default={})
+ md.merge_field('healthcheck', merge_healthchecks, default={})
+ md.merge_field('deploy', merge_deploy, default={})
+
+ for field in set(ALLOWED_KEYS) - set(md):
+ md.merge_scalar(field)
+
+ if version == V1:
+ legacy_v1_merge_image_or_build(md, base, override)
+ elif md.needs_merge('build'):
+ md['build'] = merge_build(md, base, override)
+
+ return dict(md)
+
+
+def merge_unique_items_lists(base, override):
+ override = (str(o) for o in override)
+ base = (str(b) for b in base)
+ return sorted(set(chain(base, override)))
+
+
+def merge_healthchecks(base, override):
+ if override.get('disabled') is True:
+ return override
+ result = base.copy()
+ result.update(override)
+ return result
+
+
+def merge_ports(md, base, override):
+ def parse_sequence_func(seq):
+ acc = [s for item in seq for s in ServicePort.parse(item)]
+ return to_mapping(acc, 'merge_field')
+
+ field = 'ports'
+
+ if not md.needs_merge(field):
+ return
+
+ merged = parse_sequence_func(md.base.get(field, []))
+ merged.update(parse_sequence_func(md.override.get(field, [])))
+ md[field] = [item for item in sorted(merged.values(), key=attrgetter("target"))]
+
+
+def merge_build(output, base, override):
+ def to_dict(service):
+ build_config = service.get('build', {})
+ if isinstance(build_config, str):
+ return {'context': build_config}
+ return build_config
+
+ md = MergeDict(to_dict(base), to_dict(override))
+ md.merge_scalar('context')
+ md.merge_scalar('dockerfile')
+ md.merge_scalar('network')
+ md.merge_scalar('target')
+ md.merge_scalar('shm_size')
+ md.merge_scalar('isolation')
+ md.merge_mapping('args', parse_build_arguments)
+ md.merge_field('cache_from', merge_unique_items_lists, default=[])
+ md.merge_mapping('labels', parse_labels)
+ md.merge_mapping('extra_hosts', parse_extra_hosts)
+ return dict(md)
+
+
+def merge_deploy(base, override):
+ md = MergeDict(base or {}, override or {})
+ md.merge_scalar('mode')
+ md.merge_scalar('endpoint_mode')
+ md.merge_scalar('replicas')
+ md.merge_mapping('labels', parse_labels)
+ md.merge_mapping('update_config')
+ md.merge_mapping('rollback_config')
+ md.merge_mapping('restart_policy')
+ if md.needs_merge('resources'):
+ resources_md = MergeDict(md.base.get('resources') or {}, md.override.get('resources') or {})
+ resources_md.merge_mapping('limits')
+ resources_md.merge_field('reservations', merge_reservations, default={})
+ md['resources'] = dict(resources_md)
+ if md.needs_merge('placement'):
+ placement_md = MergeDict(md.base.get('placement') or {}, md.override.get('placement') or {})
+ placement_md.merge_scalar('max_replicas_per_node')
+ placement_md.merge_field('constraints', merge_unique_items_lists, default=[])
+ placement_md.merge_field('preferences', merge_unique_objects_lists, default=[])
+ md['placement'] = dict(placement_md)
+
+ return dict(md)
+
+
+def merge_networks(base, override):
+ merged_networks = {}
+ all_network_names = set(base) | set(override)
+ base = {k: {} for k in base} if isinstance(base, list) else base
+ override = {k: {} for k in override} if isinstance(override, list) else override
+ for network_name in all_network_names:
+ md = MergeDict(base.get(network_name) or {}, override.get(network_name) or {})
+ md.merge_field('aliases', merge_unique_items_lists, [])
+ md.merge_field('link_local_ips', merge_unique_items_lists, [])
+ md.merge_scalar('priority')
+ md.merge_scalar('ipv4_address')
+ md.merge_scalar('ipv6_address')
+ merged_networks[network_name] = dict(md)
+ return merged_networks
+
+
+def merge_reservations(base, override):
+ md = MergeDict(base, override)
+ md.merge_scalar('cpus')
+ md.merge_scalar('memory')
+ md.merge_sequence('generic_resources', types.GenericResource.parse)
+ md.merge_field('devices', merge_unique_objects_lists, default=[])
+ return dict(md)
+
+
+def merge_unique_objects_lists(base, override):
+ result = {json_hash(i): i for i in base + override}
+ return [i[1] for i in sorted(((k, v) for k, v in result.items()), key=itemgetter(0))]
+
+
+def merge_blkio_config(base, override):
+ md = MergeDict(base, override)
+ md.merge_scalar('weight')
+
+ def merge_blkio_limits(base, override):
+ get_path = itemgetter('path')
+ index = {get_path(b): b for b in base}
+ index.update((get_path(o), o) for o in override)
+
+ return sorted(index.values(), key=get_path)
+
+ for field in [
+ "device_read_bps", "device_read_iops", "device_write_bps",
+ "device_write_iops", "weight_device",
+ ]:
+ md.merge_field(field, merge_blkio_limits, default=[])
+
+ return dict(md)
+
+
+def merge_logging(base, override):
+ md = MergeDict(base, override)
+ md.merge_scalar('driver')
+ if md.get('driver') == base.get('driver') or base.get('driver') is None:
+ md.merge_mapping('options', lambda m: m or {})
+ elif override.get('options'):
+ md['options'] = override.get('options', {})
+ return dict(md)
+
+
+def legacy_v1_merge_image_or_build(output, base, override):
+ output.pop('image', None)
+ output.pop('build', None)
+ if 'image' in override:
+ output['image'] = override['image']
+ elif 'build' in override:
+ output['build'] = override['build']
+ elif 'image' in base:
+ output['image'] = base['image']
+ elif 'build' in base:
+ output['build'] = base['build']
+
+
+def merge_environment(base, override):
+ env = parse_environment(base)
+ env.update(parse_environment(override))
+ return env
+
+
+def merge_labels(base, override):
+ labels = parse_labels(base)
+ labels.update(parse_labels(override))
+ return labels
+
+
+def split_kv(kvpair):
+ if '=' in kvpair:
+ return kvpair.split('=', 1)
+ else:
+ return kvpair, ''
+
+
+def parse_dict_or_list(split_func, type_name, arguments):
+ if not arguments:
+ return {}
+
+ if isinstance(arguments, list):
+ return dict(split_func(e) for e in arguments)
+
+ if isinstance(arguments, dict):
+ return dict(arguments)
+
+ raise ConfigurationError(
+ "%s \"%s\" must be a list or mapping," %
+ (type_name, arguments)
+ )
+
+
+parse_build_arguments = functools.partial(parse_dict_or_list, split_env, 'build arguments')
+parse_environment = functools.partial(parse_dict_or_list, split_env, 'environment')
+parse_labels = functools.partial(parse_dict_or_list, split_kv, 'labels')
+parse_networks = functools.partial(parse_dict_or_list, lambda k: (k, None), 'networks')
+parse_sysctls = functools.partial(parse_dict_or_list, split_kv, 'sysctls')
+parse_depends_on = functools.partial(
+ parse_dict_or_list, lambda k: (k, {'condition': 'service_started'}), 'depends_on'
+)
+
+
+def parse_flat_dict(d):
+ if not d:
+ return {}
+
+ if isinstance(d, dict):
+ return dict(d)
+
+ raise ConfigurationError("Invalid type: expected mapping")
+
+
+def resolve_env_var(key, val, environment):
+ if val is not None:
+ return key, val
+ elif environment and key in environment:
+ return key, environment[key]
+ else:
+ return key, None
+
+
+def resolve_volume_paths(working_dir, service_dict):
+ return [
+ resolve_volume_path(working_dir, volume)
+ for volume in service_dict['volumes']
+ ]
+
+
+def resolve_volume_path(working_dir, volume):
+ if isinstance(volume, dict):
+ if volume.get('source', '').startswith(('.', '~')) and volume['type'] == 'bind':
+ volume['source'] = expand_path(working_dir, volume['source'])
+ return volume
+
+ mount_params = None
+ container_path, mount_params = split_path_mapping(volume)
+
+ if mount_params is not None:
+ host_path, mode = mount_params
+ if host_path is None:
+ return container_path
+ if host_path.startswith('.'):
+ host_path = expand_path(working_dir, host_path)
+ host_path = os.path.expanduser(host_path)
+ return "{}:{}{}".format(host_path, container_path, (':' + mode if mode else ''))
+
+ return container_path
+
+
+def normalize_build(service_dict, working_dir, environment):
+
+ if 'build' in service_dict:
+ build = {}
+ # Shortcut where specifying a string is treated as the build context
+ if isinstance(service_dict['build'], str):
+ build['context'] = service_dict.pop('build')
+ else:
+ build.update(service_dict['build'])
+ if 'args' in build:
+ build['args'] = build_string_dict(
+ resolve_build_args(build.get('args'), environment)
+ )
+
+ service_dict['build'] = build
+
+
+def resolve_build_path(working_dir, build_path):
+ if is_url(build_path):
+ return build_path
+ return expand_path(working_dir, build_path)
+
+
+def is_url(build_path):
+ return build_path.startswith(DOCKER_VALID_URL_PREFIXES)
+
+
+def validate_paths(service_dict):
+ if 'build' in service_dict:
+ build = service_dict.get('build', {})
+
+ if isinstance(build, str):
+ build_path = build
+ elif isinstance(build, dict) and 'context' in build:
+ build_path = build['context']
+ else:
+ # We have a build section but no context, so nothing to validate
+ return
+
+ if (
+ not is_url(build_path) and
+ (not os.path.exists(build_path) or not os.access(build_path, os.R_OK))
+ ):
+ raise ConfigurationError(
+ "build path %s either does not exist, is not accessible, "
+ "or is not a valid URL." % build_path)
+
+
+def merge_path_mappings(base, override):
+ d = dict_from_path_mappings(base)
+ d.update(dict_from_path_mappings(override))
+ return path_mappings_from_dict(d)
+
+
+def dict_from_path_mappings(path_mappings):
+ if path_mappings:
+ return dict(split_path_mapping(v) for v in path_mappings)
+ else:
+ return {}
+
+
+def path_mappings_from_dict(d):
+ return [join_path_mapping(v) for v in sorted(d.items())]
+
+
+def split_path_mapping(volume_path):
+ """
+ Ascertain if the volume_path contains a host path as well as a container
+ path. Using splitdrive so windows absolute paths won't cause issues with
+ splitting on ':'.
+ """
+ if isinstance(volume_path, dict):
+ return (volume_path.get('target'), volume_path)
+ drive, volume_config = splitdrive(volume_path)
+
+ if ':' in volume_config:
+ (host, container) = volume_config.split(':', 1)
+ container_drive, container_path = splitdrive(container)
+ mode = None
+ if ':' in container_path:
+ container_path, mode = container_path.rsplit(':', 1)
+
+ return (container_drive + container_path, (drive + host, mode))
+ else:
+ return (volume_path, None)
+
+
+def process_security_opt(service_dict):
+ security_opts = service_dict.get('security_opt', [])
+ result = []
+ for value in security_opts:
+ result.append(SecurityOpt.parse(value))
+ if result:
+ service_dict['security_opt'] = result
+ return service_dict
+
+
+def join_path_mapping(pair):
+ (container, host) = pair
+ if isinstance(host, dict):
+ return host
+ elif host is None:
+ return container
+ else:
+ host, mode = host
+ result = ":".join((host, container))
+ if mode:
+ result += ":" + mode
+ return result
+
+
+def expand_path(working_dir, path):
+ return os.path.abspath(os.path.join(working_dir, os.path.expanduser(path)))
+
+
+def merge_list_or_string(base, override):
+ return to_list(base) + to_list(override)
+
+
+def to_list(value):
+ if value is None:
+ return []
+ elif isinstance(value, str):
+ return [value]
+ else:
+ return value
+
+
+def to_mapping(sequence, key_field):
+ return {getattr(item, key_field): item for item in sequence}
+
+
+def has_uppercase(name):
+ return any(char in string.ascii_uppercase for char in name)
+
+
+def load_yaml(filename, encoding=None, binary=True):
+ try:
+ with open(filename, 'rb' if binary else 'r', encoding=encoding) as fh:
+ return yaml.safe_load(fh)
+ except (OSError, yaml.YAMLError, UnicodeDecodeError) as e:
+ if encoding is None:
+ # Sometimes the user's locale sets an encoding that doesn't match
+ # the YAML files. Im such cases, retry once with the "default"
+ # UTF-8 encoding
+ return load_yaml(filename, encoding='utf-8-sig', binary=False)
+ error_name = getattr(e, '__module__', '') + '.' + e.__class__.__name__
+ raise ConfigurationError("{}: {}".format(error_name, e))
diff --git a/compose/config/config_schema_v1.json b/compose/config/config_schema_v1.json
new file mode 100644
index 00000000000..2771f9958fb
--- /dev/null
+++ b/compose/config/config_schema_v1.json
@@ -0,0 +1,203 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "id": "config_schema_v1.json",
+
+ "type": "object",
+
+ "patternProperties": {
+ "^[a-zA-Z0-9._-]+$": {
+ "$ref": "#/definitions/service"
+ }
+ },
+
+ "additionalProperties": false,
+
+ "definitions": {
+ "service": {
+ "id": "#/definitions/service",
+ "type": "object",
+
+ "properties": {
+ "build": {"type": "string"},
+ "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "cgroup_parent": {"type": "string"},
+ "command": {
+ "oneOf": [
+ {"type": "string"},
+ {"type": "array", "items": {"type": "string"}}
+ ]
+ },
+ "container_name": {"type": "string"},
+ "cpu_shares": {"type": ["number", "string"]},
+ "cpu_quota": {"type": ["number", "string"]},
+ "cpuset": {"type": "string"},
+ "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "dns": {"$ref": "#/definitions/string_or_list"},
+ "dns_search": {"$ref": "#/definitions/string_or_list"},
+ "dockerfile": {"type": "string"},
+ "domainname": {"type": "string"},
+ "entrypoint": {
+ "oneOf": [
+ {"type": "string"},
+ {"type": "array", "items": {"type": "string"}}
+ ]
+ },
+ "env_file": {"$ref": "#/definitions/string_or_list"},
+ "environment": {"$ref": "#/definitions/list_or_dict"},
+
+ "expose": {
+ "type": "array",
+ "items": {
+ "type": ["string", "number"],
+ "format": "expose"
+ },
+ "uniqueItems": true
+ },
+
+ "extends": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "object",
+
+ "properties": {
+ "service": {"type": "string"},
+ "file": {"type": "string"}
+ },
+ "required": ["service"],
+ "additionalProperties": false
+ }
+ ]
+ },
+
+ "extra_hosts": {"$ref": "#/definitions/list_or_dict"},
+ "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "hostname": {"type": "string"},
+ "image": {"type": "string"},
+ "ipc": {"type": "string"},
+ "labels": {"$ref": "#/definitions/labels"},
+ "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "log_driver": {"type": "string"},
+ "log_opt": {"type": "object"},
+ "mac_address": {"type": "string"},
+ "mem_limit": {"type": ["number", "string"]},
+ "memswap_limit": {"type": ["number", "string"]},
+ "mem_swappiness": {"type": "integer"},
+ "net": {"type": "string"},
+ "pid": {"type": ["string", "null"]},
+
+ "ports": {
+ "type": "array",
+ "items": {
+ "type": ["string", "number"],
+ "format": "ports"
+ },
+ "uniqueItems": true
+ },
+
+ "privileged": {"type": "boolean"},
+ "read_only": {"type": "boolean"},
+ "restart": {"type": "string"},
+ "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "shm_size": {"type": ["number", "string"]},
+ "stdin_open": {"type": "boolean"},
+ "stop_signal": {"type": "string"},
+ "tty": {"type": "boolean"},
+ "ulimits": {
+ "type": "object",
+ "patternProperties": {
+ "^[a-z]+$": {
+ "oneOf": [
+ {"type": "integer"},
+ {
+ "type":"object",
+ "properties": {
+ "hard": {"type": "integer"},
+ "soft": {"type": "integer"}
+ },
+ "required": ["soft", "hard"],
+ "additionalProperties": false
+ }
+ ]
+ }
+ }
+ },
+ "user": {"type": "string"},
+ "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "volume_driver": {"type": "string"},
+ "volumes_from": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+ "working_dir": {"type": "string"}
+ },
+
+ "dependencies": {
+ "memswap_limit": ["mem_limit"]
+ },
+ "additionalProperties": false
+ },
+
+ "string_or_list": {
+ "oneOf": [
+ {"type": "string"},
+ {"$ref": "#/definitions/list_of_strings"}
+ ]
+ },
+
+ "list_of_strings": {
+ "type": "array",
+ "items": {"type": "string"},
+ "uniqueItems": true
+ },
+
+ "list_or_dict": {
+ "oneOf": [
+ {
+ "type": "object",
+ "patternProperties": {
+ ".+": {
+ "type": ["string", "number", "null"]
+ }
+ },
+ "additionalProperties": false
+ },
+ {"type": "array", "items": {"type": "string"}, "uniqueItems": true}
+ ]
+ },
+
+ "labels": {
+ "oneOf": [
+ {
+ "type": "object",
+ "patternProperties": {
+ ".+": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ {"type": "array", "items": {"type": "string"}, "uniqueItems": true}
+ ]
+ },
+
+ "constraints": {
+ "service": {
+ "id": "#/definitions/constraints/service",
+ "anyOf": [
+ {
+ "required": ["build"],
+ "not": {"required": ["image"]}
+ },
+ {
+ "required": ["image"],
+ "not": {"anyOf": [
+ {"required": ["build"]},
+ {"required": ["dockerfile"]}
+ ]}
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/compose/config/environment.py b/compose/config/environment.py
new file mode 100644
index 00000000000..5045a730b77
--- /dev/null
+++ b/compose/config/environment.py
@@ -0,0 +1,126 @@
+import logging
+import os
+import re
+
+import dotenv
+
+from ..const import IS_WINDOWS_PLATFORM
+from .errors import ConfigurationError
+from .errors import EnvFileNotFound
+
+log = logging.getLogger(__name__)
+
+
+def split_env(env):
+ if isinstance(env, bytes):
+ env = env.decode('utf-8', 'replace')
+ key = value = None
+ if '=' in env:
+ key, value = env.split('=', 1)
+ else:
+ key = env
+ if re.search(r'\s', key):
+ raise ConfigurationError(
+ "environment variable name '{}' may not contain whitespace.".format(key)
+ )
+ return key, value
+
+
+def env_vars_from_file(filename, interpolate=True):
+ """
+ Read in a line delimited file of environment variables.
+ """
+ if not os.path.exists(filename):
+ raise EnvFileNotFound("Couldn't find env file: {}".format(filename))
+ elif not os.path.isfile(filename):
+ raise EnvFileNotFound("{} is not a file.".format(filename))
+
+ env = dotenv.dotenv_values(dotenv_path=filename, encoding='utf-8-sig', interpolate=interpolate)
+ for k, v in env.items():
+ env[k] = v if interpolate else v.replace('$', '$$')
+ return env
+
+
+class Environment(dict):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.missing_keys = []
+ self.silent = False
+
+ @classmethod
+ def from_env_file(cls, base_dir, env_file=None):
+ def _initialize():
+ result = cls()
+ if base_dir is None:
+ return result
+ if env_file:
+ env_file_path = os.path.join(os.getcwd(), env_file)
+ return cls(env_vars_from_file(env_file_path))
+
+ env_file_path = os.path.join(base_dir, '.env')
+ try:
+ return cls(env_vars_from_file(env_file_path))
+ except EnvFileNotFound:
+ pass
+ return result
+
+ instance = _initialize()
+ instance.update(os.environ)
+ return instance
+
+ @classmethod
+ def from_command_line(cls, parsed_env_opts):
+ result = cls()
+ for k, v in parsed_env_opts.items():
+ # Values from the command line take priority, unless they're unset
+ # in which case they take the value from the system's environment
+ if v is None and k in os.environ:
+ result[k] = os.environ[k]
+ else:
+ result[k] = v
+ return result
+
+ def __getitem__(self, key):
+ try:
+ return super().__getitem__(key)
+ except KeyError:
+ if IS_WINDOWS_PLATFORM:
+ try:
+ return super().__getitem__(key.upper())
+ except KeyError:
+ pass
+ if not self.silent and key not in self.missing_keys:
+ log.warning(
+ "The {} variable is not set. Defaulting to a blank string."
+ .format(key)
+ )
+ self.missing_keys.append(key)
+
+ return ""
+
+ def __contains__(self, key):
+ result = super().__contains__(key)
+ if IS_WINDOWS_PLATFORM:
+ return (
+ result or super().__contains__(key.upper())
+ )
+ return result
+
+ def get(self, key, *args, **kwargs):
+ if IS_WINDOWS_PLATFORM:
+ return super().get(
+ key,
+ super().get(key.upper(), *args, **kwargs)
+ )
+ return super().get(key, *args, **kwargs)
+
+ def get_boolean(self, key, default=False):
+ # Convert a value to a boolean using "common sense" rules.
+ # Unset, empty, "0" and "false" (i-case) yield False.
+ # All other values yield True.
+ value = self.get(key)
+ if not value:
+ return default
+ if value.lower() in ['0', 'false']:
+ return False
+ return True
diff --git a/compose/config/errors.py b/compose/config/errors.py
new file mode 100644
index 00000000000..b66433a7998
--- /dev/null
+++ b/compose/config/errors.py
@@ -0,0 +1,55 @@
+VERSION_EXPLANATION = (
+ 'You might be seeing this error because you\'re using the wrong Compose file version. '
+ 'Either specify a supported version (e.g "2.2" or "3.3") and place '
+ 'your service definitions under the `services` key, or omit the `version` key '
+ 'and place your service definitions at the root of the file to use '
+ 'version 1.\nFor more on the Compose file format versions, see '
+ 'https://docs.docker.com/compose/compose-file/')
+
+
+class ConfigurationError(Exception):
+ def __init__(self, msg):
+ self.msg = msg
+
+ def __str__(self):
+ return self.msg
+
+
+class EnvFileNotFound(ConfigurationError):
+ pass
+
+
+class DependencyError(ConfigurationError):
+ pass
+
+
+class CircularReference(ConfigurationError):
+ def __init__(self, trail):
+ self.trail = trail
+
+ @property
+ def msg(self):
+ lines = [
+ "{} in {}".format(service_name, filename)
+ for (filename, service_name) in self.trail
+ ]
+ return "Circular reference:\n {}".format("\n extends ".join(lines))
+
+
+class ComposeFileNotFound(ConfigurationError):
+ def __init__(self, supported_filenames):
+ super().__init__("""
+ Can't find a suitable configuration file in this directory or any
+ parent. Are you in the right directory?
+
+ Supported filenames: %s
+ """ % ", ".join(supported_filenames))
+
+
+class DuplicateOverrideFileFound(ConfigurationError):
+ def __init__(self, override_filenames):
+ self.override_filenames = override_filenames
+ super().__init__(
+ "Multiple override files found: {}. You may only use a single "
+ "override file.".format(", ".join(override_filenames))
+ )
diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py
new file mode 100644
index 00000000000..ff0d90e8332
--- /dev/null
+++ b/compose/config/interpolation.py
@@ -0,0 +1,296 @@
+import logging
+import re
+from string import Template
+
+from .errors import ConfigurationError
+from compose.const import COMPOSEFILE_V1 as V1
+from compose.utils import parse_bytes
+from compose.utils import parse_nanoseconds_int
+
+
+log = logging.getLogger(__name__)
+
+
+class Interpolator:
+
+ def __init__(self, templater, mapping):
+ self.templater = templater
+ self.mapping = mapping
+
+ def interpolate(self, string):
+ try:
+ return self.templater(string).substitute(self.mapping)
+ except ValueError:
+ raise InvalidInterpolation(string)
+
+
+def interpolate_environment_variables(version, config, section, environment):
+ if version == V1:
+ interpolator = Interpolator(Template, environment)
+ else:
+ interpolator = Interpolator(TemplateWithDefaults, environment)
+
+ def process_item(name, config_dict):
+ return {
+ key: interpolate_value(name, key, val, section, interpolator)
+ for key, val in (config_dict or {}).items()
+ }
+
+ return {
+ name: process_item(name, config_dict or {})
+ for name, config_dict in config.items()
+ }
+
+
+def get_config_path(config_key, section, name):
+ return '{}/{}/{}'.format(section, name, config_key)
+
+
+def interpolate_value(name, config_key, value, section, interpolator):
+ try:
+ return recursive_interpolate(value, interpolator, get_config_path(config_key, section, name))
+ except InvalidInterpolation as e:
+ raise ConfigurationError(
+ 'Invalid interpolation format for "{config_key}" option '
+ 'in {section} "{name}": "{string}"'.format(
+ config_key=config_key,
+ name=name,
+ section=section,
+ string=e.string))
+ except UnsetRequiredSubstitution as e:
+ raise ConfigurationError(
+ 'Missing mandatory value for "{config_key}" option interpolating {value} '
+ 'in {section} "{name}": {err}'.format(config_key=config_key,
+ value=value,
+ name=name,
+ section=section,
+ err=e.err)
+ )
+
+
+def recursive_interpolate(obj, interpolator, config_path):
+ def append(config_path, key):
+ return '{}/{}'.format(config_path, key)
+
+ if isinstance(obj, str):
+ return converter.convert(config_path, interpolator.interpolate(obj))
+ if isinstance(obj, dict):
+ return {
+ key: recursive_interpolate(val, interpolator, append(config_path, key))
+ for key, val in obj.items()
+ }
+ if isinstance(obj, list):
+ return [recursive_interpolate(val, interpolator, config_path) for val in obj]
+ return converter.convert(config_path, obj)
+
+
+class TemplateWithDefaults(Template):
+ pattern = r"""
+ {delim}(?:
+ (?P{delim}) |
+ (?P{id}) |
+ {{(?P{bid})}} |
+ (?P)
+ )
+ """.format(
+ delim=re.escape('$'),
+ id=r'[_a-z][_a-z0-9]*',
+ bid=r'[_a-z][_a-z0-9]*(?:(?P:?[-?])[^}]*)?',
+ )
+
+ @staticmethod
+ def process_braced_group(braced, sep, mapping):
+ if ':-' == sep:
+ var, _, default = braced.partition(':-')
+ return mapping.get(var) or default
+ elif '-' == sep:
+ var, _, default = braced.partition('-')
+ return mapping.get(var, default)
+
+ elif ':?' == sep:
+ var, _, err = braced.partition(':?')
+ result = mapping.get(var)
+ if not result:
+ err = err or var
+ raise UnsetRequiredSubstitution(err)
+ return result
+ elif '?' == sep:
+ var, _, err = braced.partition('?')
+ if var in mapping:
+ return mapping.get(var)
+ err = err or var
+ raise UnsetRequiredSubstitution(err)
+
+ # Modified from python2.7/string.py
+ def substitute(self, mapping):
+ # Helper function for .sub()
+
+ def convert(mo):
+ named = mo.group('named') or mo.group('braced')
+ braced = mo.group('braced')
+ if braced is not None:
+ sep = mo.group('sep')
+ if sep:
+ return self.process_braced_group(braced, sep, mapping)
+
+ if named is not None:
+ val = mapping[named]
+ if isinstance(val, bytes):
+ val = val.decode('utf-8')
+ return '{}'.format(val)
+ if mo.group('escaped') is not None:
+ return self.delimiter
+ if mo.group('invalid') is not None:
+ self._invalid(mo)
+ raise ValueError('Unrecognized named group in pattern',
+ self.pattern)
+ return self.pattern.sub(convert, self.template)
+
+
+class InvalidInterpolation(Exception):
+ def __init__(self, string):
+ self.string = string
+
+
+class UnsetRequiredSubstitution(Exception):
+ def __init__(self, custom_err_msg):
+ self.err = custom_err_msg
+
+
+PATH_JOKER = '[^/]+'
+FULL_JOKER = '.+'
+
+
+def re_path(*args):
+ return re.compile('^{}$'.format('/'.join(args)))
+
+
+def re_path_basic(section, name):
+ return re_path(section, PATH_JOKER, name)
+
+
+def service_path(*args):
+ return re_path('service', PATH_JOKER, *args)
+
+
+def to_boolean(s):
+ if not isinstance(s, str):
+ return s
+ s = s.lower()
+ if s in ['y', 'yes', 'true', 'on']:
+ return True
+ elif s in ['n', 'no', 'false', 'off']:
+ return False
+ raise ValueError('"{}" is not a valid boolean value'.format(s))
+
+
+def to_int(s):
+ if not isinstance(s, str):
+ return s
+
+ # We must be able to handle octal representation for `mode` values notably
+ if re.match('^0[0-9]+$', s.strip()):
+ s = '0o' + s[1:]
+ try:
+ return int(s, base=0)
+ except ValueError:
+ raise ValueError('"{}" is not a valid integer'.format(s))
+
+
+def to_float(s):
+ if not isinstance(s, str):
+ return s
+
+ try:
+ return float(s)
+ except ValueError:
+ raise ValueError('"{}" is not a valid float'.format(s))
+
+
+def to_str(o):
+ if isinstance(o, (bool, float, int)):
+ return '{}'.format(o)
+ return o
+
+
+def bytes_to_int(s):
+ v = parse_bytes(s)
+ if v is None:
+ raise ValueError('"{}" is not a valid byte value'.format(s))
+ return v
+
+
+def to_microseconds(v):
+ if not isinstance(v, str):
+ return v
+ return int(parse_nanoseconds_int(v) / 1000)
+
+
+class ConversionMap:
+ map = {
+ service_path('blkio_config', 'weight'): to_int,
+ service_path('blkio_config', 'weight_device', 'weight'): to_int,
+ service_path('build', 'labels', FULL_JOKER): to_str,
+ service_path('cpus'): to_float,
+ service_path('cpu_count'): to_int,
+ service_path('cpu_quota'): to_microseconds,
+ service_path('cpu_period'): to_microseconds,
+ service_path('cpu_rt_period'): to_microseconds,
+ service_path('cpu_rt_runtime'): to_microseconds,
+ service_path('configs', 'mode'): to_int,
+ service_path('secrets', 'mode'): to_int,
+ service_path('healthcheck', 'retries'): to_int,
+ service_path('healthcheck', 'disable'): to_boolean,
+ service_path('deploy', 'labels', PATH_JOKER): to_str,
+ service_path('deploy', 'replicas'): to_int,
+ service_path('deploy', 'placement', 'max_replicas_per_node'): to_int,
+ service_path('deploy', 'resources', 'limits', "cpus"): to_float,
+ service_path('deploy', 'update_config', 'parallelism'): to_int,
+ service_path('deploy', 'update_config', 'max_failure_ratio'): to_float,
+ service_path('deploy', 'rollback_config', 'parallelism'): to_int,
+ service_path('deploy', 'rollback_config', 'max_failure_ratio'): to_float,
+ service_path('deploy', 'restart_policy', 'max_attempts'): to_int,
+ service_path('mem_swappiness'): to_int,
+ service_path('labels', FULL_JOKER): to_str,
+ service_path('oom_kill_disable'): to_boolean,
+ service_path('oom_score_adj'): to_int,
+ service_path('ports', 'target'): to_int,
+ service_path('ports', 'published'): to_int,
+ service_path('scale'): to_int,
+ service_path('ulimits', PATH_JOKER): to_int,
+ service_path('ulimits', PATH_JOKER, 'soft'): to_int,
+ service_path('ulimits', PATH_JOKER, 'hard'): to_int,
+ service_path('privileged'): to_boolean,
+ service_path('read_only'): to_boolean,
+ service_path('stdin_open'): to_boolean,
+ service_path('tty'): to_boolean,
+ service_path('volumes', 'read_only'): to_boolean,
+ service_path('volumes', 'volume', 'nocopy'): to_boolean,
+ service_path('volumes', 'tmpfs', 'size'): bytes_to_int,
+ re_path_basic('network', 'attachable'): to_boolean,
+ re_path_basic('network', 'external'): to_boolean,
+ re_path_basic('network', 'internal'): to_boolean,
+ re_path('network', PATH_JOKER, 'labels', FULL_JOKER): to_str,
+ re_path_basic('volume', 'external'): to_boolean,
+ re_path('volume', PATH_JOKER, 'labels', FULL_JOKER): to_str,
+ re_path_basic('secret', 'external'): to_boolean,
+ re_path('secret', PATH_JOKER, 'labels', FULL_JOKER): to_str,
+ re_path_basic('config', 'external'): to_boolean,
+ re_path('config', PATH_JOKER, 'labels', FULL_JOKER): to_str,
+ }
+
+ def convert(self, path, value):
+ for rexp in self.map.keys():
+ if rexp.match(path):
+ try:
+ return self.map[rexp](value)
+ except ValueError as e:
+ raise ConfigurationError(
+ 'Error while attempting to convert {} to appropriate type: {}'.format(
+ path.replace('/', '.'), e
+ )
+ )
+ return value
+
+
+converter = ConversionMap()
diff --git a/compose/config/serialize.py b/compose/config/serialize.py
new file mode 100644
index 00000000000..e3295df78ea
--- /dev/null
+++ b/compose/config/serialize.py
@@ -0,0 +1,149 @@
+import yaml
+
+from compose.config import types
+from compose.const import COMPOSE_SPEC as VERSION
+from compose.const import COMPOSEFILE_V1 as V1
+
+
+def serialize_config_type(dumper, data):
+ representer = dumper.represent_str
+ return representer(data.repr())
+
+
+def serialize_dict_type(dumper, data):
+ return dumper.represent_dict(data.repr())
+
+
+def serialize_string(dumper, data):
+ """ Ensure boolean-like strings are quoted in the output """
+ representer = dumper.represent_str
+
+ if isinstance(data, bytes):
+ data = data.decode('utf-8')
+
+ if data.lower() in ('y', 'n', 'yes', 'no', 'on', 'off', 'true', 'false'):
+ # Empirically only y/n appears to be an issue, but this might change
+ # depending on which PyYaml version is being used. Err on safe side.
+ return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='"')
+ return representer(data)
+
+
+def serialize_string_escape_dollar(dumper, data):
+ """ Ensure boolean-like strings are quoted in the output and escape $ characters """
+ data = data.replace('$', '$$')
+ return serialize_string(dumper, data)
+
+
+yaml.SafeDumper.add_representer(types.MountSpec, serialize_dict_type)
+yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type)
+yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type)
+yaml.SafeDumper.add_representer(types.SecurityOpt, serialize_config_type)
+yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type)
+yaml.SafeDumper.add_representer(types.ServiceConfig, serialize_dict_type)
+yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type)
+
+
+def denormalize_config(config, image_digests=None):
+ result = {'version': str(config.config_version)}
+ denormalized_services = [
+ denormalize_service_dict(
+ service_dict,
+ config.version,
+ image_digests[service_dict['name']] if image_digests else None)
+ for service_dict in config.services
+ ]
+ result['services'] = {
+ service_dict.pop('name'): service_dict
+ for service_dict in denormalized_services
+ }
+
+ for key in ('networks', 'volumes', 'secrets', 'configs'):
+ config_dict = getattr(config, key)
+ if not config_dict:
+ continue
+ result[key] = config_dict.copy()
+ for name, conf in result[key].items():
+ if 'external_name' in conf:
+ del conf['external_name']
+
+ if 'name' in conf:
+ if 'external' in conf:
+ conf['external'] = bool(conf['external'])
+ return result
+
+
+def serialize_config(config, image_digests=None, escape_dollar=True):
+ if escape_dollar:
+ yaml.SafeDumper.add_representer(str, serialize_string_escape_dollar)
+ yaml.SafeDumper.add_representer(str, serialize_string_escape_dollar)
+ else:
+ yaml.SafeDumper.add_representer(str, serialize_string)
+ yaml.SafeDumper.add_representer(str, serialize_string)
+ return yaml.safe_dump(
+ denormalize_config(config, image_digests),
+ default_flow_style=False,
+ indent=2,
+ width=80,
+ allow_unicode=True
+ )
+
+
+def serialize_ns_time_value(value):
+ result = (value, 'ns')
+ table = [
+ (1000., 'us'),
+ (1000., 'ms'),
+ (1000., 's'),
+ (60., 'm'),
+ (60., 'h')
+ ]
+ for stage in table:
+ tmp = value / stage[0]
+ if tmp == int(value / stage[0]):
+ value = tmp
+ result = (int(value), stage[1])
+ else:
+ break
+ return '{}{}'.format(*result)
+
+
+def denormalize_service_dict(service_dict, version, image_digest=None):
+ service_dict = service_dict.copy()
+
+ if image_digest:
+ service_dict['image'] = image_digest
+
+ if 'restart' in service_dict:
+ service_dict['restart'] = types.serialize_restart_spec(
+ service_dict['restart']
+ )
+
+ if version == V1 and 'network_mode' not in service_dict:
+ service_dict['network_mode'] = 'bridge'
+
+ if 'healthcheck' in service_dict:
+ if 'interval' in service_dict['healthcheck']:
+ service_dict['healthcheck']['interval'] = serialize_ns_time_value(
+ service_dict['healthcheck']['interval']
+ )
+ if 'timeout' in service_dict['healthcheck']:
+ service_dict['healthcheck']['timeout'] = serialize_ns_time_value(
+ service_dict['healthcheck']['timeout']
+ )
+
+ if 'start_period' in service_dict['healthcheck']:
+ service_dict['healthcheck']['start_period'] = serialize_ns_time_value(
+ service_dict['healthcheck']['start_period']
+ )
+
+ if 'ports' in service_dict:
+ service_dict['ports'] = [
+ p.legacy_repr() if p.external_ip or version < VERSION else p
+ for p in service_dict['ports']
+ ]
+ if 'volumes' in service_dict and (version == V1):
+ service_dict['volumes'] = [
+ v.legacy_repr() if isinstance(v, types.MountSpec) else v for v in service_dict['volumes']
+ ]
+
+ return service_dict
diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py
new file mode 100644
index 00000000000..0a7eb2b4fda
--- /dev/null
+++ b/compose/config/sort_services.py
@@ -0,0 +1,71 @@
+from compose.config.errors import DependencyError
+
+
+def get_service_name_from_network_mode(network_mode):
+ return get_source_name_from_network_mode(network_mode, 'service')
+
+
+def get_container_name_from_network_mode(network_mode):
+ return get_source_name_from_network_mode(network_mode, 'container')
+
+
+def get_source_name_from_network_mode(network_mode, source_type):
+ if not network_mode:
+ return
+
+ if not network_mode.startswith(source_type+':'):
+ return
+
+ _, net_name = network_mode.split(':', 1)
+ return net_name
+
+
+def get_service_names(links):
+ return [link.split(':', 1)[0] for link in links]
+
+
+def get_service_names_from_volumes_from(volumes_from):
+ return [volume_from.source for volume_from in volumes_from]
+
+
+def get_service_dependents(service_dict, services):
+ name = service_dict['name']
+ return [
+ service for service in services
+ if (name in get_service_names(service.get('links', [])) or
+ name in get_service_names_from_volumes_from(service.get('volumes_from', [])) or
+ name == get_service_name_from_network_mode(service.get('network_mode')) or
+ name == get_service_name_from_network_mode(service.get('pid')) or
+ name == get_service_name_from_network_mode(service.get('ipc')) or
+ name in service.get('depends_on', []))
+ ]
+
+
+def sort_service_dicts(services):
+ # Topological sort (Cormen/Tarjan algorithm).
+ unmarked = services[:]
+ temporary_marked = set()
+ sorted_services = []
+
+ def visit(n):
+ if n['name'] in temporary_marked:
+ if n['name'] in get_service_names(n.get('links', [])):
+ raise DependencyError('A service can not link to itself: %s' % n['name'])
+ if n['name'] in n.get('volumes_from', []):
+ raise DependencyError('A service can not mount itself as volume: %s' % n['name'])
+ if n['name'] in n.get('depends_on', []):
+ raise DependencyError('A service can not depend on itself: %s' % n['name'])
+ raise DependencyError('Circular dependency between %s' % ' and '.join(temporary_marked))
+
+ if n in unmarked:
+ temporary_marked.add(n['name'])
+ for m in get_service_dependents(n, services):
+ visit(m)
+ temporary_marked.remove(n['name'])
+ unmarked.remove(n)
+ sorted_services.insert(0, n)
+
+ while unmarked:
+ visit(unmarked[-1])
+
+ return sorted_services
diff --git a/compose/config/types.py b/compose/config/types.py
new file mode 100644
index 00000000000..f52b5654139
--- /dev/null
+++ b/compose/config/types.py
@@ -0,0 +1,500 @@
+"""
+Types for objects parsed from the configuration.
+"""
+import json
+import ntpath
+import os
+import re
+from collections import namedtuple
+
+from docker.utils.ports import build_port_bindings
+
+from ..const import COMPOSEFILE_V1 as V1
+from ..utils import unquote_path
+from .errors import ConfigurationError
+from compose.const import IS_WINDOWS_PLATFORM
+from compose.utils import splitdrive
+
+win32_root_path_pattern = re.compile(r'^[A-Za-z]\:\\.*')
+
+
+class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode type')):
+
+ # TODO: drop service_names arg when v1 is removed
+ @classmethod
+ def parse(cls, volume_from_config, service_names, version):
+ func = cls.parse_v1 if version == V1 else cls.parse_v2
+ return func(service_names, volume_from_config)
+
+ @classmethod
+ def parse_v1(cls, service_names, volume_from_config):
+ parts = volume_from_config.split(':')
+ if len(parts) > 2:
+ raise ConfigurationError(
+ "volume_from {} has incorrect format, should be "
+ "service[:mode]".format(volume_from_config))
+
+ if len(parts) == 1:
+ source = parts[0]
+ mode = 'rw'
+ else:
+ source, mode = parts
+
+ type = 'service' if source in service_names else 'container'
+ return cls(source, mode, type)
+
+ @classmethod
+ def parse_v2(cls, service_names, volume_from_config):
+ parts = volume_from_config.split(':')
+ if len(parts) > 3:
+ raise ConfigurationError(
+ "volume_from {} has incorrect format, should be one of "
+ "'[:]' or "
+ "'container:[:]'".format(volume_from_config))
+
+ if len(parts) == 1:
+ source = parts[0]
+ return cls(source, 'rw', 'service')
+
+ if len(parts) == 2:
+ if parts[0] == 'container':
+ type, source = parts
+ return cls(source, 'rw', type)
+
+ source, mode = parts
+ return cls(source, mode, 'service')
+
+ if len(parts) == 3:
+ type, source, mode = parts
+ if type not in ('service', 'container'):
+ raise ConfigurationError(
+ "Unknown volumes_from type '{}' in '{}'".format(
+ type,
+ volume_from_config))
+
+ return cls(source, mode, type)
+
+ def repr(self):
+ return '{v.type}:{v.source}:{v.mode}'.format(v=self)
+
+
+def parse_restart_spec(restart_config):
+ if not restart_config:
+ return None
+ parts = restart_config.split(':')
+ if len(parts) > 2:
+ raise ConfigurationError(
+ "Restart %s has incorrect format, should be "
+ "mode[:max_retry]" % restart_config)
+ if len(parts) == 2:
+ name, max_retry_count = parts
+ else:
+ name, = parts
+ max_retry_count = 0
+
+ return {'Name': name, 'MaximumRetryCount': int(max_retry_count)}
+
+
+def serialize_restart_spec(restart_spec):
+ if not restart_spec:
+ return ''
+ parts = [restart_spec['Name']]
+ if restart_spec['MaximumRetryCount']:
+ parts.append(str(restart_spec['MaximumRetryCount']))
+ return ':'.join(parts)
+
+
+def parse_extra_hosts(extra_hosts_config):
+ if not extra_hosts_config:
+ return {}
+
+ if isinstance(extra_hosts_config, dict):
+ return dict(extra_hosts_config)
+
+ if isinstance(extra_hosts_config, list):
+ extra_hosts_dict = {}
+ for extra_hosts_line in extra_hosts_config:
+ # TODO: validate string contains ':' ?
+ host, ip = extra_hosts_line.split(':', 1)
+ extra_hosts_dict[host.strip()] = ip.strip()
+ return extra_hosts_dict
+
+
+def normalize_path_for_engine(path):
+ """Windows paths, c:\\my\\path\\shiny, need to be changed to be compatible with
+ the Engine. Volume paths are expected to be linux style /c/my/path/shiny/
+ """
+ drive, tail = splitdrive(path)
+
+ if drive:
+ path = '/' + drive.lower().rstrip(':') + tail
+
+ return path.replace('\\', '/')
+
+
+def normpath(path, win_host=False):
+ """ Custom path normalizer that handles Compose-specific edge cases like
+ UNIX paths on Windows hosts and vice-versa. """
+
+ sysnorm = ntpath.normpath if win_host else os.path.normpath
+ # If a path looks like a UNIX absolute path on Windows, it probably is;
+ # we'll need to revert the backslashes to forward slashes after normalization
+ flip_slashes = path.startswith('/') and IS_WINDOWS_PLATFORM
+ path = sysnorm(path)
+ if flip_slashes:
+ path = path.replace('\\', '/')
+ return path
+
+
+class MountSpec:
+ options_map = {
+ 'volume': {
+ 'nocopy': 'no_copy'
+ },
+ 'bind': {
+ 'propagation': 'propagation'
+ },
+ 'tmpfs': {
+ 'size': 'tmpfs_size'
+ }
+ }
+ _fields = ['type', 'source', 'target', 'read_only', 'consistency']
+
+ @classmethod
+ def parse(cls, mount_dict, normalize=False, win_host=False):
+ if mount_dict.get('source'):
+ if mount_dict['type'] == 'tmpfs':
+ raise ConfigurationError('tmpfs mounts can not specify a source')
+
+ mount_dict['source'] = normpath(mount_dict['source'], win_host)
+ if normalize:
+ mount_dict['source'] = normalize_path_for_engine(mount_dict['source'])
+
+ return cls(**mount_dict)
+
+ def __init__(self, type, source=None, target=None, read_only=None, consistency=None, **kwargs):
+ self.type = type
+ self.source = source
+ self.target = target
+ self.read_only = read_only
+ self.consistency = consistency
+ self.options = None
+ if self.type in kwargs:
+ self.options = kwargs[self.type]
+
+ def as_volume_spec(self):
+ mode = 'ro' if self.read_only else 'rw'
+ return VolumeSpec(external=self.source, internal=self.target, mode=mode)
+
+ def legacy_repr(self):
+ return self.as_volume_spec().repr()
+
+ def repr(self):
+ res = {}
+ for field in self._fields:
+ if getattr(self, field, None):
+ res[field] = getattr(self, field)
+ if self.options:
+ res[self.type] = self.options
+ return res
+
+ @property
+ def is_named_volume(self):
+ return self.type == 'volume' and self.source
+
+ @property
+ def is_tmpfs(self):
+ return self.type == 'tmpfs'
+
+ @property
+ def external(self):
+ return self.source
+
+
+class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')):
+ win32 = False
+
+ @classmethod
+ def _parse_unix(cls, volume_config):
+ parts = volume_config.split(':')
+
+ if len(parts) > 3:
+ raise ConfigurationError(
+ "Volume %s has incorrect format, should be "
+ "external:internal[:mode]" % volume_config)
+
+ if len(parts) == 1:
+ external = None
+ internal = os.path.normpath(parts[0])
+ else:
+ external = os.path.normpath(parts[0])
+ internal = os.path.normpath(parts[1])
+
+ mode = 'rw'
+ if len(parts) == 3:
+ mode = parts[2]
+
+ return cls(external, internal, mode)
+
+ @classmethod
+ def _parse_win32(cls, volume_config, normalize):
+ # relative paths in windows expand to include the drive, eg C:\
+ # so we join the first 2 parts back together to count as one
+ mode = 'rw'
+
+ def separate_next_section(volume_config):
+ drive, tail = splitdrive(volume_config)
+ parts = tail.split(':', 1)
+ if drive:
+ parts[0] = drive + parts[0]
+ return parts
+
+ parts = separate_next_section(volume_config)
+ if len(parts) == 1:
+ internal = parts[0]
+ external = None
+ else:
+ external = parts[0]
+ parts = separate_next_section(parts[1])
+ external = normpath(external, True)
+ internal = parts[0]
+ if len(parts) > 1:
+ if ':' in parts[1]:
+ raise ConfigurationError(
+ "Volume %s has incorrect format, should be "
+ "external:internal[:mode]" % volume_config
+ )
+ mode = parts[1]
+
+ if normalize:
+ external = normalize_path_for_engine(external) if external else None
+
+ result = cls(external, internal, mode)
+ result.win32 = True
+ return result
+
+ @classmethod
+ def parse(cls, volume_config, normalize=False, win_host=False):
+ """Parse a volume_config path and split it into external:internal[:mode]
+ parts to be returned as a valid VolumeSpec.
+ """
+ if IS_WINDOWS_PLATFORM or win_host:
+ return cls._parse_win32(volume_config, normalize)
+ else:
+ return cls._parse_unix(volume_config)
+
+ def repr(self):
+ external = self.external + ':' if self.external else ''
+ mode = ':' + self.mode if self.external else ''
+ return '{ext}{v.internal}{mode}'.format(mode=mode, ext=external, v=self)
+
+ @property
+ def is_named_volume(self):
+ res = self.external and not self.external.startswith(('.', '/', '~'))
+ if not self.win32:
+ return res
+
+ return (
+ res and not self.external.startswith('\\') and
+ not win32_root_path_pattern.match(self.external)
+ )
+
+
+class ServiceLink(namedtuple('_ServiceLink', 'target alias')):
+
+ @classmethod
+ def parse(cls, link_spec):
+ target, _, alias = link_spec.partition(':')
+ if not alias:
+ alias = target
+ return cls(target, alias)
+
+ def repr(self):
+ if self.target == self.alias:
+ return self.target
+ return '{s.target}:{s.alias}'.format(s=self)
+
+ @property
+ def merge_field(self):
+ return self.alias
+
+
+class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode name')):
+ @classmethod
+ def parse(cls, spec):
+ if isinstance(spec, str):
+ return cls(spec, None, None, None, None, None)
+ return cls(
+ spec.get('source'),
+ spec.get('target'),
+ spec.get('uid'),
+ spec.get('gid'),
+ spec.get('mode'),
+ spec.get('name')
+ )
+
+ @property
+ def merge_field(self):
+ return self.source
+
+ def repr(self):
+ return {
+ k: v for k, v in zip(self._fields, self) if v is not None
+ }
+
+
+class ServiceSecret(ServiceConfigBase):
+ pass
+
+
+class ServiceConfig(ServiceConfigBase):
+ pass
+
+
+class ServicePort(namedtuple('_ServicePort', 'target published protocol mode external_ip')):
+ def __new__(cls, target, published, *args, **kwargs):
+ try:
+ if target:
+ target = int(target)
+ except ValueError:
+ raise ConfigurationError('Invalid target port: {}'.format(target))
+
+ if published:
+ if isinstance(published, str) and '-' in published: # "x-y:z" format
+ a, b = published.split('-', 1)
+ if not a.isdigit() or not b.isdigit():
+ raise ConfigurationError('Invalid published port: {}'.format(published))
+ else:
+ try:
+ published = int(published)
+ except ValueError:
+ raise ConfigurationError('Invalid published port: {}'.format(published))
+
+ return super().__new__(
+ cls, target, published, *args, **kwargs
+ )
+
+ @classmethod
+ def parse(cls, spec):
+ if isinstance(spec, cls):
+ # When extending a service with ports, the port definitions have already been parsed
+ return [spec]
+
+ if not isinstance(spec, dict):
+ result = []
+ try:
+ for k, v in build_port_bindings([spec]).items():
+ if '/' in k:
+ target, proto = k.split('/', 1)
+ else:
+ target, proto = (k, None)
+ for pub in v:
+ if pub is None:
+ result.append(
+ cls(target, None, proto, None, None)
+ )
+ elif isinstance(pub, tuple):
+ result.append(
+ cls(target, pub[1], proto, None, pub[0])
+ )
+ else:
+ result.append(
+ cls(target, pub, proto, None, None)
+ )
+ except ValueError as e:
+ raise ConfigurationError(str(e))
+
+ return result
+
+ return [cls(
+ spec.get('target'),
+ spec.get('published'),
+ spec.get('protocol'),
+ spec.get('mode'),
+ None
+ )]
+
+ @property
+ def merge_field(self):
+ return (self.target, self.published, self.external_ip, self.protocol)
+
+ def repr(self):
+ return {
+ k: v for k, v in zip(self._fields, self) if v is not None
+ }
+
+ def legacy_repr(self):
+ return normalize_port_dict(self.repr())
+
+
+class GenericResource(namedtuple('_GenericResource', 'kind value')):
+ @classmethod
+ def parse(cls, dct):
+ if 'discrete_resource_spec' not in dct:
+ raise ConfigurationError(
+ 'generic_resource entry must include a discrete_resource_spec key'
+ )
+ if 'kind' not in dct['discrete_resource_spec']:
+ raise ConfigurationError(
+ 'generic_resource entry must include a discrete_resource_spec.kind subkey'
+ )
+ return cls(
+ dct['discrete_resource_spec']['kind'],
+ dct['discrete_resource_spec'].get('value')
+ )
+
+ def repr(self):
+ return {
+ 'discrete_resource_spec': {
+ 'kind': self.kind,
+ 'value': self.value,
+ }
+ }
+
+ @property
+ def merge_field(self):
+ return self.kind
+
+
+def normalize_port_dict(port):
+ return '{external_ip}{has_ext_ip}{published}{is_pub}{target}/{protocol}'.format(
+ published=port.get('published', ''),
+ is_pub=(':' if port.get('published') is not None or port.get('external_ip') else ''),
+ target=port.get('target'),
+ protocol=port.get('protocol', 'tcp'),
+ external_ip=port.get('external_ip', ''),
+ has_ext_ip=(':' if port.get('external_ip') else ''),
+ )
+
+
+class SecurityOpt(namedtuple('_SecurityOpt', 'value src_file')):
+ @classmethod
+ def parse(cls, value):
+ if not isinstance(value, str):
+ return value
+ # based on https://github.com/docker/cli/blob/9de1b162f/cli/command/container/opts.go#L673-L697
+ con = value.split('=', 2)
+ if len(con) == 1 and con[0] != 'no-new-privileges':
+ if ':' not in value:
+ raise ConfigurationError('Invalid security_opt: {}'.format(value))
+ con = value.split(':', 2)
+
+ if con[0] == 'seccomp' and con[1] != 'unconfined':
+ try:
+ with open(unquote_path(con[1])) as f:
+ seccomp_data = json.load(f)
+ except (OSError, ValueError) as e:
+ raise ConfigurationError('Error reading seccomp profile: {}'.format(e))
+ return cls(
+ 'seccomp={}'.format(json.dumps(seccomp_data)), con[1]
+ )
+ return cls(value, None)
+
+ def repr(self):
+ if self.src_file is not None:
+ return 'seccomp:{}'.format(self.src_file)
+ return self.value
+
+ @property
+ def merge_field(self):
+ return self.value
diff --git a/compose/config/validation.py b/compose/config/validation.py
new file mode 100644
index 00000000000..d9aaeda4bd2
--- /dev/null
+++ b/compose/config/validation.py
@@ -0,0 +1,569 @@
+import json
+import logging
+import os
+import re
+import sys
+
+from docker.utils.ports import split_port
+from jsonschema import Draft4Validator
+from jsonschema import FormatChecker
+from jsonschema import RefResolver
+from jsonschema import ValidationError
+
+from ..const import COMPOSEFILE_V1 as V1
+from ..const import NANOCPUS_SCALE
+from .errors import ConfigurationError
+from .errors import VERSION_EXPLANATION
+from .sort_services import get_service_name_from_network_mode
+
+
+log = logging.getLogger(__name__)
+
+
+DOCKER_CONFIG_HINTS = {
+ 'cpu_share': 'cpu_shares',
+ 'add_host': 'extra_hosts',
+ 'hosts': 'extra_hosts',
+ 'extra_host': 'extra_hosts',
+ 'device': 'devices',
+ 'link': 'links',
+ 'memory_swap': 'memswap_limit',
+ 'port': 'ports',
+ 'privilege': 'privileged',
+ 'priviliged': 'privileged',
+ 'privilige': 'privileged',
+ 'volume': 'volumes',
+ 'workdir': 'working_dir',
+}
+
+
+VALID_NAME_CHARS = r'[a-zA-Z0-9\._\-]'
+VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$'
+
+VALID_IPV4_SEG = r'(\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])'
+VALID_IPV4_ADDR = r"({IPV4_SEG}\.){{3}}{IPV4_SEG}".format(IPV4_SEG=VALID_IPV4_SEG)
+VALID_REGEX_IPV4_CIDR = r"^{IPV4_ADDR}/(\d|[1-2]\d|3[0-2])$".format(IPV4_ADDR=VALID_IPV4_ADDR)
+
+VALID_IPV6_SEG = r'[0-9a-fA-F]{1,4}'
+VALID_REGEX_IPV6_CIDR = "".join(r"""
+^
+(
+ (({IPV6_SEG}:){{7}}{IPV6_SEG})|
+ (({IPV6_SEG}:){{1,7}}:)|
+ (({IPV6_SEG}:){{1,6}}(:{IPV6_SEG}){{1,1}})|
+ (({IPV6_SEG}:){{1,5}}(:{IPV6_SEG}){{1,2}})|
+ (({IPV6_SEG}:){{1,4}}(:{IPV6_SEG}){{1,3}})|
+ (({IPV6_SEG}:){{1,3}}(:{IPV6_SEG}){{1,4}})|
+ (({IPV6_SEG}:){{1,2}}(:{IPV6_SEG}){{1,5}})|
+ (({IPV6_SEG}:){{1,1}}(:{IPV6_SEG}){{1,6}})|
+ (:((:{IPV6_SEG}){{1,7}}|:))|
+ (fe80:(:{IPV6_SEG}){{0,4}}%[0-9a-zA-Z]{{1,}})|
+ (::(ffff(:0{{1,4}}){{0,1}}:){{0,1}}{IPV4_ADDR})|
+ (({IPV6_SEG}:){{1,4}}:{IPV4_ADDR})
+)
+/(\d|[1-9]\d|1[0-1]\d|12[0-8])
+$
+""".format(IPV6_SEG=VALID_IPV6_SEG, IPV4_ADDR=VALID_IPV4_ADDR).split())
+
+
+@FormatChecker.cls_checks(format="ports", raises=ValidationError)
+def format_ports(instance):
+ try:
+ split_port(instance)
+ except ValueError as e:
+ raise ValidationError(str(e))
+ return True
+
+
+@FormatChecker.cls_checks(format="expose", raises=ValidationError)
+def format_expose(instance):
+ if isinstance(instance, str):
+ if not re.match(VALID_EXPOSE_FORMAT, instance):
+ raise ValidationError(
+ "should be of the format 'PORT[/PROTOCOL]'")
+
+ return True
+
+
+@FormatChecker.cls_checks("subnet_ip_address", raises=ValidationError)
+def format_subnet_ip_address(instance):
+ if isinstance(instance, str):
+ if not re.match(VALID_REGEX_IPV4_CIDR, instance) and \
+ not re.match(VALID_REGEX_IPV6_CIDR, instance):
+ raise ValidationError("should use the CIDR format")
+
+ return True
+
+
+def match_named_volumes(service_dict, project_volumes):
+ service_volumes = service_dict.get('volumes', [])
+ for volume_spec in service_volumes:
+ if volume_spec.is_named_volume and volume_spec.external not in project_volumes:
+ raise ConfigurationError(
+ 'Named volume "{}" is used in service "{}" but no'
+ ' declaration was found in the volumes section.'.format(
+ volume_spec.repr(), service_dict.get('name')
+ )
+ )
+
+
+def python_type_to_yaml_type(type_):
+ type_name = type(type_).__name__
+ return {
+ 'dict': 'mapping',
+ 'list': 'array',
+ 'int': 'number',
+ 'float': 'number',
+ 'bool': 'boolean',
+ 'unicode': 'string',
+ 'str': 'string',
+ 'bytes': 'string',
+ }.get(type_name, type_name)
+
+
+def validate_config_section(filename, config, section):
+ """Validate the structure of a configuration section. This must be done
+ before interpolation so it's separate from schema validation.
+ """
+ if not isinstance(config, dict):
+ raise ConfigurationError(
+ "In file '{filename}', {section} must be a mapping, not "
+ "{type}.".format(
+ filename=filename,
+ section=section,
+ type=anglicize_json_type(python_type_to_yaml_type(config))))
+
+ for key, value in config.items():
+ if not isinstance(key, str):
+ raise ConfigurationError(
+ "In file '{filename}', the {section} name {name} must be a "
+ "quoted string, i.e. '{name}'.".format(
+ filename=filename,
+ section=section,
+ name=key))
+
+ if not isinstance(value, (dict, type(None))):
+ raise ConfigurationError(
+ "In file '{filename}', {section} '{name}' must be a mapping not "
+ "{type}.".format(
+ filename=filename,
+ section=section,
+ name=key,
+ type=anglicize_json_type(python_type_to_yaml_type(value))))
+
+
+def validate_top_level_object(config_file):
+ if not isinstance(config_file.config, dict):
+ raise ConfigurationError(
+ "Top level object in '{}' needs to be an object not '{}'.".format(
+ config_file.filename,
+ type(config_file.config)))
+
+
+def validate_ulimits(service_config):
+ ulimit_config = service_config.config.get('ulimits', {})
+ for limit_name, soft_hard_values in ulimit_config.items():
+ if isinstance(soft_hard_values, dict):
+ if not soft_hard_values['soft'] <= soft_hard_values['hard']:
+ raise ConfigurationError(
+ "Service '{s.name}' has invalid ulimit '{ulimit}'. "
+ "'soft' value can not be greater than 'hard' value ".format(
+ s=service_config,
+ ulimit=ulimit_config))
+
+
+def validate_extends_file_path(service_name, extends_options, filename):
+ """
+ The service to be extended must either be defined in the config key 'file',
+ or within 'filename'.
+ """
+ error_prefix = "Invalid 'extends' configuration for %s:" % service_name
+
+ if 'file' not in extends_options and filename is None:
+ raise ConfigurationError(
+ "%s you need to specify a 'file', e.g. 'file: something.yml'" % error_prefix
+ )
+
+
+def validate_network_mode(service_config, service_names):
+ network_mode = service_config.config.get('network_mode')
+ if not network_mode:
+ return
+
+ if 'networks' in service_config.config:
+ raise ConfigurationError("'network_mode' and 'networks' cannot be combined")
+
+ dependency = get_service_name_from_network_mode(network_mode)
+ if not dependency:
+ return
+
+ if dependency not in service_names:
+ raise ConfigurationError(
+ "Service '{s.name}' uses the network stack of service '{dep}' which "
+ "is undefined.".format(s=service_config, dep=dependency))
+
+
+def validate_pid_mode(service_config, service_names):
+ pid_mode = service_config.config.get('pid')
+ if not pid_mode:
+ return
+
+ dependency = get_service_name_from_network_mode(pid_mode)
+ if not dependency:
+ return
+ if dependency not in service_names:
+ raise ConfigurationError(
+ "Service '{s.name}' uses the PID namespace of service '{dep}' which "
+ "is undefined.".format(s=service_config, dep=dependency)
+ )
+
+
+def validate_ipc_mode(service_config, service_names):
+ ipc_mode = service_config.config.get('ipc')
+ if not ipc_mode:
+ return
+
+ dependency = get_service_name_from_network_mode(ipc_mode)
+ if not dependency:
+ return
+ if dependency not in service_names:
+ raise ConfigurationError(
+ "Service '{s.name}' uses the IPC namespace of service '{dep}' which "
+ "is undefined.".format(s=service_config, dep=dependency)
+ )
+
+
+def validate_links(service_config, service_names):
+ for link in service_config.config.get('links', []):
+ if link.split(':')[0] not in service_names:
+ raise ConfigurationError(
+ "Service '{s.name}' has a link to service '{link}' which is "
+ "undefined.".format(s=service_config, link=link))
+
+
+def validate_depends_on(service_config, service_names):
+ deps = service_config.config.get('depends_on', {})
+ for dependency in deps.keys():
+ if dependency not in service_names:
+ raise ConfigurationError(
+ "Service '{s.name}' depends on service '{dep}' which is "
+ "undefined.".format(s=service_config, dep=dependency)
+ )
+
+
+def validate_credential_spec(service_config):
+ credential_spec = service_config.config.get('credential_spec')
+ if not credential_spec:
+ return
+
+ if 'registry' not in credential_spec and 'file' not in credential_spec:
+ raise ConfigurationError(
+ "Service '{s.name}' is missing 'credential_spec.file' or "
+ "credential_spec.registry'".format(s=service_config)
+ )
+
+
+def get_unsupported_config_msg(path, error_key):
+ msg = "Unsupported config option for {}: '{}'".format(path_string(path), error_key)
+ if error_key in DOCKER_CONFIG_HINTS:
+ msg += " (did you mean '{}'?)".format(DOCKER_CONFIG_HINTS[error_key])
+ return msg
+
+
+def anglicize_json_type(json_type):
+ if json_type.startswith(('a', 'e', 'i', 'o', 'u')):
+ return 'an ' + json_type
+ return 'a ' + json_type
+
+
+def is_service_dict_schema(schema_id):
+ return schema_id in ('config_schema_v1.json', '#/properties/services')
+
+
+def handle_error_for_schema_with_id(error, path):
+ schema_id = error.schema['id']
+
+ if is_service_dict_schema(schema_id) and error.validator == 'additionalProperties':
+ return "Invalid service name '{}' - only {} characters are allowed".format(
+ # The service_name is one of the keys in the json object
+ [i for i in list(error.instance) if not i or any(filter(
+ lambda c: not re.match(VALID_NAME_CHARS, c), i
+ ))][0],
+ VALID_NAME_CHARS
+ )
+
+ if error.validator == 'additionalProperties':
+ if schema_id == '#/definitions/service':
+ invalid_config_key = parse_key_from_error_msg(error)
+ return get_unsupported_config_msg(path, invalid_config_key)
+
+ if schema_id.startswith('config_schema_'):
+ invalid_config_key = parse_key_from_error_msg(error)
+ return ('Invalid top-level property "{key}". Valid top-level '
+ 'sections for this Compose file are: {properties}, and '
+ 'extensions starting with "x-".\n\n{explanation}').format(
+ key=invalid_config_key,
+ properties=', '.join(error.schema['properties'].keys()),
+ explanation=VERSION_EXPLANATION
+ )
+
+ if not error.path:
+ return '{}\n\n{}'.format(error.message, VERSION_EXPLANATION)
+
+
+def handle_generic_error(error, path):
+ msg_format = None
+ error_msg = error.message
+
+ if error.validator == 'oneOf':
+ msg_format = "{path} {msg}"
+ config_key, error_msg = _parse_oneof_validator(error)
+ if config_key:
+ path.append(config_key)
+
+ elif error.validator == 'type':
+ msg_format = "{path} contains an invalid type, it should be {msg}"
+ error_msg = _parse_valid_types_from_validator(error.validator_value)
+
+ elif error.validator == 'required':
+ error_msg = ", ".join(error.validator_value)
+ msg_format = "{path} is invalid, {msg} is required."
+
+ elif error.validator == 'dependencies':
+ config_key = list(error.validator_value.keys())[0]
+ required_keys = ",".join(error.validator_value[config_key])
+
+ msg_format = "{path} is invalid: {msg}"
+ path.append(config_key)
+ error_msg = "when defining '{}' you must set '{}' as well".format(
+ config_key,
+ required_keys)
+
+ elif error.cause:
+ error_msg = str(error.cause)
+ msg_format = "{path} is invalid: {msg}"
+
+ elif error.path:
+ msg_format = "{path} value {msg}"
+
+ if msg_format:
+ return msg_format.format(path=path_string(path), msg=error_msg)
+
+ return error.message
+
+
+def parse_key_from_error_msg(error):
+ try:
+ return error.message.split("'")[1]
+ except IndexError:
+ return error.message.split('(')[1].split(' ')[0].strip("'")
+
+
+def path_string(path):
+ return ".".join(c for c in path if isinstance(c, str))
+
+
+def _parse_valid_types_from_validator(validator):
+ """A validator value can be either an array of valid types or a string of
+ a valid type. Parse the valid types and prefix with the correct article.
+ """
+ if not isinstance(validator, list):
+ return anglicize_json_type(validator)
+
+ if len(validator) == 1:
+ return anglicize_json_type(validator[0])
+
+ return "{}, or {}".format(
+ ", ".join([anglicize_json_type(validator[0])] + validator[1:-1]),
+ anglicize_json_type(validator[-1]))
+
+
+def _parse_oneof_validator(error):
+ """oneOf has multiple schemas, so we need to reason about which schema, sub
+ schema or constraint the validation is failing on.
+ Inspecting the context value of a ValidationError gives us information about
+ which sub schema failed and which kind of error it is.
+ """
+ types = []
+ for context in error.context:
+ if context.validator == 'oneOf':
+ _, error_msg = _parse_oneof_validator(context)
+ return path_string(context.path), error_msg
+
+ if context.validator == 'required':
+ return (None, context.message)
+
+ if context.validator == 'additionalProperties':
+ invalid_config_key = parse_key_from_error_msg(context)
+ return (None, "contains unsupported option: '{}'".format(invalid_config_key))
+
+ if context.validator == 'uniqueItems':
+ return (
+ path_string(context.path) if context.path else None,
+ "contains non-unique items, please remove duplicates from {}".format(
+ context.instance),
+ )
+
+ if context.path:
+ return (
+ path_string(context.path),
+ "contains {}, which is an invalid type, it should be {}".format(
+ json.dumps(context.instance),
+ _parse_valid_types_from_validator(context.validator_value)),
+ )
+
+ if context.validator == 'type':
+ types.append(context.validator_value)
+
+ valid_types = _parse_valid_types_from_validator(types)
+ return (None, "contains an invalid type, it should be {}".format(valid_types))
+
+
+def process_service_constraint_errors(error, service_name, version):
+ if version == V1:
+ if 'image' in error.instance and 'build' in error.instance:
+ return (
+ "Service {} has both an image and build path specified. "
+ "A service can either be built to image or use an existing "
+ "image, not both.".format(service_name))
+
+ if 'image' in error.instance and 'dockerfile' in error.instance:
+ return (
+ "Service {} has both an image and alternate Dockerfile. "
+ "A service can either be built to image or use an existing "
+ "image, not both.".format(service_name))
+
+ if 'image' not in error.instance and 'build' not in error.instance:
+ return (
+ "Service {} has neither an image nor a build context specified. "
+ "At least one must be provided.".format(service_name))
+
+
+def process_config_schema_errors(error):
+ path = list(error.path)
+
+ if 'id' in error.schema:
+ error_msg = handle_error_for_schema_with_id(error, path)
+ if error_msg:
+ return error_msg
+
+ return handle_generic_error(error, path)
+
+
+def keys_to_str(config_file):
+ """
+ Non-string keys may break validator with patterned fields.
+ """
+ d = {}
+ for k, v in config_file.items():
+ d[str(k)] = v
+ if isinstance(v, dict):
+ d[str(k)] = keys_to_str(v)
+ return d
+
+
+def validate_against_config_schema(config_file, version):
+ schema = load_jsonschema(version)
+ config = keys_to_str(config_file.config)
+
+ format_checker = FormatChecker(["ports", "expose", "subnet_ip_address"])
+ validator = Draft4Validator(
+ schema,
+ resolver=RefResolver(get_resolver_path(), schema),
+ format_checker=format_checker)
+ handle_errors(
+ validator.iter_errors(config),
+ process_config_schema_errors,
+ config_file.filename)
+
+
+def validate_service_constraints(config, service_name, config_file):
+ def handler(errors):
+ return process_service_constraint_errors(
+ errors, service_name, config_file.version)
+
+ schema = load_jsonschema(config_file.version)
+ validator = Draft4Validator(schema['definitions']['constraints']['service'])
+ handle_errors(validator.iter_errors(config), handler, None)
+
+
+def validate_cpu(service_config):
+ cpus = service_config.config.get('cpus')
+ if not cpus:
+ return
+ nano_cpus = cpus * NANOCPUS_SCALE
+ if isinstance(nano_cpus, float) and not nano_cpus.is_integer():
+ raise ConfigurationError(
+ "cpus must have nine or less digits after decimal point")
+
+
+def get_schema_path():
+ return os.path.dirname(os.path.abspath(__file__))
+
+
+def load_jsonschema(version):
+ name = "compose_spec"
+ if version == V1:
+ name = "config_schema_v1"
+
+ filename = os.path.join(
+ get_schema_path(),
+ "{}.json".format(name))
+
+ if not os.path.exists(filename):
+ raise ConfigurationError(
+ 'Version in "{}" is unsupported. {}'
+ .format(filename, VERSION_EXPLANATION))
+ with open(filename) as fh:
+ return json.load(fh)
+
+
+def get_resolver_path():
+ schema_path = get_schema_path()
+ if sys.platform == "win32":
+ scheme = "///"
+ # TODO: why is this necessary?
+ schema_path = schema_path.replace('\\', '/')
+ else:
+ scheme = "//"
+ return "file:{}{}/".format(scheme, schema_path)
+
+
+def handle_errors(errors, format_error_func, filename):
+ """jsonschema returns an error tree full of information to explain what has
+ gone wrong. Process each error and pull out relevant information and re-write
+ helpful error messages that are relevant.
+ """
+ errors = sorted(errors, key=str)
+ if not errors:
+ return
+
+ error_msg = '\n'.join(format_error_func(error) for error in errors)
+ raise ConfigurationError(
+ "The Compose file{file_msg} is invalid because:\n{error_msg}".format(
+ file_msg=" '{}'".format(filename) if filename else "",
+ error_msg=error_msg))
+
+
+def validate_healthcheck(service_config):
+ healthcheck = service_config.config.get('healthcheck', {})
+
+ if 'test' in healthcheck and isinstance(healthcheck['test'], list):
+ if len(healthcheck['test']) == 0:
+ raise ConfigurationError(
+ 'Service "{}" defines an invalid healthcheck: '
+ '"test" is an empty list'
+ .format(service_config.name))
+
+ # when disable is true config.py::process_healthcheck adds "test: ['NONE']" to service_config
+ elif healthcheck['test'][0] == 'NONE' and len(healthcheck) > 1:
+ raise ConfigurationError(
+ 'Service "{}" defines an invalid healthcheck: '
+ '"disable: true" cannot be combined with other options'
+ .format(service_config.name))
+
+ elif healthcheck['test'][0] not in ('NONE', 'CMD', 'CMD-SHELL'):
+ raise ConfigurationError(
+ 'Service "{}" defines an invalid healthcheck: '
+ 'when "test" is a list the first item must be either NONE, CMD or CMD-SHELL'
+ .format(service_config.name))
diff --git a/compose/const.py b/compose/const.py
new file mode 100644
index 00000000000..90cd38e82eb
--- /dev/null
+++ b/compose/const.py
@@ -0,0 +1,40 @@
+import sys
+
+from .version import ComposeVersion
+
+DEFAULT_TIMEOUT = 10
+HTTP_TIMEOUT = 60
+IS_WINDOWS_PLATFORM = (sys.platform == "win32")
+IS_LINUX_PLATFORM = (sys.platform == "linux")
+LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number'
+LABEL_ONE_OFF = 'com.docker.compose.oneoff'
+LABEL_PROJECT = 'com.docker.compose.project'
+LABEL_WORKING_DIR = 'com.docker.compose.project.working_dir'
+LABEL_CONFIG_FILES = 'com.docker.compose.project.config_files'
+LABEL_ENVIRONMENT_FILE = 'com.docker.compose.project.environment_file'
+LABEL_SERVICE = 'com.docker.compose.service'
+LABEL_NETWORK = 'com.docker.compose.network'
+LABEL_VERSION = 'com.docker.compose.version'
+LABEL_SLUG = 'com.docker.compose.slug'
+LABEL_VOLUME = 'com.docker.compose.volume'
+LABEL_CONFIG_HASH = 'com.docker.compose.config-hash'
+NANOCPUS_SCALE = 1000000000
+PARALLEL_LIMIT = 64
+
+SECRETS_PATH = '/run/secrets'
+WINDOWS_LONGPATH_PREFIX = '\\\\?\\'
+
+COMPOSEFILE_V1 = ComposeVersion('1')
+COMPOSE_SPEC = ComposeVersion('3.9')
+
+# minimum DOCKER ENGINE API version needed to support
+# features for each compose schema version
+API_VERSIONS = {
+ COMPOSEFILE_V1: '1.21',
+ COMPOSE_SPEC: '1.38',
+}
+
+API_VERSION_TO_ENGINE_VERSION = {
+ API_VERSIONS[COMPOSEFILE_V1]: '1.9.0',
+ API_VERSIONS[COMPOSE_SPEC]: '18.06.0',
+}
diff --git a/compose/container.py b/compose/container.py
new file mode 100644
index 00000000000..c1c85d14339
--- /dev/null
+++ b/compose/container.py
@@ -0,0 +1,322 @@
+from functools import reduce
+
+from docker.errors import ImageNotFound
+
+from .const import LABEL_CONTAINER_NUMBER
+from .const import LABEL_ONE_OFF
+from .const import LABEL_PROJECT
+from .const import LABEL_SERVICE
+from .const import LABEL_SLUG
+from .const import LABEL_VERSION
+from .utils import truncate_id
+from .version import ComposeVersion
+
+
+class Container:
+ """
+ Represents a Docker container, constructed from the output of
+ GET /containers/:id:/json.
+ """
+ def __init__(self, client, dictionary, has_been_inspected=False):
+ self.client = client
+ self.dictionary = dictionary
+ self.has_been_inspected = has_been_inspected
+ self.log_stream = None
+
+ @classmethod
+ def from_ps(cls, client, dictionary, **kwargs):
+ """
+ Construct a container object from the output of GET /containers/json.
+ """
+ name = get_container_name(dictionary)
+ if name is None:
+ return None
+
+ new_dictionary = {
+ 'Id': dictionary['Id'],
+ 'Image': dictionary['Image'],
+ 'Name': '/' + name,
+ }
+ return cls(client, new_dictionary, **kwargs)
+
+ @classmethod
+ def from_id(cls, client, id):
+ return cls(client, client.inspect_container(id), has_been_inspected=True)
+
+ @classmethod
+ def create(cls, client, **options):
+ response = client.create_container(**options)
+ return cls.from_id(client, response['Id'])
+
+ @property
+ def id(self):
+ return self.dictionary['Id']
+
+ @property
+ def image(self):
+ return self.dictionary['Image']
+
+ @property
+ def image_config(self):
+ return self.client.inspect_image(self.image)
+
+ @property
+ def short_id(self):
+ return self.id[:12]
+
+ @property
+ def name(self):
+ return self.dictionary['Name'][1:]
+
+ @property
+ def project(self):
+ return self.labels.get(LABEL_PROJECT)
+
+ @property
+ def service(self):
+ return self.labels.get(LABEL_SERVICE)
+
+ @property
+ def name_without_project(self):
+ if self.name.startswith('{}_{}'.format(self.project, self.service)):
+ return '{}_{}'.format(self.service, self.number if self.number is not None else self.slug)
+ else:
+ return self.name
+
+ @property
+ def number(self):
+ if self.one_off:
+ # One-off containers are no longer assigned numbers and use slugs instead.
+ return None
+
+ number = self.labels.get(LABEL_CONTAINER_NUMBER)
+ if not number:
+ raise ValueError("Container {} does not have a {} label".format(
+ self.short_id, LABEL_CONTAINER_NUMBER))
+ return int(number)
+
+ @property
+ def slug(self):
+ if not self.full_slug:
+ return None
+ return truncate_id(self.full_slug)
+
+ @property
+ def full_slug(self):
+ return self.labels.get(LABEL_SLUG)
+
+ @property
+ def one_off(self):
+ return self.labels.get(LABEL_ONE_OFF) == 'True'
+
+ @property
+ def ports(self):
+ self.inspect_if_not_inspected()
+ return self.get('NetworkSettings.Ports') or {}
+
+ @property
+ def human_readable_ports(self):
+ def format_port(private, public):
+ if not public:
+ return [private]
+ return [
+ '{HostIp}:{HostPort}->{private}'.format(private=private, **pub)
+ for pub in public
+ ]
+
+ return ', '.join(
+ ','.join(format_port(*item))
+ for item in sorted(self.ports.items())
+ )
+
+ @property
+ def labels(self):
+ return self.get('Config.Labels') or {}
+
+ @property
+ def stop_signal(self):
+ return self.get('Config.StopSignal')
+
+ @property
+ def log_config(self):
+ return self.get('HostConfig.LogConfig') or None
+
+ @property
+ def human_readable_state(self):
+ if self.is_paused:
+ return 'Paused'
+ if self.is_restarting:
+ return 'Restarting'
+ if self.is_running:
+ return 'Ghost' if self.get('State.Ghost') else self.human_readable_health_status
+ else:
+ return 'Exit %s' % self.get('State.ExitCode')
+
+ @property
+ def human_readable_command(self):
+ entrypoint = self.get('Config.Entrypoint') or []
+ cmd = self.get('Config.Cmd') or []
+ return ' '.join(entrypoint + cmd)
+
+ @property
+ def environment(self):
+ def parse_env(var):
+ if '=' in var:
+ return var.split("=", 1)
+ return var, None
+ return dict(parse_env(var) for var in self.get('Config.Env') or [])
+
+ @property
+ def exit_code(self):
+ return self.get('State.ExitCode')
+
+ @property
+ def is_running(self):
+ return self.get('State.Running')
+
+ @property
+ def is_restarting(self):
+ return self.get('State.Restarting')
+
+ @property
+ def is_paused(self):
+ return self.get('State.Paused')
+
+ @property
+ def log_driver(self):
+ return self.get('HostConfig.LogConfig.Type')
+
+ @property
+ def human_readable_health_status(self):
+ """ Generate UP status string with up time and health
+ """
+ status_string = 'Up'
+ container_status = self.get('State.Health.Status')
+ if container_status == 'starting':
+ status_string += ' (health: starting)'
+ elif container_status is not None:
+ status_string += ' (%s)' % container_status
+ return status_string
+
+ def attach_log_stream(self):
+ self.log_stream = self.attach(stdout=True, stderr=True, stream=True)
+
+ def get(self, key):
+ """Return a value from the container or None if the value is not set.
+
+ :param key: a string using dotted notation for nested dictionary
+ lookups
+ """
+ self.inspect_if_not_inspected()
+
+ def get_value(dictionary, key):
+ return (dictionary or {}).get(key)
+
+ return reduce(get_value, key.split('.'), self.dictionary)
+
+ def get_local_port(self, port, protocol='tcp'):
+ port = self.ports.get("{}/{}".format(port, protocol))
+ return "{HostIp}:{HostPort}".format(**port[0]) if port else None
+
+ def get_mount(self, mount_dest):
+ for mount in self.get('Mounts'):
+ if mount['Destination'] == mount_dest:
+ return mount
+ return None
+
+ def start(self, **options):
+ return self.client.start(self.id, **options)
+
+ def stop(self, **options):
+ return self.client.stop(self.id, **options)
+
+ def pause(self, **options):
+ return self.client.pause(self.id, **options)
+
+ def unpause(self, **options):
+ return self.client.unpause(self.id, **options)
+
+ def kill(self, **options):
+ return self.client.kill(self.id, **options)
+
+ def restart(self, **options):
+ return self.client.restart(self.id, **options)
+
+ def remove(self, **options):
+ return self.client.remove_container(self.id, **options)
+
+ def create_exec(self, command, **options):
+ return self.client.exec_create(self.id, command, **options)
+
+ def start_exec(self, exec_id, **options):
+ return self.client.exec_start(exec_id, **options)
+
+ def rename_to_tmp_name(self):
+ """Rename the container to a hopefully unique temporary container name
+ by prepending the short id.
+ """
+ if not self.name.startswith(self.short_id):
+ self.client.rename(
+ self.id, '{}_{}'.format(self.short_id, self.name)
+ )
+
+ def inspect_if_not_inspected(self):
+ if not self.has_been_inspected:
+ self.inspect()
+
+ def wait(self):
+ return self.client.wait(self.id).get('StatusCode', 127)
+
+ def logs(self, *args, **kwargs):
+ return self.client.logs(self.id, *args, **kwargs)
+
+ def inspect(self):
+ self.dictionary = self.client.inspect_container(self.id)
+ self.has_been_inspected = True
+ return self.dictionary
+
+ def image_exists(self):
+ try:
+ self.client.inspect_image(self.image)
+ except ImageNotFound:
+ return False
+
+ return True
+
+ def reset_image(self, img_id):
+ """ If this container's image has been removed, temporarily replace the old image ID
+ with `img_id`.
+ """
+ if not self.image_exists():
+ self.dictionary['Image'] = img_id
+
+ def attach(self, *args, **kwargs):
+ return self.client.attach(self.id, *args, **kwargs)
+
+ def has_legacy_proj_name(self, project_name):
+ return (
+ ComposeVersion(self.labels.get(LABEL_VERSION)) < ComposeVersion('1.21.0') and
+ self.project != project_name
+ )
+
+ def __repr__(self):
+ return ''.format(self.name, self.id[:6])
+
+ def __eq__(self, other):
+ if type(self) != type(other):
+ return False
+ return self.id == other.id
+
+ def __hash__(self):
+ return self.id.__hash__()
+
+
+def get_container_name(container):
+ if not container.get('Name') and not container.get('Names'):
+ return None
+ # inspect
+ if 'Name' in container:
+ return container['Name']
+ # ps
+ shortest_name = min(container['Names'], key=lambda n: len(n.split('/')))
+ return shortest_name.split('/')[-1]
diff --git a/compose/errors.py b/compose/errors.py
new file mode 100644
index 00000000000..502b64b897e
--- /dev/null
+++ b/compose/errors.py
@@ -0,0 +1,34 @@
+class OperationFailedError(Exception):
+ def __init__(self, reason):
+ self.msg = reason
+
+
+class StreamParseError(RuntimeError):
+ def __init__(self, reason):
+ self.msg = reason
+
+
+class HealthCheckException(Exception):
+ def __init__(self, reason):
+ self.msg = reason
+
+
+class HealthCheckFailed(HealthCheckException):
+ def __init__(self, container_id):
+ super().__init__(
+ 'Container "{}" is unhealthy.'.format(container_id)
+ )
+
+
+class NoHealthCheckConfigured(HealthCheckException):
+ def __init__(self, service_name):
+ super().__init__(
+ 'Service "{}" is missing a healthcheck configuration'.format(
+ service_name
+ )
+ )
+
+
+class CompletedUnsuccessfully(Exception):
+ def __init__(self, container_id, exit_code):
+ self.msg = 'Container "{}" exited with code {}.'.format(container_id, exit_code)
diff --git a/compose/metrics/__init__.py b/compose/metrics/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/compose/metrics/client.py b/compose/metrics/client.py
new file mode 100644
index 00000000000..204d7e55c16
--- /dev/null
+++ b/compose/metrics/client.py
@@ -0,0 +1,64 @@
+import os
+from enum import Enum
+
+import requests
+from docker import ContextAPI
+from docker.transport import UnixHTTPAdapter
+
+from compose.const import IS_WINDOWS_PLATFORM
+
+if IS_WINDOWS_PLATFORM:
+ from docker.transport import NpipeHTTPAdapter
+
+
+class Status(Enum):
+ SUCCESS = "success"
+ FAILURE = "failure"
+ CANCELED = "canceled"
+
+
+class MetricsSource:
+ CLI = "docker-compose"
+
+
+if IS_WINDOWS_PLATFORM:
+ METRICS_SOCKET_FILE = 'npipe://\\\\.\\pipe\\docker_cli'
+else:
+ METRICS_SOCKET_FILE = 'http+unix:///var/run/docker-cli.sock'
+
+
+class MetricsCommand(requests.Session):
+ """
+ Representation of a command in the metrics.
+ """
+
+ def __init__(self, command,
+ context_type=None, status=Status.SUCCESS,
+ source=MetricsSource.CLI, uri=None):
+ super().__init__()
+ self.command = ("compose " + command).strip() if command else "compose --help"
+ self.context = context_type or ContextAPI.get_current_context().context_type or 'moby'
+ self.source = source
+ self.status = status.value
+ self.uri = uri or os.environ.get("METRICS_SOCKET_FILE", METRICS_SOCKET_FILE)
+ if IS_WINDOWS_PLATFORM:
+ self.mount("http+unix://", NpipeHTTPAdapter(self.uri))
+ else:
+ self.mount("http+unix://", UnixHTTPAdapter(self.uri))
+
+ def send_metrics(self):
+ try:
+ return self.post("http+unix://localhost/usage",
+ json=self.to_map(),
+ timeout=.05,
+ headers={'Content-Type': 'application/json'})
+ except Exception as e:
+ return e
+
+ def to_map(self):
+ return {
+ 'command': self.command,
+ 'context': self.context,
+ 'source': self.source,
+ 'status': self.status,
+ }
diff --git a/compose/metrics/decorator.py b/compose/metrics/decorator.py
new file mode 100644
index 00000000000..3126e6941fa
--- /dev/null
+++ b/compose/metrics/decorator.py
@@ -0,0 +1,21 @@
+import functools
+
+from compose.metrics.client import MetricsCommand
+from compose.metrics.client import Status
+
+
+class metrics:
+ def __init__(self, command_name=None):
+ self.command_name = command_name
+
+ def __call__(self, fn):
+ @functools.wraps(fn,
+ assigned=functools.WRAPPER_ASSIGNMENTS,
+ updated=functools.WRAPPER_UPDATES)
+ def wrapper(*args, **kwargs):
+ if not self.command_name:
+ self.command_name = fn.__name__
+ result = fn(*args, **kwargs)
+ MetricsCommand(self.command_name, status=Status.SUCCESS).send_metrics()
+ return result
+ return wrapper
diff --git a/compose/network.py b/compose/network.py
new file mode 100644
index 00000000000..a67c703c01f
--- /dev/null
+++ b/compose/network.py
@@ -0,0 +1,332 @@
+import logging
+import re
+from collections import OrderedDict
+from operator import itemgetter
+
+from docker.errors import NotFound
+from docker.types import IPAMConfig
+from docker.types import IPAMPool
+from docker.utils import version_gte
+from docker.utils import version_lt
+
+from . import __version__
+from .config import ConfigurationError
+from .const import LABEL_NETWORK
+from .const import LABEL_PROJECT
+from .const import LABEL_VERSION
+
+
+log = logging.getLogger(__name__)
+
+OPTS_EXCEPTIONS = [
+ 'com.docker.network.driver.overlay.vxlanid_list',
+ 'com.docker.network.windowsshim.hnsid',
+ 'com.docker.network.windowsshim.networkname'
+]
+
+
+class Network:
+ def __init__(self, client, project, name, driver=None, driver_opts=None,
+ ipam=None, external=False, internal=False, enable_ipv6=False,
+ labels=None, custom_name=False):
+ self.client = client
+ self.project = project
+ self.name = name
+ self.driver = driver
+ self.driver_opts = driver_opts
+ self.ipam = create_ipam_config_from_dict(ipam)
+ self.external = external
+ self.internal = internal
+ self.enable_ipv6 = enable_ipv6
+ self.labels = labels
+ self.custom_name = custom_name
+ self.legacy = None
+
+ def ensure(self):
+ if self.external:
+ if self.driver == 'overlay':
+ # Swarm nodes do not register overlay networks that were
+ # created on a different node unless they're in use.
+ # See docker/compose#4399
+ return
+ try:
+ self.inspect()
+ log.debug(
+ 'Network {} declared as external. No new '
+ 'network will be created.'.format(self.name)
+ )
+ except NotFound:
+ raise ConfigurationError(
+ 'Network {name} declared as external, but could'
+ ' not be found. Please create the network manually'
+ ' using `{command} {name}` and try again.'.format(
+ name=self.full_name,
+ command='docker network create'
+ )
+ )
+ return
+
+ self._set_legacy_flag()
+ try:
+ data = self.inspect(legacy=self.legacy)
+ check_remote_network_config(data, self)
+ except NotFound:
+ driver_name = 'the default driver'
+ if self.driver:
+ driver_name = 'driver "{}"'.format(self.driver)
+
+ log.info(
+ 'Creating network "{}" with {}'.format(self.full_name, driver_name)
+ )
+
+ self.client.create_network(
+ name=self.full_name,
+ driver=self.driver,
+ options=self.driver_opts,
+ ipam=self.ipam,
+ internal=self.internal,
+ enable_ipv6=self.enable_ipv6,
+ labels=self._labels,
+ attachable=version_gte(self.client._version, '1.24') or None,
+ check_duplicate=True,
+ )
+
+ def remove(self):
+ if self.external:
+ log.info("Network %s is external, skipping", self.true_name)
+ return
+
+ log.info("Removing network {}".format(self.true_name))
+ self.client.remove_network(self.true_name)
+
+ def inspect(self, legacy=False):
+ if legacy:
+ return self.client.inspect_network(self.legacy_full_name)
+ return self.client.inspect_network(self.full_name)
+
+ @property
+ def legacy_full_name(self):
+ if self.custom_name:
+ return self.name
+ return '{}_{}'.format(
+ re.sub(r'[_-]', '', self.project), self.name
+ )
+
+ @property
+ def full_name(self):
+ if self.custom_name:
+ return self.name
+ return '{}_{}'.format(self.project, self.name)
+
+ @property
+ def true_name(self):
+ self._set_legacy_flag()
+ if self.legacy:
+ return self.legacy_full_name
+ return self.full_name
+
+ @property
+ def _labels(self):
+ if version_lt(self.client._version, '1.23'):
+ return None
+ labels = self.labels.copy() if self.labels else {}
+ labels.update({
+ LABEL_PROJECT: self.project,
+ LABEL_NETWORK: self.name,
+ LABEL_VERSION: __version__,
+ })
+ return labels
+
+ def _set_legacy_flag(self):
+ if self.legacy is not None:
+ return
+ try:
+ data = self.inspect(legacy=True)
+ self.legacy = data is not None
+ except NotFound:
+ self.legacy = False
+
+
+def create_ipam_config_from_dict(ipam_dict):
+ if not ipam_dict:
+ return None
+
+ return IPAMConfig(
+ driver=ipam_dict.get('driver') or 'default',
+ pool_configs=[
+ IPAMPool(
+ subnet=config.get('subnet'),
+ iprange=config.get('ip_range'),
+ gateway=config.get('gateway'),
+ aux_addresses=config.get('aux_addresses'),
+ )
+ for config in ipam_dict.get('config', [])
+ ],
+ options=ipam_dict.get('options')
+ )
+
+
+class NetworkConfigChangedError(ConfigurationError):
+ def __init__(self, net_name, property_name):
+ super().__init__(
+ 'Network "{}" needs to be recreated - {} has changed'.format(
+ net_name, property_name
+ )
+ )
+
+
+def check_remote_ipam_config(remote, local):
+ remote_ipam = remote.get('IPAM')
+ ipam_dict = create_ipam_config_from_dict(local.ipam)
+ if local.ipam.get('driver') and local.ipam.get('driver') != remote_ipam.get('Driver'):
+ raise NetworkConfigChangedError(local.true_name, 'IPAM driver')
+ if len(ipam_dict['Config']) != 0:
+ if len(ipam_dict['Config']) != len(remote_ipam['Config']):
+ raise NetworkConfigChangedError(local.true_name, 'IPAM configs')
+ remote_configs = sorted(remote_ipam['Config'], key='Subnet')
+ local_configs = sorted(ipam_dict['Config'], key='Subnet')
+ while local_configs:
+ lc = local_configs.pop()
+ rc = remote_configs.pop()
+ if lc.get('Subnet') != rc.get('Subnet'):
+ raise NetworkConfigChangedError(local.true_name, 'IPAM config subnet')
+ if lc.get('Gateway') is not None and lc.get('Gateway') != rc.get('Gateway'):
+ raise NetworkConfigChangedError(local.true_name, 'IPAM config gateway')
+ if lc.get('IPRange') != rc.get('IPRange'):
+ raise NetworkConfigChangedError(local.true_name, 'IPAM config ip_range')
+ if sorted(lc.get('AuxiliaryAddresses')) != sorted(rc.get('AuxiliaryAddresses')):
+ raise NetworkConfigChangedError(local.true_name, 'IPAM config aux_addresses')
+
+ remote_opts = remote_ipam.get('Options') or {}
+ local_opts = local.ipam.get('Options') or {}
+ for k in set.union(set(remote_opts.keys()), set(local_opts.keys())):
+ if remote_opts.get(k) != local_opts.get(k):
+ raise NetworkConfigChangedError(local.true_name, 'IPAM option "{}"'.format(k))
+
+
+def check_remote_network_config(remote, local):
+ if local.driver and remote.get('Driver') != local.driver:
+ raise NetworkConfigChangedError(local.true_name, 'driver')
+ local_opts = local.driver_opts or {}
+ remote_opts = remote.get('Options') or {}
+ for k in set.union(set(remote_opts.keys()), set(local_opts.keys())):
+ if k in OPTS_EXCEPTIONS:
+ continue
+ if remote_opts.get(k) != local_opts.get(k):
+ raise NetworkConfigChangedError(local.true_name, 'option "{}"'.format(k))
+
+ if local.ipam is not None:
+ check_remote_ipam_config(remote, local)
+
+ if local.internal is not None and local.internal != remote.get('Internal', False):
+ raise NetworkConfigChangedError(local.true_name, 'internal')
+ if local.enable_ipv6 is not None and local.enable_ipv6 != remote.get('EnableIPv6', False):
+ raise NetworkConfigChangedError(local.true_name, 'enable_ipv6')
+
+ local_labels = local.labels or {}
+ remote_labels = remote.get('Labels') or {}
+ for k in set.union(set(remote_labels.keys()), set(local_labels.keys())):
+ if k.startswith('com.docker.'): # We are only interested in user-specified labels
+ continue
+ if remote_labels.get(k) != local_labels.get(k):
+ log.warning(
+ 'Network {}: label "{}" has changed. It may need to be'
+ ' recreated.'.format(local.true_name, k)
+ )
+
+
+def build_networks(name, config_data, client):
+ network_config = config_data.networks or {}
+ networks = {
+ network_name: Network(
+ client=client, project=name,
+ name=data.get('name', network_name),
+ driver=data.get('driver'),
+ driver_opts=data.get('driver_opts'),
+ ipam=data.get('ipam'),
+ external=bool(data.get('external', False)),
+ internal=data.get('internal'),
+ enable_ipv6=data.get('enable_ipv6'),
+ labels=data.get('labels'),
+ custom_name=data.get('name') is not None,
+ )
+ for network_name, data in network_config.items()
+ }
+
+ if 'default' not in networks:
+ networks['default'] = Network(client, name, 'default')
+
+ return networks
+
+
+class ProjectNetworks:
+
+ def __init__(self, networks, use_networking):
+ self.networks = networks or {}
+ self.use_networking = use_networking
+
+ @classmethod
+ def from_services(cls, services, networks, use_networking):
+ service_networks = {
+ network: networks.get(network)
+ for service in services
+ for network in get_network_names_for_service(service)
+ }
+ unused = set(networks) - set(service_networks) - {'default'}
+ if unused:
+ log.warning(
+ "Some networks were defined but are not used by any service: "
+ "{}".format(", ".join(unused)))
+ return cls(service_networks, use_networking)
+
+ def remove(self):
+ if not self.use_networking:
+ return
+ for network in self.networks.values():
+ try:
+ network.remove()
+ except NotFound:
+ log.warning("Network %s not found.", network.true_name)
+
+ def initialize(self):
+ if not self.use_networking:
+ return
+
+ for network in self.networks.values():
+ network.ensure()
+
+
+def get_network_defs_for_service(service_dict):
+ if 'network_mode' in service_dict:
+ return {}
+ networks = service_dict.get('networks', {'default': None})
+ return {
+ net: (config or {})
+ for net, config in networks.items()
+ }
+
+
+def get_network_names_for_service(service_dict):
+ return get_network_defs_for_service(service_dict).keys()
+
+
+def get_networks(service_dict, network_definitions):
+ networks = {}
+ for name, netdef in get_network_defs_for_service(service_dict).items():
+ network = network_definitions.get(name)
+ if network:
+ networks[network.true_name] = netdef
+ else:
+ raise ConfigurationError(
+ 'Service "{}" uses an undefined network "{}"'
+ .format(service_dict['name'], name))
+
+ if any([v.get('priority') for v in networks.values()]):
+ return OrderedDict(sorted(
+ networks.items(),
+ key=lambda t: t[1].get('priority') or 0, reverse=True
+ ))
+ else:
+ # Ensure Compose will pick a consistent primary network if no
+ # priority is set
+ return OrderedDict(sorted(networks.items(), key=itemgetter(0)))
diff --git a/compose/parallel.py b/compose/parallel.py
new file mode 100644
index 00000000000..316e2217ade
--- /dev/null
+++ b/compose/parallel.py
@@ -0,0 +1,366 @@
+import _thread as thread
+import logging
+import operator
+import sys
+from queue import Empty
+from queue import Queue
+from threading import Lock
+from threading import Semaphore
+from threading import Thread
+
+from docker.errors import APIError
+from docker.errors import ImageNotFound
+
+from compose.cli.colors import AnsiMode
+from compose.cli.colors import green
+from compose.cli.colors import red
+from compose.cli.signals import ShutdownException
+from compose.const import PARALLEL_LIMIT
+from compose.errors import CompletedUnsuccessfully
+from compose.errors import HealthCheckFailed
+from compose.errors import NoHealthCheckConfigured
+from compose.errors import OperationFailedError
+
+
+log = logging.getLogger(__name__)
+
+STOP = object()
+
+
+class GlobalLimit:
+ """Simple class to hold a global semaphore limiter for a project. This class
+ should be treated as a singleton that is instantiated when the project is.
+ """
+
+ global_limiter = Semaphore(PARALLEL_LIMIT)
+
+ @classmethod
+ def set_global_limit(cls, value):
+ if value is None:
+ value = PARALLEL_LIMIT
+ cls.global_limiter = Semaphore(value)
+
+
+def parallel_execute_watch(events, writer, errors, results, msg, get_name, fail_check):
+ """ Watch events from a parallel execution, update status and fill errors and results.
+ Returns exception to re-raise.
+ """
+ error_to_reraise = None
+ for obj, result, exception in events:
+ if exception is None:
+ if fail_check is not None and fail_check(obj):
+ writer.write(msg, get_name(obj), 'failed', red)
+ else:
+ writer.write(msg, get_name(obj), 'done', green)
+ results.append(result)
+ elif isinstance(exception, ImageNotFound):
+ # This is to bubble up ImageNotFound exceptions to the client so we
+ # can prompt the user if they want to rebuild.
+ errors[get_name(obj)] = exception.explanation
+ writer.write(msg, get_name(obj), 'error', red)
+ error_to_reraise = exception
+ elif isinstance(exception, APIError):
+ errors[get_name(obj)] = exception.explanation
+ writer.write(msg, get_name(obj), 'error', red)
+ elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured,
+ CompletedUnsuccessfully)):
+ errors[get_name(obj)] = exception.msg
+ writer.write(msg, get_name(obj), 'error', red)
+ elif isinstance(exception, UpstreamError):
+ writer.write(msg, get_name(obj), 'error', red)
+ else:
+ errors[get_name(obj)] = exception
+ error_to_reraise = exception
+ return error_to_reraise
+
+
+def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, fail_check=None):
+ """Runs func on objects in parallel while ensuring that func is
+ ran on object only after it is ran on all its dependencies.
+
+ get_deps called on object must return a collection with its dependencies.
+ get_name called on object must return its name.
+ fail_check is an additional failure check for cases that should display as a failure
+ in the CLI logs, but don't raise an exception (such as attempting to start 0 containers)
+ """
+ objects = list(objects)
+ stream = sys.stderr
+
+ writer = ParallelStreamWriter.get_or_assign_instance(ParallelStreamWriter(stream))
+
+ for obj in objects:
+ writer.add_object(msg, get_name(obj))
+ for obj in objects:
+ writer.write_initial(msg, get_name(obj))
+
+ events = parallel_execute_iter(objects, func, get_deps, limit)
+
+ errors = {}
+ results = []
+ error_to_reraise = parallel_execute_watch(
+ events, writer, errors, results, msg, get_name, fail_check
+ )
+
+ for obj_name, error in errors.items():
+ stream.write("\nERROR: for {} {}\n".format(obj_name, error))
+
+ if error_to_reraise:
+ raise error_to_reraise
+
+ return results, errors
+
+
+def _no_deps(x):
+ return []
+
+
+class State:
+ """
+ Holds the state of a partially-complete parallel operation.
+
+ state.started: objects being processed
+ state.finished: objects which have been processed
+ state.failed: objects which either failed or whose dependencies failed
+ """
+ def __init__(self, objects):
+ self.objects = objects
+
+ self.started = set()
+ self.finished = set()
+ self.failed = set()
+
+ def is_done(self):
+ return len(self.finished) + len(self.failed) >= len(self.objects)
+
+ def pending(self):
+ return set(self.objects) - self.started - self.finished - self.failed
+
+
+class NoLimit:
+ def __enter__(self):
+ pass
+
+ def __exit__(self, *ex):
+ pass
+
+
+def parallel_execute_iter(objects, func, get_deps, limit):
+ """
+ Runs func on objects in parallel while ensuring that func is
+ ran on object only after it is ran on all its dependencies.
+
+ Returns an iterator of tuples which look like:
+
+ # if func returned normally when run on object
+ (object, result, None)
+
+ # if func raised an exception when run on object
+ (object, None, exception)
+
+ # if func raised an exception when run on one of object's dependencies
+ (object, None, UpstreamError())
+ """
+ if get_deps is None:
+ get_deps = _no_deps
+
+ if limit is None:
+ limiter = NoLimit()
+ else:
+ limiter = Semaphore(limit)
+
+ results = Queue()
+ state = State(objects)
+
+ while True:
+ feed_queue(objects, func, get_deps, results, state, limiter)
+
+ try:
+ event = results.get(timeout=0.1)
+ except Empty:
+ continue
+ # See https://github.com/docker/compose/issues/189
+ except thread.error:
+ raise ShutdownException()
+
+ if event is STOP:
+ break
+
+ obj, _, exception = event
+ if exception is None:
+ log.debug('Finished processing: {}'.format(obj))
+ state.finished.add(obj)
+ else:
+ log.debug('Failed: {}'.format(obj))
+ state.failed.add(obj)
+
+ yield event
+
+
+def producer(obj, func, results, limiter):
+ """
+ The entry point for a producer thread which runs func on a single object.
+ Places a tuple on the results queue once func has either returned or raised.
+ """
+ with limiter, GlobalLimit.global_limiter:
+ try:
+ result = func(obj)
+ results.put((obj, result, None))
+ except Exception as e:
+ results.put((obj, None, e))
+
+
+def feed_queue(objects, func, get_deps, results, state, limiter):
+ """
+ Starts producer threads for any objects which are ready to be processed
+ (i.e. they have no dependencies which haven't been successfully processed).
+
+ Shortcuts any objects whose dependencies have failed and places an
+ (object, None, UpstreamError()) tuple on the results queue.
+ """
+ pending = state.pending()
+ log.debug('Pending: {}'.format(pending))
+
+ for obj in pending:
+ deps = get_deps(obj)
+ try:
+ if any(dep[0] in state.failed for dep in deps):
+ log.debug('{} has upstream errors - not processing'.format(obj))
+ results.put((obj, None, UpstreamError()))
+ state.failed.add(obj)
+ elif all(
+ dep not in objects or (
+ dep in state.finished and (not ready_check or ready_check(dep))
+ ) for dep, ready_check in deps
+ ):
+ log.debug('Starting producer thread for {}'.format(obj))
+ t = Thread(target=producer, args=(obj, func, results, limiter))
+ t.daemon = True
+ t.start()
+ state.started.add(obj)
+ except (HealthCheckFailed, NoHealthCheckConfigured) as e:
+ log.debug(
+ 'Healthcheck for service(s) upstream of {} failed - '
+ 'not processing'.format(obj)
+ )
+ results.put((obj, None, e))
+ except CompletedUnsuccessfully as e:
+ log.debug(
+ 'Service(s) upstream of {} did not completed successfully - '
+ 'not processing'.format(obj)
+ )
+ results.put((obj, None, e))
+
+ if state.is_done():
+ results.put(STOP)
+
+
+class UpstreamError(Exception):
+ pass
+
+
+class ParallelStreamWriter:
+ """Write out messages for operations happening in parallel.
+
+ Each operation has its own line, and ANSI code characters are used
+ to jump to the correct line, and write over the line.
+ """
+
+ default_ansi_mode = AnsiMode.AUTO
+ write_lock = Lock()
+
+ instance = None
+ instance_lock = Lock()
+
+ @classmethod
+ def get_instance(cls):
+ return cls.instance
+
+ @classmethod
+ def get_or_assign_instance(cls, writer):
+ cls.instance_lock.acquire()
+ try:
+ if cls.instance is None:
+ cls.instance = writer
+ return cls.instance
+ finally:
+ cls.instance_lock.release()
+
+ @classmethod
+ def set_default_ansi_mode(cls, ansi_mode):
+ cls.default_ansi_mode = ansi_mode
+
+ def __init__(self, stream, ansi_mode=None):
+ if ansi_mode is None:
+ ansi_mode = self.default_ansi_mode
+ self.stream = stream
+ self.use_ansi_codes = ansi_mode.use_ansi_codes(stream)
+ self.lines = []
+ self.width = 0
+
+ def add_object(self, msg, obj_index):
+ if msg is None:
+ return
+ self.lines.append(msg + obj_index)
+ self.width = max(self.width, len(msg + ' ' + obj_index))
+
+ def write_initial(self, msg, obj_index):
+ if msg is None:
+ return
+ return self._write_noansi(msg, obj_index, '')
+
+ def _write_ansi(self, msg, obj_index, status):
+ self.write_lock.acquire()
+ position = self.lines.index(msg + obj_index)
+ diff = len(self.lines) - position
+ # move up
+ self.stream.write("%c[%dA" % (27, diff))
+ # erase
+ self.stream.write("%c[2K\r" % 27)
+ self.stream.write("{:<{width}} ... {}\r".format(msg + ' ' + obj_index,
+ status, width=self.width))
+ # move back down
+ self.stream.write("%c[%dB" % (27, diff))
+ self.stream.flush()
+ self.write_lock.release()
+
+ def _write_noansi(self, msg, obj_index, status):
+ self.stream.write(
+ "{:<{width}} ... {}\r\n".format(
+ msg + ' ' + obj_index, status, width=self.width
+ )
+ )
+ self.stream.flush()
+
+ def write(self, msg, obj_index, status, color_func):
+ if msg is None:
+ return
+ if self.use_ansi_codes:
+ self._write_ansi(msg, obj_index, color_func(status))
+ else:
+ self._write_noansi(msg, obj_index, status)
+
+
+def parallel_operation(containers, operation, options, message):
+ parallel_execute(
+ containers,
+ operator.methodcaller(operation, **options),
+ operator.attrgetter('name'),
+ message,
+ )
+
+
+def parallel_remove(containers, options):
+ stopped_containers = [c for c in containers if not c.is_running]
+ parallel_operation(stopped_containers, 'remove', options, 'Removing')
+
+
+def parallel_pause(containers, options):
+ parallel_operation(containers, 'pause', options, 'Pausing')
+
+
+def parallel_unpause(containers, options):
+ parallel_operation(containers, 'unpause', options, 'Unpausing')
+
+
+def parallel_kill(containers, options):
+ parallel_operation(containers, 'kill', options, 'Killing')
diff --git a/compose/progress_stream.py b/compose/progress_stream.py
new file mode 100644
index 00000000000..3c03cc4b5b9
--- /dev/null
+++ b/compose/progress_stream.py
@@ -0,0 +1,123 @@
+from compose import utils
+
+
+class StreamOutputError(Exception):
+ pass
+
+
+def write_to_stream(s, stream):
+ try:
+ stream.write(s)
+ except UnicodeEncodeError:
+ encoding = getattr(stream, 'encoding', 'ascii')
+ stream.write(s.encode(encoding, errors='replace').decode(encoding))
+
+
+def stream_output(output, stream):
+ is_terminal = hasattr(stream, 'isatty') and stream.isatty()
+ stream = stream
+ lines = {}
+ diff = 0
+
+ for event in utils.json_stream(output):
+ yield event
+ is_progress_event = 'progress' in event or 'progressDetail' in event
+
+ if not is_progress_event:
+ print_output_event(event, stream, is_terminal)
+ stream.flush()
+ continue
+
+ if not is_terminal:
+ continue
+
+ # if it's a progress event and we have a terminal, then display the progress bars
+ image_id = event.get('id')
+ if not image_id:
+ continue
+
+ if image_id not in lines:
+ lines[image_id] = len(lines)
+ write_to_stream("\n", stream)
+
+ diff = len(lines) - lines[image_id]
+
+ # move cursor up `diff` rows
+ write_to_stream("%c[%dA" % (27, diff), stream)
+
+ print_output_event(event, stream, is_terminal)
+
+ if 'id' in event:
+ # move cursor back down
+ write_to_stream("%c[%dB" % (27, diff), stream)
+
+ stream.flush()
+
+
+def print_output_event(event, stream, is_terminal):
+ if 'errorDetail' in event:
+ raise StreamOutputError(event['errorDetail']['message'])
+
+ terminator = ''
+
+ if is_terminal and 'stream' not in event:
+ # erase current line
+ write_to_stream("%c[2K\r" % 27, stream)
+ terminator = "\r"
+ elif 'progressDetail' in event:
+ return
+
+ if 'time' in event:
+ write_to_stream("[%s] " % event['time'], stream)
+
+ if 'id' in event:
+ write_to_stream("%s: " % event['id'], stream)
+
+ if 'from' in event:
+ write_to_stream("(from %s) " % event['from'], stream)
+
+ status = event.get('status', '')
+
+ if 'progress' in event:
+ write_to_stream("{} {}{}".format(status, event['progress'], terminator), stream)
+ elif 'progressDetail' in event:
+ detail = event['progressDetail']
+ total = detail.get('total')
+ if 'current' in detail and total:
+ percentage = float(detail['current']) / float(total) * 100
+ write_to_stream('{} ({:.1f}%){}'.format(status, percentage, terminator), stream)
+ else:
+ write_to_stream('{}{}'.format(status, terminator), stream)
+ elif 'stream' in event:
+ write_to_stream("{}{}".format(event['stream'], terminator), stream)
+ else:
+ write_to_stream("{}{}\n".format(status, terminator), stream)
+
+
+def get_digest_from_pull(events):
+ digest = None
+ for event in events:
+ status = event.get('status')
+ if not status or 'Digest' not in status:
+ continue
+ else:
+ digest = status.split(':', 1)[1].strip()
+ return digest
+
+
+def get_digest_from_push(events):
+ for event in events:
+ digest = event.get('aux', {}).get('Digest')
+ if digest:
+ return digest
+ return None
+
+
+def read_status(event):
+ status = event['status'].lower()
+ if 'progressDetail' in event:
+ detail = event['progressDetail']
+ if 'current' in detail and 'total' in detail:
+ percentage = float(detail['current']) / float(detail['total'])
+ status = '{} ({:.1%})'.format(status, percentage)
+ return status
diff --git a/compose/project.py b/compose/project.py
new file mode 100644
index 00000000000..e862464d863
--- /dev/null
+++ b/compose/project.py
@@ -0,0 +1,1164 @@
+import datetime
+import enum
+import logging
+import operator
+import re
+from functools import reduce
+from os import path
+
+from docker.errors import APIError
+from docker.errors import ImageNotFound
+from docker.errors import NotFound
+from docker.utils import version_lt
+
+from . import parallel
+from .cli.errors import UserError
+from .config import ConfigurationError
+from .config.config import V1
+from .config.sort_services import get_container_name_from_network_mode
+from .config.sort_services import get_service_name_from_network_mode
+from .const import LABEL_ONE_OFF
+from .const import LABEL_PROJECT
+from .const import LABEL_SERVICE
+from .container import Container
+from .network import build_networks
+from .network import get_networks
+from .network import ProjectNetworks
+from .progress_stream import read_status
+from .service import BuildAction
+from .service import ContainerIpcMode
+from .service import ContainerNetworkMode
+from .service import ContainerPidMode
+from .service import ConvergenceStrategy
+from .service import IpcMode
+from .service import NetworkMode
+from .service import NoSuchImageError
+from .service import parse_repository_tag
+from .service import PidMode
+from .service import Service
+from .service import ServiceIpcMode
+from .service import ServiceNetworkMode
+from .service import ServicePidMode
+from .utils import filter_attached_for_up
+from .utils import microseconds_from_time_nano
+from .utils import truncate_string
+from .volume import ProjectVolumes
+
+log = logging.getLogger(__name__)
+
+
+@enum.unique
+class OneOffFilter(enum.Enum):
+ include = 0
+ exclude = 1
+ only = 2
+
+ @classmethod
+ def update_labels(cls, value, labels):
+ if value == cls.only:
+ labels.append('{}={}'.format(LABEL_ONE_OFF, "True"))
+ elif value == cls.exclude:
+ labels.append('{}={}'.format(LABEL_ONE_OFF, "False"))
+ elif value == cls.include:
+ pass
+ else:
+ raise ValueError("Invalid value for one_off: {}".format(repr(value)))
+
+
+class Project:
+ """
+ A collection of services.
+ """
+ def __init__(self, name, services, client, networks=None, volumes=None, config_version=None,
+ enabled_profiles=None):
+ self.name = name
+ self.services = services
+ self.client = client
+ self.volumes = volumes or ProjectVolumes({})
+ self.networks = networks or ProjectNetworks({}, False)
+ self.config_version = config_version
+ self.enabled_profiles = enabled_profiles or []
+
+ def labels(self, one_off=OneOffFilter.exclude, legacy=False):
+ name = self.name
+ if legacy:
+ name = re.sub(r'[_-]', '', name)
+ labels = ['{}={}'.format(LABEL_PROJECT, name)]
+
+ OneOffFilter.update_labels(one_off, labels)
+ return labels
+
+ @classmethod
+ def from_config(cls, name, config_data, client, default_platform=None, extra_labels=None,
+ enabled_profiles=None):
+ """
+ Construct a Project from a config.Config object.
+ """
+ extra_labels = extra_labels or []
+ use_networking = (config_data.version and config_data.version != V1)
+ networks = build_networks(name, config_data, client)
+ project_networks = ProjectNetworks.from_services(
+ config_data.services,
+ networks,
+ use_networking)
+ volumes = ProjectVolumes.from_config(name, config_data, client)
+ project = cls(name, [], client, project_networks, volumes, config_data.version, enabled_profiles)
+
+ for service_dict in config_data.services:
+ service_dict = dict(service_dict)
+ if use_networking:
+ service_networks = get_networks(service_dict, networks)
+ else:
+ service_networks = {}
+
+ service_dict.pop('networks', None)
+ links = project.get_links(service_dict)
+ ipc_mode = project.get_ipc_mode(service_dict)
+ network_mode = project.get_network_mode(
+ service_dict, list(service_networks.keys())
+ )
+ pid_mode = project.get_pid_mode(service_dict)
+ volumes_from = get_volumes_from(project, service_dict)
+
+ if config_data.version != V1:
+ service_dict['volumes'] = [
+ volumes.namespace_spec(volume_spec)
+ for volume_spec in service_dict.get('volumes', [])
+ ]
+
+ secrets = get_secrets(
+ service_dict['name'],
+ service_dict.pop('secrets', None) or [],
+ config_data.secrets)
+
+ service_dict['scale'] = project.get_service_scale(service_dict)
+ service_dict['device_requests'] = project.get_device_requests(service_dict)
+ service_dict = translate_credential_spec_to_security_opt(service_dict)
+ service_dict, ignored_keys = translate_deploy_keys_to_container_config(
+ service_dict
+ )
+ if ignored_keys:
+ log.warning(
+ 'The following deploy sub-keys are not supported and have'
+ ' been ignored: {}'.format(', '.join(ignored_keys))
+ )
+
+ project.services.append(
+ Service(
+ service_dict.pop('name'),
+ client=client,
+ project=name,
+ use_networking=use_networking,
+ networks=service_networks,
+ links=links,
+ network_mode=network_mode,
+ volumes_from=volumes_from,
+ secrets=secrets,
+ pid_mode=pid_mode,
+ ipc_mode=ipc_mode,
+ platform=service_dict.pop('platform', None),
+ default_platform=default_platform,
+ extra_labels=extra_labels,
+ **service_dict)
+ )
+
+ return project
+
+ @property
+ def service_names(self):
+ return [service.name for service in self.services]
+
+ def get_service(self, name):
+ """
+ Retrieve a service by name. Raises NoSuchService
+ if the named service does not exist.
+ """
+ for service in self.services:
+ if service.name == name:
+ return service
+
+ raise NoSuchService(name)
+
+ def validate_service_names(self, service_names):
+ """
+ Validate that the given list of service names only contains valid
+ services. Raises NoSuchService if one of the names is invalid.
+ """
+ valid_names = self.service_names
+ for name in service_names:
+ if name not in valid_names:
+ raise NoSuchService(name)
+
+ def get_services(self, service_names=None, include_deps=False, auto_enable_profiles=True):
+ """
+ Returns a list of this project's services filtered
+ by the provided list of names, or all services if service_names is None
+ or [].
+
+ If include_deps is specified, returns a list including the dependencies for
+ service_names, in order of dependency.
+
+ Preserves the original order of self.services where possible,
+ reordering as needed to resolve dependencies.
+
+ Raises NoSuchService if any of the named services do not exist.
+
+ Raises ConfigurationError if any service depended on is not enabled by active profiles
+ """
+ # create a copy so we can *locally* add auto-enabled profiles later
+ enabled_profiles = self.enabled_profiles.copy()
+
+ if service_names is None or len(service_names) == 0:
+ auto_enable_profiles = False
+ service_names = [
+ service.name
+ for service in self.services
+ if service.enabled_for_profiles(enabled_profiles)
+ ]
+
+ unsorted = [self.get_service(name) for name in service_names]
+ services = [s for s in self.services if s in unsorted]
+
+ if auto_enable_profiles:
+ # enable profiles of explicitly targeted services
+ for service in services:
+ for profile in service.get_profiles():
+ if profile not in enabled_profiles:
+ enabled_profiles.append(profile)
+
+ if include_deps:
+ services = reduce(
+ lambda acc, s: self._inject_deps(acc, s, enabled_profiles),
+ services,
+ []
+ )
+
+ uniques = []
+ [uniques.append(s) for s in services if s not in uniques]
+
+ return uniques
+
+ def get_services_without_duplicate(self, service_names=None, include_deps=False):
+ services = self.get_services(service_names, include_deps)
+ for service in services:
+ service.remove_duplicate_containers()
+ return services
+
+ def get_links(self, service_dict):
+ links = []
+ if 'links' in service_dict:
+ for link in service_dict.get('links', []):
+ if ':' in link:
+ service_name, link_name = link.split(':', 1)
+ else:
+ service_name, link_name = link, None
+ try:
+ links.append((self.get_service(service_name), link_name))
+ except NoSuchService:
+ raise ConfigurationError(
+ 'Service "%s" has a link to service "%s" which does not '
+ 'exist.' % (service_dict['name'], service_name))
+ del service_dict['links']
+ return links
+
+ def get_network_mode(self, service_dict, networks):
+ network_mode = service_dict.pop('network_mode', None)
+ if not network_mode:
+ if self.networks.use_networking:
+ return NetworkMode(networks[0]) if networks else NetworkMode('none')
+ return NetworkMode(None)
+
+ service_name = get_service_name_from_network_mode(network_mode)
+ if service_name:
+ return ServiceNetworkMode(self.get_service(service_name))
+
+ container_name = get_container_name_from_network_mode(network_mode)
+ if container_name:
+ try:
+ return ContainerNetworkMode(Container.from_id(self.client, container_name))
+ except APIError:
+ raise ConfigurationError(
+ "Service '{name}' uses the network stack of container '{dep}' which "
+ "does not exist.".format(name=service_dict['name'], dep=container_name))
+
+ return NetworkMode(network_mode)
+
+ def get_pid_mode(self, service_dict):
+ pid_mode = service_dict.pop('pid', None)
+ if not pid_mode:
+ return PidMode(None)
+
+ service_name = get_service_name_from_network_mode(pid_mode)
+ if service_name:
+ return ServicePidMode(self.get_service(service_name))
+
+ container_name = get_container_name_from_network_mode(pid_mode)
+ if container_name:
+ try:
+ return ContainerPidMode(Container.from_id(self.client, container_name))
+ except APIError:
+ raise ConfigurationError(
+ "Service '{name}' uses the PID namespace of container '{dep}' which "
+ "does not exist.".format(name=service_dict['name'], dep=container_name)
+ )
+
+ return PidMode(pid_mode)
+
+ def get_ipc_mode(self, service_dict):
+ ipc_mode = service_dict.pop('ipc', None)
+ if not ipc_mode:
+ return IpcMode(None)
+
+ service_name = get_service_name_from_network_mode(ipc_mode)
+ if service_name:
+ return ServiceIpcMode(self.get_service(service_name))
+
+ container_name = get_container_name_from_network_mode(ipc_mode)
+ if container_name:
+ try:
+ return ContainerIpcMode(Container.from_id(self.client, container_name))
+ except APIError:
+ raise ConfigurationError(
+ "Service '{name}' uses the IPC namespace of container '{dep}' which "
+ "does not exist.".format(name=service_dict['name'], dep=container_name)
+ )
+
+ return IpcMode(ipc_mode)
+
+ def get_service_scale(self, service_dict):
+ # service.scale for v2 and deploy.replicas for v3
+ scale = service_dict.get('scale', None)
+ deploy_dict = service_dict.get('deploy', None)
+ if not deploy_dict:
+ return 1 if scale is None else scale
+
+ if deploy_dict.get('mode', 'replicated') != 'replicated':
+ return 1 if scale is None else scale
+
+ replicas = deploy_dict.get('replicas', None)
+ if scale is not None and replicas is not None:
+ raise ConfigurationError(
+ "Both service.scale and service.deploy.replicas are set."
+ " Only one of them must be set."
+ )
+ if replicas is not None:
+ scale = replicas
+ if scale is None:
+ return 1
+ # deploy may contain placement constraints introduced in v3.8
+ max_replicas = deploy_dict.get('placement', {}).get(
+ 'max_replicas_per_node',
+ scale)
+
+ scale = min(scale, max_replicas)
+ if max_replicas < scale:
+ log.warning("Scale is limited to {} ('max_replicas_per_node' field).".format(
+ max_replicas))
+ return scale
+
+ def get_device_requests(self, service_dict):
+ deploy_dict = service_dict.get('deploy', None)
+ if not deploy_dict:
+ return
+
+ resources = deploy_dict.get('resources', None)
+ if not resources or not resources.get('reservations', None):
+ return
+ devices = resources['reservations'].get('devices')
+ if not devices:
+ return
+
+ for dev in devices:
+ count = dev.get("count", -1)
+ if not isinstance(count, int):
+ if count != "all":
+ raise ConfigurationError(
+ 'Invalid value "{}" for devices count'.format(dev["count"]),
+ '(expected integer or "all")')
+ dev["count"] = -1
+
+ if 'capabilities' in dev:
+ dev['capabilities'] = [dev['capabilities']]
+ return devices
+
+ def start(self, service_names=None, **options):
+ containers = []
+
+ def start_service(service):
+ service_containers = service.start(quiet=True, **options)
+ containers.extend(service_containers)
+
+ services = self.get_services(service_names)
+
+ def get_deps(service):
+ return {
+ (self.get_service(dep), config)
+ for dep, config in service.get_dependency_configs().items()
+ }
+
+ parallel.parallel_execute(
+ services,
+ start_service,
+ operator.attrgetter('name'),
+ 'Starting',
+ get_deps,
+ fail_check=lambda obj: not obj.containers(),
+ )
+
+ return containers
+
+ def stop(self, service_names=None, one_off=OneOffFilter.exclude, **options):
+ containers = self.containers(service_names, one_off=one_off)
+
+ def get_deps(container):
+ # actually returning inversed dependencies
+ return {(other, None) for other in containers
+ if container.service in
+ self.get_service(other.service).get_dependency_names()}
+
+ parallel.parallel_execute(
+ containers,
+ self.build_container_operation_with_timeout_func('stop', options),
+ operator.attrgetter('name'),
+ 'Stopping',
+ get_deps,
+ )
+
+ def pause(self, service_names=None, **options):
+ containers = self.containers(service_names)
+ parallel.parallel_pause(reversed(containers), options)
+ return containers
+
+ def unpause(self, service_names=None, **options):
+ containers = self.containers(service_names)
+ parallel.parallel_unpause(containers, options)
+ return containers
+
+ def kill(self, service_names=None, **options):
+ parallel.parallel_kill(self.containers(service_names), options)
+
+ def remove_stopped(self, service_names=None, one_off=OneOffFilter.exclude, **options):
+ parallel.parallel_remove(self.containers(
+ service_names, stopped=True, one_off=one_off
+ ), options)
+
+ def down(
+ self,
+ remove_image_type,
+ include_volumes,
+ remove_orphans=False,
+ timeout=None,
+ ignore_orphans=False):
+ self.stop(one_off=OneOffFilter.include, timeout=timeout)
+ if not ignore_orphans:
+ self.find_orphan_containers(remove_orphans)
+ self.remove_stopped(v=include_volumes, one_off=OneOffFilter.include)
+
+ self.networks.remove()
+
+ if include_volumes:
+ self.volumes.remove()
+
+ self.remove_images(remove_image_type)
+
+ def remove_images(self, remove_image_type):
+ for service in self.services:
+ service.remove_image(remove_image_type)
+
+ def restart(self, service_names=None, **options):
+ # filter service_names by enabled profiles
+ service_names = [s.name for s in self.get_services(service_names)]
+ containers = self.containers(service_names, stopped=True)
+
+ parallel.parallel_execute(
+ containers,
+ self.build_container_operation_with_timeout_func('restart', options),
+ operator.attrgetter('name'),
+ 'Restarting',
+ )
+ return containers
+
+ def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, memory=None,
+ build_args=None, gzip=False, parallel_build=False, rm=True, silent=False, cli=False,
+ progress=None):
+
+ services = []
+ for service in self.get_services(service_names):
+ if service.can_be_built():
+ services.append(service)
+ elif not silent:
+ log.info('%s uses an image, skipping' % service.name)
+
+ if cli:
+ if parallel_build:
+ log.warning("Flag '--parallel' is ignored when building with "
+ "COMPOSE_DOCKER_CLI_BUILD=1")
+ if gzip:
+ log.warning("Flag '--compress' is ignored when building with "
+ "COMPOSE_DOCKER_CLI_BUILD=1")
+
+ def build_service(service):
+ service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm, silent, cli, progress)
+
+ if parallel_build:
+ _, errors = parallel.parallel_execute(
+ services,
+ build_service,
+ operator.attrgetter('name'),
+ 'Building',
+ limit=5,
+ )
+ if len(errors):
+ combined_errors = '\n'.join([
+ e.decode('utf-8') if isinstance(e, bytes) else e for e in errors.values()
+ ])
+ raise ProjectError(combined_errors)
+
+ else:
+ for service in services:
+ build_service(service)
+
+ def create(
+ self,
+ service_names=None,
+ strategy=ConvergenceStrategy.changed,
+ do_build=BuildAction.none,
+ ):
+ services = self.get_services_without_duplicate(service_names, include_deps=True)
+
+ for svc in services:
+ svc.ensure_image_exists(do_build=do_build)
+ plans = self._get_convergence_plans(services, strategy)
+
+ for service in services:
+ service.execute_convergence_plan(
+ plans[service.name],
+ detached=True,
+ start=False)
+
+ def _legacy_event_processor(self, service_names):
+ # Only for v1 files or when Compose is forced to use an older API version
+ def build_container_event(event, container):
+ time = datetime.datetime.fromtimestamp(event['time'])
+ time = time.replace(
+ microsecond=microseconds_from_time_nano(event['timeNano'])
+ )
+ return {
+ 'time': time,
+ 'type': 'container',
+ 'action': event['status'],
+ 'id': container.id,
+ 'service': container.service,
+ 'attributes': {
+ 'name': container.name,
+ 'image': event['from'],
+ },
+ 'container': container,
+ }
+
+ service_names = set(service_names or self.service_names)
+ for event in self.client.events(
+ filters={'label': self.labels()},
+ decode=True
+ ):
+ # This is a guard against some events broadcasted by swarm that
+ # don't have a status field.
+ # See https://github.com/docker/compose/issues/3316
+ if 'status' not in event:
+ continue
+
+ try:
+ # this can fail if the container has been removed or if the event
+ # refers to an image
+ container = Container.from_id(self.client, event['id'])
+ except APIError:
+ continue
+ if container.service not in service_names:
+ continue
+ yield build_container_event(event, container)
+
+ def events(self, service_names=None):
+ if version_lt(self.client.api_version, '1.22'):
+ # New, better event API was introduced in 1.22.
+ return self._legacy_event_processor(service_names)
+
+ def build_container_event(event):
+ container_attrs = event['Actor']['Attributes']
+ time = datetime.datetime.fromtimestamp(event['time'])
+ time = time.replace(
+ microsecond=microseconds_from_time_nano(event['timeNano'])
+ )
+
+ container = None
+ try:
+ container = Container.from_id(self.client, event['id'])
+ except APIError:
+ # Container may have been removed (e.g. if this is a destroy event)
+ pass
+
+ return {
+ 'time': time,
+ 'type': 'container',
+ 'action': event['status'],
+ 'id': event['Actor']['ID'],
+ 'service': container_attrs.get(LABEL_SERVICE),
+ 'attributes': {
+ k: v for k, v in container_attrs.items()
+ if not k.startswith('com.docker.compose.')
+ },
+ 'container': container,
+ }
+
+ def yield_loop(service_names):
+ for event in self.client.events(
+ filters={'label': self.labels()},
+ decode=True
+ ):
+ # TODO: support other event types
+ if event.get('Type') != 'container':
+ continue
+
+ try:
+ if event['Actor']['Attributes'][LABEL_SERVICE] not in service_names:
+ continue
+ except KeyError:
+ continue
+ yield build_container_event(event)
+
+ return yield_loop(set(service_names) if service_names else self.service_names)
+
+ def up(self,
+ service_names=None,
+ start_deps=True,
+ strategy=ConvergenceStrategy.changed,
+ do_build=BuildAction.none,
+ timeout=None,
+ detached=False,
+ remove_orphans=False,
+ ignore_orphans=False,
+ scale_override=None,
+ rescale=True,
+ start=True,
+ always_recreate_deps=False,
+ reset_container_image=False,
+ renew_anonymous_volumes=False,
+ silent=False,
+ cli=False,
+ one_off=False,
+ attach_dependencies=False,
+ override_options=None,
+ ):
+
+ self.initialize()
+ if not ignore_orphans:
+ self.find_orphan_containers(remove_orphans)
+
+ if scale_override is None:
+ scale_override = {}
+
+ services = self.get_services_without_duplicate(
+ service_names,
+ include_deps=start_deps)
+
+ for svc in services:
+ svc.ensure_image_exists(do_build=do_build, silent=silent, cli=cli)
+ plans = self._get_convergence_plans(
+ services,
+ strategy,
+ always_recreate_deps=always_recreate_deps,
+ one_off=service_names if one_off else [],
+ )
+
+ services_to_attach = filter_attached_for_up(
+ services,
+ service_names,
+ attach_dependencies,
+ lambda service: service.name)
+
+ def do(service):
+ return service.execute_convergence_plan(
+ plans[service.name],
+ timeout=timeout,
+ detached=detached or (service not in services_to_attach),
+ scale_override=scale_override.get(service.name),
+ rescale=rescale,
+ start=start,
+ reset_container_image=reset_container_image,
+ renew_anonymous_volumes=renew_anonymous_volumes,
+ override_options=override_options,
+ )
+
+ def get_deps(service):
+ return {
+ (self.get_service(dep), config)
+ for dep, config in service.get_dependency_configs().items()
+ }
+
+ results, errors = parallel.parallel_execute(
+ services,
+ do,
+ operator.attrgetter('name'),
+ None,
+ get_deps,
+ )
+ if errors:
+ raise ProjectError(
+ 'Encountered errors while bringing up the project.'
+ )
+
+ return [
+ container
+ for svc_containers in results
+ if svc_containers is not None
+ for container in svc_containers
+ ]
+
+ def initialize(self):
+ self.networks.initialize()
+ self.volumes.initialize()
+
+ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False, one_off=None):
+ plans = {}
+
+ for service in services:
+ updated_dependencies = [
+ name
+ for name in service.get_dependency_names()
+ if name in plans and
+ plans[name].action in ('recreate', 'create')
+ ]
+ is_one_off = one_off and service.name in one_off
+
+ if updated_dependencies and strategy.allows_recreate:
+ log.debug('%s has upstream changes (%s)',
+ service.name,
+ ", ".join(updated_dependencies))
+ containers_stopped = any(
+ service.containers(stopped=True, filters={'status': ['created', 'exited']}))
+ service_has_links = any(service.get_link_names())
+ container_has_links = any(c.get('HostConfig.Links') for c in service.containers())
+ should_recreate_for_links = service_has_links ^ container_has_links
+ if always_recreate_deps or containers_stopped or should_recreate_for_links:
+ plan = service.convergence_plan(ConvergenceStrategy.always, is_one_off)
+ else:
+ plan = service.convergence_plan(strategy, is_one_off)
+ else:
+ plan = service.convergence_plan(strategy, is_one_off)
+
+ plans[service.name] = plan
+
+ return plans
+
+ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=True, silent=False,
+ include_deps=False):
+ services = self.get_services(service_names, include_deps)
+
+ if parallel_pull:
+ self.parallel_pull(services, silent=silent)
+
+ else:
+ must_build = []
+ for service in services:
+ try:
+ service.pull(ignore_pull_failures, silent=silent)
+ except (ImageNotFound, NotFound):
+ if service.can_be_built():
+ must_build.append(service.name)
+ else:
+ raise
+
+ if len(must_build):
+ log.warning('Some service image(s) must be built from source by running:\n'
+ ' docker-compose build {}'
+ .format(' '.join(must_build)))
+
+ def parallel_pull(self, services, ignore_pull_failures=False, silent=False):
+ msg = 'Pulling' if not silent else None
+ must_build = []
+
+ def pull_service(service):
+ strm = service.pull(ignore_pull_failures, True, stream=True)
+
+ if strm is None: # Attempting to pull service with no `image` key is a no-op
+ return
+
+ try:
+ writer = parallel.ParallelStreamWriter.get_instance()
+ if writer is None:
+ raise RuntimeError('ParallelStreamWriter has not yet been instantiated')
+ for event in strm:
+ if 'status' not in event:
+ continue
+ status = read_status(event)
+ writer.write(
+ msg, service.name, truncate_string(status), lambda s: s
+ )
+ except (ImageNotFound, NotFound):
+ if service.can_be_built():
+ must_build.append(service.name)
+ else:
+ raise
+
+ _, errors = parallel.parallel_execute(
+ services,
+ pull_service,
+ operator.attrgetter('name'),
+ msg,
+ limit=5,
+ )
+
+ if len(must_build):
+ log.warning('Some service image(s) must be built from source by running:\n'
+ ' docker-compose build {}'
+ .format(' '.join(must_build)))
+ if len(errors):
+ combined_errors = '\n'.join([
+ e.decode('utf-8') if isinstance(e, bytes) else e for e in errors.values()
+ ])
+ raise ProjectError(combined_errors)
+
+ def push(self, service_names=None, ignore_push_failures=False):
+ unique_images = set()
+ for service in self.get_services(service_names, include_deps=False):
+ # Considering and as the same
+ repo, tag, sep = parse_repository_tag(service.image_name)
+ service_image_name = sep.join((repo, tag)) if tag else sep.join((repo, 'latest'))
+
+ if service_image_name not in unique_images:
+ service.push(ignore_push_failures)
+ unique_images.add(service_image_name)
+
+ def _labeled_containers(self, stopped=False, one_off=OneOffFilter.exclude):
+ ctnrs = list(filter(None, [
+ Container.from_ps(self.client, container)
+ for container in self.client.containers(
+ all=stopped,
+ filters={'label': self.labels(one_off=one_off)})])
+ )
+ if ctnrs:
+ return ctnrs
+
+ return list(filter(lambda c: c.has_legacy_proj_name(self.name), filter(None, [
+ Container.from_ps(self.client, container)
+ for container in self.client.containers(
+ all=stopped,
+ filters={'label': self.labels(one_off=one_off, legacy=True)})])
+ ))
+
+ def containers(self, service_names=None, stopped=False, one_off=OneOffFilter.exclude):
+ if service_names:
+ self.validate_service_names(service_names)
+ else:
+ service_names = self.service_names
+
+ containers = self._labeled_containers(stopped, one_off)
+
+ def matches_service_names(container):
+ return container.labels.get(LABEL_SERVICE) in service_names
+
+ return [c for c in containers if matches_service_names(c)]
+
+ def find_orphan_containers(self, remove_orphans):
+ def _find():
+ containers = set(self._labeled_containers() + self._labeled_containers(stopped=True))
+ for ctnr in containers:
+ service_name = ctnr.labels.get(LABEL_SERVICE)
+ if service_name not in self.service_names:
+ yield ctnr
+ orphans = list(_find())
+ if not orphans:
+ return
+ if remove_orphans:
+ for ctnr in orphans:
+ log.info('Removing orphan container "{}"'.format(ctnr.name))
+ try:
+ ctnr.kill()
+ except APIError:
+ pass
+ ctnr.remove(force=True)
+ else:
+ log.warning(
+ 'Found orphan containers ({}) for this project. If '
+ 'you removed or renamed this service in your compose '
+ 'file, you can run this command with the '
+ '--remove-orphans flag to clean it up.'.format(
+ ', '.join(["{}".format(ctnr.name) for ctnr in orphans])
+ )
+ )
+
+ def _inject_deps(self, acc, service, enabled_profiles):
+ dep_names = service.get_dependency_names()
+
+ if len(dep_names) > 0:
+ dep_services = self.get_services(
+ service_names=list(set(dep_names)),
+ include_deps=True,
+ auto_enable_profiles=False
+ )
+
+ for dep in dep_services:
+ if not dep.enabled_for_profiles(enabled_profiles):
+ raise ConfigurationError(
+ 'Service "{dep_name}" was pulled in as a dependency of '
+ 'service "{service_name}" but is not enabled by the '
+ 'active profiles. '
+ 'You may fix this by adding a common profile to '
+ '"{dep_name}" and "{service_name}".'
+ .format(dep_name=dep.name, service_name=service.name)
+ )
+ else:
+ dep_services = []
+
+ dep_services.append(service)
+ return acc + dep_services
+
+ def build_container_operation_with_timeout_func(self, operation, options):
+ def container_operation_with_timeout(container):
+ _options = options.copy()
+ if _options.get('timeout') is None:
+ service = self.get_service(container.service)
+ _options['timeout'] = service.stop_timeout(None)
+ return getattr(container, operation)(**_options)
+ return container_operation_with_timeout
+
+
+def translate_credential_spec_to_security_opt(service_dict):
+ result = []
+
+ if 'credential_spec' in service_dict:
+ spec = convert_credential_spec_to_security_opt(service_dict['credential_spec'])
+ result.append('credentialspec={spec}'.format(spec=spec))
+
+ if result:
+ service_dict['security_opt'] = result
+
+ return service_dict
+
+
+def translate_resource_keys_to_container_config(resources_dict, service_dict):
+ if 'limits' in resources_dict:
+ service_dict['mem_limit'] = resources_dict['limits'].get('memory')
+ if 'cpus' in resources_dict['limits']:
+ service_dict['cpus'] = float(resources_dict['limits']['cpus'])
+ if 'reservations' in resources_dict:
+ service_dict['mem_reservation'] = resources_dict['reservations'].get('memory')
+ if 'cpus' in resources_dict['reservations']:
+ return ['resources.reservations.cpus']
+ return []
+
+
+def convert_restart_policy(name):
+ try:
+ return {
+ 'any': 'always',
+ 'none': 'no',
+ 'on-failure': 'on-failure'
+ }[name]
+ except KeyError:
+ raise ConfigurationError('Invalid restart policy "{}"'.format(name))
+
+
+def convert_credential_spec_to_security_opt(credential_spec):
+ if 'file' in credential_spec:
+ return 'file://{file}'.format(file=credential_spec['file'])
+ return 'registry://{registry}'.format(registry=credential_spec['registry'])
+
+
+def translate_deploy_keys_to_container_config(service_dict):
+ if 'credential_spec' in service_dict:
+ del service_dict['credential_spec']
+ if 'configs' in service_dict:
+ del service_dict['configs']
+
+ if 'deploy' not in service_dict:
+ return service_dict, []
+
+ deploy_dict = service_dict['deploy']
+ ignored_keys = [
+ k for k in ['endpoint_mode', 'labels', 'update_config', 'rollback_config']
+ if k in deploy_dict
+ ]
+
+ if 'restart_policy' in deploy_dict:
+ service_dict['restart'] = {
+ 'Name': convert_restart_policy(deploy_dict['restart_policy'].get('condition', 'any')),
+ 'MaximumRetryCount': deploy_dict['restart_policy'].get('max_attempts', 0)
+ }
+ for k in deploy_dict['restart_policy'].keys():
+ if k != 'condition' and k != 'max_attempts':
+ ignored_keys.append('restart_policy.{}'.format(k))
+
+ ignored_keys.extend(
+ translate_resource_keys_to_container_config(
+ deploy_dict.get('resources', {}), service_dict
+ )
+ )
+ del service_dict['deploy']
+ return service_dict, ignored_keys
+
+
+def get_volumes_from(project, service_dict):
+ volumes_from = service_dict.pop('volumes_from', None)
+ if not volumes_from:
+ return []
+
+ def build_volume_from(spec):
+ if spec.type == 'service':
+ try:
+ return spec._replace(source=project.get_service(spec.source))
+ except NoSuchService:
+ pass
+
+ if spec.type == 'container':
+ try:
+ container = Container.from_id(project.client, spec.source)
+ return spec._replace(source=container)
+ except APIError:
+ pass
+
+ raise ConfigurationError(
+ "Service \"{}\" mounts volumes from \"{}\", which is not the name "
+ "of a service or container.".format(
+ service_dict['name'],
+ spec.source))
+
+ return [build_volume_from(vf) for vf in volumes_from]
+
+
+def get_secrets(service, service_secrets, secret_defs):
+ secrets = []
+
+ for secret in service_secrets:
+ secret_def = secret_defs.get(secret.source)
+ if not secret_def:
+ raise ConfigurationError(
+ "Service \"{service}\" uses an undefined secret \"{secret}\" "
+ .format(service=service, secret=secret.source))
+
+ if secret_def.get('external'):
+ log.warning('Service "{service}" uses secret "{secret}" which is external. '
+ 'External secrets are not available to containers created by '
+ 'docker-compose.'.format(service=service, secret=secret.source))
+ continue
+
+ if secret.uid or secret.gid or secret.mode:
+ log.warning(
+ 'Service "{service}" uses secret "{secret}" with uid, '
+ 'gid, or mode. These fields are not supported by this '
+ 'implementation of the Compose file'.format(
+ service=service, secret=secret.source
+ )
+ )
+
+ secret_file = secret_def.get('file')
+ if not path.isfile(str(secret_file)):
+ log.warning(
+ 'Service "{service}" uses an undefined secret file "{secret_file}", '
+ 'the following file should be created "{secret_file}"'.format(
+ service=service, secret_file=secret_file
+ )
+ )
+ secrets.append({'secret': secret, 'file': secret_file})
+
+ return secrets
+
+
+def get_image_digests(project):
+ digests = {}
+ needs_push = set()
+ needs_pull = set()
+
+ for service in project.services:
+ try:
+ digests[service.name] = get_image_digest(service)
+ except NeedsPush as e:
+ needs_push.add(e.image_name)
+ except NeedsPull as e:
+ needs_pull.add(e.service_name)
+
+ if needs_push or needs_pull:
+ raise MissingDigests(needs_push, needs_pull)
+
+ return digests
+
+
+def get_image_digest(service):
+ if 'image' not in service.options:
+ raise UserError(
+ "Service '{s.name}' doesn't define an image tag. An image name is "
+ "required to generate a proper image digest. Specify an image repo "
+ "and tag with the 'image' option.".format(s=service))
+
+ _, _, separator = parse_repository_tag(service.options['image'])
+ # Compose file already uses a digest, no lookup required
+ if separator == '@':
+ return service.options['image']
+
+ digest = get_digest(service)
+
+ if digest:
+ return digest
+
+ if 'build' not in service.options:
+ raise NeedsPull(service.image_name, service.name)
+
+ raise NeedsPush(service.image_name)
+
+
+def get_digest(service):
+ digest = None
+ try:
+ image = service.image()
+ # TODO: pick a digest based on the image tag if there are multiple
+ # digests
+ if image['RepoDigests']:
+ digest = image['RepoDigests'][0]
+ except NoSuchImageError:
+ try:
+ # Fetch the image digest from the registry
+ distribution = service.get_image_registry_data()
+
+ if distribution['Descriptor']['digest']:
+ digest = '{image_name}@{digest}'.format(
+ image_name=service.image_name,
+ digest=distribution['Descriptor']['digest']
+ )
+ except NoSuchImageError:
+ raise UserError(
+ "Digest not found for service '{service}'. "
+ "Repository does not exist or may require 'docker login'"
+ .format(service=service.name))
+ return digest
+
+
+class MissingDigests(Exception):
+ def __init__(self, needs_push, needs_pull):
+ self.needs_push = needs_push
+ self.needs_pull = needs_pull
+
+
+class NeedsPush(Exception):
+ def __init__(self, image_name):
+ self.image_name = image_name
+
+
+class NeedsPull(Exception):
+ def __init__(self, image_name, service_name):
+ self.image_name = image_name
+ self.service_name = service_name
+
+
+class NoSuchService(Exception):
+ def __init__(self, name):
+ if isinstance(name, bytes):
+ name = name.decode('utf-8')
+ self.name = name
+ self.msg = "No such service: %s" % self.name
+
+ def __str__(self):
+ return self.msg
+
+
+class ProjectError(Exception):
+ def __init__(self, msg):
+ self.msg = msg
diff --git a/compose/service.py b/compose/service.py
new file mode 100644
index 00000000000..17505fb1937
--- /dev/null
+++ b/compose/service.py
@@ -0,0 +1,1979 @@
+import enum
+import itertools
+import logging
+import os
+import re
+import subprocess
+import sys
+import tempfile
+from collections import namedtuple
+from collections import OrderedDict
+from operator import attrgetter
+
+from docker.errors import APIError
+from docker.errors import ImageNotFound
+from docker.errors import NotFound
+from docker.types import LogConfig
+from docker.types import Mount
+from docker.utils import version_gte
+from docker.utils import version_lt
+from docker.utils.ports import build_port_bindings
+from docker.utils.ports import split_port
+from docker.utils.utils import convert_tmpfs_mounts
+
+from . import __version__
+from . import const
+from . import progress_stream
+from .config import DOCKER_CONFIG_KEYS
+from .config import is_url
+from .config import merge_environment
+from .config import merge_labels
+from .config.errors import DependencyError
+from .config.types import MountSpec
+from .config.types import ServicePort
+from .config.types import VolumeSpec
+from .const import DEFAULT_TIMEOUT
+from .const import IS_WINDOWS_PLATFORM
+from .const import LABEL_CONFIG_HASH
+from .const import LABEL_CONTAINER_NUMBER
+from .const import LABEL_ONE_OFF
+from .const import LABEL_PROJECT
+from .const import LABEL_SERVICE
+from .const import LABEL_SLUG
+from .const import LABEL_VERSION
+from .const import NANOCPUS_SCALE
+from .const import WINDOWS_LONGPATH_PREFIX
+from .container import Container
+from .errors import CompletedUnsuccessfully
+from .errors import HealthCheckFailed
+from .errors import NoHealthCheckConfigured
+from .errors import OperationFailedError
+from .parallel import parallel_execute
+from .progress_stream import stream_output
+from .progress_stream import StreamOutputError
+from .utils import generate_random_id
+from .utils import json_hash
+from .utils import parse_bytes
+from .utils import parse_seconds_float
+from .utils import truncate_id
+from .utils import unique_everseen
+from compose.cli.utils import binarystr_to_unicode
+
+
+log = logging.getLogger(__name__)
+
+HOST_CONFIG_KEYS = [
+ 'cap_add',
+ 'cap_drop',
+ 'cgroup_parent',
+ 'cpu_count',
+ 'cpu_percent',
+ 'cpu_period',
+ 'cpu_quota',
+ 'cpu_rt_period',
+ 'cpu_rt_runtime',
+ 'cpu_shares',
+ 'cpus',
+ 'cpuset',
+ 'device_cgroup_rules',
+ 'devices',
+ 'device_requests',
+ 'dns',
+ 'dns_search',
+ 'dns_opt',
+ 'env_file',
+ 'extra_hosts',
+ 'group_add',
+ 'init',
+ 'ipc',
+ 'isolation',
+ 'read_only',
+ 'log_driver',
+ 'log_opt',
+ 'mem_limit',
+ 'mem_reservation',
+ 'memswap_limit',
+ 'mem_swappiness',
+ 'oom_kill_disable',
+ 'oom_score_adj',
+ 'pid',
+ 'pids_limit',
+ 'privileged',
+ 'restart',
+ 'runtime',
+ 'security_opt',
+ 'shm_size',
+ 'storage_opt',
+ 'sysctls',
+ 'userns_mode',
+ 'volumes_from',
+ 'volume_driver',
+]
+
+CONDITION_STARTED = 'service_started'
+CONDITION_HEALTHY = 'service_healthy'
+CONDITION_COMPLETED_SUCCESSFULLY = 'service_completed_successfully'
+
+
+class BuildError(Exception):
+ def __init__(self, service, reason):
+ self.service = service
+ self.reason = reason
+
+
+class NeedsBuildError(Exception):
+ def __init__(self, service):
+ self.service = service
+
+
+class NoSuchImageError(Exception):
+ pass
+
+
+ServiceName = namedtuple('ServiceName', 'project service number')
+
+ConvergencePlan = namedtuple('ConvergencePlan', 'action containers')
+
+
+@enum.unique
+class ConvergenceStrategy(enum.Enum):
+ """Enumeration for all possible convergence strategies. Values refer to
+ when containers should be recreated.
+ """
+ changed = 1
+ always = 2
+ never = 3
+
+ @property
+ def allows_recreate(self):
+ return self is not type(self).never
+
+
+@enum.unique
+class ImageType(enum.Enum):
+ """Enumeration for the types of images known to compose."""
+ none = 0
+ local = 1
+ all = 2
+
+
+@enum.unique
+class BuildAction(enum.Enum):
+ """Enumeration for the possible build actions."""
+ none = 0
+ force = 1
+ skip = 2
+
+
+class Service:
+ def __init__(
+ self,
+ name,
+ client=None,
+ project='default',
+ use_networking=False,
+ links=None,
+ volumes_from=None,
+ network_mode=None,
+ networks=None,
+ secrets=None,
+ scale=1,
+ ipc_mode=None,
+ pid_mode=None,
+ default_platform=None,
+ extra_labels=None,
+ **options
+ ):
+ self.name = name
+ self.client = client
+ self.project = project
+ self.use_networking = use_networking
+ self.links = links or []
+ self.volumes_from = volumes_from or []
+ self.ipc_mode = ipc_mode or IpcMode(None)
+ self.network_mode = network_mode or NetworkMode(None)
+ self.pid_mode = pid_mode or PidMode(None)
+ self.networks = networks or {}
+ self.secrets = secrets or []
+ self.scale_num = scale
+ self.default_platform = default_platform
+ self.options = options
+ self.extra_labels = extra_labels or []
+
+ def __repr__(self):
+ return ''.format(self.name)
+
+ def containers(self, stopped=False, one_off=False, filters=None, labels=None):
+ if filters is None:
+ filters = {}
+ filters.update({'label': self.labels(one_off=one_off) + (labels or [])})
+
+ result = list(filter(None, [
+ Container.from_ps(self.client, container)
+ for container in self.client.containers(
+ all=stopped,
+ filters=filters)])
+ )
+ if result:
+ return result
+
+ filters.update({'label': self.labels(one_off=one_off, legacy=True) + (labels or [])})
+ return list(
+ filter(
+ lambda c: c.has_legacy_proj_name(self.project), filter(None, [
+ Container.from_ps(self.client, container)
+ for container in self.client.containers(
+ all=stopped,
+ filters=filters)])
+ )
+ )
+
+ def get_container(self, number=1):
+ """Return a :class:`compose.container.Container` for this service. The
+ container must be active, and match `number`.
+ """
+ for container in self.containers(labels=['{}={}'.format(LABEL_CONTAINER_NUMBER, number)]):
+ return container
+
+ raise ValueError("No container found for {}_{}".format(self.name, number))
+
+ def start(self, **options):
+ containers = self.containers(stopped=True)
+ for c in containers:
+ self.start_container_if_stopped(c, **options)
+ return containers
+
+ def show_scale_warnings(self, desired_num):
+ if self.custom_container_name and desired_num > 1:
+ log.warning('The "%s" service is using the custom container name "%s". '
+ 'Docker requires each container to have a unique name. '
+ 'Remove the custom name to scale the service.'
+ % (self.name, self.custom_container_name))
+
+ if self.specifies_host_port() and desired_num > 1:
+ log.warning('The "%s" service specifies a port on the host. If multiple containers '
+ 'for this service are created on a single host, the port will clash.'
+ % self.name)
+
+ def scale(self, desired_num, timeout=None):
+ """
+ Adjusts the number of containers to the specified number and ensures
+ they are running.
+
+ - creates containers until there are at least `desired_num`
+ - stops containers until there are at most `desired_num` running
+ - starts containers until there are at least `desired_num` running
+ - removes all stopped containers
+ """
+
+ self.show_scale_warnings(desired_num)
+
+ running_containers = self.containers(stopped=False)
+ num_running = len(running_containers)
+ for c in running_containers:
+ if not c.has_legacy_proj_name(self.project):
+ continue
+ log.info('Recreating container with legacy name %s' % c.name)
+ self.recreate_container(c, timeout, start_new_container=False)
+
+ if desired_num == num_running:
+ # do nothing as we already have the desired number
+ log.info('Desired container number already achieved')
+ return
+
+ if desired_num > num_running:
+ all_containers = self.containers(stopped=True)
+
+ if num_running != len(all_containers):
+ # we have some stopped containers, check for divergences
+ stopped_containers = [
+ c for c in all_containers if not c.is_running
+ ]
+
+ # Remove containers that have diverged
+ divergent_containers = [
+ c for c in stopped_containers if self._containers_have_diverged([c])
+ ]
+ for c in divergent_containers:
+ c.remove()
+
+ all_containers = list(set(all_containers) - set(divergent_containers))
+
+ sorted_containers = sorted(all_containers, key=attrgetter('number'))
+ self._execute_convergence_start(
+ sorted_containers, desired_num, timeout, True, True
+ )
+
+ if desired_num < num_running:
+ num_to_stop = num_running - desired_num
+
+ sorted_running_containers = sorted(
+ running_containers,
+ key=attrgetter('number'))
+
+ self._downscale(sorted_running_containers[-num_to_stop:], timeout)
+
+ def create_container(self,
+ one_off=False,
+ previous_container=None,
+ number=None,
+ quiet=False,
+ **override_options):
+ """
+ Create a container for this service. If the image doesn't exist, attempt to pull
+ it.
+ """
+ # This is only necessary for `scale` and `volumes_from`
+ # auto-creating containers to satisfy the dependency.
+ self.ensure_image_exists()
+
+ container_options = self._get_container_create_options(
+ override_options,
+ number or self._next_container_number(one_off=one_off),
+ one_off=one_off,
+ previous_container=previous_container,
+ )
+
+ if 'name' in container_options and not quiet:
+ log.info("Creating %s" % container_options['name'])
+
+ try:
+ return Container.create(self.client, **container_options)
+ except APIError as ex:
+ raise OperationFailedError("Cannot create container for service %s: %s" %
+ (self.name, binarystr_to_unicode(ex.explanation)))
+
+ def ensure_image_exists(self, do_build=BuildAction.none, silent=False, cli=False):
+ if self.can_be_built() and do_build == BuildAction.force:
+ self.build(cli=cli)
+ return
+
+ try:
+ self.image()
+ return
+ except NoSuchImageError:
+ pass
+
+ if not self.can_be_built():
+ self.pull(silent=silent)
+ return
+
+ if do_build == BuildAction.skip:
+ raise NeedsBuildError(self)
+
+ self.build(cli=cli)
+ log.warning(
+ "Image for service {} was built because it did not already exist. To "
+ "rebuild this image you must use `docker-compose build` or "
+ "`docker-compose up --build`.".format(self.name))
+
+ def get_image_registry_data(self):
+ try:
+ return self.client.inspect_distribution(self.image_name)
+ except APIError:
+ raise NoSuchImageError("Image '{}' not found".format(self.image_name))
+
+ def image(self):
+ try:
+ return self.client.inspect_image(self.image_name)
+ except ImageNotFound:
+ raise NoSuchImageError("Image '{}' not found".format(self.image_name))
+
+ @property
+ def image_name(self):
+ return self.options.get('image', '{project}_{s.name}'.format(
+ s=self, project=self.project.lstrip('_-')
+ ))
+
+ @property
+ def platform(self):
+ platform = self.options.get('platform')
+ if not platform and version_gte(self.client.api_version, '1.35'):
+ platform = self.default_platform
+ return platform
+
+ def convergence_plan(self, strategy=ConvergenceStrategy.changed, one_off=False):
+ containers = self.containers(stopped=True)
+
+ if one_off:
+ return ConvergencePlan('one_off', [])
+
+ if not containers:
+ return ConvergencePlan('create', [])
+
+ if strategy is ConvergenceStrategy.never:
+ return ConvergencePlan('start', containers)
+
+ if (
+ strategy is ConvergenceStrategy.always or
+ self._containers_have_diverged(containers)
+ ):
+ return ConvergencePlan('recreate', containers)
+
+ stopped = [c for c in containers if not c.is_running]
+
+ if stopped:
+ return ConvergencePlan('start', containers)
+
+ return ConvergencePlan('noop', containers)
+
+ def _containers_have_diverged(self, containers):
+ config_hash = None
+
+ try:
+ config_hash = self.config_hash
+ except NoSuchImageError as e:
+ log.debug(
+ 'Service %s has diverged: %s',
+ self.name, str(e),
+ )
+ return True
+
+ has_diverged = False
+
+ for c in containers:
+ if c.has_legacy_proj_name(self.project):
+ log.debug('%s has diverged: Legacy project name' % c.name)
+ has_diverged = True
+ continue
+ container_config_hash = c.labels.get(LABEL_CONFIG_HASH, None)
+ if container_config_hash != config_hash:
+ log.debug(
+ '%s has diverged: %s != %s',
+ c.name, container_config_hash, config_hash,
+ )
+ has_diverged = True
+
+ return has_diverged
+
+ def _execute_convergence_create(self, scale, detached, start, one_off=False, override_options=None):
+
+ i = self._next_container_number()
+
+ def create_and_start(service, n):
+ if one_off:
+ container = service.create_container(one_off=True, quiet=True, **override_options)
+ else:
+ container = service.create_container(number=n, quiet=True)
+ if not detached:
+ container.attach_log_stream()
+ if start and not one_off:
+ self.start_container(container)
+ return container
+
+ def get_name(service_name):
+ if one_off:
+ return "_".join([
+ service_name.project,
+ service_name.service,
+ "run",
+ ])
+ return self.get_container_name(service_name.service, service_name.number)
+
+ containers, errors = parallel_execute(
+ [
+ ServiceName(self.project, self.name, index)
+ for index in range(i, i + scale)
+ ],
+ lambda service_name: create_and_start(self, service_name.number),
+ get_name,
+ "Creating"
+ )
+ for error in errors.values():
+ raise OperationFailedError(error)
+
+ return containers
+
+ def _execute_convergence_recreate(self, containers, scale, timeout, detached, start,
+ renew_anonymous_volumes):
+ if scale is not None and len(containers) > scale:
+ self._downscale(containers[scale:], timeout)
+ containers = containers[:scale]
+
+ def recreate(container):
+ return self.recreate_container(
+ container, timeout=timeout, attach_logs=not detached,
+ start_new_container=start, renew_anonymous_volumes=renew_anonymous_volumes
+ )
+
+ containers, errors = parallel_execute(
+ containers,
+ recreate,
+ lambda c: c.name,
+ "Recreating",
+ )
+ for error in errors.values():
+ raise OperationFailedError(error)
+
+ if scale is not None and len(containers) < scale:
+ containers.extend(self._execute_convergence_create(
+ scale - len(containers), detached, start
+ ))
+ return containers
+
+ def _execute_convergence_start(self, containers, scale, timeout, detached, start):
+ if scale is not None and len(containers) > scale:
+ self._downscale(containers[scale:], timeout)
+ containers = containers[:scale]
+ if start:
+ stopped = [c for c in containers if not c.is_running]
+ _, errors = parallel_execute(
+ stopped,
+ lambda c: self.start_container_if_stopped(c, attach_logs=not detached, quiet=True),
+ lambda c: c.name,
+ "Starting",
+ )
+
+ for error in errors.values():
+ raise OperationFailedError(error)
+
+ if scale is not None and len(containers) < scale:
+ containers.extend(self._execute_convergence_create(
+ scale - len(containers), detached, start
+ ))
+ return containers
+
+ def _downscale(self, containers, timeout=None):
+ def stop_and_remove(container):
+ container.stop(timeout=self.stop_timeout(timeout))
+ container.remove()
+
+ parallel_execute(
+ containers,
+ stop_and_remove,
+ lambda c: c.name,
+ "Stopping and removing",
+ )
+
+ def execute_convergence_plan(self, plan, timeout=None, detached=False,
+ start=True, scale_override=None,
+ rescale=True, reset_container_image=False,
+ renew_anonymous_volumes=False, override_options=None):
+ (action, containers) = plan
+ scale = scale_override if scale_override is not None else self.scale_num
+ containers = sorted(containers, key=attrgetter('number'))
+
+ self.show_scale_warnings(scale)
+
+ if action in ['create', 'one_off']:
+ return self._execute_convergence_create(
+ scale,
+ detached,
+ start,
+ one_off=(action == 'one_off'),
+ override_options=override_options
+ )
+
+ # The create action needs always needs an initial scale, but otherwise,
+ # we set scale to none in no-rescale scenarios (`run` dependencies)
+ if not rescale:
+ scale = None
+
+ if action == 'recreate':
+ if reset_container_image:
+ # Updating the image ID on the container object lets us recover old volumes if
+ # the new image uses them as well
+ img_id = self.image()['Id']
+ for c in containers:
+ c.reset_image(img_id)
+ return self._execute_convergence_recreate(
+ containers, scale, timeout, detached, start,
+ renew_anonymous_volumes,
+ )
+
+ if action == 'start':
+ return self._execute_convergence_start(
+ containers, scale, timeout, detached, start
+ )
+
+ if action == 'noop':
+ if scale != len(containers):
+ return self._execute_convergence_start(
+ containers, scale, timeout, detached, start
+ )
+ for c in containers:
+ log.info("%s is up-to-date" % c.name)
+
+ return containers
+
+ raise Exception("Invalid action: {}".format(action))
+
+ def recreate_container(self, container, timeout=None, attach_logs=False, start_new_container=True,
+ renew_anonymous_volumes=False):
+ """Recreate a container.
+
+ The original container is renamed to a temporary name so that data
+ volumes can be copied to the new container, before the original
+ container is removed.
+ """
+
+ container.stop(timeout=self.stop_timeout(timeout))
+ container.rename_to_tmp_name()
+ new_container = self.create_container(
+ previous_container=container if not renew_anonymous_volumes else None,
+ number=container.number,
+ quiet=True,
+ )
+ if attach_logs:
+ new_container.attach_log_stream()
+ if start_new_container:
+ self.start_container(new_container)
+ container.remove()
+ return new_container
+
+ def stop_timeout(self, timeout):
+ if timeout is not None:
+ return timeout
+ timeout = parse_seconds_float(self.options.get('stop_grace_period'))
+ if timeout is not None:
+ return timeout
+ return DEFAULT_TIMEOUT
+
+ def start_container_if_stopped(self, container, attach_logs=False, quiet=False):
+ if not container.is_running:
+ if not quiet:
+ log.info("Starting %s" % container.name)
+ if attach_logs:
+ container.attach_log_stream()
+ return self.start_container(container)
+
+ def start_container(self, container, use_network_aliases=True):
+ self.connect_container_to_networks(container, use_network_aliases)
+ try:
+ container.start()
+ except APIError as ex:
+ expl = binarystr_to_unicode(ex.explanation)
+ if "driver failed programming external connectivity" in expl:
+ log.warn("Host is already in use by another container")
+ raise OperationFailedError("Cannot start service {}: {}".format(self.name, expl))
+ return container
+
+ @property
+ def prioritized_networks(self):
+ return OrderedDict(
+ sorted(
+ self.networks.items(),
+ key=lambda t: t[1].get('priority') or 0, reverse=True
+ )
+ )
+
+ def connect_container_to_networks(self, container, use_network_aliases=True):
+ connected_networks = container.get('NetworkSettings.Networks')
+
+ for network, netdefs in self.prioritized_networks.items():
+ if network in connected_networks:
+ if short_id_alias_exists(container, network):
+ continue
+ self.client.disconnect_container_from_network(container.id, network)
+
+ aliases = self._get_aliases(netdefs, container) if use_network_aliases else []
+
+ self.client.connect_container_to_network(
+ container.id, network,
+ aliases=aliases,
+ ipv4_address=netdefs.get('ipv4_address', None),
+ ipv6_address=netdefs.get('ipv6_address', None),
+ links=self._get_links(False),
+ link_local_ips=netdefs.get('link_local_ips', None),
+ )
+
+ def remove_duplicate_containers(self, timeout=None):
+ for c in self.duplicate_containers():
+ log.info('Removing %s' % c.name)
+ c.stop(timeout=self.stop_timeout(timeout))
+ c.remove()
+
+ def duplicate_containers(self):
+ containers = sorted(
+ self.containers(stopped=True),
+ key=lambda c: c.get('Created'),
+ )
+
+ numbers = set()
+
+ for c in containers:
+ if c.number in numbers:
+ yield c
+ else:
+ numbers.add(c.number)
+
+ @property
+ def config_hash(self):
+ return json_hash(self.config_dict())
+
+ def config_dict(self):
+ def image_id():
+ try:
+ return self.image()['Id']
+ except NoSuchImageError:
+ return None
+
+ return {
+ 'options': self.options,
+ 'image_id': image_id(),
+ 'links': self.get_link_names(),
+ 'net': self.network_mode.id,
+ 'ipc_mode': self.ipc_mode.mode,
+ 'networks': self.networks,
+ 'secrets': self.secrets,
+ 'volumes_from': [
+ (v.source.name, v.mode)
+ for v in self.volumes_from if isinstance(v.source, Service)
+ ]
+ }
+
+ def get_dependency_names(self):
+ net_name = self.network_mode.service_name
+ pid_namespace = self.pid_mode.service_name
+ ipc_namespace = self.ipc_mode.service_name
+ return (
+ self.get_linked_service_names() +
+ self.get_volumes_from_names() +
+ ([net_name] if net_name else []) +
+ ([pid_namespace] if pid_namespace else []) +
+ ([ipc_namespace] if ipc_namespace else []) +
+ list(self.options.get('depends_on', {}).keys())
+ )
+
+ def get_dependency_configs(self):
+ net_name = self.network_mode.service_name
+ pid_namespace = self.pid_mode.service_name
+ ipc_namespace = self.ipc_mode.service_name
+
+ configs = {
+ name: None for name in self.get_linked_service_names()
+ }
+ configs.update(
+ (name, None) for name in self.get_volumes_from_names()
+ )
+ configs.update({net_name: None} if net_name else {})
+ configs.update({pid_namespace: None} if pid_namespace else {})
+ configs.update({ipc_namespace: None} if ipc_namespace else {})
+ configs.update(self.options.get('depends_on', {}))
+ for svc, config in self.options.get('depends_on', {}).items():
+ if config['condition'] == CONDITION_STARTED:
+ configs[svc] = lambda s: True
+ elif config['condition'] == CONDITION_HEALTHY:
+ configs[svc] = lambda s: s.is_healthy()
+ elif config['condition'] == CONDITION_COMPLETED_SUCCESSFULLY:
+ configs[svc] = lambda s: s.is_completed_successfully()
+ else:
+ # The config schema already prevents this, but it might be
+ # bypassed if Compose is called programmatically.
+ raise ValueError(
+ 'depends_on condition "{}" is invalid.'.format(
+ config['condition']
+ )
+ )
+
+ return configs
+
+ def get_linked_service_names(self):
+ return [service.name for (service, _) in self.links]
+
+ def get_link_names(self):
+ return [(service.name, alias) for service, alias in self.links]
+
+ def get_volumes_from_names(self):
+ return [s.source.name for s in self.volumes_from if isinstance(s.source, Service)]
+
+ def _next_container_number(self, one_off=False):
+ if one_off:
+ return None
+ containers = itertools.chain(
+ self._fetch_containers(
+ all=True,
+ filters={'label': self.labels(one_off=False)}
+ ), self._fetch_containers(
+ all=True,
+ filters={'label': self.labels(one_off=False, legacy=True)}
+ )
+ )
+ numbers = [c.number for c in containers if c.number is not None]
+ return 1 if not numbers else max(numbers) + 1
+
+ def _fetch_containers(self, **fetch_options):
+ # Account for containers that might have been removed since we fetched
+ # the list.
+ def soft_inspect(container):
+ try:
+ return Container.from_id(self.client, container['Id'])
+ except NotFound:
+ return None
+
+ return filter(None, [
+ soft_inspect(container)
+ for container in self.client.containers(**fetch_options)
+ ])
+
+ def _get_aliases(self, network, container=None):
+ return list(
+ {self.name} |
+ ({container.short_id} if container else set()) |
+ set(network.get('aliases', ()))
+ )
+
+ def build_default_networking_config(self):
+ if not self.networks:
+ return {}
+
+ network = self.networks[self.network_mode.id]
+ endpoint = {
+ 'Aliases': self._get_aliases(network),
+ 'IPAMConfig': {},
+ }
+
+ if network.get('ipv4_address'):
+ endpoint['IPAMConfig']['IPv4Address'] = network.get('ipv4_address')
+ if network.get('ipv6_address'):
+ endpoint['IPAMConfig']['IPv6Address'] = network.get('ipv6_address')
+
+ return {"EndpointsConfig": {self.network_mode.id: endpoint}}
+
+ def _get_links(self, link_to_self):
+ links = {}
+
+ for service, link_name in self.links:
+ for container in service.containers():
+ links[link_name or service.name] = container.name
+ links[container.name] = container.name
+ links[container.name_without_project] = container.name
+
+ if link_to_self:
+ for container in self.containers():
+ links[self.name] = container.name
+ links[container.name] = container.name
+ links[container.name_without_project] = container.name
+
+ for external_link in self.options.get('external_links') or []:
+ if ':' not in external_link:
+ link_name = external_link
+ else:
+ external_link, link_name = external_link.split(':')
+ links[link_name] = external_link
+
+ return [
+ (alias, container_name)
+ for (container_name, alias) in links.items()
+ ]
+
+ def _get_volumes_from(self):
+ return [build_volume_from(spec) for spec in self.volumes_from]
+
+ def _get_container_create_options(
+ self,
+ override_options,
+ number,
+ one_off=False,
+ previous_container=None):
+ add_config_hash = (not one_off and not override_options)
+ slug = generate_random_id() if one_off else None
+
+ container_options = {
+ k: self.options[k]
+ for k in DOCKER_CONFIG_KEYS if k in self.options}
+ override_volumes = override_options.pop('volumes', [])
+ container_options.update(override_options)
+
+ if not container_options.get('name'):
+ container_options['name'] = self.get_container_name(self.name, number, slug)
+
+ container_options.setdefault('detach', True)
+
+ # If a qualified hostname was given, split it into an
+ # unqualified hostname and a domainname unless domainname
+ # was also given explicitly. This matches behavior
+ # until Docker Engine 1.11.0 - Docker API 1.23.
+ if (version_lt(self.client.api_version, '1.23') and
+ 'hostname' in container_options and
+ 'domainname' not in container_options and
+ '.' in container_options['hostname']):
+ parts = container_options['hostname'].partition('.')
+ container_options['hostname'] = parts[0]
+ container_options['domainname'] = parts[2]
+
+ if (version_gte(self.client.api_version, '1.25') and
+ 'stop_grace_period' in self.options):
+ container_options['stop_timeout'] = self.stop_timeout(None)
+
+ if 'ports' in container_options or 'expose' in self.options:
+ container_options['ports'] = build_container_ports(
+ formatted_ports(container_options.get('ports', [])),
+ self.options)
+
+ if 'volumes' in container_options or override_volumes:
+ container_options['volumes'] = list(set(
+ container_options.get('volumes', []) + override_volumes
+ ))
+
+ container_options['environment'] = merge_environment(
+ self._parse_proxy_config(),
+ merge_environment(
+ self.options.get('environment'),
+ override_options.get('environment')
+ )
+ )
+
+ container_options['labels'] = merge_labels(
+ self.options.get('labels'),
+ override_options.get('labels'))
+
+ container_options, override_options = self._build_container_volume_options(
+ previous_container, container_options, override_options
+ )
+
+ container_options['image'] = self.image_name
+
+ container_options['labels'] = build_container_labels(
+ container_options.get('labels', {}),
+ self.labels(one_off=one_off) + self.extra_labels,
+ number,
+ self.config_hash if add_config_hash else None,
+ slug
+ )
+
+ # Delete options which are only used in HostConfig
+ for key in HOST_CONFIG_KEYS:
+ container_options.pop(key, None)
+
+ container_options['host_config'] = self._get_container_host_config(
+ override_options,
+ one_off=one_off)
+
+ networking_config = self.build_default_networking_config()
+ if networking_config:
+ container_options['networking_config'] = networking_config
+
+ container_options['environment'] = format_environment(
+ container_options['environment'])
+ return container_options
+
+ def _build_container_volume_options(self, previous_container, container_options, override_options):
+ container_volumes = []
+ container_mounts = []
+ if 'volumes' in container_options:
+ container_volumes = [
+ v for v in container_options.get('volumes') if isinstance(v, VolumeSpec)
+ ]
+ container_mounts = [v for v in container_options.get('volumes') if isinstance(v, MountSpec)]
+
+ binds, affinity = merge_volume_bindings(
+ container_volumes, self.options.get('tmpfs') or [], previous_container,
+ container_mounts
+ )
+ container_options['environment'].update(affinity)
+
+ container_options['volumes'] = {v.internal: {} for v in container_volumes or {}}
+ if version_gte(self.client.api_version, '1.30'):
+ override_options['mounts'] = [build_mount(v) for v in container_mounts] or None
+ else:
+ # Workaround for 3.2 format
+ override_options['tmpfs'] = self.options.get('tmpfs') or []
+ for m in container_mounts:
+ if m.is_tmpfs:
+ override_options['tmpfs'].append(m.target)
+ else:
+ binds.append(m.legacy_repr())
+ container_options['volumes'][m.target] = {}
+
+ secret_volumes = self.get_secret_volumes()
+ if secret_volumes:
+ if version_lt(self.client.api_version, '1.30'):
+ binds.extend(v.legacy_repr() for v in secret_volumes)
+ container_options['volumes'].update(
+ (v.target, {}) for v in secret_volumes
+ )
+ else:
+ override_options['mounts'] = override_options.get('mounts') or []
+ override_options['mounts'].extend([build_mount(v) for v in secret_volumes])
+
+ # Remove possible duplicates (see e.g. https://github.com/docker/compose/issues/5885).
+ # unique_everseen preserves order. (see https://github.com/docker/compose/issues/6091).
+ override_options['binds'] = list(unique_everseen(binds))
+ return container_options, override_options
+
+ def _get_container_host_config(self, override_options, one_off=False):
+ options = dict(self.options, **override_options)
+
+ logging_dict = options.get('logging', None)
+ blkio_config = convert_blkio_config(options.get('blkio_config', None))
+ log_config = get_log_config(logging_dict)
+ init_path = None
+ if isinstance(options.get('init'), str):
+ init_path = options.get('init')
+ options['init'] = True
+
+ security_opt = [
+ o.value for o in options.get('security_opt')
+ ] if options.get('security_opt') else None
+
+ nano_cpus = None
+ if 'cpus' in options:
+ nano_cpus = int(options.get('cpus') * NANOCPUS_SCALE)
+
+ return self.client.create_host_config(
+ links=self._get_links(link_to_self=one_off),
+ port_bindings=build_port_bindings(
+ formatted_ports(options.get('ports', []))
+ ),
+ binds=options.get('binds'),
+ volumes_from=self._get_volumes_from(),
+ privileged=options.get('privileged', False),
+ network_mode=self.network_mode.mode,
+ devices=options.get('devices'),
+ device_requests=options.get('device_requests'),
+ dns=options.get('dns'),
+ dns_opt=options.get('dns_opt'),
+ dns_search=options.get('dns_search'),
+ restart_policy=options.get('restart'),
+ runtime=options.get('runtime'),
+ cap_add=options.get('cap_add'),
+ cap_drop=options.get('cap_drop'),
+ mem_limit=options.get('mem_limit'),
+ mem_reservation=options.get('mem_reservation'),
+ memswap_limit=options.get('memswap_limit'),
+ ulimits=build_ulimits(options.get('ulimits')),
+ log_config=log_config,
+ extra_hosts=options.get('extra_hosts'),
+ read_only=options.get('read_only'),
+ pid_mode=self.pid_mode.mode,
+ security_opt=security_opt,
+ ipc_mode=self.ipc_mode.mode,
+ cgroup_parent=options.get('cgroup_parent'),
+ cpu_quota=options.get('cpu_quota'),
+ shm_size=options.get('shm_size'),
+ sysctls=options.get('sysctls'),
+ pids_limit=options.get('pids_limit'),
+ tmpfs=options.get('tmpfs'),
+ oom_kill_disable=options.get('oom_kill_disable'),
+ oom_score_adj=options.get('oom_score_adj'),
+ mem_swappiness=options.get('mem_swappiness'),
+ group_add=options.get('group_add'),
+ userns_mode=options.get('userns_mode'),
+ init=options.get('init', None),
+ init_path=init_path,
+ isolation=options.get('isolation'),
+ cpu_count=options.get('cpu_count'),
+ cpu_percent=options.get('cpu_percent'),
+ nano_cpus=nano_cpus,
+ volume_driver=options.get('volume_driver'),
+ cpuset_cpus=options.get('cpuset'),
+ cpu_shares=options.get('cpu_shares'),
+ storage_opt=options.get('storage_opt'),
+ blkio_weight=blkio_config.get('weight'),
+ blkio_weight_device=blkio_config.get('weight_device'),
+ device_read_bps=blkio_config.get('device_read_bps'),
+ device_read_iops=blkio_config.get('device_read_iops'),
+ device_write_bps=blkio_config.get('device_write_bps'),
+ device_write_iops=blkio_config.get('device_write_iops'),
+ mounts=options.get('mounts'),
+ device_cgroup_rules=options.get('device_cgroup_rules'),
+ cpu_period=options.get('cpu_period'),
+ cpu_rt_period=options.get('cpu_rt_period'),
+ cpu_rt_runtime=options.get('cpu_rt_runtime'),
+ )
+
+ def get_secret_volumes(self):
+ def build_spec(secret):
+ target = secret['secret'].target
+ if target is None:
+ target = '{}/{}'.format(const.SECRETS_PATH, secret['secret'].source)
+ elif not os.path.isabs(target):
+ target = '{}/{}'.format(const.SECRETS_PATH, target)
+
+ return MountSpec('bind', secret['file'], target, read_only=True)
+
+ return [build_spec(secret) for secret in self.secrets]
+
+ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None,
+ gzip=False, rm=True, silent=False, cli=False, progress=None):
+ output_stream = open(os.devnull, 'w')
+ if not silent:
+ output_stream = sys.stdout
+ log.info('Building %s' % self.name)
+
+ build_opts = self.options.get('build', {})
+
+ build_args = build_opts.get('args', {}).copy()
+ if build_args_override:
+ build_args.update(build_args_override)
+
+ for k, v in self._parse_proxy_config().items():
+ build_args.setdefault(k, v)
+
+ path = rewrite_build_path(build_opts.get('context'))
+ if self.platform and version_lt(self.client.api_version, '1.35'):
+ raise OperationFailedError(
+ 'Impossible to perform platform-targeted builds for API version < 1.35'
+ )
+
+ builder = _ClientBuilder(self.client) if not cli else _CLIBuilder(progress)
+ return builder.build(
+ service=self,
+ path=path,
+ tag=self.image_name,
+ rm=rm,
+ forcerm=force_rm,
+ pull=pull,
+ nocache=no_cache,
+ dockerfile=build_opts.get('dockerfile', None),
+ cache_from=self.get_cache_from(build_opts),
+ labels=build_opts.get('labels', None),
+ buildargs=build_args,
+ network_mode=build_opts.get('network', None),
+ target=build_opts.get('target', None),
+ shmsize=parse_bytes(build_opts.get('shm_size')) if build_opts.get('shm_size') else None,
+ extra_hosts=build_opts.get('extra_hosts', None),
+ container_limits={
+ 'memory': parse_bytes(memory) if memory else None
+ },
+ gzip=gzip,
+ isolation=build_opts.get('isolation', self.options.get('isolation', None)),
+ platform=self.platform,
+ output_stream=output_stream)
+
+ def get_cache_from(self, build_opts):
+ cache_from = build_opts.get('cache_from', None)
+ if cache_from is not None:
+ cache_from = [tag for tag in cache_from if tag]
+ return cache_from
+
+ def can_be_built(self):
+ return 'build' in self.options
+
+ def labels(self, one_off=False, legacy=False):
+ proj_name = self.project if not legacy else re.sub(r'[_-]', '', self.project)
+ return [
+ '{}={}'.format(LABEL_PROJECT, proj_name),
+ '{}={}'.format(LABEL_SERVICE, self.name),
+ '{}={}'.format(LABEL_ONE_OFF, "True" if one_off else "False"),
+ ]
+
+ @property
+ def custom_container_name(self):
+ return self.options.get('container_name')
+
+ def get_container_name(self, service_name, number, slug=None):
+ if self.custom_container_name and slug is None:
+ return self.custom_container_name
+
+ container_name = build_container_name(
+ self.project, service_name, number, slug,
+ )
+ ext_links_origins = [link.split(':')[0] for link in self.options.get('external_links', [])]
+ if container_name in ext_links_origins:
+ raise DependencyError(
+ 'Service {} has a self-referential external link: {}'.format(
+ self.name, container_name
+ )
+ )
+ return container_name
+
+ def remove_image(self, image_type):
+ if not image_type or image_type == ImageType.none:
+ return False
+ if image_type == ImageType.local and self.options.get('image'):
+ return False
+
+ log.info("Removing image %s", self.image_name)
+ try:
+ self.client.remove_image(self.image_name)
+ return True
+ except ImageNotFound:
+ log.warning("Image %s not found.", self.image_name)
+ return False
+ except APIError as e:
+ log.error("Failed to remove image for service %s: %s", self.name, e)
+ return False
+
+ def specifies_host_port(self):
+ def has_host_port(binding):
+ if isinstance(binding, dict):
+ external_bindings = binding.get('published')
+ else:
+ _, external_bindings = split_port(binding)
+
+ # there are no external bindings
+ if external_bindings is None:
+ return False
+
+ # we only need to check the first binding from the range
+ external_binding = external_bindings[0]
+
+ # non-tuple binding means there is a host port specified
+ if not isinstance(external_binding, tuple):
+ return True
+
+ # extract actual host port from tuple of (host_ip, host_port)
+ _, host_port = external_binding
+ if host_port is not None:
+ return True
+
+ return False
+
+ return any(has_host_port(binding) for binding in self.options.get('ports', []))
+
+ def _do_pull(self, repo, pull_kwargs, silent, ignore_pull_failures):
+ try:
+ output = self.client.pull(repo, **pull_kwargs)
+ if silent:
+ with open(os.devnull, 'w') as devnull:
+ yield from stream_output(output, devnull)
+ else:
+ yield from stream_output(output, sys.stdout)
+ except (StreamOutputError, NotFound) as e:
+ if not ignore_pull_failures:
+ raise
+ else:
+ log.error(str(e))
+
+ def pull(self, ignore_pull_failures=False, silent=False, stream=False):
+ if 'image' not in self.options:
+ return
+
+ repo, tag, separator = parse_repository_tag(self.options['image'])
+ kwargs = {
+ 'tag': tag or 'latest',
+ 'stream': True,
+ 'platform': self.platform,
+ }
+ if not silent:
+ log.info('Pulling {} ({}{}{})...'.format(self.name, repo, separator, tag))
+
+ if kwargs['platform'] and version_lt(self.client.api_version, '1.35'):
+ raise OperationFailedError(
+ 'Impossible to perform platform-targeted pulls for API version < 1.35'
+ )
+
+ event_stream = self._do_pull(repo, kwargs, silent, ignore_pull_failures)
+ if stream:
+ return event_stream
+ return progress_stream.get_digest_from_pull(event_stream)
+
+ def push(self, ignore_push_failures=False):
+ if 'image' not in self.options or 'build' not in self.options:
+ return
+
+ repo, tag, separator = parse_repository_tag(self.options['image'])
+ tag = tag or 'latest'
+ log.info('Pushing {} ({}{}{})...'.format(self.name, repo, separator, tag))
+ output = self.client.push(repo, tag=tag, stream=True)
+
+ try:
+ return progress_stream.get_digest_from_push(
+ stream_output(output, sys.stdout))
+ except StreamOutputError as e:
+ if not ignore_push_failures:
+ raise
+ else:
+ log.error(str(e))
+
+ def is_healthy(self):
+ """ Check that all containers for this service report healthy.
+ Returns false if at least one healthcheck is pending.
+ If an unhealthy container is detected, raise a HealthCheckFailed
+ exception.
+ """
+ result = True
+ for ctnr in self.containers():
+ ctnr.inspect()
+ status = ctnr.get('State.Health.Status')
+ if status is None:
+ raise NoHealthCheckConfigured(self.name)
+ elif status == 'starting':
+ result = False
+ elif status == 'unhealthy':
+ raise HealthCheckFailed(ctnr.short_id)
+ return result
+
+ def is_completed_successfully(self):
+ """ Check that all containers for this service has completed successfully
+ Returns false if at least one container does not exited and
+ raises CompletedUnsuccessfully exception if at least one container
+ exited with non-zero exit code.
+ """
+ result = True
+ for ctnr in self.containers(stopped=True):
+ ctnr.inspect()
+ if ctnr.get('State.Status') != 'exited':
+ result = False
+ elif ctnr.exit_code != 0:
+ raise CompletedUnsuccessfully(ctnr.short_id, ctnr.exit_code)
+ return result
+
+ def _parse_proxy_config(self):
+ client = self.client
+ if 'proxies' not in client._general_configs:
+ return {}
+ docker_host = getattr(client, '_original_base_url', client.base_url)
+ proxy_config = client._general_configs['proxies'].get(
+ docker_host, client._general_configs['proxies'].get('default')
+ ) or {}
+
+ permitted = {
+ 'ftpProxy': 'FTP_PROXY',
+ 'httpProxy': 'HTTP_PROXY',
+ 'httpsProxy': 'HTTPS_PROXY',
+ 'noProxy': 'NO_PROXY',
+ }
+
+ result = {}
+
+ for k, v in proxy_config.items():
+ if k not in permitted:
+ continue
+ result[permitted[k]] = result[permitted[k].lower()] = v
+
+ return result
+
+ def get_profiles(self):
+ if 'profiles' not in self.options:
+ return []
+
+ return self.options.get('profiles')
+
+ def enabled_for_profiles(self, enabled_profiles):
+ # if service has no profiles specified it is always enabled
+ if 'profiles' not in self.options:
+ return True
+
+ service_profiles = self.options.get('profiles')
+ for profile in enabled_profiles:
+ if profile in service_profiles:
+ return True
+
+ return False
+
+
+def short_id_alias_exists(container, network):
+ aliases = container.get(
+ 'NetworkSettings.Networks.{net}.Aliases'.format(net=network)) or ()
+ return container.short_id in aliases
+
+
+class IpcMode:
+ def __init__(self, mode):
+ self._mode = mode
+
+ @property
+ def mode(self):
+ return self._mode
+
+ @property
+ def service_name(self):
+ return None
+
+
+class ServiceIpcMode(IpcMode):
+ def __init__(self, service):
+ self.service = service
+
+ @property
+ def service_name(self):
+ return self.service.name
+
+ @property
+ def mode(self):
+ containers = self.service.containers()
+ if containers:
+ return 'container:' + containers[0].id
+
+ log.warning(
+ "Service %s is trying to use reuse the IPC namespace "
+ "of another service that is not running." % (self.service_name)
+ )
+ return None
+
+
+class ContainerIpcMode(IpcMode):
+ def __init__(self, container):
+ self.container = container
+ self._mode = 'container:{}'.format(container.id)
+
+
+class PidMode:
+ def __init__(self, mode):
+ self._mode = mode
+
+ @property
+ def mode(self):
+ return self._mode
+
+ @property
+ def service_name(self):
+ return None
+
+
+class ServicePidMode(PidMode):
+ def __init__(self, service):
+ self.service = service
+
+ @property
+ def service_name(self):
+ return self.service.name
+
+ @property
+ def mode(self):
+ containers = self.service.containers()
+ if containers:
+ return 'container:' + containers[0].id
+
+ log.warning(
+ "Service %s is trying to use reuse the PID namespace "
+ "of another service that is not running." % (self.service_name)
+ )
+ return None
+
+
+class ContainerPidMode(PidMode):
+ def __init__(self, container):
+ self.container = container
+ self._mode = 'container:{}'.format(container.id)
+
+
+class NetworkMode:
+ """A `standard` network mode (ex: host, bridge)"""
+
+ service_name = None
+
+ def __init__(self, network_mode):
+ self.network_mode = network_mode
+
+ @property
+ def id(self):
+ return self.network_mode
+
+ mode = id
+
+
+class ContainerNetworkMode:
+ """A network mode that uses a container's network stack."""
+
+ service_name = None
+
+ def __init__(self, container):
+ self.container = container
+
+ @property
+ def id(self):
+ return self.container.id
+
+ @property
+ def mode(self):
+ return 'container:' + self.container.id
+
+
+class ServiceNetworkMode:
+ """A network mode that uses a service's network stack."""
+
+ def __init__(self, service):
+ self.service = service
+
+ @property
+ def id(self):
+ return self.service.name
+
+ service_name = id
+
+ @property
+ def mode(self):
+ containers = self.service.containers()
+ if containers:
+ return 'container:' + containers[0].id
+
+ log.warning("Service %s is trying to use reuse the network stack "
+ "of another service that is not running." % (self.id))
+ return None
+
+
+# Names
+
+
+def build_container_name(project, service, number, slug=None):
+ bits = [project.lstrip('-_'), service]
+ if slug:
+ bits.extend(['run', truncate_id(slug)])
+ else:
+ bits.append(str(number))
+ return '_'.join(bits)
+
+
+# Images
+
+def parse_repository_tag(repo_path):
+ """Splits image identification into base image path, tag/digest
+ and it's separator.
+
+ Example:
+
+ >>> parse_repository_tag('user/repo@sha256:digest')
+ ('user/repo', 'sha256:digest', '@')
+ >>> parse_repository_tag('user/repo:v1')
+ ('user/repo', 'v1', ':')
+ """
+ tag_separator = ":"
+ digest_separator = "@"
+
+ if digest_separator in repo_path:
+ repo, tag = repo_path.rsplit(digest_separator, 1)
+ return repo, tag, digest_separator
+
+ repo, tag = repo_path, ""
+ if tag_separator in repo_path:
+ repo, tag = repo_path.rsplit(tag_separator, 1)
+ if "/" in tag:
+ repo, tag = repo_path, ""
+
+ return repo, tag, tag_separator
+
+
+# Volumes
+
+
+def merge_volume_bindings(volumes, tmpfs, previous_container, mounts):
+ """
+ Return a list of volume bindings for a container. Container data volumes
+ are replaced by those from the previous container.
+ Anonymous mounts are updated in place.
+ """
+ affinity = {}
+
+ volume_bindings = OrderedDict(
+ build_volume_binding(volume)
+ for volume in volumes
+ if volume.external
+ )
+
+ if previous_container:
+ old_volumes, old_mounts = get_container_data_volumes(
+ previous_container, volumes, tmpfs, mounts
+ )
+ warn_on_masked_volume(volumes, old_volumes, previous_container.service)
+ volume_bindings.update(
+ build_volume_binding(volume) for volume in old_volumes
+ )
+
+ if old_volumes or old_mounts:
+ affinity = {'affinity:container': '=' + previous_container.id}
+
+ return list(volume_bindings.values()), affinity
+
+
+def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_option):
+ """
+ Find the container data volumes that are in `volumes_option`, and return
+ a mapping of volume bindings for those volumes.
+ Anonymous volume mounts are updated in place instead.
+ """
+ volumes = []
+ volumes_option = volumes_option or []
+
+ container_mounts = {
+ mount['Destination']: mount
+ for mount in container.get('Mounts') or {}
+ }
+
+ image_volumes = [
+ VolumeSpec.parse(volume)
+ for volume in
+ container.image_config['ContainerConfig'].get('Volumes') or {}
+ ]
+
+ for volume in set(volumes_option + image_volumes):
+ # No need to preserve host volumes
+ if volume.external:
+ continue
+
+ # Attempting to rebind tmpfs volumes breaks: https://github.com/docker/compose/issues/4751
+ if volume.internal in convert_tmpfs_mounts(tmpfs_option).keys():
+ continue
+
+ mount = container_mounts.get(volume.internal)
+
+ # New volume, doesn't exist in the old container
+ if not mount:
+ continue
+
+ # Volume was previously a host volume, now it's a container volume
+ if not mount.get('Name'):
+ continue
+
+ # Volume (probably an image volume) is overridden by a mount in the service's config
+ # and would cause a duplicate mountpoint error
+ if volume.internal in [m.target for m in mounts_option]:
+ continue
+
+ # Copy existing volume from old container
+ volume = volume._replace(external=mount['Name'])
+ volumes.append(volume)
+
+ updated_mounts = False
+ for mount in mounts_option:
+ if mount.type != 'volume':
+ continue
+
+ ctnr_mount = container_mounts.get(mount.target)
+ if not ctnr_mount or not ctnr_mount.get('Name'):
+ continue
+
+ mount.source = ctnr_mount['Name']
+ updated_mounts = True
+
+ return volumes, updated_mounts
+
+
+def warn_on_masked_volume(volumes_option, container_volumes, service):
+ container_volumes = {
+ volume.internal: volume.external
+ for volume in container_volumes}
+
+ for volume in volumes_option:
+ if (
+ volume.external and
+ volume.internal in container_volumes and
+ container_volumes.get(volume.internal) != volume.external
+ ):
+ log.warning((
+ "Service \"{service}\" is using volume \"{volume}\" from the "
+ "previous container. Host mapping \"{host_path}\" has no effect. "
+ "Remove the existing containers (with `docker-compose rm {service}`) "
+ "to use the host volume mapping."
+ ).format(
+ service=service,
+ volume=volume.internal,
+ host_path=volume.external))
+
+
+def build_volume_binding(volume_spec):
+ return volume_spec.internal, volume_spec.repr()
+
+
+def build_volume_from(volume_from_spec):
+ """
+ volume_from can be either a service or a container. We want to return the
+ container.id and format it into a string complete with the mode.
+ """
+ if isinstance(volume_from_spec.source, Service):
+ containers = volume_from_spec.source.containers(stopped=True)
+ if not containers:
+ return "{}:{}".format(
+ volume_from_spec.source.create_container().id,
+ volume_from_spec.mode)
+
+ container = containers[0]
+ return "{}:{}".format(container.id, volume_from_spec.mode)
+ elif isinstance(volume_from_spec.source, Container):
+ return "{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode)
+
+
+def build_mount(mount_spec):
+ kwargs = {}
+ if mount_spec.options:
+ for option, sdk_name in mount_spec.options_map[mount_spec.type].items():
+ if option in mount_spec.options:
+ kwargs[sdk_name] = mount_spec.options[option]
+
+ return Mount(
+ type=mount_spec.type, target=mount_spec.target, source=mount_spec.source,
+ read_only=mount_spec.read_only, consistency=mount_spec.consistency, **kwargs
+ )
+
+
+# Labels
+
+
+def build_container_labels(label_options, service_labels, number, config_hash, slug):
+ labels = dict(label_options or {})
+ labels.update(label.split('=', 1) for label in service_labels)
+ if number is not None:
+ labels[LABEL_CONTAINER_NUMBER] = str(number)
+ if slug is not None:
+ labels[LABEL_SLUG] = slug
+ labels[LABEL_VERSION] = __version__
+
+ if config_hash:
+ log.debug("Added config hash: %s" % config_hash)
+ labels[LABEL_CONFIG_HASH] = config_hash
+
+ return labels
+
+
+# Ulimits
+
+
+def build_ulimits(ulimit_config):
+ if not ulimit_config:
+ return None
+ ulimits = []
+ for limit_name, soft_hard_values in ulimit_config.items():
+ if isinstance(soft_hard_values, int):
+ ulimits.append({'name': limit_name, 'soft': soft_hard_values, 'hard': soft_hard_values})
+ elif isinstance(soft_hard_values, dict):
+ ulimit_dict = {'name': limit_name}
+ ulimit_dict.update(soft_hard_values)
+ ulimits.append(ulimit_dict)
+
+ return ulimits
+
+
+def get_log_config(logging_dict):
+ log_driver = logging_dict.get('driver', "") if logging_dict else ""
+ log_options = logging_dict.get('options', None) if logging_dict else None
+ return LogConfig(
+ type=log_driver,
+ config=log_options
+ )
+
+
+# TODO: remove once fix is available in docker-py
+def format_environment(environment):
+ def format_env(key, value):
+ if value is None:
+ return key
+ if isinstance(value, bytes):
+ value = value.decode('utf-8')
+ return '{key}={value}'.format(key=key, value=value)
+
+ return [format_env(*item) for item in environment.items()]
+
+
+# Ports
+def formatted_ports(ports):
+ result = []
+ for port in ports:
+ if isinstance(port, ServicePort):
+ result.append(port.legacy_repr())
+ else:
+ result.append(port)
+ return result
+
+
+def build_container_ports(container_ports, options):
+ ports = []
+ all_ports = container_ports + options.get('expose', [])
+ for port_range in all_ports:
+ internal_range, _ = split_port(port_range)
+ for port in internal_range:
+ port = str(port)
+ if '/' in port:
+ port = tuple(port.split('/'))
+ ports.append(port)
+ return ports
+
+
+def convert_blkio_config(blkio_config):
+ result = {}
+ if blkio_config is None:
+ return result
+
+ result['weight'] = blkio_config.get('weight')
+ for field in [
+ "device_read_bps", "device_read_iops", "device_write_bps",
+ "device_write_iops", "weight_device",
+ ]:
+ if field not in blkio_config:
+ continue
+ arr = []
+ for item in blkio_config[field]:
+ arr.append({k.capitalize(): v for k, v in item.items()})
+ result[field] = arr
+ return result
+
+
+def rewrite_build_path(path):
+ if IS_WINDOWS_PLATFORM and not is_url(path) and not path.startswith(WINDOWS_LONGPATH_PREFIX):
+ path = WINDOWS_LONGPATH_PREFIX + os.path.normpath(path)
+
+ return path
+
+
+class _ClientBuilder:
+ def __init__(self, client):
+ self.client = client
+
+ def build(self, service, path, tag=None, quiet=False, fileobj=None,
+ nocache=False, rm=False, timeout=None,
+ custom_context=False, encoding=None, pull=False,
+ forcerm=False, dockerfile=None, container_limits=None,
+ decode=False, buildargs=None, gzip=False, shmsize=None,
+ labels=None, cache_from=None, target=None, network_mode=None,
+ squash=None, extra_hosts=None, platform=None, isolation=None,
+ use_config_proxy=True, output_stream=sys.stdout):
+ build_output = self.client.build(
+ path=path,
+ tag=tag,
+ nocache=nocache,
+ rm=rm,
+ pull=pull,
+ forcerm=forcerm,
+ dockerfile=dockerfile,
+ labels=labels,
+ cache_from=cache_from,
+ buildargs=buildargs,
+ network_mode=network_mode,
+ target=target,
+ shmsize=shmsize,
+ extra_hosts=extra_hosts,
+ container_limits=container_limits,
+ gzip=gzip,
+ isolation=isolation,
+ platform=platform)
+
+ try:
+ all_events = list(stream_output(build_output, output_stream))
+ except StreamOutputError as e:
+ raise BuildError(service, str(e))
+
+ # Ensure the HTTP connection is not reused for another
+ # streaming command, as the Docker daemon can sometimes
+ # complain about it
+ self.client.close()
+
+ image_id = None
+
+ for event in all_events:
+ if 'stream' in event:
+ match = re.search(r'Successfully built ([0-9a-f]+)', event.get('stream', ''))
+ if match:
+ image_id = match.group(1)
+
+ if image_id is None:
+ raise BuildError(service, event if all_events else 'Unknown')
+
+ return image_id
+
+
+class _CLIBuilder:
+ def __init__(self, progress):
+ self._progress = progress
+
+ def build(self, service, path, tag=None, quiet=False, fileobj=None,
+ nocache=False, rm=False, timeout=None,
+ custom_context=False, encoding=None, pull=False,
+ forcerm=False, dockerfile=None, container_limits=None,
+ decode=False, buildargs=None, gzip=False, shmsize=None,
+ labels=None, cache_from=None, target=None, network_mode=None,
+ squash=None, extra_hosts=None, platform=None, isolation=None,
+ use_config_proxy=True, output_stream=sys.stdout):
+ """
+ Args:
+ service (str): Service to be built
+ path (str): Path to the directory containing the Dockerfile
+ buildargs (dict): A dictionary of build arguments
+ cache_from (:py:class:`list`): A list of images used for build
+ cache resolution
+ container_limits (dict): A dictionary of limits applied to each
+ container created by the build process. Valid keys:
+ - memory (int): set memory limit for build
+ - memswap (int): Total memory (memory + swap), -1 to disable
+ swap
+ - cpushares (int): CPU shares (relative weight)
+ - cpusetcpus (str): CPUs in which to allow execution, e.g.,
+ ``"0-3"``, ``"0,1"``
+ custom_context (bool): Optional if using ``fileobj``
+ decode (bool): If set to ``True``, the returned stream will be
+ decoded into dicts on the fly. Default ``False``
+ dockerfile (str): path within the build context to the Dockerfile
+ encoding (str): The encoding for a stream. Set to ``gzip`` for
+ compressing
+ extra_hosts (dict): Extra hosts to add to /etc/hosts in building
+ containers, as a mapping of hostname to IP address.
+ fileobj: A file object to use as the Dockerfile. (Or a file-like
+ object)
+ forcerm (bool): Always remove intermediate containers, even after
+ unsuccessful builds
+ isolation (str): Isolation technology used during build.
+ Default: `None`.
+ labels (dict): A dictionary of labels to set on the image
+ network_mode (str): networking mode for the run commands during
+ build
+ nocache (bool): Don't use the cache when set to ``True``
+ platform (str): Platform in the format ``os[/arch[/variant]]``
+ pull (bool): Downloads any updates to the FROM image in Dockerfiles
+ quiet (bool): Whether to return the status
+ rm (bool): Remove intermediate containers. The ``docker build``
+ command now defaults to ``--rm=true``, but we have kept the old
+ default of `False` to preserve backward compatibility
+ shmsize (int): Size of `/dev/shm` in bytes. The size must be
+ greater than 0. If omitted the system uses 64MB
+ squash (bool): Squash the resulting images layers into a
+ single layer.
+ tag (str): A tag to add to the final image
+ target (str): Name of the build-stage to build in a multi-stage
+ Dockerfile
+ timeout (int): HTTP timeout
+ use_config_proxy (bool): If ``True``, and if the docker client
+ configuration file (``~/.docker/config.json`` by default)
+ contains a proxy configuration, the corresponding environment
+ variables will be set in the container being built.
+ output_stream (writer): stream to use for build logs
+ Returns:
+ A generator for the build output.
+ """
+ if dockerfile and os.path.isdir(path):
+ dockerfile = os.path.join(path, dockerfile)
+ iidfile = tempfile.mktemp()
+
+ command_builder = _CommandBuilder()
+ command_builder.add_params("--build-arg", buildargs)
+ command_builder.add_list("--cache-from", cache_from)
+ command_builder.add_arg("--file", dockerfile)
+ command_builder.add_flag("--force-rm", forcerm)
+ command_builder.add_params("--label", labels)
+ command_builder.add_arg("--memory", container_limits.get("memory"))
+ command_builder.add_arg("--network", network_mode)
+ command_builder.add_flag("--no-cache", nocache)
+ command_builder.add_arg("--progress", self._progress)
+ command_builder.add_flag("--pull", pull)
+ command_builder.add_arg("--tag", tag)
+ command_builder.add_arg("--target", target)
+ command_builder.add_arg("--iidfile", iidfile)
+ command_builder.add_arg("--platform", platform)
+ command_builder.add_arg("--isolation", isolation)
+
+ if extra_hosts:
+ if isinstance(extra_hosts, dict):
+ extra_hosts = ["{}:{}".format(host, ip) for host, ip in extra_hosts.items()]
+ for host in extra_hosts:
+ command_builder.add_arg("--add-host", "{}".format(host))
+
+ args = command_builder.build([path])
+
+ with subprocess.Popen(args, stdout=output_stream, stderr=sys.stderr,
+ universal_newlines=True) as p:
+ p.communicate()
+ if p.returncode != 0:
+ raise BuildError(service, "Build failed")
+
+ with open(iidfile) as f:
+ line = f.readline()
+ image_id = line.split(":")[1].strip()
+ os.remove(iidfile)
+
+ return image_id
+
+
+class _CommandBuilder:
+ def __init__(self):
+ self._args = ["docker", "build"]
+
+ def add_arg(self, name, value):
+ if value:
+ self._args.extend([name, str(value)])
+
+ def add_flag(self, name, flag):
+ if flag:
+ self._args.extend([name])
+
+ def add_params(self, name, params):
+ if params:
+ for key, val in params.items():
+ self._args.extend([name, "{}={}".format(key, val)])
+
+ def add_list(self, name, values):
+ if values:
+ for val in values:
+ self._args.extend([name, val])
+
+ def build(self, args):
+ return self._args + args
diff --git a/compose/timeparse.py b/compose/timeparse.py
new file mode 100644
index 00000000000..47744562519
--- /dev/null
+++ b/compose/timeparse.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python
+'''
+timeparse.py
+(c) Will Roberts 1 February, 2014
+
+This is a vendored and modified copy of:
+github.com/wroberts/pytimeparse @ cc0550d
+
+It has been modified to mimic the behaviour of
+https://golang.org/pkg/time/#ParseDuration
+'''
+# MIT LICENSE
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import re
+
+HOURS = r'(?P[\d.]+)h'
+MINS = r'(?P[\d.]+)m'
+SECS = r'(?P[\d.]+)s'
+MILLI = r'(?P[\d.]+)ms'
+MICRO = r'(?P[\d.]+)(?:us|µs)'
+NANO = r'(?P[\d.]+)ns'
+
+
+def opt(x):
+ return r'(?:{x})?'.format(x=x)
+
+
+TIMEFORMAT = r'{HOURS}{MINS}{SECS}{MILLI}{MICRO}{NANO}'.format(
+ HOURS=opt(HOURS),
+ MINS=opt(MINS),
+ SECS=opt(SECS),
+ MILLI=opt(MILLI),
+ MICRO=opt(MICRO),
+ NANO=opt(NANO),
+)
+
+MULTIPLIERS = {
+ 'hours': 60 * 60,
+ 'mins': 60,
+ 'secs': 1,
+ 'milli': 1.0 / 1000,
+ 'micro': 1.0 / 1000.0 / 1000,
+ 'nano': 1.0 / 1000.0 / 1000.0 / 1000.0,
+}
+
+
+def timeparse(sval):
+ """Parse a time expression, returning it as a number of seconds. If
+ possible, the return value will be an `int`; if this is not
+ possible, the return will be a `float`. Returns `None` if a time
+ expression cannot be parsed from the given string.
+
+ Arguments:
+ - `sval`: the string value to parse
+
+ >>> timeparse('1m24s')
+ 84
+ >>> timeparse('1.2 minutes')
+ 72
+ >>> timeparse('1.2 seconds')
+ 1.2
+ """
+ match = re.match(r'\s*' + TIMEFORMAT + r'\s*$', sval, re.I)
+ if not match or not match.group(0).strip():
+ return
+
+ mdict = match.groupdict()
+ return sum(
+ MULTIPLIERS[k] * cast(v) for (k, v) in mdict.items() if v is not None)
+
+
+def cast(value):
+ return int(value) if value.isdigit() else float(value)
diff --git a/compose/utils.py b/compose/utils.py
new file mode 100644
index 00000000000..86af8f8852a
--- /dev/null
+++ b/compose/utils.py
@@ -0,0 +1,191 @@
+import hashlib
+import json.decoder
+import logging
+import ntpath
+import random
+
+from docker.errors import DockerException
+from docker.utils import parse_bytes as sdk_parse_bytes
+
+from .errors import StreamParseError
+from .timeparse import MULTIPLIERS
+from .timeparse import timeparse
+
+
+json_decoder = json.JSONDecoder()
+log = logging.getLogger(__name__)
+
+
+def stream_as_text(stream):
+ """Given a stream of bytes or text, if any of the items in the stream
+ are bytes convert them to text.
+
+ This function can be removed once docker-py returns text streams instead
+ of byte streams.
+ """
+ for data in stream:
+ if not isinstance(data, str):
+ data = data.decode('utf-8', 'replace')
+ yield data
+
+
+def line_splitter(buffer, separator='\n'):
+ index = buffer.find(str(separator))
+ if index == -1:
+ return None
+ return buffer[:index + 1], buffer[index + 1:]
+
+
+def split_buffer(stream, splitter=None, decoder=lambda a: a):
+ """Given a generator which yields strings and a splitter function,
+ joins all input, splits on the separator and yields each chunk.
+
+ Unlike string.split(), each chunk includes the trailing
+ separator, except for the last one if none was found on the end
+ of the input.
+ """
+ splitter = splitter or line_splitter
+ buffered = ''
+
+ for data in stream_as_text(stream):
+ buffered += data
+ while True:
+ buffer_split = splitter(buffered)
+ if buffer_split is None:
+ break
+
+ item, buffered = buffer_split
+ yield item
+
+ if buffered:
+ try:
+ yield decoder(buffered)
+ except Exception as e:
+ log.error(
+ 'Compose tried decoding the following data chunk, but failed:'
+ '\n%s' % repr(buffered)
+ )
+ raise StreamParseError(e)
+
+
+def json_splitter(buffer):
+ """Attempt to parse a json object from a buffer. If there is at least one
+ object, return it and the rest of the buffer, otherwise return None.
+ """
+ buffer = buffer.strip()
+ try:
+ obj, index = json_decoder.raw_decode(buffer)
+ rest = buffer[json.decoder.WHITESPACE.match(buffer, index).end():]
+ return obj, rest
+ except ValueError:
+ return None
+
+
+def json_stream(stream):
+ """Given a stream of text, return a stream of json objects.
+ This handles streams which are inconsistently buffered (some entries may
+ be newline delimited, and others are not).
+ """
+ return split_buffer(stream, json_splitter, json_decoder.decode)
+
+
+def json_hash(obj):
+ dump = json.dumps(obj, sort_keys=True, separators=(',', ':'), default=lambda x: x.repr())
+ h = hashlib.sha256()
+ h.update(dump.encode('utf8'))
+ return h.hexdigest()
+
+
+def microseconds_from_time_nano(time_nano):
+ return int(time_nano % 1000000000 / 1000)
+
+
+def nanoseconds_from_time_seconds(time_seconds):
+ return int(time_seconds / MULTIPLIERS['nano'])
+
+
+def parse_seconds_float(value):
+ return timeparse(value or '')
+
+
+def parse_nanoseconds_int(value):
+ parsed = timeparse(value or '')
+ if parsed is None:
+ return None
+ return nanoseconds_from_time_seconds(parsed)
+
+
+def build_string_dict(source_dict):
+ return {k: str(v if v is not None else '') for k, v in source_dict.items()}
+
+
+def splitdrive(path):
+ if len(path) == 0:
+ return ('', '')
+ if path[0] in ['.', '\\', '/', '~']:
+ return ('', path)
+ return ntpath.splitdrive(path)
+
+
+def parse_bytes(n):
+ try:
+ return sdk_parse_bytes(n)
+ except DockerException:
+ return None
+
+
+def unquote_path(s):
+ if not s:
+ return s
+ if s[0] == '"' and s[-1] == '"':
+ return s[1:-1]
+ return s
+
+
+def generate_random_id():
+ while True:
+ val = hex(random.getrandbits(32 * 8))[2:-1]
+ try:
+ int(truncate_id(val))
+ continue
+ except ValueError:
+ return val
+
+
+def truncate_id(value):
+ if ':' in value:
+ value = value[value.index(':') + 1:]
+ if len(value) > 12:
+ return value[:12]
+ return value
+
+
+def unique_everseen(iterable, key=lambda x: x):
+ "List unique elements, preserving order. Remember all elements ever seen."
+ seen = set()
+ for element in iterable:
+ unique_key = key(element)
+ if unique_key not in seen:
+ seen.add(unique_key)
+ yield element
+
+
+def truncate_string(s, max_chars=35):
+ if len(s) > max_chars:
+ return s[:max_chars - 2] + '...'
+ return s
+
+
+def filter_attached_for_up(items, service_names, attach_dependencies=False,
+ item_to_service_name=lambda x: x):
+ """This function contains the logic of choosing which services to
+ attach when doing docker-compose up. It may be used both with containers
+ and services, and any other entities that map to service names -
+ this mapping is provided by item_to_service_name."""
+ if attach_dependencies or not service_names:
+ return items
+
+ return [
+ item
+ for item in items if item_to_service_name(item) in service_names
+ ]
diff --git a/compose/version.py b/compose/version.py
new file mode 100644
index 00000000000..c039263acb9
--- /dev/null
+++ b/compose/version.py
@@ -0,0 +1,7 @@
+from distutils.version import LooseVersion
+
+
+class ComposeVersion(LooseVersion):
+ """ A hashable version object """
+ def __hash__(self):
+ return hash(self.vstring)
diff --git a/compose/volume.py b/compose/volume.py
new file mode 100644
index 00000000000..5f36e432ba9
--- /dev/null
+++ b/compose/volume.py
@@ -0,0 +1,213 @@
+import logging
+import re
+from itertools import chain
+
+from docker.errors import NotFound
+from docker.utils import version_lt
+
+from . import __version__
+from .config import ConfigurationError
+from .config.types import VolumeSpec
+from .const import LABEL_PROJECT
+from .const import LABEL_VERSION
+from .const import LABEL_VOLUME
+
+
+log = logging.getLogger(__name__)
+
+
+class Volume:
+ def __init__(self, client, project, name, driver=None, driver_opts=None,
+ external=False, labels=None, custom_name=False):
+ self.client = client
+ self.project = project
+ self.name = name
+ self.driver = driver
+ self.driver_opts = driver_opts
+ self.external = external
+ self.labels = labels
+ self.custom_name = custom_name
+ self.legacy = None
+
+ def create(self):
+ return self.client.create_volume(
+ self.full_name, self.driver, self.driver_opts, labels=self._labels
+ )
+
+ def remove(self):
+ if self.external:
+ log.info("Volume %s is external, skipping", self.true_name)
+ return
+ log.info("Removing volume %s", self.true_name)
+ return self.client.remove_volume(self.true_name)
+
+ def inspect(self, legacy=None):
+ if legacy:
+ return self.client.inspect_volume(self.legacy_full_name)
+ return self.client.inspect_volume(self.full_name)
+
+ def exists(self):
+ self._set_legacy_flag()
+ try:
+ self.inspect(legacy=self.legacy)
+ except NotFound:
+ return False
+ return True
+
+ @property
+ def full_name(self):
+ if self.custom_name:
+ return self.name
+ return '{}_{}'.format(self.project.lstrip('-_'), self.name)
+
+ @property
+ def legacy_full_name(self):
+ if self.custom_name:
+ return self.name
+ return '{}_{}'.format(
+ re.sub(r'[_-]', '', self.project), self.name
+ )
+
+ @property
+ def true_name(self):
+ self._set_legacy_flag()
+ if self.legacy:
+ return self.legacy_full_name
+ return self.full_name
+
+ @property
+ def _labels(self):
+ if version_lt(self.client._version, '1.23'):
+ return None
+ labels = self.labels.copy() if self.labels else {}
+ labels.update({
+ LABEL_PROJECT: self.project,
+ LABEL_VOLUME: self.name,
+ LABEL_VERSION: __version__,
+ })
+ return labels
+
+ def _set_legacy_flag(self):
+ if self.legacy is not None:
+ return
+ try:
+ data = self.inspect(legacy=True)
+ self.legacy = data is not None
+ except NotFound:
+ self.legacy = False
+
+
+class ProjectVolumes:
+
+ def __init__(self, volumes):
+ self.volumes = volumes
+
+ @classmethod
+ def from_config(cls, name, config_data, client):
+ config_volumes = config_data.volumes or {}
+ volumes = {
+ vol_name: Volume(
+ client=client,
+ project=name,
+ name=data.get('name', vol_name),
+ driver=data.get('driver'),
+ driver_opts=data.get('driver_opts'),
+ custom_name=data.get('name') is not None,
+ labels=data.get('labels'),
+ external=bool(data.get('external', False))
+ )
+ for vol_name, data in config_volumes.items()
+ }
+ return cls(volumes)
+
+ def remove(self):
+ for volume in self.volumes.values():
+ try:
+ volume.remove()
+ except NotFound:
+ log.warning("Volume %s not found.", volume.true_name)
+
+ def initialize(self):
+ try:
+ for volume in self.volumes.values():
+ volume_exists = volume.exists()
+ if volume.external:
+ log.debug(
+ 'Volume {} declared as external. No new '
+ 'volume will be created.'.format(volume.name)
+ )
+ if not volume_exists:
+ raise ConfigurationError(
+ 'Volume {name} declared as external, but could'
+ ' not be found. Please create the volume manually'
+ ' using `{command}{name}` and try again.'.format(
+ name=volume.full_name,
+ command='docker volume create --name='
+ )
+ )
+ continue
+
+ if not volume_exists:
+ log.info(
+ 'Creating volume "{}" with {} driver'.format(
+ volume.full_name, volume.driver or 'default'
+ )
+ )
+ volume.create()
+ else:
+ check_remote_volume_config(volume.inspect(legacy=volume.legacy), volume)
+ except NotFound:
+ raise ConfigurationError(
+ 'Volume {} specifies nonexistent driver {}'.format(volume.name, volume.driver)
+ )
+
+ def namespace_spec(self, volume_spec):
+ if not volume_spec.is_named_volume:
+ return volume_spec
+
+ if isinstance(volume_spec, VolumeSpec):
+ volume = self.volumes[volume_spec.external]
+ return volume_spec._replace(external=volume.true_name)
+ else:
+ volume_spec.source = self.volumes[volume_spec.source].true_name
+ return volume_spec
+
+
+class VolumeConfigChangedError(ConfigurationError):
+ def __init__(self, local, property_name, local_value, remote_value):
+ super().__init__(
+ 'Configuration for volume {vol_name} specifies {property_name} '
+ '{local_value}, but a volume with the same name uses a different '
+ '{property_name} ({remote_value}). If you wish to use the new '
+ 'configuration, please remove the existing volume "{full_name}" '
+ 'first:\n$ docker volume rm {full_name}'.format(
+ vol_name=local.name, property_name=property_name,
+ local_value=local_value, remote_value=remote_value,
+ full_name=local.true_name
+ )
+ )
+
+
+def check_remote_volume_config(remote, local):
+ if local.driver and remote.get('Driver') != local.driver:
+ raise VolumeConfigChangedError(local, 'driver', local.driver, remote.get('Driver'))
+ local_opts = local.driver_opts or {}
+ remote_opts = remote.get('Options') or {}
+ for k in set(chain(remote_opts, local_opts)):
+ if k.startswith('com.docker.'): # These options are set internally
+ continue
+ if remote_opts.get(k) != local_opts.get(k):
+ raise VolumeConfigChangedError(
+ local, '"{}" driver_opt'.format(k), local_opts.get(k), remote_opts.get(k),
+ )
+
+ local_labels = local.labels or {}
+ remote_labels = remote.get('Labels') or {}
+ for k in set(chain(remote_labels, local_labels)):
+ if k.startswith('com.docker.'): # We are only interested in user-specified labels
+ continue
+ if remote_labels.get(k) != local_labels.get(k):
+ log.warning(
+ 'Volume {}: label "{}" has changed. It may need to be'
+ ' recreated.'.format(local.name, k)
+ )
diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose
new file mode 100644
index 00000000000..c3b6157d391
--- /dev/null
+++ b/contrib/completion/bash/docker-compose
@@ -0,0 +1,676 @@
+#!/bin/bash
+#
+# bash completion for docker-compose
+#
+# This work is based on the completion for the docker command.
+#
+# This script provides completion of:
+# - commands and their options
+# - service names
+# - filepaths
+#
+# To enable the completions either:
+# - place this file in /etc/bash_completion.d
+# or
+# - copy this file to e.g. ~/.docker-compose-completion.sh and add the line
+# below to your .bashrc after bash completion features are loaded
+# . ~/.docker-compose-completion.sh
+
+__docker_compose_previous_extglob_setting=$(shopt -p extglob)
+shopt -s extglob
+
+__docker_compose_q() {
+ docker-compose 2>/dev/null "${top_level_options[@]}" "$@"
+}
+
+# Transforms a multiline list of strings into a single line string
+# with the words separated by "|".
+__docker_compose_to_alternatives() {
+ local parts=( $1 )
+ local IFS='|'
+ echo "${parts[*]}"
+}
+
+# Transforms a multiline list of options into an extglob pattern
+# suitable for use in case statements.
+__docker_compose_to_extglob() {
+ local extglob=$( __docker_compose_to_alternatives "$1" )
+ echo "@($extglob)"
+}
+
+# Determines whether the option passed as the first argument exist on
+# the commandline. The option may be a pattern, e.g. `--force|-f`.
+__docker_compose_has_option() {
+ local pattern="$1"
+ for (( i=2; i < $cword; ++i)); do
+ if [[ ${words[$i]} =~ ^($pattern)$ ]] ; then
+ return 0
+ fi
+ done
+ return 1
+}
+
+# Returns `key` if we are currently completing the value of a map option (`key=value`)
+# which matches the extglob passed in as an argument.
+# This function is needed for key-specific completions.
+__docker_compose_map_key_of_current_option() {
+ local glob="$1"
+
+ local key glob_pos
+ if [ "$cur" = "=" ] ; then # key= case
+ key="$prev"
+ glob_pos=$((cword - 2))
+ elif [[ $cur == *=* ]] ; then # key=value case (OSX)
+ key=${cur%=*}
+ glob_pos=$((cword - 1))
+ elif [ "$prev" = "=" ] ; then
+ key=${words[$cword - 2]} # key=value case
+ glob_pos=$((cword - 3))
+ else
+ return
+ fi
+
+ [ "${words[$glob_pos]}" = "=" ] && ((glob_pos--)) # --option=key=value syntax
+
+ [[ ${words[$glob_pos]} == @($glob) ]] && echo "$key"
+}
+
+# suppress trailing whitespace
+__docker_compose_nospace() {
+ # compopt is not available in ancient bash versions
+ type compopt &>/dev/null && compopt -o nospace
+}
+
+
+# Outputs a list of all defined services, regardless of their running state.
+# Arguments for `docker-compose ps` may be passed in order to filter the service list,
+# e.g. `status=running`.
+__docker_compose_services() {
+ __docker_compose_q ps --services "$@"
+}
+
+# Applies completion of services based on the current value of `$cur`.
+# Arguments for `docker-compose ps` may be passed in order to filter the service list,
+# see `__docker_compose_services`.
+__docker_compose_complete_services() {
+ COMPREPLY=( $(compgen -W "$(__docker_compose_services "$@")" -- "$cur") )
+}
+
+# The services for which at least one running container exists
+__docker_compose_complete_running_services() {
+ local names=$(__docker_compose_services --filter status=running)
+ COMPREPLY=( $(compgen -W "$names" -- "$cur") )
+}
+
+
+_docker_compose_build() {
+ case "$prev" in
+ --build-arg)
+ COMPREPLY=( $( compgen -e -- "$cur" ) )
+ __docker_compose_nospace
+ return
+ ;;
+ --memory|-m)
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory -m --no-cache --no-rm --pull --parallel -q --quiet" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services --filter source=build
+ ;;
+ esac
+}
+
+
+_docker_compose_config() {
+ case "$prev" in
+ --hash)
+ if [[ $cur == \\* ]] ; then
+ COMPREPLY=( '\*' )
+ else
+ COMPREPLY=( $(compgen -W "$(__docker_compose_services) \\\* " -- "$cur") )
+ fi
+ return
+ ;;
+ esac
+
+ COMPREPLY=( $( compgen -W "--hash --help --no-interpolate --profiles --quiet -q --resolve-image-digests --services --volumes" -- "$cur" ) )
+}
+
+
+_docker_compose_create() {
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--build --force-recreate --help --no-build --no-recreate" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services
+ ;;
+ esac
+}
+
+
+_docker_compose_docker_compose() {
+ case "$prev" in
+ --tlscacert|--tlscert|--tlskey)
+ _filedir
+ return
+ ;;
+ --file|-f)
+ _filedir "y?(a)ml"
+ return
+ ;;
+ --ansi)
+ COMPREPLY=( $( compgen -W "never always auto" -- "$cur" ) )
+ return
+ ;;
+ --log-level)
+ COMPREPLY=( $( compgen -W "debug info warning error critical" -- "$cur" ) )
+ return
+ ;;
+ --profile)
+ COMPREPLY=( $( compgen -W "$(__docker_compose_q config --profiles)" -- "$cur" ) )
+ return
+ ;;
+ --project-directory)
+ _filedir -d
+ return
+ ;;
+ --env-file)
+ _filedir
+ return
+ ;;
+ $(__docker_compose_to_extglob "$daemon_options_with_args") )
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "$daemon_boolean_options $daemon_options_with_args $top_level_options_with_args --help -h --no-ansi --verbose --version -v" -- "$cur" ) )
+ ;;
+ *)
+ COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) )
+ ;;
+ esac
+}
+
+
+_docker_compose_down() {
+ case "$prev" in
+ --rmi)
+ COMPREPLY=( $( compgen -W "all local" -- "$cur" ) )
+ return
+ ;;
+ --timeout|-t)
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help --rmi --timeout -t --volumes -v --remove-orphans" -- "$cur" ) )
+ ;;
+ esac
+}
+
+
+_docker_compose_events() {
+ case "$prev" in
+ --json)
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help --json" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services
+ ;;
+ esac
+}
+
+
+_docker_compose_exec() {
+ case "$prev" in
+ --index|--user|-u|--workdir|-w)
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "-d --detach --help --index --privileged -T --user -u --workdir -w" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_running_services
+ ;;
+ esac
+}
+
+
+_docker_compose_help() {
+ COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) )
+}
+
+_docker_compose_images() {
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help --quiet -q" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services
+ ;;
+ esac
+}
+
+_docker_compose_kill() {
+ case "$prev" in
+ -s)
+ COMPREPLY=( $( compgen -W "SIGHUP SIGINT SIGKILL SIGUSR1 SIGUSR2" -- "$(echo $cur | tr '[:lower:]' '[:upper:]')" ) )
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help -s" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_running_services
+ ;;
+ esac
+}
+
+
+_docker_compose_logs() {
+ case "$prev" in
+ --tail)
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--follow -f --help --no-color --no-log-prefix --tail --timestamps -t" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services
+ ;;
+ esac
+}
+
+
+_docker_compose_pause() {
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_running_services
+ ;;
+ esac
+}
+
+
+_docker_compose_port() {
+ case "$prev" in
+ --protocol)
+ COMPREPLY=( $( compgen -W "tcp udp" -- "$cur" ) )
+ return;
+ ;;
+ --index)
+ return;
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help --index --protocol" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services
+ ;;
+ esac
+}
+
+
+_docker_compose_ps() {
+ local key=$(__docker_compose_map_key_of_current_option '--filter')
+ case "$key" in
+ source)
+ COMPREPLY=( $( compgen -W "build image" -- "${cur##*=}" ) )
+ return
+ ;;
+ status)
+ COMPREPLY=( $( compgen -W "paused restarting running stopped" -- "${cur##*=}" ) )
+ return
+ ;;
+ esac
+
+ case "$prev" in
+ --filter)
+ COMPREPLY=( $( compgen -W "source status" -S "=" -- "$cur" ) )
+ __docker_compose_nospace
+ return;
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--all -a --filter --help --quiet -q --services" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services
+ ;;
+ esac
+}
+
+
+_docker_compose_pull() {
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --include-deps --no-parallel --quiet -q" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services --filter source=image
+ ;;
+ esac
+}
+
+
+_docker_compose_push() {
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help --ignore-push-failures" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services
+ ;;
+ esac
+}
+
+
+_docker_compose_restart() {
+ case "$prev" in
+ --timeout|-t)
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_running_services
+ ;;
+ esac
+}
+
+
+_docker_compose_rm() {
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--force -f --help --stop -s -v" -- "$cur" ) )
+ ;;
+ *)
+ if __docker_compose_has_option "--stop|-s" ; then
+ __docker_compose_complete_services
+ else
+ __docker_compose_complete_services --filter status=stopped
+ fi
+ ;;
+ esac
+}
+
+
+_docker_compose_run() {
+ case "$prev" in
+ -e)
+ COMPREPLY=( $( compgen -e -- "$cur" ) )
+ __docker_compose_nospace
+ return
+ ;;
+ --entrypoint|--label|-l|--name|--user|-u|--volume|-v|--workdir|-w)
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--detach -d --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --use-aliases --user -u --volume -v --workdir -w" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services
+ ;;
+ esac
+}
+
+
+_docker_compose_scale() {
+ case "$prev" in
+ =)
+ COMPREPLY=("$cur")
+ return
+ ;;
+ --timeout|-t)
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) )
+ ;;
+ *)
+ COMPREPLY=( $(compgen -S "=" -W "$(__docker_compose_services)" -- "$cur") )
+ __docker_compose_nospace
+ ;;
+ esac
+}
+
+
+_docker_compose_start() {
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services --filter status=stopped
+ ;;
+ esac
+}
+
+
+_docker_compose_stop() {
+ case "$prev" in
+ --timeout|-t)
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_running_services
+ ;;
+ esac
+}
+
+
+_docker_compose_top() {
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_running_services
+ ;;
+ esac
+}
+
+
+_docker_compose_unpause() {
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--help" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services --filter status=paused
+ ;;
+ esac
+}
+
+
+_docker_compose_up() {
+ case "$prev" in
+ =)
+ COMPREPLY=("$cur")
+ return
+ ;;
+ --exit-code-from)
+ __docker_compose_complete_services
+ return
+ ;;
+ --scale)
+ COMPREPLY=( $(compgen -S "=" -W "$(__docker_compose_services)" -- "$cur") )
+ __docker_compose_nospace
+ return
+ ;;
+ --timeout|-t)
+ return
+ ;;
+ esac
+
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--abort-on-container-exit --always-recreate-deps --attach-dependencies --build -d --detach --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-log-prefix --no-recreate --no-start --renew-anon-volumes -V --remove-orphans --scale --timeout -t" -- "$cur" ) )
+ ;;
+ *)
+ __docker_compose_complete_services
+ ;;
+ esac
+}
+
+
+_docker_compose_version() {
+ case "$cur" in
+ -*)
+ COMPREPLY=( $( compgen -W "--short" -- "$cur" ) )
+ ;;
+ esac
+}
+
+
+_docker_compose() {
+ local previous_extglob_setting=$(shopt -p extglob)
+ shopt -s extglob
+
+ local commands=(
+ build
+ config
+ create
+ down
+ events
+ exec
+ help
+ images
+ kill
+ logs
+ pause
+ port
+ ps
+ pull
+ push
+ restart
+ rm
+ run
+ scale
+ start
+ stop
+ top
+ unpause
+ up
+ version
+ )
+
+ # Options for the docker daemon that have to be passed to secondary calls to
+ # docker-compose executed by this script.
+ local daemon_boolean_options="
+ --skip-hostname-check
+ --tls
+ --tlsverify
+ "
+ local daemon_options_with_args="
+ --context -c
+ --env-file
+ --file -f
+ --host -H
+ --project-directory
+ --project-name -p
+ --tlscacert
+ --tlscert
+ --tlskey
+ "
+
+ # These options require special treatment when searching the command.
+ local top_level_options_with_args="
+ --ansi
+ --log-level
+ --profile
+ "
+
+ COMPREPLY=()
+ local cur prev words cword
+ _get_comp_words_by_ref -n : cur prev words cword
+
+ # search subcommand and invoke its handler.
+ # special treatment of some top-level options
+ local command='docker_compose'
+ local top_level_options=()
+ local counter=1
+
+ while [ $counter -lt $cword ]; do
+ case "${words[$counter]}" in
+ $(__docker_compose_to_extglob "$daemon_boolean_options") )
+ local opt=${words[counter]}
+ top_level_options+=($opt)
+ ;;
+ $(__docker_compose_to_extglob "$daemon_options_with_args") )
+ local opt=${words[counter]}
+ local arg=${words[++counter]}
+ top_level_options+=($opt $arg)
+ ;;
+ $(__docker_compose_to_extglob "$top_level_options_with_args") )
+ (( counter++ ))
+ ;;
+ -*)
+ ;;
+ *)
+ command="${words[$counter]}"
+ break
+ ;;
+ esac
+ (( counter++ ))
+ done
+
+ local completions_func=_docker_compose_${command//-/_}
+ declare -F $completions_func >/dev/null && $completions_func
+
+ eval "$previous_extglob_setting"
+ return 0
+}
+
+eval "$__docker_compose_previous_extglob_setting"
+unset __docker_compose_previous_extglob_setting
+
+complete -F _docker_compose docker-compose docker-compose.exe
diff --git a/contrib/completion/fish/docker-compose.fish b/contrib/completion/fish/docker-compose.fish
new file mode 100644
index 00000000000..9183a098e68
--- /dev/null
+++ b/contrib/completion/fish/docker-compose.fish
@@ -0,0 +1,27 @@
+# Tab completion for docker-compose (https://github.com/docker/compose).
+# Version: 1.9.0
+
+complete -e -c docker-compose
+
+for line in (docker-compose --help | \
+ string match -r '^\s+\w+\s+[^\n]+' | \
+ string trim)
+ set -l doc (string split -m 1 ' ' -- $line)
+ complete -c docker-compose -n '__fish_use_subcommand' -xa $doc[1] --description $doc[2]
+end
+
+complete -c docker-compose -s f -l file -r -d 'Specify an alternate compose file'
+complete -c docker-compose -s p -l project-name -x -d 'Specify an alternate project name'
+complete -c docker-compose -l env-file -r -d 'Specify an alternate environment file (default: .env)'
+complete -c docker-compose -l verbose -d 'Show more output'
+complete -c docker-compose -s H -l host -x -d 'Daemon socket to connect to'
+complete -c docker-compose -l tls -d 'Use TLS; implied by --tlsverify'
+complete -c docker-compose -l tlscacert -r -d 'Trust certs signed only by this CA'
+complete -c docker-compose -l tlscert -r -d 'Path to TLS certificate file'
+complete -c docker-compose -l tlskey -r -d 'Path to TLS key file'
+complete -c docker-compose -l tlsverify -d 'Use TLS and verify the remote'
+complete -c docker-compose -l skip-hostname-check -d "Don't check the daemon's hostname against the name specified in the client certificate (for example if your docker host is an IP address)"
+complete -c docker-compose -l no-ansi -d 'Do not print ANSI control characters'
+complete -c docker-compose -l ansi -a 'never always auto' -d 'Control when to print ANSI control characters'
+complete -c docker-compose -s h -l help -d 'Print usage'
+complete -c docker-compose -s v -l version -d 'Print version and exit'
diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose
new file mode 100755
index 00000000000..c6b733500b3
--- /dev/null
+++ b/contrib/completion/zsh/_docker-compose
@@ -0,0 +1,421 @@
+#compdef docker-compose
+
+# Description
+# -----------
+# zsh completion for docker-compose
+# -------------------------------------------------------------------------
+# Authors
+# -------
+# * Steve Durrheimer
+# -------------------------------------------------------------------------
+# Inspiration
+# -----------
+# * @albers docker-compose bash completion script
+# * @felixr docker zsh completion script : https://github.com/felixr/docker-zsh-completion
+# -------------------------------------------------------------------------
+
+__docker-compose_q() {
+ docker-compose 2>/dev/null $compose_options "$@"
+}
+
+# All services defined in docker-compose.yml
+__docker-compose_all_services_in_compose_file() {
+ local already_selected
+ local -a services
+ already_selected=$(echo $words | tr " " "|")
+ __docker-compose_q ps --services "$@" \
+ | grep -Ev "^(${already_selected})$"
+}
+
+# All services, even those without an existing container
+__docker-compose_services_all() {
+ [[ $PREFIX = -* ]] && return 1
+ integer ret=1
+ services=$(__docker-compose_all_services_in_compose_file "$@")
+ _alternative "args:services:($services)" && ret=0
+
+ return ret
+}
+
+# All services that are defined by a Dockerfile reference
+__docker-compose_services_from_build() {
+ [[ $PREFIX = -* ]] && return 1
+ __docker-compose_services_all --filter source=build
+}
+
+# All services that are defined by an image
+__docker-compose_services_from_image() {
+ [[ $PREFIX = -* ]] && return 1
+ __docker-compose_services_all --filter source=image
+}
+
+__docker-compose_pausedservices() {
+ [[ $PREFIX = -* ]] && return 1
+ __docker-compose_services_all --filter status=paused
+}
+
+__docker-compose_stoppedservices() {
+ [[ $PREFIX = -* ]] && return 1
+ __docker-compose_services_all --filter status=stopped
+}
+
+__docker-compose_runningservices() {
+ [[ $PREFIX = -* ]] && return 1
+ __docker-compose_services_all --filter status=running
+}
+
+__docker-compose_services() {
+ [[ $PREFIX = -* ]] && return 1
+ __docker-compose_services_all
+}
+
+__docker-compose_caching_policy() {
+ oldp=( "$1"(Nmh+1) ) # 1 hour
+ (( $#oldp ))
+}
+
+__docker-compose_commands() {
+ local cache_policy
+
+ zstyle -s ":completion:${curcontext}:" cache-policy cache_policy
+ if [[ -z "$cache_policy" ]]; then
+ zstyle ":completion:${curcontext}:" cache-policy __docker-compose_caching_policy
+ fi
+
+ if ( [[ ${+_docker_compose_subcommands} -eq 0 ]] || _cache_invalid docker_compose_subcommands) \
+ && ! _retrieve_cache docker_compose_subcommands;
+ then
+ local -a lines
+ lines=(${(f)"$(_call_program commands docker-compose 2>&1)"})
+ _docker_compose_subcommands=(${${${lines[$((${lines[(i)Commands:]} + 1)),${lines[(I) *]}]}## #}/ ##/:})
+ (( $#_docker_compose_subcommands > 0 )) && _store_cache docker_compose_subcommands _docker_compose_subcommands
+ fi
+ _describe -t docker-compose-commands "docker-compose command" _docker_compose_subcommands
+}
+
+__docker-compose_subcommand() {
+ local opts_help opts_force_recreate opts_no_recreate opts_no_build opts_remove_orphans opts_timeout opts_no_color opts_no_deps
+
+ opts_help='(: -)--help[Print usage]'
+ opts_force_recreate="(--no-recreate)--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]"
+ opts_no_recreate="(--force-recreate)--no-recreate[If containers already exist, don't recreate them. Incompatible with --force-recreate.]"
+ opts_no_build="(--build)--no-build[Don't build an image, even if it's missing.]"
+ opts_remove_orphans="--remove-orphans[Remove containers for services not defined in the Compose file]"
+ opts_timeout=('(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: ")
+ opts_no_color='--no-color[Produce monochrome output.]'
+ opts_no_deps="--no-deps[Don't start linked services.]"
+
+ integer ret=1
+
+ case "$words[1]" in
+ (build)
+ _arguments \
+ $opts_help \
+ "*--build-arg=[Set build-time variables for one service.]:=: " \
+ '--force-rm[Always remove intermediate containers.]' \
+ '(--quiet -q)'{--quiet,-q}'[Curb build output]' \
+ '(--memory -m)'{--memory,-m}'[Memory limit for the build container.]' \
+ '--no-cache[Do not use cache when building the image.]' \
+ '--pull[Always attempt to pull a newer version of the image.]' \
+ '--compress[Compress the build context using gzip.]' \
+ '--parallel[Build images in parallel.]' \
+ '*:services:__docker-compose_services_from_build' && ret=0
+ ;;
+ (config)
+ _arguments \
+ $opts_help \
+ '(--quiet -q)'{--quiet,-q}"[Only validate the configuration, don't print anything.]" \
+ '--resolve-image-digests[Pin image tags to digests.]' \
+ '--services[Print the service names, one per line.]' \
+ '--volumes[Print the volume names, one per line.]' \
+ '--hash[Print the service config hash, one per line. Set "service1,service2" for a list of specified services.]' \ && ret=0
+ ;;
+ (create)
+ _arguments \
+ $opts_help \
+ $opts_force_recreate \
+ $opts_no_recreate \
+ $opts_no_build \
+ "(--no-build)--build[Build images before creating containers.]" \
+ '*:services:__docker-compose_services' && ret=0
+ ;;
+ (down)
+ _arguments \
+ $opts_help \
+ $opts_timeout \
+ "--rmi[Remove images. Type must be one of: 'all': Remove all images used by any service. 'local': Remove only images that don't have a custom tag set by the \`image\` field.]:type:(all local)" \
+ '(-v --volumes)'{-v,--volumes}"[Remove named volumes declared in the \`volumes\` section of the Compose file and anonymous volumes attached to containers.]" \
+ $opts_remove_orphans && ret=0
+ ;;
+ (events)
+ _arguments \
+ $opts_help \
+ '--json[Output events as a stream of json objects]' \
+ '*:services:__docker-compose_services' && ret=0
+ ;;
+ (exec)
+ _arguments \
+ $opts_help \
+ '-d[Detached mode: Run command in the background.]' \
+ '--privileged[Give extended privileges to the process.]' \
+ '(-u --user)'{-u,--user=}'[Run the command as this user.]:username:_users' \
+ '-T[Disable pseudo-tty allocation. By default `docker-compose exec` allocates a TTY.]' \
+ '--index=[Index of the container if there are multiple instances of a service \[default: 1\]]:index: ' \
+ '*'{-e,--env}'[KEY=VAL Set an environment variable (can be used multiple times)]:environment variable KEY=VAL: ' \
+ '(-w --workdir)'{-w,--workdir=}'[Working directory inside the container]:workdir: ' \
+ '(-):running services:__docker-compose_runningservices' \
+ '(-):command: _command_names -e' \
+ '*::arguments: _normal' && ret=0
+ ;;
+ (help)
+ _arguments ':subcommand:__docker-compose_commands' && ret=0
+ ;;
+ (images)
+ _arguments \
+ $opts_help \
+ '-q[Only display IDs]' \
+ '*:services:__docker-compose_services' && ret=0
+ ;;
+ (kill)
+ _arguments \
+ $opts_help \
+ '-s[SIGNAL to send to the container. Default signal is SIGKILL.]:signal:_signals' \
+ '*:running services:__docker-compose_runningservices' && ret=0
+ ;;
+ (logs)
+ _arguments \
+ $opts_help \
+ '(-f --follow)'{-f,--follow}'[Follow log output]' \
+ $opts_no_color \
+ '--tail=[Number of lines to show from the end of the logs for each container.]:number of lines: ' \
+ '(-t --timestamps)'{-t,--timestamps}'[Show timestamps]' \
+ '*:services:__docker-compose_services' && ret=0
+ ;;
+ (pause)
+ _arguments \
+ $opts_help \
+ '*:running services:__docker-compose_runningservices' && ret=0
+ ;;
+ (port)
+ _arguments \
+ $opts_help \
+ '--protocol=[tcp or udp \[default: tcp\]]:protocol:(tcp udp)' \
+ '--index=[index of the container if there are multiple instances of a service \[default: 1\]]:index: ' \
+ '1:running services:__docker-compose_runningservices' \
+ '2:port:_ports' && ret=0
+ ;;
+ (ps)
+ _arguments \
+ $opts_help \
+ '-q[Only display IDs]' \
+ '--filter KEY=VAL[Filter services by a property]:=:' \
+ '*:services:__docker-compose_services' && ret=0
+ ;;
+ (pull)
+ _arguments \
+ $opts_help \
+ '--ignore-pull-failures[Pull what it can and ignores images with pull failures.]' \
+ '--no-parallel[Disable parallel pulling]' \
+ '(-q --quiet)'{-q,--quiet}'[Pull without printing progress information]' \
+ '--include-deps[Also pull services declared as dependencies]' \
+ '*:services:__docker-compose_services_from_image' && ret=0
+ ;;
+ (push)
+ _arguments \
+ $opts_help \
+ '--ignore-push-failures[Push what it can and ignores images with push failures.]' \
+ '*:services:__docker-compose_services' && ret=0
+ ;;
+ (rm)
+ _arguments \
+ $opts_help \
+ '(-f --force)'{-f,--force}"[Don't ask to confirm removal]" \
+ '-v[Remove any anonymous volumes attached to containers]' \
+ '*:stopped services:__docker-compose_stoppedservices' && ret=0
+ ;;
+ (run)
+ _arguments \
+ $opts_help \
+ $opts_no_deps \
+ '-d[Detached mode: Run container in the background, print new container name.]' \
+ '*-e[KEY=VAL Set an environment variable (can be used multiple times)]:environment variable KEY=VAL: ' \
+ '*'{-l,--label}'[KEY=VAL Add or override a label (can be used multiple times)]:label KEY=VAL: ' \
+ '--entrypoint[Overwrite the entrypoint of the image.]:entry point: ' \
+ '--name=[Assign a name to the container]:name: ' \
+ '(-p --publish)'{-p,--publish=}"[Publish a container's port(s) to the host]" \
+ '--rm[Remove container after run. Ignored in detached mode.]' \
+ "--service-ports[Run command with the service's ports enabled and mapped to the host.]" \
+ '-T[Disable pseudo-tty allocation. By default `docker-compose run` allocates a TTY.]' \
+ '(-u --user)'{-u,--user=}'[Run as specified username or uid]:username or uid:_users' \
+ '(-v --volume)*'{-v,--volume=}'[Bind mount a volume]:volume: ' \
+ '(-w --workdir)'{-w,--workdir=}'[Working directory inside the container]:workdir: ' \
+ "--use-aliases[Use the services network aliases in the network(s) the container connects to]" \
+ '(-):services:__docker-compose_services' \
+ '(-):command: _command_names -e' \
+ '*::arguments: _normal' && ret=0
+ ;;
+ (scale)
+ _arguments \
+ $opts_help \
+ $opts_timeout \
+ '*:running services:__docker-compose_runningservices' && ret=0
+ ;;
+ (start)
+ _arguments \
+ $opts_help \
+ '*:stopped services:__docker-compose_stoppedservices' && ret=0
+ ;;
+ (stop|restart)
+ _arguments \
+ $opts_help \
+ $opts_timeout \
+ '*:running services:__docker-compose_runningservices' && ret=0
+ ;;
+ (top)
+ _arguments \
+ $opts_help \
+ '*:running services:__docker-compose_runningservices' && ret=0
+ ;;
+ (unpause)
+ _arguments \
+ $opts_help \
+ '*:paused services:__docker-compose_pausedservices' && ret=0
+ ;;
+ (up)
+ _arguments \
+ $opts_help \
+ '(--abort-on-container-exit)-d[Detached mode: Run containers in the background, print new container names. Incompatible with --abort-on-container-exit and --attach-dependencies.]' \
+ $opts_no_color \
+ $opts_no_deps \
+ $opts_force_recreate \
+ $opts_no_recreate \
+ $opts_no_build \
+ "(--no-build)--build[Build images before starting containers.]" \
+ "(-d)--abort-on-container-exit[Stops all containers if any container was stopped. Incompatible with -d.]" \
+ "(-d)--attach-dependencies[Attach to dependent containers. Incompatible with -d.]" \
+ '(-t --timeout)'{-t,--timeout}"[Use this timeout in seconds for container shutdown when attached or when containers are already running. (default: 10)]:seconds: " \
+ '--scale[SERVICE=NUM Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present.]:service scale SERVICE=NUM: ' \
+ '--exit-code-from=[Return the exit code of the selected service container. Implies --abort-on-container-exit]:service:__docker-compose_services' \
+ $opts_remove_orphans \
+ '*:services:__docker-compose_services' && ret=0
+ ;;
+ (version)
+ _arguments \
+ $opts_help \
+ "--short[Shows only Compose's version number.]" && ret=0
+ ;;
+ (*)
+ _message 'Unknown sub command' && ret=1
+ ;;
+ esac
+
+ return ret
+}
+
+_docker-compose() {
+ # Support for subservices, which allows for `compdef _docker docker-shell=_docker_containers`.
+ # Based on /usr/share/zsh/functions/Completion/Unix/_git without support for `ret`.
+ if [[ $service != docker-compose ]]; then
+ _call_function - _$service
+ return
+ fi
+
+ local curcontext="$curcontext" state line
+ integer ret=1
+ typeset -A opt_args
+
+ local file_description
+
+ if [[ -n ${words[(r)-f]} || -n ${words[(r)--file]} ]] ; then
+ file_description="Specify an override docker-compose file (default: docker-compose.override.yml)"
+ else
+ file_description="Specify an alternate docker-compose file (default: docker-compose.yml)"
+ fi
+
+ _arguments -C \
+ '(- :)'{-h,--help}'[Get help]' \
+ '*'{-f,--file}"[${file_description}]:file:_files -g '*.yml'" \
+ '(-p --project-name)'{-p,--project-name}'[Specify an alternate project name (default: directory name)]:project name:' \
+ '--env-file[Specify an alternate environment file (default: .env)]:env-file:_files' \
+ "--compatibility[If set, Compose will attempt to convert keys in v3 files to their non-Swarm equivalent]" \
+ '(- :)'{-v,--version}'[Print version and exit]' \
+ '--verbose[Show more output]' \
+ '--log-level=[Set log level]:level:(DEBUG INFO WARNING ERROR CRITICAL)' \
+ '--no-ansi[Do not print ANSI control characters]' \
+ '--ansi=[Control when to print ANSI control characters]:when:(never always auto)' \
+ '(-H --host)'{-H,--host}'[Daemon socket to connect to]:host:' \
+ '--tls[Use TLS; implied by --tlsverify]' \
+ '--tlscacert=[Trust certs signed only by this CA]:ca path:' \
+ '--tlscert=[Path to TLS certificate file]:client cert path:' \
+ '--tlskey=[Path to TLS key file]:tls key path:' \
+ '--tlsverify[Use TLS and verify the remote]' \
+ "--skip-hostname-check[Don't check the daemon's hostname against the name specified in the client certificate (for example if your docker host is an IP address)]" \
+ '(-): :->command' \
+ '(-)*:: :->option-or-argument' && ret=0
+
+ local -a relevant_compose_flags relevant_compose_repeatable_flags relevant_docker_flags compose_options docker_options
+
+ relevant_compose_flags=(
+ "--env-file"
+ "--file" "-f"
+ "--host" "-H"
+ "--project-name" "-p"
+ "--tls"
+ "--tlscacert"
+ "--tlscert"
+ "--tlskey"
+ "--tlsverify"
+ "--skip-hostname-check"
+ )
+
+ relevant_compose_repeatable_flags=(
+ "--file" "-f"
+ )
+
+ relevant_docker_flags=(
+ "--host" "-H"
+ "--tls"
+ "--tlscacert"
+ "--tlscert"
+ "--tlskey"
+ "--tlsverify"
+ )
+
+ for k in "${(@k)opt_args}"; do
+ if [[ -n "${relevant_docker_flags[(r)$k]}" ]]; then
+ docker_options+=$k
+ if [[ -n "$opt_args[$k]" ]]; then
+ docker_options+=$opt_args[$k]
+ fi
+ fi
+ if [[ -n "${relevant_compose_flags[(r)$k]}" ]]; then
+ if [[ -n "${relevant_compose_repeatable_flags[(r)$k]}" ]]; then
+ values=("${(@s/:/)opt_args[$k]}")
+ for value in $values
+ do
+ compose_options+=$k
+ compose_options+=$value
+ done
+ else
+ compose_options+=$k
+ if [[ -n "$opt_args[$k]" ]]; then
+ compose_options+=$opt_args[$k]
+ fi
+ fi
+ fi
+ done
+
+ case $state in
+ (command)
+ __docker-compose_commands && ret=0
+ ;;
+ (option-or-argument)
+ curcontext=${curcontext%:*:*}:docker-compose-$words[1]:
+ __docker-compose_subcommand && ret=0
+ ;;
+ esac
+
+ return ret
+}
+
+_docker-compose "$@"
diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py
new file mode 100755
index 00000000000..26511206c5f
--- /dev/null
+++ b/contrib/migration/migrate-compose-file-v1-to-v2.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python
+"""
+Migrate a Compose file from the V1 format in Compose 1.5 to the V2 format
+supported by Compose 1.6+
+"""
+import argparse
+import logging
+import sys
+
+import ruamel.yaml
+
+from compose.config.types import VolumeSpec
+
+
+log = logging.getLogger('migrate')
+
+
+def migrate(content):
+ data = ruamel.yaml.load(content, ruamel.yaml.RoundTripLoader)
+
+ service_names = data.keys()
+
+ for name, service in data.items():
+ warn_for_links(name, service)
+ warn_for_external_links(name, service)
+ rewrite_net(service, service_names)
+ rewrite_build(service)
+ rewrite_logging(service)
+ rewrite_volumes_from(service, service_names)
+
+ services = {name: data.pop(name) for name in data.keys()}
+
+ data['version'] = "2"
+ data['services'] = services
+ create_volumes_section(data)
+
+ return data
+
+
+def warn_for_links(name, service):
+ links = service.get('links')
+ if links:
+ example_service = links[0].partition(':')[0]
+ log.warning(
+ "Service {name} has links, which no longer create environment "
+ "variables such as {example_service_upper}_PORT. "
+ "If you are using those in your application code, you should "
+ "instead connect directly to the hostname, e.g. "
+ "'{example_service}'."
+ .format(name=name, example_service=example_service,
+ example_service_upper=example_service.upper()))
+
+
+def warn_for_external_links(name, service):
+ external_links = service.get('external_links')
+ if external_links:
+ log.warning(
+ "Service {name} has external_links: {ext}, which now work "
+ "slightly differently. In particular, two containers must be "
+ "connected to at least one network in common in order to "
+ "communicate, even if explicitly linked together.\n\n"
+ "Either connect the external container to your app's default "
+ "network, or connect both the external container and your "
+ "service's containers to a pre-existing network. See "
+ "https://docs.docker.com/compose/networking/ "
+ "for more on how to do this."
+ .format(name=name, ext=external_links))
+
+
+def rewrite_net(service, service_names):
+ if 'net' in service:
+ network_mode = service.pop('net')
+
+ # "container:" is now "service:"
+ if network_mode.startswith('container:'):
+ name = network_mode.partition(':')[2]
+ if name in service_names:
+ network_mode = 'service:{}'.format(name)
+
+ service['network_mode'] = network_mode
+
+
+def rewrite_build(service):
+ if 'dockerfile' in service:
+ service['build'] = {
+ 'context': service.pop('build'),
+ 'dockerfile': service.pop('dockerfile'),
+ }
+
+
+def rewrite_logging(service):
+ if 'log_driver' in service:
+ service['logging'] = {'driver': service.pop('log_driver')}
+ if 'log_opt' in service:
+ service['logging']['options'] = service.pop('log_opt')
+
+
+def rewrite_volumes_from(service, service_names):
+ for idx, volume_from in enumerate(service.get('volumes_from', [])):
+ if volume_from.split(':', 1)[0] not in service_names:
+ service['volumes_from'][idx] = 'container:%s' % volume_from
+
+
+def create_volumes_section(data):
+ named_volumes = get_named_volumes(data['services'])
+ if named_volumes:
+ log.warning(
+ "Named volumes ({names}) must be explicitly declared. Creating a "
+ "'volumes' section with declarations.\n\n"
+ "For backwards-compatibility, they've been declared as external. "
+ "If you don't mind the volume names being prefixed with the "
+ "project name, you can remove the 'external' option from each one."
+ .format(names=', '.join(list(named_volumes))))
+
+ data['volumes'] = named_volumes
+
+
+def get_named_volumes(services):
+ volume_specs = [
+ VolumeSpec.parse(volume)
+ for service in services.values()
+ for volume in service.get('volumes', [])
+ ]
+ names = {
+ spec.external
+ for spec in volume_specs
+ if spec.is_named_volume
+ }
+ return {name: {'external': True} for name in names}
+
+
+def write(stream, new_format, indent, width):
+ ruamel.yaml.dump(
+ new_format,
+ stream,
+ Dumper=ruamel.yaml.RoundTripDumper,
+ indent=indent,
+ width=width)
+
+
+def parse_opts(args):
+ parser = argparse.ArgumentParser()
+ parser.add_argument("filename", help="Compose file filename.")
+ parser.add_argument("-i", "--in-place", action='store_true')
+ parser.add_argument(
+ "--indent", type=int, default=2,
+ help="Number of spaces used to indent the output yaml.")
+ parser.add_argument(
+ "--width", type=int, default=80,
+ help="Number of spaces used as the output width.")
+ return parser.parse_args()
+
+
+def main(args):
+ logging.basicConfig(format='\033[33m%(levelname)s:\033[37m %(message)s\033[0m\n')
+
+ opts = parse_opts(args)
+
+ with open(opts.filename) as fh:
+ new_format = migrate(fh.read())
+
+ if opts.in_place:
+ output = open(opts.filename, 'w')
+ else:
+ output = sys.stdout
+ write(output, new_format, opts.indent, opts.width)
+
+
+if __name__ == "__main__":
+ main(sys.argv)
diff --git a/contrib/update/update-docker-compose.ps1 b/contrib/update/update-docker-compose.ps1
new file mode 100644
index 00000000000..bb033b46467
--- /dev/null
+++ b/contrib/update/update-docker-compose.ps1
@@ -0,0 +1,116 @@
+# Self-elevate the script if required
+# http://www.expta.com/2017/03/how-to-self-elevate-powershell-script.html
+If (-Not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) {
+ If ([int](Get-CimInstance -Class Win32_OperatingSystem | Select-Object -ExpandProperty BuildNumber) -ge 6000) {
+ $CommandLine = "-File `"" + $MyInvocation.MyCommand.Path + "`" " + $MyInvocation.UnboundArguments
+ Start-Process -FilePath PowerShell.exe -Verb Runas -ArgumentList $CommandLine
+ Exit
+ }
+}
+
+$SectionSeparator = "--------------------------------------------------"
+
+# Update docker-compose if required
+Function UpdateDockerCompose() {
+ Write-Host "Updating docker-compose if required..."
+ Write-Host $SectionSeparator
+
+ # Find the installed docker-compose.exe location
+ Try {
+ $DockerComposePath = Get-Command docker-compose.exe -ErrorAction Stop | `
+ Select-Object -First 1 -ExpandProperty Definition
+ }
+ Catch {
+ Write-Host "Error: Could not find path to docker-compose.exe" `
+ -ForegroundColor Red
+ Return $false
+ }
+
+ # Prefer/enable TLS 1.2
+ # https://stackoverflow.com/a/48030563/153079
+ [Net.ServicePointManager]::SecurityProtocol = "tls12, tls11, tls"
+
+ # Query for the latest release version
+ Try {
+ $URI = "https://api.github.com/repos/docker/compose/releases/latest"
+ $LatestComposeVersion = [System.Version](Invoke-RestMethod -Method Get -Uri $URI).tag_name
+ }
+ Catch {
+ Write-Host "Error: Query for the latest docker-compose release version failed" `
+ -ForegroundColor Red
+ Return $false
+ }
+
+ # Check the installed version and compare with latest release
+ $UpdateDockerCompose = $false
+ Try {
+ $InstalledComposeVersion = `
+ [System.Version]((docker-compose.exe version --short) | Out-String)
+
+ If ($InstalledComposeVersion -eq $LatestComposeVersion) {
+ Write-Host ("Installed docker-compose version ({0}) same as latest ({1})." `
+ -f $InstalledComposeVersion.ToString(), $LatestComposeVersion.ToString())
+ }
+ ElseIf ($InstalledComposeVersion -lt $LatestComposeVersion) {
+ Write-Host ("Installed docker-compose version ({0}) older than latest ({1})." `
+ -f $InstalledComposeVersion.ToString(), $LatestComposeVersion.ToString())
+ $UpdateDockerCompose = $true
+ }
+ Else {
+ Write-Host ("Installed docker-compose version ({0}) newer than latest ({1})." `
+ -f $InstalledComposeVersion.ToString(), $LatestComposeVersion.ToString()) `
+ -ForegroundColor Yellow
+ }
+ }
+ Catch {
+ Write-Host `
+ "Warning: Couldn't get docker-compose version, assuming an update is required..." `
+ -ForegroundColor Yellow
+ $UpdateDockerCompose = $true
+ }
+
+ If (-Not $UpdateDockerCompose) {
+ # Nothing to do!
+ Return $false
+ }
+
+ # Download the latest version of docker-compose.exe
+ Try {
+ $RemoteFileName = "docker-compose-Windows-x86_64.exe"
+ $URI = ("https://github.com/docker/compose/releases/download/{0}/{1}" `
+ -f $LatestComposeVersion.ToString(), $RemoteFileName)
+ Invoke-WebRequest -UseBasicParsing -Uri $URI `
+ -OutFile $DockerComposePath
+ Return $true
+ }
+ Catch {
+ Write-Host ("Error: Failed to download the latest version of docker-compose`n{0}" `
+ -f $_.Exception.Message) -ForegroundColor Red
+ Return $false
+ }
+
+ Return $false
+}
+
+If (UpdateDockerCompose) {
+ Write-Host "Updated to latest-version of docker-compose, running update again to verify.`n"
+ If (UpdateDockerCompose) {
+ Write-Host "Error: Should not have updated twice." -ForegroundColor Red
+ }
+}
+
+# Assuming elevation popped up a new powershell window, pause so the user can see what happened
+# https://stackoverflow.com/a/22362868/153079
+Function Pause ($Message = "Press any key to continue . . . ") {
+ If ((Test-Path variable:psISE) -and $psISE) {
+ $Shell = New-Object -ComObject "WScript.Shell"
+ $Shell.Popup("Click OK to continue.", 0, "Script Paused", 0)
+ }
+ Else {
+ Write-Host "`n$SectionSeparator"
+ Write-Host -NoNewline $Message
+ [void][System.Console]::ReadKey($true)
+ Write-Host
+ }
+}
+Pause
diff --git a/desktop-module/module-metadata.json b/desktop-module/module-metadata.json
deleted file mode 100644
index 721f7789008..00000000000
--- a/desktop-module/module-metadata.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "version": 1,
- "content": [
- {
- "type": "cli-plugin",
- "name": "compose"
- }
- ]
-}
diff --git a/docker-bake.hcl b/docker-bake.hcl
deleted file mode 100644
index 72dcc84ed71..00000000000
--- a/docker-bake.hcl
+++ /dev/null
@@ -1,162 +0,0 @@
-// Copyright 2022 Docker Compose CLI authors
-//
-// 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
-//
-// http://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.
-
-variable "GO_VERSION" {
- # default ARG value set in Dockerfile
- default = null
-}
-
-variable "BUILD_TAGS" {
- default = "e2e"
-}
-
-variable "DOCS_FORMATS" {
- default = "md,yaml"
-}
-
-# Defines the output folder to override the default behavior.
-# See Makefile for details, this is generally only useful for
-# the packaging scripts and care should be taken to not break
-# them.
-variable "DESTDIR" {
- default = ""
-}
-function "outdir" {
- params = [defaultdir]
- result = DESTDIR != "" ? DESTDIR : "${defaultdir}"
-}
-
-# Special target: https://github.com/docker/metadata-action#bake-definition
-target "meta-helper" {}
-
-target "_common" {
- args = {
- GO_VERSION = GO_VERSION
- BUILD_TAGS = BUILD_TAGS
- BUILDKIT_CONTEXT_KEEP_GIT_DIR = 1
- }
-}
-
-group "default" {
- targets = ["binary"]
-}
-
-group "validate" {
- targets = ["lint", "vendor-validate", "license-validate"]
-}
-
-target "lint" {
- inherits = ["_common"]
- target = "lint"
- output = ["type=cacheonly"]
-}
-
-target "license-validate" {
- target = "license-validate"
- output = ["type=cacheonly"]
-}
-
-target "license-update" {
- target = "license-update"
- output = ["."]
-}
-
-target "vendor-validate" {
- inherits = ["_common"]
- target = "vendor-validate"
- output = ["type=cacheonly"]
-}
-
-target "vendor-update" {
- inherits = ["_common"]
- target = "vendor-update"
- output = ["."]
-}
-
-target "test" {
- inherits = ["_common"]
- target = "test-coverage"
- output = [outdir("./bin/coverage/unit")]
-}
-
-target "binary-with-coverage" {
- inherits = ["_common"]
- target = "binary"
- args = {
- BUILD_FLAGS = "-cover -covermode=atomic"
- }
- output = [outdir("./bin/build")]
- platforms = ["local"]
-}
-
-target "binary" {
- inherits = ["_common"]
- target = "binary"
- output = [outdir("./bin/build")]
- platforms = ["local"]
-}
-
-target "binary-cross" {
- inherits = ["binary"]
- platforms = [
- "darwin/amd64",
- "darwin/arm64",
- "linux/amd64",
- "linux/arm/v6",
- "linux/arm/v7",
- "linux/arm64",
- "linux/ppc64le",
- "linux/riscv64",
- "linux/s390x",
- "windows/amd64",
- "windows/arm64"
- ]
-}
-
-target "release" {
- inherits = ["binary-cross"]
- target = "release"
- output = [outdir("./bin/release")]
-}
-
-target "docs-validate" {
- inherits = ["_common"]
- target = "docs-validate"
- output = ["type=cacheonly"]
-}
-
-target "docs-update" {
- inherits = ["_common"]
- target = "docs-update"
- output = ["./docs"]
-}
-
-target "image-cross" {
- inherits = ["meta-helper", "binary-cross"]
- output = ["type=image"]
-}
-
-target "image-module-cross" {
- inherits = ["meta-helper", "binary-cross"]
- target = "module"
- output = ["type=image"]
- platforms = [
- "darwin/amd64",
- "darwin/arm64",
- "linux/amd64",
- "linux/arm64",
- "windows/amd64",
- "windows/arm64",
- ]
-}
diff --git a/docker-compose-entrypoint.sh b/docker-compose-entrypoint.sh
new file mode 100755
index 00000000000..84436fa0778
--- /dev/null
+++ b/docker-compose-entrypoint.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+set -e
+
+# first arg is `-f` or `--some-option`
+if [ "${1#-}" != "$1" ]; then
+ set -- docker-compose "$@"
+fi
+
+# if our command is a valid Docker subcommand, let's invoke it through Docker instead
+# (this allows for "docker run docker ps", etc)
+if docker-compose help "$1" > /dev/null 2>&1; then
+ set -- docker-compose "$@"
+fi
+
+# if we have "--link some-docker:docker" and not DOCKER_HOST, let's set DOCKER_HOST automatically
+if [ -z "$DOCKER_HOST" -a "$DOCKER_PORT_2375_TCP" ]; then
+ export DOCKER_HOST='tcp://docker:2375'
+fi
+
+exec "$@"
diff --git a/docker-compose.spec b/docker-compose.spec
new file mode 100644
index 00000000000..0c2fa3dec9f
--- /dev/null
+++ b/docker-compose.spec
@@ -0,0 +1,42 @@
+# -*- mode: python -*-
+
+block_cipher = None
+
+a = Analysis(['bin/docker-compose'],
+ pathex=['.'],
+ hiddenimports=[],
+ hookspath=None,
+ runtime_hooks=None,
+ cipher=block_cipher)
+
+pyz = PYZ(a.pure, cipher=block_cipher)
+
+exe = EXE(pyz,
+ a.scripts,
+ a.binaries,
+ a.zipfiles,
+ a.datas,
+ [
+ (
+ 'compose/config/config_schema_v1.json',
+ 'compose/config/config_schema_v1.json',
+ 'DATA'
+ ),
+ (
+ 'compose/config/compose_spec.json',
+ 'compose/config/compose_spec.json',
+ 'DATA'
+ ),
+ (
+ 'compose/GITSHA',
+ 'compose/GITSHA',
+ 'DATA'
+ )
+ ],
+
+ name='docker-compose',
+ debug=False,
+ strip=None,
+ upx=True,
+ console=True,
+ bootloader_ignore_signals=True)
diff --git a/docker-compose_darwin.spec b/docker-compose_darwin.spec
new file mode 100644
index 00000000000..24889475990
--- /dev/null
+++ b/docker-compose_darwin.spec
@@ -0,0 +1,48 @@
+# -*- mode: python -*-
+
+block_cipher = None
+
+a = Analysis(['bin/docker-compose'],
+ pathex=['.'],
+ hiddenimports=[],
+ hookspath=[],
+ runtime_hooks=[],
+ cipher=block_cipher)
+
+pyz = PYZ(a.pure, a.zipped_data,
+ cipher=block_cipher)
+
+exe = EXE(pyz,
+ a.scripts,
+ exclude_binaries=True,
+ name='docker-compose',
+ debug=False,
+ strip=False,
+ upx=True,
+ console=True,
+ bootloader_ignore_signals=True)
+coll = COLLECT(exe,
+ a.binaries,
+ a.zipfiles,
+ a.datas,
+ [
+ (
+ 'compose/config/config_schema_v1.json',
+ 'compose/config/config_schema_v1.json',
+ 'DATA'
+ ),
+ (
+ 'compose/config/compose_spec.json',
+ 'compose/config/compose_spec.json',
+ 'DATA'
+ ),
+ (
+ 'compose/GITSHA',
+ 'compose/GITSHA',
+ 'DATA'
+ )
+ ],
+ strip=False,
+ upx=True,
+ upx_exclude=[],
+ name='docker-compose-Darwin-x86_64')
diff --git a/docs/Compose file reference (legacy)/compose-versioning.md b/docs/Compose file reference (legacy)/compose-versioning.md
new file mode 100644
index 00000000000..63acae76ec0
--- /dev/null
+++ b/docs/Compose file reference (legacy)/compose-versioning.md
@@ -0,0 +1,474 @@
+There are three legacy versions of the Compose file format:
+
+- Version 1. This is specified by omitting a `version` key at the root of the YAML.
+
+- Version 2.x. This is specified with a `version: '2'` or `version: '2.1'`, etc., entry at the root of the YAML.
+
+- Version 3.x, designed to be cross-compatible between Compose and the Docker Engine's
+[swarm mode](https://docs.docker.com/engine/swarm/). This is specified with a `version: '3'` or `version: '3.1'`, etc., entry at the root of the YAML.
+
+The latest and recommended version of the Compose file format is defined by the [Compose Specification](https://docs.docker.com/compose/compose-file/). This format merges the 2.x and 3.x versions and is implemented by **Compose 1.27.0+**.
+
+
+> **Note**
+>
+> If you're using [multiple Compose files](https://docs.docker.com/compose/multiple-compose-files/) or
+> [extending services](https://docs.docker.com/compose/multiple-compose-files/extends/),
+> each file must be of the same version - you cannot, for example,
+> mix version 1 and 2 in a single project.
+
+Several things differ depending on which version you use:
+
+- The structure and permitted configuration keys
+- The minimum Docker Engine version you must be running
+- Compose's behaviour with regards to networking
+
+These differences are explained below.
+
+### Version 2
+
+Compose files using the version 2 syntax must indicate the version number at
+the root of the document. All [services](compose-file-v2.md#service-configuration-reference)
+must be declared under the `services` key.
+
+Version 2 files are supported by **Compose 1.6.0+** and require a Docker Engine
+of version **1.10.0+**.
+
+Named [volumes](compose-file-v2.md#volume-configuration-reference) can be declared under the
+`volumes` key, and [networks](compose-file-v2.md#network-configuration-reference) can be declared
+under the `networks` key.
+
+By default, every container joins an application-wide default network, and is
+discoverable at a hostname that's the same as the service name. This means
+[links](compose-file-v2.md#links) are largely unnecessary. For more details, see
+[Networking in Compose](https://docs.docker.com/compose/networking/).
+
+> **Note**
+>
+> With Compose version 2, when specifying the Compose file version to use, make sure to
+> specify both the _major_ and _minor_ numbers. If no minor version is given,
+> `0` is used by default and not the latest minor version. As a result, features added in later versions will not be supported. For example:
+>
+> ```yaml
+> version: "2"
+> ```
+>
+> is equivalent to:
+>
+> ```yaml
+> version: "2.0"
+> ```
+
+Simple example:
+
+ version: "{{% param "compose_file_v2" %}}"
+ services:
+ web:
+ build: .
+ ports:
+ - "8000:5000"
+ volumes:
+ - .:/code
+ redis:
+ image: redis
+
+A more extended example, defining volumes and networks:
+
+ version: "{{% param "compose_file_v2" %}}"
+ services:
+ web:
+ build: .
+ ports:
+ - "8000:5000"
+ volumes:
+ - .:/code
+ networks:
+ - front-tier
+ - back-tier
+ redis:
+ image: redis
+ volumes:
+ - redis-data:/var/lib/redis
+ networks:
+ - back-tier
+ volumes:
+ redis-data:
+ driver: local
+ networks:
+ front-tier:
+ driver: bridge
+ back-tier:
+ driver: bridge
+
+Several other options were added to support networking, such as:
+
+* [`aliases`](compose-file-v2.md#aliases)
+
+* The [`depends_on`](compose-file-v2.md#depends_on) option can be used in place of links to indicate dependencies
+between services and startup order.
+
+ version: "{{% param "compose_file_v2" %}}"
+ services:
+ web:
+ build: .
+ depends_on:
+ - db
+ - redis
+ redis:
+ image: redis
+ db:
+ image: postgres
+
+* [`ipv4_address`, `ipv6_address`](compose-file-v2.md#ipv4_address-ipv6_address)
+
+[Variable substitution](compose-file-v2.md#variable-substitution) also was added in Version 2.
+
+### Version 2.1
+
+An upgrade of [version 2](#version-2) that introduces new parameters only
+available with Docker Engine version **1.12.0+**. Version 2.1 files are
+supported by **Compose 1.9.0+**.
+
+Introduces the following additional parameters:
+
+- [`link_local_ips`](compose-file-v2.md#link_local_ips)
+- [`isolation`](compose-file-v2.md#isolation-1) in build configurations and
+ service definitions
+- `labels` for [volumes](compose-file-v2.md#volume-configuration-reference),
+ [networks](compose-file-v2.md#network-configuration-reference), and
+ [build](compose-file-v3.md#build)
+- `name` for [volumes](compose-file-v2.md#volume-configuration-reference)
+- [`userns_mode`](compose-file-v2.md#userns_mode)
+- [`healthcheck`](compose-file-v2.md#healthcheck)
+- [`sysctls`](compose-file-v2.md#sysctls)
+- [`pids_limit`](compose-file-v2.md#pids_limit)
+- [`oom_kill_disable`](compose-file-v2.md#cpu-and-other-resources)
+- [`cpu_period`](compose-file-v2.md#cpu-and-other-resources)
+
+### Version 2.2
+
+An upgrade of [version 2.1](#version-21) that introduces new parameters only
+available with Docker Engine version **1.13.0+**. Version 2.2 files are
+supported by **Compose 1.13.0+**. This version also allows you to specify
+default scale numbers inside the service's configuration.
+
+Introduces the following additional parameters:
+
+- [`init`](compose-file-v2.md#init)
+- [`scale`](compose-file-v2.md#scale)
+- [`cpu_rt_runtime` and `cpu_rt_period`](compose-file-v2.md#cpu_rt_runtime-cpu_rt_period)
+- [`network`](compose-file-v2.md#network) for [build configurations](compose-file-v2.md#build)
+
+### Version 2.3
+
+An upgrade of [version 2.2](#version-22) that introduces new parameters only
+available with Docker Engine version **17.06.0+**. Version 2.3 files are
+supported by **Compose 1.16.0+**.
+
+Introduces the following additional parameters:
+
+- [`target`](compose-file-v2.md#target), [`extra_hosts`](compose-file-v2.md#extra_hosts-1) and
+ [`shm_size`](compose-file-v2.md#shm_size) for [build configurations](compose-file-v2.md#build)
+- `start_period` for [`healthchecks`](compose-file-v2.md#healthcheck)
+- ["Long syntax" for volumes](compose-file-v2.md#long-syntax)
+- [`runtime`](compose-file-v2.md#runtime) for service definitions
+- [`device_cgroup_rules`](compose-file-v2.md#device_cgroup_rules)
+
+### Version 2.4
+
+An upgrade of [version 2.3](#version-23) that introduces new parameters only
+available with Docker Engine version **17.12.0+**. Version 2.4 files are
+supported by **Compose 1.21.0+**.
+
+Introduces the following additional parameters:
+
+- [`platform`](compose-file-v2.md#platform) for service definitions
+- Support for extension fields at the root of service, network, and volume
+ definitions
+
+### Version 3
+
+Designed to be cross-compatible between Compose and the Docker Engine's
+[swarm mode](/engine/swarm/), version 3 removes several options and adds
+several more.
+
+- Removed: `volume_driver`, `volumes_from`, `cpu_shares`, `cpu_quota`,
+ `cpuset`, `mem_limit`, `memswap_limit`, `extends`, `group_add`. See
+ the [upgrading](#upgrading) guide for how to migrate away from these.
+
+- Added: [deploy](compose-file-v3.md#deploy)
+
+If only the major version is given (`version: '3'`),
+the latest minor version is used by default.
+
+### Version 3.1
+
+An upgrade of [version 3](#version-3) that introduces new parameters only
+available with Docker Engine version **1.13.1+**, and higher.
+
+Introduces the following additional parameters:
+
+- [`secrets`](compose-file-v3.md#secrets)
+
+### Version 3.2
+
+An upgrade of [version 3](#version-3) that introduces new parameters only
+available with Docker Engine version **17.04.0+**, and higher.
+
+Introduces the following additional parameters:
+
+- [`cache_from`](compose-file-v3.md#cache_from) in [build configurations](compose-file-v3.md#build)
+- Long syntax for [ports](compose-file-v3.md#ports) and [volume mounts](compose-file-v3.md#volumes)
+- [`attachable`](compose-file-v3.md#attachable) network driver option
+- [deploy `endpoint_mode`](compose-file-v3.md#endpoint_mode)
+- [deploy placement `preference`](compose-file-v3.md#placement)
+
+### Version 3.3
+
+An upgrade of [version 3](#version-3) that introduces new parameters only
+available with Docker Engine version **17.06.0+**, and higher.
+
+Introduces the following additional parameters:
+
+- [build `labels`](compose-file-v3.md#build)
+- [`credential_spec`](compose-file-v3.md#credential_spec)
+- [`configs`](compose-file-v3.md#configs)
+
+### Version 3.4
+
+An upgrade of [version 3](#version-3) that introduces new parameters. It is
+only available with Docker Engine version **17.09.0** and higher.
+
+Introduces the following additional parameters:
+
+- [`target`](compose-file-v3.md#target) and [`network`](compose-file-v3.md#network) in
+ [build configurations](compose-file-v3.md#build)
+- `start_period` for [`healthchecks`](compose-file-v3.md#healthcheck)
+- `order` for [update configurations](compose-file-v3.md#update_config)
+- `name` for [volumes](compose-file-v3.md#volume-configuration-reference)
+
+### Version 3.5
+
+An upgrade of [version 3](#version-3) that introduces new parameters. It is
+only available with Docker Engine version **17.12.0** and higher.
+
+Introduces the following additional parameters:
+
+- [`isolation`](compose-file-v3.md#isolation) in service definitions
+- `name` for networks, secrets and configs
+- `shm_size` in [build configurations](compose-file-v3.md#build)
+
+### Version 3.6
+
+An upgrade of [version 3](#version-3) that introduces new parameters. It is
+only available with Docker Engine version **18.02.0** and higher.
+
+Introduces the following additional parameters:
+
+- [`tmpfs` size](compose-file-v3.md#long-syntax-3) for `tmpfs`-type mounts
+
+### Version 3.7
+
+An upgrade of [version 3](#version-3) that introduces new parameters. It is
+only available with Docker Engine version **18.06.0** and higher.
+
+Introduces the following additional parameters:
+
+- [`init`](compose-file-v3.md#init) in service definitions
+- [`rollback_config`](compose-file-v3.md#rollback_config) in deploy configurations
+- Support for extension fields at the root of service, network, volume, secret
+ and config definitions
+
+### Version 3.8
+
+An upgrade of [version 3](#version-3) that introduces new parameters. It is
+only available with Docker Engine version **19.03.0** and higher.
+
+Introduces the following additional parameters:
+
+- [`max_replicas_per_node`](compose-file-v3.md#max_replicas_per_node) in placement
+ configurations
+- `template_driver` option for [config](compose-file-v3.md#configs-configuration-reference)
+ and [secret](compose-file-v3.md#secrets-configuration-reference) configurations. This
+ option is only supported when deploying swarm services using
+ `docker stack deploy`.
+- `driver` and `driver_opts` option for [secret](compose-file-v3.md#secrets-configuration-reference)
+ configurations. This option is only supported when deploying swarm services
+ using `docker stack deploy`.
+
+### Version 1 (Deprecated)
+
+Compose versions below 1.6.x are
+
+Compose files that do not declare a version are considered "version 1". In those
+files, all the [services](compose-file-v3.md#service-configuration-reference) are
+declared at the root of the document.
+
+Version 1 is supported by Compose up to 1.6.x** and has been deprecated.
+
+Version 1 files cannot declare named
+[volumes](compose-file-v3.md#volume-configuration-reference), [networks](compose-file-v3.md#network-configuration-reference) or
+[build arguments](compose-file-v3.md#args).
+
+Compose does not take advantage of [networking](https://docs.docker.com/compose/networking/) when you
+use version 1: every container is placed on the default `bridge` network and is
+reachable from every other container at its IP address. You need to use
+`links` to enable discovery between containers.
+
+Example:
+
+ web:
+ build: .
+ ports:
+ - "8000:5000"
+ volumes:
+ - .:/code
+ links:
+ - redis
+ redis:
+ image: redis
+
+## Upgrading
+
+### Version 2.x to 3.x
+
+Between versions 2.x and 3.x, the structure of the Compose file is the same, but
+several options have been removed:
+
+- `volume_driver`: Instead of setting the volume driver on the service, define
+ a volume using the
+ [top-level `volumes` option](compose-file-v3.md#volume-configuration-reference)
+ and specify the driver there.
+
+ version: "3.8"
+ services:
+ db:
+ image: postgres
+ volumes:
+ - data:/var/lib/postgresql/data
+ volumes:
+ data:
+ driver: mydriver
+
+- `volumes_from`: To share a volume between services, define it using the
+ [top-level `volumes` option](compose-file-v3.md#volume-configuration-reference)
+ and reference it from each service that shares it using the
+ [service-level `volumes` option](compose-file-v3.md#driver).
+
+- `cpu_shares`, `cpu_quota`, `cpuset`, `mem_limit`, `memswap_limit`: These
+ have been replaced by the [resources](compose-file-v3.md#resources) key under
+ `deploy`. `deploy` configuration only takes effect when using
+ `docker stack deploy`, and is ignored by `docker-compose`.
+
+- `extends`: This option has been removed for `version: "3.x"` Compose files.
+ For more information on `extends`, see
+ [Extending services](https://docs.docker.com/compose/multiple-compose-files/extends/).
+- `group_add`: This option has been removed for `version: "3.x"` Compose files.
+- `pids_limit`: This option has not been introduced in `version: "3.x"` Compose files.
+- `link_local_ips` in `networks`: This option has not been introduced in
+ `version: "3.x"` Compose files.
+
+#### Compatibility mode
+
+`docker-compose` 1.20.0 introduces a new `--compatibility` flag designed to
+help developers transition to version 3 more easily. When enabled,
+`docker-compose` reads the `deploy` section of each service's definition and
+attempts to translate it into the equivalent version 2 parameter. Currently,
+the following deploy keys are translated:
+
+- [resources](compose-file-v3.md#resources) limits and memory reservations
+- [replicas](compose-file-v3.md#replicas)
+- [restart_policy](compose-file-v3.md#restart_policy) `condition` and `max_attempts`
+
+All other keys are ignored and produce a warning if present. You can review
+the configuration that will be used to deploy by using the `--compatibility`
+flag with the `config` command.
+
+> Do not use this in production
+>
+> We recommend against using `--compatibility` mode in production. The
+> resulting configuration is only an approximate using non-Swarm mode
+> properties, it may produce unexpected results.
+
+### Version 1 to 2.x
+
+In the majority of cases, moving from version 1 to 2 is a very simple process:
+
+1. Indent the whole file by one level and put a `services:` key at the top.
+2. Add a `version: '2'` line at the top of the file.
+
+It's more complicated if you're using particular configuration features:
+
+- `dockerfile`: This now lives under the `build` key:
+
+ build:
+ context: .
+ dockerfile: Dockerfile-alternate
+
+- `log_driver`, `log_opt`: These now live under the `logging` key:
+
+ logging:
+ driver: syslog
+ options:
+ syslog-address: "tcp://192.168.0.42:123"
+
+- `links` with environment variables: environment variables created by
+ links, such as `CONTAINERNAME_PORT`, ` have been deprecated for some time. In the new Docker network system,
+ they have been removed. You should either connect directly to the
+ appropriate hostname or set the relevant environment variable yourself,
+ using the link hostname:
+
+ web:
+ links:
+ - db
+ environment:
+ - DB_PORT=tcp://db:5432
+
+- `external_links`: Compose uses Docker networks when running version 2
+ projects, so links behave slightly differently. In particular, two
+ containers must be connected to at least one network in common in order to
+ communicate, even if explicitly linked together.
+
+ Either connect the external container to your app's
+ [default network](https://docs.docker.com/compose/networking/), or connect both the external container and
+ your service's containers to an
+ [external network](https://docs.docker.com/compose/networking/).
+
+- `net`: This is now replaced by [network_mode](compose-file-v3.md#network_mode):
+
+ net: host -> network_mode: host
+ net: bridge -> network_mode: bridge
+ net: none -> network_mode: none
+
+ If you're using `net: "container:[service name]"`, you must now use
+ `network_mode: "service:[service name]"` instead.
+
+ net: "container:web" -> network_mode: "service:web"
+
+ If you're using `net: "container:[container name/id]"`, the value does not
+ need to change.
+
+ net: "container:cont-name" -> network_mode: "container:cont-name"
+ net: "container:abc12345" -> network_mode: "container:abc12345"
+
+- `volumes` with named volumes: these must now be explicitly declared in a
+ top-level `volumes` section of your Compose file. If a service mounts a
+ named volume called `data`, you must declare a `data` volume in your
+ top-level `volumes` section. The whole file might look like this:
+
+ version: "{{% param "compose_file_v2" %}}"
+ services:
+ db:
+ image: postgres
+ volumes:
+ - data:/var/lib/postgresql/data
+ volumes:
+ data: {}
+
+ By default, Compose creates a volume whose name is prefixed with your
+ project name. If you want it to just be called `data`, declare it as
+ external:
+
+ volumes:
+ data:
+ external: true
diff --git a/docs/Compose file reference (legacy)/version-2.md b/docs/Compose file reference (legacy)/version-2.md
new file mode 100644
index 00000000000..0d3eb6c0648
--- /dev/null
+++ b/docs/Compose file reference (legacy)/version-2.md
@@ -0,0 +1,2136 @@
+This page describes version 2 of the Compose file format.
+
+This is legacy content. The latest Compose file format is defined by the [Compose Specification](https://docs.docker.com/compose/compose-file/) and is implemented by Docker Compose **1.27.0+**.
+
+## Compose and Docker compatibility matrix
+
+There are several versions of the Compose file format – 1, 2, 2.x, and 3.x. The
+table below is a quick look. For full details on what each version includes and
+how to upgrade, see **[About versions and upgrading](compose-versioning.md)**.
+
+This table shows which Compose file versions support specific Docker releases.
+
+| **Compose file format** | **Docker Engine release** |
+| ------------------- | ------------------ |
+| Compose specification | 19.03.0+ |
+| 3.8 | 19.03.0+ |
+| 3.7 | 18.06.0+ |
+| 3.6 | 18.02.0+ |
+| 3.5 | 17.12.0+ |
+| 3.4 | 17.09.0+ |
+| 3.3 | 17.06.0+ |
+| 3.2 | 17.04.0+ |
+| 3.1 | 1.13.1+ |
+| 3.0 | 1.13.0+ |
+| 2.4 | 17.12.0+ |
+| 2.3 | 17.06.0+ |
+| 2.2 | 1.13.0+ |
+| 2.1 | 1.12.0+ |
+| 2.0 | 1.10.0+ |
+
+In addition to Compose file format versions shown in the table, the Compose
+itself is on a release schedule, as shown in [Compose
+releases](https://github.com/docker/compose/releases/), but file format versions
+do not necessarily increment with each release. For example, Compose file format
+3.0 was first introduced in [Compose release
+1.10.0](https://github.com/docker/compose/releases/tag/1.10.0), and versioned
+gradually in subsequent releases.
+
+## Service configuration reference
+
+The Compose file is a [YAML](https://yaml.org) file defining
+[services](#service-configuration-reference),
+[networks](#network-configuration-reference) and
+[volumes](#volume-configuration-reference).
+The default path for a Compose file is `./docker-compose.yml`.
+
+> **Tip**: You can use either a `.yml` or `.yaml` extension for this file.
+> They both work.
+
+A service definition contains configuration that is applied to each
+container started for that service, much like passing command-line parameters to
+`docker run`. Likewise, network and volume definitions are analogous to
+`docker network create` and `docker volume create`.
+
+As with `docker run`, options specified in the Dockerfile, such as `CMD`,
+`EXPOSE`, `VOLUME`, `ENV`, are respected by default - you don't need to
+specify them again in `docker-compose.yml`.
+
+You can use environment variables in configuration values with a Bash-like
+`${VARIABLE}` syntax - see [variable substitution](#variable-substitution) for
+full details.
+
+This section contains a list of all configuration options supported by a service
+definition in version 2.
+
+### blkio_config
+
+A set of configuration options to set block IO limits for this service.
+
+ version: "{{% param "compose_file_v2" %}}"
+ services:
+ foo:
+ image: busybox
+ blkio_config:
+ weight: 300
+ weight_device:
+ - path: /dev/sda
+ weight: 400
+ device_read_bps:
+ - path: /dev/sdb
+ rate: '12mb'
+ device_read_iops:
+ - path: /dev/sdb
+ rate: 120
+ device_write_bps:
+ - path: /dev/sdb
+ rate: '1024k'
+ device_write_iops:
+ - path: /dev/sdb
+ rate: 30
+
+#### device_read_bps, device_write_bps
+
+Set a limit in bytes per second for read / write operations on a given device.
+Each item in the list must have two keys:
+
+* `path`, defining the symbolic path to the affected device
+* `rate`, either as an integer value representing the number of bytes or as
+ a string expressing a [byte value](#specifying-byte-values).
+
+#### device_read_iops, device_write_iops
+
+Set a limit in operations per second for read / write operations on a given
+device. Each item in the list must have two keys:
+
+* `path`, defining the symbolic path to the affected device
+* `rate`, as an integer value representing the permitted number of operations
+ per second.
+
+#### weight
+
+Modify the proportion of bandwidth allocated to this service relative to other
+services. Takes an integer value between 10 and 1000, with 500 being the
+default.
+
+#### weight_device
+
+Fine-tune bandwidth allocation by device. Each item in the list must have
+two keys:
+
+* `path`, defining the symbolic path to the affected device
+* `weight`, an integer value between 10 and 1000
+
+### build
+
+Configuration options that are applied at build time.
+
+`build` can be specified either as a string containing a path to the build
+context:
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+services:
+ webapp:
+ build: ./dir
+```
+
+Or, as an object with the path specified under [context](#context) and
+optionally [Dockerfile](#dockerfile) and [args](#args):
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+services:
+ webapp:
+ build:
+ context: ./dir
+ dockerfile: Dockerfile-alternate
+ args:
+ buildno: 1
+```
+
+If you specify `image` as well as `build`, then Compose names the built image
+with the `webapp` and optional `tag` specified in `image`:
+
+```yaml
+build: ./dir
+image: webapp:tag
+```
+
+This results in an image named `webapp` and tagged `tag`, built from `./dir`.
+
+#### context
+
+> Added in [version 2.0](compose-versioning.md#version-2) file format.
+
+Either a path to a directory containing a Dockerfile, or a url to a git repository.
+
+When the value supplied is a relative path, it is interpreted as relative to the
+location of the Compose file. This directory is also the build context that is
+sent to the Docker daemon.
+
+Compose builds and tags it with a generated name, and uses that image
+thereafter.
+
+```yaml
+build:
+ context: ./dir
+```
+
+#### dockerfile
+
+Alternate Dockerfile.
+
+Compose uses an alternate file to build with. A build path must also be
+specified.
+
+```yaml
+build:
+ context: .
+ dockerfile: Dockerfile-alternate
+```
+
+#### args
+
+> Added in [version 2.0](compose-versioning.md#version-2) file format.
+
+Add build arguments, which are environment variables accessible only during the
+build process.
+
+First, specify the arguments in your Dockerfile:
+
+```dockerfile
+# syntax=docker/dockerfile:1
+
+ARG buildno
+ARG gitcommithash
+
+RUN echo "Build number: $buildno"
+RUN echo "Based on commit: $gitcommithash"
+```
+
+Then specify the arguments under the `build` key. You can pass a mapping
+or a list:
+
+```yaml
+build:
+ context: .
+ args:
+ buildno: 1
+ gitcommithash: cdc3b19
+```
+
+```yaml
+build:
+ context: .
+ args:
+ - buildno=1
+ - gitcommithash=cdc3b19
+```
+
+> Scope of build-args
+>
+> In your Dockerfile, if you specify `ARG` before the `FROM` instruction,
+> `ARG` is not available in the build instructions under `FROM`.
+> If you need an argument to be available in both places, also specify it under
+> the `FROM` instruction. Refer to the [understand how ARGS and FROM interact](https://docs.docker.com/reference/dockerfile#understand-how-arg-and-from-interact)
+> section in the documentation for usage details.
+
+You can omit the value when specifying a build argument, in which case its value
+at build time is the value in the environment where Compose is running.
+
+```yaml
+args:
+ - buildno
+ - gitcommithash
+```
+
+> Tip when using boolean values
+>
+> YAML boolean values (`"true"`, `"false"`, `"yes"`, `"no"`, `"on"`,
+> `"off"`) must be enclosed in quotes, so that the parser interprets them as
+> strings.
+
+#### cache_from
+
+> Added in [version 2.2](compose-versioning.md#version-22) file format
+
+A list of images that the engine uses for cache resolution.
+
+```yaml
+build:
+ context: .
+ cache_from:
+ - alpine:latest
+ - corp/web_app:3.14
+```
+
+#### extra_hosts
+
+Add hostname mappings at build-time. Use the same values as the docker client `--add-host` parameter.
+
+```yaml
+extra_hosts:
+ - "somehost:162.242.195.82"
+ - "otherhost:50.31.209.229"
+```
+
+An entry with the ip address and hostname is created in `/etc/hosts` inside containers for this build, e.g:
+
+```console
+162.242.195.82 somehost
+50.31.209.229 otherhost
+```
+
+#### isolation
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Specify a build’s container isolation technology. On Linux, the only supported value
+is `default`. On Windows, acceptable values are `default`, `process` and
+`hyperv`. Refer to the
+[Docker Engine docs](https://docs.docker.com/reference/cli/docker/container/run#isolation)
+for details.
+
+If unspecified, Compose will use the `isolation` value found in the service's definition
+to determine the value to use for builds.
+
+#### labels
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format
+
+Add metadata to the resulting image using [Docker labels](https://docs.docker.com/config/labels-custom-metadata).
+You can use either an array or a dictionary.
+
+It's recommended that you use reverse-DNS notation to prevent your labels from
+conflicting with those used by other software.
+
+```yaml
+build:
+ context: .
+ labels:
+ com.example.description: "Accounting webapp"
+ com.example.department: "Finance"
+ com.example.label-with-empty-value: ""
+```
+
+```yaml
+build:
+ context: .
+ labels:
+ - "com.example.description=Accounting webapp"
+ - "com.example.department=Finance"
+ - "com.example.label-with-empty-value"
+```
+
+#### network
+
+> Added in [version 2.2](compose-versioning.md#version-22) file format
+
+Set the network containers connect to for the `RUN` instructions during
+build.
+
+```yaml
+build:
+ context: .
+ network: host
+```
+
+```yaml
+build:
+ context: .
+ network: custom_network_1
+```
+
+Use `none` to disable networking during build:
+
+```yaml
+build:
+ context: .
+ network: none
+```
+
+#### shm_size
+
+> Added in [version 2.3](compose-versioning.md#version-23) file format
+
+Set the size of the `/dev/shm` partition for this build's containers. Specify
+as an integer value representing the number of bytes or as a string expressing
+a [byte value](#specifying-byte-values).
+
+```yaml
+build:
+ context: .
+ shm_size: '2gb'
+```
+
+```yaml
+build:
+ context: .
+ shm_size: 10000000
+```
+
+#### target
+
+> Added in [version 2.3](compose-versioning.md#version-23) file format
+
+Build the specified stage as defined inside the `Dockerfile`. See the
+[multi-stage build docs](https://docs.docker.com/build/building/multi-stage) for
+details.
+
+```yaml
+build:
+ context: .
+ target: prod
+```
+
+### cap_add, cap_drop
+
+Add or drop container capabilities.
+See `man 7 capabilities` for a full list.
+
+```yaml
+cap_add:
+ - ALL
+
+cap_drop:
+ - NET_ADMIN
+ - SYS_ADMIN
+```
+
+### cgroup_parent
+
+Specify an optional parent cgroup for the container.
+
+```yaml
+cgroup_parent: m-executor-abcd
+```
+
+### command
+
+Override the default command.
+
+```yaml
+command: bundle exec thin -p 3000
+```
+
+The command can also be a list, in a manner similar to
+[dockerfile](https://docs.docker.com/reference/dockerfile#cmd):
+
+```yaml
+command: ["bundle", "exec", "thin", "-p", "3000"]
+```
+
+### container_name
+
+Specify a custom container name, rather than a generated default name.
+
+```yaml
+container_name: my-web-container
+```
+
+Because Docker container names must be unique, you cannot scale a service beyond
+1 container if you have specified a custom name. Attempting to do so results in
+an error.
+
+### cpu_rt_runtime, cpu_rt_period
+
+> Added in [version 2.2](compose-versioning.md#version-22) file format
+
+Configure CPU allocation parameters using the Docker daemon realtime scheduler.
+
+```yaml
+cpu_rt_runtime: '400ms'
+cpu_rt_period: '1400us'
+```
+
+Integer values will use microseconds as units:
+
+```yaml
+cpu_rt_runtime: 95000
+cpu_rt_period: 11000
+```
+
+### device_cgroup_rules
+
+> Added in [version 2.3](compose-versioning.md#version-23) file format.
+
+Add rules to the cgroup allowed devices list.
+
+```yaml
+device_cgroup_rules:
+ - 'c 1:3 mr'
+ - 'a 7:* rmw'
+```
+
+### devices
+
+List of device mappings. Uses the same format as the `--device` docker
+client create option.
+
+```yaml
+devices:
+ - "/dev/ttyUSB0:/dev/ttyUSB0"
+```
+
+### depends_on
+
+> Added in [version 2.0](compose-versioning.md#version-2) file format.
+
+Express dependency between services. Service dependencies cause the following
+behaviors:
+
+- `docker-compose up` starts services in dependency order. In the following
+ example, `db` and `redis` are started before `web`.
+- `docker-compose up SERVICE` automatically includes `SERVICE`'s
+ dependencies. In the example below, `docker-compose up web` also
+ creates and starts `db` and `redis`.
+- `docker-compose stop` stops services in dependency order. In the following
+ example, `web` is stopped before `db` and `redis`.
+
+Simple example:
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+services:
+ web:
+ build: .
+ depends_on:
+ - db
+ - redis
+ redis:
+ image: redis
+ db:
+ image: postgres
+```
+
+> **Note**
+>
+> `depends_on` does not wait for `db` and `redis` to be "ready" before
+> starting `web` - only until they have been started. If you need to wait
+> for a service to be ready, see [Controlling startup order](https://docs.docker.com/compose/startup-order/)
+> for more on this problem and strategies for solving it.
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+A healthcheck indicates that you want a dependency to wait
+for another container to be "healthy" (as indicated by a successful state from
+the healthcheck) before starting.
+
+Example:
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+services:
+ web:
+ build: .
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_started
+ redis:
+ image: redis
+ db:
+ image: postgres
+ healthcheck:
+ test: "exit 0"
+```
+
+In the above example, Compose waits for the `redis` service to be started
+(legacy behavior) and the `db` service to be healthy before starting `web`.
+
+See the [healthcheck section](#healthcheck) for complementary
+information.
+
+### dns
+
+Custom DNS servers. Can be a single value or a list.
+
+```yaml
+dns: 8.8.8.8
+```
+
+```yaml
+dns:
+ - 8.8.8.8
+ - 9.9.9.9
+```
+
+### dns_opt
+
+List of custom DNS options to be added to the container's `resolv.conf` file.
+
+```yaml
+dns_opt:
+ - use-vc
+ - no-tld-query
+```
+
+### dns_search
+
+Custom DNS search domains. Can be a single value or a list.
+
+```yaml
+dns_search: example.com
+```
+
+```yaml
+dns_search:
+ - dc1.example.com
+ - dc2.example.com
+```
+
+### entrypoint
+
+Override the default entrypoint.
+
+```yaml
+entrypoint: /code/entrypoint.sh
+```
+
+The entrypoint can also be a list, in a manner similar to
+[dockerfile](https://docs.docker.com/reference/dockerfile#entrypoint):
+
+```yaml
+entrypoint: ["php", "-d", "memory_limit=-1", "vendor/bin/phpunit"]
+```
+
+> **Note**
+>
+> Setting `entrypoint` both overrides any default entrypoint set on the service's
+> image with the `ENTRYPOINT` Dockerfile instruction, *and* clears out any default
+> command on the image - meaning that if there's a `CMD` instruction in the
+> Dockerfile, it is ignored.
+
+### env_file
+
+Add environment variables from a file. Can be a single value or a list.
+
+If you have specified a Compose file with `docker-compose -f FILE`, paths in
+`env_file` are relative to the directory that file is in.
+
+Environment variables declared in the [environment](#environment) section
+_override_ these values – this holds true even if those values are
+empty or undefined.
+
+```yaml
+env_file: .env
+```
+
+```yaml
+env_file:
+ - ./common.env
+ - ./apps/web.env
+ - /opt/runtime_opts.env
+```
+
+Compose expects each line in an env file to be in `VAR=VAL` format. Lines
+beginning with `#` are treated as comments and are ignored. Blank lines are
+also ignored.
+
+```console
+# Set Rails/Rack environment
+RACK_ENV=development
+```
+
+> **Note**
+>
+> If your service specifies a [build](#build) option, variables defined in
+> environment files are _not_ automatically visible during the build. Use
+> the [args](#args) sub-option of `build` to define build-time environment
+> variables.
+
+The value of `VAL` is used as is and not modified at all. For example if the
+value is surrounded by quotes (as is often the case of shell variables), the
+quotes are included in the value passed to Compose.
+
+Keep in mind that _the order of files in the list is significant in determining
+the value assigned to a variable that shows up more than once_. The files in the
+list are processed from the top down. For the same variable specified in file
+`a.env` and assigned a different value in file `b.env`, if `b.env` is
+listed below (after), then the value from `b.env` stands. For example, given the
+following declaration in `docker-compose.yml`:
+
+```yaml
+services:
+ some-service:
+ env_file:
+ - a.env
+ - b.env
+```
+
+And the following files:
+
+```console
+# a.env
+VAR=1
+```
+
+and
+
+```console
+# b.env
+VAR=hello
+```
+
+`$VAR` is `hello`.
+
+### environment
+
+Add environment variables. You can use either an array or a dictionary. Any
+boolean values (true, false, yes, no) need to be enclosed in quotes to ensure
+they are not converted to True or False by the YML parser.
+
+Environment variables with only a key are resolved to their values on the
+machine Compose is running on, which can be helpful for secret or host-specific values.
+
+```yaml
+environment:
+ RACK_ENV: development
+ SHOW: 'true'
+ SESSION_SECRET:
+```
+
+```yaml
+environment:
+ - RACK_ENV=development
+ - SHOW=true
+ - SESSION_SECRET
+```
+
+> **Note**
+>
+> If your service specifies a [build](#build) option, variables defined in
+> `environment` are _not_ automatically visible during the build. Use the
+> [args](#args) sub-option of `build` to define build-time environment
+> variables.
+
+### expose
+
+Expose ports without publishing them to the host machine - they'll only be
+accessible to linked services. Only the internal port can be specified.
+
+```yaml
+expose:
+ - "3000"
+ - "8000"
+```
+
+### extends
+
+Extend another service, in the current file or another, optionally overriding
+configuration.
+
+You can use `extends` on any service together with other configuration keys.
+The `extends` value must be a dictionary defined with a required `service`
+and an optional `file` key.
+
+```yaml
+extends:
+ file: common.yml
+ service: webapp
+```
+
+The `service` is the name of the service being extended, for example
+`web` or `database`. The `file` is the location of a Compose configuration
+file defining that service.
+
+If you omit the `file` Compose looks for the service configuration in the
+current file. The `file` value can be an absolute or relative path. If you
+specify a relative path, Compose treats it as relative to the location of the
+current file.
+
+You can extend a service that itself extends another. You can extend
+indefinitely. Compose does not support circular references and `docker-compose`
+returns an error if it encounters one.
+
+For more on `extends`, see the
+[the extends documentation](https://docs.docker.com/compose/multiple-compose-files/extends)
+
+### external_links
+
+Link to containers started outside this `docker-compose.yml` or even outside of
+Compose, especially for containers that provide shared or common services.
+`external_links` follow semantics similar to the legacy option `links` when
+specifying both the container name and the link alias (`CONTAINER:ALIAS`).
+
+```yaml
+external_links:
+ - redis_1
+ - project_db_1:mysql
+ - project_db_1:postgresql
+```
+
+> **Note**
+>
+> If you're using the [version 2 or above file format](compose-versioning.md#version-2),
+> the externally-created containers must be connected to at least one of the same
+> networks as the service that is linking to them. [Links](compose-file-v2.md#links)
+> are a legacy option. We recommend using [networks](#networks) instead.
+
+### extra_hosts
+
+Add hostname mappings. Use the same values as the docker client `--add-host` parameter.
+
+```yaml
+extra_hosts:
+ - "somehost:162.242.195.82"
+ - "otherhost:50.31.209.229"
+```
+
+An entry with the ip address and hostname is created in `/etc/hosts` inside containers for this service, e.g:
+
+```console
+162.242.195.82 somehost
+50.31.209.229 otherhost
+```
+
+### group_add
+
+Specify additional groups (by name or number) which the user inside the
+container should be a member of. Groups must exist in both the container and the
+host system to be added. An example of where this is useful is when multiple
+containers (running as different users) need to all read or write the same
+file on the host system. That file can be owned by a group shared by all the
+containers, and specified in `group_add`. See the
+[Docker documentation](https://docs.docker.com/reference/cli/docker/container/run#additional-groups) for more
+details.
+
+A full example:
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+services:
+ myservice:
+ image: alpine
+ group_add:
+ - mail
+```
+
+Running `id` inside the created container shows that the user belongs to
+the `mail` group, which would not have been the case if `group_add` were not
+used.
+
+### healthcheck
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Configure a check that's run to determine whether or not containers for this
+service are "healthy". See the docs for the
+[HEALTHCHECK Dockerfile instruction](https://docs.docker.com/reference/dockerfile#healthcheck)
+for details on how healthchecks work.
+
+```yaml
+healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost"]
+ interval: 1m30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+```
+
+`interval`, `timeout` and `start_period` are specified as
+[durations](#specifying-durations).
+
+> Added in [version 2.3](compose-versioning.md#version-23) file format.
+>
+> The `start_period` option was added in file format 2.3.
+
+`test` must be either a string or a list. If it's a list, the first item must be
+either `NONE`, `CMD` or `CMD-SHELL`. If it's a string, it's equivalent to
+specifying `CMD-SHELL` followed by that string.
+
+```yaml
+# Hit the local web app
+test: ["CMD", "curl", "-f", "http://localhost"]
+```
+
+As above, but wrapped in `/bin/sh`. Both forms below are equivalent.
+
+```yaml
+test: ["CMD-SHELL", "curl -f http://localhost || exit 1"]
+```
+
+```yaml
+test: curl -f https://localhost || exit 1
+```
+
+To disable any default healthcheck set by the image, you can use `disable: true`.
+This is equivalent to specifying `test: ["NONE"]`.
+
+```yaml
+healthcheck:
+ disable: true
+```
+
+### image
+
+Specify the image to start the container from. Can either be a repository/tag or
+a partial image ID.
+
+```yaml
+image: redis
+```
+```yaml
+image: ubuntu:22.04
+```
+```yaml
+image: tutum/influxdb
+```
+```yaml
+image: example-registry.com:4000/postgresql
+```
+```yaml
+image: a4bc65fd
+```
+
+If the image does not exist, Compose attempts to pull it, unless you have also
+specified [build](#build), in which case it builds it using the specified
+options and tags it with the specified tag.
+
+### init
+
+> Added in [version 2.2](compose-versioning.md#version-22) file format.
+
+Run an init inside the container that forwards signals and reaps processes.
+Set this option to `true` to enable this feature for the service.
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+services:
+ web:
+ image: alpine:latest
+ init: true
+```
+
+> The default init binary that is used is [Tini](https://github.com/krallin/tini),
+> and is installed in `/usr/libexec/docker-init` on the daemon host. You can
+> configure the daemon to use a custom init binary through the
+> [`init-path` configuration option](https://docs.docker.com/reference/cli/dockerd#daemon-configuration-file).
+
+### isolation
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Specify a container’s isolation technology. On Linux, the only supported value
+is `default`. On Windows, acceptable values are `default`, `process` and
+`hyperv`. Refer to the
+[Docker Engine docs](https://docs.docker.com/reference/cli/docker/container/run#isolation)
+for details.
+
+### labels
+
+Add metadata to containers using [Docker labels](https://docs.docker.com/config/labels-custom-metadata). You can use either an array or a dictionary.
+
+It's recommended that you use reverse-DNS notation to prevent your labels from conflicting with those used by other software.
+
+```yaml
+labels:
+ com.example.description: "Accounting webapp"
+ com.example.department: "Finance"
+ com.example.label-with-empty-value: ""
+```
+
+```yaml
+labels:
+ - "com.example.description=Accounting webapp"
+ - "com.example.department=Finance"
+ - "com.example.label-with-empty-value"
+```
+
+### links
+
+Link to containers in another service. Either specify both the service name and
+a link alias (`"SERVICE:ALIAS"`), or just the service name.
+
+> Links are a legacy option. We recommend using
+> [networks](#networks) instead.
+
+```yaml
+web:
+ links:
+ - "db"
+ - "db:database"
+ - "redis"
+```
+
+Containers for the linked service are reachable at a hostname identical to
+the alias, or the service name if no alias was specified.
+
+Links are not required to enable services to communicate - by default,
+any service can reach any other service at that service’s name. (See also, the
+[Links topic in Networking in Compose](https://docs.docker.com/compose/networking#link-containers).)
+
+Links also express dependency between services in the same way as
+[depends_on](#depends_on), so they determine the order of service startup.
+
+> **Note**
+>
+> If you define both links and [networks](#networks), services with
+> links between them must share at least one network in common to
+> communicate. We recommend using networks instead.
+
+### logging
+
+Logging configuration for the service.
+
+```yaml
+logging:
+ driver: syslog
+ options:
+ syslog-address: "tcp://192.168.0.42:123"
+```
+
+The `driver` name specifies a logging driver for the service's
+containers, as with the ``--log-driver`` option for docker run
+([documented here](https://docs.docker.com/config/containers/logging/configure)).
+
+The default value is json-file.
+
+```yaml
+driver: "json-file"
+```
+```yaml
+driver: "syslog"
+```
+```yaml
+driver: "none"
+```
+
+> **Note**
+>
+> Only the `json-file` and `journald` drivers make the logs available directly
+> from `docker-compose up` and `docker-compose logs`. Using any other driver
+> does not print any logs.
+
+Specify logging options for the logging driver with the ``options`` key, as with the ``--log-opt`` option for `docker run`.
+
+Logging options are key-value pairs. An example of `syslog` options:
+
+```yaml
+driver: "syslog"
+options:
+ syslog-address: "tcp://192.168.0.42:123"
+```
+
+### network_mode
+
+> Changed in [version 2](compose-versioning.md#version-2) file format.
+
+Network mode. Use the same values as the docker client `--network` parameter, plus
+the special form `service:[service name]`.
+
+```yaml
+network_mode: "bridge"
+```
+```yaml
+network_mode: "host"
+```
+```yaml
+network_mode: "none"
+```
+```yaml
+network_mode: "service:[service name]"
+```
+```yaml
+network_mode: "container:[container name/id]"
+```
+
+### networks
+
+> Changed in [version 2](compose-versioning.md#version-2) file format.
+
+Networks to join, referencing entries under the
+[top-level `networks` key](#network-configuration-reference).
+
+```yaml
+services:
+ some-service:
+ networks:
+ - some-network
+ - other-network
+```
+
+#### aliases
+
+Aliases (alternative hostnames) for this service on the network. Other containers on the same network can use either the service name or this alias to connect to one of the service's containers.
+
+Since `aliases` is network-scoped, the same service can have different aliases on different networks.
+
+> **Note**
+>
+> A network-wide alias can be shared by multiple containers, and even by multiple
+> services. If it is, then exactly which container the name resolves to is not
+> guaranteed.
+
+The general format is shown here.
+
+```yaml
+services:
+ some-service:
+ networks:
+ some-network:
+ aliases:
+ - alias1
+ - alias3
+ other-network:
+ aliases:
+ - alias2
+```
+
+In the example below, three services are provided (`web`, `worker`, and `db`),
+along with two networks (`new` and `legacy`). The `db` service is reachable at
+the hostname `db` or `database` on the `new` network, and at `db` or `mysql` on
+the `legacy` network.
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+
+services:
+ web:
+ image: "nginx:alpine"
+ networks:
+ - new
+
+ worker:
+ image: "my-worker-image:latest"
+ networks:
+ - legacy
+
+ db:
+ image: mysql
+ networks:
+ new:
+ aliases:
+ - database
+ legacy:
+ aliases:
+ - mysql
+
+networks:
+ new:
+ legacy:
+```
+
+#### ipv4_address, ipv6_address
+
+Specify a static IP address for containers for this service when joining the network.
+
+The corresponding network configuration in the
+[top-level networks section](#network-configuration-reference) must have an
+`ipam` block with subnet and gateway configurations covering each static address.
+
+> If IPv6 addressing is desired, the [`enable_ipv6`](#enable_ipv6) option must be set.
+
+An example:
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+
+services:
+ app:
+ image: busybox
+ command: ifconfig
+ networks:
+ app_net:
+ ipv4_address: 172.16.238.10
+ ipv6_address: 2001:3984:3989::10
+
+networks:
+ app_net:
+ driver: bridge
+ enable_ipv6: true
+ ipam:
+ driver: default
+ config:
+ - subnet: 172.16.238.0/24
+ gateway: 172.16.238.1
+ - subnet: 2001:3984:3989::/64
+ gateway: 2001:3984:3989::1
+```
+
+#### link_local_ips
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Specify a list of link-local IPs. Link-local IPs are special IPs which belong
+to a well known subnet and are purely managed by the operator, usually
+dependent on the architecture where they are deployed. Therefore they are not
+managed by docker (IPAM driver).
+
+Example usage:
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+services:
+ app:
+ image: busybox
+ command: top
+ networks:
+ app_net:
+ link_local_ips:
+ - 57.123.22.11
+ - 57.123.22.13
+networks:
+ app_net:
+ driver: bridge
+```
+
+#### priority
+
+Specify a priority to indicate in which order Compose should connect the
+service's containers to its networks. If unspecified, the default value is `0`.
+
+In the following example, the `app` service connects to `app_net_1` first
+as it has the highest priority. It then connects to `app_net_3`, then
+`app_net_2`, which uses the default priority value of `0`.
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+services:
+ app:
+ image: busybox
+ command: top
+ networks:
+ app_net_1:
+ priority: 1000
+ app_net_2:
+
+ app_net_3:
+ priority: 100
+networks:
+ app_net_1:
+ app_net_2:
+ app_net_3:
+```
+
+> **Note**
+>
+> If multiple networks have the same priority, the connection order is undefined.
+
+### pid
+
+```yaml
+pid: "host"
+```
+```yaml
+pid: "container:custom_container_1"
+```
+```yaml
+pid: "service:foobar"
+```
+
+If set to one of the following forms: `container:`,
+`service:`, the service shares the PID address space of the
+designated container or service.
+
+If set to "host", the service's PID mode is the host PID mode. This turns
+on sharing between container and the host operating system the PID address
+space. Containers launched with this flag can access and manipulate
+other containers in the bare-metal machine's namespace and vice versa.
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+>
+> The `service:` and `container:` forms require [version 2.1](compose-versioning.md#version-21) or above
+
+### pids_limit
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Tunes a container's PIDs limit. Set to `-1` for unlimited PIDs.
+
+```yaml
+pids_limit: 10
+```
+
+### platform
+
+> Added in [version 2.4](compose-versioning.md#version-24) file format.
+
+Target platform containers for this service will run on, using the
+`os[/arch[/variant]]` syntax, e.g.
+
+```yaml
+platform: osx
+```
+```yaml
+platform: windows/amd64
+```
+```yaml
+platform: linux/arm64/v8
+```
+
+This parameter determines which version of the image will be pulled and/or
+on which platform the service's build will be performed.
+
+### ports
+
+Expose ports. Either specify both ports (`HOST:CONTAINER`), or just the container
+port (an ephemeral host port is chosen).
+
+> **Note**
+>
+> When mapping ports in the `HOST:CONTAINER` format, you may experience
+> erroneous results when using a container port lower than 60, because YAML
+> parses numbers in the format `xx:yy` as a base-60 value. For this reason,
+> we recommend always explicitly specifying your port mappings as strings.
+
+```yaml
+ports:
+ - "3000"
+ - "3000-3005"
+ - "8000:8000"
+ - "9090-9091:8080-8081"
+ - "49100:22"
+ - "127.0.0.1:8001:8001"
+ - "127.0.0.1:5000-5010:5000-5010"
+ - "6060:6060/udp"
+ - "12400-12500:1240"
+```
+
+### runtime
+
+> Added in [version 2.3](compose-versioning.md#version-23) file format.
+
+Specify which runtime to use for the service's containers. Default runtime
+and available runtimes are listed in the output of `docker info`.
+
+```yaml
+web:
+ image: busybox:latest
+ command: true
+ runtime: runc
+```
+
+### scale
+
+> Added in [version 2.2](compose-versioning.md#version-22) file format.
+
+Specify the default number of containers to deploy for this service. Whenever
+you run `docker-compose up`, Compose creates or removes containers to match
+the specified number. This value can be overridden using the
+[`--scale`](https://docs.docker.com/reference/cli/docker/compose/up)
+
+```yaml
+web:
+ image: busybox:latest
+ command: echo 'scaled'
+ scale: 3
+```
+
+### security_opt
+
+Override the default labeling scheme for each container.
+
+```yaml
+security_opt:
+ - label:user:USER
+ - label:role:ROLE
+```
+
+### stop_grace_period
+
+Specify how long to wait when attempting to stop a container if it doesn't
+handle SIGTERM (or whatever stop signal has been specified with
+[`stop_signal`](#stop_signal)), before sending SIGKILL. Specified
+as a [duration](#specifying-durations).
+
+```yaml
+stop_grace_period: 1s
+```
+
+```yaml
+stop_grace_period: 1m30s
+```
+
+By default, `stop` waits 10 seconds for the container to exit before sending
+SIGKILL.
+
+### stop_signal
+
+Sets an alternative signal to stop the container. By default `stop` uses
+SIGTERM. Setting an alternative signal using `stop_signal` causes
+`stop` to send that signal instead.
+
+```yaml
+stop_signal: SIGUSR1
+```
+
+### storage_opt
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Set storage driver options for this service.
+
+```yaml
+storage_opt:
+ size: '1G'
+```
+
+### sysctls
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Kernel parameters to set in the container. You can use either an array or a
+dictionary.
+
+```yaml
+sysctls:
+ net.core.somaxconn: 1024
+ net.ipv4.tcp_syncookies: 0
+```
+
+```yaml
+sysctls:
+ - net.core.somaxconn=1024
+ - net.ipv4.tcp_syncookies=0
+```
+
+### tmpfs
+
+Mount a temporary file system inside the container. Can be a single value or a list.
+
+```yaml
+tmpfs: /run
+```
+
+```yaml
+tmpfs:
+ - /run
+ - /tmp
+```
+
+### ulimits
+
+Override the default ulimits for a container. You can either specify a single
+limit as an integer or soft/hard limits as a mapping.
+
+```yaml
+ulimits:
+ nproc: 65535
+ nofile:
+ soft: 20000
+ hard: 40000
+```
+
+### userns_mode
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+```yaml
+userns_mode: "host"
+```
+
+Disables the user namespace for this service, if Docker daemon is configured with user namespaces.
+See [dockerd](https://docs.docker.com/engine/security/userns-remap#disable-namespace-remapping-for-a-container) for
+more information.
+
+### volumes
+
+Mount host paths or named volumes. Named volumes need to be specified with the
+[top-level `volumes` key](#volume-configuration-reference).
+
+#### Short syntax
+
+The short syntax uses the generic `[SOURCE:]TARGET[:MODE]` format, where
+`SOURCE` can be either a host path or volume name. `TARGET` is the container
+path where the volume is mounted. Standard modes are `ro` for read-only
+and `rw` for read-write (default).
+
+You can mount a relative path on the host, which expands relative to
+the directory of the Compose configuration file being used. Relative paths
+should always begin with `.` or `..`.
+
+```yaml
+volumes:
+ # Just specify a path and let the Engine create a volume
+ - /var/lib/mysql
+
+ # Specify an absolute path mapping
+ - /opt/data:/var/lib/mysql
+
+ # Path on the host, relative to the Compose file
+ - ./cache:/tmp/cache
+
+ # User-relative path
+ - ~/configs:/etc/configs/:ro
+
+ # Named volume
+ - datavolume:/var/lib/mysql
+```
+
+#### Long syntax
+
+> Added in [version 2.3](compose-versioning.md#version-23) file format.
+
+The long form syntax allows the configuration of additional fields that can't be
+expressed in the short form.
+
+- `type`: the mount type `volume`, `bind`, `tmpfs` or `npipe`
+- `source`: the source of the mount, a path on the host for a bind mount, or the
+ name of a volume defined in the
+ [top-level `volumes` key](#volume-configuration-reference). Not applicable for a tmpfs mount.
+- `target`: the path in the container where the volume is mounted
+- `read_only`: flag to set the volume as read-only
+- `bind`: configure additional bind options
+ - `propagation`: the propagation mode used for the bind
+- `volume`: configure additional volume options
+ - `nocopy`: flag to disable copying of data from a container when a volume is
+ created
+- `tmpfs`: configure additional tmpfs options
+ - `size`: the size for the tmpfs mount in bytes
+
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+services:
+ web:
+ image: nginx:alpine
+ ports:
+ - "80:80"
+ volumes:
+ - type: volume
+ source: mydata
+ target: /data
+ volume:
+ nocopy: true
+ - type: bind
+ source: ./static
+ target: /opt/app/static
+
+networks:
+ webnet:
+
+volumes:
+ mydata:
+```
+
+> **Note**
+>
+> When creating bind mounts, using the long syntax requires the
+> referenced folder to be created beforehand. Using the short syntax
+> creates the folder on the fly if it doesn't exist.
+> See the [bind mounts documentation](https://docs.docker.com/storage/bind-mounts#differences-between--v-and---mount-behavior)
+> for more information.
+
+### volume\_driver
+
+Specify a default volume driver to be used for all declared volumes on this
+service.
+
+```yaml
+volume_driver: mydriver
+```
+
+> **Note**
+>
+> In [version 2 files](compose-versioning.md#version-2), this
+> option only applies to anonymous volumes (those specified in the image,
+> or specified under `volumes` without an explicit named volume or host path).
+> To configure the driver for a named volume, use the `driver` key under the
+> entry in the [top-level `volumes` option](#volume-configuration-reference).
+
+
+See [Docker Volumes](https://docs.docker.com/storage/volumes) and
+[Volume Plugins](/engine/extend/plugins_volume/) for more information.
+
+### volumes_from
+
+Mount all of the volumes from another service or container, optionally
+specifying read-only access (``ro``) or read-write (``rw``). If no access level
+is specified, then read-write is used.
+
+```yaml
+volumes_from:
+ - service_name
+ - service_name:ro
+ - container:container_name
+ - container:container_name:rw
+```
+
+> Changed in [version 2](compose-versioning.md#version-2) file format.
+
+### restart
+
+`no` is the default restart policy, and it doesn't restart a container under any circumstance. When `always` is specified, the container always restarts. The `on-failure` policy restarts a container if the exit code indicates an on-failure error.
+
+```yaml
+restart: "no"
+```
+```yaml
+restart: "always"
+```
+```yaml
+restart: "on-failure"
+```
+```yaml
+restart: "unless-stopped"
+```
+
+{ #cpu-and-other-resources }
+
+### cpu_count, cpu_percent, cpu\_shares, cpu\_period, cpu\_quota, cpus, cpuset, domainname, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, mem\_swappiness, mem\_reservation, oom_kill_disable, oom_score_adj, privileged, read\_only, shm\_size, stdin\_open, tty, user, working\_dir
+
+Each of these is a single value, analogous to its
+[docker run](https://docs.docker.com/reference/cli/docker/container/run#runtime-constraints-on-resources) counterpart.
+
+> Added in [version 2.2](compose-versioning.md#version-22) file format.
+>
+> The `cpu_count`, `cpu_percent`, and `cpus` options were added in [version 2.2](compose-versioning.md#version-22).
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+>
+> The `oom_kill_disable` and `cpu_period` options were added in [version 2.1](compose-versioning.md#version-21).
+
+```yaml
+cpu_count: 2
+cpu_percent: 50
+cpus: 0.5
+cpu_shares: 73
+cpu_quota: 50000
+cpu_period: 20ms
+cpuset: 0,1
+
+user: postgresql
+working_dir: /code
+
+domainname: foo.com
+hostname: foo
+ipc: host
+mac_address: 02:42:ac:11:65:43
+
+mem_limit: 1000000000
+memswap_limit: 2000000000
+mem_reservation: 512m
+privileged: true
+
+oom_score_adj: 500
+oom_kill_disable: true
+
+read_only: true
+shm_size: 64M
+stdin_open: true
+tty: true
+```
+
+
+
+## Specifying durations
+
+Some configuration options, such as the `interval` and `timeout` sub-options for
+[`healthcheck`](#healthcheck), accept a duration as a string in a
+format that looks like this:
+
+ 2.5s
+ 10s
+ 1m30s
+ 2h32m
+ 5h34m56s
+
+The supported units are `us`, `ms`, `s`, `m` and `h`.
+
+## Specifying byte values
+
+Some configuration options, such as the `device_read_bps` sub-option for
+[`blkio_config`](#blkio_config), accept a byte value as a string in a format
+that looks like this:
+
+ 2b
+ 1024kb
+ 2048k
+ 300m
+ 1gb
+
+The supported units are `b`, `k`, `m` and `g`, and their alternative notation `kb`,
+`mb` and `gb`. Decimal values are not supported at this time.
+
+## Volume configuration reference
+
+While it is possible to declare [volumes](#volumes) on the fly as part of the
+service declaration, this section allows you to create named volumes that can be
+reused across multiple services (without relying on `volumes_from`), and are
+easily retrieved and inspected using the docker command line or API.
+See the [docker volume](https://docs.docker.com/reference/cli/docker/volume/create)
+subcommand documentation for more information.
+
+See [use volumes](https://docs.docker.com/storage/volumes) and [volume plugins](/engine/extend/plugins_volume/)
+for general information on volumes.
+
+Here's an example of a two-service setup where a database's data directory is
+shared with another service as a volume so that it can be periodically backed
+up:
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+
+services:
+ db:
+ image: db
+ volumes:
+ - data-volume:/var/lib/db
+ backup:
+ image: backup-service
+ volumes:
+ - data-volume:/var/lib/backup/data
+
+volumes:
+ data-volume:
+```
+
+An entry under the top-level `volumes` key can be empty, in which case it
+uses the default driver configured by the Engine (in most cases, this is the
+`local` driver). Optionally, you can configure it with the following keys:
+
+### driver
+
+Specify which volume driver should be used for this volume. Defaults to whatever
+driver the Docker Engine has been configured to use, which in most cases is
+`local`. If the driver is not available, the Engine returns an error when
+`docker-compose up` tries to create the volume.
+
+```yaml
+driver: foobar
+```
+
+### driver_opts
+
+Specify a list of options as key-value pairs to pass to the driver for this
+volume. Those options are driver-dependent - consult the driver's
+documentation for more information. Optional.
+
+```yaml
+volumes:
+ example:
+ driver_opts:
+ type: "nfs"
+ o: "addr=10.40.0.199,nolock,soft,rw"
+ device: ":/docker/example"
+```
+
+### external
+
+If set to `true`, specifies that this volume has been created outside of
+Compose. `docker-compose up` does not attempt to create it, and raises
+an error if it doesn't exist.
+
+For version 2.0 of the format, `external` cannot be used in
+conjunction with other volume configuration keys (`driver`, `driver_opts`,
+`labels`). This limitation no longer exists for
+[version 2.1](compose-versioning.md#version-21) and above.
+
+In the example below, instead of attempting to create a volume called
+`[projectname]_data`, Compose looks for an existing volume simply
+called `data` and mount it into the `db` service's containers.
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+
+services:
+ db:
+ image: postgres
+ volumes:
+ - data:/var/lib/postgresql/data
+
+volumes:
+ data:
+ external: true
+```
+
+You can also specify the name of the volume separately from the name used to
+refer to it within the Compose file:
+
+```yaml
+volumes:
+ data:
+ external:
+ name: actual-name-of-volume
+```
+
+> Deprecated in [version 2.1](compose-versioning.md#version-21) file format.
+>
+> external.name was deprecated in version 2.1 file format use `name` instead.
+{ .important }
+
+### labels
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Add metadata to containers using
+[Docker labels](https://docs.docker.com/config/labels-custom-metadata). You can use either
+an array or a dictionary.
+
+It's recommended that you use reverse-DNS notation to prevent your labels from
+conflicting with those used by other software.
+
+```yaml
+labels:
+ com.example.description: "Database volume"
+ com.example.department: "IT/Ops"
+ com.example.label-with-empty-value: ""
+```
+
+```yaml
+labels:
+ - "com.example.description=Database volume"
+ - "com.example.department=IT/Ops"
+ - "com.example.label-with-empty-value"
+```
+
+### name
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Set a custom name for this volume. The name field can be used to reference
+volumes that contain special characters. The name is used as is
+and will **not** be scoped with the stack name.
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+volumes:
+ data:
+ name: my-app-data
+```
+
+It can also be used in conjunction with the `external` property:
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+volumes:
+ data:
+ external: true
+ name: my-app-data
+```
+
+## Network configuration reference
+
+The top-level `networks` key lets you specify networks to be created. For a full
+explanation of Compose's use of Docker networking features, see the
+[Networking guide](https://docs.docker.com/compose/networking).
+
+### driver
+
+Specify which driver should be used for this network.
+
+The default driver depends on how the Docker Engine you're using is configured,
+but in most instances it is `bridge` on a single host and `overlay` on a
+Swarm.
+
+The Docker Engine returns an error if the driver is not available.
+
+```yaml
+driver: overlay
+```
+
+> Changed in [version 2.1](compose-versioning.md#version-21) file format.
+>
+> Starting with Compose file format 2.1, overlay networks are always created as
+> `attachable`, and this is not configurable. This means that standalone
+> containers can connect to overlay networks.
+
+### driver_opts
+
+Specify a list of options as key-value pairs to pass to the driver for this
+network. Those options are driver-dependent - consult the driver's
+documentation for more information. Optional.
+
+```yaml
+driver_opts:
+ foo: "bar"
+ baz: 1
+```
+
+### enable_ipv6
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Enable IPv6 networking on this network.
+
+### ipam
+
+Specify custom IPAM config. This is an object with several properties, each of
+which is optional:
+
+- `driver`: Custom IPAM driver, instead of the default.
+- `config`: A list with zero or more config blocks, each containing any of
+ the following keys:
+ - `subnet`: Subnet in CIDR format that represents a network segment
+ - `ip_range`: Range of IPs from which to allocate container IPs
+ - `gateway`: IPv4 or IPv6 gateway for the master subnet
+ - `aux_addresses`: Auxiliary IPv4 or IPv6 addresses used by Network driver,
+ as a mapping from hostname to IP
+- `options`: Driver-specific options as a key-value mapping.
+
+A full example:
+
+```yaml
+ipam:
+ driver: default
+ config:
+ - subnet: 172.28.0.0/16
+ ip_range: 172.28.5.0/24
+ gateway: 172.28.5.254
+ aux_addresses:
+ host1: 172.28.1.5
+ host2: 172.28.1.6
+ host3: 172.28.1.7
+ options:
+ foo: bar
+ baz: "0"
+```
+
+### internal
+
+By default, Docker also connects a bridge network to it to provide external
+connectivity. If you want to create an externally isolated overlay network,
+you can set this option to `true`.
+
+### labels
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Add metadata to containers using
+[Docker labels](https://docs.docker.com/config/labels-custom-metadata). You can use either
+an array or a dictionary.
+
+It's recommended that you use reverse-DNS notation to prevent your labels from
+conflicting with those used by other software.
+
+```yaml
+labels:
+ com.example.description: "Financial transaction network"
+ com.example.department: "Finance"
+ com.example.label-with-empty-value: ""
+```
+
+```yaml
+labels:
+ - "com.example.description=Financial transaction network"
+ - "com.example.department=Finance"
+ - "com.example.label-with-empty-value"
+```
+
+### external
+
+If set to `true`, specifies that this network has been created outside of
+Compose. `docker-compose up` does not attempt to create it, and raises
+an error if it doesn't exist.
+
+For version 2.0 of the format, `external` cannot be used in conjunction with
+other network configuration keys (`driver`, `driver_opts`, `ipam`, `internal`).
+This limitation no longer exists for
+[version 2.1](compose-versioning.md#version-21) and above.
+
+In the example below, `proxy` is the gateway to the outside world. Instead of
+attempting to create a network called `[projectname]_outside`, Compose
+looks for an existing network simply called `outside` and connect the `proxy`
+service's containers to it.
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+
+services:
+ proxy:
+ build: ./proxy
+ networks:
+ - outside
+ - default
+ app:
+ build: ./app
+ networks:
+ - default
+
+networks:
+ outside:
+ external: true
+```
+
+You can also specify the name of the network separately from the name used to
+refer to it within the Compose file:
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+networks:
+ outside:
+ external:
+ name: actual-name-of-network
+```
+
+Not supported for version 2 `docker-compose` files. Use
+[network_mode](#network_mode) instead.
+
+### name
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+Set a custom name for this network. The name field can be used to reference
+networks which contain special characters. The name is used as is
+and will **not** be scoped with the stack name.
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+networks:
+ network1:
+ name: my-app-net
+```
+
+It can also be used in conjunction with the `external` property:
+
+```yaml
+version: "{{% param "compose_file_v2" %}}"
+networks:
+ network1:
+ external: true
+ name: my-app-net
+```
+
+## Variable substitution
+
+Your configuration options can contain environment variables. Compose uses the
+variable values from the shell environment in which `docker compose` is run. For
+example, suppose the shell contains `POSTGRES_VERSION=9.3` and you supply this
+configuration:
+
+```yaml
+db:
+ image: "postgres:${POSTGRES_VERSION}"
+```
+
+When you run `docker compose up` with this configuration, Compose looks for the
+`POSTGRES_VERSION` environment variable in the shell and substitutes its value
+in. For this example, Compose resolves the `image` to `postgres:9.3` before
+running the configuration.
+
+If an environment variable is not set, Compose substitutes with an empty
+string. In the example above, if `POSTGRES_VERSION` is not set, the value for
+the `image` option is `postgres:`.
+
+You can set default values for environment variables using a
+`.env` file, which Compose automatically looks for in
+project directory (parent folder of your Compose file).
+Values set in the shell environment override those set in the `.env` file.
+
+> Note when using docker stack deploy
+>
+> The `.env file` feature only works when you use the `docker compose up` command
+> and does not work with `docker stack deploy`.
+{ .important }
+
+Both `$VARIABLE` and `${VARIABLE}` syntax are supported. Additionally when using
+the [2.1 file format](compose-versioning.md#version-21), it is possible to
+provide inline default values using typical shell syntax:
+
+- `${VARIABLE:-default}` evaluates to `default` if `VARIABLE` is unset or
+ empty in the environment.
+- `${VARIABLE-default}` evaluates to `default` only if `VARIABLE` is unset
+ in the environment.
+
+Similarly, the following syntax allows you to specify mandatory variables:
+
+- `${VARIABLE:?err}` exits with an error message containing `err` if
+ `VARIABLE` is unset or empty in the environment.
+- `${VARIABLE?err}` exits with an error message containing `err` if
+ `VARIABLE` is unset in the environment.
+
+Other extended shell-style features, such as `${VARIABLE/foo/bar}`, are not
+supported.
+
+You can use a `$$` (double-dollar sign) when your configuration needs a literal
+dollar sign. This also prevents Compose from interpolating a value, so a `$$`
+allows you to refer to environment variables that you don't want processed by
+Compose.
+
+```yaml
+web:
+ build: .
+ command: "$$VAR_NOT_INTERPOLATED_BY_COMPOSE"
+```
+
+If you forget and use a single dollar sign (`$`), Compose interprets the value
+as an environment variable and warns you:
+
+```console
+The VAR_NOT_INTERPOLATED_BY_COMPOSE is not set. Substituting an empty string.
+```
+
+## Extension fields
+
+> Added in [version 2.1](compose-versioning.md#version-21) file format.
+
+It is possible to re-use configuration fragments using extension fields. Those
+special fields can be of any format as long as they are located at the root of
+your Compose file and their name start with the `x-` character sequence.
+
+> **Note**
+>
+> Starting with the 3.7 format (for the 3.x series) and 2.4 format
+> (for the 2.x series), extension fields are also allowed at the root
+> of service, volume, network, config and secret definitions.
+
+```yaml
+version: "{{% param "compose_file_v3" %}}"
+x-custom:
+ items:
+ - a
+ - b
+ options:
+ max-size: '12m'
+ name: "custom"
+```
+
+The contents of those fields are ignored by Compose, but they can be
+inserted in your resource definitions using [YAML anchors](https://yaml.org/spec/1.2/spec.html#id2765878).
+For example, if you want several of your services to use the same logging
+configuration:
+
+```yaml
+logging:
+ options:
+ max-size: '12m'
+ max-file: '5'
+ driver: json-file
+```
+
+You may write your Compose file as follows:
+
+```yaml
+version: "{{% param "compose_file_v3" %}}"
+x-logging:
+ &default-logging
+ options:
+ max-size: '12m'
+ max-file: '5'
+ driver: json-file
+
+services:
+ web:
+ image: myapp/web:latest
+ logging: *default-logging
+ db:
+ image: mysql:latest
+ logging: *default-logging
+```
+
+It is also possible to partially override values in extension fields using
+the [YAML merge type](https://yaml.org/type/merge.html). For example:
+
+```yaml
+version: "{{% param "compose_file_v3" %}}"
+x-volumes:
+ &default-volume
+ driver: foobar-storage
+
+services:
+ web:
+ image: myapp/web:latest
+ volumes: ["vol1", "vol2", "vol3"]
+volumes:
+ vol1: *default-volume
+ vol2:
+ << : *default-volume
+ name: volume02
+ vol3:
+ << : *default-volume
+ driver: default
+ name: volume-local
+```
diff --git a/docs/Compose file reference (legacy)/version-3.md b/docs/Compose file reference (legacy)/version-3.md
new file mode 100644
index 00000000000..d95d3c33edd
--- /dev/null
+++ b/docs/Compose file reference (legacy)/version-3.md
@@ -0,0 +1,2913 @@
+
+This page describes version 3 of the Compose file format.
+
+This is legacy content. The latest Compose file format is defined by the [Compose Specification](https://docs.docker.com/compose/compose-file/) and is implemented by Docker Compose **1.27.0+**.
+
+## Compose and Docker compatibility matrix
+
+There are several versions of the Compose file format – 1, 2, 2.x, and 3.x. The
+table below is a quick look. For full details on what each version includes and
+how to upgrade, see **[About versions and upgrading](compose-versioning.md)**.
+
+This table shows which Compose file versions support specific Docker releases.
+
+| **Compose file format** | **Docker Engine release** |
+| ------------------- | ------------------ |
+| Compose specification | 19.03.0+ |
+| 3.8 | 19.03.0+ |
+| 3.7 | 18.06.0+ |
+| 3.6 | 18.02.0+ |
+| 3.5 | 17.12.0+ |
+| 3.4 | 17.09.0+ |
+| 3.3 | 17.06.0+ |
+| 3.2 | 17.04.0+ |
+| 3.1 | 1.13.1+ |
+| 3.0 | 1.13.0+ |
+| 2.4 | 17.12.0+ |
+| 2.3 | 17.06.0+ |
+| 2.2 | 1.13.0+ |
+| 2.1 | 1.12.0+ |
+| 2.0 | 1.10.0+ |
+
+In addition to Compose file format versions shown in the table, the Compose
+itself is on a release schedule, as shown in [Compose
+releases](https://github.com/docker/compose/releases/), but file format versions
+do not necessarily increment with each release. For example, Compose file format
+3.0 was first introduced in [Compose release
+1.10.0](https://github.com/docker/compose/releases/tag/1.10.0), and versioned
+gradually in subsequent releases.
+
+The latest Compose file format is defined by the [Compose Specification](https://docs.docker.com/compose/compose-file/) and is implemented by Docker Compose **1.27.0+**.
+
+## Compose file structure and examples
+
+Here is a sample Compose file from the voting app sample used in the
+[Docker for Beginners lab](https://github.com/docker/labs/tree/master/beginner/)
+topic on [Deploying an app to a Swarm](https://github.com/docker/labs/blob/master/beginner/chapters/votingapp.md):
+
+
+{{< accordion title="Example Compose file version 3" >}}
+
+```yml
+version: "{{% param "compose_file_v3" %}}"
+services:
+
+ redis:
+ image: redis:alpine
+ ports:
+ - "6379"
+ networks:
+ - frontend
+ deploy:
+ replicas: 2
+ update_config:
+ parallelism: 2
+ delay: 10s
+ restart_policy:
+ condition: on-failure
+
+ db:
+ image: postgres:9.4
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ networks:
+ - backend
+ deploy:
+ placement:
+ max_replicas_per_node: 1
+ constraints:
+ - "node.role==manager"
+
+ vote:
+ image: dockersamples/examplevotingapp_vote:before
+ ports:
+ - "5000:80"
+ networks:
+ - frontend
+ depends_on:
+ - redis
+ deploy:
+ replicas: 2
+ update_config:
+ parallelism: 2
+ restart_policy:
+ condition: on-failure
+
+ result:
+ image: dockersamples/examplevotingapp_result:before
+ ports:
+ - "5001:80"
+ networks:
+ - backend
+ depends_on:
+ - db
+ deploy:
+ replicas: 1
+ update_config:
+ parallelism: 2
+ delay: 10s
+ restart_policy:
+ condition: on-failure
+
+ worker:
+ image: dockersamples/examplevotingapp_worker
+ networks:
+ - frontend
+ - backend
+ deploy:
+ mode: replicated
+ replicas: 1
+ labels: [APP=VOTING]
+ restart_policy:
+ condition: on-failure
+ delay: 10s
+ max_attempts: 3
+ window: 120s
+ placement:
+ constraints:
+ - "node.role==manager"
+
+ visualizer:
+ image: dockersamples/visualizer:stable
+ ports:
+ - "8080:8080"
+ stop_grace_period: 1m30s
+ volumes:
+ - "/var/run/docker.sock:/var/run/docker.sock"
+ deploy:
+ placement:
+ constraints:
+ - "node.role==manager"
+
+networks:
+ frontend:
+ backend:
+
+volumes:
+ db-data:
+```
+{{< /accordion >}}
+
+The topics on this reference page are organized alphabetically by top-level key
+to reflect the structure of the Compose file itself. Top-level keys that define
+a section in the configuration file such as `build`, `deploy`, `depends_on`,
+`networks`, and so on, are listed with the options that support them as
+sub-topics. This maps to the `: